Compare commits
10 Commits
49a83ba135
...
e2d74bac46
| Author | SHA1 | Date | |
|---|---|---|---|
| e2d74bac46 | |||
| 6fffcb9e00 | |||
| f8edc4e716 | |||
| dd292741c1 | |||
| 3083ff471d | |||
| 6a780be744 | |||
| 57fd43a98a | |||
| da87a3d99b | |||
| 6fafa4e098 | |||
| bdfc856115 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
.env
|
.env
|
||||||
storage.sqlite
|
storage.sqlite
|
||||||
|
kalshi.key
|
||||||
|
|||||||
259
bot.py
Normal file
259
bot.py
Normal 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 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}")
|
||||||
|
print(f"[heartbeat] {dt.datetime.utcnow().isoformat()}Z")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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
38
fair_prob.py
Normal 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))
|
||||||
@@ -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
35
risk.py
Normal 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
|
||||||
@@ -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
64
storage.py
Normal 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()
|
||||||
Reference in New Issue
Block a user