From 11f54276b338707c491e4ae5565110bcf6e4d3d5 Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Sat, 10 Oct 2026 23:06:40 +0200 Subject: [PATCH] Add tools/parkcash.py: idle-cash park/flip scan using the empirical model Where to park idle cash in a buy-low/sell-high position while it has no better use - scans every ticker for real bid/ask spread and liquidity, scoring round-trip (buy-dip, sell-recovery) via the same empirical first-passage model as sell.py's optimal-ask fix, instead of an assumed random walk. Dropped the universe's top pick from ~94%/day to ~1%/day with real waits of 19-194+ days under the corrected model. Default --rate 0.0 matches the tool's own idle-cash premise (sell.py's rate of 0.05 assumes cash is earmarked, which isn't this tool's case). Co-Authored-By: Claude Sonnet 5 --- puga/parkcash.py | 82 +++++++++++++++++++++++++++++++++++++++++++++++ tools/parkcash.py | 47 +++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 puga/parkcash.py create mode 100644 tools/parkcash.py diff --git a/puga/parkcash.py b/puga/parkcash.py new file mode 100644 index 0000000..f9ae34b --- /dev/null +++ b/puga/parkcash.py @@ -0,0 +1,82 @@ +"""Where to park idle cash as a buy-low/sell-high position (not a build) while ships are busy or +cash has no better immediate use - the market-making tactic worked out by hand in-session before +this existed. A big bid/ask spread alone is a trap (see OPEN GAPS below); this scores candidates +on spread, real trading liquidity, and an EMPIRICAL round-trip estimate from the ticker's own +price history - no random-walk/mean-reversion assumption (an earlier driftless-random-walk +version of this kind of model was built for tools/sell.py and backtested WRONG against 3 years of +real data - see docs/decisions.md's 2026-10-10 entries - so this one measures directly instead). + +Method per ticker: +- spread_pct: (ask - bid) / bid at the live quote. +- liquidity: trading_days()/trade_gaps_days() over the last `days` calendar days - trades on most + days with a short gap is real liquidity; a huge spread with few trading days is the trap this + project already got burned by once (AST: 130% spread, 19/30 trading days, but the resting ask + was stale - real last-traded price had already crashed 60% below it). +- round trip = two sequential empirical first-passage legs (puga.history.empirical_touch), using + the ticker's own full multi-year history, not an assumed distribution: + buy leg: y = ln(bid/latest) - how often/fast has a move of this size (usually downward) + actually happened, ever, in this ticker's real history? + sell leg: y = ln(ask/bid) - same question for the recovery back up to ask. + combined_days = buy.median_days + sell.median_days (both must happen, in sequence) + combined_hit = buy.hit_factor * sell.hit_factor (treats the two legs as independent - an + approximation, not exact, but each factor is itself a real empirically-measured expectation, + not a parametric guess). +- daily_pct: (ask-bid)/bid pessimistic margin over combined_days, as a SIMPLE (non-compounding) + rate - see docs/decisions.md's "Drop ROI/day %" entry. For ranking by speed, not a yield. +- discounted_pct: margin_pct x combined_hit - the SAME margin, discounted by both legs' real odds + of ever happening at all (`--rate`'s only effect on the output; a low/near-0 default matches + this tool's "idle cash, no better use" premise - pass a higher rate if this money actually IS + earmarked for something and waiting has a real opportunity cost). A big gap between daily_pct + and discounted_pct means the raw speed number is flattering a trade that may just never clear. + +OPEN GAPS: +- No CX transaction/fee field in FIO's exchange data (checked) - can't confirm or rule out a fee + eating into the spread; not modelled. +- Treating buy-leg and sell-leg as independent (multiplying hit factors, adding median days) is an + approximation - the real joint distribution of "time to dip to bid, THEN time to recover to ask + starting from wherever it actually dipped to" isn't computed exactly. +""" +import math, statistics +from . import market +from .history import daily_prices, trading_days, trade_gaps_days, full_series, empirical_touch + + +def scan(cx: str = "AI1", min_spread_pct: float = 20.0, days: int = 30, rate: float = 0.0): + """Every (ticker, cx) with a live quote and spread_pct >= min_spread_pct, scored by pessimistic + simple daily-equivalent return from the empirical round-trip model. Returns a list of dicts, + unsorted (sort by 'daily_pct' - or let the caller filter on 'trade_days'/'gap_days' first, + since those are what separate a real opportunity from a stale-quote trap).""" + snap = market.snapshot() + rows = [] + for (tk, q_cx), q in snap.items(): + if q_cx != cx or not q.ask or not q.bid or q.bid <= 0: + continue + spread_pct = 100 * (q.ask - q.bid) / q.bid + if spread_pct < min_spread_pct: + continue + dp = daily_prices(tk, cx) + if len(dp) < 2: + continue + td = trading_days(tk, cx, days) + gaps = trade_gaps_days([d for d, _ in td]) + gap_days = statistics.median(gaps) if gaps else None + if not gap_days or gap_days <= 0: + continue + series = full_series(tk, cx) + latest = series[-1][1] + if latest <= 0: + continue + buy = empirical_touch(series, math.log(q.bid / latest), rate) + sell = empirical_touch(series, math.log(q.ask / q.bid), rate) + if buy["median_days"] is None or sell["median_days"] is None: + continue # one leg has never reliably happened in this ticker's real history + combined_days = buy["median_days"] + sell["median_days"] + combined_hit = buy["hit_factor"] * sell["hit_factor"] + margin_pct = 100 * (q.ask - q.bid) / q.bid + rows.append(dict(tk=tk, cx=cx, bid=q.bid, ask=q.ask, spread_pct=spread_pct, latest=latest, + margin_pct=margin_pct, trade_days=len(td), window_days=days, gap_days=gap_days, + buy_touch=buy["touched_frac"], sell_touch=sell["touched_frac"], + wait_days=combined_days, hit_factor=combined_hit, + daily_pct=margin_pct / combined_days if combined_days else 0.0, + discounted_pct=margin_pct * combined_hit)) + return rows diff --git a/tools/parkcash.py b/tools/parkcash.py new file mode 100644 index 0000000..77306d9 --- /dev/null +++ b/tools/parkcash.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Where to park idle cash as a buy-low/sell-high position while ships are busy or cash has no +better immediate use. Scans every ticker for a big spread AND real trading liquidity, scored by +an EMPIRICAL round-trip estimate from the ticker's own price history (no random-walk assumption - +see puga/parkcash.py's docstring for the method and open gaps). + + tools/parkcash.py # AI1, default filters, top 25 by pessimistic daily % + tools/parkcash.py --min-trade-days 15 # require real liquidity (half the window trading) +""" +import argparse, sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from puga import config, parkcash + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--cx", default=config.DEFAULT_CX) + ap.add_argument("--min-spread", type=float, default=20.0, help="minimum bid/ask spread %% to consider") + ap.add_argument("--min-trade-days", type=int, default=10, help="minimum trading days out of the window - filters stale-quote traps") + ap.add_argument("--days", type=int, default=30, help="calendar-day window for liquidity stats (the empirical round-trip model itself uses full history)") + ap.add_argument("--rate", type=float, default=0.0, help="opportunity cost of waiting on this cash - default 0.0 matches this tool's premise (idle cash, no better use right now); pass your realized capital growth rate (e.g. 0.05, see tools/sell.py --rate) if this money IS earmarked for something and waiting has a real cost") + ap.add_argument("--max-wait", type=float, help="drop anything with a combined round-trip median wait above this many days") + ap.add_argument("--top", type=int, default=25) + a = ap.parse_args() + + rows = parkcash.scan(a.cx, a.min_spread, a.days, a.rate) + rows = [r for r in rows if r["trade_days"] >= a.min_trade_days] + if a.max_wait is not None: + rows = [r for r in rows if r["wait_days"] <= a.max_wait] + rows.sort(key=lambda r: -r["daily_pct"]) + + print(f"# {a.cx} spread>={a.min_spread:.0f}% trade_days>={a.min_trade_days}/{a.days}d rate={100*a.rate:.1f}%/day " + f"(round trip = empirical buy-dip + sell-recovery from real history, see puga/parkcash.py)") + print(f"{'daily%':>7} {'disc%':>6} {'margin%':>8} {'spread%':>8} {'days':>5} {'buy%':>5} {'sell%':>5} {'wait(d)':>7} {'bid':>9} {'ask':>9} tk") + for r in rows[:a.top]: + print(f"{r['daily_pct']:7.1f} {r['discounted_pct']:6.1f} {r['margin_pct']:8.1f} {r['spread_pct']:8.0f} {r['trade_days']:5d} " + f"{100*r['buy_touch']:5.0f} {100*r['sell_touch']:5.0f} {r['wait_days']:7.1f} {r['bid']:9.0f} {r['ask']:9.0f} {r['tk']}") + + print("\nNOTE: disc% = margin% x real odds both legs ever happen (at --rate; a big gap from daily% " + "means the speed number is flattering a trade that may never clear). buy%/sell% are each leg's " + "real historical frequency. Not a compoundable rate - see docs/decisions.md. See puga/parkcash.py's " + "OPEN GAPS (no fee data; legs treated as independent).") + + +if __name__ == "__main__": + main()