SPB Git

spb/vrai-prix Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

TypeScript 96.7% CSS 3.1%
5.5 KB · 175 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  estimate,8  haversineM,9  timeFactor,10  weightedMedian,11  type CompInput,12  type MarketIndexPoint,13  type Subject,14} from "./engine";1516const NOW = "2026-08-08";1718const INDEX: MarketIndexPoint[] = [19  { month: "2024-01", idx: 0.8 },20  { month: "2025-01", idx: 0.9 },21  { month: "2026-01", idx: 1.0 },22];2324function comp(over: Partial<CompInput>): CompInput {25  return {26    id: "c1",27    date: "2026-02-15",28    amount: 400000,29    lat: 46.8,30    lng: -71.2,31    propertyType: "unifamilial",32    yearBuilt: 1990,33    floorArea: 120,34    street: "1 rue Test",35    city: "Québec",36    ...over,37  };38}3940const SUBJECT: Subject = {41  lat: 46.8,42  lng: -71.2,43  typeProp: "unifamilial",44  floorArea: 120,45  yearBuilt: 1990,46  modelEstimate: 410000,47  modelP10: 340000,48  modelP90: 480000,49};5051describe("haversineM", () => {52  it("est nul à distance nulle et ~111 km par degré de latitude", () => {53    expect(haversineM(46.8, -71.2, 46.8, -71.2)).toBe(0);54    expect(haversineM(46.0, -71.2, 47.0, -71.2)).toBeGreaterThan(110000);55    expect(haversineM(46.0, -71.2, 47.0, -71.2)).toBeLessThan(112000);56  });57});5859describe("timeFactor", () => {60  it("majore une vente ancienne selon l'indice", () => {61    expect(timeFactor("2024-01", INDEX)).toBeCloseTo(1.0 / 0.8, 5);62    expect(timeFactor("2026-03", INDEX)).toBe(1);63  });64  it("retourne 1 sans indice", () => {65    expect(timeFactor("2024-01", [])).toBe(1);66  });67});6869describe("areaAdjustment", () => {70  it("ajuste à 50 % du $/m² du comparable", () => {71    // comp 100 m² à 400 000 $ => 4 000 $/m² ; sujet 110 m² => +10 × 2 000 = +20 00072    expect(areaAdjustment(110, { floorArea: 100, amount: 400000 })).toBe(20000);73    expect(areaAdjustment(90, { floorArea: 100, amount: 400000 })).toBe(-20000);74  });75  it("borne à ±25 % du prix", () => {76    expect(areaAdjustment(300, { floorArea: 100, amount: 400000 })).toBe(100000);77  });78  it("retourne 0 si superficie manquante", () => {79    expect(areaAdjustment(null, { floorArea: 100, amount: 400000 })).toBe(0);80    expect(areaAdjustment(110, { floorArea: null, amount: 400000 })).toBe(0);81  });82});8384describe("ageAdjustment", () => {85  it("0,5 % par année d'écart, borné à ±10 %", () => {86    expect(ageAdjustment(2000, { yearBuilt: 1990, amount: 400000 })).toBeCloseTo(20000);87    expect(ageAdjustment(1900, { yearBuilt: 2020, amount: 400000 })).toBeCloseTo(-40000);88  });89});9091describe("weightedMedian", () => {92  it("respecte les poids", () => {93    expect(weightedMedian([100, 200, 300], [1, 1, 10])).toBe(300);94    expect(weightedMedian([100, 200, 300], [1, 1, 1])).toBe(200);95  });96});9798describe("adjustComps", () => {99  it("filtre par type et superficie, trie par poids", () => {100    const candidates = [101      comp({ id: "proche", lat: 46.801, floorArea: 118 }),102      comp({ id: "loin", lat: 46.9, floorArea: 118 }),103      comp({ id: "condo", propertyType: "condo" }),104      comp({ id: "trop-grand", floorArea: 400 }),105      comp({ id: "a", floorArea: 125 }),106      comp({ id: "b", floorArea: 115 }),107      comp({ id: "c", floorArea: 130 }),108      comp({ id: "d", floorArea: 110 }),109    ];110    const res = adjustComps(SUBJECT, candidates, INDEX, NOW);111    const ids = res.map((c) => c.id);112    expect(ids).not.toContain("condo");113    expect(ids).not.toContain("trop-grand");114    expect(res[0].id).toBe("proche"); // le plus proche pèse le plus115  });116117  it("relâche le filtre de type quand le marché est mince", () => {118    const candidates = [119      comp({ id: "x", propertyType: "indéterminé" }),120      comp({ id: "y", propertyType: "indéterminé" }),121    ];122    const res = adjustComps(SUBJECT, candidates, INDEX, NOW);123    expect(res.length).toBe(2);124  });125});126127describe("estimate", () => {128  const candidates = Array.from({ length: 8 }, (_, i) =>129    comp({130      id: `c${i}`,131      lat: 46.8 + i * 0.001,132      amount: 380000 + i * 10000,133      date: "2026-01-10",134    })135  );136137  it("combine modèle (65 %) et comparables (35 %)", () => {138    const r = estimate(SUBJECT, candidates, INDEX, NOW);139    expect(r.modelWeight).toBe(0.65);140    expect(r.modelEstimate).toBe(410000);141    expect(r.compsEstimate).not.toBeNull();142    const expected = 0.65 * 410000 + 0.35 * r.compsEstimate!;143    expect(Math.abs(r.estimate - expected)).toBeLessThanOrEqual(100);144    expect(r.low).toBeLessThan(r.estimate);145    expect(r.high).toBeGreaterThan(r.estimate);146  });147148  it("retombe sur le modèle seul sans comparables", () => {149    const r = estimate(SUBJECT, [], INDEX, NOW);150    expect(r.modelWeight).toBe(1);151    expect(r.estimate).toBe(410000);152    expect(r.nCompsUsed).toBe(0);153  });154155  it("fonctionne aux comparables seuls (sans modèle)", () => {156    const r = estimate({ ...SUBJECT, modelEstimate: null, modelP10: null, modelP90: null },157      candidates, INDEX, NOW);158    expect(r.modelEstimate).toBeNull();159    expect(r.estimate).toBeGreaterThan(300000);160    expect(r.confidenceLevel).toMatch(/[A-D]/);161  });162163  it("la confiance augmente avec le nombre de comparables", () => {164    const few = estimate({ ...SUBJECT, modelEstimate: null }, candidates.slice(0, 3), INDEX, NOW);165    const many = estimate({ ...SUBJECT, modelEstimate: null }, candidates, INDEX, NOW);166    expect(many.confidencePct).toBeGreaterThanOrEqual(few.confidencePct);167  });168169  it("jamais d'estimation sans niveau de confiance", () => {170    const r = estimate(SUBJECT, candidates, INDEX, NOW);171    expect(r.confidencePct).toBeGreaterThan(0);172    expect(["A", "B", "C", "D"]).toContain(r.confidenceLevel);173  });174});175