Files
PuGa/puga/saturation.py
T
dodoxandClaude Sonnet 5 3f67cc7ed5 scan.py: real bid-depth liquidity flag, real-variance flow bound, profit/area
- max_units_at_impact(): walks the LIVE bid book for a genuine, ticker-
  specific instant-exit liquidity check, replacing a flat 25%-of-flow
  guess (a price-vs-volume elasticity regression was tried first and
  abandoned - confounded by demand-driven volume on real data). Surfaced
  as an [exit-thin] WARNING flag, not a hard cap - a single order-book
  snapshot is less reliable than the 30-day flow aggregate that still
  drives n_lim/filtering (using it as a hard cap once silently zeroed
  out a real recipe from one noisy snapshot).
- tref_precise(): real variance of daily traded volume instead of a
  Poisson unit-independence bound, wired into stage 2 only (bounded
  candidate set, cached). Also fixed: was averaging only over trading
  days, overstating flow ~3x for sparse tickers.
- n_out(): dropped the demand-coverage term, which penalized thin
  resting-bid books even though production sells via patient asks taken
  by walk-up buyers with no resting bid at all.
- --sort area: profit/day per tile of TOTAL footprint, now including the
  single-tier hab area a building's workforce drags along (was building
  area only).
- --deprec/--reclaim-frac: amortizes unreclaimed capex, not full capex -
  real FIO reclaim data showed 50-86% returned even at near-100%
  condition, contradicting a 100%->0 decay-curve assumption.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-10 23:06:49 +02:00

140 lines
7.7 KiB
Python

"""Saturation model v1 (docs/saturation-design.md, after Opus review 2026-09-18). Pure functions.
Idea: N buildings is limited by (a) share of the market's steady traded flow and (b) the queue of
existing sell orders (supply days). Price is a haircut model, not a book walk.
is_thin() checks demand depth as an independent red flag (a genuinely dead bid book is worth
knowing about); n_out() does not multiply by it (see n_out's own docstring for why)."""
import math, statistics
from .history import trading_days
S_SHARE = 0.25 # max share of traded flow one producer takes
T_Q = 7 # days of flow the standing sell queue is compared against
T_D = 7 # days of flow standing demand should cover (used by is_thin only)
T_W = 3 # patient-selling window (days)
def tref(traded7: float, traded30: float) -> float:
"""Conservative daily flow: min(7d, 30d); with a Poisson lower bound when 30d volume is small.
CAVEAT: the Poisson term treats each traded UNIT as an independent event, which overstates
confidence for lumpy real trades (one 500-unit trade is one event, not 500) - see
`tref_precise()` for the real-variance version, used once a candidate reaches stage 2 (this
cheap version stays for stage-1 screening across every recipe, where fetching daily history
for every ticker would be too expensive)."""
t = min(traded7, traded30)
if 0 < traded30 < 20:
t = min(t, max(0.0, traded30 * (1 - 1.96 / math.sqrt(30 * traded30))))
return max(0.0, t)
def tref_precise(tk: str, cx: str, traded7: float, traded30: float, days: int = 30) -> float:
"""Like tref(), but the lower bound comes from the REAL variance of daily traded volume
(`puga.history.trading_days`) instead of a Poisson unit-independence assumption - one 500-unit
trade is one data point here, not 500 independent ones. Falls back to tref() if there's too
little real history to estimate variance from (<=2 trading days).
`trading_days()` only returns days with a trade - zero-volume days are absent, not zero - so
the volume list is padded with explicit zeros for the missing calendar days before computing
mean/stdev, and `days` (not the trading-day count) is used as n. Averaging only over the rows
that have a trade would compute the mean volume ON TRADING DAYS, overstating real
per-CALENDAR-day flow by 1/(fraction of days traded) - worst precisely for the sparse tickers
this function exists to protect."""
td = trading_days(tk, cx, days)
if len(td) <= 2:
return tref(traded7, traded30)
vols = [v for _, v in td] + [0.0] * (days - len(td))
mean = statistics.mean(vols)
sd = statistics.stdev(vols)
bound = max(0.0, mean - 1.96 * sd / math.sqrt(days))
return max(0.0, min(tref(traded7, traded30), bound))
def max_units_at_impact(bids: list[tuple[float, float]], ref_price: float, max_impact_pct: float = 10.0) -> float:
"""Real, ticker-specific price-impact cap, replacing the flat S_SHARE guess for THIS check:
how many units could be sold into the LIVE bid book before the price paid degrades more than
`max_impact_pct` below `ref_price`. Walks bids highest-first (puga.market.walk's own
mechanics) and stops at the first tier priced below the threshold.
A day-over-day price-vs-volume regression does not give a usable general elasticity estimate
here: volume and price co-move mostly from DEMAND bursts (more buyers -> more volume AND
steady/higher price), which swamps and often reverses the sign of the SUPPLY-side effect
actually wanted (does MY added output push price down). Walking the real book sidesteps that
confounding, but only captures the static liquidity snapshot, not how the market reprices
over weeks of sustained extra output (nothing in available data answers that cleanly). See
docs/decisions.md."""
if not ref_price or ref_price <= 0:
return 0.0
threshold = ref_price * (1 - max_impact_pct / 100)
cum = 0.0
for price, qty in sorted(bids, key=lambda x: -x[0]):
if price < threshold:
break
cum += qty
return cum
def is_thin(tr: float, demand: float, q_out: float) -> bool:
return tr < 3 * q_out or demand < 7 * q_out
def n_out(tr: float, supply: float, q_out: float) -> float:
"""Buildings the output market absorbs: flow share x queue penalty. 0 if no flow.
Deliberately has NO resting-bid-depth term: production sells via patient ASKS (`p_patient()`)
that get taken by buyers crossing the spread, including walk-up buyers with no resting bid at
all - a thin resting bid book doesn't mean they won't show up, since `tr` (real traded flow,
already in `base`) is the actual evidence they do. A market with healthy ask-side flow and
thin standing bids is normal (most buyers don't leave a bid sitting around), not evidence of
absent demand. Real resting-bid demand is still checked independently by `is_thin()` as a red
flag, not folded into this score."""
if tr <= 0 or q_out <= 0:
return 0.0
base = S_SHARE * tr / q_out
queue = min(1.0, T_Q * tr / supply) if supply > 0 else 1.0
return base * queue
def p_patient(asks: list[tuple[float, float]], tr: float, produced_per_day: float, bid: float, vwap7: float | None,
vwap30: float | None, ask: float | None, wide_high: float | None = None) -> float:
"""Price we can hold asks at. asks = [(price, units)] ascending. Find the highest ask level whose units-ahead
(levels strictly below) <= what buyers will absorb of the queue over T_W days once our output is counted:
target = T_W * (tr - produced). target <= 0 => sell at the bid. Clamped to [bid, min(vwap7, vwap30, ask, wide_high)]."""
hi = min(x for x in (vwap7, vwap30, ask, wide_high) if x)
target = T_W * (tr - produced_per_day)
if target <= 0:
return bid
p, ahead = hi, 0.0
for price, units in asks:
if ahead <= target:
p = price
ahead += units
return max(bid, min(p, hi))
def in_band(price: float, band: tuple[float | None, float | None] | None) -> bool:
"""band = (low, high), the exchange's tradeable Price Band (FIO order_book's Narrow/WidePriceBandLow/High;
verified live 2026-09-25 to match APEX's own displayed 'Price Band' field exactly). An order outside it is
not just uncompetitive, it is not currently placeable at all, and existing ones there are stale artifacts
(e.g. a leftover 334 AIC bid sitting under a 663 floor) that will never fill; not just a heuristic cutoff."""
if not band:
return True
lo, hi = band
return (lo is None or price >= lo) and (hi is None or price <= hi)
def effective_supply(asks: list[tuple[float, float]], ref_price: float | None, mult: float = 1.25,
band: tuple[float | None, float | None] | None = None) -> float:
"""Units of standing sell orders that actually compete: in the tradeable price band (hard filter, if given)
AND price <= mult x reference (vwap7 or ask; soft filter for "active competition" vs merely valid-but-stale
high asks like OCK's top tier). asks = [(price, units)]."""
asks = [(p, u) for p, u in asks if in_band(p, band)]
if not ref_price:
return sum(u for _, u in asks)
return sum(u for p, u in asks if p <= mult * ref_price)
def effective_demand(bids: list[tuple[float, float]], band: tuple[float | None, float | None] | None) -> float:
"""Units of standing buy orders that are actually tradeable: within the exchange's price band. Filters out
stale/abandoned bids placed under a different band or long since left behind by the market (e.g. a 334 AIC
bid when the floor is 663) rather than genuine, fillable demand. bids = [(price, units)]."""
return sum(u for p, u in bids if in_band(p, band))