itf-tauric-trading-floor / event_executor.py
TESTforge42's picture
deploy: event_executor.py
60f275c verified
Raw
History Blame Contribute Delete
13.1 kB
"""Prediction-market event executor — Kalshi + Polymarket, paper mode.
Both venues are READ live (free public APIs — no auth needed for market data):
- Kalshi: https://trading-api.kalshi.com/trade-api/v2/markets
- Polymarket: https://gamma-api.polymarket.com/markets
Fills are SIMULATED at midpoint (+slippage) and kept in a paper ledger. Live
execution is gated behind env flags (KALSHI_LIVE=1 / POLY_LIVE=1) — neither
is on by default because Polymarket trading requires USDC+gas on Polygon and
Kalshi requires signed API credentials.
Positions survive across ticks, are marked to market via current midpoint,
and close on agent-close / stop / take-profit / EOD flatten.
Public surface:
list_markets(venue, limit) → [ {market_id, question, yes_price, no_price, volume_usd, close_ts} ]
place_paper_order(agent_tid, venue, market_id, side, size_usd, thesis) → dict entry or None
close_paper_position(agent_tid, venue, market_id) → dict entry with realized P&L
mark_to_market(agent_tid) → {unrealized: float, open_n: int}
realized_pnl(agent_tid) → float (all-time)
load_positions() → raw ledger
"""
from __future__ import annotations
import json
import os
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import urllib.request
import urllib.error
_HERE = Path(__file__).resolve()
try:
_REPO = _HERE.parents[3]
except IndexError:
_REPO = _HERE.parent
_LEDGER = _REPO / "data" / "intraday" / "event_positions.json"
_LEDGER.parent.mkdir(parents=True, exist_ok=True)
# 2026-04-22 — trading-api.kalshi.com/* returns 401 without signed creds. The
# public election API serves identical market data with no auth (verified live).
_KALSHI_BASE = "https://api.elections.kalshi.com/trade-api/v2"
_POLY_BASE = "https://gamma-api.polymarket.com"
_MARKET_CACHE: Dict[str, Any] = {"kalshi": {"ts": 0, "rows": []}, "polymarket": {"ts": 0, "rows": []}}
_CACHE_TTL_S = 60 # refresh markets once per minute
PAPER_SLIPPAGE = 0.01 # 1¢ slippage on a 0-1 price — conservative for prediction markets
MIN_EVENT_STAKE_USD = 5.0 # sanity floor; prediction markets pay 0-100¢ on $1
MAX_EVENT_STAKE_USD = 5000.0 # per single event position
def _http_get_json(url: str, timeout: float = 6.0) -> Optional[Any]:
try:
req = urllib.request.Request(url, headers={"User-Agent": "nomos42-itf/1.0"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read())
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError):
return None
def _kalshi_markets(limit: int = 30) -> List[Dict[str, Any]]:
# Kalshi modernized 2025 — fields moved to *_dollars (already in USD, not cents).
# Older yes_bid/no_bid are deprecated. Also request a larger page + tiered filters
# so we return enough quotable binaries after dropping mid-less contracts.
data = _http_get_json(f"{_KALSHI_BASE}/markets?status=open&limit={max(limit, 100)}")
if not data:
return []
rows: List[Dict[str, Any]] = []
def _f(v: Any) -> Optional[float]:
if v is None:
return None
try:
return float(v)
except (TypeError, ValueError):
return None
for m in (data.get("markets") or []):
yb = _f(m.get("yes_bid_dollars"))
ya = _f(m.get("yes_ask_dollars"))
nb = _f(m.get("no_bid_dollars"))
na = _f(m.get("no_ask_dollars"))
if yb is None:
raw = _f(m.get("yes_bid"))
yb = (raw / 100.0) if raw is not None else None
if ya is None:
raw = _f(m.get("yes_ask"))
ya = (raw / 100.0) if raw is not None else None
last = _f(m.get("last_price_dollars"))
if last is None:
raw = _f(m.get("last_price"))
last = (raw / 100.0) if raw else None
# Midpoint preference: (yb+ya)/2 > ya > last > yb. Skip markets with no price.
if yb is not None and ya is not None and yb > 0 and ya > 0:
yes_price = (yb + ya) / 2.0
elif ya is not None and ya > 0:
yes_price = ya
elif last is not None and last > 0:
yes_price = last
elif yb is not None and yb > 0:
yes_price = yb
else:
yes_price = None
if nb is not None and na is not None and nb > 0 and na > 0:
no_price = (nb + na) / 2.0
elif na is not None and na > 0:
no_price = na
elif yes_price is not None:
no_price = 1.0 - yes_price
else:
no_price = None
vol = _f(m.get("notional_value_dollars")) or _f(m.get("liquidity_dollars")) or _f(m.get("volume")) or 0.0
rows.append({
"venue": "kalshi",
"market_id": m.get("ticker"),
"question": (m.get("title") or m.get("subtitle") or m.get("yes_sub_title") or "")[:220],
"yes_price": yes_price,
"no_price": no_price,
"volume_usd": float(vol),
"close_ts": m.get("close_time") or m.get("expected_expiration_time"),
})
usable = [r for r in rows if r["yes_price"] is not None and 0 < r["yes_price"] < 1]
# Highest-liquidity first so the prompt slot goes to the most tradeable markets.
usable.sort(key=lambda r: r["volume_usd"], reverse=True)
return usable[:limit]
def _polymarket_markets(limit: int = 30) -> List[Dict[str, Any]]:
data = _http_get_json(f"{_POLY_BASE}/markets?active=true&closed=false&limit={limit}&order=volume24hr&ascending=false")
if not isinstance(data, list):
return []
rows: List[Dict[str, Any]] = []
for m in data[:limit]:
try:
outcomes = json.loads(m.get("outcomePrices") or "[]")
if not (isinstance(outcomes, list) and len(outcomes) >= 2):
continue
yes_price = float(outcomes[0])
no_price = float(outcomes[1])
except (ValueError, TypeError):
continue
rows.append({
"venue": "polymarket",
"market_id": m.get("conditionId") or m.get("id"),
"question": (m.get("question") or "")[:220],
"yes_price": yes_price,
"no_price": no_price,
"volume_usd": float(m.get("volume24hr") or 0),
"close_ts": m.get("endDate"),
})
return rows
def list_markets(venue: str, limit: int = 20) -> List[Dict[str, Any]]:
"""Cached live feed of open markets for a venue."""
now = time.time()
cache = _MARKET_CACHE.get(venue) or {"ts": 0, "rows": []}
if now - cache["ts"] < _CACHE_TTL_S and cache["rows"]:
return cache["rows"][:limit]
rows = _kalshi_markets(limit=max(limit, 30)) if venue == "kalshi" else _polymarket_markets(limit=max(limit, 30))
_MARKET_CACHE[venue] = {"ts": now, "rows": rows}
return rows[:limit]
def _find_market(venue: str, market_id: str) -> Optional[Dict[str, Any]]:
for row in list_markets(venue, limit=50):
if row["market_id"] == market_id:
return row
return None
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def load_positions() -> Dict[str, List[Dict[str, Any]]]:
if not _LEDGER.exists():
return {}
try:
return json.loads(_LEDGER.read_text() or "{}")
except json.JSONDecodeError:
return {}
def _save_positions(ledger: Dict[str, List[Dict[str, Any]]]) -> None:
_LEDGER.write_text(json.dumps(ledger, indent=2))
def place_paper_order(
agent_tid: str,
venue: str,
market_id: str,
side: str,
size_usd: float,
thesis: str = "",
) -> Optional[Dict[str, Any]]:
"""Open a paper position at midpoint + slippage. Returns the ledger entry."""
side = (side or "").lower()
if side not in ("yes", "no"):
return {"status": "error", "reason": f"bad side: {side}"}
if venue not in ("kalshi", "polymarket"):
return {"status": "error", "reason": f"bad venue: {venue}"}
size_usd = max(MIN_EVENT_STAKE_USD, min(MAX_EVENT_STAKE_USD, float(size_usd or 0)))
market = _find_market(venue, market_id)
if not market:
return {"status": "error", "reason": f"market not found: {venue}:{market_id}"}
entry_price = market["yes_price"] if side == "yes" else market["no_price"]
if entry_price is None or entry_price <= 0 or entry_price >= 1:
return {"status": "error", "reason": f"bad midpoint: {entry_price}"}
entry_price = min(0.99, entry_price + PAPER_SLIPPAGE) # pay slippage on entry
contracts = size_usd / entry_price
entry = {
"venue": venue,
"market_id": market_id,
"question": market["question"],
"side": side,
"size_usd": round(size_usd, 2),
"entry_price": round(entry_price, 4),
"contracts": round(contracts, 4),
"entry_ts": _now_iso(),
"thesis": (thesis or "")[:300],
"status": "open",
"realized_pnl": 0.0,
"mode": "paper",
}
ledger = load_positions()
ledger.setdefault(agent_tid, []).append(entry)
_save_positions(ledger)
return entry
def close_paper_position(agent_tid: str, venue: str, market_id: str) -> Optional[Dict[str, Any]]:
"""Close a paper position at current midpoint. Returns the closed entry with realized P&L."""
ledger = load_positions()
positions = ledger.get(agent_tid) or []
for p in positions:
if (p.get("venue") == venue and p.get("market_id") == market_id
and p.get("status") == "open"):
market = _find_market(venue, market_id)
exit_price = (market["yes_price"] if p["side"] == "yes" else market["no_price"]) if market else p["entry_price"]
exit_price = max(0.01, min(0.99, float(exit_price or p["entry_price"])))
pnl = (exit_price - p["entry_price"]) * p["contracts"]
p["status"] = "closed"
p["exit_price"] = round(exit_price, 4)
p["exit_ts"] = _now_iso()
p["realized_pnl"] = round(pnl, 2)
_save_positions(ledger)
return p
return {"status": "error", "reason": f"no open position: {agent_tid} {venue}:{market_id}"}
def mark_to_market(agent_tid: str) -> Dict[str, float]:
"""Compute unrealized P&L + open notional across agent's event positions."""
positions = load_positions().get(agent_tid) or []
unrealized = 0.0
open_n = 0
for p in positions:
if p.get("status") != "open":
continue
market = _find_market(p["venue"], p["market_id"])
if not market:
continue
cur_price = market["yes_price"] if p["side"] == "yes" else market["no_price"]
if cur_price is None:
continue
unrealized += (float(cur_price) - p["entry_price"]) * p["contracts"]
open_n += 1
return {"unrealized_pnl": round(unrealized, 2), "open_positions": open_n}
def realized_pnl(agent_tid: str) -> float:
positions = load_positions().get(agent_tid) or []
return round(sum(float(p.get("realized_pnl") or 0) for p in positions if p.get("status") == "closed"), 2)
def agent_event_exposure(agent_tid: str) -> float:
"""Total USD currently locked in open event positions."""
positions = load_positions().get(agent_tid) or []
return round(sum(float(p.get("size_usd") or 0) for p in positions if p.get("status") == "open"), 2)
def expire_stale(now: Optional[datetime] = None) -> int:
"""Close any position whose market close_ts has passed. Returns count closed."""
now = now or datetime.now(timezone.utc)
ledger = load_positions()
closed = 0
for agent_tid, positions in ledger.items():
for p in positions:
if p.get("status") != "open":
continue
close_iso = p.get("close_ts")
if not close_iso:
continue
try:
close_dt = datetime.fromisoformat(close_iso.replace("Z", "+00:00"))
if close_dt.tzinfo is None:
close_dt = close_dt.replace(tzinfo=timezone.utc)
except (ValueError, AttributeError):
continue
if now >= close_dt:
market = _find_market(p["venue"], p["market_id"])
exit_price = (market["yes_price"] if p["side"] == "yes" else market["no_price"]) if market else p["entry_price"]
exit_price = max(0.01, min(0.99, float(exit_price or p["entry_price"])))
p["status"] = "closed"
p["exit_price"] = round(exit_price, 4)
p["exit_ts"] = _now_iso()
p["realized_pnl"] = round((exit_price - p["entry_price"]) * p["contracts"], 2)
p["close_reason"] = "market_expired"
closed += 1
if closed:
_save_positions(ledger)
return closed