File size: 11,348 Bytes
2e8a71f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
"""
THAMAN API β€” Pydantic request/response schemas
"""

from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List


# ── Borough mapping ───────────────────────────────────────────────────
# 1 = Manhattan, 2 = Bronx, 3 = Brooklyn, 4 = Queens, 5 = Staten Island

BOROUGH_NAMES = {1: "Manhattan", 2: "Bronx", 3: "Brooklyn", 4: "Queens", 5: "Staten Island"}

BLDGCLASS_DESCRIPTIONS = {
    "A0": "Cape Cod",
    "A1": "Two story detached - small or moderate",
    "A2": "One story - permanent living quarters",
    "A3": "Large suburban residence",
    "A4": "City residence - one family",
    "A5": "City residence - attached (rowhouse)",
    "A6": "Summer residence",
    "A7": "Mansion type or town house",
    "A8": "Bungalow colony",
    "A9": "Single family residence NEC",
    "B1": "Two family brick",
    "B2": "Two family frame",
    "B3": "Two family conversion",
    "B9": "Two family NEC",
    "C0": "Three families",
    "C1": "Over six families without stores",
    "C2": "Five to six families",
    "C3": "Four families",
    "C4": "Old law tenements",
    "C5": "Converted dwellings - rooming houses",
    "C6": "Cooperative",
    "C7": "Walk-up apartments with stores",
    "C8": "Walk-up apartment with offices",
    "C9": "Walk-up apartment NEC",
    "D0": "Elevator cooperative",
    "D1": "Elevator apartment (semi-fireproof)",
    "D2": "Elevator apartment (artists)",
    "D3": "Elevator apartment (fireproof - subsidized)",
    "D4": "Elevator apartment building (full building)",
    "D5": "Converted elevator building",
    "D6": "Elevator apartment with stores",
    "D7": "Elevator apartment with offices",
    "D8": "Elevator apartment NEC",
    "D9": "Elevator apartment NEC",
    "R1": "Condo residential unit in elevator building",
    "S0": "Primarily one-family with two stores or offices",
    "S1": "Primarily one-family with one store or office",
    "S2": "Primarily two-family with one store or office",
    "S4": "Primarily mixed use (3+ families + commercial)",
    "S5": "Converted from one/two family",
}


# ── Request model ─────────────────────────────────────────────────────

class PredictRequest(BaseModel):
    # Required: Location
    latitude: float = Field(..., ge=40.4, le=40.95, description="Property latitude (NYC: 40.4–40.95)")
    longitude: float = Field(..., ge=-74.3, le=-73.7, description="Property longitude (NYC: -74.3 – -73.7)")

    # Required: Property basics
    gross_square_feet: float = Field(..., gt=0, description="Total building gross square footage")
    building_age: int = Field(..., ge=0, le=200, description="Age of building in years")
    bldgclass: str = Field(..., description="NYC building class code (e.g. 'A1', 'D4', 'R1')")
    borough: int = Field(..., ge=1, le=5, description="Borough: 1=Manhattan, 2=Bronx, 3=Brooklyn, 4=Queens, 5=Staten Island")
    numfloors: float = Field(..., gt=0, description="Number of floors")
    residential_units: int = Field(..., ge=0, description="Number of residential units")

    # Optional property attributes
    land_square_feet: Optional[float] = Field(None, ge=0, description="Land area in sq ft")

    # Optional: building type flags (auto-inferred from bldgclass if not set)
    has_elevator: Optional[int] = Field(None, ge=0, le=1, description="1 if building has elevator")
    is_condo: Optional[int] = Field(None, ge=0, le=1, description="1 if condo (R-class)")
    is_multifamily: Optional[int] = Field(None, ge=0, le=1, description="1 if multi-family elevator (D-class)")
    is_single_fam: Optional[int] = Field(None, ge=0, le=1, description="1 if single family (A-class)")
    is_mixed_use: Optional[int] = Field(None, ge=0, le=1, description="1 if mixed-use (S-class)")

    # Optional: FAR/Zoning (from PLUTO; defaults to 0 if unknown)
    builtfar: Optional[float] = Field(None, ge=0, description="Built Floor Area Ratio")
    residfar: Optional[float] = Field(None, ge=0, description="Residential allowable FAR")
    commfar: Optional[float] = Field(None, ge=0, description="Commercial allowable FAR")
    facilfar: Optional[float] = Field(None, ge=0, description="Community facility allowable FAR")
    maxallwfar: Optional[float] = Field(None, ge=0, description="Maximum allowable FAR")
    far_utilization: Optional[float] = Field(None, ge=0, description="FAR utilization ratio (builtfar/maxallwfar)")

    # Optional: ACRIS prior sale data
    prior_sale_price: Optional[float] = Field(None, ge=0, description="Prior recorded sale price (from ACRIS)")
    price_appreciation: Optional[float] = Field(None, description="Price appreciation ratio since prior sale")
    years_since_prior_sale: Optional[float] = Field(None, ge=0, description="Years since prior sale")
    has_prior_sale: Optional[int] = Field(0, ge=0, le=1, description="1 if property has a prior sale record")
    is_flip: Optional[int] = Field(0, ge=0, le=1, description="1 if resold within 2 years")

    # Optional: Renovation / landmark status
    renovated_since_2018: Optional[int] = Field(0, ge=0, le=1, description="1 if major permit since 2018")
    years_since_renovation: Optional[float] = Field(0.0, ge=0, description="Years since last major renovation")

    # Optional: Time context (defaults to current year/month)
    sale_year: Optional[int] = Field(None, ge=2010, le=2030, description="Year of hypothetical sale")
    sale_month: Optional[int] = Field(None, ge=1, le=12, description="Month of hypothetical sale")

    model_config = ConfigDict(json_schema_extra={
        "example": {
            "latitude": 40.6892,
            "longitude": -73.9442,
            "gross_square_feet": 1800,
            "building_age": 55,
            "bldgclass": "A1",
            "borough": 3,
            "numfloors": 2,
            "residential_units": 1,
            "land_square_feet": 2000,
        }
    })


# ── Response models ───────────────────────────────────────────────────

class FeatureDriver(BaseModel):
    feature: str = Field(description="Feature name")
    value: float = Field(description="Feature value for this property")
    impact: float = Field(description="SHAP value (log-price units)")
    direction: str = Field(description="'positive' or 'negative'")
    description: str = Field(description="Human-readable feature description")


class AvmQc(BaseModel):
    """2026 AVM Quality Control block β€” confidence score, hit rate, and QC flags."""
    confidence_score:     int   = Field(description="0–100 AVM reliability score (100 – segment MedAPE)")
    confidence_grade:     str   = Field(description="Letter grade: A (β‰₯85) / B (β‰₯75) / C (β‰₯65) / D (<65)")
    segment_medape_pct:   float = Field(description="Segment-specific MedAPE used for this prediction")
    comparables_found:    int   = Field(description="Training-set sales within 800m (hit rate signal)")
    comparables_radius_m: int   = Field(default=800, description="Radius used for comparable count")
    sparse_market:        bool  = Field(description="True if fewer than 5 comparables found within 800m")
    qc_flags:             List[str] = Field(default_factory=list,
                              description="SPARSE_MARKET | LUXURY_SEGMENT | HIGH_UNCERTAINTY | METRO_CORE")


class SpatialFeatures(BaseModel):
    dist_subway_m: float
    dist_bus_m: float
    dist_express_subway_m: float
    nearest_station_is_express: int
    dist_school_m: float
    dist_elem_school_m: float
    dist_park_m: float
    dist_hospital_m: float
    dist_waterfront_m: float
    dist_bike_lane_m: float
    poi_count_500m: float
    airbnb_count_500m: float
    crime_rate_nta: float
    noise_density_nta: float
    livability_complaint_rate: float
    population_2020: float
    median_income_nta: float
    school_district: float
    district_avg_score: float
    mortgage_rate_30yr: float


class PredictResponse(BaseModel):
    # Core prediction
    predicted_price: int
    confidence_low: int
    confidence_high: int
    confidence_note: str = "Β±18.98% MedAPE confidence interval"

    # Model metadata
    model: str
    r2_test: float
    medape_pct: float

    # Context
    borough_name: str
    bldgclass_description: str

    # Auto-computed spatial features
    spatial_features: dict

    # SHAP explanations
    top_drivers: List[FeatureDriver]

    # AVM Quality Control (2026 standard) β€” always present
    avm_qc: Optional[AvmQc] = None

    # Resolved NTA code (for transparency / debugging)
    nta_code: Optional[str] = None

# ── Riyadh predict schemas ────────────────────────────────────────────

RIYADH_TYPE_LABELS = {
    "Ψ΄Ω‚Ψ©":           "Apartment / Ψ΄Ω‚Ψ©",
    "ΩΩŠΩ„Ψ§":          "Villa / ΩΩŠΩ„Ψ§",
    "Ω‚Ψ·ΨΉΨ© Ψ£Ψ±ΨΆ-Ψ³ΩƒΩ†Ω‰": "Residential Plot / Ω‚Ψ·ΨΉΨ© Ψ£Ψ±ΨΆ Ψ³ΩƒΩ†ΩŠΨ©",
    "ΨΉΩ…Ψ§Ψ±Ψ©":         "Building / ΨΉΩ…Ψ§Ψ±Ψ©",
}


class RiyadhPredictRequest(BaseModel):
    latitude:      float = Field(..., ge=23.5, le=26.0, description="Property latitude (Riyadh bbox)")
    longitude:     float = Field(..., ge=45.5, le=48.0, description="Property longitude (Riyadh bbox)")
    property_type: str   = Field(..., description="Property type: Ψ΄Ω‚Ψ© | ΩΩŠΩ„Ψ§ | Ω‚Ψ·ΨΉΨ© Ψ£Ψ±ΨΆ-Ψ³ΩƒΩ†Ω‰ | ΨΉΩ…Ψ§Ψ±Ψ©")
    area_sqm:      float = Field(..., gt=0, description="Property area in square meters")
    year:          Optional[int]   = Field(None, ge=2018, le=2030)
    quarter:       Optional[int]   = Field(None, ge=1, le=4)

    model_config = ConfigDict(json_schema_extra={
        "example": {
            "latitude": 24.7500,
            "longitude": 46.6800,
            "property_type": "Ψ΄Ω‚Ψ©",
            "area_sqm": 150.0,
            "year": 2025,
            "quarter": 2,
        }
    })


class RiyadhPredictResponse(BaseModel):
    predicted_price_sqm:  int   = Field(description="Predicted price per sqm (SAR/mΒ²)")
    predicted_total_sar:  int   = Field(description="Estimated total price (SAR) for the given area")
    confidence_low_sqm:   int
    confidence_high_sqm:  int
    confidence_low_sar:   int
    confidence_high_sar:  int
    area_sqm:             float
    property_type:        str
    district_ar:          Optional[str] = None
    model:                str
    r2_test:              float
    medape_pct:           float
    spatial_features:     dict
    top_drivers:          List[FeatureDriver] = Field(default_factory=list, description="Top SHAP feature drivers")
    # Asking-price overlay (Bayut listing median for matched district)
    asking_price_psqm:    Optional[int]   = Field(None, description="Bayut median asking price per sqm (SAR/mΒ²)")
    asking_price_total:   Optional[int]   = Field(None, description="Estimated Bayut asking total for given area (SAR)")
    asking_spread_pct:    Optional[float] = Field(None, description="Asking-price premium over THAMAN transaction estimate (%)")
    asking_price_source:  Optional[str]   = Field(None, description="Source platform for asking-price data")