Compare commits
7 Commits
e2d74bac46
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0470a951eb | |||
| 56512bcd5c | |||
| 0a6e5e3846 | |||
| 779c637a73 | |||
| d2c9dcab29 | |||
| 21d9596e8e | |||
| 681231ce04 |
8
.env.example
Normal file
8
.env.example
Normal 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
20
README.md
Normal 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:
|
||||||
|
- 70–80% => $1
|
||||||
|
- 80–90% => $2
|
||||||
|
- 90–95% => $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
374
bot.py
@@ -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 1–99 cents."""
|
|
||||||
return max(1, min(99, int(round(p * 100))))
|
|
||||||
|
|
||||||
|
|
||||||
def extract_strike_and_rule(market: dict) -> Tuple[float, bool]:
|
|
||||||
"""
|
"""
|
||||||
Returns (strike, resolves_yes_if_spot_ge_strike).
|
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: T−6 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__":
|
||||||
|
|||||||
55
config.yaml
55
config.yaml
@@ -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
|
|
||||||
|
|||||||
38
fair_prob.py
38
fair_prob.py
@@ -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))
|
|
||||||
194
kalshi_client.py
194
kalshi_client.py
@@ -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.
|
|
||||||
# We’ll 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)
|
|
||||||
|
|||||||
@@ -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
35
risk.py
@@ -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
|
|
||||||
98
spot_feed.py
98
spot_feed.py
@@ -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
|
|
||||||
125
storage.py
125
storage.py
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user