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 *.pyc
.env .env
storage.sqlite 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"] 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"

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:
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: class KalshiClient:
""" """
Minimal REST client using Kalshi's signed headers: Minimal Kalshi REST client (public market data + authenticated trading).
KALSHI-ACCESS-KEY, KALSHI-ACCESS-TIMESTAMP (ms), KALSHI-ACCESS-SIGNATURE Auth scheme: KALSHI-ACCESS-KEY, KALSHI-ACCESS-TIMESTAMP (ms), KALSHI-ACCESS-SIGNATURE
Signature = RSA-PSS(SHA256) over: timestamp + METHOD + path_without_query 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): def __init__(self, cfg: KalshiConfig, session: Optional[requests.Session] = None):
self.cfg = cfg self.cfg = cfg
self.session = session or requests.Session() self.session = session or requests.Session()
self._private_key = _load_private_key_from_file(cfg.private_key_path)
@staticmethod # Base URL differs for demo vs prod per Kalshi quick-start docs. :contentReference[oaicite:2]{index=2}
def from_env() -> "KalshiClient": if cfg.env.lower() == "demo":
env = os.getenv("KALSHI_ENV", "demo") self.base_url = "https://demo-api.kalshi.co"
api_key_id = os.environ["KALSHI_API_KEY_ID"] elif cfg.env.lower() in ("prod", "production"):
key_path = os.environ["KALSHI_PRIVATE_KEY_PATH"] self.base_url = "https://api.kalshi.com"
return KalshiClient(KalshiConfig(env=env, api_key_id=api_key_id, private_key_path=key_path)) 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]: 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,3 +1,5 @@
cryptography>=42.0.0
requests>=2.31.0 requests>=2.31.0
PyYAML>=6.0.1 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()