- 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>
140 lines
7.7 KiB
Python
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))
|