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>
179 lines
11 KiB
Python
Executable File
179 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Where to post a sell order and how long it should take to clear, vs hitting the bids now.
|
|
Formalizes the by-hand check done before every AL/BHP sale, including the tranche split: an
|
|
aggressive (undercut) tranche sized to a quantile of daily volume so it's confident to clear
|
|
today, and a patient tranche (priced just under the next competitor tier) for the rest. The
|
|
quantile is the newsvendor critical fractile Cu/(Cu+Co): median (0.5) when the cost of running
|
|
out of cash (Cu) and the cost of discounting unnecessarily (Co) are about equal; higher (0.8 via
|
|
--tight) when Cu dominates, i.e. a restock/buildout payment is imminent and stockout risk (not
|
|
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, 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:
|
|
return sorted(xs)[int(q * (len(xs) - 1))] if xs else 0.0
|
|
|
|
|
|
def tranche_split(qty: float, trades: list[float], tight: bool) -> tuple[float, float]:
|
|
"""(aggressive_qty, patient_qty). Aggressive is capped at qty and at the volume quantile."""
|
|
q = 0.8 if tight else 0.5
|
|
aggressive = min(qty, quantile(trades, q)) if trades else qty
|
|
return aggressive, qty - aggressive
|
|
|
|
|
|
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():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("ticker")
|
|
ap.add_argument("qty", type=float)
|
|
ap.add_argument("--cx", default=config.DEFAULT_CX)
|
|
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)
|
|
asks = sorted((o["ItemCost"], o["ItemCount"] or 0) for o in ob["SellingOrders"] if o.get("ItemCost"))
|
|
best_ask = asks[0][0] if asks else None
|
|
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)
|
|
|
|
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" 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")
|
|
|
|
if a.show_bid:
|
|
hit = market.walk(t, a.cx, a.qty, "sell")
|
|
print(f"\ninstant (hit the bids now): {hit['total']:,.0f} (avg {hit['avg']:.0f}, worst {hit['worst']:.0f}"
|
|
+ (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)} 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")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|