BizzleBot 6bfbd30e3d fix: comparable periods pick one example per market cycle
Instead of showing 5 recent days with similar scores (all from the same
2-week window), now picks one example per cycle:
- pre-2016, 2016-17 Bull, 2018-19 Bear, 2020-21 Bull, 2022-23 Bear, 2024+
- Sorted by closest score match, then picks one per cycle
- Shows cycle label in brackets next to each example
- Much more representative of how the score performed across different eras
2026-03-21 22:21:14 +00:00

1484 lines
62 KiB
Python

#!/usr/bin/env python3
"""
Bitcoin Accumulation Zone Monitor — Web Dashboard
FastAPI server with inline HTML/CSS/JS dashboard.
Monitors on-chain metrics to identify optimal BTC accumulation zones.
"""
import asyncio
import json
import logging
import os
import sys
import threading
import time
import traceback
from datetime import datetime, timezone
import requests
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s")
log = logging.getLogger("btc-monitor")
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BASE_DIR)
from scrapers import fear_greed, price
from scoring import engine
app = FastAPI(title="Bitcoin Accumulation Zone Monitor")
CONFIG_DIR = os.path.join(BASE_DIR, "config")
DATA_DIR = os.path.join(BASE_DIR, "data")
CACHE_PATH = os.path.join(DATA_DIR, "cache.json")
HISTORY_PATH = os.path.join(DATA_DIR, "score_history.jsonl")
LLM_SETTINGS_PATH = os.path.join(CONFIG_DIR, "llm_settings.json")
os.makedirs(DATA_DIR, exist_ok=True)
# Background scraper state
_scraper_lock = threading.Lock()
_scraper_running = False
_last_update = None
_last_error = None
# ── Cache management ──────────────────────────────────────────────────────
def load_cache():
if os.path.exists(CACHE_PATH):
try:
with open(CACHE_PATH) as f:
return json.load(f)
except Exception:
pass
return {}
def save_cache(data):
with open(CACHE_PATH, "w") as f:
json.dump(data, f, indent=2, default=str)
def append_history(score_data):
"""Append a daily score entry to history."""
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"composite_score": score_data.get("composite_score", 0),
"scored_count": score_data.get("scored_count", 0),
"metrics": {
m["key"]: {"score": m["score"], "value": m["value"]}
for m in score_data.get("metrics", [])
},
}
with open(HISTORY_PATH, "a") as f:
f.write(json.dumps(entry) + "\n")
def load_history():
if not os.path.exists(HISTORY_PATH):
return []
entries = []
with open(HISTORY_PATH) as f:
for line in f:
line = line.strip()
if line:
try:
entries.append(json.loads(line))
except Exception:
pass
return entries
# ── Background scraper ────────────────────────────────────────────────────
def run_scrape(force_full=False):
"""Run a scrape cycle and update cache.
By default, only refreshes fast data (price, F&G) and reuses cached on-chain data.
On-chain metrics (Playwright scrapes) only refresh if:
- force_full=True (manual full refresh)
- No cached on-chain data exists
- Cached on-chain data is >6 hours old (they update daily)
"""
global _last_update, _last_error, _scraper_running
with _scraper_lock:
if _scraper_running:
return
_scraper_running = True
try:
# Load existing cache to preserve on-chain data
existing_cache = load_cache()
metrics = {}
# 1. Fear & Greed (fast API call)
log.info("Fetching Fear & Greed...")
metrics["fear_greed"] = fear_greed.fetch()
# 2. BTC Price data (fast API calls)
log.info("Fetching BTC price...")
price_current = price.fetch_current()
metrics["price"] = price_current
log.info("Fetching BTC ATH...")
ath_data = price.fetch_ath()
if price_current.get("price") and ath_data.get("ath"):
drawdown = price.calculate_drawdown(price_current["price"], ath_data["ath"])
metrics["drawdown"] = {"value": drawdown, "ath": ath_data["ath"]}
else:
metrics["drawdown"] = {"value": None}
log.info("Fetching historical prices...")
hist = price.fetch_historical()
if hist:
sma_200d = price.calculate_200d_sma(hist)
mayer = price.calculate_mayer_multiple(price_current.get("price"), sma_200d)
metrics["price_extras"] = {"sma_200d": sma_200d, "mayer_multiple": mayer}
# 3. On-chain metrics — use cached values (historical data is permanent)
onchain_keys = ["puell_multiple", "mvrv_zscore", "reserve_risk", "rhodl_ratio",
"nupl", "200w_sma", "lth_realized_price", "hash_ribbons",
"pi_cycle_bottom", "lth_supply"]
has_cached_onchain = any(existing_cache.get(k, {}).get("value") is not None for k in onchain_keys)
if force_full or not has_cached_onchain:
# Only do a full Playwright scrape if explicitly requested or no data exists
log.info("Scraping on-chain metrics from LookIntoBitcoin (full refresh requested)...")
try:
from scrapers import lookintobitcoin
onchain = lookintobitcoin.scrape_all()
metrics.update(onchain)
metrics["_onchain_timestamp"] = datetime.now(timezone.utc).isoformat()
except Exception as e:
log.error("LookIntoBitcoin scraping failed: %s\n%s", e, traceback.format_exc())
_last_error = f"On-chain scraping failed: {e}"
for k in onchain_keys:
if k in existing_cache:
metrics[k] = existing_cache[k]
else:
# Reuse cached on-chain values — they're stored permanently
log.info("Reusing cached on-chain data (use Full Refresh to re-scrape)")
for k in onchain_keys:
if k in existing_cache:
metrics[k] = existing_cache[k]
if "_onchain_timestamp" in existing_cache:
metrics["_onchain_timestamp"] = existing_cache["_onchain_timestamp"]
# 4. Score everything
log.info("Scoring metrics...")
scored = engine.score_all(metrics)
metrics["_scored"] = scored
metrics["_timestamp"] = datetime.now(timezone.utc).isoformat()
save_cache(metrics)
append_history(scored)
# Append today's values to permanent history (incremental, not full re-scrape)
try:
from scrapers.history_updater import update_history
update_history()
except Exception as e:
log.warning("History update failed (non-critical): %s", e)
_last_update = datetime.now(timezone.utc).isoformat()
_last_error = None
log.info("Scrape cycle complete. Composite score: %s", scored["composite_score"])
except Exception as e:
log.error("Scrape cycle error: %s\n%s", e, traceback.format_exc())
_last_error = str(e)
finally:
with _scraper_lock:
_scraper_running = False
def scraper_loop():
"""Background loop: quick refresh every 15min. Full scrape only on first boot with no data."""
cache = load_cache()
has_data = any(cache.get(k, {}).get("value") is not None
for k in ["puell_multiple", "mvrv_zscore", "nupl"])
run_scrape(force_full=not has_data) # Full only if no cached on-chain data
while True:
time.sleep(900) # 15 minutes
run_scrape() # Quick refresh only
# Start background scraper on import
_scraper_thread = threading.Thread(target=scraper_loop, daemon=True)
_scraper_thread.start()
# ── LLM Settings (preserved from original) ───────────────────────────────
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):
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):
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
def _fetch_models(provider, providers):
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()
return [{"id": m["name"], "name": m["name"]} for m in resp.json().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()
return [{"id": m["id"], "name": m["id"]} for m in resp.json().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()
models = [m for m in resp.json().get("data", []) if m["id"].startswith("gpt-")]
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()
return [{"id": m["id"], "name": m.get("display_name", m["id"])} for m in resp.json().get("data", [])]
elif provider == "openrouter":
resp = requests.get("https://openrouter.ai/api/v1/models", timeout=15)
resp.raise_for_status()
models = resp.json().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}")
# ── API Routes ────────────────────────────────────────────────────────────
@app.get("/api/data")
def api_data():
"""Return current cached metrics + scores."""
cache = load_cache()
scored = cache.get("_scored", {})
price_data = cache.get("price", {})
drawdown_data = cache.get("drawdown", {})
extras = cache.get("price_extras", {})
return {
"scored": scored,
"price": price_data.get("price"),
"change_24h": price_data.get("change_24h"),
"ath": drawdown_data.get("ath"),
"mayer_multiple": extras.get("mayer_multiple"),
"sma_200d": extras.get("sma_200d"),
"last_update": cache.get("_timestamp"),
"scraper_running": _scraper_running,
"last_error": _last_error,
}
@app.get("/api/history")
def api_history():
return load_history()[-90:] # Last 90 entries
@app.post("/api/refresh")
def api_refresh(full: bool = False):
"""Trigger a scrape. Quick refresh (default) updates price + F&G only (~2s).
Full refresh (?full=true) also re-scrapes on-chain data via Playwright (~2-3min)."""
if _scraper_running:
return JSONResponse({"error": "Scrape already in progress"}, status_code=409)
t = threading.Thread(target=run_scrape, kwargs={"force_full": full}, daemon=True)
t.start()
mode = "full (on-chain + price + F&G)" if full else "quick (price + F&G only)"
return {"ok": True, "message": f"Scrape started — {mode}"}
# Settings routes (preserved)
@app.get("/api/settings")
def api_get_settings():
return _safe_settings(_load_llm_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):
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, "message": f"Connected — {len(models)} model(s) found"}
except requests.exceptions.ConnectionError:
return JSONResponse({"ok": False, "error": "Connection refused"}, status_code=502)
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):
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)
# ── HTML 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;
--bright-green:#4ade80;--score-excellent:#22c55e;--score-good:#4ade80;
--score-neutral:#eab308;--score-bad:#f97316;--score-terrible:#ef4444}
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:.8rem;font-weight:600;color:var(--text-dim);margin-bottom:12px;text-transform:uppercase;letter-spacing:.05em}
.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)}
.btn{padding:8px 18px;border:none;border-radius:6px;font-family:inherit;font-weight:600;font-size:.85rem;cursor:pointer;transition:all .15s}
.btn-accent{background:var(--accent);color:#000}.btn-accent:hover{background:#e8850f}
.btn-secondary{background:var(--border);color:var(--text)}.btn-secondary:hover{background:var(--card-hover)}
.btn-cyan{background:var(--cyan);color:#000}.btn-cyan:hover{background:#06b6d4}
.btn:disabled{opacity:.4;cursor:not-allowed}
.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_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">"""
NAV_HTML = """<div class="nav">
<a href="/" id="nav-dashboard">Dashboard</a>
<a href="/backtest" id="nav-backtest">&#x1F4CA; Backtest</a>
<a href="/settings" id="nav-settings">&#9881; Settings</a>
</div>"""
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>Bitcoin Accumulation Zone Monitor</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<style>
""" + SHARED_CSS + """
.hero{display:flex;align-items:center;gap:24px;flex-wrap:wrap;margin-bottom:20px}
.score-ring{position:relative;width:160px;height:160px;flex-shrink:0}
.score-ring canvas{width:160px;height:160px}
.score-ring .score-text{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center}
.score-ring .score-number{font-size:2.8rem;font-weight:800;font-family:var(--mono);line-height:1}
.score-ring .score-label{font-size:.65rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.08em;margin-top:2px}
.hero-info{flex:1;min-width:200px}
.assessment{font-size:1.3rem;font-weight:700;letter-spacing:.02em;margin-bottom:4px}
.price-display{font-size:2rem;font-weight:700;font-family:var(--mono);color:var(--accent);margin-bottom:4px}
.price-change{font-size:.9rem;font-family:var(--mono)}
.price-change.up{color:var(--green)}
.price-change.down{color:var(--red)}
.meta-row{display:flex;gap:16px;flex-wrap:wrap;margin-top:8px;font-size:.8rem;color:var(--text-dim)}
.meta-row span{display:flex;align-items:center;gap:4px}
.metrics-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px;margin-bottom:20px}
.metric-card{background:var(--card);border-radius:10px;padding:14px;border:1px solid var(--border);transition:border-color .15s}
.metric-card:hover{border-color:var(--text-dim)}
.metric-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px}
.metric-name{font-size:.85rem;font-weight:600}
.metric-score{display:flex;align-items:center;gap:6px}
.metric-score-num{font-size:1.1rem;font-weight:800;font-family:var(--mono)}
.metric-score-bar{width:60px;height:6px;background:var(--bg);border-radius:3px;overflow:hidden}
.metric-score-fill{height:100%;border-radius:3px;transition:width .3s}
.metric-value{font-size:.95rem;font-family:var(--mono);color:var(--accent);margin-bottom:4px}
.metric-desc{font-size:.78rem;color:var(--text-dim);line-height:1.4}
.metric-sparkline{margin-top:8px;height:30px}
.metric-sparkline canvas{width:100%;height:30px}
.chart-section{margin-bottom:20px}
.chart-container{position:relative;height:280px}
.status-line{display:flex;align-items:center;gap:8px;font-size:.78rem;color:var(--text-dim)}
.status-dot{width:8px;height:8px;border-radius:50%}
.status-dot.live{background:var(--green);animation:pulse 1.5s infinite}
.status-dot.stale{background:var(--yellow)}
.status-dot.error{background:var(--red)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<h1><span class="btc">&#x20BF;</span> Accumulation Zone Monitor</h1>
<div style="margin-top:8px;display:flex;align-items:center;gap:12px">
""" + NAV_HTML + """
</div>
</div>
<div style="display:flex;align-items:center;gap:12px">
<div class="status-line" id="statusLine">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">Loading...</span>
</div>
<button class="btn btn-accent" onclick="doRefresh(false)" id="btnRefresh">⚡ Quick Refresh</button>
<button class="btn btn-secondary" onclick="doRefresh(true)" id="btnFullRefresh" title="Re-scrape on-chain metrics from LookIntoBitcoin (~2-3 min)">🔄 Full Refresh</button>
</div>
</div>
<!-- Hero: Score + Price -->
<div class="hero">
<div class="score-ring">
<canvas id="scoreRing" width="160" height="160"></canvas>
<div class="score-text">
<div class="score-number" id="scoreNumber">--</div>
<div class="score-label">of 100</div>
</div>
</div>
<div class="hero-info">
<div class="assessment" id="assessment">Loading...</div>
<div class="price-display" id="priceDisplay">--</div>
<div class="price-change" id="priceChange"></div>
<div class="meta-row">
<span>ATH: <strong id="athDisplay">--</strong></span>
<span>Mayer: <strong id="mayerDisplay">--</strong></span>
<span>200D SMA: <strong id="sma200dDisplay">--</strong></span>
<span id="scoredCount"></span>
</div>
</div>
</div>
<!-- Historical Context (from backtest) -->
<div class="card" id="histContext" style="margin-bottom:20px;display:none;border-color:#22d3ee">
<h2 style="color:#22d3ee">Historical Context</h2>
<div id="histContextText" style="font-size:.9rem;font-family:var(--mono);line-height:1.6"></div>
<a href="/backtest" style="font-size:.8rem;color:#22d3ee;text-decoration:none;margin-top:8px;display:inline-block">View full backtest &rarr;</a>
</div>
<!-- Metrics Grid -->
<h2>On-Chain Metrics</h2>
<div class="metrics-grid" id="metricsGrid">
<div class="card" style="text-align:center;color:var(--text-dim);padding:40px">Loading metrics...</div>
</div>
<!-- Historical Chart -->
<div class="card chart-section">
<h2>Composite Score History</h2>
<div class="chart-container">
<canvas id="historyChart"></canvas>
</div>
</div>
<div class="footer">Bitcoin Accumulation Zone Monitor &mdash; On-chain metrics updated every 15 minutes</div>
</div>
<script>
document.getElementById('nav-dashboard').classList.add('active');
""" + TOAST_JS + """
function scoreColor(score, max) {
const pct = max > 0 ? score / max : 0;
if (pct >= 0.7) return '#22c55e';
if (pct >= 0.5) return '#4ade80';
if (pct >= 0.3) return '#eab308';
if (pct >= 0.15) return '#f97316';
return '#ef4444';
}
function assessmentColor(score) {
if (score >= 71) return '#22c55e';
if (score >= 51) return '#4ade80';
if (score >= 31) return '#eab308';
if (score >= 15) return '#f97316';
return '#ef4444';
}
function drawScoreRing(score) {
const canvas = document.getElementById('scoreRing');
const ctx = canvas.getContext('2d');
const cx = 80, cy = 80, r = 65, lw = 12;
ctx.clearRect(0, 0, 160, 160);
// Background ring
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = '#1e293b';
ctx.lineWidth = lw;
ctx.stroke();
// Score arc
const pct = Math.min(score / 100, 1);
const startAngle = -Math.PI / 2;
const endAngle = startAngle + pct * Math.PI * 2;
ctx.beginPath();
ctx.arc(cx, cy, r, startAngle, endAngle);
ctx.strokeStyle = assessmentColor(score);
ctx.lineWidth = lw;
ctx.lineCap = 'round';
ctx.stroke();
document.getElementById('scoreNumber').textContent = Math.round(score);
document.getElementById('scoreNumber').style.color = assessmentColor(score);
}
function drawSparkline(canvasId, data, color) {
const canvas = document.getElementById(canvasId);
if (!canvas || !data || data.length < 2) return;
const ctx = canvas.getContext('2d');
const w = canvas.width = canvas.offsetWidth * 2;
const h = canvas.height = 60;
ctx.clearRect(0, 0, w, h);
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const pad = 4;
ctx.beginPath();
for (let i = 0; i < data.length; i++) {
const x = pad + (i / (data.length - 1)) * (w - pad * 2);
const y = h - pad - ((data[i] - min) / range) * (h - pad * 2);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
function renderMetrics(metrics) {
const grid = document.getElementById('metricsGrid');
if (!metrics || !metrics.length) {
grid.innerHTML = '<div class="card" style="text-align:center;color:var(--text-dim);padding:40px">No metrics available yet. Data is being scraped...</div>';
return;
}
let html = '';
metrics.forEach((m, idx) => {
const score = m.score != null ? m.score : '--';
const color = m.score != null ? scoreColor(m.score, 10) : '#64748b';
const fillPct = m.score != null ? (m.score / 10 * 100) : 0;
const hasSparkline = m.recent && m.recent.length > 2;
html += '<div class="metric-card">';
html += '<div class="metric-header">';
html += '<div class="metric-name">' + m.name + '</div>';
html += '<div class="metric-score">';
html += '<div class="metric-score-bar"><div class="metric-score-fill" style="width:' + fillPct + '%;background:' + color + '"></div></div>';
html += '<div class="metric-score-num" style="color:' + color + '">' + score + '</div>';
html += '</div></div>';
html += '<div class="metric-value">' + (m.display_value || 'N/A') + '</div>';
html += '<div class="metric-desc">' + (m.description || '') + '</div>';
if (hasSparkline) {
html += '<div class="metric-sparkline"><canvas id="spark-' + idx + '"></canvas></div>';
}
html += '</div>';
});
grid.innerHTML = html;
// Draw sparklines after DOM update
requestAnimationFrame(() => {
metrics.forEach((m, idx) => {
if (m.recent && m.recent.length > 2) {
drawSparkline('spark-' + idx, m.recent, scoreColor(m.score || 0, 10));
}
});
});
}
let histChart = null;
function renderHistory(history) {
const ctx = document.getElementById('historyChart').getContext('2d');
if (!history || !history.length) return;
const labels = history.map(h => {
const d = new Date(h.timestamp);
return (d.getMonth()+1) + '/' + d.getDate();
});
const scores = history.map(h => h.composite_score);
if (histChart) histChart.destroy();
histChart = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
label: 'Composite Score',
data: scores,
borderColor: '#f7931a',
backgroundColor: 'rgba(247,147,26,0.1)',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#f7931a',
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: '#94a3b8', font: { size: 11 } } },
annotation: null,
},
scales: {
x: { ticks: { color: '#94a3b8', maxTicksLimit: 15 }, grid: { color: '#1e293b' } },
y: { min: 0, max: 100, ticks: { color: '#f7931a' }, grid: { color: '#1e293b' },
title: { display: true, text: 'Score (0-100)', color: '#f7931a' }
}
}
}
});
}
function updateStatus(data) {
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
if (data.scraper_running) {
dot.className = 'status-dot live';
text.textContent = 'Scraping in progress...';
} else if (data.last_error) {
dot.className = 'status-dot error';
text.textContent = 'Error: ' + data.last_error.substring(0, 50);
} else if (data.last_update) {
dot.className = 'status-dot live';
const ago = Math.round((Date.now() - new Date(data.last_update).getTime()) / 60000);
text.textContent = 'Updated ' + (ago < 1 ? 'just now' : ago + 'm ago');
} else {
dot.className = 'status-dot stale';
text.textContent = 'Waiting for first scrape...';
}
}
async function poll() {
try {
const [dataRes, histRes] = await Promise.all([
fetch('/api/data'), fetch('/api/history')
]);
const data = await dataRes.json();
const history = await histRes.json();
// Update score ring
const scored = data.scored || {};
const composite = scored.composite_score || 0;
drawScoreRing(composite);
// Assessment
const el = document.getElementById('assessment');
el.textContent = scored.assessment || 'Loading...';
el.style.color = assessmentColor(composite);
// Price
if (data.price) {
document.getElementById('priceDisplay').textContent = '$' + data.price.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0});
}
const chEl = document.getElementById('priceChange');
if (data.change_24h != null) {
const ch = data.change_24h;
chEl.textContent = (ch >= 0 ? '+' : '') + ch.toFixed(2) + '% (24h)';
chEl.className = 'price-change ' + (ch >= 0 ? 'up' : 'down');
}
// Meta
if (data.ath) document.getElementById('athDisplay').textContent = '$' + data.ath.toLocaleString();
if (data.mayer_multiple) document.getElementById('mayerDisplay').textContent = data.mayer_multiple.toFixed(2);
if (data.sma_200d) document.getElementById('sma200dDisplay').textContent = '$' + Math.round(data.sma_200d).toLocaleString();
if (scored.scored_count != null) {
document.getElementById('scoredCount').textContent = scored.scored_count + '/' + scored.total_count + ' metrics active';
}
// Metrics
renderMetrics(scored.metrics || []);
// Status
updateStatus(data);
// History chart
renderHistory(history);
} catch(e) { console.error('Poll error:', e); }
}
async function doRefresh(full) {
const btn = document.getElementById(full ? 'btnFullRefresh' : 'btnRefresh');
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = full ? 'Scraping...' : 'Refreshing...';
try {
const r = await fetch('/api/refresh' + (full ? '?full=true' : ''), { method: 'POST' });
const d = await r.json();
if (d.error) showToast(d.error, 'error');
else showToast(d.message || 'Refresh started', 'success');
} catch(e) { showToast('Failed: ' + e, 'error'); }
const delay = full ? 180000 : 5000;
setTimeout(() => { btn.disabled = false; btn.textContent = origText; }, delay);
}
drawScoreRing(0);
poll();
setInterval(poll, 30000);
// Load historical context from backtest
(async function() {
try {
const r = await fetch('/api/backtest/status');
const s = await r.json();
if (!s.exists) return;
const br = await fetch('/api/backtest');
const bt = await br.json();
if (bt.error || !bt.current_context) return;
const ctx = bt.current_context;
const el = document.getElementById('histContext');
const txt = document.getElementById('histContextText');
let html = 'Score <strong>' + ctx.current_score + '</strong> is in the <strong style="color:#22d3ee">top ' + (100 - ctx.percentile).toFixed(1) + '%</strong> historically.<br>';
const fmtR = (v) => v == null ? null : (v >= 0 ? '+' : '') + v.toFixed(1) + '%';
const cR = (v) => v >= 0 ? '#22c55e' : '#ef4444';
const periods = [
['30d', ctx.avg_30d_return], ['90d', ctx.avg_90d_return],
['180d', ctx.avg_180d_return], ['1yr', ctx.avg_1yr_return]
];
let parts = [];
for (const [label, val] of periods) {
if (val != null) parts.push('<strong style="color:' + cR(val) + '">' + label + ': ' + fmtR(val) + '</strong>');
}
if (parts.length) html += 'Average returns from this level: ' + parts.join(' · ');
txt.innerHTML = html;
el.style.display = 'block';
} catch(e) { /* backtest data not available yet */ }
})();
</script>
</body>
</html>"""
SETTINGS_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
""" + SHARED_HEAD + """
<title>Settings — Bitcoin Accumulation Zone Monitor</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 Zone Monitor</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">Bitcoin Accumulation Zone Monitor &mdash; On-chain metrics</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() {
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) {
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];
document.getElementById('fieldBaseUrl').style.display = fields.baseUrl ? 'block' : 'none';
document.getElementById('fieldApiKey').style.display = fields.apiKey ? 'block' : 'none';
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 = '';
}
document.getElementById('selectModel').innerHTML = '<option value="">-- click Test Connection to load models --</option>';
}
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(e); }
}
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');
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', 'error'); return; }
try {
const r = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: prov, model, providers: buildProviders() }) });
const data = await r.json();
if (data.ok) { showToast('Settings saved!', 'success'); document.getElementById('currentProvider').textContent = 'Current: ' + prov + ' / ' + model; loadSettings(); }
else showToast(data.error || 'Save failed', 'error');
} catch(e) { showToast('Save failed: ' + e, 'error'); }
}
loadSettings();
</script>
</body>
</html>"""
# ── Backtest API ───────────────────────────────────────────────────────
_history_collector_running = False
_history_collector_progress = {}
@app.get("/api/backtest")
def api_backtest():
"""Run backtest and return full results."""
try:
from backtesting.engine import run_backtest
return run_backtest()
except Exception as e:
log.error("Backtest error: %s", traceback.format_exc())
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/api/backtest/history")
def api_backtest_history():
"""Return historical daily scores + prices for charting."""
try:
from backtesting.engine import run_backtest
result = run_backtest()
return {"chart_data": result.get("chart_data", []), "date_range": result.get("date_range")}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.post("/api/backtest/collect")
def api_backtest_collect():
"""Trigger historical data collection."""
global _history_collector_running, _history_collector_progress
if _history_collector_running:
return JSONResponse({"error": "Collection already in progress", "progress": _history_collector_progress}, status_code=409)
def _run_collector():
global _history_collector_running, _history_collector_progress
_history_collector_running = True
_history_collector_progress = {"status": "starting", "current": "", "step": 0, "total": 0}
try:
from scrapers.history_collector import collect_all_history
def progress_cb(metric, step, total):
_history_collector_progress = {"status": "scraping", "current": metric, "step": step + 1, "total": total}
collect_all_history(progress_cb=progress_cb)
_history_collector_progress = {"status": "complete"}
except Exception as e:
log.error("History collection error: %s", traceback.format_exc())
_history_collector_progress = {"status": "error", "error": str(e)}
finally:
_history_collector_running = False
t = threading.Thread(target=_run_collector, daemon=True)
t.start()
return {"ok": True, "message": "Collection started"}
@app.get("/api/backtest/status")
def api_backtest_status():
"""Check if historical data exists and collection status."""
from scrapers.history_collector import history_status
status = history_status()
status["collecting"] = _history_collector_running
status["progress"] = _history_collector_progress
return status
# ── Backtest HTML Page ─────────────────────────────────────────────────
BACKTEST_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
""" + SHARED_HEAD + """
<title>Historical Backtest — Bitcoin Accumulation Zone Monitor</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<style>
""" + SHARED_CSS + """
.section{margin-bottom:24px}
.section h2{margin-bottom:12px}
.collect-banner{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:24px;text-align:center;margin-bottom:24px}
.collect-banner p{color:var(--text-dim);margin:8px 0 16px}
.progress-bar{width:100%;height:8px;background:var(--bg);border-radius:4px;overflow:hidden;margin:12px 0}
.progress-fill{height:100%;background:var(--cyan);border-radius:4px;transition:width .3s}
.progress-text{font-size:.8rem;color:var(--text-dim);font-family:var(--mono)}
table{width:100%;border-collapse:collapse;font-size:.82rem;font-family:var(--mono)}
th{text-align:left;padding:10px 8px;border-bottom:2px solid var(--border);color:var(--text-dim);font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;white-space:nowrap}
td{padding:8px;border-bottom:1px solid var(--border)}
tr:hover td{background:var(--card-hover)}
.t-green{color:var(--green)}.t-red{color:var(--red)}.t-yellow{color:var(--yellow)}.t-cyan{color:var(--cyan)}
.chart-dual{position:relative;height:400px}
.context-box{background:var(--card);border:1px solid var(--cyan);border-radius:10px;padding:20px}
.context-score{font-size:2.5rem;font-weight:800;font-family:var(--mono);margin-bottom:4px}
.context-percentile{font-size:1rem;color:var(--cyan);margin-bottom:12px}
.context-return{font-size:1.1rem;color:var(--green);font-weight:600;margin-bottom:12px}
.comparable-list{margin-top:12px}
.comparable-item{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--border);font-size:.82rem;font-family:var(--mono)}
.signal-card{background:var(--card);border-radius:8px;padding:12px;border:1px solid var(--border);margin-bottom:8px}
.signal-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
.signal-date{font-weight:700;color:var(--accent);font-family:var(--mono)}
.signal-score{font-weight:800;font-family:var(--mono);padding:2px 8px;border-radius:4px;font-size:.85rem}
.signal-returns{display:flex;gap:16px;font-size:.8rem;font-family:var(--mono)}
.loading-spinner{display:inline-block;width:20px;height:20px;border:3px solid var(--border);border-top-color:var(--cyan);border-radius:50%;animation:spin .6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<h1><span class="btc">&#x20BF;</span> Accumulation Zone Monitor</h1>
<div style="margin-top:8px;display:flex;align-items:center;gap:12px">
""" + NAV_HTML + """
</div>
</div>
</div>
<!-- Collection Banner (shown if no data) -->
<div id="collectBanner" class="collect-banner" style="display:none">
<h2>Historical Data Required</h2>
<p>Scrape full historical time series from LookIntoBitcoin charts, CoinGecko, and Fear & Greed Index.<br>This takes several minutes (10+ charts to scrape).</p>
<button class="btn btn-cyan" id="btnCollect" onclick="startCollection()">Collect Historical Data</button>
<div id="progressArea" style="display:none;margin-top:16px">
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0%"></div></div>
<div class="progress-text" id="progressText">Starting...</div>
</div>
</div>
<!-- Main content (shown after data exists) -->
<div id="mainContent" style="display:none">
<!-- Section 1: Current Signal Context -->
<div class="section">
<div class="context-box" id="contextBox">
<h2>Current Signal Context</h2>
<div class="context-score" id="ctxScore">--</div>
<div class="context-percentile" id="ctxPercentile">Loading...</div>
<div class="context-return" id="ctxReturn"></div>
<div class="comparable-list" id="ctxComparables"></div>
</div>
</div>
<!-- Section 2: Historical Score vs Price Chart -->
<div class="section">
<div class="card">
<h2>Historical Score vs BTC Price</h2>
<div class="chart-dual">
<canvas id="dualChart"></canvas>
</div>
</div>
</div>
<!-- Section 3: Score Bracket Performance -->
<div class="section">
<div class="card">
<h2>Score Bracket Performance</h2>
<div style="overflow-x:auto">
<table id="bracketTable">
<thead>
<tr>
<th>Score Range</th><th>Label</th><th>Days</th>
<th>Avg 30d</th><th>Avg 90d</th><th>Avg 180d</th><th>Avg 1yr</th>
<th>Win Rate (1yr)</th><th>Max Gain (1yr)</th><th>Max Loss (1yr)</th>
<th>Avg Max DD</th>
</tr>
</thead>
<tbody id="bracketBody"></tbody>
</table>
</div>
</div>
</div>
<!-- Section 4: Major Signal Events -->
<div class="section">
<div class="card">
<h2>Major Signal Events (Score Crossed 70/80/90+)</h2>
<div id="signalEvents"></div>
</div>
</div>
</div><!-- /mainContent -->
<div class="footer">Bitcoin Accumulation Zone Monitor &mdash; Historical Backtest Engine</div>
</div>
<script>
document.getElementById('nav-backtest').classList.add('active');
""" + TOAST_JS + """
let backtestData = null;
function fmtPct(v) { if (v == null) return '--'; return (v >= 0 ? '+' : '') + v.toFixed(1) + '%'; }
function fmtPrice(v) { if (v == null) return '--'; return '$' + Math.round(v).toLocaleString(); }
function retClass(v) { if (v == null) return ''; return v >= 0 ? 't-green' : 't-red'; }
function scoreColorCSS(score) {
if (score >= 71) return '#22c55e';
if (score >= 51) return '#4ade80';
if (score >= 31) return '#eab308';
if (score >= 15) return '#f97316';
return '#ef4444';
}
async function checkStatus() {
try {
const r = await fetch('/api/backtest/status');
const s = await r.json();
if (s.exists && !s.collecting) {
document.getElementById('collectBanner').style.display = 'none';
document.getElementById('mainContent').style.display = 'block';
loadBacktest();
} else if (s.collecting) {
document.getElementById('collectBanner').style.display = 'block';
document.getElementById('progressArea').style.display = 'block';
document.getElementById('btnCollect').disabled = true;
document.getElementById('btnCollect').textContent = 'Collecting...';
const p = s.progress || {};
if (p.total > 0) {
const pct = Math.round((p.step / (p.total + 2)) * 100);
document.getElementById('progressFill').style.width = pct + '%';
document.getElementById('progressText').textContent = p.current + ' (' + p.step + '/' + p.total + ')';
} else {
document.getElementById('progressText').textContent = p.status || 'Working...';
}
if (p.status === 'complete') {
document.getElementById('collectBanner').style.display = 'none';
document.getElementById('mainContent').style.display = 'block';
loadBacktest();
return;
}
setTimeout(checkStatus, 3000);
} else {
document.getElementById('collectBanner').style.display = 'block';
}
} catch(e) { console.error(e); }
}
async function startCollection() {
document.getElementById('btnCollect').disabled = true;
document.getElementById('btnCollect').textContent = 'Starting...';
document.getElementById('progressArea').style.display = 'block';
try {
const r = await fetch('/api/backtest/collect', { method: 'POST' });
const d = await r.json();
if (d.error) { showToast(d.error, 'error'); return; }
showToast('Collection started — this will take several minutes', 'success');
setTimeout(checkStatus, 3000);
} catch(e) { showToast('Failed: ' + e, 'error'); }
}
async function loadBacktest() {
try {
document.getElementById('mainContent').innerHTML = '<div style="text-align:center;padding:60px;color:var(--text-dim)"><div class="loading-spinner"></div><p style="margin-top:16px">Running backtest analysis...</p></div>';
const r = await fetch('/api/backtest');
backtestData = await r.json();
if (backtestData.error) {
document.getElementById('mainContent').innerHTML = '<div class="card" style="text-align:center;padding:40px;color:var(--red)">' + backtestData.error + '</div>';
return;
}
renderAll();
} catch(e) { console.error(e); }
}
function renderAll() {
// Rebuild the main content
document.getElementById('mainContent').innerHTML = `
<div class="section"><div class="context-box" id="contextBox"><h2>Current Signal Context</h2><div class="context-score" id="ctxScore">--</div><div class="context-percentile" id="ctxPercentile"></div><div class="context-return" id="ctxReturn"></div><div class="comparable-list" id="ctxComparables"></div></div></div>
<div class="section"><div class="card"><h2>Historical Score vs BTC Price</h2><div class="chart-dual"><canvas id="dualChart"></canvas></div></div></div>
<div class="section"><div class="card"><h2>Score Bracket Performance</h2><div style="overflow-x:auto"><table><thead><tr><th>Score Range</th><th>Label</th><th>Days</th><th>Avg 30d</th><th>Avg 90d</th><th>Avg 180d</th><th>Avg 1yr</th><th>Win Rate (1yr)</th><th>Max Gain</th><th>Max Loss</th><th>Avg Max DD</th></tr></thead><tbody id="bracketBody"></tbody></table></div></div></div>
<div class="section"><div class="card"><h2>Major Signal Events (Score Crossed 70/80/90+)</h2><div id="signalEvents"></div></div></div>
`;
renderContext();
renderDualChart();
renderBracketTable();
renderSignalEvents();
}
function renderContext() {
const ctx = backtestData.current_context;
if (!ctx) return;
const el = document.getElementById('ctxScore');
el.textContent = ctx.current_score + ' / 100';
el.style.color = scoreColorCSS(ctx.current_score);
document.getElementById('ctxPercentile').textContent =
'Historical percentile: top ' + (100 - ctx.percentile).toFixed(1) + '% of all days (' + ctx.comparable_days + ' comparable days found)';
if (ctx.avg_1yr_return != null) {
let retHtml = 'Average returns from this score level: ';
if (ctx.avg_30d_return != null) retHtml += '<span class="' + retClass(ctx.avg_30d_return) + '">30d: ' + fmtPct(ctx.avg_30d_return) + '</span> · ';
if (ctx.avg_90d_return != null) retHtml += '<span class="' + retClass(ctx.avg_90d_return) + '">90d: ' + fmtPct(ctx.avg_90d_return) + '</span> · ';
if (ctx.avg_180d_return != null) retHtml += '<span class="' + retClass(ctx.avg_180d_return) + '">180d: ' + fmtPct(ctx.avg_180d_return) + '</span> · ';
retHtml += '<span class="' + retClass(ctx.avg_1yr_return) + '"><strong>1yr: ' + fmtPct(ctx.avg_1yr_return) + '</strong></span>';
document.getElementById('ctxReturn').innerHTML = retHtml;
}
// Examples
const list = document.getElementById('ctxComparables');
if (ctx.examples && ctx.examples.length) {
let html = '<h2 style="margin-top:12px">Comparable Historical Periods <span style="color:var(--muted);font-size:.7em;font-weight:normal">(one per market cycle)</span></h2>';
for (const ex of ctx.examples) {
const fr = ex.forward_returns || {};
const cycleTag = ex.cycle ? '<span style="color:var(--accent);font-size:.75em;opacity:.7;margin-left:6px">[' + ex.cycle + ']</span>' : '';
html += '<div class="comparable-item"><span>' + ex.date + ' — Score ' + ex.score + '' + fmtPrice(ex.price) + cycleTag + '</span>';
html += '<span>';
if (fr['30d'] != null) html += '<span class="' + retClass(fr['30d']) + '">30d: ' + fmtPct(fr['30d']) + '</span> ';
if (fr['90d'] != null) html += '<span class="' + retClass(fr['90d']) + '">90d: ' + fmtPct(fr['90d']) + '</span> ';
if (fr['180d'] != null) html += '<span class="' + retClass(fr['180d']) + '">180d: ' + fmtPct(fr['180d']) + '</span> ';
if (fr['365d'] != null) html += '<span class="' + retClass(fr['365d']) + '">1yr: ' + fmtPct(fr['365d']) + '</span>';
html += '</span></div>';
}
list.innerHTML = html;
}
}
function renderDualChart() {
const chart = backtestData.chart_data;
if (!chart || !chart.length) return;
const ctx = document.getElementById('dualChart').getContext('2d');
const labels = chart.map(d => d.date);
const scores = chart.map(d => d.score);
const prices = chart.map(d => d.price);
// Zone backgrounds via plugin
const zonePlugin = {
id: 'zoneBackground',
beforeDraw(chart) {
const { ctx: c, chartArea: {left, right, top, bottom}, scales: {y} } = chart;
if (!y) return;
const zones = [
{ min: 0, max: 40, color: 'rgba(239,68,68,0.06)' },
{ min: 40, max: 70, color: 'rgba(234,179,8,0.06)' },
{ min: 70, max: 100, color: 'rgba(34,197,94,0.08)' },
];
for (const z of zones) {
const yTop = y.getPixelForValue(Math.min(z.max, 100));
const yBot = y.getPixelForValue(z.min);
c.fillStyle = z.color;
c.fillRect(left, yTop, right - left, yBot - yTop);
}
}
};
new Chart(ctx, {
type: 'line',
plugins: [zonePlugin],
data: {
labels,
datasets: [
{
label: 'Accumulation Score',
data: scores,
borderColor: '#f7931a',
backgroundColor: 'rgba(247,147,26,0.1)',
borderWidth: 1.5,
fill: false,
tension: 0.2,
pointRadius: 0,
yAxisID: 'y',
},
{
label: 'BTC Price (USD)',
data: prices,
borderColor: '#22d3ee',
borderWidth: 1.5,
fill: false,
tension: 0.2,
pointRadius: 0,
yAxisID: 'y1',
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { labels: { color: '#94a3b8', font: { size: 11 } } },
tooltip: {
callbacks: {
label: function(ctx) {
if (ctx.datasetIndex === 1) return 'BTC: $' + Math.round(ctx.parsed.y).toLocaleString();
return 'Score: ' + ctx.parsed.y;
}
}
}
},
scales: {
x: { ticks: { color: '#94a3b8', maxTicksLimit: 20, maxRotation: 45 }, grid: { color: '#1e293b' } },
y: { position: 'left', min: 0, max: 100, ticks: { color: '#f7931a' }, grid: { color: '#1e293b' },
title: { display: true, text: 'Score (0-100)', color: '#f7931a' } },
y1: { position: 'right', type: 'logarithmic', ticks: { color: '#22d3ee', callback: v => '$' + v.toLocaleString() },
grid: { drawOnChartArea: false }, title: { display: true, text: 'BTC Price (log)', color: '#22d3ee' } }
}
}
});
}
function renderBracketTable() {
const brackets = backtestData.bracket_stats;
if (!brackets) return;
const tbody = document.getElementById('bracketBody');
let html = '';
for (const b of brackets) {
const rowColor = b.days === 0 ? '' : (b.avg_365d > 50 ? 'style="background:rgba(34,197,94,0.08)"' : b.avg_365d < 0 ? 'style="background:rgba(239,68,68,0.08)"' : '');
html += '<tr ' + rowColor + '>';
html += '<td style="font-weight:700">' + b.range + '</td>';
html += '<td>' + b.label + '</td>';
html += '<td>' + b.days + '</td>';
html += '<td class="' + retClass(b.avg_30d) + '">' + fmtPct(b.avg_30d) + '</td>';
html += '<td class="' + retClass(b.avg_90d) + '">' + fmtPct(b.avg_90d) + '</td>';
html += '<td class="' + retClass(b.avg_180d) + '">' + fmtPct(b.avg_180d) + '</td>';
html += '<td class="' + retClass(b.avg_365d) + '">' + fmtPct(b.avg_365d) + '</td>';
html += '<td>' + (b.win_rate_365d != null ? b.win_rate_365d + '%' : '--') + '</td>';
html += '<td class="t-green">' + fmtPct(b.max_gain_365d) + '</td>';
html += '<td class="t-red">' + fmtPct(b.max_loss_365d) + '</td>';
html += '<td>' + (b.avg_max_drawdown_90d != null ? b.avg_max_drawdown_90d + '%' : '--') + '</td>';
html += '</tr>';
}
tbody.innerHTML = html;
}
function renderSignalEvents() {
const events = backtestData.signal_events;
if (!events || !events.length) return;
const el = document.getElementById('signalEvents');
let html = '';
// Show most recent events first, limit to 30
const shown = events.slice(-30).reverse();
for (const ev of shown) {
const color = scoreColorCSS(ev.score);
html += '<div class="signal-card">';
html += '<div class="signal-header">';
html += '<span class="signal-date">' + ev.date + ' &mdash; ' + fmtPrice(ev.price) + '</span>';
html += '<span class="signal-score" style="background:' + color + ';color:#000">Score: ' + ev.score + ' (crossed ' + ev.threshold + ')</span>';
html += '</div>';
html += '<div class="signal-returns">';
const fr = ev.forward_returns || {};
if (fr['30d'] != null) html += '<span class="' + retClass(fr['30d']) + '">30d: ' + fmtPct(fr['30d']) + '</span>';
if (fr['90d'] != null) html += '<span class="' + retClass(fr['90d']) + '">90d: ' + fmtPct(fr['90d']) + '</span>';
if (fr['180d'] != null) html += '<span class="' + retClass(fr['180d']) + '">180d: ' + fmtPct(fr['180d']) + '</span>';
if (fr['365d'] != null) html += '<span class="' + retClass(fr['365d']) + '">1yr: ' + fmtPct(fr['365d']) + '</span>';
if (ev.price_365d) html += '<span style="color:var(--text-dim)">Price 1yr: ' + fmtPrice(ev.price_365d) + '</span>';
html += '</div></div>';
}
el.innerHTML = html;
}
// Init
checkStatus();
</script>
</body>
</html>"""
@app.get("/", response_class=HTMLResponse)
def dashboard():
return DASHBOARD_HTML
@app.get("/backtest", response_class=HTMLResponse)
def backtest_page():
return BACKTEST_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)