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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user