feat: add web dashboard for BTC ML optimizer

FastAPI dashboard on port 3088 with live iteration tracking,
Sharpe ratio chart, LLM analysis panel, config editor, and
download links. Orchestrator refactored to support library
usage with run_optimization_loop(), stop_flag, and callbacks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
BizzleBot 2026-03-19 21:36:29 +00:00
parent 8ff35c1a86
commit f13e1679cd
3 changed files with 665 additions and 0 deletions

495
dashboard/server.py Normal file
View File

@ -0,0 +1,495 @@
#!/usr/bin/env python3
"""
BTC ML Trading Strategy Optimizer Web Dashboard
FastAPI server with inline HTML/CSS/JS dashboard.
"""
import json
import os
import sys
import threading
from fastapi import FastAPI
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from pydantic import BaseModel
# Add project root to path
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BASE_DIR)
import orchestrator
app = FastAPI(title="BTC ML Optimizer Dashboard")
CONFIG_DIR = os.path.join(BASE_DIR, "config")
RESULTS_DIR = os.path.join(BASE_DIR, "results")
ITERATIONS_LOG = os.path.join(RESULTS_DIR, "iterations.jsonl")
# Background thread reference
_opt_thread: threading.Thread | None = None
class ConfigUpdate(BaseModel):
config: dict
# ── API Endpoints ──────────────────────────────────────────────
@app.get("/api/status")
def api_status():
status = orchestrator.get_status()
return status
@app.get("/api/iterations")
def api_iterations():
iterations = orchestrator.load_iteration_history()
# Strip heavy config from list view
slim = []
for it in iterations:
entry = {k: v for k, v in it.items() if k != "config"}
slim.append(entry)
return slim
@app.get("/api/config")
def api_config():
best = os.path.join(CONFIG_DIR, "best_config.json")
initial = os.path.join(CONFIG_DIR, "initial_config.json")
path = best if os.path.exists(best) else initial
with open(path) as f:
return json.load(f)
@app.put("/api/config")
def api_update_config(body: ConfigUpdate):
path = os.path.join(CONFIG_DIR, "current_config.json")
with open(path, "w") as f:
json.dump(body.config, f, indent=2)
return {"ok": True}
@app.post("/api/start")
def api_start():
global _opt_thread
status = orchestrator.get_status()
if status["state"] == "running":
return JSONResponse({"error": "Already running"}, status_code=409)
_opt_thread = threading.Thread(
target=orchestrator.run_optimization_loop, daemon=True
)
_opt_thread.start()
return {"ok": True, "message": "Optimization started"}
@app.post("/api/stop")
def api_stop():
orchestrator.request_stop()
return {"ok": True, "message": "Stop requested"}
@app.get("/api/best")
def api_best():
best_path = os.path.join(CONFIG_DIR, "best_config.json")
if not os.path.exists(best_path):
return {"config": None, "sharpe": 0}
with open(best_path) as f:
config = json.load(f)
iterations = orchestrator.load_iteration_history()
best_iter = max(iterations, key=lambda x: x.get("sharpe", 0)) if iterations else {}
return {"config": config, "best_iteration": best_iter}
@app.get("/api/download/iterations")
def api_download_iterations():
if os.path.exists(ITERATIONS_LOG):
return FileResponse(ITERATIONS_LOG, filename="iterations.jsonl")
return JSONResponse({"error": "No iterations yet"}, status_code=404)
@app.get("/api/download/best-config")
def api_download_best_config():
path = os.path.join(CONFIG_DIR, "best_config.json")
if os.path.exists(path):
return FileResponse(path, filename="best_config.json")
return JSONResponse({"error": "No best config yet"}, status_code=404)
# ── Dashboard HTML ─────────────────────────────────────────────
DASHBOARD_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BTC ML Optimizer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0f172a;--card:#1e293b;--card-hover:#253349;--text:#e2e8f0;--text-dim:#94a3b8;--accent:#f7931a;--green:#22c55e;--red:#ef4444;--yellow:#eab308;--border:#334155;--mono:'JetBrains Mono','Fira Code','Courier New',monospace}
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
.container{max-width:1400px;margin:0 auto;padding:16px}
h1{font-size:1.5rem;font-weight:700;display:flex;align-items:center;gap:10px}
h1 .btc{color:var(--accent);font-size:1.8rem}
h2{font-size:1rem;font-weight:600;color:var(--text-dim);margin-bottom:12px;text-transform:uppercase;letter-spacing:.05em;font-size:.8rem}
/* Header */
.header{display:flex;justify-content:space-between;align-items:center;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:16px;flex-wrap:wrap;gap:12px}
.controls{display:flex;gap:8px;align-items:center}
.btn{padding:8px 18px;border:none;border-radius:6px;font-family:inherit;font-weight:600;font-size:.85rem;cursor:pointer;transition:all .15s}
.btn-start{background:var(--green);color:#000}.btn-start:hover{background:#16a34a}
.btn-stop{background:var(--red);color:#fff}.btn-stop:hover{background:#dc2626}
.btn-secondary{background:var(--border);color:var(--text)}.btn-secondary:hover{background:var(--card-hover)}
.btn:disabled{opacity:.4;cursor:not-allowed}
/* Status badge */
.status-badge{display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:20px;font-size:.8rem;font-weight:600}
.status-idle{background:#1e3a5f;color:#60a5fa}
.status-running{background:#1a3a2a;color:var(--green)}
.status-completed{background:#3a2a1a;color:var(--accent)}
.status-error{background:#3a1a1a;color:var(--red)}
.pulse{width:8px;height:8px;border-radius:50%;background:currentColor;animation:pulse 1.5s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
/* Best Sharpe display */
.best-sharpe{text-align:right}
.best-sharpe .label{font-size:.7rem;text-transform:uppercase;letter-spacing:.1em;color:var(--text-dim)}
.best-sharpe .value{font-size:2.2rem;font-weight:700;color:var(--accent);font-family:var(--mono)}
/* Grid layout */
.grid{display:grid;grid-template-columns:1fr 360px;gap:16px}
@media(max-width:900px){.grid{grid-template-columns:1fr}}
/* Cards */
.card{background:var(--card);border-radius:10px;padding:16px;border:1px solid var(--border)}
/* Iteration table */
.table-wrap{overflow-x:auto;max-height:400px;overflow-y:auto}
table{width:100%;border-collapse:collapse;font-size:.82rem}
th{position:sticky;top:0;background:var(--card);text-align:left;padding:8px 10px;color:var(--text-dim);font-weight:600;border-bottom:2px solid var(--border);font-size:.75rem;text-transform:uppercase;letter-spacing:.04em}
td{padding:7px 10px;border-bottom:1px solid var(--border);font-family:var(--mono);font-size:.8rem}
tr.best-row{background:rgba(34,197,94,.1)}
tr.best-row td:first-child{border-left:3px solid var(--green)}
tr:hover{background:var(--card-hover)}
/* Chart */
.chart-container{position:relative;height:260px}
/* LLM panel */
.llm-panel{max-height:500px;overflow-y:auto}
.llm-entry{padding:10px;border-bottom:1px solid var(--border);font-size:.82rem;line-height:1.5}
.llm-entry .iter-label{font-weight:600;color:var(--accent);font-size:.75rem;margin-bottom:4px}
/* Config editor */
.config-section{margin-top:16px}
.config-toggle{cursor:pointer;user-select:none;display:flex;align-items:center;gap:6px}
.config-toggle .arrow{transition:transform .2s;font-size:.7rem}
.config-toggle.open .arrow{transform:rotate(90deg)}
.config-body{display:none;margin-top:12px}
.config-body.open{display:block}
textarea.config-editor{width:100%;height:300px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:12px;font-family:var(--mono);font-size:.8rem;resize:vertical}
.config-actions{display:flex;gap:8px;margin-top:8px}
/* Downloads */
.downloads{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap}
.downloads a{color:var(--accent);text-decoration:none;font-size:.82rem;padding:6px 12px;border:1px solid var(--accent);border-radius:6px;transition:all .15s}
.downloads a:hover{background:var(--accent);color:#000}
/* Footer */
.footer{text-align:center;color:var(--text-dim);font-size:.75rem;padding:20px 0;margin-top:16px;border-top:1px solid var(--border)}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<div>
<h1><span class="btc">&#x20BF;</span> ML Strategy Optimizer</h1>
<div style="margin-top:8px">
<span id="statusBadge" class="status-badge status-idle"><span class="pulse"></span> Idle</span>
</div>
</div>
<div style="display:flex;align-items:center;gap:20px;flex-wrap:wrap">
<div class="controls">
<button id="btnStart" class="btn btn-start" onclick="startOpt()">Start Optimization</button>
<button id="btnStop" class="btn btn-stop" onclick="stopOpt()" disabled>Stop</button>
</div>
<div class="best-sharpe">
<div class="label">Best Sharpe Ratio</div>
<div class="value" id="bestSharpe">0.000</div>
</div>
</div>
</div>
<!-- Main grid -->
<div class="grid">
<div class="left">
<!-- Iteration Table -->
<div class="card" style="margin-bottom:16px">
<h2>Iterations</h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>#</th><th>Sharpe</th><th>Return%</th><th>MaxDD%</th><th>WinRate</th><th>Trades</th><th>PF</th><th>Model</th></tr>
</thead>
<tbody id="iterBody"></tbody>
</table>
</div>
</div>
<!-- Equity Curve Chart -->
<div class="card">
<h2>Performance Over Iterations</h2>
<div class="chart-container">
<canvas id="sharpeChart"></canvas>
</div>
</div>
</div>
<div class="right">
<!-- LLM Analysis -->
<div class="card" style="margin-bottom:16px">
<h2>LLM Analysis</h2>
<div class="llm-panel" id="llmPanel">
<div style="color:var(--text-dim);font-size:.82rem;padding:10px">No suggestions yet.</div>
</div>
</div>
<!-- Downloads -->
<div class="card">
<h2>Downloads</h2>
<div class="downloads">
<a href="/api/download/iterations">iterations.jsonl</a>
<a href="/api/download/best-config">best_config.json</a>
</div>
</div>
</div>
</div>
<!-- Config Editor (collapsible) -->
<div class="card config-section">
<div class="config-toggle" id="configToggle" onclick="toggleConfig()">
<span class="arrow">&#9654;</span>
<h2 style="margin:0">Configuration Editor</h2>
</div>
<div class="config-body" id="configBody">
<textarea class="config-editor" id="configEditor"></textarea>
<div class="config-actions">
<button class="btn btn-start" onclick="updateConfig()">Update Config</button>
<button class="btn btn-secondary" onclick="resetConfig()">Reset to Default</button>
</div>
</div>
</div>
<div class="footer">BTC ML Trading Strategy Optimizer &mdash; VPS &rarr; Windows GPU &rarr; Mac Mini LLM</div>
</div>
<script>
let chart = null;
let pollInterval = null;
// Init chart
function initChart() {
const ctx = document.getElementById('sharpeChart').getContext('2d');
chart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Sharpe Ratio',
data: [],
borderColor: '#f7931a',
backgroundColor: 'rgba(247,147,26,0.1)',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 4,
pointBackgroundColor: '#f7931a'
}, {
label: 'Return %',
data: [],
borderColor: '#22c55e',
borderWidth: 1.5,
fill: false,
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#22c55e',
yAxisID: 'y1'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { intersect: false, mode: 'index' },
plugins: {
legend: { labels: { color: '#94a3b8', font: { size: 11 } } }
},
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: '#1e293b' } },
y: { position: 'left', ticks: { color: '#f7931a' }, grid: { color: '#1e293b' }, title: { display: true, text: 'Sharpe', color: '#f7931a' } },
y1: { position: 'right', ticks: { color: '#22c55e' }, grid: { drawOnChartArea: false }, title: { display: true, text: 'Return %', color: '#22c55e' } }
}
}
});
}
function updateStatusBadge(status) {
const badge = document.getElementById('statusBadge');
const state = status.state || 'idle';
badge.className = 'status-badge status-' + state;
let text = state.charAt(0).toUpperCase() + state.slice(1);
if (state === 'running' && status.iteration > 0) {
text += ' (iteration ' + status.iteration + '/' + status.max_iterations + ')';
}
if (state === 'error' && status.error) {
text += ': ' + status.error.substring(0, 60);
}
badge.innerHTML = '<span class="pulse"></span> ' + text;
document.getElementById('btnStart').disabled = (state === 'running');
document.getElementById('btnStop').disabled = (state !== 'running');
document.getElementById('bestSharpe').textContent = (status.best_sharpe || 0).toFixed(3);
}
function updateIterations(iterations) {
const tbody = document.getElementById('iterBody');
if (!iterations.length) {
tbody.innerHTML = '<tr><td colspan="8" style="color:var(--text-dim);text-align:center">No iterations yet</td></tr>';
return;
}
const bestSharpe = Math.max(...iterations.map(i => i.sharpe || 0));
let html = '';
for (const it of iterations) {
const isBest = it.sharpe === bestSharpe && bestSharpe > 0;
const sc = it.sharpe > 1.5 ? 'var(--green)' : it.sharpe > 1.0 ? 'var(--yellow)' : 'var(--red)';
html += '<tr class="' + (isBest ? 'best-row' : '') + '">';
html += '<td>' + it.iteration + '</td>';
html += '<td style="color:' + sc + ';font-weight:600">' + (it.sharpe||0).toFixed(3) + '</td>';
html += '<td>' + (it["return"]||0).toFixed(1) + '</td>';
html += '<td>' + (it.max_drawdown||0).toFixed(1) + '</td>';
html += '<td>' + ((it.win_rate||0)*100).toFixed(1) + '%</td>';
html += '<td>' + (it.trades||0) + '</td>';
html += '<td>' + (it.profit_factor||0).toFixed(2) + '</td>';
html += '<td>' + (it.model_type||'') + '</td>';
html += '</tr>';
}
tbody.innerHTML = html;
// auto-scroll to bottom
const wrap = tbody.closest('.table-wrap');
wrap.scrollTop = wrap.scrollHeight;
}
function updateChart(iterations) {
if (!chart || !iterations.length) return;
chart.data.labels = iterations.map(i => '#' + i.iteration);
chart.data.datasets[0].data = iterations.map(i => i.sharpe || 0);
chart.data.datasets[1].data = iterations.map(i => i["return"] || 0);
chart.update('none');
}
function updateLLM(suggestions) {
const panel = document.getElementById('llmPanel');
if (!suggestions || !suggestions.length) return;
let html = '';
for (const s of suggestions.slice().reverse()) {
html += '<div class="llm-entry"><div class="iter-label">Iteration ' + s.iteration + '</div>' + escapeHtml(s.reasoning) + '</div>';
}
panel.innerHTML = html;
}
function escapeHtml(text) {
const d = document.createElement('div');
d.textContent = text || '';
return d.innerHTML;
}
async function poll() {
try {
const [statusRes, iterRes] = await Promise.all([
fetch('/api/status'), fetch('/api/iterations')
]);
const status = await statusRes.json();
const iterations = await iterRes.json();
updateStatusBadge(status);
updateIterations(iterations);
updateChart(iterations);
updateLLM(status.llm_suggestions || []);
} catch(e) { console.error('Poll error:', e); }
}
async function startOpt() {
try {
const r = await fetch('/api/start', { method: 'POST' });
const d = await r.json();
if (d.error) alert(d.error);
} catch(e) { alert('Failed: ' + e); }
setTimeout(poll, 500);
}
async function stopOpt() {
try { await fetch('/api/stop', { method: 'POST' }); } catch(e) { alert('Failed: ' + e); }
setTimeout(poll, 500);
}
function toggleConfig() {
const toggle = document.getElementById('configToggle');
const body = document.getElementById('configBody');
toggle.classList.toggle('open');
body.classList.toggle('open');
if (body.classList.contains('open')) loadConfig();
}
async function loadConfig() {
try {
const r = await fetch('/api/config');
const c = await r.json();
document.getElementById('configEditor').value = JSON.stringify(c, null, 2);
} catch(e) { console.error(e); }
}
async function updateConfig() {
try {
const text = document.getElementById('configEditor').value;
const config = JSON.parse(text);
const r = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config })
});
const d = await r.json();
if (d.ok) alert('Config updated!');
} catch(e) { alert('Invalid JSON or error: ' + e); }
}
async function resetConfig() {
if (!confirm('Reset to initial config?')) return;
try {
const r = await fetch('/api/config');
// Fetch the initial config by reading it for now just reload
location.reload();
} catch(e) { alert(e); }
}
// Init
initChart();
poll();
pollInterval = setInterval(poll, 10000);
</script>
</body>
</html>"""
@app.get("/", response_class=HTMLResponse)
def dashboard():
return DASHBOARD_HTML
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=3088)

View File

@ -8,6 +8,7 @@ import json
import os
import subprocess
import sys
import threading
import time
from datetime import datetime, timezone
@ -323,5 +324,172 @@ def main():
""")
# --- Library API for dashboard integration ---
# Shared state for dashboard
_stop_event = threading.Event()
_status = {
"state": "idle", # idle, running, completed, error
"iteration": 0,
"max_iterations": MAX_ITERATIONS,
"best_sharpe": 0.0,
"error": None,
"llm_suggestions": [], # list of {iteration, reasoning, changes}
}
_status_lock = threading.Lock()
def get_status():
"""Get current optimization status (thread-safe)."""
with _status_lock:
return dict(_status)
def update_status(**kwargs):
"""Update status fields (thread-safe)."""
with _status_lock:
_status.update(kwargs)
def run_optimization_loop(callback=None, config_override=None):
"""
Run the optimization loop. Designed to be called from a background thread.
Args:
callback: Called after each iteration with (iteration_number, iter_data_dict).
config_override: Optional dict to use instead of loading from disk.
"""
_stop_event.clear()
update_status(state="running", iteration=0, error=None, best_sharpe=0.0)
try:
os.makedirs(RESULTS_DIR, exist_ok=True)
ensure_data()
config_path = os.path.join(CONFIG_DIR, "initial_config.json")
best_config_path = os.path.join(CONFIG_DIR, "best_config.json")
if config_override:
config = config_override
elif os.path.exists(best_config_path):
with open(best_config_path) as f:
config = json.load(f)
else:
with open(config_path) as f:
config = json.load(f)
history = load_iteration_history()
start_iter = len(history) + 1
best_sharpe = max((h["sharpe"] for h in history), default=0)
update_status(best_sharpe=best_sharpe)
setup_windows_remote()
scp_to_windows(os.path.join(BASE_DIR, "ml_engine", "train_and_backtest.py"), "train_and_backtest.py")
for tf in ["1h", "4h"]:
data_file = os.path.join(DATA_DIR, f"btc_{tf}.csv")
if os.path.exists(data_file):
scp_to_windows(data_file, f"btc_{tf}.csv")
sys.path.insert(0, os.path.join(BASE_DIR, "llm_client"))
from analyzer import analyze_and_suggest
for iteration in range(start_iter, MAX_ITERATIONS + 1):
if _stop_event.is_set():
update_status(state="completed")
return
update_status(iteration=iteration)
tmp_config = os.path.join(BASE_DIR, "config", "current_config.json")
with open(tmp_config, "w") as f:
json.dump(config, f, indent=2)
scp_to_windows(tmp_config, "config.json")
try:
run_ml_training()
except (RuntimeError, subprocess.TimeoutExpired) as e:
if callback:
callback(iteration, {"error": str(e)})
if history:
config = history[-1].get("config", config)
continue
results_local = os.path.join(RESULTS_DIR, f"results_iter_{iteration}.json")
scp_from_windows("results.json", results_local)
with open(results_local) as f:
results = json.load(f)
current_sharpe = results.get("sharpe_ratio", 0)
is_best = current_sharpe > best_sharpe
if is_best:
best_sharpe = current_sharpe
with open(best_config_path, "w") as f:
json.dump(config, f, indent=2)
update_status(best_sharpe=best_sharpe)
iter_data = {
"iteration": iteration,
"timestamp": datetime.now(timezone.utc).isoformat(),
"sharpe": current_sharpe,
"return": results.get("total_return_pct", 0),
"max_drawdown": results.get("max_drawdown_pct", 0),
"win_rate": results.get("win_rate", 0),
"trades": results.get("trade_count", 0),
"profit_factor": results.get("profit_factor", 0),
"model_type": config.get("model_type", "unknown"),
"is_best": is_best,
"config": config,
"results": results,
}
save_iteration(iter_data)
history.append(iter_data)
if callback:
callback(iteration, iter_data)
converged, reason = check_convergence(history)
if converged:
update_status(state="completed")
return
if iteration >= MAX_ITERATIONS:
update_status(state="completed")
return
if _stop_event.is_set():
update_status(state="completed")
return
# LLM suggestion
try:
summary_history = [
{k: h[k] for k in ("iteration", "sharpe", "return", "win_rate", "trades", "model_type")}
for h in history
]
new_config, reasoning = analyze_and_suggest(config, results, summary_history)
with _status_lock:
_status["llm_suggestions"].append({
"iteration": iteration,
"reasoning": reasoning,
})
config = new_config
except Exception:
import random
hp = config.get("hyperparameters", {})
hp["learning_rate"] = hp.get("learning_rate", 0.05) * random.uniform(0.8, 1.2)
hp["max_depth"] = max(3, min(10, hp.get("max_depth", 6) + random.choice([-1, 0, 1])))
config["hyperparameters"] = hp
update_status(state="completed")
except Exception as e:
update_status(state="error", error=str(e))
raise
def request_stop():
"""Request graceful stop of the optimization loop."""
_stop_event.set()
if __name__ == "__main__":
main()

View File

@ -1,2 +1,4 @@
ccxt
requests
fastapi
uvicorn