Files
kalshi-bot/bot.py

796 lines
41 KiB
Python

#!/usr/bin/env python3
"""
K4LSH1_OPS — autonomous 15-minute BTC up/down trading bot.
Momentum + local-Ollama ensemble, tiny stakes, full sqlite audit trail.
Modes: AUTO | DOWN_SPAM | UP_SPAM | OFF (persisted in config.json, hot-reloaded)
DRY_RUN=true simulates orders (still logs + tracks hypothetical P&L).
"""
import base64, json, logging, os, sqlite3, sys, time
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
HOME = Path(__file__).parent
CFG_PATH = HOME / "config.json"
DB_PATH = HOME / "state.db"
# per-coin Kraken pairs
KRAKEN_PAIRS = {"BTC": "XBTUSD", "ETH": "ETHUSD", "DOGE": "XDGUSD", "SOL": "SOLUSD", "XRP": "XRPUSD"}
LOG = logging.getLogger("bot")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s",
handlers=[logging.FileHandler(HOME/"bot.log"), logging.StreamHandler()])
DEFAULT_CFG = {
"api_key_id": "28d5876b-2ece-4aa3-aa17-ec96e1e706eb",
"key_path": str(HOME / "kalshi_private_key.pem"),
"mode": "AUTO", # AUTO | DOWN_SPAM | UP_SPAM | OFF
"dry_run": True, # simulated until flipped from dashboard
"stake_cents": 50, # taker: max ask we'll pay; maker: price we post
"post_price_cents": 45, # maker mode: resting bid price when book is empty
"max_contracts": 1,
"daily_loss_cap_cents": 500,
"min_conf": 0.55, # AUTO mode: min ensemble confidence to fire
"swing_threshold_pct": 0.12, # fade triggers when |weighted 15m move| >= this
"learning": True,
"ollama_url": "http://localhost:11434",
"ollama_model": "qwen3.5:4b",
"series": "KXBTC15M",
"kill": False,
}
def load_cfg():
cfg = dict(DEFAULT_CFG)
if CFG_PATH.exists():
try: cfg.update(json.loads(CFG_PATH.read_text()))
except Exception: pass
else:
CFG_PATH.write_text(json.dumps(cfg, indent=1))
return cfg
# ───────── kalshi client ─────────
class Kalshi:
BASE = "https://api.elections.kalshi.com"
PRE = "/trade-api/v2"
def __init__(self, key_id, key_path):
self.key_id = key_id
self.pk = serialization.load_pem_private_key(open(key_path, "rb").read(), password=None)
self.s = requests.Session()
def _h(self, method, path):
ts = str(int(time.time()*1000))
msg = ts + method.upper() + path.split("?")[0]
sig = self.pk.sign(msg.encode(), padding.PSS(mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.DIGEST_LENGTH), hashes.SHA256())
return {"KALSHI-ACCESS-KEY": self.key_id,
"KALSHI-ACCESS-SIGNATURE": base64.b64encode(sig).decode(),
"KALSHI-ACCESS-TIMESTAMP": ts}
def req(self, method, path, **kw):
full = self.PRE + path
for attempt in range(3):
try:
r = self.s.request(method, self.BASE+full, headers=self._h(method, full), timeout=15, **kw)
r.raise_for_status()
return r.json()
except requests.HTTPError as e:
if r.status_code in (401, 403): raise
if attempt == 2: raise
time.sleep(1.5*(attempt+1))
def markets(self, series, status="open", limit=5):
return self.req("GET", f"/markets?series_ticker={series}&status={status}&limit={limit}").get("markets", [])
def orderbook(self, ticker, depth=5):
return self.req("GET", f"/markets/{ticker}/orderbook?depth={depth}").get("orderbook", {})
def balance(self):
return self.req("GET", "/portfolio/balance")
def positions(self):
return self.req("GET", "/portfolio/positions?limit=50").get("market_positions", [])
def order(self, ticker, side, count, price_cents, dry):
"""side='yes'|'no'. price_cents = limit price in cents.
V2 endpoint: /portfolio/events/orders with bid/ask side + dollar prices."""
side_v2 = "bid" if side == "yes" else "ask"
price_dollars = f"{price_cents / 100:.4f}"
body = {"ticker": ticker, "client_order_id": str(uuid4()),
"side": side_v2, "count": f"{count:.2f}",
"price": price_dollars, "time_in_force": "good_till_canceled",
"self_trade_prevention_type": "taker_at_cross",
"post_only": False, "reduce_only": False}
if dry: return {"simulated": True, "order": body}
return self.req("POST", "/portfolio/events/orders", json=body)
def cancel_order(self, order_id, dry=False):
"""Cancel an open order by ID."""
if dry: return {"simulated": True, "cancelled": order_id}
return self.req("DELETE", f"/portfolio/events/orders/{order_id}")
def amend_order(self, order_id, new_price_cents, dry=False):
"""Amend an existing order's price."""
body = {"price": f"{new_price_cents/100:.4f}"}
if dry: return {"simulated": True, "amended": order_id}
return self.req("POST", f"/portfolio/events/orders/{order_id}/amend", json=body)
# ───────── btc price feed ─────────
def btc_price(coin="BTC"):
pair = KRAKEN_PAIRS.get(coin, "XBTUSD")
try:
r = requests.get(f"https://api.kraken.com/0/public/Ticker?pair={pair}", timeout=8).json()
key = list(r["result"].keys())[0]
t = r["result"][key]
_intel_cache.setdefault("kraken", {})["open24"] = float(t["o"][1] if isinstance(t["o"], list) else t["o"])
return float(t["c"][0])
except Exception:
try:
cb = "BTC" if coin == "BTC" else coin
r = requests.get(f"https://api.coinbase.com/v2/prices/{cb}-USD/spot", timeout=8)
return float(r.json()["data"]["amount"])
except Exception:
return None
# ───────── db ─────────
def db():
conn = sqlite3.connect(DB_PATH)
conn.execute("""CREATE TABLE IF NOT EXISTS ticks (ts REAL, price REAL)""")
conn.execute("""CREATE TABLE IF NOT EXISTS decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL, ticker TEXT, mode TEXT,
momentum TEXT, llm_vote TEXT, llm_conf REAL, llm_why TEXT,
final TEXT, price REAL, reason TEXT)""")
conn.execute("""CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL, ticker TEXT, side TEXT,
count INT, price_cents INT, dry INT, order_id TEXT, status TEXT,
settle_ts REAL, pnl_cents INT)""")
conn.execute("""CREATE TABLE IF NOT EXISTS events (ts REAL, level TEXT, msg TEXT)""")
conn.execute("""CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v REAL)""")
return conn
def log_event(conn, level, msg):
conn.execute("INSERT INTO events VALUES (?,?,?)", (time.time(), level, msg))
conn.commit()
getattr(LOG, level if level in ("info","warning","error") else "info")(msg)
# ───────── brain ─────────
def momentum(conn):
rows = conn.execute("SELECT ts, price FROM ticks WHERE ts > ? ORDER BY ts",
(time.time()-1800,)).fetchall()
if len(rows) < 4: return "FLAT", 0.0
now = rows[-1][1]
def ret(minutes):
ref = min(rows, key=lambda r: abs(r[0]-(time.time()-minutes*60)))[1]
return (now-ref)/ref*100 if ref else 0
r5, r15 = ret(5), ret(15)
score = r5*0.6 + r15*0.4
if score > 0.008: return "UP", abs(score)
if score < -0.008: return "DOWN", abs(score)
return "FLAT", abs(score)
# ───────── multi-source intel ─────────
_intel_cache = {"ts": 0, "data": {}}
def intel(coin="BTC"):
"""Slow external context, cached 5 min: Binance 24h stats, Fear&Greed, mempool fees."""
if time.time() - _intel_cache["ts"] < 300 and _intel_cache["data"] and coin in _intel_cache.get("coins", {}):
return _intel_cache["data"]
d = {}
# per-coin binance 24h
BINANCE_SYMBOLS = {"BTC": "BTCUSDT", "ETH": "ETHUSDT", "DOGE": "DOGEUSDT", "SOL": "SOLUSDT", "XRP": "XRPUSDT"}
bs = BINANCE_SYMBOLS.get(coin, "BTCUSDT")
try:
j = requests.get(f"https://api.binance.com/api/v3/ticker/24hr?symbol={bs}", timeout=8).json()
d["chg24"] = float(j["priceChangePercent"]); d["vol24"] = float(j["volume"])
d["high24"] = float(j["highPrice"]); d["low24"] = float(j["lowPrice"])
except Exception:
pass
if "chg24" not in d:
# kraken-derived 24h change (works everywhere, no geo block)
k = _intel_cache.get("kraken", {}).get("open24")
try:
cur = requests.get("https://api.kraken.com/0/public/Ticker?pair=XBTUSD", timeout=8).json()["result"]["XXBTZUSD"]
op = float(cur["o"][1] if isinstance(cur["o"], list) else cur["o"])
cl = float(cur["c"][0])
d["chg24"] = (cl - op) / op * 100
d["high24"] = float(cur["h"][1]); d["low24"] = float(cur["l"][1])
d["vol24"] = float(cur["v"][1])
except Exception:
pass
try:
j = requests.get("https://api.alternative.me/fng/?limit=1", timeout=8).json()
d["fng"] = int(j["data"][0]["value"]); d["fng_class"] = j["data"][0]["value_classification"]
except Exception: pass
try:
j = requests.get("https://mempool.space/api/v1/fees/recommended", timeout=8).json()
d["fee_fast"] = j.get("fastestFee")
except Exception: pass
_intel_cache.update({"ts": time.time(), "data": d, "coins": {coin: True}})
return d
def rsi(conn, period=14):
rows = conn.execute("SELECT price FROM ticks WHERE ts > ? ORDER BY ts", (time.time()-1200,)).fetchall()
px = [r[0] for r in rows]
if len(px) < period+2: return None
gains, losses = [], []
for a, b in zip(px[-period-1:-1], px[-period:]):
ch = b - a
(gains if ch >= 0 else losses).append(abs(ch))
ag = sum(gains)/period; al = sum(losses)/period if losses else 1e-9
return 100 - 100/(1 + ag/al)
def enriched_context(cfg, price, mom, mom_score, conn):
"""Build rich trading context: multi-TF trends, RSI, orderbook, BTC corr, WR history, regime."""
coin = cfg.get("coin","BTC")
ix = intel(coin)
r1 = rsi(conn, 14); r5 = rsi(conn, 14*5) # 1min and 5min RSI
lines = [f"=== {coin} MARKET SNAPSHOT ==="]
lines.append(f"spot: ${price:,.4f}")
lines.append(f"15m momentum: {mom} ({mom_score:.3f}%)")
if r1 is not None: lines.append(f"RSI(14,1m): {r1:.0f} {'OVERBOUGHT' if r1>70 else 'OVERSOLD' if r1<30 else 'neutral'}")
if r5 is not None: lines.append(f"RSI(14,5m): {r5:.0f} {'OVERBOUGHT' if r5>70 else 'OVERSOLD' if r5<30 else 'neutral'}")
# Multi-timeframe trend
rows = conn.execute("SELECT ts, price FROM ticks WHERE ts > ? ORDER BY ts",
(time.time()-7200,)).fetchall()
if len(rows) >= 8:
def ret_at(minutes):
ref = min(rows, key=lambda r: abs(r[0]-(time.time()-minutes*60)))[1]
return (price-ref)/ref*100 if ref else 0
lines.append(f"trend 1m: {ret_at(1):+.3f}% | 5m: {ret_at(5):+.3f}% | 15m: {ret_at(15):+.3f}% | 1h: {ret_at(60):+.2f}% | 2h: {ret_at(120):+.2f}%")
# 24h stats
if "chg24" in ix:
lines.append(f"24h change: {ix['chg24']:+.2f}% | range: ${ix.get('low24',0):,.2f}-${ix.get('high24',0):,.2f} | vol24: {ix.get('vol24',0):,.0f}")
# BTC correlation
btc_chg = ix.get("chg24", 0)
if abs(btc_chg) > 0.3:
lines.append(f"BTC 24h: {btc_chg:+.1f}% ({'bullish' if btc_chg>0 else 'bearish'}) — {'coin follows' if coin=='BTC' else 'alt follows macro'}")
# Orderbook depth (if ticker available)
ticker = getattr(cfg, '_current_ticker', None)
if ticker:
try:
kx = Kalshi(cfg["api_key_id"], cfg["key_path"])
book = kx.orderbook(ticker)
yes = book.get("yes",[]); no = book.get("no",[])
if yes and no:
y_ask = yes[0][0]; n_ask = no[0][0]
spread = abs(y_ask - n_ask)
lines.append(f"orderbook: YES ask={y_ask}¢ NO ask={n_ask}¢ spread={spread}¢")
# imbalance
y_depth = sum(o[1] for o in yes[:3]) if len(yes)>=3 else 0
n_depth = sum(o[1] for o in no[:3]) if len(no)>=3 else 0
if y_depth+n_depth > 0:
imb = (y_depth - n_depth) / (y_depth + n_depth)
lines.append(f"orderbook imbalance: {'YES-heavy' if imb>0.15 else 'NO-heavy' if imb<-0.15 else 'balanced'} ({imb:+.2f})")
except Exception:
pass
# Fear&Greed
if "fng" in ix:
lines.append(f"Fear&Greed: {ix['fng']} ({ix.get('fng_class','')})")
# Per-coin recent performance
perf = conn.execute("SELECT COUNT(*), SUM(CASE WHEN pnl_cents>0 THEN 1 ELSE 0 END) FROM orders WHERE dry=0 AND status IN ('won','lost') AND ts > ?",
(time.time()-86400,)).fetchone()
total, wins = perf[0] or 0, perf[1] or 0
if total >= 3:
wr = wins / total
lines.append(f"{coin} 24h record: {wins}W/{total-wins}L ({wr:.0%})")
# Market regime
if len(rows) >= 20:
prices = [r[1] for r in rows[-20:]]
mean = sum(prices)/len(prices)
std = (sum((p-mean)**2 for p in prices)/len(prices))**0.5
vol = std/mean*100 if mean else 0
regime = "choppy" if vol < 0.01 else "trending" if vol > 0.05 else "normal"
lines.append(f"market regime: {regime} (vol={vol:.3f}%)")
# Pattern matching: find similar historical states in Chroma
try:
state_text = f"{mom} {mom_score:.3f}% RSI1m:{r1 or '?'} RSI5m:{r5 or '?'} {coin} {btc_chg:+.1f}% 24h vol:{ix.get('vol24',0):.0f}"
embed = requests.post(cfg.get("embed_url","http://10.30.20.186:11434")+"/api/embed",
json={"model": cfg.get("embed_model","nomic-embed-text-v2-moe:latest"), "input": state_text},
timeout=8).json()
vec = embed.get("embeddings",[[]])[0]
if vec:
chroma_r = requests.post("http://10.30.20.89:27124/api/v1/collections/kalshi-patterns/query",
json={"query_embeddings": [vec], "n_results": 5, "include": ["metadatas"]}, timeout=8)
if chroma_r.status_code == 200:
results = chroma_r.json()
if results.get("metadatas") and results["metadatas"][0]:
outcomes = [m.get("outcome","?") for m in results["metadatas"][0] if m]
wins = outcomes.count("won"); losses = outcomes.count("lost")
if wins + losses > 0:
lines.append(f"PATTERN: 5 nearest historical market states → {wins}W/{losses}L")
except Exception:
pass # silently skip if Chroma/embed not available
# On-chain
if "fee_fast" in ix:
lines.append(f"mempool fast fee: {ix['fee_fast']} sat/vB")
# Learned patterns from past bets
learnings = conn.execute("SELECT v FROM kv WHERE k='learnings'").fetchone()
if learnings:
try:
for l in json.loads(learnings[0])[-5:]:
lines.append(f"LEARNED: {l}")
except Exception: pass
lines.append("")
lines.append("You trade 15-minute crypto up/down binary contracts by FADING spikes:")
lines.append("- Sharp UP move → bet DOWN (mean reversion)")
lines.append("- Sharp DOWN move → bet UP (mean reversion)")
lines.append("- SKIP when: strong trend continuation, thin orderbook, BTC-fighting trade, or no clear edge")
lines.append("- Consider: RSI extremes (>80 or <20 = high-prob fade), 24h range position, BTC macro direction")
lines.append("Reply with ONLY JSON: {\"vote\":\"UP\"|\"DOWN\"|\"SKIP\",\"conf\":0.0-1.0,\"why\":\"<10 words>\"}")
return "\n".join(lines)
def llm_vote(cfg, price, mom, mom_score, conn):
"""Two-tier: qwen3.5 (fast gate, 0.4s on MacBook) → ornith (deep verify on RTX 3070).
Gate handles clear signals; borderline calls escalate to ornith for final verdict."""
coin = cfg.get("coin","BTC")
ix = intel(coin); r = rsi(conn)
prompt = enriched_context(cfg, price, mom, mom_score, conn)
try:
fast_url = cfg.get("fast_llm_url", cfg.get("ollama_url","http://localhost:11434"))
fast_model = cfg.get("fast_llm_model", "qwen3.5:4b")
r1 = requests.post(fast_url+"/api/generate",
json={"model": fast_model, "prompt": prompt, "stream": False, "think": False, "keep_alive": "10m",
"options": {"temperature": 0.3, "num_predict": 60}}, timeout=8,
proxies={"http": None, "https": None}) # bypass proxy for localhost
txt = r1.json().get("response","")
s, e = txt.find("{"), txt.rfind("}")+1
d1 = json.loads(txt[s:e]) if s >= 0 else {}
fast_v, fast_c, fast_w = str(d1.get("vote","BORDERLINE")).upper(), float(d1.get("conf",0.5)), str(d1.get("why",""))[:120]
except Exception as ex:
fast_v, fast_c, fast_w = "BORDERLINE", 0.50, f"gate: {str(ex)[:40]}"
# ── 2-model: ornith leads when confident, qwen vetoes borderline ──
NO_PROXY = {"proxies": {"http": None, "https": None}}
# Model 1: qwen3.5 fast gate (MacBook — already ran)
v1 = fast_v if fast_v in ("UP","DOWN","SKIP") else "SKIP"
c1 = fast_c if fast_v in ("UP","DOWN","SKIP") else 0.50
# Model 2: ornith deep verify (GamingPC RTX 3070)
try:
r2 = requests.post("http://10.30.20.186:11434/api/generate",
json={"model": "ornith:latest", "prompt": prompt, "stream": False, "think": False, "keep_alive": "10m",
"options": {"temperature": 0.2, "num_predict": 90}}, timeout=60, **NO_PROXY)
txt2 = r2.json().get("response","")
s2, e2 = txt2.find("{"), txt2.rfind("}")+1
d2 = json.loads(txt2[s2:e2]) if s2 >= 0 else {}
v2 = str(d2.get("vote","SKIP")).upper()
if v2 not in ("UP","DOWN","SKIP"): v2 = "SKIP"
c2 = float(d2.get("conf",0))
w2 = str(d2.get("why",""))[:120]
except Exception as ex2:
LOG.warning(f"ornith failed: {ex2}")
return v1, c1, f"[qwen-only: {v1} {c1:.2f}] {fast_w[:80]}"
# ornith confident (>0.55) → lead, regardless of qwen
if v2 in ("UP","DOWN") and c2 >= 0.55:
return v2, c2, f"[ornith-lead {v2} {c2:.2f} | qwen={v1}] {w2[:60]}"
# both agree → go
if v1 == v2 and v1 in ("UP","DOWN"):
avg = (c1 + c2) / 2
return v1, avg, f"[agree {v1} {avg:.2f}] {w2[:60]}"
# qwen confident + ornith uncertain → use qwen
if v1 in ("UP","DOWN") and c1 >= 0.65 and c2 < 0.55:
return v1, c1, f"[qwen-lead {v1} {c1:.2f} | ornith={v2}] {fast_w[:60]}"
# disagreement or both uncertain → skip
return "SKIP", 0.0, f"[split qwen={v1}({c1:.2f}) ornith={v2}({c2:.2f})] {w2[:50]}"
def decide(cfg, conn, price):
"""Two-tier decision: qwen fast-gate → ornith deep-verify.
RSI extremes → force mean-reversion. BTC macro trend → gate counter-trend bets.
Self-adapting thresholds from resolved bet performance."""
mom, mom_score = momentum(conn)
lv, lc, lw = llm_vote(cfg, price, mom, mom_score, conn)
mode = cfg["mode"]
if mode == "DOWN_SPAM": return mom, lv, lc, lw, "DOWN", "spam-mode short"
if mode == "UP_SPAM": return mom, lv, lc, lw, "UP", "spam-mode long"
# ── RSI contrarian: only on TRUE extremes + trend alignment ──
r = rsi(conn)
ix_pre = intel(cfg.get("coin","BTC"))
chg24 = ix_pre.get("chg24", 0)
if r is not None:
# Fade DOWN only when: extreme OB + momentum reversing + not deep downtrend
if r > 92 and mom == "DOWN" and mom_score > 0.01:
return mom, lv, lc, lw, "DOWN", f"⚠ RSI {r:.0f} extreme OB + momentum reversing → fade DOWN"
# Fade UP only when: extreme OS + momentum reversing + NOT in deep downtrend (don't catch falling knives)
if r < 8 and mom == "UP" and mom_score > 0.01 and chg24 > -0.5:
return mom, lv, lc, lw, "UP", f"⚠ RSI {r:.0f} extreme OS + momentum reversing → fade UP"
# RSI extreme in downtrend → SKIP, don't catch the knife
if r < 8 and chg24 <= -0.5:
return mom, lv, lc, lw, "SKIP", f"⚠ RSI {r:.0f} extreme OS but 24h {chg24:.1f}% downtrend — no knife catch"
# ── swing threshold gate ──
thr = cfg.get("swing_threshold_pct", 0.005)
if mom == "FLAT" or mom_score < thr:
return mom, lv, lc, lw, "SKIP", f"no swing ({mom_score:.3f}% < {thr}%)"
if lv not in ("UP", "DOWN"):
return mom, lv, lc, lw, "SKIP", f"passes: {lw}"
# ── BTC macro correlation: don't fight the trend (both directions) ──
coin = cfg.get("coin","BTC")
ix = intel(coin)
btc_chg = ix.get("chg24", 0)
if lv == "DOWN" and btc_chg > 0.5:
if lc < 0.78:
return mom, lv, lc, lw, "SKIP", f"BTC +{btc_chg:.1f}% 24h — no counter-trend shorts (conf {lc:.2f}<0.78)"
if lv == "UP" and btc_chg < -0.5:
if lc < 0.78:
return mom, lv, lc, lw, "SKIP", f"BTC {btc_chg:.1f}% 24h — no counter-trend longs (conf {lc:.2f}<0.78)"
# ── adaptive confidence (self-tunes from resolved bets) ──
min_conf = learned_min_conf(conn, cfg)
# ── self-adapting overrides (LLM-written after bet resolution) ──
override = conn.execute("SELECT v FROM kv WHERE k='control_override'").fetchone()
if override:
try:
ov = json.loads(override[0])
if ov.get("force_min_conf"): min_conf = float(ov["force_min_conf"])
thr = ov.get("force_swing_threshold", thr)
except Exception: pass
if lc < min_conf:
return mom, lv, lc, lw, "SKIP", f"conf {lc:.2f}<{min_conf:.2f} (adaptive)"
return mom, lv, lc, lw, lv, f"{lv}: {mom} spike {mom_score:.2f}% conf={lc:.2f} [{lw[:60]}]"
# ───────── learning: resolve + adapt ─────────
def learned_min_conf(conn, cfg):
"""Adaptive confidence: rises after losses, relaxes after wins. Persisted in kv table."""
if not cfg.get("learning", True):
return cfg.get("min_conf", 0.55)
row = conn.execute("SELECT v FROM kv WHERE k='min_conf'").fetchone()
stored = row[0] if row else cfg.get("min_conf", 0.55)
res = conn.execute("SELECT pnl_cents FROM orders WHERE status IN ('won','lost') ORDER BY id DESC LIMIT 20").fetchall()
if len(res) < 8:
return stored
wr = sum(1 for r in res if r[0] and r[0] > 0) / len(res)
new = stored
if wr < 0.45: new = min(0.80, stored + 0.05)
elif wr > 0.60: new = max(0.50, stored - 0.05)
if new != stored:
conn.execute("INSERT OR REPLACE INTO kv VALUES ('min_conf', ?)", (new,))
conn.commit()
return new
def resolve_bets(kx, conn):
"""Settle finished markets against real outcomes (sim AND live)."""
open_orders = conn.execute(
"SELECT id, ticker, side, count, price_cents, dry FROM orders WHERE status IN ('placed','posted')").fetchall()
for oid, ticker, side, count, px, dry in open_orders:
try:
m = kx.req("GET", f"/markets/{ticker}")
mk = m.get("market", m) # Kalshi nests under "market" key
status = mk.get("status")
if status not in ("settled", "finalized", "determined", "closed"):
ct = mk.get("close_time")
if ct:
from datetime import datetime, timezone
if datetime.fromisoformat(ct.replace("Z","+00:00")).timestamp() < time.time() - 120:
conn.execute("UPDATE orders SET status='expired', settle_ts=? WHERE id=?", (time.time(), oid))
conn.commit()
continue
result = (mk.get("result") or "").lower() # 'yes' | 'no'
if not result:
continue
won = (result == side)
pnl = (100 - px) * count if won else -px * count
conn.execute("UPDATE orders SET status=?, settle_ts=?, pnl_cents=? WHERE id=?",
("won" if won else "lost", time.time(), pnl, oid))
conn.commit()
log_event(conn, "info", f"{'[SIM] ' if dry else ''}{'WIN' if won else 'LOSS'} {ticker} {side}@{px}¢ → {result} ({'+' if won else ''}{pnl}¢)")
# Store pattern in Chroma for future matching
try:
from bot import rsi as _rsi, momentum as _mom
c_rsi = _rsi(conn); c_mom, c_score = _mom(conn)
state_text = f"{c_mom} {c_score:.3f}% RSI:{c_rsi or '?'} ticker:{ticker[:6]}"
embed = requests.post("http://10.30.20.186:11434/api/embed",
json={"model": "nomic-embed-text-v2-moe:latest", "input": state_text}, timeout=8).json()
vec = embed.get("embeddings",[[]])[0]
if vec:
requests.post("http://10.30.20.89:27124/api/v1/collections/kalshi-patterns/add",
json={"embeddings": [vec], "metadatas": [{"outcome": "won" if won else "lost", "coin": ticker[:4],
"pnl": pnl, "side": side, "ticker": ticker, "ts": time.time()}],
"ids": [f"{ticker}-{oid}"], "documents": [state_text]}, timeout=8)
except Exception:
pass
except Exception:
continue
# ───────── exposure cap: total open ≤ 25% of balance ─────────
_exposure_cache = {"ts": 0, "balance": 0, "open_cents": 0}
def check_exposure_cap(kx, cfg, new_cost_cents):
"""Block trades if total open exposure would exceed 25% of balance."""
global _exposure_cache
now = time.time()
if now - _exposure_cache["ts"] < 60: # cache for 60s
bal = _exposure_cache["balance"]; open_c = _exposure_cache["open_cents"]
else:
try:
b = kx.balance()
bal = float(b.get("balance_dollars", 0)) * 100 # cents
# Sum open positions from Kalshi
positions = kx.positions()
open_c = sum(int(float(p.get("exposure_dollars", 0)) * 100) for p in positions)
_exposure_cache.update({"ts": now, "balance": bal, "open_cents": open_c})
except Exception:
bal = 2500; open_c = 0 # fallback: assume $25, no open
cap = bal * 0.25
projected = open_c + new_cost_cents
allowed = projected <= cap
if not allowed:
LOG.info(f"exposure cap: open={open_c}¢ + new={new_cost_cents}¢ = {projected}¢ > 25% of {bal:.0f}¢ ({cap:.0f}¢)")
return allowed, open_c, bal
# ───────── counter-hedge: lock in profit on open positions ─────────
def hedge_positions(conn, kx, ticker, cfg, book):
"""Scan open positions. If opposite side is cheap enough, buy it to lock profit.
Returns 'hedged' if a counter-order was placed, else None."""
open_orders = conn.execute(
"SELECT id,side,price_cents FROM orders WHERE ticker=? AND status IN ('placed','posted')",
(ticker,)).fetchall()
if not open_orders:
return None
min_profit = cfg.get("min_profit_cents", 8)
for oid, side, entry_px in open_orders:
opp_side = "yes" if side == "no" else "no"
opp_ask_list = book.get("yes" if opp_side == "yes" else "no")
opp_ask = (opp_ask_list or [[None]])[0][0]
if opp_ask is None:
continue
profit = 100 - entry_px - opp_ask
if profit >= min_profit:
res = kx.order(ticker, opp_side, 1, opp_ask, cfg["dry_run"])
oid2 = res.get("order_id", res.get("order", {}).get("order_id", "hedge"))
conn.execute("INSERT INTO orders (ts,ticker,side,count,price_cents,dry,order_id,status) VALUES (?,?,?,?,?,?,?,?)",
(time.time(), ticker, opp_side, 1, opp_ask, 1 if cfg["dry_run"] else 0, str(oid2), "placed"))
conn.execute("UPDATE orders SET status='hedged' WHERE id=?", (oid,))
conn.commit()
log_event(conn, "info", f"HEDGE {ticker}: {side}@{entry_px}¢ + {opp_side}@{opp_ask}¢ → locked {profit}¢")
return "hedged"
return None
# ───────── main loop ─────────
def daily_pnl(conn):
return conn.execute("SELECT COALESCE(SUM(pnl_cents),0) FROM orders WHERE date(ts,'unixepoch','localtime')=date('now','localtime') AND dry=0").fetchone()[0]
_last_adapt_ts = 0
def adapt_controls(conn, cfg):
"""After bets resolve, ask qwen to self-tune: adjust thresholds, confidence, sizing."""
global _last_adapt_ts
if time.time() - _last_adapt_ts < 600: # max every 10 min
return
resolved = conn.execute(
"SELECT side, pnl_cents, price_cents FROM orders WHERE status IN ('won','lost') ORDER BY id DESC LIMIT 20"
).fetchall()
if len(resolved) < 5:
return
# ── WR-based stake rebalancing (always runs, not dependent on LLM) ──
coin_name = cfg.get("coin","?")
max_spend = cfg.get("max_spend_cents", 100)
wins_rb = conn.execute("SELECT COUNT(*) FROM orders WHERE status='won' AND dry=0").fetchone()[0]
total_rb = conn.execute("SELECT COUNT(*) FROM orders WHERE status IN ('won','lost') AND dry=0").fetchone()[0]
if total_rb >= 5:
wr_rb = wins_rb / total_rb
new_stake = max_spend
if wr_rb >= 0.60: new_stake = min(250, max_spend + 25)
elif wr_rb < 0.35: new_stake = max(25, max_spend - 25)
if new_stake != max_spend:
conn.execute("INSERT OR REPLACE INTO kv(k,v) VALUES ('stake_rebalance',?)",
(json.dumps({"from": max_spend, "to": new_stake, "wr": round(wr_rb,2), "total": total_rb}),))
conn.commit()
LOG.info(f"{coin_name} WR={wr_rb:.0%} → stake ${max_spend/100:.2f}→${new_stake/100:.2f} (bets:{total_rb})")
_last_adapt_ts = time.time() # throttle rebalance logs to 10min
wins = sum(1 for r in resolved if r[1] and r[1] > 0)
wr = wins / len(resolved)
summary = {"recent_bets": len(resolved), "win_rate": round(wr, 2),
"wins": wins, "losses": len(resolved)-wins,
"avg_pnl": round(sum(r[1] for r in resolved if r[1])/len(resolved)) if resolved else 0}
prompt = (
f"Bot trading performance: {json.dumps(summary)}. "
"Current settings: swing_threshold={cfg.get('swing_threshold_pct',0.005)}% "
"min_conf={cfg.get('min_conf',0.50)}. "
"Suggest adjustments to maximize net profit. Reply with ONLY JSON: "
'{"force_min_conf":0.50,"force_swing_threshold":0.005,"reason":"<10 words>"} '
"or empty JSON {} if no changes needed."
)
try:
fast_url = cfg.get("fast_llm_url", "http://localhost:11434")
r = requests.post(fast_url+"/api/generate",
json={"model": cfg.get("fast_llm_model","qwen3.5:4b"), "prompt": prompt,
"stream": False, "think": False, "options": {"temperature": 0.1, "num_predict": 80}},
timeout=10)
txt = r.json().get("response","")
s, e = txt.find("{"), txt.rfind("}")+1
ov = json.loads(txt[s:e]) if s >= 0 else {}
if ov and ov.get("reason"):
conn.execute("INSERT OR REPLACE INTO kv(k,v) VALUES ('control_override',?)",
(json.dumps(ov),))
conn.commit()
LOG.info(f"🔧 self-adapt: {ov.get('reason')} — override={json.dumps({k:v for k,v in ov.items() if k!='reason'})}")
_last_adapt_ts = time.time()
# extract learnings from resolved bets
lp = (
f"Recent trades: {json.dumps([{'side':r[0],'pnl':r[1],'price':r[2]} for r in resolved[:10]])}. "
"Extract 1-2 actionable trading patterns. Reply with ONLY JSON array of strings, e.g.: "
'["RSI < 20 UP bets won 3/4 times","DOGE fades after 0.1% spike lose 60%"]'
)
try:
r3 = requests.post(fast_url+"/api/generate",
json={"model": cfg.get("fast_llm_model","qwen3.5:4b"), "prompt": lp,
"stream": False, "think": False, "options": {"temperature": 0.1, "num_predict": 60}},
timeout=10)
txt3 = r3.json().get("response","")
s3, e3 = txt3.find("["), txt3.rfind("]")+1
new_learnings = json.loads(txt3[s3:e3]) if s3 >= 0 else []
if new_learnings:
existing = conn.execute("SELECT v FROM kv WHERE k='learnings'").fetchone()
old = json.loads(existing[0]) if existing else []
old.extend(new_learnings)
conn.execute("INSERT OR REPLACE INTO kv(k,v) VALUES ('learnings',?)",
(json.dumps(old[-20:]),)) # keep last 20
conn.commit()
LOG.info(f"🧠 learned: {new_learnings}")
except Exception:
pass
except Exception:
pass # silently skip on failure
# ───────── main loop ─────────
return conn.execute("SELECT COALESCE(SUM(pnl_cents),0) FROM orders WHERE date(ts,'unixepoch','localtime')=date('now','localtime') AND dry=0").fetchone()[0]
def run():
global HOME, CFG_PATH, DB_PATH
import argparse, fcntl
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=str(HOME/"config.json"), help="config path")
ap.add_argument("--lock", default="bot", help="lock file name stem")
ap.add_argument("--db", default=None, help="db path (overrides config)")
args, _ = ap.parse_known_args()
CFG_PATH = Path(args.config)
HOME = CFG_PATH.parent
DB_PATH = Path(args.db) if args.db else (HOME / "state.db")
lock_fd = open(HOME / f"{args.lock}.lock", "w")
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
print("another bot instance is already running — exiting")
sys.exit(0)
cfg = load_cfg()
if cfg.get("proxy"):
os.environ["HTTP_PROXY"] = os.environ["HTTPS_PROXY"] = cfg["proxy"]
if cfg.get("db_path"):
DB_PATH = HOME / cfg["db_path"]
kx = Kalshi(cfg["api_key_id"], cfg["key_path"])
conn = db()
log_event(conn, "info", f"K4LSH1_OPS online — mode={cfg['mode']} dry_run={cfg['dry_run']} stake={cfg['stake_cents']}¢ max_spend={cfg.get('max_spend_cents',100)}¢")
last_bet_market = None
last_decision_ts = None
# stagger startup to avoid simultaneous LLM calls
stagger = cfg.get("start_delay_sec", 0)
if stagger:
LOG.info(f"staggering start by {stagger}s")
time.sleep(stagger)
while True:
cfg = load_cfg() # hot reload (dashboard edits)
if cfg.get("kill"):
log_event(conn, "warning", "KILL switch engaged — bot halted")
break
try:
resolve_bets(kx, conn)
adapt_controls(conn, cfg)
# WR-based stake rebalance (from adapt_controls) — override config
try:
reb = conn.execute("SELECT v FROM kv WHERE k='stake_rebalance'").fetchone()
if reb:
cfg["max_spend_cents"] = json.loads(reb[0]).get("to", cfg.get("max_spend_cents", 100))
except Exception: pass
# cancel stale unfilled orders close to market expiration
try:
open_orders = conn.execute(
"SELECT id, ticker, order_id, side, price_cents FROM orders WHERE status IN ('placed','posted') AND dry=0"
).fetchall()
for oid, ticker, koid, side, px in open_orders:
try:
m = kx.req("GET", f"/markets/{ticker}")
mk = m.get("market", m) # Kalshi nests under "market"
ct = mk.get("close_time","")
if ct:
close_ts = datetime.fromisoformat(ct.replace("Z","+00:00")).timestamp()
mins_left = (close_ts - time.time())/60
if mins_left < 1.5:
kx.cancel_order(koid, cfg.get("dry_run", False))
conn.execute("UPDATE orders SET status='cancelled' WHERE id=?", (oid,))
conn.commit()
log_event(conn, "info", f"CANCEL stale {ticker} {side}@{px}¢ (mins_left={mins_left:.1f})")
except Exception:
pass
except Exception:
pass
p = btc_price(cfg.get("coin", "BTC"))
if p:
conn.execute("INSERT INTO ticks VALUES (?,?)", (time.time(), p))
conn.execute("DELETE FROM ticks WHERE ts < ?", (time.time()-7200,))
conn.commit()
coin = cfg.get("coin", "BTC")
if cfg["mode"] != "OFF" and p:
mkts = kx.markets(cfg["series"], "open", 3)
if mkts:
m = sorted(mkts, key=lambda x: x["close_time"])[-1]
ticker = m["ticker"]
close_ts = datetime.fromisoformat(m["close_time"].replace("Z","+00:00")).timestamp()
mins_left = (close_ts - time.time())/60
if mins_left > 0.5:
# 5-min re-evaluation window
cycle_sec = cfg.get("decision_interval_sec", 300)
now_ts = time.time()
if last_decision_ts and (now_ts - last_decision_ts) < cycle_sec:
continue # wait for next decision window
last_decision_ts = now_ts
book = kx.orderbook(ticker)
# hedge check: close profitable positions
if hedge_positions(conn, kx, ticker, cfg, book) == "hedged":
continue
mom, lv, lc, lw, final, why = decide(cfg, conn, p)
yes_ask = (book.get("yes") or [[None]])[0][0]
no_ask = (book.get("no") or [[None]])[0][0]
ask = yes_ask if final == "UP" else (no_ask if final == "DOWN" else None)
side = "yes" if final == "UP" else "no"
conn.execute("INSERT INTO decisions (ts,ticker,mode,momentum,llm_vote,llm_conf,llm_why,final,price,reason) VALUES (?,?,?,?,?,?,?,?,?,?)",
(time.time(), ticker, cfg["mode"], mom, lv, lc, lw, final, p, why))
conn.commit()
if final in ("UP","DOWN"):
if daily_pnl(conn) <= -cfg["daily_loss_cap_cents"]:
log_event(conn, "warning", "daily loss cap hit — sitting out")
continue
try:
verify = kx.req("GET", f"/markets/{ticker}")
vm = verify.get("market", verify) # Kalshi nests under "market"
st = vm.get("status", "open")
if st and st not in ("open", "active"):
log_event(conn, "info", f"skip {ticker}: market closed (status={st})")
continue
except Exception:
pass
# confidence-scaled contracts: max_spend cap + 25% exposure cap
max_spend = cfg.get("max_spend_cents", 100)
if ask and ask <= max_spend:
n = 1 if lc < 0.70 else (2 if lc < 0.80 else (3 if lc < 0.90 else 4))
while n * ask > max_spend and n > 1:
n -= 1
px = ask
maker = False
else:
n = 1
px = min(cfg.get("post_price_cents", 50), max_spend)
maker = True
# 25% total exposure cap
trade_cost = n * px
ok, open_c, bal = check_exposure_cap(kx, cfg, trade_cost)
if not ok:
log_event(conn, "info", f"skip {ticker}: exposure cap ({open_c}¢+{trade_cost}¢ > 25% of {bal:.0f}¢)")
continue
try:
res = kx.order(ticker, side, n, px, cfg["dry_run"])
oid = res.get("order_id", res.get("order", {}).get("order_id", "sim"))
conn.execute("INSERT INTO orders (ts,ticker,side,count,price_cents,dry,order_id,status) VALUES (?,?,?,?,?,?,?,?)",
(time.time(), ticker, side, n, px, 1 if cfg["dry_run"] else 0, str(oid), "placed"))
conn.commit()
tag = "SIM" if cfg["dry_run"] else ("TAKER" if not maker else "POST")
log_event(conn, "info", f"{tag} {side.upper()} x{n} {ticker} @ {px}¢ — {why}")
except Exception as e:
log_event(conn, "warning", f"order failed: {e}")
else:
LOG.info(f"skip {ticker}: {why}")
except Exception as e:
log_event(conn, "error", f"loop error: {e}")
time.sleep(20)
if __name__ == "__main__":
run()