BizzleBot aba30f7718 fix: LLM analysis + new run button + settings page support
- Fixed LLM failing silently (401 auth error on every iteration)
- Reset provider to Ollama (working) from broken OpenRouter config
- Added /api/clear endpoint + 'New Run' button to reset history
- LLM failures now logged visibly with error details
- LLM suggestions persisted to iteration data (survive restarts)
- Settings page support via llm_settings.json (multi-provider)
2026-03-20 21:51:05 +00:00

1037 lines
38 KiB
Python

#!/usr/bin/env python3
"""
BTC Accumulation Signal Optimizer -- Web Dashboard
FastAPI server with inline HTML/CSS/JS dashboard.
"""
import json
import os
import sys
import threading
import requests
from fastapi import FastAPI
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from pydantic import BaseModel
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BASE_DIR)
import orchestrator
app = FastAPI(title="BTC Accumulation Signal Optimizer")
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")
LLM_SETTINGS_PATH = os.path.join(CONFIG_DIR, "llm_settings.json")
_opt_thread = None
class ConfigUpdate(BaseModel):
config: dict
class LLMSettingsUpdate(BaseModel):
provider: str
model: str
providers: dict
class TestConnectionRequest(BaseModel):
provider: str
providers: dict
class FetchModelsRequest(BaseModel):
provider: str
providers: dict
def _load_llm_settings():
if os.path.exists(LLM_SETTINGS_PATH):
with open(LLM_SETTINGS_PATH) as f:
return json.load(f)
return {
"provider": "ollama",
"model": "qwen3.5:27b",
"providers": {
"ollama": {"base_url": "http://100.100.242.21:11434"},
"lmstudio": {"base_url": "http://100.100.242.21:1234"},
"openai": {"api_key": ""},
"anthropic": {"api_key": ""},
"openrouter": {"api_key": ""},
},
}
def _mask_api_key(key):
if not key or len(key) < 8:
return ""
return "••••••••" + key[-4:]
def _safe_settings(settings):
"""Return settings with API keys masked."""
out = json.loads(json.dumps(settings))
for name, cfg in out.get("providers", {}).items():
if "api_key" in cfg:
cfg["api_key"] = _mask_api_key(cfg["api_key"])
return out
def _merge_api_keys(new_providers, existing_providers):
"""Preserve existing API keys when the incoming value is masked."""
for name, cfg in new_providers.items():
if "api_key" in cfg:
masked = cfg["api_key"]
if masked.startswith("••••") or masked == "":
existing_key = existing_providers.get(name, {}).get("api_key", "")
cfg["api_key"] = existing_key
@app.get("/api/status")
def api_status():
return orchestrator.get_status()
@app.get("/api/iterations")
def api_iterations():
iterations = orchestrator.load_iteration_history()
slim = []
for it in iterations:
entry = {k: v for k, v in it.items() if k not in ("config", "results")}
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, "best_score": 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("cost_improvement", 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)
# ── Settings API ──────────────────────────────────────────────────────────
@app.get("/api/settings")
def api_get_settings():
settings = _load_llm_settings()
return _safe_settings(settings)
@app.post("/api/settings")
def api_save_settings(body: LLMSettingsUpdate):
existing = _load_llm_settings()
new_settings = {
"provider": body.provider,
"model": body.model,
"providers": body.providers,
}
_merge_api_keys(new_settings["providers"], existing.get("providers", {}))
with open(LLM_SETTINGS_PATH, "w") as f:
json.dump(new_settings, f, indent=2)
return {"ok": True, "message": "Settings saved"}
@app.post("/api/settings/test")
def api_test_connection(body: TestConnectionRequest):
"""Test connection to a provider and return available models."""
existing = _load_llm_settings()
providers = json.loads(json.dumps(body.providers))
_merge_api_keys(providers, existing.get("providers", {}))
provider = body.provider
try:
models = _fetch_models(provider, providers)
return {"ok": True, "models": models, "message": f"Connected — {len(models)} model(s) found"}
except requests.exceptions.ConnectionError:
return JSONResponse(
{"ok": False, "error": "Connection refused — is the server running?"},
status_code=502,
)
except requests.exceptions.Timeout:
return JSONResponse(
{"ok": False, "error": "Connection timed out"},
status_code=504,
)
except Exception as e:
return JSONResponse(
{"ok": False, "error": str(e)},
status_code=500,
)
@app.post("/api/settings/models")
def api_fetch_models(body: FetchModelsRequest):
"""Fetch available models for a provider (proxied to avoid CORS)."""
existing = _load_llm_settings()
providers = json.loads(json.dumps(body.providers))
_merge_api_keys(providers, existing.get("providers", {}))
try:
models = _fetch_models(body.provider, providers)
return {"ok": True, "models": models}
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
def _fetch_models(provider, providers):
"""Fetch model list from a provider. Returns list of {id, name}."""
cfg = providers.get(provider, {})
if provider == "ollama":
base_url = cfg.get("base_url", "http://100.100.242.21:11434")
resp = requests.get(f"{base_url}/api/tags", timeout=10)
resp.raise_for_status()
data = resp.json()
return [{"id": m["name"], "name": m["name"]} for m in data.get("models", [])]
elif provider == "lmstudio":
base_url = cfg.get("base_url", "http://100.100.242.21:1234")
resp = requests.get(f"{base_url}/v1/models", timeout=10)
resp.raise_for_status()
data = resp.json()
return [{"id": m["id"], "name": m["id"]} for m in data.get("data", [])]
elif provider == "openai":
api_key = cfg.get("api_key", "")
if not api_key:
raise ValueError("OpenAI API key is required")
resp = requests.get(
"https://api.openai.com/v1/models",
headers={"Authorization": f"Bearer {api_key}"},
timeout=15,
)
resp.raise_for_status()
data = resp.json()
models = [m for m in data.get("data", []) if m["id"].startswith("gpt-") or "chat" in m.get("id", "")]
models.sort(key=lambda m: m["id"])
return [{"id": m["id"], "name": m["id"]} for m in models]
elif provider == "anthropic":
api_key = cfg.get("api_key", "")
if not api_key:
raise ValueError("Anthropic API key is required")
resp = requests.get(
"https://api.anthropic.com/v1/models",
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
},
timeout=15,
)
resp.raise_for_status()
data = resp.json()
return [{"id": m["id"], "name": m.get("display_name", m["id"])} for m in data.get("data", [])]
elif provider == "openrouter":
resp = requests.get("https://openrouter.ai/api/v1/models", timeout=15)
resp.raise_for_status()
data = resp.json()
models = data.get("data", [])
models.sort(key=lambda m: m.get("id", ""))
return [{"id": m["id"], "name": m.get("name", m["id"])} for m in models[:200]]
else:
raise ValueError(f"Unknown provider: {provider}")
# ── HTML Pages ────────────────────────────────────────────────────────────
# Shared CSS used by both pages
SHARED_CSS = """
*,*::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;--cyan:#22d3ee}
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{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}
.nav{display:flex;gap:4px;align-items:center}
.nav a{color:var(--text-dim);text-decoration:none;font-size:.85rem;font-weight:600;padding:6px 14px;border-radius:6px;transition:all .15s}
.nav a:hover{color:var(--text);background:var(--card)}
.nav a.active{color:var(--cyan);background:var(--card);border:1px solid var(--border)}
.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-accent{background:var(--accent);color:#000}.btn-accent:hover{background:#e8850f}
.btn-cyan{background:var(--cyan);color:#000}.btn-cyan:hover{background:#06b6d4}
.btn:disabled{opacity:.4;cursor:not-allowed}
.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-score{text-align:right}
.best-score .label{font-size:.7rem;text-transform:uppercase;letter-spacing:.1em;color:var(--text-dim)}
.best-score .value{font-size:2.2rem;font-weight:700;color:var(--accent);font-family:var(--mono)}
.best-score .unit{font-size:1rem;color:var(--text-dim)}
.card{background:var(--card);border-radius:10px;padding:16px;border:1px solid var(--border)}
.footer{text-align:center;color:var(--text-dim);font-size:.75rem;padding:20px 0;margin-top:16px;border-top:1px solid var(--border)}
.toast{position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:8px;font-size:.85rem;font-weight:600;z-index:9999;opacity:0;transform:translateY(-10px);transition:all .3s;pointer-events:none}
.toast.show{opacity:1;transform:translateY(0)}
.toast-success{background:var(--green);color:#000}
.toast-error{background:var(--red);color:#fff}
"""
# Shared HTML head
SHARED_HEAD = """<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">"""
# Navigation bar HTML (parameterized via JS to highlight active page)
NAV_HTML = """<div class="nav">
<a href="/" id="nav-dashboard">Dashboard</a>
<a href="/settings" id="nav-settings">&#9881; Settings</a>
</div>"""
# Toast JS helper (shared)
TOAST_JS = """
function showToast(msg, type) {
let t = document.getElementById('toast');
if (!t) {
t = document.createElement('div');
t.id = 'toast';
t.className = 'toast';
document.body.appendChild(t);
}
t.textContent = msg;
t.className = 'toast toast-' + type + ' show';
setTimeout(() => { t.classList.remove('show'); }, 3500);
}
"""
DASHBOARD_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
""" + SHARED_HEAD + """
<title>BTC Accumulation Signal Optimizer</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<style>
""" + SHARED_CSS + """
.grid{display:grid;grid-template-columns:1fr 360px;gap:16px}
@media(max-width:900px){.grid{grid-template-columns:1fr}}
.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-container{position:relative;height:260px}
.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-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{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}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<h1><span class="btc">&#x20BF;</span> Accumulation Signal Optimizer</h1>
<div style="margin-top:8px;display:flex;align-items:center;gap:12px">
<span id="statusBadge" class="status-badge status-idle"><span class="pulse"></span> Idle</span>
""" + NAV_HTML + """
</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>
<button id="btnClear" class="btn btn-secondary" onclick="clearHistory()" title="Clear all iteration history and start fresh">New Run</button>
</div>
<div class="best-score">
<div class="label">Best Cost Improvement</div>
<div class="value" id="bestScore">0.0<span class="unit">%</span></div>
</div>
</div>
</div>
<div class="grid">
<div class="left">
<div class="card" style="margin-bottom:16px">
<h2>Iterations</h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>#</th><th>Cost Imp%</th><th>Signals</th><th>Frequency</th><th>R2</th><th>Bottoms</th><th>Tops</th><th>Model</th></tr>
</thead>
<tbody id="iterBody"></tbody>
</table>
</div>
</div>
<div class="card">
<h2>Cost Improvement Over Iterations</h2>
<div class="chart-container">
<canvas id="mainChart"></canvas>
</div>
</div>
</div>
<div class="right">
<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>
<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>
<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 Accumulation Signal Optimizer &mdash; VPS &rarr; Windows GPU &rarr; Mac Mini LLM</div>
</div>
<script>
document.getElementById('nav-dashboard').classList.add('active');
""" + TOAST_JS + """
let chart = null;
let pollInterval = null;
function initChart() {
const ctx = document.getElementById('mainChart').getContext('2d');
chart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Cost Improvement %',
data: [],
borderColor: '#f7931a',
backgroundColor: 'rgba(247,147,26,0.1)',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 4,
pointBackgroundColor: '#f7931a'
}, {
label: 'Signal Count',
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: 'Cost Improvement %', color: '#f7931a' } },
y1: { position: 'right', ticks: { color: '#22c55e' }, grid: { drawOnChartArea: false }, title: { display: true, text: 'Signal Count', 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('btnClear').disabled = (state === 'running');
document.getElementById('bestScore').innerHTML = (status.best_score || 0).toFixed(1) + '<span class="unit">%</span>';
}
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 bestCI = Math.max(...iterations.map(i => i.cost_improvement || 0));
let html = '';
for (const it of iterations) {
const isBest = it.cost_improvement === bestCI && bestCI > 0;
const sc = it.cost_improvement > 15 ? 'var(--green)' : it.cost_improvement > 10 ? 'var(--yellow)' : 'var(--red)';
html += '<tr class="' + (isBest ? 'best-row' : '') + '">';
html += '<td>' + it.iteration + '</td>';
html += '<td style="color:' + sc + ';font-weight:600">' + (it.cost_improvement||0).toFixed(1) + '</td>';
html += '<td>' + (it.signal_count||0) + '</td>';
html += '<td>' + (it.signal_frequency||0).toFixed(1) + '%</td>';
html += '<td>' + (it.r2_score||0).toFixed(4) + '</td>';
html += '<td>' + (it.score_at_bottoms||0).toFixed(1) + '</td>';
html += '<td>' + (it.score_at_tops||0).toFixed(1) + '</td>';
html += '<td>' + (it.model_type||'-') + '</td>';
html += '</tr>';
}
tbody.innerHTML = html;
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.cost_improvement || 0);
chart.data.datasets[1].data = iterations.map(i => i.signal_count || 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);
}
async function clearHistory() {
if (!confirm('Clear ALL iteration history and start a fresh run? This cannot be undone.')) return;
try {
const r = await fetch('/api/clear', { method: 'POST' });
const d = await r.json();
if (d.ok) {
document.getElementById('llmPanel').innerHTML = '<div style="color:var(--text-dim);font-size:.82rem;padding:10px">History cleared. Ready for fresh run.</div>';
setTimeout(poll, 500);
} else { alert('Failed: ' + (d.detail || 'unknown')); }
} catch(e) { alert('Failed: ' + e); }
}
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) showToast('Config updated!', 'success');
} catch(e) { showToast('Invalid JSON or error: ' + e, 'error'); }
}
async function resetConfig() {
if (!confirm('Reset to initial config?')) return;
try { location.reload(); } catch(e) { alert(e); }
}
initChart();
poll();
pollInterval = setInterval(poll, 10000);
</script>
</body>
</html>"""
SETTINGS_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
""" + SHARED_HEAD + """
<title>Settings — BTC Accumulation Signal Optimizer</title>
<style>
""" + SHARED_CSS + """
.settings-grid{display:grid;grid-template-columns:320px 1fr;gap:16px;margin-top:16px}
@media(max-width:800px){.settings-grid{grid-template-columns:1fr}}
.provider-list{display:flex;flex-direction:column;gap:6px}
.provider-option{display:flex;align-items:center;gap:10px;padding:12px 14px;border-radius:8px;border:1px solid var(--border);cursor:pointer;transition:all .15s;background:var(--card)}
.provider-option:hover{border-color:var(--text-dim)}
.provider-option.selected{border-color:var(--cyan);background:#0f2a3a}
.provider-option input[type=radio]{accent-color:var(--cyan);width:16px;height:16px}
.provider-option .provider-name{font-weight:600;font-size:.9rem}
.provider-option .provider-type{font-size:.7rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.06em}
.field-group{margin-bottom:16px}
.field-group label{display:block;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim);margin-bottom:6px}
.field-group input,.field-group select{width:100%;padding:10px 12px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;font-family:var(--mono);font-size:.85rem}
.field-group input:focus,.field-group select:focus{outline:none;border-color:var(--cyan)}
.field-group select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2394a3b8' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:32px}
.model-select-wrap{position:relative}
.model-spinner{display:none;position:absolute;right:36px;top:50%;transform:translateY(-50%);width:16px;height:16px;border:2px solid var(--border);border-top-color:var(--cyan);border-radius:50%;animation:spin .6s linear infinite}
.model-spinner.active{display:block}
@keyframes spin{to{transform:translateY(-50%) rotate(360deg)}}
.btn-row{display:flex;gap:8px;margin-top:20px;flex-wrap:wrap}
.current-provider{font-size:.8rem;color:var(--text-dim);margin-top:4px;font-family:var(--mono)}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<h1><span class="btc">&#x20BF;</span> Accumulation Signal Optimizer</h1>
<div style="margin-top:8px;display:flex;align-items:center;gap:12px">
""" + NAV_HTML + """
</div>
</div>
</div>
<div class="card">
<h2>&#9881; LLM Provider Settings</h2>
<p class="current-provider" id="currentProvider"></p>
<div class="settings-grid">
<div>
<h2 style="margin-top:8px">Provider</h2>
<div class="provider-list" id="providerList">
<label class="provider-option" data-provider="ollama">
<input type="radio" name="provider" value="ollama">
<div><div class="provider-name">Ollama</div><div class="provider-type">Local</div></div>
</label>
<label class="provider-option" data-provider="lmstudio">
<input type="radio" name="provider" value="lmstudio">
<div><div class="provider-name">LM Studio</div><div class="provider-type">Local</div></div>
</label>
<label class="provider-option" data-provider="openai">
<input type="radio" name="provider" value="openai">
<div><div class="provider-name">OpenAI</div><div class="provider-type">Cloud</div></div>
</label>
<label class="provider-option" data-provider="anthropic">
<input type="radio" name="provider" value="anthropic">
<div><div class="provider-name">Anthropic</div><div class="provider-type">Cloud</div></div>
</label>
<label class="provider-option" data-provider="openrouter">
<input type="radio" name="provider" value="openrouter">
<div><div class="provider-name">OpenRouter</div><div class="provider-type">Cloud</div></div>
</label>
</div>
</div>
<div>
<h2 style="margin-top:8px">Connection</h2>
<div class="field-group" id="fieldBaseUrl" style="display:none">
<label>Base URL</label>
<input type="text" id="inputBaseUrl" placeholder="http://localhost:11434">
</div>
<div class="field-group" id="fieldApiKey" style="display:none">
<label>API Key</label>
<input type="password" id="inputApiKey" placeholder="sk-...">
</div>
<div class="field-group">
<label>Model</label>
<div class="model-select-wrap">
<select id="selectModel"><option value="">— select provider first —</option></select>
<div class="model-spinner" id="modelSpinner"></div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-cyan" onclick="testConnection()">Test Connection</button>
<button class="btn btn-accent" onclick="saveSettings()">Save Settings</button>
</div>
</div>
</div>
</div>
<div class="footer">BTC Accumulation Signal Optimizer &mdash; VPS &rarr; Windows GPU &rarr; Mac Mini LLM</div>
</div>
<script>
document.getElementById('nav-settings').classList.add('active');
""" + TOAST_JS + """
let settings = null;
const PROVIDER_FIELDS = {
ollama: { baseUrl: true, apiKey: false, defaultUrl: 'http://100.100.242.21:11434' },
lmstudio: { baseUrl: true, apiKey: false, defaultUrl: 'http://100.100.242.21:1234' },
openai: { baseUrl: false, apiKey: true },
anthropic: { baseUrl: false, apiKey: true },
openrouter: { baseUrl: false, apiKey: true },
};
function getSelectedProvider() {
const r = document.querySelector('input[name=provider]:checked');
return r ? r.value : null;
}
function buildProviders() {
// Build providers dict from current UI state, merging with loaded settings
const p = settings ? JSON.parse(JSON.stringify(settings.providers)) : {};
const prov = getSelectedProvider();
if (!prov) return p;
if (!p[prov]) p[prov] = {};
const fields = PROVIDER_FIELDS[prov];
if (fields.baseUrl) {
p[prov].base_url = document.getElementById('inputBaseUrl').value;
}
if (fields.apiKey) {
const v = document.getElementById('inputApiKey').value;
if (v) p[prov].api_key = v;
}
return p;
}
function selectProvider(prov) {
// Highlight the selected option
document.querySelectorAll('.provider-option').forEach(el => {
el.classList.toggle('selected', el.dataset.provider === prov);
});
document.querySelector('input[name=provider][value="' + prov + '"]').checked = true;
const fields = PROVIDER_FIELDS[prov];
const baseUrlField = document.getElementById('fieldBaseUrl');
const apiKeyField = document.getElementById('fieldApiKey');
baseUrlField.style.display = fields.baseUrl ? 'block' : 'none';
apiKeyField.style.display = fields.apiKey ? 'block' : 'none';
// Populate from settings
if (settings && settings.providers[prov]) {
const cfg = settings.providers[prov];
if (fields.baseUrl) {
document.getElementById('inputBaseUrl').value = cfg.base_url || fields.defaultUrl || '';
}
if (fields.apiKey) {
document.getElementById('inputApiKey').value = cfg.api_key || '';
}
} else {
if (fields.baseUrl) document.getElementById('inputBaseUrl').value = fields.defaultUrl || '';
if (fields.apiKey) document.getElementById('inputApiKey').value = '';
}
// Reset model dropdown
document.getElementById('selectModel').innerHTML = '<option value="">— click Test Connection to load models —</option>';
}
// Provider click handlers
document.querySelectorAll('.provider-option').forEach(el => {
el.addEventListener('click', () => selectProvider(el.dataset.provider));
});
async function loadSettings() {
try {
const r = await fetch('/api/settings');
settings = await r.json();
document.getElementById('currentProvider').textContent =
'Current: ' + settings.provider + ' / ' + settings.model;
selectProvider(settings.provider);
} catch(e) {
console.error('Failed to load settings:', e);
}
}
async function fetchModels(providerOverride) {
const prov = providerOverride || getSelectedProvider();
if (!prov) return;
const spinner = document.getElementById('modelSpinner');
const sel = document.getElementById('selectModel');
spinner.classList.add('active');
sel.innerHTML = '<option value="">Loading models...</option>';
try {
const r = await fetch('/api/settings/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: prov, providers: buildProviders() })
});
const data = await r.json();
if (!data.ok) {
sel.innerHTML = '<option value="">Error: ' + (data.error||'unknown') + '</option>';
return;
}
sel.innerHTML = '';
if (!data.models.length) {
sel.innerHTML = '<option value="">No models found</option>';
return;
}
for (const m of data.models) {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name !== m.id ? m.name + ' (' + m.id + ')' : m.id;
sel.appendChild(opt);
}
// Select current model if it's in the list
if (settings && settings.model) {
sel.value = settings.model;
}
} catch(e) {
sel.innerHTML = '<option value="">Fetch failed: ' + e + '</option>';
} finally {
spinner.classList.remove('active');
}
}
async function testConnection() {
const prov = getSelectedProvider();
if (!prov) { showToast('Select a provider first', 'error'); return; }
showToast('Testing connection...', 'success');
try {
const r = await fetch('/api/settings/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: prov, providers: buildProviders() })
});
const data = await r.json();
if (data.ok) {
showToast(data.message, 'success');
// Populate model dropdown
const sel = document.getElementById('selectModel');
sel.innerHTML = '';
for (const m of data.models) {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name !== m.id ? m.name + ' (' + m.id + ')' : m.id;
sel.appendChild(opt);
}
if (settings && settings.model) sel.value = settings.model;
} else {
showToast(data.error || 'Connection failed', 'error');
}
} catch(e) {
showToast('Connection failed: ' + e, 'error');
}
}
async function saveSettings() {
const prov = getSelectedProvider();
if (!prov) { showToast('Select a provider first', 'error'); return; }
const model = document.getElementById('selectModel').value;
if (!model) { showToast('Select a model first (use Test Connection to load models)', 'error'); return; }
try {
const r = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: prov,
model: model,
providers: buildProviders()
})
});
const data = await r.json();
if (data.ok) {
showToast('Settings saved!', 'success');
document.getElementById('currentProvider').textContent = 'Current: ' + prov + ' / ' + model;
// Reload settings to get masked keys
loadSettings();
} else {
showToast(data.error || 'Save failed', 'error');
}
} catch(e) {
showToast('Save failed: ' + e, 'error');
}
}
loadSettings();
</script>
</body>
</html>"""
@app.get("/", response_class=HTMLResponse)
def dashboard():
return DASHBOARD_HTML
@app.get("/settings", response_class=HTMLResponse)
def settings_page():
return SETTINGS_HTML
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=3088)
@app.post("/api/clear")
def api_clear():
"""Clear iteration history for a fresh run."""
import glob
results_dir = os.path.join(BASE_DIR, "results")
# Clear iterations log
log_path = os.path.join(results_dir, "iterations.jsonl")
if os.path.exists(log_path):
os.remove(log_path)
# Clear individual result files
for f in glob.glob(os.path.join(results_dir, "results_iter_*.json")):
os.remove(f)
# Reset best config to initial
initial = os.path.join(BASE_DIR, "config", "initial_config.json")
best = os.path.join(BASE_DIR, "config", "best_config.json")
current = os.path.join(BASE_DIR, "config", "current_config.json")
if os.path.exists(initial):
import shutil
shutil.copy(initial, best)
shutil.copy(initial, current)
# Clear in-memory status
orchestrator._status["llm_suggestions"] = []
orchestrator._status["best_score"] = 0.0
orchestrator._status["iteration"] = 0
return {"ok": True, "message": "History cleared. Ready for fresh run."}