Compare commits

..

17 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
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
10 changed files with 577 additions and 240 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"

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ __pycache__/
*.pyc
.env
storage.sqlite
kalshi.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

327
bot.py Normal file
View File

@@ -0,0 +1,327 @@
from __future__ import annotations
import datetime as dt
import json
import time
import uuid
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
import yaml
from kalshi_client import KalshiClient, KalshiConfig
from storage import Storage, DecisionRow
def _parse_iso_z(s: str) -> dt.datetime:
# example: "2026-02-10T15:29:16Z"
return dt.datetime.fromisoformat(s.replace("Z", "+00:00"))
def _market_prob_from_asks(m: dict) -> Tuple[Optional[float], Optional[float]]:
"""
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).
"""
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
yes_p = read_prob("yes")
no_p = read_prob("no")
return yes_p, no_p
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]:
"""
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.list_open_markets(limit=1000)
markets = data.get("markets") or []
now = dt.datetime.now(dt.timezone.utc)
per_symbol: dict[str, dict] = {}
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
prev = per_symbol.get(sym)
if prev is None or _parse_iso_z(prev["close_time"]) > close_time:
per_symbol[sym] = m
return per_symbol
def main() -> None:
cfg = yaml.safe_load(open("config.yaml", "r"))
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(KalshiConfig(env=str((__import__("os").getenv("KALSHI_ENV") or "demo"))))
print(f"[init] mode={mode} symbols={symbols} lead_seconds={lead_seconds} window={trade_window_seconds}s")
last_traded_market: Dict[str, str] = {} # sym -> market_ticker
while True:
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"])
# 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
# Pull full market object (more reliable fields)
market_full = client.get_market(market_ticker).get("market") or m
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",
)
)
continue
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}",
)
)
continue
# 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))
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(
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}-{side}-{uuid.uuid4().hex[:12]}"
if mode == "paper":
print(
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=market_ticker,
mode="paper",
client_order_id=client_order_id,
order_id=None,
status="simulated",
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} {side.upper()} prob={prob:.3f} "
f"{market_ticker} count={count} limit={limit_cents}c stake=${stake:.2f}"
)
resp = client.create_order(order)
o = resp.get("order", {})
storage.log_order(
market_ticker=market_ticker,
mode="live",
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 sym={sym}: {e}")
time.sleep(poll_seconds)
if __name__ == "__main__":
main()

View File

@@ -1,36 +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"
# Logging / persistence
sqlite_path: "storage.sqlite"
poll_seconds: 2

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
def _sign_pss_text(private_key: rsa.RSAPrivateKey, text: str) -> str:
message = text.encode("utf-8")
signature = private_key.sign(
message,
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
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()
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))
# 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(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,3 +1,5 @@
cryptography>=42.0.0
requests>=2.31.0
PyYAML>=6.0.1
cryptography>=41.0.0
sds

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 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)
r.raise_for_status()
data = r.json()
# Coinbase shape: {"data": {"amount": "70428.82", "currency": "USD"}}
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

111
storage.py Normal file
View File

@@ -0,0 +1,111 @@
from __future__ import annotations
import sqlite3
import time
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):
self.path = path
self._init()
def _init(self) -> None:
con = sqlite3.connect(self.path)
cur = con.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS decisions (
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
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS orders (
ts REAL,
market_ticker TEXT,
mode TEXT,
client_order_id TEXT,
order_id TEXT,
status TEXT,
details TEXT
)
"""
)
con.commit()
con.close()
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,
),
)
con.commit()
con.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()