// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // VraiPrixService.swift — l'estimation VRAI-PRIX (www.vrai-prix.com, modèle // hédonique du Groupe Ka) dans le mode Découvrir. Volontairement frugal : // UNE seule requête légère (/api/nearby, boîte ~60 m, JSON ~2 Ko) par carte // affichée — jamais pour la file ni les marqueurs — avec cache mémoire par // annonce (résultats vides compris) et échec silencieux. Aucun overload. import Foundation import CoreLocation /// Estimation Vrai-Prix appariée à une annonce Lou-Ka / Immo-Ka. struct VraiPrixEstimate { let value: Int // est. 2026 ($) du modèle hédonique Vrai-Prix let sameAddress: Bool // le numéro civique correspond (même immeuble) let unitConfirmed: Bool // l'unité précise est confirmée (unité unique ou apt apparié) /// Montant à la québécoise : « 452 000 $ » ou « 1,17 M$ ». var label: String { if value >= 1_000_000 { return String(format: "%.2f", Double(value) / 1_000_000) .replacingOccurrences(of: ".", with: ",") + " M$" } let rounded = (value + 500) / 1000 * 1000 return rounded.formatted(.number.locale(Locale(identifier: "fr_CA"))) + " $" } /// Écart % du prix affiché vs l'estimation. Seulement quand l'unité est /// confirmée (jamais sur une médiane d'immeuble ni un voisinage), ventes /// seulement (un loyer mensuel passe sous le plancher), et borné — un /// écart énorme trahit un mauvais appariement (stationnement, etc.). func deltaPct(asking: Double?) -> Int? { guard unitConfirmed, let asking, asking >= 25_000 else { return nil } let pct = (asking - Double(value)) / Double(value) * 100 guard abs(pct) <= 60 else { return nil } return Int(pct.rounded()) } } actor VraiPrixService { static let shared = VraiPrixService() private var cache: [String: VraiPrixEstimate?] = [:] func estimate(for poi: POIItem) async -> VraiPrixEstimate? { if let hit = cache[poi.id] { return hit } let r = await fetch(poi) cache[poi.id] = r return r } private func fetch(_ poi: POIItem) async -> VraiPrixEstimate? { var comps = URLComponents(string: "https://www.vrai-prix.com/api/nearby")! comps.queryItems = [ .init(name: "lat", value: "\(poi.coordinate.latitude)"), .init(name: "lng", value: "\(poi.coordinate.longitude)"), .init(name: "halfLat", value: "0.0005"), // ≈ 55 m .init(name: "halfLng", value: "0.0007"), .init(name: "limit", value: "20"), ] var req = URLRequest(url: comps.url!) req.timeoutInterval = 6 req.setValue("KaTrajet/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent") guard let (data, _) = try? await URLSession.shared.data(for: req), let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let results = root["results"] as? [[String: Any]] else { return nil } struct Candidate { let value: Int, civic: Bool, apt: String, meters: Double, terrain: Bool } let civic = Self.civicNumber(in: poi.address) let streetTokens = Self.streetTokens(poi.address) let aptTokens = Self.aptTokens(in: poi.address, civic: civic) let units: [Candidate] = results.compactMap { u in guard let est = u["est2026"] as? Double, est > 0, let lat = u["lat"] as? Double, let lng = u["lng"] as? Double, let adresse = u["adresse"] as? String else { return nil } // une rue clairement différente disqualifie l'unité, même toute proche let uTokens = Self.streetTokens(adresse) if !streetTokens.isEmpty, !uTokens.isEmpty, uTokens.isDisjoint(with: streetTokens) { return nil } let range = Self.civicRange(in: adresse) return Candidate( value: Int(est), civic: civic.flatMap { c in range.map { $0.contains(c) } } ?? false, apt: Self.normalized(u["apt"] as? String ?? ""), meters: CLLocation(latitude: lat, longitude: lng) .distance(from: CLLocation(latitude: poi.coordinate.latitude, longitude: poi.coordinate.longitude)), terrain: (u["typeProp"] as? String) == "terrain") } let sameCivic = units.filter(\.civic) if let only = sameCivic.first, sameCivic.count == 1 { return VraiPrixEstimate(value: only.value, sameAddress: true, unitConfirmed: true) } if !sameCivic.isEmpty { // plusieurs unités au même civique (condos) : l'apt apparié confirme // l'unité ; sinon, médiane de l'immeuble sans écart de prix if !aptTokens.isEmpty, let am = sameCivic.first(where: { !$0.apt.isEmpty && aptTokens.contains($0.apt) }) { return VraiPrixEstimate(value: am.value, sameAddress: true, unitConfirmed: true) } let sorted = sameCivic.map(\.value).sorted() return VraiPrixEstimate(value: sorted[sorted.count / 2], sameAddress: true, unitConfirmed: false) } // repli : unité compatible la plus proche (≤ 30 m, pas un terrain) return units.filter { !$0.terrain && $0.meters <= 30 } .min { $0.meters < $1.meters } .map { VraiPrixEstimate(value: $0.value, sameAddress: false, unitConfirmed: false) } } // MARK: appariement d'adresses /// Numéro civique d'une annonce : dernier nombre du premier groupe /// (les annonces QC écrivent souvent « apt-civique Rue », ex. « 311-1225 /// Rue Notre-Dame » → 1225 ; « SS2-224-98 Rue Charlotte » → 98). private static func civicNumber(in address: String) -> Int? { guard let m = address.firstMatch(of: #/(?:\w{1,4}\s*-\s*)*(\d{1,6})/#) else { return nil } return Int(m.output.1) } /// Candidats « appartement » d'une annonce : courts jetons avec chiffre /// (« #14 », « App S3 », « 311- ») autres que le civique lui-même. private static func aptTokens(in address: String, civic: Int?) -> Set { let civicStr = civic.map(String.init) return Set(address.uppercased() .components(separatedBy: CharacterSet.alphanumerics.inverted) .filter { t in t.count <= 5 && !t.isEmpty && t.contains(where: \.isNumber) && t != civicStr }) } /// Apt d'une unité d'évaluation, réduit à l'alphanumérique majuscule. private static func normalized(_ apt: String) -> String { String(apt.uppercased().unicodeScalars.filter(CharacterSet.alphanumerics.contains)) } /// « 1152-1154 Boulevard X » → 1152...1154 ; « 1163 Rue CLARK » → 1163...1163 private static func civicRange(in address: String) -> ClosedRange? { guard let m = address.firstMatch(of: #/(\d{1,6})(?:\s*-\s*(\d{1,6}))?/#), let lo = Int(m.output.1) else { return nil } let hi = m.output.2.flatMap { Int($0) } ?? lo return min(lo, hi)...max(lo, hi) } /// Jetons de nom de rue (≥ 4 lettres, sans accents, génériques exclus) /// pour un rapprochement souple « boul. Saint-Laurent » ↔ « Boulevard SAINT-LAURENT ». private static let genericTokens: Set = [ "RUE", "AVENUE", "BOULEVARD", "BOUL", "CHEMIN", "PLACE", "ROUTE", "MONTREAL", "QUEBEC", "LAVAL", "LONGUEUIL", "GATINEAU", "APPARTEMENT", "SAINT", "SAINTE", "OUEST", "NORD", ] private static func streetTokens(_ s: String) -> Set { Set(s.folding(options: .diacriticInsensitive, locale: Locale(identifier: "fr_CA")) .uppercased() .components(separatedBy: CharacterSet.letters.inverted) .filter { $0.count >= 4 && !genericTokens.contains($0) }) } }