SPB Git

spb/valoplex Public

ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.

TypeScript 90.3% Python 7.1% CSS 2.5%
4.7 KB · 156 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2import { describe, expect, it } from "vitest";3import {4  adjustComps,5  ageAdjustment,6  areaAdjustment,7  doorsAdjustment,8  estimate,9  haversineM,10  timeFactor,11  weightedMedian,12  type CompInput,13  type MarketIndexPoint,14  type Subject,15} from "./engine";1617const NOW = "2026-08-09";1819const INDEX: MarketIndexPoint[] = [20  { month: "2024-01", idx: 0.8 },21  { month: "2025-01", idx: 0.9 },22  { month: "2026-01", idx: 1.0 },23];2425function comp(over: Partial<CompInput>): CompInput {26  return {27    id: "c1",28    date: "2026-02-15",29    amount: 900000,30    lat: 45.56,31    lng: -73.58,32    propertyType: "plex",33    doors: 3,34    yearBuilt: 1955,35    floorArea: 240,36    street: "4562 rue Chabot",37    city: "Montréal",38    ...over,39  };40}4142const SUBJECT: Subject = {43  lat: 45.55,44  lng: -73.58,45  typeProp: "plex",46  doors: 3,47  floorArea: 240,48  yearBuilt: 1955,49  modelEstimate: 920000,50  modelP10: 760000,51  modelP90: 1100000,52};5354describe("haversineM", () => {55  it("est nul à distance nulle et ~111 km par degré de latitude", () => {56    expect(haversineM(45.55, -73.58, 45.55, -73.58)).toBe(0);57    expect(haversineM(45.0, -73.58, 46.0, -73.58)).toBeGreaterThan(110000);58    expect(haversineM(45.0, -73.58, 46.0, -73.58)).toBeLessThan(112000);59  });60});6162describe("timeFactor", () => {63  it("majore une vente ancienne selon l'indice", () => {64    expect(timeFactor("2024-01", INDEX)).toBeCloseTo(1.0 / 0.8, 5);65    expect(timeFactor("2026-03", INDEX)).toBe(1);66  });67});6869describe("areaAdjustment", () => {70  it("ajuste à 50 % du $/m² du comparable", () => {71    expect(areaAdjustment(260, { floorArea: 240, amount: 900000 })).toBe(72      20 * 0.5 * (900000 / 240)73    );74  });75});7677describe("ageAdjustment", () => {78  it("0,5 % par année d'écart, borné à ±10 %", () => {79    expect(ageAdjustment(1965, { yearBuilt: 1955, amount: 900000 })).toBeCloseTo(45000);80    expect(ageAdjustment(1900, { yearBuilt: 1990, amount: 900000 })).toBeCloseTo(-90000);81  });82});8384describe("doorsAdjustment (plex)", () => {85  it("ajuste à 50 % du prix par porte du comparable", () => {86    // comp triplex 900 000 $ => 300 000 $/porte ; sujet quadruplex => +150 000 $87    expect(doorsAdjustment(4, { doors: 3, amount: 900000 })).toBe(150000);88    expect(doorsAdjustment(2, { doors: 3, amount: 900000 })).toBe(-150000);89  });90  it("borne à ±30 % du prix du comparable", () => {91    expect(doorsAdjustment(12, { doors: 2, amount: 600000 })).toBe(180000);92  });93  it("retourne 0 si portes manquantes", () => {94    expect(doorsAdjustment(null, { doors: 3, amount: 900000 })).toBe(0);95    expect(doorsAdjustment(3, { doors: null, amount: 900000 })).toBe(0);96  });97});9899describe("weightedMedian", () => {100  it("respecte les poids", () => {101    expect(weightedMedian([100, 200, 300], [1, 1, 10])).toBe(300);102  });103});104105describe("adjustComps", () => {106  it("filtre par similarité de portes et pondère la proximité", () => {107    const candidates = [108      comp({ id: "proche", lat: 45.551 }),109      comp({ id: "loin", lat: 45.65 }),110      comp({ id: "tour-12portes", doors: 12, amount: 2500000 }),111      comp({ id: "a", doors: 2 }),112      comp({ id: "b", doors: 4 }),113      comp({ id: "c" }),114      comp({ id: "d", doors: 3 }),115    ];116    const res = adjustComps(SUBJECT, candidates, INDEX, NOW);117    const ids = res.map((c) => c.id);118    expect(ids).not.toContain("tour-12portes"); // ±1 porte suffit ici119    expect(res[0].id).toBe("proche");120  });121122  it("intègre l'ajustement portes dans le prix ajusté", () => {123    const candidates = Array.from({ length: 6 }, (_, i) =>124      comp({ id: `c${i}`, doors: 2, amount: 600000 })125    );126    const res = adjustComps({ ...SUBJECT, doors: 3 }, candidates, INDEX, NOW);127    expect(res[0].adjDoors).toBeGreaterThan(0); // sujet a plus de portes128  });129});130131describe("estimate", () => {132  const candidates = Array.from({ length: 8 }, (_, i) =>133    comp({ id: `c${i}`, lat: 45.55 + i * 0.001, amount: 850000 + i * 20000, date: "2026-01-10" })134  );135136  it("combine modèle (65 %) et comparables (35 %)", () => {137    const r = estimate(SUBJECT, candidates, INDEX, NOW);138    expect(r.modelWeight).toBe(0.65);139    expect(r.compsEstimate).not.toBeNull();140    const expected = 0.65 * 920000 + 0.35 * r.compsEstimate!;141    expect(Math.abs(r.estimate - expected)).toBeLessThanOrEqual(100);142  });143144  it("retombe sur le modèle seul sans comparables", () => {145    const r = estimate(SUBJECT, [], INDEX, NOW);146    expect(r.modelWeight).toBe(1);147    expect(r.estimate).toBe(920000);148  });149150  it("jamais d'estimation sans niveau de confiance", () => {151    const r = estimate(SUBJECT, candidates, INDEX, NOW);152    expect(r.confidencePct).toBeGreaterThan(0);153    expect(["A", "B", "C", "D"]).toContain(r.confidenceLevel);154  });155});156