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