819 lines
42 KiB
Python
819 lines
42 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-mlx",
|
|
"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-mlx")
|
|
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
|
|
|
|
# Fast-gate skip: if qwen is confident enough, skip ornith entirely
|
|
fast_gate_conf = cfg.get("fast_gate_conf", 0.65)
|
|
if v1 in ("UP","DOWN") and c1 >= fast_gate_conf:
|
|
LOG.info(f"[fast-gate {v1} {c1:.2f}] skipping ornith — qwen confident")
|
|
return v1, c1, f"[fast-gate {v1} {c1:.2f}] {fast_w[:80]}"
|
|
|
|
# Model 2: ornith deep verify (GamingPC RTX 3070) — only on borderline/uncertain
|
|
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", 5) # scalp: lock smaller profits more often
|
|
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-mlx"), "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-mlx"), "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:
|
|
now_ts = time.time()
|
|
# ── ALWAYS watching: hedge check EVERY tick (every 15s) ──
|
|
book = kx.orderbook(ticker)
|
|
if hedge_positions(conn, kx, ticker, cfg, book) == "hedged":
|
|
continue
|
|
# ── micro-swing detector: price moved 0.03%+ in last 60s ──
|
|
swing_60s = False
|
|
recent = conn.execute(
|
|
"SELECT price FROM ticks WHERE ts > ? ORDER BY ts DESC LIMIT 5",
|
|
(now_ts - 60,)).fetchall()
|
|
if len(recent) >= 4:
|
|
hi = max(t[0] for t in recent); lo = min(t[0] for t in recent)
|
|
if lo > 0 and (hi - lo) / lo * 100 >= 0.03:
|
|
swing_60s = True
|
|
# ── 5-min re-evaluation window (skip unless micro-swing) ──
|
|
cycle_sec = cfg.get("decision_interval_sec", 300)
|
|
if last_decision_ts and (now_ts - last_decision_ts) < cycle_sec and not swing_60s:
|
|
continue
|
|
last_decision_ts = now_ts
|
|
if swing_60s and last_decision_ts:
|
|
LOG.info(f"⚡ micro-swing 0.03%+ in 60s on {ticker} — evaluating entry")
|
|
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
|
|
# skip-if-positioned: one open position per ticker max
|
|
existing = conn.execute(
|
|
"SELECT COUNT(*) FROM orders WHERE ticker=? AND status IN ('placed','posted') AND dry=0",
|
|
(ticker,)).fetchone()[0]
|
|
if existing > 0:
|
|
continue # already positioned on this market
|
|
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(cfg.get("scalp_interval_sec", 60)) # configurable scalp loop
|
|
|
|
if __name__ == "__main__":
|
|
run()
|