260 lines
8.1 KiB
Python
260 lines
8.1 KiB
Python
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()
|