from __future__ import annotations import datetime as dt import json import time import uuid from typing import Dict, Tuple import yaml from fair_prob import fair_prob_threshold from kalshi_client import KalshiClient from risk import RiskManager from spot_feed import SpotFeed from storage import Storage # ---------- helpers ---------- def _parse_iso_z(s: str) -> dt.datetime: """Parse ISO timestamps ending in Z.""" return dt.datetime.fromisoformat(s.replace("Z", "+00:00")) def dollars_to_cents_price(p: float) -> int: """Convert $0.00–$1.00 price to 1–99 cents.""" return max(1, min(99, int(round(p * 100)))) def extract_strike_and_rule(market: dict) -> Tuple[float, bool]: """ Returns (strike, resolves_yes_if_spot_ge_strike). """ strike_type = market.get("strike_type") if strike_type == "greater": return float(market["floor_strike"]), True if strike_type == "less": return float(market["cap_strike"]), False if market.get("floor_strike") is not None: return float(market["floor_strike"]), True if market.get("cap_strike") is not None: return float(market["cap_strike"]), False raise RuntimeError(f"Unable to determine strike from market: {json.dumps(market)[:400]}") def discover_open_crypto_15m_markets( client: KalshiClient, symbols: list[str], ) -> dict[str, dict]: """ Discover nearest-closing open 15-minute crypto markets by scanning open markets directly. """ data = client.get_markets(series_ticker=None, status="open", limit=500) markets = data.get("markets", []) now = dt.datetime.now(dt.timezone.utc) per_symbol: dict[str, dict] = {} for m in markets: title = (m.get("title") or "").upper() if "UP OR DOWN" not in title: continue if "15" not in title: continue for sym in symbols: if sym.upper() not in title: continue close_time = _parse_iso_z(m["close_time"]) if close_time <= now: continue prev = per_symbol.get(sym) if prev is None or close_time < _parse_iso_z(prev["close_time"]): per_symbol[sym] = m return per_symbol # ---------- main bot ---------- def main() -> None: with open("config.yaml", "r") as f: cfg = yaml.safe_load(f) mode = cfg["mode"] # "paper" or "live" symbols = cfg["symbols"] client = KalshiClient.from_env() spot = SpotFeed( urls=cfg["coinbase"], lookback_seconds=int(cfg["vol_lookback_seconds"]), ) storage = Storage("storage.sqlite") risk = RiskManager( cfg["daily_loss_limit"], cfg["max_consecutive_losses"], ) last_traded_market: Dict[str, str] = {} print(f"[init] mode={mode} symbols={symbols}") while True: if risk.trading_halted(): print("[risk] Trading halted — sleeping 60s") time.sleep(60) continue # update spot feed try: spot.update() except Exception as e: print(f"[spot] update failed: {e}") time.sleep(5) continue now = dt.datetime.now(dt.timezone.utc) markets_by_symbol = discover_open_crypto_15m_markets(client, symbols) for sym, m in markets_by_symbol.items(): try: market_ticker = m["ticker"] close_time = _parse_iso_z(m["close_time"]) # Trade window: T−6 minutes for a short window lead = dt.timedelta(seconds=int(cfg["lead_seconds"])) window = dt.timedelta(seconds=int(cfg["trade_window_seconds"])) start = close_time - lead end = start + window if not (start <= now <= end): continue if last_traded_market.get(sym) == market_ticker: continue market_full = client.get_market(market_ticker)["market"] strike, yes_if_ge = extract_strike_and_rule(market_full) yes_bid = float(market_full.get("yes_bid_dollars") or 0.0) yes_ask = float(market_full.get("yes_ask_dollars") or 1.0) spread = yes_ask - yes_bid market_prob = yes_ask if spread > float(cfg["max_spread_dollars"]): continue if not (cfg["min_market_prob"] <= market_prob <= cfg["max_market_prob"]): continue jump = abs( spot.returns_over_window(sym, int(cfg["jump_lookback_seconds"])) ) if jump > cfg["max_abs_jump"]: continue spot_px = spot.latest(sym) sigma = spot.realized_vol(sym) # IMPORTANT: settlement is the AVERAGE of the final 60 seconds time_remaining = max( 60.0, (close_time - now).total_seconds(), ) fair = fair_prob_threshold( spot=spot_px, strike=strike, sigma_per_second=sigma, time_remaining_seconds=time_remaining, resolves_yes_if_spot_ge_strike=yes_if_ge, ) edge = fair - market_prob if edge < cfg["min_edge"]: continue yes_ask_cents = dollars_to_cents_price(yes_ask) improve = int(cfg.get("limit_price_improve_cents", 0)) limit_cents = max(1, yes_ask_cents - improve) max_cost_cents = int(round(cfg["max_cost_dollars"] * 100)) count = max_cost_cents // limit_cents if count <= 0: continue storage.log_decision( ts=time.time(), symbol=sym, series_ticker="", market_ticker=market_ticker, close_time=m["close_time"], strike=strike, side="yes", market_prob=market_prob, fair_prob=fair, edge=edge, spread=spread, jump=jump, reason="trade", ) client_order_id = f"{sym}-{uuid.uuid4().hex[:10]}" if mode == "paper": print( f"[PAPER] {sym} {market_ticker} " f"count={count} limit={limit_cents}c " f"edge={edge:.3f}" ) storage.log_order( market_ticker, order_id=None, mode="paper", status="simulated", details=f"count={count} limit={limit_cents} edge={edge:.4f}", ) else: print( f"[LIVE] {sym} {market_ticker} " f"count={count} limit={limit_cents}c " f"edge={edge:.3f}" ) resp = client.create_order( ticker=market_ticker, side="yes", action="buy", count=count, yes_price_cents=limit_cents, buy_max_cost_cents=max_cost_cents, time_in_force="fill_or_kill", client_order_id=client_order_id, ) order = resp.get("order", {}) storage.log_order( market_ticker, order_id=order.get("order_id"), mode="live", status=order.get("status", "unknown"), details=json.dumps(order)[:1500], ) last_traded_market[sym] = market_ticker except Exception as e: print(f"[loop] error for {sym}: {e}") time.sleep(2) if __name__ == "__main__": main()