K4LSH1_OPS: multi-coin LLM bot — DOGE/SOL/ETH, two-tier qwen+ornith, self-adapting
This commit is contained in:
617
bot.py
Normal file
617
bot.py
Normal file
@@ -0,0 +1,617 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user