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 time
import uuid
from typing import Dict, Tuple
from dataclasses import dataclass
from typing import Dict, List, Optional, 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
from kalshi_client import KalshiClient, KalshiConfig
from storage import Storage, DecisionRow
# ---------- helpers ----------
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"))
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]:
def _market_prob_from_asks(m: dict) -> Tuple[Optional[float], Optional[float]]:
"""
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":
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]}")
yes_p = read_prob("yes")
no_p = read_prob("no")
return yes_p, no_p
def discover_open_crypto_15m_markets(
client: KalshiClient,
symbols: list[str],
) -> dict[str, dict]:
def _yes_bid_ask_spread(m: dict) -> Tuple[float, float, float]:
def read(prefix: str, side: str) -> Optional[float]:
cents_key = f"{prefix}_{side}"
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)
markets = data.get("markets", [])
data = client.list_open_markets(limit=1000)
markets = data.get("markets") or []
now = dt.datetime.now(dt.timezone.utc)
per_symbol: dict[str, dict] = {}
@@ -62,197 +82,245 @@ def discover_open_crypto_15m_markets(
for m in markets:
title = (m.get("title") or "").upper()
# Heuristics: 15-min, Up/Down, and the symbol present
if "UP OR DOWN" not in title:
continue
if "15" not in title:
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:
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"]):
if prev is None or _parse_iso_z(prev["close_time"]) > 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)
cfg = yaml.safe_load(open("config.yaml", "r"))
mode = cfg["mode"] # "paper" or "live"
mode = cfg["mode"].lower()
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()
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"],
)
client = KalshiClient(KalshiConfig(env=str((__import__("os").getenv("KALSHI_ENV") or "demo"))))
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:
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)
print(f"[heartbeat] {now.isoformat().replace('+00:00','Z')}")
try:
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():
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
# Fire only in a narrow window at T - lead_seconds
start = close_time - dt.timedelta(seconds=lead_seconds)
end = start + dt.timedelta(seconds=trade_window_seconds)
if not (start <= now <= end):
continue
# Dedup per symbol per market
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)
# Pull full market object (more reliable fields)
market_full = client.get_market(market_ticker).get("market") or m
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"]))
yes_p, no_p = _market_prob_from_asks(market_full)
if yes_p is None or no_p is None:
storage.log_decision(
DecisionRow(
ts=time.time(),
symbol=sym,
market_ticker=market_ticker,
close_time=market_full.get("close_time", ""),
side="",
prob=0.0,
yes_bid=0.0,
yes_ask=0.0,
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
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(),
yes_bid, yes_ask, spread = _yes_bid_ask_spread(market_full)
if spread > max_spread:
storage.log_decision(
DecisionRow(
ts=time.time(), symbol=sym, market_ticker=market_ticker,
close_time=market_full.get("close_time",""),
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
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)
# Choose side(s) that qualify: any of the 6 outcomes (YES/NO for each symbol)
candidates: List[Tuple[str, float]] = []
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
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
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,
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=count, limit_cents=limit_cents,
reason="trade",
)
)
client_order_id = f"{sym}-{uuid.uuid4().hex[:10]}"
client_order_id = f"{sym}-{side}-{uuid.uuid4().hex[:12]}"
if mode == "paper":
print(
f"[PAPER] {sym} {market_ticker} "
f"count={count} limit={limit_cents}c "
f"edge={edge:.3f}"
f"[PAPER] {sym} {side.upper()} prob={prob:.3f} "
f"{market_ticker} count={count} limit={limit_cents}c stake=${stake:.2f}"
)
storage.log_order(
market_ticker,
order_id=None,
market_ticker=market_ticker,
mode="paper",
client_order_id=client_order_id,
order_id=None,
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:
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(
f"[LIVE] {sym} {market_ticker} "
f"count={count} limit={limit_cents}c "
f"edge={edge:.3f}"
f"[LIVE] {sym} {side.upper()} prob={prob:.3f} "
f"{market_ticker} count={count} limit={limit_cents}c stake=${stake:.2f}"
)
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", {})
resp = client.create_order(order)
o = resp.get("order", {})
storage.log_order(
market_ticker,
order_id=order.get("order_id"),
market_ticker=market_ticker,
mode="live",
status=order.get("status", "unknown"),
details=json.dumps(order)[:1500],
client_order_id=client_order_id,
order_id=o.get("order_id"),
status=o.get("status", "unknown"),
details=json.dumps(o)[:2000],
)
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)
print(f"[loop] error sym={sym}: {e}")
time.sleep(poll_seconds)
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"]
series_title_contains: "Up or Down - 15 minutes"
category: "crypto"
# When to fire relative to market close time
trade_window_seconds: 20 # only trade within this window starting at (close_time - lead_seconds)
lead_seconds: 360 # 6 minutes
# Timing
lead_seconds: 360 # 6 minutes before close
trade_window_seconds: 25 # only attempt inside this window (prevents spam)
# Guardrails
min_market_prob: 0.80
max_market_prob: 0.97
min_edge: 0.05 # fair_prob - market_prob
max_spread_dollars: 0.02
# Probability filter (derived from ask price)
min_prob: 0.70
max_prob: 0.95
# Volatility / tail-risk controls (spot feed)
vol_lookback_seconds: 900 # 15 minutes of spot history
jump_lookback_seconds: 120 # 2 minutes
max_abs_jump: 0.0015 # 0.15% over jump_lookback_seconds
# Bet sizing tiers based on implied probability
# stake_dollars is total max cost you want to spend on that bet
tiers:
- { 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
max_cost_dollars: 2.00 # cap per trade (uses buy_max_cost)
# Microstructure guardrails (optional but recommended)
max_spread_dollars: 0.08 # skip if yes_ask - yes_bid > this (wide spreads = junk/illiquid)
# Risk limits
daily_loss_limit: 10.0
max_consecutive_losses: 3
# Order placement behavior
time_in_force: "fill_or_kill" # avoids hanging orders
improve_cents: 0 # pay ask by default; set 1 to try to improve by 1c
# Pricing
limit_price_improve_cents: 0 # 0 = use current yes_ask; >0 = bid cheaper by N cents
# 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
# Logging / persistence
sqlite_path: "storage.sqlite"
poll_seconds: 2

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
import base64
import dataclasses
import datetime as dt
import os
import time
from dataclasses import dataclass
from typing import Any, Dict, Optional
from urllib.parse import urljoin
import requests
from cryptography.hazmat.backends import default_backend
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:
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:
with open(file_path, "rb") as key_file:
private_key = serialization.load_pem_private_key(
key_file.read(),
password=None,
backend=default_backend(),
)
if not isinstance(private_key, rsa.RSAPrivateKey):
raise ValueError("Private key is not an RSA private key")
return private_key
class KalshiClient:
"""
Minimal Kalshi REST client (public market data + authenticated trading).
Auth scheme: KALSHI-ACCESS-KEY, KALSHI-ACCESS-TIMESTAMP (ms), KALSHI-ACCESS-SIGNATURE
where signature is RSA-PSS-SHA256 of: timestamp + METHOD + path_without_query. :contentReference[oaicite:1]{index=1}
"""
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:
message = text.encode("utf-8")
signature = private_key.sign(
message,
# Base URL differs for demo vs prod per Kalshi quick-start docs. :contentReference[oaicite:2]{index=2}
if cfg.env.lower() == "demo":
self.base_url = "https://demo-api.kalshi.co"
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(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.DIGEST_LENGTH,
),
hashes.SHA256(),
)
return base64.b64encode(signature).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))
return base64.b64encode(sig).decode("utf-8")
def _auth_headers(self, method: str, path: str) -> Dict[str, str]:
ts = str(int(time.time() * 1000))
path_wo_query = path.split("?")[0]
msg = f"{ts}{method.upper()}{path_wo_query}"
sig = _sign_pss_text(self._private_key, msg)
if not self.api_key_id:
raise RuntimeError("Missing KALSHI_API_KEY_ID (did you source .env?)")
ts = self._timestamp_ms()
sig = self._sign(ts, method, path)
return {
"KALSHI-ACCESS-KEY": self.cfg.api_key_id,
"KALSHI-ACCESS-KEY": self.api_key_id,
"KALSHI-ACCESS-TIMESTAMP": ts,
"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]:
url = self.cfg.base_url + path
headers: Dict[str, str] = {"Content-Type": "application/json"}
if auth:
headers.update(self._auth_headers(method, path))
def get_public(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
url = urljoin(self.base_url, path)
r = self.session.get(url, params=params, timeout=20)
r.raise_for_status()
return r.json()
resp = self.session.request(method=method, url=url, params=params, json=json, headers=headers, timeout=15)
if resp.status_code >= 400:
raise RuntimeError(f"Kalshi API error {resp.status_code}: {resp.text}")
if resp.status_code == 204:
return {}
return resp.json()
def get_authed(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
url = urljoin(self.base_url, path)
headers = self._auth_headers("GET", path)
r = self.session.get(url, headers=headers, params=params, timeout=20)
r.raise_for_status()
return r.json()
# ---- Public market-data endpoints (no auth required in docs) ----
def get_series_list(self, *, category: str) -> Dict[str, Any]:
return self._request("GET", "/trade-api/v2/series", params={"category": category}, auth=False)
def post_authed(self, path: str, json_body: Dict[str, Any]) -> Dict[str, Any]:
url = urljoin(self.base_url, path)
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]:
params = {"series_ticker": series_ticker, "status": status, "limit": limit}
return self._request("GET", "/trade-api/v2/markets", params=params, auth=False)
# Convenience wrappers
def list_open_markets(self, limit: int = 1000) -> Dict[str, Any]:
# 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]:
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,
*,
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)
def create_order(self, order_data: Dict[str, Any]) -> Dict[str, Any]:
# POST /trade-api/v2/portfolio/orders per quick-start :contentReference[oaicite:5]{index=5}
return self.post_authed("/trade-api/v2/portfolio/orders", order_data)

View File

@@ -1,4 +1,5 @@
cryptography>=42.0.0
requests>=2.31.0
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 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:
def __init__(self, path: str = "storage.sqlite"):
self.conn = sqlite3.connect(path)
self.conn.execute("PRAGMA journal_mode=WAL;")
def __init__(self, path: str):
self.path = path
self._init()
def _init(self) -> None:
self.conn.execute(
con = sqlite3.connect(self.path)
cur = con.cursor()
cur.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
ts REAL,
symbol TEXT,
market_ticker TEXT,
close_time TEXT,
side TEXT,
prob REAL,
yes_bid REAL,
yes_ask REAL,
spread REAL,
stake_dollars REAL,
count INTEGER,
limit_cents INTEGER,
reason TEXT
)
"""
)
self.conn.execute(
cur.execute(
"""
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts REAL NOT NULL,
market_ticker TEXT NOT NULL,
ts REAL,
market_ticker TEXT,
mode TEXT,
client_order_id TEXT,
order_id TEXT,
mode TEXT NOT NULL,
status TEXT NOT NULL,
status TEXT,
details TEXT
)
"""
)
self.conn.commit()
con.commit()
con.close()
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),
def log_decision(self, row: DecisionRow) -> None:
con = sqlite3.connect(self.path)
cur = con.cursor()
cur.execute(
"""
INSERT INTO decisions VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
""",
(
row.ts,
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:
self.conn.close()
def log_order(
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()