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