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>
83 lines
5.1 KiB
Python
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
|