Files
kalshi-bot/bot.py

618 lines
31 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)
# ───────── 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}%)")
# On-chain
if "fee_fast" in ix:
lines.append(f"mempool fast fee: {ix['fee_fast']} sat/vB")
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)
"""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,
"options": {"temperature": 0.3, "num_predict": 60}}, timeout=8)
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]}"
if fast_v == "BORDERLINE" or (fast_v in ("UP","DOWN") and fast_c < 0.70):
try:
deep_url = cfg.get("deep_llm_url", cfg.get("ollama_url","http://10.30.20.186:11434"))
deep_model = cfg.get("deep_llm_model", cfg.get("ollama_model","ornith:latest"))
r2 = requests.post(deep_url+"/api/generate",
json={"model": deep_model, "prompt": prompt, "stream": False, "think": False,
"options": {"temperature": 0.2, "num_predict": 90}}, timeout=60)
txt2 = r2.json().get("response","")
s2, e2 = txt2.find("{"), txt2.rfind("}")+1
d2 = json.loads(txt2[s2:e2]) if s2 >= 0 else {}
v = str(d2.get("vote","SKIP")).upper()
if v not in ("UP","DOWN","SKIP"): v = "SKIP"
return v, float(d2.get("conf",0)), str(d2.get("why",""))[:120]
except Exception as ex2:
LOG.warning(f"deep llm failed: {ex2}")
if fast_v in ("UP","DOWN","SKIP"): return fast_v, fast_c, fast_w
return "SKIP", 0.0, "llm offline"
if fast_v not in ("UP","DOWN","SKIP"): fast_v = "SKIP"
return fast_v, fast_c, fast_w
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: extreme readings → auto-fade ──
r = rsi(conn)
if r is not None:
if r > 85:
return mom, lv, lc, lw, "DOWN", f"⚠ RSI {r:.0f} extreme overbought → fade DOWN"
if r < 15:
return mom, lv, lc, lw, "UP", f"⚠ RSI {r:.0f} extreme oversold → fade UP"
# ── 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 ──
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}")
status = m.get("status")
if status not in ("settled", "finalized", "determined"):
# also catch expired-untraded: close_time passed + no result → mark dead
ct = m.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 = (m.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}¢)")
except Exception:
continue
# ───────── 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
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()
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)
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}")
st = verify.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 $1.00 total spend
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
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()