Compare commits

..

7 Commits

Author SHA1 Message Date
0470a951eb added README.md 2026-02-10 12:03:23 -05:00
56512bcd5c added bot.py 2026-02-10 12:02:54 -05:00
0a6e5e3846 added storage.py 2026-02-10 12:02:37 -05:00
779c637a73 added kalshi_client.py 2026-02-10 12:02:16 -05:00
d2c9dcab29 added config.yaml 2026-02-10 12:01:39 -05:00
21d9596e8e added .env.example 2026-02-10 12:01:16 -05:00
681231ce04 removed old files to recode 2026-02-10 12:00:42 -05:00
11 changed files with 447 additions and 511 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Kalshi environment: demo or prod
export KALSHI_ENV="demo"
# Your API Key ID (UUID shown when you create the key)
export KALSHI_API_KEY_ID="REPLACE_ME"
# Path to the downloaded private key (.key) from Kalshi
export KALSHI_PRIVATE_KEY_PATH="/absolute/path/to/kalshi-private.key"

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
# Kalshi 15m Crypto Probability Bot
Strategy:
- Every loop, discover the nearest-close open 15-minute "Up or Down" market for BTC/ETH/SOL
- At T - 6 minutes (configurable), if YES ask or NO ask is between 70% and 95%, place a bet
- Stake tiers:
- 7080% => $1
- 8090% => $2
- 9095% => $2 (change in config.yaml)
## Setup (Mac / Linux)
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# edit .env with your key id + private key path
source .env
python bot.py

374
bot.py
View File

@@ -4,57 +4,77 @@ import datetime as dt
import json import json
import time import time
import uuid import uuid
from typing import Dict, Tuple from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
import yaml import yaml
from fair_prob import fair_prob_threshold from kalshi_client import KalshiClient, KalshiConfig
from kalshi_client import KalshiClient from storage import Storage, DecisionRow
from risk import RiskManager
from spot_feed import SpotFeed
from storage import Storage
# ---------- helpers ----------
def _parse_iso_z(s: str) -> dt.datetime: def _parse_iso_z(s: str) -> dt.datetime:
"""Parse ISO timestamps ending in Z.""" # example: "2026-02-10T15:29:16Z"
return dt.datetime.fromisoformat(s.replace("Z", "+00:00")) return dt.datetime.fromisoformat(s.replace("Z", "+00:00"))
def dollars_to_cents_price(p: float) -> int: def _market_prob_from_asks(m: dict) -> Tuple[Optional[float], Optional[float]]:
"""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). Return (yes_ask_prob, no_ask_prob) in 0..1 using whatever fields are present.
Kalshi market objects often include yes_ask/yes_bid (cents) and/or yes_ask_dollars (float).
""" """
strike_type = market.get("strike_type") def read_prob(prefix: str) -> Optional[float]:
cents_key = f"{prefix}_ask"
dollars_key = f"{prefix}_ask_dollars"
if m.get(dollars_key) is not None:
return float(m[dollars_key])
if m.get(cents_key) is not None:
return float(m[cents_key]) / 100.0
return None
if strike_type == "greater": yes_p = read_prob("yes")
return float(market["floor_strike"]), True no_p = read_prob("no")
if strike_type == "less": return yes_p, no_p
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( def _yes_bid_ask_spread(m: dict) -> Tuple[float, float, float]:
client: KalshiClient, def read(prefix: str, side: str) -> Optional[float]:
symbols: list[str], cents_key = f"{prefix}_{side}"
) -> dict[str, dict]: dollars_key = f"{prefix}_{side}_dollars"
if m.get(dollars_key) is not None:
return float(m[dollars_key])
if m.get(cents_key) is not None:
return float(m[cents_key]) / 100.0
return None
yes_bid = read("yes", "bid") or 0.0
yes_ask = read("yes", "ask") or 1.0
return yes_bid, yes_ask, max(0.0, yes_ask - yes_bid)
def _stake_for_prob(prob: float, tiers: List[dict]) -> Optional[float]:
for t in tiers:
if float(t["min"]) <= prob < float(t["max"]):
return float(t["stake_dollars"])
# allow exact upper bound match (e.g., prob==0.95)
for t in tiers:
if abs(prob - float(t["max"])) < 1e-12:
return float(t["stake_dollars"])
return None
def _dollars_to_cents_price(p: float) -> int:
c = int(round(p * 100))
return max(1, min(99, c))
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. Scan open markets and pick the nearest-to-close market for each symbol.
Uses title heuristics so you don't need series discovery (more robust).
""" """
data = client.get_markets(series_ticker=None, status="open", limit=500) data = client.list_open_markets(limit=1000)
markets = data.get("markets", []) markets = data.get("markets") or []
now = dt.datetime.now(dt.timezone.utc) now = dt.datetime.now(dt.timezone.utc)
per_symbol: dict[str, dict] = {} per_symbol: dict[str, dict] = {}
@@ -62,197 +82,245 @@ def discover_open_crypto_15m_markets(
for m in markets: for m in markets:
title = (m.get("title") or "").upper() title = (m.get("title") or "").upper()
# Heuristics: 15-min, Up/Down, and the symbol present
if "UP OR DOWN" not in title: if "UP OR DOWN" not in title:
continue continue
if "15" not in title: if "15" not in title:
continue continue
close_time_s = m.get("close_time")
if not close_time_s:
continue
close_time = _parse_iso_z(close_time_s)
if close_time <= now:
continue
for sym in symbols: for sym in symbols:
if sym.upper() not in title: if sym.upper() not in title:
continue continue
close_time = _parse_iso_z(m["close_time"])
if close_time <= now:
continue
prev = per_symbol.get(sym) prev = per_symbol.get(sym)
if prev is None or close_time < _parse_iso_z(prev["close_time"]): if prev is None or _parse_iso_z(prev["close_time"]) > close_time:
per_symbol[sym] = m per_symbol[sym] = m
return per_symbol return per_symbol
# ---------- main bot ----------
def main() -> None: def main() -> None:
with open("config.yaml", "r") as f: cfg = yaml.safe_load(open("config.yaml", "r"))
cfg = yaml.safe_load(f)
mode = cfg["mode"] # "paper" or "live" mode = cfg["mode"].lower()
symbols = cfg["symbols"] symbols = cfg["symbols"]
lead_seconds = int(cfg["lead_seconds"])
trade_window_seconds = int(cfg["trade_window_seconds"])
min_prob = float(cfg["min_prob"])
max_prob = float(cfg["max_prob"])
tiers = list(cfg["tiers"])
max_spread = float(cfg["max_spread_dollars"])
improve_cents = int(cfg.get("improve_cents", 0))
tif = str(cfg.get("time_in_force", "fill_or_kill"))
poll_seconds = float(cfg.get("poll_seconds", 2))
storage = Storage(cfg.get("sqlite_path", "storage.sqlite"))
client = KalshiClient.from_env() client = KalshiClient(KalshiConfig(env=str((__import__("os").getenv("KALSHI_ENV") or "demo"))))
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} lead_seconds={lead_seconds} window={trade_window_seconds}s")
print(f"[init] mode={mode} symbols={symbols}") last_traded_market: Dict[str, str] = {} # sym -> market_ticker
while True: 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) now = dt.datetime.now(dt.timezone.utc)
print(f"[heartbeat] {now.isoformat().replace('+00:00','Z')}")
try:
markets_by_symbol = discover_open_crypto_15m_markets(client, symbols) markets_by_symbol = discover_open_crypto_15m_markets(client, symbols)
except Exception as e:
print(f"[kalshi] market discovery failed: {e}")
time.sleep(poll_seconds)
continue
for sym, m in markets_by_symbol.items(): for sym, m in markets_by_symbol.items():
try: try:
market_ticker = m["ticker"] market_ticker = m["ticker"]
close_time = _parse_iso_z(m["close_time"]) close_time = _parse_iso_z(m["close_time"])
# Trade window: T6 minutes for a short window # Fire only in a narrow window at T - lead_seconds
lead = dt.timedelta(seconds=int(cfg["lead_seconds"])) start = close_time - dt.timedelta(seconds=lead_seconds)
window = dt.timedelta(seconds=int(cfg["trade_window_seconds"])) end = start + dt.timedelta(seconds=trade_window_seconds)
start = close_time - lead
end = start + window
if not (start <= now <= end): if not (start <= now <= end):
continue continue
# Dedup per symbol per market
if last_traded_market.get(sym) == market_ticker: if last_traded_market.get(sym) == market_ticker:
continue continue
market_full = client.get_market(market_ticker)["market"] # Pull full market object (more reliable fields)
strike, yes_if_ge = extract_strike_and_rule(market_full) market_full = client.get_market(market_ticker).get("market") or m
yes_bid = float(market_full.get("yes_bid_dollars") or 0.0) yes_p, no_p = _market_prob_from_asks(market_full)
yes_ask = float(market_full.get("yes_ask_dollars") or 1.0) if yes_p is None or no_p is None:
spread = yes_ask - yes_bid storage.log_decision(
market_prob = yes_ask DecisionRow(
ts=time.time(),
if spread > float(cfg["max_spread_dollars"]): symbol=sym,
continue market_ticker=market_ticker,
close_time=market_full.get("close_time", ""),
if not (cfg["min_market_prob"] <= market_prob <= cfg["max_market_prob"]): side="",
continue prob=0.0,
yes_bid=0.0,
jump = abs( yes_ask=0.0,
spot.returns_over_window(sym, int(cfg["jump_lookback_seconds"])) spread=0.0,
stake_dollars=0.0,
count=0,
limit_cents=0,
reason="skip: missing yes/no ask fields",
)
) )
if jump > cfg["max_abs_jump"]:
continue continue
spot_px = spot.latest(sym) yes_bid, yes_ask, spread = _yes_bid_ask_spread(market_full)
sigma = spot.realized_vol(sym) if spread > max_spread:
storage.log_decision(
# IMPORTANT: settlement is the AVERAGE of the final 60 seconds DecisionRow(
time_remaining = max( ts=time.time(), symbol=sym, market_ticker=market_ticker,
60.0, close_time=market_full.get("close_time",""),
(close_time - now).total_seconds(), side="",
prob=0.0,
yes_bid=yes_bid, yes_ask=yes_ask, spread=spread,
stake_dollars=0.0, count=0, limit_cents=0,
reason=f"skip: spread {spread:.4f} > {max_spread:.4f}",
) )
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 continue
yes_ask_cents = dollars_to_cents_price(yes_ask) # Choose side(s) that qualify: any of the 6 outcomes (YES/NO for each symbol)
improve = int(cfg.get("limit_price_improve_cents", 0)) candidates: List[Tuple[str, float]] = []
limit_cents = max(1, yes_ask_cents - improve) if min_prob <= yes_p <= max_prob:
candidates.append(("yes", yes_p))
if min_prob <= no_p <= max_prob:
candidates.append(("no", no_p))
max_cost_cents = int(round(cfg["max_cost_dollars"] * 100)) if not candidates:
storage.log_decision(
DecisionRow(
ts=time.time(), symbol=sym, market_ticker=market_ticker,
close_time=market_full.get("close_time",""),
side="",
prob=max(yes_p, no_p),
yes_bid=yes_bid, yes_ask=yes_ask, spread=spread,
stake_dollars=0.0, count=0, limit_cents=0,
reason=f"skip: prob not in [{min_prob:.2f},{max_prob:.2f}] (yes={yes_p:.3f} no={no_p:.3f})",
)
)
continue
# If both somehow qualify (rare/unexpected), prefer the higher probability side
side, prob = sorted(candidates, key=lambda x: x[1], reverse=True)[0]
stake = _stake_for_prob(prob, tiers)
if stake is None:
storage.log_decision(
DecisionRow(
ts=time.time(), symbol=sym, market_ticker=market_ticker,
close_time=market_full.get("close_time",""),
side=side,
prob=prob,
yes_bid=yes_bid, yes_ask=yes_ask, spread=spread,
stake_dollars=0.0, count=0, limit_cents=0,
reason="skip: no tier matched prob",
)
)
continue
# Determine limit price (in cents) using ask for that side
if side == "yes":
ask = float(market_full.get("yes_ask_dollars") or (market_full.get("yes_ask", 99) / 100.0))
else:
ask = float(market_full.get("no_ask_dollars") or (market_full.get("no_ask", 99) / 100.0))
ask_cents = _dollars_to_cents_price(ask)
limit_cents = max(1, ask_cents - improve_cents)
# Size contracts to spend up to stake_dollars
max_cost_cents = int(round(stake * 100))
count = max_cost_cents // limit_cents count = max_cost_cents // limit_cents
if count <= 0: if count <= 0:
storage.log_decision(
DecisionRow(
ts=time.time(), symbol=sym, market_ticker=market_ticker,
close_time=market_full.get("close_time",""),
side=side, prob=prob,
yes_bid=yes_bid, yes_ask=yes_ask, spread=spread,
stake_dollars=stake, count=0, limit_cents=limit_cents,
reason="skip: count computed as 0",
)
)
continue continue
storage.log_decision( storage.log_decision(
ts=time.time(), DecisionRow(
symbol=sym, ts=time.time(), symbol=sym, market_ticker=market_ticker,
series_ticker="", close_time=market_full.get("close_time",""),
market_ticker=market_ticker, side=side, prob=prob,
close_time=m["close_time"], yes_bid=yes_bid, yes_ask=yes_ask, spread=spread,
strike=strike, stake_dollars=stake, count=count, limit_cents=limit_cents,
side="yes",
market_prob=market_prob,
fair_prob=fair,
edge=edge,
spread=spread,
jump=jump,
reason="trade", reason="trade",
) )
)
client_order_id = f"{sym}-{uuid.uuid4().hex[:10]}" client_order_id = f"{sym}-{side}-{uuid.uuid4().hex[:12]}"
if mode == "paper": if mode == "paper":
print( print(
f"[PAPER] {sym} {market_ticker} " f"[PAPER] {sym} {side.upper()} prob={prob:.3f} "
f"count={count} limit={limit_cents}c " f"{market_ticker} count={count} limit={limit_cents}c stake=${stake:.2f}"
f"edge={edge:.3f}"
) )
storage.log_order( storage.log_order(
market_ticker, market_ticker=market_ticker,
order_id=None,
mode="paper", mode="paper",
client_order_id=client_order_id,
order_id=None,
status="simulated", status="simulated",
details=f"count={count} limit={limit_cents} edge={edge:.4f}", details=json.dumps(
{"symbol": sym, "side": side, "prob": prob, "count": count, "limit_cents": limit_cents, "stake": stake}
),
) )
else: else:
order = {
"ticker": market_ticker,
"action": "buy",
"side": side,
"count": int(count),
"type": "limit",
"client_order_id": client_order_id,
"time_in_force": tif,
}
if side == "yes":
order["yes_price"] = int(limit_cents)
else:
order["no_price"] = int(limit_cents)
print( print(
f"[LIVE] {sym} {market_ticker} " f"[LIVE] {sym} {side.upper()} prob={prob:.3f} "
f"count={count} limit={limit_cents}c " f"{market_ticker} count={count} limit={limit_cents}c stake=${stake:.2f}"
f"edge={edge:.3f}"
) )
resp = client.create_order( resp = client.create_order(order)
ticker=market_ticker, o = resp.get("order", {})
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( storage.log_order(
market_ticker, market_ticker=market_ticker,
order_id=order.get("order_id"),
mode="live", mode="live",
status=order.get("status", "unknown"), client_order_id=client_order_id,
details=json.dumps(order)[:1500], order_id=o.get("order_id"),
status=o.get("status", "unknown"),
details=json.dumps(o)[:2000],
) )
last_traded_market[sym] = market_ticker last_traded_market[sym] = market_ticker
except Exception as e: except Exception as e:
print(f"[loop] error for {sym}: {e}") print(f"[loop] error sym={sym}: {e}")
print(f"[heartbeat] {dt.datetime.utcnow().isoformat()}Z")
time.sleep(2) time.sleep(poll_seconds)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,39 +1,32 @@
mode: paper # paper or live # paper = log-only (no orders placed)
# live = real orders placed
mode: paper
# Crypto symbols to trade
symbols: ["BTC", "ETH", "SOL"] symbols: ["BTC", "ETH", "SOL"]
series_title_contains: "Up or Down - 15 minutes"
category: "crypto"
# When to fire relative to market close time # Timing
trade_window_seconds: 20 # only trade within this window starting at (close_time - lead_seconds) lead_seconds: 360 # 6 minutes before close
lead_seconds: 360 # 6 minutes trade_window_seconds: 25 # only attempt inside this window (prevents spam)
# Guardrails # Probability filter (derived from ask price)
min_market_prob: 0.80 min_prob: 0.70
max_market_prob: 0.97 max_prob: 0.95
min_edge: 0.05 # fair_prob - market_prob
max_spread_dollars: 0.02
# Volatility / tail-risk controls (spot feed) # Bet sizing tiers based on implied probability
vol_lookback_seconds: 900 # 15 minutes of spot history # stake_dollars is total max cost you want to spend on that bet
jump_lookback_seconds: 120 # 2 minutes tiers:
max_abs_jump: 0.0015 # 0.15% over jump_lookback_seconds - { min: 0.70, max: 0.80, stake_dollars: 1.00 }
- { min: 0.80, max: 0.90, stake_dollars: 2.00 }
- { min: 0.90, max: 0.95, stake_dollars: 2.00 }
# Position sizing # Microstructure guardrails (optional but recommended)
max_cost_dollars: 2.00 # cap per trade (uses buy_max_cost) max_spread_dollars: 0.08 # skip if yes_ask - yes_bid > this (wide spreads = junk/illiquid)
# Risk limits # Order placement behavior
daily_loss_limit: 10.0 time_in_force: "fill_or_kill" # avoids hanging orders
max_consecutive_losses: 3 improve_cents: 0 # pay ask by default; set 1 to try to improve by 1c
# Pricing # Logging / persistence
limit_price_improve_cents: 0 # 0 = use current yes_ask; >0 = bid cheaper by N cents sqlite_path: "storage.sqlite"
poll_seconds: 2
# Coinbase spot feed endpoints (simple + free)
coinbase:
BTC: "https://api.coinbase.com/v2/prices/BTC-USD/spot"
ETH: "https://api.coinbase.com/v2/prices/ETH-USD/spot"
SOL: "https://api.coinbase.com/v2/prices/SOL-USD/spot"
#testing

View File

@@ -1,38 +0,0 @@
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,148 +1,118 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import dataclasses
import datetime as dt import datetime as dt
import os import os
import time from dataclasses import dataclass
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from urllib.parse import urljoin
import requests import requests
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa from cryptography.hazmat.primitives.asymmetric import padding
@dataclasses.dataclass(frozen=True) @dataclass(frozen=True)
class KalshiConfig: class KalshiConfig:
env: str # "demo" or "prod" env: str # "demo" or "prod"
api_key_id: str
private_key_path: str
@property
def base_url(self) -> str:
# Docs show demo at demo-api.kalshi.co and public market data at api.elections.kalshi.com.
# Well use:
# - demo trading: https://demo-api.kalshi.co
# - prod/public: https://api.elections.kalshi.com
if self.env.lower() == "demo":
return "https://demo-api.kalshi.co"
return "https://api.elections.kalshi.com"
def _load_private_key_from_file(file_path: str) -> rsa.RSAPrivateKey: class KalshiClient:
with open(file_path, "rb") as key_file: """
private_key = serialization.load_pem_private_key( Minimal Kalshi REST client (public market data + authenticated trading).
key_file.read(), Auth scheme: KALSHI-ACCESS-KEY, KALSHI-ACCESS-TIMESTAMP (ms), KALSHI-ACCESS-SIGNATURE
password=None, where signature is RSA-PSS-SHA256 of: timestamp + METHOD + path_without_query. :contentReference[oaicite:1]{index=1}
backend=default_backend(), """
)
if not isinstance(private_key, rsa.RSAPrivateKey):
raise ValueError("Private key is not an RSA private key")
return private_key
def __init__(self, cfg: KalshiConfig, session: Optional[requests.Session] = None):
self.cfg = cfg
self.session = session or requests.Session()
def _sign_pss_text(private_key: rsa.RSAPrivateKey, text: str) -> str: # Base URL differs for demo vs prod per Kalshi quick-start docs. :contentReference[oaicite:2]{index=2}
message = text.encode("utf-8") if cfg.env.lower() == "demo":
signature = private_key.sign( self.base_url = "https://demo-api.kalshi.co"
message, elif cfg.env.lower() in ("prod", "production"):
self.base_url = "https://api.kalshi.com"
else:
raise ValueError("KALSHI_ENV must be 'demo' or 'prod'")
# Auth fields are optional in paper mode; only required when placing orders
self.api_key_id = os.getenv("KALSHI_API_KEY_ID")
self.private_key_path = os.getenv("KALSHI_PRIVATE_KEY_PATH")
self._private_key = None
def _load_private_key(self) -> None:
if self._private_key is not None:
return
if not self.private_key_path:
raise RuntimeError("Missing KALSHI_PRIVATE_KEY_PATH (did you source .env?)")
with open(self.private_key_path, "rb") as f:
self._private_key = serialization.load_pem_private_key(f.read(), password=None)
def _timestamp_ms(self) -> str:
return str(int(dt.datetime.now(dt.timezone.utc).timestamp() * 1000))
def _sign(self, timestamp_ms: str, method: str, path: str) -> str:
"""
Sign timestamp + METHOD + path_without_query using RSA-PSS(SHA256), return base64 signature.
:contentReference[oaicite:3]{index=3}
"""
self._load_private_key()
assert self._private_key is not None
path_wo_query = path.split("?", 1)[0]
msg = f"{timestamp_ms}{method.upper()}{path_wo_query}".encode("utf-8")
sig = self._private_key.sign(
msg,
padding.PSS( padding.PSS(
mgf=padding.MGF1(hashes.SHA256()), mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.DIGEST_LENGTH, salt_length=padding.PSS.DIGEST_LENGTH,
), ),
hashes.SHA256(), hashes.SHA256(),
) )
return base64.b64encode(signature).decode("utf-8") return base64.b64encode(sig).decode("utf-8")
class KalshiClient:
"""
Minimal REST client using Kalshi's signed headers:
KALSHI-ACCESS-KEY, KALSHI-ACCESS-TIMESTAMP (ms), KALSHI-ACCESS-SIGNATURE
Signature = RSA-PSS(SHA256) over: timestamp + METHOD + path_without_query
"""
def __init__(self, cfg: KalshiConfig, session: Optional[requests.Session] = None):
self.cfg = cfg
self.session = session or requests.Session()
self._private_key = _load_private_key_from_file(cfg.private_key_path)
@staticmethod
def from_env() -> "KalshiClient":
env = os.getenv("KALSHI_ENV", "demo")
api_key_id = os.environ["KALSHI_API_KEY_ID"]
key_path = os.environ["KALSHI_PRIVATE_KEY_PATH"]
return KalshiClient(KalshiConfig(env=env, api_key_id=api_key_id, private_key_path=key_path))
def _auth_headers(self, method: str, path: str) -> Dict[str, str]: def _auth_headers(self, method: str, path: str) -> Dict[str, str]:
ts = str(int(time.time() * 1000)) if not self.api_key_id:
path_wo_query = path.split("?")[0] raise RuntimeError("Missing KALSHI_API_KEY_ID (did you source .env?)")
msg = f"{ts}{method.upper()}{path_wo_query}" ts = self._timestamp_ms()
sig = _sign_pss_text(self._private_key, msg) sig = self._sign(ts, method, path)
return { return {
"KALSHI-ACCESS-KEY": self.cfg.api_key_id, "KALSHI-ACCESS-KEY": self.api_key_id,
"KALSHI-ACCESS-TIMESTAMP": ts, "KALSHI-ACCESS-TIMESTAMP": ts,
"KALSHI-ACCESS-SIGNATURE": sig, "KALSHI-ACCESS-SIGNATURE": sig,
} }
def _request(self, method: str, path: str, *, params: Optional[dict] = None, json: Optional[dict] = None, auth: bool = False) -> Dict[str, Any]: def get_public(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
url = self.cfg.base_url + path url = urljoin(self.base_url, path)
headers: Dict[str, str] = {"Content-Type": "application/json"} r = self.session.get(url, params=params, timeout=20)
if auth: r.raise_for_status()
headers.update(self._auth_headers(method, path)) return r.json()
resp = self.session.request(method=method, url=url, params=params, json=json, headers=headers, timeout=15) def get_authed(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if resp.status_code >= 400: url = urljoin(self.base_url, path)
raise RuntimeError(f"Kalshi API error {resp.status_code}: {resp.text}") headers = self._auth_headers("GET", path)
if resp.status_code == 204: r = self.session.get(url, headers=headers, params=params, timeout=20)
return {} r.raise_for_status()
return resp.json() return r.json()
# ---- Public market-data endpoints (no auth required in docs) ---- def post_authed(self, path: str, json_body: Dict[str, Any]) -> Dict[str, Any]:
def get_series_list(self, *, category: str) -> Dict[str, Any]: url = urljoin(self.base_url, path)
return self._request("GET", "/trade-api/v2/series", params={"category": category}, auth=False) headers = self._auth_headers("POST", path)
headers["Content-Type"] = "application/json"
r = self.session.post(url, headers=headers, json=json_body, timeout=20)
r.raise_for_status()
return r.json()
def get_markets(self, *, series_ticker: str, status: str = "open", limit: int = 200) -> Dict[str, Any]: # Convenience wrappers
params = {"series_ticker": series_ticker, "status": status, "limit": limit} def list_open_markets(self, limit: int = 1000) -> Dict[str, Any]:
return self._request("GET", "/trade-api/v2/markets", params=params, auth=False) # public market data endpoint per quick-start :contentReference[oaicite:4]{index=4}
return self.get_public("/trade-api/v2/markets", params={"status": "open", "limit": str(limit)})
def get_market(self, ticker: str) -> Dict[str, Any]: def get_market(self, ticker: str) -> Dict[str, Any]:
return self._request("GET", f"/trade-api/v2/markets/{ticker}", auth=False) return self.get_public(f"/trade-api/v2/markets/{ticker}")
# ---- Trading endpoints (auth required) ---- def create_order(self, order_data: Dict[str, Any]) -> Dict[str, Any]:
def create_order( # POST /trade-api/v2/portfolio/orders per quick-start :contentReference[oaicite:5]{index=5}
self, return self.post_authed("/trade-api/v2/portfolio/orders", order_data)
*,
ticker: str,
side: str,
action: str,
count: int,
yes_price_cents: Optional[int] = None,
no_price_cents: Optional[int] = None,
buy_max_cost_cents: Optional[int] = None,
time_in_force: str = "fill_or_kill",
client_order_id: Optional[str] = None,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"ticker": ticker,
"side": side, # "yes" or "no"
"action": action, # "buy" or "sell"
"count": int(count),
"type": "limit",
"time_in_force": time_in_force,
}
if client_order_id:
payload["client_order_id"] = client_order_id
if yes_price_cents is not None:
payload["yes_price"] = int(yes_price_cents)
if no_price_cents is not None:
payload["no_price"] = int(no_price_cents)
if buy_max_cost_cents is not None:
payload["buy_max_cost"] = int(buy_max_cost_cents)
return self._request("POST", "/trade-api/v2/portfolio/orders", json=payload, auth=True)
def get_order(self, order_id: str) -> Dict[str, Any]:
return self._request("GET", f"/trade-api/v2/portfolio/orders/{order_id}", auth=True)
def cancel_order(self, order_id: str) -> Dict[str, Any]:
return self._request("DELETE", f"/trade-api/v2/portfolio/orders/{order_id}", auth=True)

View File

@@ -1,4 +1,5 @@
cryptography>=42.0.0
requests>=2.31.0 requests>=2.31.0
PyYAML>=6.0.1 PyYAML>=6.0.1
s cryptography>=41.0.0
sds

35
risk.py
View File

@@ -1,35 +0,0 @@
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

@@ -1,98 +0,0 @@
from __future__ import annotations
import time
from collections import deque
from dataclasses import dataclass
from typing import Deque, Dict, Tuple
import certifi
import requests
@dataclass
class SpotPoint:
ts: float
price: float
class SpotFeed:
"""
Simple polling spot feed (Coinbase spot). Keeps rolling history for volatility + jump checks.
"""
def __init__(self, urls: Dict[str, str], lookback_seconds: int):
self.urls = urls
self.lookback_seconds = lookback_seconds
self.history: Dict[str, Deque[SpotPoint]] = {sym: deque() for sym in urls.keys()}
def _fetch(self, url: str) -> float:
r = requests.get(url, timeout=10, verify=certifi.where())
r.raise_for_status()
data = r.json()
return float(data["data"]["amount"])
def update(self) -> Dict[str, float]:
now = time.time()
out: Dict[str, float] = {}
for sym, url in self.urls.items():
px = self._fetch(url)
out[sym] = px
dq = self.history[sym]
dq.append(SpotPoint(ts=now, price=px))
# trim
cutoff = now - self.lookback_seconds
while dq and dq[0].ts < cutoff:
dq.popleft()
return out
def latest(self, sym: str) -> float:
dq = self.history[sym]
if not dq:
raise RuntimeError(f"No spot data yet for {sym}")
return dq[-1].price
def returns_over_window(self, sym: str, window_seconds: int) -> float:
dq = self.history[sym]
if len(dq) < 2:
return 0.0
now = dq[-1].ts
cutoff = now - window_seconds
# find earliest point >= cutoff
base = dq[0]
for p in dq:
if p.ts >= cutoff:
base = p
break
if base.price <= 0:
return 0.0
return (dq[-1].price / base.price) - 1.0
def realized_vol(self, sym: str) -> float:
"""
Very conservative realized vol estimate from simple returns in the stored history.
Returns a per-second sigma (not annualized).
"""
dq = self.history[sym]
if len(dq) < 3:
return 0.0
rets = []
for i in range(1, len(dq)):
p0 = dq[i - 1].price
p1 = dq[i].price
if p0 > 0:
rets.append((p1 / p0) - 1.0)
if len(rets) < 2:
return 0.0
# compute stddev of returns per sample
mean = sum(rets) / len(rets)
var = sum((r - mean) ** 2 for r in rets) / (len(rets) - 1)
# Estimate average sampling interval
dt_avg = (dq[-1].ts - dq[0].ts) / max(1, (len(dq) - 1))
if dt_avg <= 0:
return 0.0
# Convert return std per sample to per-second sigma
std_per_sample = var ** 0.5
sigma_per_second = std_per_sample / (dt_avg ** 0.5)
return sigma_per_second

View File

@@ -2,63 +2,110 @@ from __future__ import annotations
import sqlite3 import sqlite3
import time import time
from typing import Any, Dict, Optional from dataclasses import dataclass
from typing import Optional
@dataclass
class DecisionRow:
ts: float
symbol: str
market_ticker: str
close_time: str
side: str
prob: float
yes_bid: float
yes_ask: float
spread: float
stake_dollars: float
count: int
limit_cents: int
reason: str
class Storage: class Storage:
def __init__(self, path: str = "storage.sqlite"): def __init__(self, path: str):
self.conn = sqlite3.connect(path) self.path = path
self.conn.execute("PRAGMA journal_mode=WAL;")
self._init() self._init()
def _init(self) -> None: def _init(self) -> None:
self.conn.execute( con = sqlite3.connect(self.path)
cur = con.cursor()
cur.execute(
""" """
CREATE TABLE IF NOT EXISTS decisions ( CREATE TABLE IF NOT EXISTS decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL,
ts REAL NOT NULL, symbol TEXT,
symbol TEXT NOT NULL, market_ticker TEXT,
series_ticker TEXT NOT NULL, close_time TEXT,
market_ticker TEXT NOT NULL, side TEXT,
close_time TEXT NOT NULL, prob REAL,
strike REAL NOT NULL, yes_bid REAL,
side TEXT NOT NULL, yes_ask REAL,
market_prob REAL NOT NULL, spread REAL,
fair_prob REAL NOT NULL, stake_dollars REAL,
edge REAL NOT NULL, count INTEGER,
spread REAL NOT NULL, limit_cents INTEGER,
jump REAL NOT NULL, reason TEXT
reason TEXT NOT NULL
) )
""" """
) )
self.conn.execute( cur.execute(
""" """
CREATE TABLE IF NOT EXISTS orders ( CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL,
ts REAL NOT NULL, market_ticker TEXT,
market_ticker TEXT NOT NULL, mode TEXT,
client_order_id TEXT,
order_id TEXT, order_id TEXT,
mode TEXT NOT NULL, status TEXT,
status TEXT NOT NULL,
details TEXT details TEXT
) )
""" """
) )
self.conn.commit() con.commit()
con.close()
def log_decision(self, **row: Any) -> None: def log_decision(self, row: DecisionRow) -> None:
cols = ",".join(row.keys()) con = sqlite3.connect(self.path)
qs = ",".join(["?"] * len(row)) cur = con.cursor()
self.conn.execute(f"INSERT INTO decisions ({cols}) VALUES ({qs})", list(row.values())) cur.execute(
self.conn.commit() """
INSERT INTO decisions VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
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 (?, ?, ?, ?, ?, ?)", row.ts,
(time.time(), market_ticker, order_id, mode, status, details), row.symbol,
row.market_ticker,
row.close_time,
row.side,
row.prob,
row.yes_bid,
row.yes_ask,
row.spread,
row.stake_dollars,
row.count,
row.limit_cents,
row.reason,
),
) )
self.conn.commit() con.commit()
con.close()
def close(self) -> None: def log_order(
self.conn.close() self,
market_ticker: str,
mode: str,
client_order_id: str,
order_id: Optional[str],
status: str,
details: str,
) -> None:
con = sqlite3.connect(self.path)
cur = con.cursor()
cur.execute(
"INSERT INTO orders VALUES (?,?,?,?,?,?,?)",
(time.time(), market_ticker, mode, client_order_id, order_id, status, details),
)
con.commit()
con.close()