Skip to content

Commit 094c098

Browse files
committed
fix(structural): statutory legal floor + TCMB double-count audit; dataset expansion + identification diagnostics
Freeze the structural architecture (no new model/source/quality abstraction/SaleProbability/ML). Two corrections + a dataset-expansion & identification sprint over the already-validated UYAP/KAP/TOKI families. 1) Statutory floor: legal_floor = max(0.5*Q, priority_claims) + realization_costs (was max(0.5Q, pc+rc)). muhammen_bedel preserved as appraised value Q, never the reserve/floor. Missing components -> legal_floor_exact=false with observed components preserved (lower bound, not fabricated). 2) TCMB anchor audit: a contemporaneous provincial TL/m2 level is used directly (V=U_gt*m2*exp(b'dZ)); the full KFE level is NOT multiplied again. roll_unit_price(U_t0,kfe_t,kfe_t0)=U_t0*(kfe_t/kfe_t0) applies the KFE RATIO only when rolling an older anchor. CLI --kfe-factor removed. - UYAP schema expanded (public_record_id, auction_date, offer_count, source_audited, parcel/unit-net/unit-gross areas kept DISTINCT, never substituted); sold+unsold; uyap_observed_moments adds variance/quantiles/mass-near-floor/offer-bidder distribution. - KAP structural dataset (real-estate disposal, related_party=false, realized price + appraisal/prior reference); moments over log(sale/appraisal). Not documented as 'KAP gives eta' -- eta jointly estimated with the mechanism shift. - TOKI guards: same project + same table semantics + non-decreasing count&total; revision_detected on decrease/semantics-change/stratum-disappearance; no property-level pairs. - Identification: sold structural identify -> dataset counts + numerical moment Jacobian (central differences, common random numbers), rank/singular values/condition number/weak directions, eta+shift profile diagnostics. rank(J)<dim(theta) -> NOT_IDENTIFIED -> prediction switches to sensitivity mode. Optimizer convergence is not identification. - Terminology: structural value is a 'structural-method prototype using provisional parameters' (sensitivity mode) until identification supports the vector; still 'inferred structural distribution', never observed/measured. - Validated Level-2 (KAP 963554, TOKI PMVR3, UYAP 16766356960) unchanged; consumer path frozen; no fabricated fields/floors/counts/cohorts/identification. - 161 tests passing
1 parent a906a34 commit 094c098

11 files changed

Lines changed: 1002 additions & 130 deletions

File tree

README.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[![Data refresh](https://github.com/onatozmenn/sold/actions/workflows/kfe-refresh.yml/badge.svg)](https://github.com/onatozmenn/sold/actions/workflows/kfe-refresh.yml)
55
[![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/)
66
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7-
[![Tests](https://img.shields.io/badge/tests-145%20passing-brightgreen.svg)](tests/)
7+
[![Tests](https://img.shields.io/badge/tests-161%20passing-brightgreen.svg)](tests/)
88
[![Data](https://img.shields.io/badge/data-TCMB%20%C2%B7%20T%C3%9C%C4%B0K-informational.svg)](#data-sources)
99

1010
> Infer the **realized transaction price** of a Turkish home from its **asking** price — a provenance-aware valuation engine.
@@ -124,12 +124,14 @@ Each public source enters as **structural moments under its own mechanism**, nev
124124

125125
| Source | Structural role |
126126
|---|---|
127-
| **TCMB** TL/m² + KFE/YÖKFE | Hedonic **fair-value level anchor** (appraisal, *not* transactions). The log-linear hedonic gives **relative** characteristic premiums only — the listing-price **intercept is never used as the price level** |
128-
| **UYAP** e-Satış | **Structural auctions** (sold *and* unsold): bidder valuations from the buyer-value distribution vs the **statutory legal floor** `max(0.5·Q, priority_claims + realization_costs)`. `muhammen_bedel = appraised value Q`, **not** the reserve; unobserved floor components stay *partially observed*, never fabricated |
129-
| **KAP** | Non-related negotiated disposals → **`η` calibration moments** with an explicit **corporate mechanism/domain shift** (never treated as ordinary-resale truth) |
130-
| **TOKİ** | Repeated cumulative disclosures **differenced within room-type strata** → period realized-sale cohort moments, with reconciliation/revision guards (no property-level pairs, no asking→closing discount) |
127+
| **TCMB** TL/m² + KFE/YÖKFE | Hedonic **fair-value level anchor** (appraisal, *not* transactions). The log-linear hedonic gives **relative** characteristic premiums only — the listing-price **intercept is never used as the price level**. A **contemporaneous** provincial TL/m² is the level anchor **used directly** (`V = U_gt · m² · e^{β'ΔZ}`); the **KFE ratio** is applied **only** to roll an *older* anchor `U_{t0} · (KFE_t/KFE_{t0})` — a level is never multiplied by the full index again (no temporal double count) |
128+
| **UYAP** e-Satış | **Structural auctions** (sold *and* unsold): bidder valuations from the buyer-value distribution vs the **statutory legal floor** `max(0.5·Q, priority_claims) + realization_costs`. `muhammen_bedel = appraised value Q`, **not** the reserve; unobserved floor components stay *partially observed* (`legal_floor_exact=false`), never fabricated. Structural observation preserves offer/bidder counts and **distinct** parcel / unit-net / unit-gross areas (never substituted) |
129+
| **KAP** | Non-related negotiated real-estate disposals → moments over **`log(sale/appraisal)`** that help **jointly** calibrate bargaining power **and** an explicit **corporate mechanism/domain shift** (this is *not* "KAP gives η"; η is jointly estimated, never ordinary-resale truth) |
130+
| **TOKİ** | Repeated cumulative disclosures **differenced within room-type strata** (same project, same table semantics, non-decreasing count *and* total) → period realized-sale cohort moments; a `revision_detected` guard blocks differencing on decreases, semantics changes or vanished strata (no property-level pairs, no asking→closing discount) |
131131

132-
For an ordinary listing, **asking price is a noisy strategic signal of the seller reservation** (not ground truth, not a ceiling): `S` is conditioned on asking, fair value and tightness, `B`/`S` are drawn, trades (`B ≥ S`) retained, and the **conditional-on-trade** closing distribution returned — median, mean, an **80% structural interval**, trade probability, and **mechanism-transfer sensitivity**. This is a **structural inference, never an observed closing price or a measured ordinary-resale accuracy**. See [`src/sold/structural/`](src/sold/structural/) and `sold structural value` / `sold structural estimate`.
132+
For an ordinary listing, **asking price is a noisy strategic signal of the seller reservation** (not ground truth, not a ceiling): `S` is conditioned on asking, fair value and tightness, `B`/`S` are drawn, trades (`B ≥ S`) retained, and the **conditional-on-trade** closing distribution returned — median, mean, an **80% structural interval**, trade probability, and **mechanism-transfer sensitivity**. This is a **structural inference, never an observed closing price or a measured ordinary-resale accuracy**.
133+
134+
**Identification before estimation.** Optimizer convergence is *not* identification. `sold structural identify` inspects the actual dataset and computes a numerical moment Jacobian `J(θ)=∂m_sim/∂θ'` (central differences, common random numbers) reporting rank, singular values, condition number and weakly-identified directions, plus per-parameter profile diagnostics for `θ` and the mechanism shifts. If `rank(J) < dim(θ)` the status is **`NOT_IDENTIFIED`** and prediction runs in **sensitivity mode**. Until an identified fit exists, `sold structural value` is labelled a **structural-method prototype using provisional parameters** — not a measured ordinary-resale model. See [`src/sold/structural/`](src/sold/structural/) and `sold structural value` / `identify` / `estimate`.
133135

134136
## Broker Data Flywheel
135137

@@ -299,7 +301,7 @@ tests/ # offline unit / end-to-end tests
299301
## Testing
300302

301303
```bash
302-
pytest -q # 145 tests, fully offline (no network or API key required)
304+
pytest -q # 161 tests, fully offline (no network or API key required)
303305
```
304306

305307
## Methodology & References
@@ -322,7 +324,8 @@ Negotiation-margin figures from Turkish market reporting: İstanbul ≈ 10%, Ank
322324
- [x] **Consumer direct-label acquisition path** — self-serve seller collector that turns a completed ordinary home sale into a provenance-aware **direct** label (`domain=consumer` · `seller_self_reported` · `ordinary_resale` · `reference=asking` · confidence `B`) eligible for `asking_to_closing_labels()` while public UYAP/KAP/TOKİ stay excluded; returns immediate non-ML seller analytics (initial/final ask-to-close gap, days to close, price cuts) and an **honest** segment benchmark (no fabricated benchmark when observations are insufficient)
323325
- [x] **Direct-label quality gate (pre-ML)** — mandatory `origin` (`consumer_submission` / `test_fixture` / `demo_seed` / `manual_import`) so `asking_to_closing_labels()` **excludes test/demo by default** (opt-in `include_non_production=True`) and fixtures never inflate the genuine count; `quality_status` (`accepted`/`flagged`/`rejected`) that **hard-rejects only structurally impossible values** (non-positive price, closing-before-listing) and merely **flags** unusual ratios (extreme close-to-ask, final-above-initial, suspicious duration, duplicate) while preserving the original self-reported values; a privacy-preserving duplicate-candidate **fingerprint** (one-way SHA-256 over bucketed canonical non-personal fields that flags submissions collapsing to the **same canonical transaction fingerprint** — a canonical-fingerprint collision, **not** general near-duplicate similarity detection, and it does **not** identify a property or seller); genuine vs test/demo reported as **separate counts**
324326
- [ ] **First genuine real-world label** — exactly one *actual* seller-submitted completed residential sale passing through the product path + quality gate. **Current genuine direct-label count: 0** — the end-to-end test proves the acquisition *path* works, not that a real-world label has been acquired
325-
- [x] **Structural econometric core** — mechanism-aware generalized **Nash bargaining** (`P = ηB+(1−η)S`, `η` estimated, not hard-coded) fit by **Simulated Method of Moments**; TCMB-anchored hedonic fair value (relative premiums only, no listing intercept as level); structural UYAP auctions with the **statutory legal floor** (`muhammen_bedel` preserved as appraised value `Q`, never the reserve; partially-observed floors not fabricated); KAP `η`-calibration moments with a corporate mechanism shift; TOKİ cumulative-disclosure differencing into room-type cohort moments. Replaces weak-label aggregation as the core; the provenance registry and validated KAP/TOKİ/UYAP Level-2 records are kept; the consumer path is frozen as an optional future *validation* channel. **Next: public structural dataset expansion + SMM estimation.** No SaleProbability model yet
327+
- [x] **Structural econometric core** — mechanism-aware generalized **Nash bargaining** (`P = ηB+(1−η)S`, `η` estimated, not hard-coded) fit by **Simulated Method of Moments**; TCMB-anchored hedonic fair value (relative premiums only, no listing intercept as level); structural UYAP auctions with the **statutory legal floor** (`muhammen_bedel` preserved as appraised value `Q`, never the reserve; partially-observed floors not fabricated); KAP `η`-calibration moments with a corporate mechanism shift; TOKİ cumulative-disclosure differencing into room-type cohort moments. Replaces weak-label aggregation as the core; the provenance registry and validated KAP/TOKİ/UYAP Level-2 records are kept; the consumer path is frozen as an optional future *validation* channel. No SaleProbability model yet
328+
- [x] **Statutory-floor fix, TCMB double-count audit & identification diagnostics** — corrected the İİK acceptance floor to `max(0.5·Q, priority_claims) + realization_costs`; audited the fair-value anchor so a contemporaneous TL/m² level is not trend-adjusted twice (KFE ratio only when rolling an older anchor); expanded the UYAP/KAP/TOKİ structural dataset schemas + observed-moment constructors (area semantics preserved, `log(sale/appraisal)` KAP moments, revision-guarded TOKİ cohorts); added `sold structural identify` (Jacobian rank / singular values / condition number / weak directions / profile diagnostics → `NOT_IDENTIFIED` → sensitivity mode). **Next: public structural dataset expansion → observed moments → identification → only then real SMM estimation**
326329
- [ ] **SaleProbability** model (`P(sold ≤ N days)`) trained on collected outcomes
327330
- [ ] Live, ToS-reviewed fetchers for the public label sources
328331
- [ ] Broker-vs-benchmark analytics over an aggregate anonymized dataset

src/sold/cli.py

Lines changed: 118 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1391,14 +1391,14 @@ def structural_value_cmd(
13911391
asking: float = typer.Argument(..., help="İlan (asking) fiyatı, TL"),
13921392
province: str = typer.Option("İstanbul", help="İl"),
13931393
gross_m2: float = typer.Option(100.0, help="Brüt m²"),
1394-
kfe_factor: float = typer.Option(1.0, help="KFE/YÖKFE zaman çarpanı (endeks_t/baz)"),
13951394
tightness: float = typer.Option(0.0, help="Piyasa sıkılığı (TÜİK hacminden; 0=nötr)"),
13961395
) -> None:
1397-
"""Sıradan ilan için YAPISAL closing dağılımı — gözlenen fiyat DEĞİL.
1396+
"""Sıradan ilan için YAPISAL-YÖNTEM PROTOTİPİ closing dağılımı — gözlenen fiyat DEĞİL.
13981397
13991398
asking, satıcı rezervasyonuna GÜRÜLTÜLÜ sinyaldir (tavan değil). Fair value TCMB
1400-
ekspertiz TL/m²'ye çıpalıdır. θ şu an ÖNSEL'dir (SMM ile gerçek veriye kalibre
1401-
edilmemiştir); çıktı yapısal bir çıkarımdır, ölçülen doğruluk değildir.
1399+
çağdaş ekspertiz TL/m²'ye çıpalıdır (EK KFE çarpanı YOK — çift-sayım önlenir). θ şu an
1400+
PROVİZYONEL'dir (kimliklendirme raporu desteklemeden ölçülen model DEĞİL); çıktı
1401+
sensitivity modunda bir yapısal-yöntem prototipidir.
14021402
"""
14031403
from .model.synthetic import load_province_ppm2
14041404
from .structural import (
@@ -1408,31 +1408,32 @@ def structural_value_cmd(
14081408
)
14091409

14101410
ppm2 = load_province_ppm2().get(province)
1411-
fv = tcmb_fair_value(ppm2, gross_m2, kfe_factor=kfe_factor)
1411+
fv = tcmb_fair_value(ppm2, gross_m2) # çağdaş çıpa; EK KFE ÇARPANI YOK
14121412
if fv is None:
14131413
typer.secho(
14141414
f"{province} için TCMB ekspertiz TL/m² bulunamadı (gross_m2 > 0 olmalı).",
14151415
fg=typer.colors.RED,
14161416
)
14171417
raise typer.Exit(code=1)
14181418

1419-
out = StructuralClosingPredictor(StructuralParams()).predict(
1419+
# identified=False → sensitivity mode (kimliklendirilmiş SMM tahmini yok)
1420+
out = StructuralClosingPredictor(StructuralParams(), identified=False).predict(
14201421
asking, fv, tightness=tightness
14211422
)
14221423
typer.secho(
1423-
"YAPISAL closing çıkarımı ÖNSELSMM ile kalibre EDİLMEMİŞ)",
1424+
"YAPISAL-YÖNTEM PROTOTİPİ PROVİZYONELkimliklendirilmemiş; sensitivity mode)",
14241425
fg=typer.colors.CYAN,
14251426
bold=True,
14261427
)
1427-
typer.echo(f" İlan: {asking:,.0f} TL · Fair value (TCMB ekspertiz çıpalı): {fv:,.0f} TL")
1428+
typer.echo(f" İlan: {asking:,.0f} TL · Fair value (TCMB çağdaş ekspertiz çıpası): {fv:,.0f} TL")
14281429
med = out["inferred_closing_median"]
14291430
if med is None:
14301431
typer.secho(" Bu senaryoda ticaret olasılığı ~0.", fg=typer.colors.YELLOW)
14311432
else:
14321433
lo, hi = out["interval_80"]
14331434
typer.echo(f" Çıkarımsal closing: medyan {med:,.0f} · ortalama {out['inferred_closing_mean']:,.0f} TL")
14341435
typer.echo(f" %80 yapısal aralık: {lo:,.0f}{hi:,.0f} TL")
1435-
typer.echo(f" Ticaret olasılığı: {out['trade_probability']:.2f}")
1436+
typer.echo(f" Ticaret olasılığı: {out['trade_probability']:.2f} · mod: {out['mode']}")
14361437
band = out["mechanism_transfer_sensitivity"].get("median_band")
14371438
if band and band[0] is not None:
14381439
typer.echo(f" Mekanizma-transfer duyarlılığı (medyan bandı): {band[0]:,.0f}{band[1]:,.0f} TL")
@@ -1482,6 +1483,114 @@ def structural_estimate_cmd(
14821483
)
14831484

14841485

1486+
@structural_app.command("identify")
1487+
def structural_identify_cmd(
1488+
auctions_file: Optional[Path] = typer.Option(None, "--auctions", help="UYAP açık artırma kayıtları (JSON)"),
1489+
kap_file: Optional[Path] = typer.Option(None, "--kap", help="KAP elden çıkarma kayıtları (JSON)"),
1490+
toki_file: Optional[Path] = typer.Option(None, "--toki", help="TOKİ açıklamaları (JSON)"),
1491+
demo: bool = typer.Option(False, "--demo", help="Sentetik veriyle diagnostiği göster"),
1492+
) -> None:
1493+
"""Yapısal KİMLİKLENDİRME raporu: dataset sayıları + Jacobian rank/SVD/koşul + profiller.
1494+
1495+
Optimizer yakınsaması KİMLİKLENDİRME DEĞİLDİR. rank(J) < dim(θ) ise NOT_IDENTIFIED ve
1496+
tahmin sensitivity moduna geçer. Epistemik katı: veri yoksa 0 raporlanır (uydurma yok).
1497+
"""
1498+
import json
1499+
1500+
import numpy as np
1501+
1502+
from .structural import (
1503+
DEFAULT_FREE,
1504+
MomentContext,
1505+
StructuralParams,
1506+
context_from_datasets,
1507+
difference_disclosures,
1508+
identification_report,
1509+
kap_observed_moments,
1510+
load_auctions,
1511+
load_kap_disposals,
1512+
observed_moments,
1513+
simulate_negotiations,
1514+
uyap_observed_moments,
1515+
)
1516+
1517+
auctions_df = kap_df = None
1518+
toki_res = None
1519+
m_obs: dict = {}
1520+
1521+
if demo:
1522+
theta0 = StructuralParams(eta=0.6)
1523+
rng = np.random.default_rng(7)
1524+
V = np.ones(20000)
1525+
neg = simulate_negotiations(rng, V, theta0, 20000, mechanism="kap")
1526+
tr = neg["traded"]
1527+
m_obs = observed_moments(kap_realized=neg["price"][tr], kap_appraisal=V[tr])
1528+
ctx = MomentContext(
1529+
auction_appraised=np.array([]), auction_floors=np.array([]),
1530+
kap_appraisal=np.ones(60), reps=300,
1531+
)
1532+
else:
1533+
if auctions_file:
1534+
auctions_df = load_auctions(
1535+
json.loads(Path(auctions_file).read_text(encoding="utf-8"))
1536+
)
1537+
m_obs.update(uyap_observed_moments(auctions_df))
1538+
if kap_file:
1539+
kap_df = load_kap_disposals(
1540+
json.loads(Path(kap_file).read_text(encoding="utf-8"))
1541+
)
1542+
m_obs.update(kap_observed_moments(kap_df))
1543+
if toki_file:
1544+
toki_res = difference_disclosures(
1545+
json.loads(Path(toki_file).read_text(encoding="utf-8"))
1546+
)
1547+
ctx = context_from_datasets(auctions_df, kap_df)
1548+
1549+
rep = identification_report(
1550+
ctx, StructuralParams(), DEFAULT_FREE, m_obs=m_obs,
1551+
auctions=auctions_df, kap=kap_df, toki_result=toki_res,
1552+
)
1553+
ds = rep["dataset"]
1554+
typer.secho("Yapısal kimliklendirme raporu", fg=typer.colors.CYAN, bold=True)
1555+
typer.echo(
1556+
f" UYAP: toplam {ds['uyap_total']} · satılan {ds['uyap_sold']} · "
1557+
f"satılmayan {ds['uyap_unsold']}"
1558+
)
1559+
typer.echo(
1560+
f" teklif gözlemli {ds['uyap_offer_count_observed']} · artıran gözlemli "
1561+
f"{ds['uyap_bidder_count_observed']} · tam yasal-taban {ds['uyap_exact_legal_floor_observed']}"
1562+
)
1563+
typer.echo(f" KAP müzakereli elden çıkarma: {ds['kap_negotiated_disposals']}")
1564+
typer.echo(f" TOKİ geçerli proje-dönem strata: {ds['toki_valid_project_period_strata']}")
1565+
typer.echo(
1566+
f" Yapısal parametre (dim θ): {rep['n_structural_parameters']} · "
1567+
f"gözlenen moment: {rep['n_observed_moments']}"
1568+
)
1569+
color = typer.colors.GREEN if rep["status"] == "IDENTIFIED" else typer.colors.RED
1570+
typer.secho(
1571+
f" DURUM: {rep['status']} (rank {rep['rank']} / dim {rep['n_structural_parameters']}) "
1572+
f"· mod: {rep['prediction_mode']}",
1573+
fg=color,
1574+
bold=True,
1575+
)
1576+
if rep.get("singular_values"):
1577+
sv = ", ".join(f"{x:.2e}" for x in rep["singular_values"])
1578+
typer.echo(f" Tekil değerler: [{sv}] · koşul sayısı: {rep['condition_number']:.2e}")
1579+
for wd in rep.get("weakly_identified_directions", []):
1580+
typer.echo(f" Zayıf-kimliklendirilmiş yön (s={wd['singular_value']:.2e}): {wd['direction']}")
1581+
for pname, pr in (rep.get("profiles") or {}).items():
1582+
rr = pr.get("relative_range")
1583+
flag = "ZAYIF" if pr.get("weakly_identified") else "belirgin"
1584+
rr_txt = f"{rr:.2e}" if isinstance(rr, (int, float)) else "—"
1585+
typer.echo(f" Profil {pname}: göreli hedef aralığı {rr_txt}{flag}")
1586+
if rep["status"] != "IDENTIFIED":
1587+
typer.secho(
1588+
" Not: rank < dim → optimizer sonucu NOKTA TAHMİNİ olarak SUNULMAZ; sensitivity "
1589+
"mode. (Optimizer yakınsaması ≠ kimliklendirme.) Sonraki iş: veri kümesi genişletmesi.",
1590+
fg=typer.colors.YELLOW,
1591+
)
1592+
1593+
14851594
@app.command("serve")
14861595
def serve_cmd(
14871596
host: str = typer.Option("127.0.0.1", help="Dinlenecek adres"),

0 commit comments

Comments
 (0)