Files
Kalshi-Bot/bot.py

260 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 199 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: T6 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()