SPB Git forge

spb/toveli

Public
14commits 1branches 0releases
1.1 MBsize
maindefault branch
2 h agolast push
TypeScript 50.6% JavaScript 46.1% SQL 2.1% Shell 0.6% CSS 0.5%
6.8 KB · 83 lines typescript
Raw Blame History
1// Glue Toveli → moteur Vrai-Prix (src/lib/engine.ts) sur la base canonique, en lecture seule.2import { getDb } from "./db";3import { estimate, type CompInput, type EstimateResult, type MarketIndexPoint, type Subject } from "./engine";4import { categoryToTypeProp } from "./normalize";56export interface RoleUnit { id: string; address: string | null; unit: string | null; city: string | null; lat: number | null; lng: number | null; type_prop: string | null; cubf_label: string | null; year_built: number | null; floor_area_m2: number | null; land_area_m2: number | null; n_units: number | null; role_land: number | null; role_building: number | null; role_value: number | null; role_market_date: string | null; est: number | null; p10: number | null; p90: number | null; }78export interface ToveliEstimate {9  subject: Subject & { label: string; city: string | null; roleUnitId: string | null; source: "role" | "manual" };10  result: EstimateResult;11  method: { name: string; version: string; date: string; inputs: string[]; limits: string[]; disclaimer: string };12  roleUnit: RoleUnit | null;13  indexType: string;14}1516const DISCLAIMER = "Estimation statistique indicative produite par un modèle (hédonique + comparables). Elle ne constitue pas une évaluation agréée (OEAQ) ni un conseil financier ; l'état intérieur, les rénovations et les particularités non observées ne sont pas pris en compte.";1718export function getRoleUnit(id: string): RoleUnit | null {19  return (getDb().prepare("SELECT * FROM role_unit WHERE id = ?").get(id) as RoleUnit | undefined) ?? null;20}2122export function indexTypeFor(typeProp: string): string {23  if (typeProp === "condo_ou_multi") return "condo";24  if (typeProp === "plex") return "plex";25  if (typeProp === "unifamilial" || typeProp === "chalet" || typeProp === "maison_mobile") return "unifamilial";26  return "indéterminé";27}2829function loadIndex(typeProp: string): MarketIndexPoint[] {30  return getDb().prepare("SELECT month, idx FROM market_index WHERE type_prop = ? ORDER BY month").all(indexTypeFor(typeProp)) as MarketIndexPoint[];31}3233/** Même stratégie que Vrai-Prix estimator.candidatesAround : bbox ±0,02° sur 30 mois, élargie si marché mince. */34export function candidatesAround(lat: number, lng: number): CompInput[] {35  const db = getDb();36  const q = db.prepare(`SELECT id, date, amount, lat, lng, property_type AS propertyType, year_built AS yearBuilt, floor_area AS floorArea, street, city FROM sale_transaction37    WHERE lat BETWEEN @lat-@h AND @lat+@h AND lng BETWEEN @lng-@h*1.4 AND @lng+@h*1.4 AND date >= @since AND amount >= 50000 ORDER BY date DESC LIMIT @limit`);38  const since = (months: number) => new Date(Date.now() - months * 30.44 * 86400000).toISOString().slice(0, 10);39  let rows = q.all({ lat, lng, h: 0.02, since: since(30), limit: 600 }) as CompInput[];40  if (rows.length < 30) rows = q.all({ lat, lng, h: 0.1, since: since(30), limit: 600 }) as CompInput[];41  if (rows.length < 30) rows = q.all({ lat, lng, h: 0.25, since: since(36), limit: 600 }) as CompInput[];42  return rows;43}4445function methodBlock(subject: Subject, hasModel: boolean, nCands: number) {46  const inputs = [`${nCands} ventes candidates (base de transactions Toveli, ≥ 50 000 $)`, "indice de marché mensuel par type (market_index)"];47  if (hasModel) inputs.unshift("estimation hédonique pré-calculée pour l'unité du rôle (LightGBM, ~690 k ventes 2021-2026) avec P10/P90");48  if (subject.floorArea) inputs.push(`superficie habitable ${Math.round(subject.floorArea)} m²`);49  if (subject.yearBuilt) inputs.push(`année de construction ${subject.yearBuilt}`);50  const limits = ["Rénovations, état intérieur, garage, piscine et vue ne sont pas observés.", "Marchés ruraux peu liquides : fourchette élargie, confiance plus faible.", "Les tours de condos partagent souvent une même géométrie au rôle."];51  if (!hasModel) limits.unshift("Aucune estimation hédonique pour ce sujet : résultat basé uniquement sur les comparables ajustés.");52  return { name: "Hybride Toveli : modèle hédonique (65 %) + comparables ajustés (35 %)", version: "toveli-estim-2026.10 (hédonique 2026-08)", date: new Date().toISOString().slice(0, 10), inputs, limits, disclaimer: DISCLAIMER };53}5455export function estimateRoleUnit(id: string): ToveliEstimate | null {56  const u = getRoleUnit(id);57  if (!u || u.lat == null || u.lng == null) return null;58  const typeProp = u.type_prop ?? "autre";59  const subject: Subject = { lat: u.lat, lng: u.lng, typeProp, floorArea: u.floor_area_m2, yearBuilt: u.year_built, landArea: u.land_area_m2, modelEstimate: u.est, modelP10: u.p10, modelP90: u.p90 };60  const cands = candidatesAround(u.lat, u.lng);61  const result = estimate(subject, cands, loadIndex(typeProp), new Date().toISOString());62  if (!Number.isFinite(result.estimate)) return null;63  return { subject: { ...subject, label: [u.address, u.unit ? `app. ${u.unit}` : null].filter(Boolean).join(", "), city: u.city, roleUnitId: u.id, source: "role" }, result, method: methodBlock(subject, u.est != null, cands.length), roleUnit: u, indexType: indexTypeFor(typeProp) };64}6566export interface ManualParams { lat: number; lng: number; category: string; floorAreaSqft?: number | null; yearBuilt?: number | null; landAreaSqft?: number | null; label?: string; city?: string | null; }6768export function estimateManual(p: ManualParams): ToveliEstimate | null {69  const typeProp = categoryToTypeProp(p.category);70  const subject: Subject = { lat: p.lat, lng: p.lng, typeProp, floorArea: p.floorAreaSqft ? p.floorAreaSqft * 0.09290304 : null, yearBuilt: p.yearBuilt ?? null, landArea: p.landAreaSqft ? p.landAreaSqft * 0.09290304 : null };71  const cands = candidatesAround(p.lat, p.lng);72  const result = estimate(subject, cands, loadIndex(typeProp), new Date().toISOString());73  if (!Number.isFinite(result.estimate) || result.nCompsUsed < 3) return null;74  return { subject: { ...subject, label: p.label ?? "Propriété", city: p.city ?? null, roleUnitId: null, source: "manual" }, result, method: methodBlock(subject, false, cands.length), roleUnit: null, indexType: indexTypeFor(typeProp) };75}7677/** Pour une annonce à vendre : estimation en direct si l'unité du rôle est connue, sinon manuelle à partir des attributs de l'annonce. */78export function estimateForListing(l: { lat: number | null; lng: number | null; category: string; area_sqft: number | null; year_built: number | null; lot_sqft: number | null; address: string | null; city: string | null }, roleUnitId: string | null): ToveliEstimate | null {79  if (roleUnitId) { const e = estimateRoleUnit(roleUnitId); if (e) return e; }80  if (l.lat == null || l.lng == null || l.category === "terrain" || l.category === "commercial") return null;81  return estimateManual({ lat: l.lat, lng: l.lng, category: l.category, floorAreaSqft: l.area_sqft, yearBuilt: l.year_built, landAreaSqft: l.lot_sqft, label: l.address ?? "Propriété", city: l.city });82}83