"""Sustained asking-price paths (DECISIONS.md D-005). Input: the platform's price_log (one row at first observation and one per price change; NULL = price withdrawn). Output: price levels with transient A->B->A reversions removed, plus event classification (entry / cut / rise). """ from __future__ import annotations from dataclasses import dataclass import numpy as np import polars as pl from . import config as C @dataclass class PathSummary: levels: list[tuple[float, float]] # (ts, price) sustained levels n_transients: int n_null: int def sustain(ts: list[float], price: list[float | None], last_seen: float, transient_seconds: float = C.TRANSIENT_SECONDS) -> PathSummary: """Remove transient levels: a level k with duration < transient_seconds whose successor returns to the price of level k-1 is dropped (and k-1, k+1 merged). Repeated until stable. NULL prices are dropped (counted).""" pts = [(t, p) for t, p in zip(ts, price) if p is not None and np.isfinite(p)] n_null = len(ts) - len(pts) pts.sort() # merge consecutive equal prices lv: list[tuple[float, float]] = [] for t, p in pts: if lv and lv[-1][1] == p: continue lv.append((t, p)) n_tr = 0 changed = True while changed and len(lv) >= 3: changed = False for k in range(1, len(lv) - 1): dur = lv[k + 1][0] - lv[k][0] if dur < transient_seconds and lv[k + 1][1] == lv[k - 1][1]: # drop level k and the (now redundant) level k+1 del lv[k:k + 2] n_tr += 1 changed = True break return PathSummary(levels=lv, n_transients=n_tr, n_null=n_null) def classify(levels: list[tuple[float, float]], min_pct: float = C.MARKDOWN_MIN_PCT, min_abs: float = C.MARKDOWN_MIN_ABS) -> list[dict]: """Events from sustained levels: first level = entry; then cut / rise / minor.""" ev = [] for k, (t, p) in enumerate(levels): if k == 0: ev.append({"ts": t, "price": p, "prev_price": None, "change_pct": None, "kind": "entry"}) continue pp = levels[k - 1][1] ch = (p - pp) / pp if pp else None if ch is None: kind = "minor" elif ch <= -min_pct and (pp - p) >= min_abs: kind = "cut" elif ch >= min_pct and (p - pp) >= min_abs: kind = "rise" else: kind = "minor" ev.append({"ts": t, "price": p, "prev_price": pp, "change_pct": ch, "kind": kind}) return ev def build(price_log: pl.DataFrame, vehicles: pl.DataFrame) -> tuple[pl.DataFrame, pl.DataFrame]: """Return (events, summary) DataFrames for all uids in price_log.""" ls = dict(zip(vehicles["uid"].to_list(), vehicles["last_seen"].to_list())) rows_ev, rows_sum = [], [] grouped = price_log.sort(["uid", "ts"]).group_by("uid", maintain_order=True).agg([pl.col("ts"), pl.col("price")]) for uid, ts, pr in grouped.iter_rows(): ps = sustain(ts, pr, ls.get(uid, max(ts))) ev = classify(ps.levels) cuts = [e for e in ev if e["kind"] == "cut"] rises = [e for e in ev if e["kind"] == "rise"] entry = ev[0] if ev else None for e in ev: rows_ev.append({"uid": uid, **e}) rows_sum.append({ "uid": uid, "entry_price": entry["price"] if entry else None, "entry_price_ts": entry["ts"] if entry else None, "last_price": ev[-1]["price"] if ev else None, "n_levels_raw": len(ts), "n_null_price": ps.n_null, "n_transients": ps.n_transients, "oscillating": ps.n_transients >= C.OSCILLATION_MAX_TRANSIENTS, "n_cuts": len(cuts), "n_rises": len(rises), "first_cut_ts": cuts[0]["ts"] if cuts else None, "first_cut_pct": cuts[0]["change_pct"] if cuts else None, "total_change_pct": ((ev[-1]["price"] - entry["price"]) / entry["price"]) if (entry and entry["price"]) else None, }) events = pl.DataFrame(rows_ev, schema={"uid": pl.Utf8, "ts": pl.Float64, "price": pl.Float64, "prev_price": pl.Float64, "change_pct": pl.Float64, "kind": pl.Utf8}) summary = pl.DataFrame(rows_sum) return events, summary