Files
PuGa/puga/parkcash.py
T
dodoxandClaude Sonnet 5 11f54276b3 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>
2026-10-10 23:06:40 +02:00

83 lines
5.1 KiB
Python

"""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