TypeScript 90.1%
JavaScript 3.2%
Python 3.2%
CSS 1.8%
HTML 1.8%
1// UQO Éval — modélisation hédonique : constitution de l'échantillon (ventes réelles ou annonces).2import { getDb, getMarketIndex, municipalityCenter, type UnitRow } from "../db";3import { timeFactor } from "../engine";4import { indexType } from "../estimator";5import { getImmoDb } from "../immoka";6import { typeGroup } from "../listing-types";7import { LIMITS, type HedonicSpec } from "./spec";89export interface Obs {10 id: string;11 price: number;12 date: string; // YYYY-MM-DD13 year: number;14 month: string; // YYYY-MM15 quarter: string; // YYYY-Qn16 lat: number;17 lng: number;18 street: string | null;19 city: string | null;20 aire: number | null;21 terrain: number | null;22 annee: number | null;23 nb_logements: number | null;24 nb_etages: number | null;25 type: string; // unifamilial | condo | plex | indéterminé26 genre: string | null;27 lien: string | null;28 muni: string | null;29 arrond: string | null;30 voisinage: string | null;31 valeur_role: number | null;32 bedrooms: number | null;33 bathrooms: number | null;34 lab: number | null; // estimation du laboratoire comparable au prix (même date)35}3637export interface Sample {38 obs: Obs[];39 available: number; // observations répondant aux filtres avant échantillonnage40 trimmed: number;41 center: { lat: number; lng: number } | null;42}4344const SQFT = 0.092903;4546function timeKeys(date: string) {47 const year = Number(date.slice(0, 4));48 const m = Number(date.slice(5, 7));49 return { year, month: date.slice(0, 7), quarter: `${year}-T${Math.floor((m - 1) / 3) + 1}` };50}5152function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {53 const R = 6371;54 const toRad = (x: number) => (x * Math.PI) / 180;55 const dLat = toRad(lat2 - lat1);56 const dLng = toRad(lng2 - lng1);57 const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;58 return 2 * R * Math.asin(Math.sqrt(a));59}6061function areaClause(spec: HedonicSpec, latCol: string, lngCol: string, muniCol: string, cityCol?: string) {62 const where: string[] = [];63 const args: unknown[] = [];64 let center: { lat: number; lng: number } | null = null;65 if (spec.area.mode === "muni" && spec.area.muni) {66 if (cityCol) {67 where.push(`(${muniCol} = ? COLLATE NOCASE OR ${cityCol} = ? COLLATE NOCASE)`);68 args.push(spec.area.muni, spec.area.muni);69 } else {70 where.push(`${muniCol} = ? COLLATE NOCASE`);71 args.push(spec.area.muni);72 }73 const c = municipalityCenter(spec.area.muni);74 if (c && c.n) center = { lat: c.lat, lng: c.lng };75 } else if (spec.area.mode === "radius" && spec.area.muni) {76 const c = municipalityCenter(spec.area.muni);77 if (!c || !c.n) throw new Error(`municipalité inconnue : ${spec.area.muni}`);78 center = { lat: c.lat, lng: c.lng };79 const r = Math.min(Math.max(spec.area.radiusKm ?? 10, 1), 150);80 const dLat = r / 111;81 const dLng = r / (111 * Math.cos((c.lat * Math.PI) / 180));82 where.push(`${latCol} BETWEEN ? AND ? AND ${lngCol} BETWEEN ? AND ?`);83 args.push(c.lat - dLat, c.lat + dLat, c.lng - dLng, c.lng + dLng);84 }85 return { where, args, center };86}8788const clampN = (n: number) => Math.min(Math.max(Math.round(n) || 1000, LIMITS.minN), LIMITS.maxN);8990/* --------------------------------- ventes --------------------------------- */91interface SaleRow {92 id: string;93 date: string;94 price: number;95 lat: number;96 lng: number;97 ttype: string | null;98 street: string | null;99 city: string | null;100 aire: number | null;101 terrain: number | null;102 annee: number | null;103 nb_logements: number | null;104 nb_etages: number | null;105 genre: string | null;106 lien: string | null;107 muni: string | null;108 arrond: string | null;109 voisinage: string | null;110 valeur_role: number | null;111 est_2026: number | null;112}113114function sampleSales(spec: HedonicSpec): Sample {115 const d = getDb();116 const n = clampN(spec.maxN);117 const where = ["t.amount BETWEEN ? AND ?", "t.lat IS NOT NULL", "t.lng IS NOT NULL"];118 const args: unknown[] = [spec.price.min, spec.price.max];119 const from = /^\d{4}-\d{2}$/.test(spec.period.from) ? `${spec.period.from}-01` : "2021-01-01";120 const to = /^\d{4}-\d{2}$/.test(spec.period.to) ? `${spec.period.to}-31` : "2099-12-31";121 where.push("t.date BETWEEN ? AND ?");122 args.push(from, to);123 if (spec.types.length) {124 where.push(`t.property_type IN (${spec.types.map(() => "?").join(",")})`);125 args.push(...spec.types);126 }127 const ac = areaClause(spec, "t.lat", "t.lng", "u.municipalite");128 where.push(...ac.where);129 args.push(...ac.args);130 const base = `FROM transactions t JOIN units u ON u.id_provinc = t.id_provinc WHERE ${where.join(" AND ")}`;131 const available = (d.prepare(`SELECT COUNT(*) AS n ${base}`).get(...args) as { n: number }).n;132 // ordre pseudo-aléatoire déterministe (graine) → échantillon reproductible133 const seed = Math.abs(Math.round(spec.seed)) % 1000003;134 const over = spec.area.mode === "radius" ? 3 : 1.2; // la bbox du rayon est filtrée ensuite135 const rows = d136 .prepare(137 `SELECT t.id, t.date, t.amount AS price, t.lat, t.lng, t.property_type AS ttype, t.street, t.city,138 u.aire_etages_m2 AS aire, u.superficie_terrain_m2 AS terrain, u.annee_construction AS annee,139 u.nb_logements, u.nb_etages, u.genre_construction AS genre, u.lien_physique AS lien,140 u.municipalite AS muni, u.arrond, u.unite_voisinage AS voisinage, u.valeur_role, u.est_2026141 ${base}142 ORDER BY ((t.rowid * 2654435761 + ${seed}) % 4294967296) LIMIT ?`143 )144 .all(...args, Math.round(n * over * (1 + spec.price.trimPct / 50))) as SaleRow[];145146 const idx = new Map<string, { month: string; idx: number }[]>();147 const idxFor = (t: string) => {148 const k = indexType(t === "condo" ? "condo_ou_multi" : t);149 if (!idx.has(k)) idx.set(k, getMarketIndex(k));150 return idx.get(k)!;151 };152153 let obs: Obs[] = rows.map((r) => {154 const tk = timeKeys(r.date);155 const type = r.ttype && r.ttype !== "" ? r.ttype : "indéterminé";156 const tf = r.est_2026 ? timeFactor(tk.month, idxFor(type)) : 1;157 return {158 id: r.id,159 price: r.price,160 date: r.date,161 ...tk,162 lat: r.lat,163 lng: r.lng,164 street: r.street,165 city: r.city,166 aire: r.aire && r.aire > 10 ? r.aire : null,167 terrain: r.terrain && r.terrain > 10 ? r.terrain : null,168 annee: r.annee && r.annee > 1600 && r.annee <= tk.year ? r.annee : null,169 nb_logements: r.nb_logements,170 nb_etages: r.nb_etages,171 type,172 genre: r.genre || null,173 lien: r.lien || null,174 muni: r.muni,175 arrond: r.arrond || null,176 voisinage: r.voisinage || null,177 valeur_role: r.valeur_role && r.valeur_role > 1000 ? r.valeur_role : null,178 bedrooms: null,179 bathrooms: null,180 lab: r.est_2026 && tf > 0 ? r.est_2026 / tf : null,181 };182 });183 if (spec.area.mode === "radius" && ac.center) {184 const r = Math.min(Math.max(spec.area.radiusKm ?? 10, 1), 150);185 obs = obs.filter((o) => haversineKm(o.lat, o.lng, ac.center!.lat, ac.center!.lng) <= r);186 }187 return finalize(obs, n, spec, available, ac.center);188}189190/* -------------------------------- annonces -------------------------------- */191interface ListingRowLite {192 id: string;193 price: number;194 first_seen: number | null;195 lat: number;196 lng: number;197 ptype: string | null;198 bedrooms: number | null;199 bathrooms: number | null;200 area_sqft: number | null;201 lot_sqft: number | null;202 year_built: number | null;203 street: string | null;204 city: string | null;205 lab: number | null;206 unit_id: string | null;207 muni_e: string | null;208}209210const LIVE = "l.status = 'a-vendre' AND l.published = 1 AND l.active = 1 AND l.dup_hidden = 0 AND l.price > 0";211212function sampleListings(spec: HedonicSpec): Sample {213 const im = getImmoDb();214 const vp = getDb();215 const n = clampN(spec.maxN);216 const where = [LIVE, "l.price BETWEEN ? AND ?", "l.lat IS NOT NULL", "l.lng IS NOT NULL"];217 const args: unknown[] = [spec.price.min, spec.price.max];218 const ac = areaClause(spec, "l.lat", "l.lng", "e.municipalite", "l.city");219 where.push(...ac.where);220 args.push(...ac.args);221 const base = `FROM listings l LEFT JOIN uqo_eval e ON e.uid = l.uid WHERE ${where.join(" AND ")}`;222 const seed = Math.abs(Math.round(spec.seed)) % 1000003;223 const rows = im224 .prepare(225 `SELECT l.uid AS id, l.price, l.first_seen, l.lat, l.lng, l.property_type AS ptype, l.bedrooms, l.bathrooms,226 l.area_sqft, l.lot_sqft, l.year_built, l.address AS street, l.city, e.est AS lab, e.unit_id, e.municipalite AS muni_e227 ${base} ORDER BY ((l.rowid * 2654435761 + ${seed}) % 4294967296)`228 )229 .all(...args) as ListingRowLite[];230231 // types : groupe Immo-Ka → clé du moteur232 const toType = (pt: string | null): string => {233 const g = typeGroup(pt);234 if (g === "maison" || g === "chalet" || g === "mobile" || g === "fermette") return "unifamilial";235 if (g === "condo") return "condo";236 if (g === "plex") return "plex";237 return "indéterminé";238 };239 const wanted = new Set(spec.types.length ? spec.types : ["unifamilial", "condo", "plex"]);240 let pool = rows.filter((r) => wanted.has(toType(r.ptype)));241 if (spec.area.mode === "radius" && ac.center) {242 const r = Math.min(Math.max(spec.area.radiusKm ?? 10, 1), 150);243 pool = pool.filter((o) => haversineKm(o.lat, o.lng, ac.center!.lat, ac.center!.lng) <= r);244 }245 const available = pool.length;246 const take = pool.slice(0, Math.round(n * 1.2 * (1 + spec.price.trimPct / 50)));247248 const getUnit = vp.prepare("SELECT * FROM units WHERE id_provinc = ?");249 const nowISO = new Date().toISOString().slice(0, 10);250 const obs: Obs[] = take.map((r) => {251 const u = r.unit_id ? (getUnit.get(r.unit_id) as UnitRow | undefined) : undefined;252 const date = r.first_seen ? new Date(r.first_seen * 1000).toISOString().slice(0, 10) : nowISO;253 const tk = timeKeys(date);254 const aireL = r.area_sqft && r.area_sqft > 200 && r.area_sqft < 20000 ? r.area_sqft * SQFT : null;255 const terrL = r.lot_sqft && r.lot_sqft > 100 ? r.lot_sqft * SQFT : null;256 const annee = u?.annee_construction && u.annee_construction > 1600 ? u.annee_construction : r.year_built && r.year_built > 1600 && r.year_built <= tk.year ? r.year_built : null;257 return {258 id: r.id,259 price: r.price,260 date,261 ...tk,262 lat: r.lat,263 lng: r.lng,264 street: r.street,265 city: r.city,266 aire: u?.aire_etages_m2 && u.aire_etages_m2 > 10 ? u.aire_etages_m2 : aireL,267 terrain: u?.superficie_terrain_m2 && u.superficie_terrain_m2 > 10 ? u.superficie_terrain_m2 : terrL,268 annee,269 nb_logements: u?.nb_logements ?? null,270 nb_etages: u?.nb_etages ?? null,271 type: toType(r.ptype),272 genre: u?.genre_construction || null,273 lien: u?.lien_physique || null,274 muni: r.muni_e ?? u?.municipalite ?? r.city,275 arrond: u?.arrond || null,276 voisinage: u?.unite_voisinage || null,277 valeur_role: u?.valeur_role && u.valeur_role > 1000 ? u.valeur_role : null,278 bedrooms: r.bedrooms,279 bathrooms: r.bathrooms,280 lab: r.lab,281 };282 });283 return finalize(obs, n, spec, available, ac.center);284}285286/** Élagage des queues de prix puis plafond n. */287function finalize(obs: Obs[], n: number, spec: HedonicSpec, available: number, center: Sample["center"]): Sample {288 let trimmed = 0;289 const p = Math.min(Math.max(spec.price.trimPct, 0), 10) / 100;290 if (p > 0 && obs.length > 50) {291 const prices = obs.map((o) => o.price).sort((a, b) => a - b);292 const lo = prices[Math.floor(prices.length * p)];293 const hi = prices[Math.min(prices.length - 1, Math.ceil(prices.length * (1 - p)))];294 const before = obs.length;295 obs = obs.filter((o) => o.price >= lo && o.price <= hi);296 trimmed = before - obs.length;297 }298 return { obs: obs.slice(0, n), available, trimmed, center };299}300301export function buildSample(spec: HedonicSpec): Sample {302 return spec.source === "listings" ? sampleListings(spec) : sampleSales(spec);303}304