Compare commits

..

10 Commits

Author SHA1 Message Date
e2d74bac46 test commit 2026-02-10 10:43:50 -05:00
6fffcb9e00 test commit 2026-02-10 10:36:59 -05:00
f8edc4e716 added keepalive prints 2026-02-09 19:58:02 -05:00
dd292741c1 changes to bot.py certificate store 2026-02-09 19:56:34 -05:00
3083ff471d added debugging to bot main 2026-02-09 19:17:08 -05:00
6a780be744 added to gitignore 2026-02-09 18:41:48 -05:00
57fd43a98a added bot.py 2026-02-09 18:30:47 -05:00
da87a3d99b added storage.py 2026-02-09 18:30:21 -05:00
6fafa4e098 added risk.py 2026-02-09 18:29:58 -05:00
bdfc856115 added fair_prob.py 2026-02-09 18:28:53 -05:00
8 changed files with 403 additions and 2 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ __pycache__/
*.pyc *.pyc
.env .env
storage.sqlite storage.sqlite
kalshi.key

259
bot.py Normal file
View File

@@ -0,0 +1,259 @@
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}")
print(f"[heartbeat] {dt.datetime.utcnow().isoformat()}Z")
time.sleep(2)
if __name__ == "__main__":
main()

View File

@@ -34,3 +34,6 @@ coinbase:
BTC: "https://api.coinbase.com/v2/prices/BTC-USD/spot" BTC: "https://api.coinbase.com/v2/prices/BTC-USD/spot"
ETH: "https://api.coinbase.com/v2/prices/ETH-USD/spot" ETH: "https://api.coinbase.com/v2/prices/ETH-USD/spot"
SOL: "https://api.coinbase.com/v2/prices/SOL-USD/spot" SOL: "https://api.coinbase.com/v2/prices/SOL-USD/spot"
#testing

38
fair_prob.py Normal file
View File

@@ -0,0 +1,38 @@
from __future__ import annotations
import math
def _norm_cdf(z: float) -> float:
# Standard normal CDF via erf (no scipy dependency)
return 0.5 * (1.0 + math.erf(z / math.sqrt(2.0)))
def fair_prob_threshold(
*,
spot: float,
strike: float,
sigma_per_second: float,
time_remaining_seconds: float,
resolves_yes_if_spot_ge_strike: bool,
) -> float:
"""
Conservative approximation:
spot(t) ~ Normal(spot, spot*sigma*sqrt(t))
and compute P(spot_T >= strike) or P(spot_T <= strike).
"""
if spot <= 0 or strike <= 0:
return 0.5
t = max(1.0, float(time_remaining_seconds))
sigma = max(1e-9, float(sigma_per_second))
stdev = spot * sigma * math.sqrt(t)
if stdev <= 0:
return 0.5
z = (spot - strike) / stdev
p_ge = _norm_cdf(z) # P(spot_T >= strike)
fair = p_ge if resolves_yes_if_spot_ge_strike else (1.0 - p_ge)
# clip away from certainty (tail risk)
return max(0.03, min(0.97, fair))

View File

@@ -1,3 +1,4 @@
cryptography>=42.0.0 cryptography>=42.0.0
requests>=2.31.0 requests>=2.31.0
PyYAML>=6.0.1 PyYAML>=6.0.1
s

35
risk.py Normal file
View File

@@ -0,0 +1,35 @@
from __future__ import annotations
import datetime as dt
class RiskManager:
def __init__(self, daily_loss_limit: float, max_consecutive_losses: int):
self.daily_loss_limit = float(daily_loss_limit)
self.max_consecutive_losses = int(max_consecutive_losses)
self._day = dt.date.today()
self._daily_pnl = 0.0
self._consec_losses = 0
def _roll_day(self) -> None:
today = dt.date.today()
if today != self._day:
self._day = today
self._daily_pnl = 0.0
self._consec_losses = 0
def trading_halted(self) -> bool:
self._roll_day()
if self._daily_pnl <= -self.daily_loss_limit:
return True
if self._consec_losses >= self.max_consecutive_losses:
return True
return False
def record_trade_result(self, pnl: float) -> None:
self._roll_day()
self._daily_pnl += float(pnl)
if pnl < 0:
self._consec_losses += 1
else:
self._consec_losses = 0

View File

@@ -4,6 +4,7 @@ import time
from collections import deque from collections import deque
from dataclasses import dataclass from dataclasses import dataclass
from typing import Deque, Dict, Tuple from typing import Deque, Dict, Tuple
import certifi
import requests import requests
@@ -24,10 +25,9 @@ class SpotFeed:
self.history: Dict[str, Deque[SpotPoint]] = {sym: deque() for sym in urls.keys()} self.history: Dict[str, Deque[SpotPoint]] = {sym: deque() for sym in urls.keys()}
def _fetch(self, url: str) -> float: def _fetch(self, url: str) -> float:
r = requests.get(url, timeout=10) r = requests.get(url, timeout=10, verify=certifi.where())
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
# Coinbase shape: {"data": {"amount": "70428.82", "currency": "USD"}}
return float(data["data"]["amount"]) return float(data["data"]["amount"])
def update(self) -> Dict[str, float]: def update(self) -> Dict[str, float]:

64
storage.py Normal file
View File

@@ -0,0 +1,64 @@
from __future__ import annotations
import sqlite3
import time
from typing import Any, Dict, Optional
class Storage:
def __init__(self, path: str = "storage.sqlite"):
self.conn = sqlite3.connect(path)
self.conn.execute("PRAGMA journal_mode=WAL;")
self._init()
def _init(self) -> None:
self.conn.execute(
"""
CREATE TABLE IF NOT EXISTS decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts REAL NOT NULL,
symbol TEXT NOT NULL,
series_ticker TEXT NOT NULL,
market_ticker TEXT NOT NULL,
close_time TEXT NOT NULL,
strike REAL NOT NULL,
side TEXT NOT NULL,
market_prob REAL NOT NULL,
fair_prob REAL NOT NULL,
edge REAL NOT NULL,
spread REAL NOT NULL,
jump REAL NOT NULL,
reason TEXT NOT NULL
)
"""
)
self.conn.execute(
"""
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts REAL NOT NULL,
market_ticker TEXT NOT NULL,
order_id TEXT,
mode TEXT NOT NULL,
status TEXT NOT NULL,
details TEXT
)
"""
)
self.conn.commit()
def log_decision(self, **row: Any) -> None:
cols = ",".join(row.keys())
qs = ",".join(["?"] * len(row))
self.conn.execute(f"INSERT INTO decisions ({cols}) VALUES ({qs})", list(row.values()))
self.conn.commit()
def log_order(self, market_ticker: str, order_id: Optional[str], mode: str, status: str, details: str = "") -> None:
self.conn.execute(
"INSERT INTO orders (ts, market_ticker, order_id, mode, status, details) VALUES (?, ?, ?, ?, ?, ?)",
(time.time(), market_ticker, order_id, mode, status, details),
)
self.conn.commit()
def close(self) -> None:
self.conn.close()