sell.py: optimal-ask model via empirical first-passage, not random walk

A driftless-random-walk closed form was tried first and backtested wrong
against 3 years of real exchange history (overstated touch probability
2-4x, confounded by discrete daily-VWAP sampling and real mean-reversion).
Replaced with puga.history.empirical_touch(): a non-parametric estimator
measuring first-passage directly from a ticker's own history, conditioned
on current state (trailing-60d z-score) since mean reversion's effect
depends on where price is now, not just move size. Queue-depth (existing
resting quantity) folds in as a deterministic shift of the hitting time,
not a max, ranking by the resulting hit_factor rather than a point
estimate of median days (which created a false cliff at the 50%-touched
threshold). Optional fixed-horizon right-censoring avoids recency bias
in "never reached" samples.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 23:06:34 +02:00
co-authored by Claude Sonnet 5
parent b9b3b9bf4e
commit c4bcc8f42c
3 changed files with 300 additions and 12 deletions
+102 -9
View File
@@ -10,11 +10,27 @@ having cash in time) matters more than a few points of margin.
puga sell BHP 8 --cx AI1
puga sell BHP 11 --tight # cash-tight: bias the split toward the aggressive tranche
puga sell BHP 8 --tiers # is pricing above the current market ever worth the wait?
--tiers answers "should I price above the current market" with TWO combined mechanisms, since
they are separate necessary conditions, not alternatives to average:
- queue-depth days: existing resting quantity ahead of you has to actually sell, at the real
historical trade-volume rate - valid only WITHIN the recently-traded price range (extrapolating
it past that would make a fat-finger outlier ask look "reachable").
- empirical first-passage probability: for a price with no resting quantity (above the whole
book, or outside the traded range), the market's own price level has to actually move up to
it - estimated directly from how often/how fast THIS ticker's real multi-year history has made
moves of that size (puga.history.empirical_touch), not assumed from a random-walk formula
(real prices mean-revert; see docs/decisions.md).
The two combine as: hit_factor (the empirical probability-and-timing-weighted value) shifted by
queue_days as a fixed extra delay (exp(-rho*queue_days)), since clearing the queue and waiting for
the price move are sequential, not alternative, requirements.
"""
import argparse, datetime, statistics, sys
import argparse, datetime, math, statistics, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from puga import config, fio, market
from puga.history import trading_days, trade_gaps_days, daily_prices, full_series, empirical_touch
def quantile(xs: list[float], q: float) -> float:
@@ -28,10 +44,24 @@ def tranche_split(qty: float, trades: list[float], tight: bool) -> tuple[float,
return aggressive, qty - aggressive
def daily_traded(tk: str, cx: str, days: int = 30) -> list[float]:
rows = [(datetime.datetime.fromtimestamp(e["DateEpochMs"] / 1000, datetime.timezone.utc).date(), e["Traded"])
for e in fio.cxpc(tk, cx) if e.get("Interval") == "DAY_ONE" and e.get("Traded")]
return [t for _, t in rows[-days:]]
def tier_table(prices: list[float], qty: float, s0: float, series: list, rate: float) -> list[dict]:
"""Discounted value of posting at each candidate price, using the EMPIRICAL first-passage
distribution of this ticker's own real multi-year price history (puga.history.empirical_touch),
not an assumed random-walk formula (real prices mean-revert; see docs/decisions.md). Has no
built-in monotonicity: if the real history shows diminishing willingness to move further, a
genuine interior optimum can appear instead of being forced by the model's shape.
"""
rows = []
for p in prices:
y = math.log(p / s0) if s0 else 0.0
if y <= 0:
stats = {"hit_factor": 1.0, "touched_frac": 1.0, "median_days": 0.0, "n": len(series)}
else:
stats = empirical_touch(series, y, rate)
rows.append({"price": p, "y": y, "hit_factor": stats["hit_factor"], "touch_frac": stats["touched_frac"],
"median_days": stats["median_days"], "n": stats["n"],
"revenue": p * qty, "discounted": p * qty * stats["hit_factor"]})
return rows
def main():
@@ -42,6 +72,8 @@ def main():
ap.add_argument("--undercut", type=float, default=10, help="AIC to undercut the current best ask by")
ap.add_argument("--tight", action="store_true", help="cash-tight: bias the tranche split toward the aggressive (undercut) tranche")
ap.add_argument("--show-bid", action="store_true", help="also show instant-bid revenue (usually worse than the tranche split; off by default)")
ap.add_argument("--rate", type=float, default=0.05, help="your REALIZED daily capital growth rate (opportunity cost of waiting), not a theoretical build ROI - default 0.05 matches this account's observed equity-growth curve as of 2026-10-10; pass your own if it's moved")
ap.add_argument("--tiers", action="store_true", help="show discounted value (price*qty discounted at --rate over the implied wait) across all existing ask tiers, to check whether climbing the book is ever worth it")
a = ap.parse_args()
t = a.ticker.upper()
ob = fio.order_book(t, a.cx)
@@ -50,17 +82,29 @@ def main():
post = round((best_ask - a.undercut) if best_ask else (ob.get("Ask") or 0))
ahead = sum(u for p, u in asks if p < post)
trades = daily_traded(t, a.cx)
days_window = 30
td = trading_days(t, a.cx, days_window)
trades = [v for _, v in td]
med = statistics.median(trades) if trades else 0
lo = sorted(trades)[int(0.2 * (len(trades) - 1))] if trades else 0
hi = sorted(trades)[int(0.8 * (len(trades) - 1))] if trades else 0
trade_frac = len(td) / days_window
gaps = trade_gaps_days([d for d, _ in td])
median_gap = statistics.median(gaps) if gaps else (days_window if len(td) <= 1 else 0)
print(f"{t}.{a.cx} best ask {best_ask} bid {ob.get('Bid')} vwap7 {ob.get('PriceAverage')}")
print(f"\npost {a.qty:g} at {post:.0f} ({ahead:.0f} units ahead of you at a lower price)")
print(f" revenue if filled: {post * a.qty:,.0f}")
if med:
print(f" expected clear time (last {len(trades)}d volume): busy day {24 * a.qty / hi:.1f}h | "
f"median day {24 * a.qty / med:.1f}h | slow day {24 * a.qty / max(lo, 1):.1f}h")
print(f" trades on {len(td)}/{days_window} of the last {days_window} days ({100*trade_frac:.0f}%)"
+ (f", median gap between trading days: {median_gap:.0f}d" if gaps else ""))
if trade_frac < 0.5:
print(f" SPARSE TICKER: most days see no trade at all - don't expect a same-day fill by default. "
f"Once a trade happens, it tends to move: busy day {24 * a.qty / hi:.1f}h | median day {24 * a.qty / med:.1f}h | "
f"slow day {24 * a.qty / max(lo, 1):.1f}h for your quantity - but you may wait ~{median_gap:.0f}d first for a trade to happen at all")
else:
print(f" expected clear time (last {days_window}d volume): busy day {24 * a.qty / hi:.1f}h | "
f"median day {24 * a.qty / med:.1f}h | slow day {24 * a.qty / max(lo, 1):.1f}h")
else:
print(" no recent trade history to estimate clear time")
@@ -70,12 +114,61 @@ def main():
+ (f", SHORT: book only fills {hit['filled']:.0f}" if hit["short"] else "") + ")")
print(f" posting patiently gains {post * a.qty - hit['total']:,.0f} over this, at the cost of waiting")
if a.tiers:
dp = daily_prices(t, a.cx)
s0 = sorted(dp.items())[-1][1][0] if dp else (best_ask or ob.get("Ask") or 0)
series = full_series(t, a.cx)
rho = math.log(1 + a.rate)
tier_prices = sorted({p for p, _ in asks} | {post, s0})
rows = tier_table(tier_prices, a.qty, s0, series, a.rate)
# Queue-depth days: valid ONLY within the range this ticker has genuinely traded in -
# the mechanism it models (real buy flow eating through resting ahead-quantity) has no
# evidence to extrapolate beyond that (this is what made a fat-finger outlier ask look
# reachable before). Outside that range it's infinite (no constraint from this mechanism;
# the empirical median_days alone governs).
cutoff_date = datetime.datetime.now(datetime.timezone.utc).date() - datetime.timedelta(days=days_window)
recent_prices = [p for d, (p, _) in dp.items() if d >= cutoff_date]
cutoff_price = max(recent_prices + [best_ask or 0]) * 1.1 if recent_prices else float("inf")
med_vol = med or 1
for r in rows:
ahead = sum(u for p, u in asks if p < r["price"])
# Outside the recent-traded range the queue mechanism doesn't meaningfully apply (no
# real evidence that cumulative ahead-quantity, much of it itself unreachable junk
# asks, clears at the normal rate) - that means "not a binding constraint" (0), NOT
# "infinite constraint".
r["queue_days"] = (ahead + a.qty) / med_vol if r["price"] <= cutoff_price else 0.0
# Rank by hit_factor (the real empirical E[e^-rho*tau] from tier_table), with
# queue_days folded in as a FIXED, deterministic shift of tau: shifting a random delay
# by a constant multiplies its Laplace transform by exp(-rho*shift), which is exactly
# right when "clear the real resting queue, THEN wait for price to reach this tier"
# are sequential, not alternative, constraints. median_days (shown separately below)
# is not used for the value itself - plugging a point estimate into (1+rate)^-days
# would be biased by Jensen's inequality, and None (<50% touched) would wrongly zero
# out a tier that hit_factor still values smoothly.
r["combined_hit"] = r["hit_factor"] * math.exp(-rho * r["queue_days"])
r["discounted_combined"] = r["revenue"] * r["combined_hit"]
best = max(rows, key=lambda r: r["discounted_combined"]) if rows else None
print(f"\nTIER TABLE (last traded {s0:.0f}, {len(series)}d history, rate {100*a.rate:.1f}%/day - see --help):")
print(f" {'price':>10} {'queue(d)':>9} {'touched':>8} {'median(d)':>10} {'hit%':>6} {'revenue':>10} {'discounted':>11}")
for r in rows:
flag = " <- best" if best is r else ""
qd = f"{r['queue_days']:.1f}" if r["queue_days"] != float("inf") else "n/a"
md = f"{r['median_days']:.1f}" if r["median_days"] is not None else "never"
print(f" {r['price']:>10.0f} {qd:>9} {100*r['touch_frac']:>7.0f}% {md:>10} {100*r['combined_hit']:>5.1f}% "
f"{r['revenue']:>10,.0f} {r['discounted_combined']:>11,.0f}{flag}")
if best and best["price"] > s0:
print(f" climbing to {best['price']:.0f} beats selling now")
else:
print(f" selling at/near the current price ({s0:.0f}) dominates")
aggr_qty, patient_qty = tranche_split(a.qty, trades, a.tight)
if patient_qty > 0.01:
tiers_above = sorted({p for p, u in asks if p > post})
patient_price = round(tiers_above[0] - 1) if tiers_above else round(post + 2 * a.undercut)
q_label = "80th pct (tight)" if a.tight else "median"
print(f"\nTRANCHE SPLIT ({q_label} volume quantile, {len(trades)}d):")
print(f"\nTRANCHE SPLIT ({q_label} volume quantile, {len(trades)} trading days in the last {days_window}d):")
print(f" aggressive: {aggr_qty:.1f} @ {post:.0f} (confident to clear today)")
print(f" patient: {patient_qty:.1f} @ {patient_price:.0f} (waits behind the next tier, higher margin)")
print(f" vs posting all {a.qty:g} at {post:.0f}: gains {patient_qty*(patient_price-post):,.0f} extra if the patient tranche fills")