Files
kalshi-bot/dashboard.py

403 lines
24 KiB
Python

#!/usr/bin/env python3
"""K4LSH1_OPS — dashboard. Hacker console on :5053. Controls write config.json (bot hot-reloads)."""
import json, sqlite3, time, threading
from pathlib import Path
from flask import Flask, jsonify, request
HOME = Path(__file__).parent
CFG_PATH = HOME / "config.json"
DB_PATH = HOME / "state.db"
app = Flask(__name__)
HTML = r"""<!DOCTYPE html><html><head><meta charset="utf-8"><title>K4LSH1_OPS</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#020603;color:#00ff41;font-family:'SF Mono',Menlo,monospace;overflow-x:hidden}
body::before{content:'';position:fixed;inset:0;pointer-events:none;z-index:99;background:repeating-linear-gradient(0deg,transparent 0 2px,rgba(0,0,0,.22) 2px 4px)}
.glow{text-shadow:0 0 8px #00ff41,0 0 22px #00ff4133}
header{display:flex;align-items:center;justify-content:space-between;padding:12px 18px;border-bottom:1px solid #00ff4133;background:#031007}
h1{font-size:1.15rem;letter-spacing:3px}
h1 .dim{color:#0a5f2e}
.badge{padding:3px 10px;border:1px solid #00ff41;border-radius:3px;font-size:.68rem;margin-left:8px}
.badge.warn{border-color:#ffb000;color:#ffb000;text-shadow:0 0 8px #ffb000}
.badge.danger{border-color:#ff3131;color:#ff3131;text-shadow:0 0 8px #ff3131}
.badge.off{border-color:#0a5f2e;color:#0a5f2e;text-shadow:none}
.grid{display:grid;grid-template-columns:1.15fr 1.5fr 1fr;gap:10px;padding:12px 18px}
.panel{border:1px solid #00ff4144;background:#031007cc;border-radius:6px;padding:12px;margin-bottom:10px;box-shadow:0 0 18px #00ff4112 inset}
.panel h3{font-size:.66rem;color:#2ea043;letter-spacing:2px;margin-bottom:10px;text-transform:uppercase}
.panel h3::before{content:''}
.big{font-size:2.4rem;font-weight:800}
.small{font-size:.7rem;color:#2ea043}
.row{display:flex;justify-content:space-between;font-size:.78rem;padding:3px 0;border-bottom:1px dashed #00ff4122}
.row:last-child{border:none}
.up{color:#00ff41}.dn{color:#ff3131;text-shadow:0 0 8px #ff313166}.skip{color:#0a5f2e}.warn-t{color:#ffb000}
.log{max-height:420px;overflow-y:auto;font-size:.72rem;scrollbar-width:thin}
.log .e{padding:4px 0;border-bottom:1px dashed #00ff4118;line-height:1.5}
.log .why{color:#0a5f2e;font-size:.64rem}
button,select{background:#020603;border:1px solid #00ff41;color:#00ff41;font-family:inherit;padding:6px 12px;border-radius:3px;cursor:pointer;font-size:.72rem;letter-spacing:1px}
button:hover,select:hover{background:#00ff4122;box-shadow:0 0 12px #00ff4144}
button.danger{border-color:#ff3131;color:#ff3131}
button.danger:hover{background:#ff313122}
.ctl{display:flex;flex-direction:column;gap:10px}
canvas{width:100%;display:block}
.count{font-size:1.5rem;font-weight:800}
.book span{display:inline-block;width:44%;font-size:.72rem}
.cursor{display:inline-block;width:8px;height:14px;background:#00ff41;animation:b 1s steps(1) infinite;vertical-align:middle}
@keyframes b{50%{opacity:0}}
#statusbar{position:fixed;bottom:0;left:0;right:0;z-index:100;display:flex;align-items:center;gap:0;background:#010502f2;border-top:1px solid #00ff4166;backdrop-filter:blur(8px);font-size:.72rem;padding:0}
#statusbar .cell{padding:8px 14px;border-right:1px solid #00ff4122;white-space:nowrap}
#statusbar .cell b{color:#00ff41}
#statusbar .lab{color:#0a5f2e;font-size:.6rem;letter-spacing:1px;display:block}
#statusbar .live{background:#ff313111;color:#ff3131;border-right-color:#ff313144;text-shadow:0 0 8px #ff3131}
#statusbar .sim{background:#ffb00011;color:#ffb000}
#statusbar .up{color:#00ff41}#statusbar .dn{color:#ff3131}
body{padding-bottom:44px}
</style></head><body>
<header>
<div><h1 class="glow">K4LSH1<span class="dim">_OPS</span> <span class="cursor"></span></h1>
<span id="badges"></span></div>
<div class="small" id="clock">—</div>
</header>
<div class="grid">
<div>
<div class="panel"><h3>BTC/USD — kraken</h3>
<div class="big glow" id="btc">$—</div>
<div class="small" id="btc-mom">—</div>
<canvas id="spark" height="70" style="margin-top:8px"></canvas>
</div>
<div class="panel"><h3>active market</h3>
<div class="row"><span>ticker</span><b id="m-ticker">—</b></div>
<div class="row"><span>question</span><b id="m-q" style="font-size:.68rem">—</b></div>
<div class="row"><span>closes in</span><span class="count glow" id="m-close">—</span></div>
<div class="book" style="margin-top:6px"><span id="m-yes">YES —¢</span><span id="m-no">NO —¢</span></div>
</div>
<div class="panel"><h3>account</h3>
<div class="row"><span>balance</span><b id="acct-bal" class="glow">—</b></div>
<div class="row"><span>open positions</span><b id="acct-pos">—</b></div>
<div class="row"><span>bets placed (session)</span><b id="acct-bets">—</b></div>
</div>
</div>
<div>
<div class="panel"><h3>AI decision stream — ollama local</h3><div class="log" id="dec"></div></div>
<div class="panel"><h3>system events</h3><div class="log" id="ev" style="max-height:140px"></div></div>
</div>
<div>
<div class="panel"><h3>controls</h3><div class="ctl">
<div class="small">STRATEGY MODE</div>
<select id="c-mode" onchange="ctl()">
<option value="AUTO">AUTO (AI+momentum)</option>
<option value="DOWN_SPAM">DOWN_SPAM (perma-short)</option>
<option value="UP_SPAM">UP_SPAM (perma-long)</option>
<option value="OFF">OFF (paused)</option>
</select>
<div class="small">MAX STAKE / BET: <b id="stake-v">50¢</b></div>
<input type="range" id="c-stake" min="10" max="99" step="1" oninput="document.getElementById('stake-v').textContent=this.value+'¢'" onchange="ctl()" style="width:100%">
<button id="c-dry" onclick="toggleDry()">—</button>
<button class="danger" onclick="kill()">⛔ KILL BOT</button>
</div></div>
<div class="panel"><h3>order blotter</h3><div class="log" id="ord" style="max-height:300px"></div></div>
</div>
</div>
<div id="statusbar">
<div class="cell" id="sb-state">—</div>
<div class="cell"><span class="lab">MODE</span><b id="sb-mode">—</b></div>
<div class="cell"><span class="lab">BTC</span><b id="sb-btc">—</b></div>
<div class="cell"><span class="lab">24H</span><b id="sb-24h">—</b></div>
<div class="cell"><span class="lab">FEAR/GREED</span><b id="sb-fng">—</b></div>
<div class="cell"><span class="lab">OPEN BETS</span><b id="sb-open">—</b></div>
<div class="cell"><span class="lab">RESOLVED P&L</span><b id="sb-pnl">—</b></div>
<div class="cell"><span class="lab">WIN RATE</span><b id="sb-wr">—</b></div>
<div class="cell"><span class="lab">AI CONF GATE</span><b id="sb-conf">—</b></div>
<div class="cell" style="margin-left:auto;border-right:none"><span class="lab">NEXT MARKET</span><b id="sb-next">—</b></div>
</div>
<script>
const $=i=>document.getElementById(i);
setInterval(()=>{$('clock').textContent=new Date().toLocaleTimeString()+' local';},1000);
function spark(pts){const c=$('spark'),x=c.getContext('2d'),dpr=devicePixelRatio||1,r=c.getBoundingClientRect();
c.width=r.width*dpr;c.height=r.height*dpr;x.scale(dpr,dpr);const W=r.width,H=r.height;if(pts.length<2)return;
const mn=Math.min(...pts),mx=Math.max(...pts),rg=(mx-mn)||1;
x.strokeStyle='#00ff41';x.lineWidth=1.4;x.shadowColor='#00ff41';x.shadowBlur=6;x.beginPath();
pts.forEach((p,i)=>{const px=i/(pts.length-1)*W,py=H-((p-mn)/rg)*(H-8)-4;i?x.lineTo(px,py):x.moveTo(px,py)});x.stroke();}
async function refresh(){
const d=await (await fetch('/api/state')).json();
$('btc').textContent='$'+Number(d.btc||0).toLocaleString(undefined,{maximumFractionDigits:0});
$('btc-mom').textContent='momentum: '+d.momentum+(d.price_age!=null?' · feed '+d.price_age+'s ago':'');
spark(d.spark||[]);
$('m-ticker').textContent=d.market.ticker||'';
$('m-q').textContent=d.market.title||'waiting for next 15m market…';
$('m-close').textContent=d.market.mins_left!=null?d.market.mins_left.toFixed(1)+'m':'';
$('m-yes').textContent='YES '+(d.market.yes_ask??'')+'¢';$('m-no').textContent='NO '+(d.market.no_ask??'')+'¢';
$('acct-bal').textContent=d.balance!=null?'$'+(d.balance/100).toFixed(2):'';
$('acct-pos').textContent=d.positions;$('acct-bets').textContent=d.bets_session;
let b='';b+=d.cfg.dry_run?'<span class="badge warn">DRY_RUN</span>':'<span class="badge danger">LIVE</span>';
b+='<span class="badge">'+d.cfg.mode+'</span>';
if(d.cfg.kill)b+='<span class="badge danger">KILLED</span>';
$('badges').innerHTML=b;
$('c-mode').value=d.cfg.mode;$('c-stake').value=d.cfg.stake_cents;$('stake-v').textContent=d.cfg.stake_cents+'¢';
$('c-dry').textContent=d.cfg.dry_run?'⚠ DRY RUN ON — click to go LIVE':'● LIVE — click to pause to DRY';
$('dec').innerHTML=d.decisions.map(e=>`<div class="e"><span class="small">${e.t}</span>
<span class="${e.final==='UP'?'up':e.final==='DOWN'?'dn':'skip'}">[${e.final}]</span>
${e.ticker.slice(-12)} · mom:${e.momentum} llm:${e.llm_vote}(${(e.llm_conf||0).toFixed(2)})
<div class="why">${e.llm_why||''} → ${e.reason}</div></div>`).join('')||'<div class="e">waiting for signals…</div>';
$('ord').innerHTML=d.orders.map(o=>`<div class="e"><span class="small">${o.t}</span>
<b class="${o.side==='yes'?'up':'dn'}">${o.side.toUpperCase()}</b> ${o.count}x @ ${o.price_cents}¢ ${o.dry?'<span class="skip">[SIM]</span>':''}
<div class="why">${o.ticker}</div></div>`).join('')||'<div class="e">no orders yet</div>';
$('ev').innerHTML=d.events.map(e=>`<div class="e"><span class="small">${e.t}</span> <span class="${e.level==='error'?'dn':e.level==='warning'?'warn-t':''}">${e.msg}</span></div>`).join('');
/* status bar */
const st=$('sb-state');
st.className='cell '+(d.cfg.dry_run?'sim':'live');
st.textContent=d.cfg.dry_run?'◐ SIMULATED':'● LIVE';
$('sb-mode').textContent=d.cfg.mode;
$('sb-btc').textContent='$'+Number(d.btc||0).toLocaleString(undefined,{maximumFractionDigits:0});
const c24=d.intel&&d.intel.chg24;
$('sb-24h').textContent=c24!=null?(c24>=0?'+':'')+c24.toFixed(2)+'%':'';
$('sb-24h').className=c24>=0?'up':'dn';
$('sb-fng').textContent=d.intel&&d.intel.fng!=null?d.intel.fng+' '+(d.intel.fng_class||''):'';
$('sb-open').textContent=d.perf.open;
const pnl=d.perf.pnl||0;
$('sb-pnl').textContent=(pnl>=0?'+':'')+pnl+'¢';
$('sb-pnl').className=pnl>=0?'up':'dn';
$('sb-wr').textContent=d.perf.resolved?((d.perf.won/d.perf.resolved*100).toFixed(0)+'% ('+d.perf.won+'/'+d.perf.resolved+')'):'';
$('sb-conf').textContent=d.perf.min_conf!=null?(d.perf.min_conf*100).toFixed(0)+'%':'';
$('sb-next').textContent=d.market.mins_left!=null?d.market.mins_left.toFixed(1)+'m':'';
}
async function ctl(){await fetch('/api/control',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({mode:$('c-mode').value,stake_cents:+$('c-stake').value})});refresh();}
async function toggleDry(){await fetch('/api/toggle-dry',{method:'POST'});refresh();}
async function kill(){if(confirm('HALT THE BOT? (relaunch manually to revive)')){await fetch('/api/kill',{method:'POST'});refresh();}}
refresh();setInterval(refresh,5000);
</script></body></html>"""
FLEET_HTML = r"""<!DOCTYPE html><html><head><meta charset="utf-8"><title>K4LSH1 FLEET</title>
<meta http-equiv="refresh" content="15">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#020603;color:#00ff41;font-family:'SF Mono',Menlo,monospace;overflow-x:hidden}
body::before{content:'';position:fixed;inset:0;pointer-events:none;z-index:99;background:repeating-linear-gradient(0deg,transparent 0 2px,rgba(0,0,0,.22) 2px 4px)}
.glow{text-shadow:0 0 8px #00ff41,0 0 22px #00ff4133}
header{display:flex;align-items:center;justify-content:space-between;padding:10px 18px;border-bottom:1px solid #00ff4133;background:#031007}
h1{font-size:1rem;letter-spacing:3px}.dim{color:#0a5f2e}
.fleet{display:grid;grid-template-columns:repeat(auto-fit,minmax(400px,1fr));gap:10px;padding:10px 18px}
.card{border:1px solid #00ff4144;background:#031007cc;border-radius:8px;padding:12px;box-shadow:0 0 18px #00ff4112 inset}
.card h2{font-size:.85rem;letter-spacing:2px;margin-bottom:8px}
.big{font-size:2rem;font-weight:800}
.small{font-size:.68rem;color:#2ea043}
.row{display:flex;justify-content:space-between;font-size:.72rem;padding:2px 0;border-bottom:1px dashed #00ff4122}
.row:last-child{border:none}
.up{color:#00ff41}.dn{color:#ff3131;text-shadow:0 0 8px #ff313166}.skip{color:#0a5f2e}
.dec{margin:2px 0;padding:3px 0;border-bottom:1px dashed #00ff4118;font-size:.7rem;line-height:1.5}
.dec .why{color:#0a5f2e;font-size:.62rem}
.dec .llm{color:#2ea043;font-size:.62rem;margin-top:1px}
.badge{display:inline-block;padding:1px 6px;border:1px solid;border-radius:3px;font-size:.6rem}
.badge.up{border-color:#00ff41;color:#00ff41}
.badge.dn{border-color:#ff3131;color:#ff3131}
.badge.skip{border-color:#0a5f2e;color:#0a5f2e}
#statusbar{position:fixed;bottom:0;left:0;right:0;z-index:100;display:flex;background:#010502f2;border-top:1px solid #00ff4166;backdrop-filter:blur(8px);font-size:.68rem;padding:0}
#statusbar .cell{padding:6px 12px;border-right:1px solid #00ff4122;white-space:nowrap}
#statusbar .lab{color:#0a5f2e;font-size:.55rem;letter-spacing:1px;display:block}
#statusbar .cell b{color:#00ff41}
</style></head><body>
<header><h1 class="glow">K4LSH1<span class="dim">_FLEET</span></h1><span id="tot"></span></header>
<div class="fleet" id="cards"></div>
<div id="statusbar"></div>
<script>
const C={DOGE:"#c2a633",SOL:"#9945FF",ETH:"#627EEA",XRP:"#00AAE4",BTC:"#f7931a"};
function fmt(c){return c<0?`<span class="dn">-${Math.abs(c)}¢</span>`:c>0?`<span class="up">+${c}¢</span>`:`<span class="skip">${c}¢</span>`}
function ago(ts){const d=Date.now()/1000-ts;return d<60?Math.floor(d)+'s':d<3600?Math.floor(d/60)+'m':Math.floor(d/3600)+'h'}
async function load(){
const[fr,sr]=await Promise.all([fetch('/api/fleet'),fetch('/api/state')]);
const d=await fr.json(),bs=await sr.json();
let h='',tp=0,to=0,tb=0,tw=0;
for(const[coin,v]of Object.entries(d)){
if(!v.mode||v.mode==='OFF')continue;
tp+=v.pnl||0;to+=v.live_bets||0;tb+=v.total_bets||0;tw+=v.won||0;
const wr=v.total_bets>0?((v.won||0)/v.total_bets*100).toFixed(0):'-';
h+=`<div class="card" style="border-color:${C[coin]}44">
<h2 style="color:${C[coin]}">${v.label} <span class="badge ${v.mode==='AUTO'?'up':'dn'}">${v.mode}</span></h2>
<div class="big">$${(v.price||0).toFixed(v.price<1?6:2)}</div>
<div class="row"><span>24h</span><span class="${(v.chg24||0)>=0?'up':'dn'}">${(v.chg24||0).toFixed(2)}%</span></div>
<div class="row"><span>RSI</span><span>${v.rsi||'-'}</span></div>
<div class="row"><span>Momentum</span><span>${v.mom||'-'}</span></div>
<div class="row"><span>WR</span><span>${wr}% (${v.won||0}W)</span></div>
<div class="row"><span>Open</span><span>${v.live_bets||0}</span></div>
<div class="row"><span>PNL</span><span>${fmt(v.pnl||0)}</span></div>
<div class="row"><span>Stake</span><span>${v.stake||10}¢</span></div>
${(v.decisions||[]).slice(0,2).map(dec=>`
<div class="dec"><span class="badge ${dec.final==='UP'?'up':dec.final==='DOWN'?'dn':'skip'}">${dec.final}</span>
<span class="small">${ago(dec.ts)}</span>
<div class="llm">LLM: ${dec.llm_vote} conf=${dec.llm_conf}</div>
<div class="why">${(dec.reason||'').substring(0,110)}</div></div>`).join('')}
</div>`;
}
document.getElementById('cards').innerHTML=h||'<div style="padding:20px">no coins</div>';
document.getElementById('statusbar').innerHTML=`<div class="cell"><span class="lab">BAL</span><b>$${(bs.balance/100).toFixed(2)}</b></div><div class="cell"><span class="lab">PNL</span>${fmt(tp)}</div><div class="cell"><span class="lab">OPEN</span>${to}</div><div class="cell"><span class="lab">REC</span>${tw}W/${tb-tw}L</div>`;
document.getElementById('tot').textContent=`PNL ${tp}¢`;
}
load();setInterval(load,15000);
</script></body></html>"""
def cfg():
return json.loads(CFG_PATH.read_text())
@app.route("/")
def index():
return HTML
@app.route("/api/state")
def state():
c = cfg()
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
now = time.time()
ticks = [r[0] for r in conn.execute("SELECT price FROM ticks WHERE ts > ? ORDER BY ts", (now-1800,)).fetchall()]
last_price = ticks[-1] if ticks else None
age = None
row = conn.execute("SELECT MAX(ts) FROM ticks").fetchone()
if row and row[0]: age = int(now - row[0])
dec = [{"t": time.strftime("%H:%M:%S", time.localtime(r["ts"])), "ticker": r["ticker"],
"momentum": r["momentum"], "llm_vote": r["llm_vote"], "llm_conf": r["llm_conf"],
"llm_why": r["llm_why"], "final": r["final"], "reason": r["reason"]}
for r in conn.execute("SELECT * FROM decisions ORDER BY id DESC LIMIT 30")]
orders = [{"t": time.strftime("%H:%M:%S", time.localtime(r["ts"])), "ticker": r["ticker"],
"side": r["side"], "count": r["count"], "price_cents": r["price_cents"], "dry": r["dry"]}
for r in conn.execute("SELECT * FROM orders ORDER BY id DESC LIMIT 20")]
events = [{"t": time.strftime("%H:%M:%S", time.localtime(r[0])), "level": r[1], "msg": r[2]}
for r in conn.execute("SELECT * FROM events ORDER BY ts DESC LIMIT 12")]
bets = conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
perf_rows = conn.execute("SELECT status, COALESCE(pnl_cents,0) FROM orders WHERE status IN ('won','lost')").fetchall()
won = sum(1 for s,p in perf_rows if s == 'won')
pnl = sum(p for s,p in perf_rows)
open_bets = conn.execute("SELECT COUNT(*) FROM orders WHERE status IN ('placed','posted')").fetchone()[0]
kv = {r[0]: r[1] for r in conn.execute("SELECT k, v FROM kv")}
conn.close()
# live market + balance (proxy through bot's client without importing heavy code)
market, balance, positions = {}, None, 0
try:
import sys
sys.path.insert(0, str(HOME))
from bot import Kalshi, btc_price, DEFAULT_CFG
kx = Kalshi(c["api_key_id"], c["key_path"])
mkts = kx.markets(c.get("series", "KXBTC15M"), "open", 2)
if mkts:
m = sorted(mkts, key=lambda x: x["close_time"])[-1]
from datetime import datetime
close_ts = datetime.fromisoformat(m["close_time"].replace("Z","+00:00")).timestamp()
book = kx.orderbook(m["ticker"])
market = {"ticker": m["ticker"], "title": m.get("title",""),
"mins_left": (close_ts-time.time())/60,
"yes_ask": (book.get("yes") or [[None]])[0][0],
"no_ask": (book.get("no") or [[None]])[0][0]}
b = kx.balance()
balance = b.get("balance", 0) # available cash (cents)
positions = len(kx.positions())
except Exception as e:
market = {"error": str(e)[:80]}
if last_price is None:
try:
from bot import btc_price
last_price = btc_price()
except Exception: pass
try:
from bot import intel as bot_intel
ix = bot_intel()
except Exception:
ix = {}
# momentum label
mom = "FLAT"
if len(ticks) > 10:
r = (ticks[-1]-ticks[0])/ticks[0]*100
mom = f"{'UP' if r>0 else 'DOWN'} {abs(r):.3f}%/30m"
return jsonify({"cfg": c, "btc": last_price, "price_age": age, "spark": ticks[-120:],
"momentum": mom, "market": market, "balance": balance,
"positions": positions, "bets_session": bets,
"decisions": dec, "orders": orders, "events": events,
"intel": ix,
"perf": {"open": open_bets, "resolved": len(perf_rows), "won": won,
"pnl": pnl, "min_conf": kv.get("min_conf", c.get("min_conf", 0.55))}})
@app.route("/api/control", methods=["POST"])
def control():
c = cfg()
d = request.get_json(force=True)
if d.get("mode") in ("AUTO","DOWN_SPAM","UP_SPAM","OFF"): c["mode"] = d["mode"]
if isinstance(d.get("stake_cents"), int): c["stake_cents"] = max(1, min(99, d["stake_cents"]))
c["kill"] = False
CFG_PATH.write_text(json.dumps(c, indent=1))
return jsonify({"ok": True})
@app.route("/api/toggle-dry", methods=["POST"])
def toggle_dry():
c = cfg(); c["dry_run"] = not c["dry_run"]; c["kill"] = False
CFG_PATH.write_text(json.dumps(c, indent=1))
return jsonify({"ok": True, "dry_run": c["dry_run"]})
@app.route("/api/fleet")
def fleet_api():
"""All active coins in one call — reads each coin's own DB."""
COINS = {"🐶DOGE": "#c2a633", "🔮SOL": "#9945FF", "💎ETH": "#627EEA", "🌊XRP": "#00AAE4", "₿BTC": "#f7931a"}
result = {}
for label, color in COINS.items():
coin = label[1:] # strip leading emoji
fpath = HOME / f"{coin.lower()}.json"
if not fpath.exists(): continue
c = json.loads(fpath.read_text())
if c.get("mode","OFF") == "OFF": continue
db_path = HOME / c.get("db_path", f"{coin.lower()}.db")
if not db_path.exists(): continue
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
now = time.time()
ticks = [r[0] for r in conn.execute("SELECT price FROM ticks WHERE ts > ? ORDER BY ts", (now-1800,)).fetchall()]
last_price = ticks[-1] if ticks else None
# enriched per-coin data — compute RSI + momentum directly (no cross-import)
decs = [dict(r) for r in conn.execute("SELECT * FROM decisions ORDER BY id DESC LIMIT 3")]
ords = [dict(r) for r in conn.execute("SELECT * FROM orders ORDER BY id DESC LIMIT 5")]
live_bets = conn.execute("SELECT COUNT(*) FROM orders WHERE status IN ('placed','posted') AND dry=0").fetchone()[0]
total_bets = conn.execute("SELECT COUNT(*) FROM orders WHERE dry=0").fetchone()[0]
pnl = conn.execute("SELECT COALESCE(SUM(pnl_cents),0) FROM orders WHERE dry=0").fetchone()[0]
won = conn.execute("SELECT COUNT(*) FROM orders WHERE status='won' AND dry=0").fetchone()[0]
# RSI
rows14 = conn.execute("SELECT price FROM ticks WHERE ts > ? ORDER BY ts DESC LIMIT 15", (now-900,)).fetchall()
c_rsi = None
if len(rows14) >= 15:
px = [r[0] for r in rows14]
gains = [max(0, b-a) for a, b in zip(px[:-1], px[1:])]
losses = [max(0, a-b) for a, b in zip(px[:-1], px[1:])]
ag = sum(gains)/14 if gains else 0; al = sum(losses)/14 if losses else 1e-9
c_rsi = 100 - 100/(1 + ag/al) if al else (100 if ag > 0 else 50)
# Momentum
rows_t = conn.execute("SELECT ts, price FROM ticks WHERE ts > ? ORDER BY ts", (now-1800,)).fetchall()
c_mom, c_score = "FLAT", 0.0
if len(rows_t) >= 4:
now_p = rows_t[-1][1]
ref5 = min(rows_t, key=lambda r: abs(r[0]-(now-300)))[1]
ref15 = min(rows_t, key=lambda r: abs(r[0]-(now-900)))[1]
r5 = (now_p-ref5)/ref5*100 if ref5 else 0; r15 = (now_p-ref15)/ref15*100 if ref15 else 0
sc = r5*0.6 + r15*0.4
if sc > 0.008: c_mom, c_score = "UP", abs(sc)
elif sc < -0.008: c_mom, c_score = "DOWN", abs(sc)
else: c_mom, c_score = "FLAT", abs(sc)
chg24 = c.get("chg24_cache", 0)
result[coin] = {"label": label, "color": color, "series": c["series"],
"price": last_price, "mode": c["mode"],
"live_bets": live_bets, "total_bets": total_bets,
"pnl": pnl, "won": won, "stake": c.get("stake_cents", 10),
"swing": c.get("swing_threshold_pct", 0.005),
"rsi": c_rsi, "mom": f"{c_mom} {c_score:.3f}%" if c_mom != "FLAT" else c_mom,
"chg24": chg24, "decisions": decs, "orders": ords}
conn.close()
return jsonify(result)
@app.route("/fleet")
def fleet_page():
return FLEET_HTML
def kill():
c = cfg(); c["kill"] = True
CFG_PATH.write_text(json.dumps(c, indent=1))
return jsonify({"ok": True})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5053, debug=False)