feat: 3-model ensemble (qwen3.5+ornith+llama3.2), WR-based stake rebalancing, 25% exposure cap, rebalance ceiling .50, exposure throttling
This commit is contained in:
165
bot.py
165
bot.py
@@ -281,6 +281,26 @@ def enriched_context(cfg, price, mom, mom_score, conn):
|
||||
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")
|
||||
@@ -312,8 +332,9 @@ def llm_vote(cfg, price, mom, mom_score, conn):
|
||||
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)
|
||||
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 {}
|
||||
@@ -321,25 +342,58 @@ def llm_vote(cfg, price, mom, mom_score, conn):
|
||||
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):
|
||||
# ── 3-model ensemble: qwen3.5 (MacBook) + ornith (GPU) + llama3.2 (CT518 diverifier) ──
|
||||
NO_PROXY = {"proxies": {"http": None, "https": None}} # bypass proxy for LAN Ollama calls
|
||||
votes = []
|
||||
# Model 1: qwen3.5 fast gate (MacBook)
|
||||
v1 = fast_v if fast_v in ("UP","DOWN","SKIP") else "SKIP"
|
||||
votes.append((v1, fast_c if fast_v in ("UP","DOWN","SKIP") else 0.50, fast_w, "qwen3.5"))
|
||||
|
||||
# Model 2: ornith deep verify (GamingPC RTX 3070)
|
||||
try:
|
||||
deep_url = cfg.get("deep_llm_url", "http://10.30.20.186:11434")
|
||||
deep_model = cfg.get("deep_llm_model", "ornith:latest")
|
||||
r2 = requests.post(deep_url+"/api/generate",
|
||||
json={"model": deep_model, "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"
|
||||
votes.append((v2, float(d2.get("conf",0)), str(d2.get("why",""))[:120], "ornith"))
|
||||
except Exception as ex2:
|
||||
LOG.warning(f"ornith failed: {ex2}")
|
||||
votes.append(("SKIP", 0.0, "ornith offline", "ornith"))
|
||||
|
||||
# Model 3: llama3.2 diverifier (CT518) — runs only on disputed signals
|
||||
fast_vote = votes[0][0]; deep_vote = votes[1][0]
|
||||
if fast_vote != deep_vote or (fast_vote in ("UP","DOWN") and votes[0][1] < 0.55):
|
||||
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
|
||||
div_url = cfg.get("diverify_url", "http://10.30.20.89:11434")
|
||||
div_model = cfg.get("diverify_model", "llama3.2:latest")
|
||||
r3 = requests.post(div_url+"/api/generate",
|
||||
json={"model": div_model, "prompt": prompt, "stream": False, "keep_alive": "10m",
|
||||
"options": {"temperature": 0.3, "num_predict": 60}}, timeout=30, **NO_PROXY)
|
||||
txt3 = r3.json().get("response","")
|
||||
s3, e3 = txt3.find("{"), txt3.rfind("}")+1
|
||||
d3 = json.loads(txt3[s3:e3]) if s3 >= 0 else {}
|
||||
v3 = str(d3.get("vote","SKIP")).upper()
|
||||
if v3 not in ("UP","DOWN","SKIP"): v3 = "SKIP"
|
||||
votes.append((v3, float(d3.get("conf",0)), str(d3.get("why",""))[:120], "llama3.2"))
|
||||
except Exception as ex3:
|
||||
LOG.warning(f"diverifier failed: {ex3}")
|
||||
votes.append(("SKIP", 0.0, "diverifier offline", "llama3.2"))
|
||||
|
||||
# Majority vote: UP vs DOWN vs SKIP counts
|
||||
tally = {"UP": 0, "DOWN": 0, "SKIP": 0}
|
||||
for v, c, w, src in votes:
|
||||
tally[v] += 1
|
||||
winner = "UP" if tally["UP"] >= 2 else ("DOWN" if tally["DOWN"] >= 2 else "SKIP")
|
||||
avg_conf = sum(v[1] for v in votes if v[0] == winner) / max(1, tally[winner])
|
||||
reasons = " | ".join(f"{src}={v}" for v, _, _, src in votes)
|
||||
tag = "ENSEMBLE" if tally[winner] >= 2 else "SPLIT"
|
||||
return winner, avg_conf, f"[{tag}: {tally['UP']}U/{tally['DOWN']}D/{tally['SKIP']}S] {reasons}"
|
||||
|
||||
def decide(cfg, conn, price):
|
||||
"""Two-tier decision: qwen fast-gate → ornith deep-verify.
|
||||
@@ -439,9 +493,50 @@ def resolve_bets(kx, conn):
|
||||
("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.
|
||||
@@ -486,6 +581,22 @@ def adapt_controls(conn, cfg):
|
||||
).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),
|
||||
@@ -584,6 +695,12 @@ def run():
|
||||
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(
|
||||
@@ -651,7 +768,7 @@ def run():
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
# confidence-scaled contracts: max $1.00 total spend
|
||||
# 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))
|
||||
@@ -663,6 +780,12 @@ def run():
|
||||
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"))
|
||||
|
||||
Reference in New Issue
Block a user