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%
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