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:
drjones
2026-08-02 21:51:00 -07:00
parent 0408ff026f
commit 32cfdfe842
14 changed files with 1098 additions and 26 deletions

165
bot.py
View File

@@ -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"))