Files
btc-accumulation-monitor/dashboard/server.py
T

2353 lines
97 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 contextlib import asynccontextmanager
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
from dashboard.persistence import (
append_daily_jsonl,
atomic_write_json,
has_observation,
load_json,
load_jsonl_tail,
merge_observation,
onchain_refresh_due,
)
from dashboard.jobs import JobRegistry
_shutdown_event = threading.Event()
_background_threads = []
_threads_lock = threading.Lock()
@asynccontextmanager
async def lifespan(_app):
"""Own background worker startup and graceful shutdown."""
_shutdown_event.clear()
scraper_thread = threading.Thread(target=scraper_loop, name="scraper-scheduler")
with _threads_lock:
_background_threads.append(scraper_thread)
scraper_thread.start()
try:
yield
finally:
_shutdown_event.set()
with _threads_lock:
threads = list(_background_threads)
for thread in threads:
thread.join(timeout=30)
with _threads_lock:
_background_threads.clear()
app = FastAPI(title="Bitcoin Accumulation Zone Monitor", lifespan=lifespan)
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")
JOBS_PATH = os.path.join(DATA_DIR, "jobs.json")
os.makedirs(DATA_DIR, exist_ok=True)
_jobs = JobRegistry(JOBS_PATH)
# Background scraper state
_scraper_lock = threading.Lock()
_scraper_running = False
_last_update = None
_last_error = None
def _job_worker(job_id, operation):
try:
_jobs.run(job_id, operation)
except Exception:
log.error("Background job %s failed:\n%s", job_id, traceback.format_exc())
finally:
current = threading.current_thread()
with _threads_lock:
if current in _background_threads:
_background_threads.remove(current)
def _spawn_job(job, operation):
"""Start an already-reserved job in a tracked, non-daemon thread."""
thread = threading.Thread(
target=_job_worker,
args=(job["id"], operation),
name=f"{job['kind']}-{job['id'][:8]}",
)
with _threads_lock:
_background_threads.append(thread)
thread.start()
return thread
# ── Cache management ──────────────────────────────────────────────────────
def load_cache():
return load_json(CACHE_PATH, {})
def save_cache(data):
atomic_write_json(CACHE_PATH, data)
@app.get("/health/live")
def health_live():
"""Report that the API process is responsive."""
return {"status": "ok"}
@app.get("/health/ready")
def health_ready():
"""Report readiness only after a usable score has been persisted."""
scored = load_cache().get("_scored", {})
score = scored.get("composite_score")
count = scored.get("scored_count", 0)
if score is None or count < 1:
return JSONResponse(
{"status": "not_ready", "reason": "no usable persisted score"},
status_code=503,
)
return {"status": "ready", "score": score, "scored_metrics": count}
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", [])
},
}
append_daily_jsonl(HISTORY_PATH, entry)
def load_history():
return load_jsonl_tail(HISTORY_PATH, limit=90)
# ── Background scraper ────────────────────────────────────────────────────
def _scrape_onchain_sources():
"""Run independent on-chain providers so one outage cannot mask the other."""
observations = {}
errors = []
successful_sources = 0
providers = (
("LookIntoBitcoin", "scrapers.lookintobitcoin"),
("CheckOnChain", "scrapers.checkonchain"),
)
for display_name, module_name in providers:
try:
module = __import__(module_name, fromlist=["scrape_all"])
observations.update(module.scrape_all())
successful_sources += 1
except Exception as exc:
log.error("%s scraping failed: %s\n%s", display_name, exc, traceback.format_exc())
errors.append(f"{display_name}: {exc}")
return observations, errors, successful_sources
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:
existing_cache = load_cache()
metrics = {}
cycle_errors = []
# Fast metrics fail independently so partial outages retain last-known-good data.
log.info("Fetching Fear & Greed...")
try:
metrics["fear_greed"] = merge_observation(
existing_cache.get("fear_greed"), fear_greed.fetch(), source="alternative.me"
)
except Exception as e:
cycle_errors.append(f"Fear & Greed: {e}")
metrics["fear_greed"] = merge_observation(
existing_cache.get("fear_greed"), None,
source="alternative.me", error=str(e),
)
log.info("Fetching BTC price...")
try:
price_current = price.fetch_current()
metrics["price"] = merge_observation(
existing_cache.get("price"), price_current, source="coingecko"
)
except Exception as e:
cycle_errors.append(f"Price: {e}")
metrics["price"] = merge_observation(
existing_cache.get("price"), None, source="coingecko", error=str(e)
)
price_current = metrics["price"]
log.info("Fetching BTC ATH...")
try:
ath_data = price.fetch_ath()
except Exception as e:
cycle_errors.append(f"ATH: {e}")
ath_data = {}
ath_val = ath_data.get("ath") or existing_cache.get("drawdown", {}).get("ath")
if price_current.get("price") and ath_val:
drawdown = price.calculate_drawdown(price_current["price"], ath_val)
metrics["drawdown"] = {"value": drawdown, "ath": ath_val}
elif existing_cache.get("drawdown", {}).get("value") is not None:
log.info("ATH fetch failed — reusing cached drawdown")
metrics["drawdown"] = existing_cache["drawdown"]
else:
metrics["drawdown"] = {"value": None}
log.info("Fetching historical prices for 200D SMA / Mayer...")
try:
hist = price.fetch_historical()
except Exception as e:
cycle_errors.append(f"Historical price: {e}")
hist = []
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}
else:
# CoinGecko rate-limited — compute from history.json instead
try:
hist_path = os.path.join(DATA_DIR, "history.json")
with open(hist_path) as f:
hdata = json.load(f)
btc_vals = hdata.get("btc_price", {}).get("values", [])
if len(btc_vals) >= 200:
sma_200d = sum(btc_vals[-200:]) / 200
cur_p = price_current.get("price") or btc_vals[-1]
mayer = cur_p / sma_200d if sma_200d else None
metrics["price_extras"] = {"sma_200d": sma_200d, "mayer_multiple": round(mayer, 4) if mayer else None}
log.info("Computed 200D SMA from history.json (CoinGecko rate-limited)")
elif existing_cache.get("price_extras"):
metrics["price_extras"] = existing_cache["price_extras"]
except Exception:
if existing_cache.get("price_extras"):
metrics["price_extras"] = existing_cache["price_extras"]
log.info("Reusing cached price_extras")
# 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", "sopr", "sellside_risk",
"active_address_momentum", "txcount_momentum", "nvt_price",
"vdd_multiple"]
refresh_onchain = force_full or onchain_refresh_due(existing_cache.get("_onchain_timestamp"))
if refresh_onchain:
log.info("Refreshing on-chain metrics (forced, missing, or TTL expired)...")
onchain, onchain_errors, successful_sources = _scrape_onchain_sources()
cycle_errors.extend(onchain_errors)
checkonchain_keys = {"sopr", "sellside_risk", "active_address_momentum",
"txcount_momentum", "nvt_price", "vdd_multiple"}
for key in onchain_keys:
source = "checkonchain" if key in checkonchain_keys else "lookintobitcoin"
metrics[key] = merge_observation(
existing_cache.get(key), onchain.get(key), source=source,
error="metric missing from scrape",
)
if successful_sources and any(has_observation(value) for value in onchain.values()):
metrics["_onchain_timestamp"] = datetime.now(timezone.utc).isoformat()
elif "_onchain_timestamp" in existing_cache:
metrics["_onchain_timestamp"] = existing_cache["_onchain_timestamp"]
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 (classic + ML)
log.info("Scoring metrics...")
scored = engine.score_all(metrics)
metrics["_scored"] = scored
# ML-optimized scoring (parallel)
try:
scored_ml = engine.score_all_ml(metrics)
metrics["_scored_ml"] = scored_ml
except Exception as e:
log.warning("ML scoring failed (non-critical): %s", e)
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 = "; ".join(cycle_errors) if cycle_errors else 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 _run_scheduled_refresh(force_full=False):
job = _jobs.reserve("refresh", details={"full": force_full, "scheduled": True})
if job is not None:
_jobs.run(job["id"], lambda: run_scrape(force_full=force_full))
def scraper_loop():
"""Background loop: refresh quickly every 15 minutes, with on-chain TTL handling."""
cache = load_cache()
has_data = any(cache.get(k, {}).get("value") is not None
for k in ["puell_multiple", "mvrv_zscore", "nupl"])
_run_scheduled_refresh(force_full=not has_data)
while not _shutdown_event.wait(900):
_run_scheduled_refresh()
# ── 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 ────────────────────────────────────────────────────────────
def _with_informational_onchain_metrics(scored, cache):
"""Add non-scored on-chain data cards without changing composite scoring."""
if not isinstance(scored, dict):
return scored
enriched = dict(scored)
metrics = [dict(m) for m in scored.get("metrics", [])]
existing_keys = {m.get("key") for m in metrics}
lth_supply = cache.get("lth_supply", {})
lth_value = lth_supply.get("value")
if lth_value is not None and "lth_supply" not in existing_keys:
trend = lth_supply.get("trend")
trend_text = f" — {trend}" if trend else ""
metrics.append({
"name": "Long-Term Holder Supply",
"key": "lth_supply",
"value": lth_value,
"display_value": f"{lth_value:,.0f} BTC",
"score": None,
"description": "Informational on-chain metric; not included in the composite score" + trend_text,
"recent": lth_supply.get("recent", []),
})
pi_cycle = cache.get("pi_cycle_bottom", {})
pi_value = pi_cycle.get("value")
if pi_value is not None and "pi_cycle_bottom" not in existing_keys:
metrics.append({
"name": "Pi Cycle Bottom",
"key": "pi_cycle_bottom",
"value": pi_value,
"display_value": f"{pi_value:,.2f}" if isinstance(pi_value, (int, float)) else str(pi_value),
"score": None,
"description": "Informational on-chain cycle metric; not included in the composite score",
"recent": pi_cycle.get("recent", []),
})
enriched["metrics"] = metrics
return enriched
@app.get("/api/data")
def api_data(mode: str = "classic"):
"""Return current cached metrics + scores.
mode=classic (default) or mode=ml for ML-optimized scoring.
"""
cache = load_cache()
if mode == "ml":
scored = cache.get("_scored_ml", cache.get("_scored", {}))
else:
scored = cache.get("_scored", {})
scored = _with_informational_onchain_metrics(scored, cache)
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,
"mode": mode,
}
@app.get("/api/history")
def api_history():
return load_history()[-90:] # Last 90 entries
@app.post("/api/refresh", status_code=202)
def api_refresh(full: bool = False):
"""Atomically reserve and start a quick or full metric refresh."""
job = _jobs.reserve("refresh", details={"full": full, "scheduled": False})
if job is None:
active = _jobs.active("refresh")
return JSONResponse(
{"error": "Scrape already in progress", "job": active}, status_code=409
)
_spawn_job(job, lambda: run_scrape(force_full=full))
mode = "full (on-chain + price + F&G)" if full else "quick (price + F&G only)"
return {
"ok": True,
"job_id": job["id"],
"status": job["status"],
"message": f"Scrape started — {mode}",
}
@app.get("/api/jobs/{job_id}")
def api_job_status(job_id: str):
job = _jobs.get(job_id)
if job is None:
return JSONResponse({"error": "Job not found"}, status_code=404)
return job
# 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;cursor:pointer}
.metric-card:hover{border-color:var(--text-dim)}
.metric-card.selected{border-color:#a78bfa;box-shadow:0 0 0 1px #a78bfa,0 0 12px rgba(167,139,250,0.15)}
.metric-click-hint{font-size:.6rem;margin-left:4px;opacity:0;transition:opacity .15s}
.metric-card:hover .metric-click-hint{opacity:.5}
.metric-card.selected .metric-click-hint{opacity:1}
.mc-examples-title{font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
.mc-example{font-size:.8rem;font-family:var(--mono);padding:4px 0;border-bottom:1px solid rgba(255,255,255,0.03)}
.mc-ex-date{color:#e2e8f0}
.mc-ex-cycle{color:#a78bfa;font-size:.7rem}
.mc-ex-price{color:#94a3b8}
.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:320px}
.range-btn{padding:4px 10px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--muted);font-size:.75rem;font-family:var(--mono);cursor:pointer;transition:all .2s}
.range-btn:hover{color:var(--text);border-color:var(--accent)}
.range-btn.active{background:var(--accent);color:#000;border-color:var(--accent);font-weight:600}
.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}}
.mode-toggle{display:flex;border-radius:6px;overflow:hidden;border:1px solid var(--border)}
.mode-btn{padding:6px 14px;border:none;background:transparent;color:var(--text-dim);font-family:inherit;font-weight:600;font-size:.8rem;cursor:pointer;transition:all .15s}
.mode-btn:hover{color:var(--text)}
.mode-btn.active[data-mode="classic"]{background:var(--accent);color:#000}
.mode-btn.active[data-mode="ml"]{background:#8b5cf6;color:#fff}
.ml-badge{display:inline-block;font-size:.6rem;font-weight:700;padding:2px 6px;border-radius:3px;background:#8b5cf6;color:#fff;vertical-align:super;margin-left:4px}
.ml-weight{font-size:.65rem;color:#8b5cf6;font-family:var(--mono);margin-top:2px}
</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>
<div class="mode-toggle" id="modeToggle" title="Switch between Classic (equal-weight) and ML-optimized scoring">
<button class="mode-btn active" data-mode="classic" onclick="setMode('classic')">Classic</button>
<button class="mode-btn" data-mode="ml" onclick="setMode('ml')">ML</button>
</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>
<!-- Metric Context Panel (shown when a metric is selected) -->
<div class="card" id="metricContextPanel" style="margin-bottom:20px;display:none;border-color:#a78bfa">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h2 style="color:#a78bfa" id="mcTitle">Metric Context</h2>
<button onclick="if(selectedMetric) selectMetric(selectedMetric, '')" style="background:none;border:1px solid #a78bfa;color:#a78bfa;padding:4px 10px;border-radius:4px;cursor:pointer;font-family:var(--mono);font-size:.75rem">✕ Clear</button>
</div>
<div id="mcCurrent" style="font-size:1rem;font-family:var(--mono);margin-bottom:4px"></div>
<div id="mcPercentile" style="font-size:.8rem;color:#94a3b8;font-family:var(--mono);margin-bottom:4px"></div>
<div id="mcComparable" style="font-size:.8rem;color:#94a3b8;font-family:var(--mono);margin-bottom:8px"></div>
<div id="mcReturns" style="font-size:.9rem;font-family:var(--mono);line-height:1.6;margin-bottom:8px"></div>
<div id="mcExamples" style="display:none"></div>
<a href="/backtest" style="font-size:.8rem;color:#a78bfa;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">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:12px">
<h2 style="margin:0">Composite Score History</h2>
<div id="chartRangeBar" style="display:flex;gap:4px">
<button class="range-btn" data-days="30">30D</button>
<button class="range-btn" data-days="90">90D</button>
<button class="range-btn" data-days="180">6M</button>
<button class="range-btn" data-days="365">1Y</button>
<button class="range-btn" data-days="730">2Y</button>
<button class="range-btn" data-days="1460">4Y</button>
<button class="range-btn active" data-days="0">ALL</button>
</div>
</div>
<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();
}
let selectedMetric = null;
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;
const isSelected = selectedMetric === m.key ? ' selected' : '';
html += '<div class="metric-card' + isSelected + '" data-key="' + m.key + '" data-name="' + m.name.replace('"', '&quot;') + '">';
html += '<div class="metric-header">';
html += '<div class="metric-name">' + m.name + '<span class="metric-click-hint">👆</span></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 (currentMode === 'ml' && m.ml_weight != null) {
const wpct = (m.ml_weight * 100).toFixed(1);
const contrib = m.ml_contribution != null ? m.ml_contribution.toFixed(1) : '--';
html += '<div class="ml-weight">ML weight: ' + wpct + '% · contribution: ' + contrib + ' pts</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));
}
});
});
// Attach click handlers to metric cards
document.querySelectorAll('.metric-card').forEach(card => {
card.addEventListener('click', function() {
const key = this.getAttribute('data-key');
const name = this.getAttribute('data-name');
if (key) selectMetric(key, name);
});
});
}
// Metric selection + context panel
function selectMetric(metricKey, metricName) {
if (selectedMetric === metricKey) {
// Deselect if clicking the same one
selectedMetric = null;
window._highlightMetric = null;
document.getElementById('metricContextPanel').style.display = 'none';
const panel = document.getElementById('histContext');
if (panel) panel.style.display = 'block';
applyChartRange(currentRange); // Re-render chart without highlight
} else {
selectedMetric = metricKey;
loadMetricContext(metricKey, metricName);
}
poll(); // Re-render metric cards with highlight
}
async function loadMetricContext(metricKey, metricName) {
try {
const r = await fetch('/api/metric-context?metric=' + encodeURIComponent(metricKey) + '&mode=' + currentMode);
const ctx = await r.json();
if (ctx.error) {
showToast(ctx.error, 'error');
return;
}
// Show metric context panel, hide composite context
const panel = document.getElementById('histContext');
if (panel) panel.style.display = 'none';
const mcp = document.getElementById('metricContextPanel');
mcp.style.display = 'block';
document.getElementById('mcTitle').textContent = metricName;
document.getElementById('mcCurrent').textContent = 'Current: ' + (ctx.current_raw != null ? ctx.current_raw : 'N/A');
document.getElementById('mcPercentile').textContent = 'Metric value in top ' + (100 - ctx.percentile).toFixed(1) + '% historically';
document.getElementById('mcComparable').textContent = ctx.comparable_days + ' comparable days found';
const fmtR = (v) => v == null ? '--' : (v >= 0 ? '+' : '') + v.toFixed(1) + '%';
const cR = v => v != null && 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 retHtml = '';
for (const [label, val] of periods) {
if (val != null) retHtml += '<strong style="color:' + cR(val) + '">' + label + ': ' + fmtR(val) + '</strong> · ';
}
document.getElementById('mcReturns').innerHTML = retHtml ? 'Avg returns when ' + metricName + ' was similar: ' + retHtml : 'No forward return data available';
// Examples
const exEl = document.getElementById('mcExamples');
if (ctx.examples && ctx.examples.length) {
let exHtml = '<div class="mc-examples-title">Historical examples:</div>';
ctx.examples.forEach(ex => {
const fwd30 = ex.forward_returns['30d'];
const fwd365 = ex.forward_returns['365d'];
exHtml += '<div class="mc-example">';
exHtml += '<span class="mc-ex-date">' + ex.date + '</span> ';
exHtml += '<span class="mc-ex-cycle">' + ex.cycle + '</span> ';
exHtml += '<span class="mc-ex-price">$' + (ex.price ? ex.price.toLocaleString() : 'N/A') + '</span>';
if (fwd30 != null) exHtml += ' <span style="color:' + cR(fwd30) + '">30d: ' + fmtR(fwd30) + '</span>';
if (fwd365 != null) exHtml += ' <span style="color:' + cR(fwd365) + '">1yr: ' + fmtR(fwd365) + '</span>';
exHtml += '</div>';
});
exEl.innerHTML = exHtml;
exEl.style.display = 'block';
} else {
exEl.innerHTML = '';
exEl.style.display = 'none';
}
// Highlight matching periods on the chart
highlightMetricPeriods(metricKey, ctx.current_raw, ctx.margin);
} catch(e) {
console.error('Metric context load failed:', e);
}
}
function highlightMetricPeriods(metricKey, currentRaw, margin) {
if (!fullDailyScores || !currentRaw || margin == null) return;
// Build an array of {date, rawValue} for the selected metric
const metricSeries = fullDailyScores
.filter(d => d.metric_values && d.metric_values[metricKey] != null)
.map(d => ({ date: d.date, value: d.metric_values[metricKey], isSimilar: Math.abs(d.metric_values[metricKey] - currentRaw) <= margin }));
// Store for use in chart rendering
window._highlightMetric = { key: metricKey, series: metricSeries, currentRaw, margin };
// Re-render chart with highlight
applyChartRange(currentRange);
}
let histChart = null;
let fullDailyScores = null;
let currentRange = 0; // 0 = ALL
let currentMode = 'classic';
function renderHistory(history) {
// Legacy: still called by loadData but we'll use backtest data instead
if (!fullDailyScores) {
// Fallback to score_history.jsonl if backtest hasn't loaded
renderHistoryFromData(history);
}
}
function renderHistoryFromData(history) {
const ctx = document.getElementById('historyChart').getContext('2d');
if (!history || !history.length) return;
const labels = history.map(h => h.date || h.timestamp);
const scores = history.map(h => h.composite_score || h.score);
const prices = history.map(h => h.price || null);
if (histChart) histChart.destroy();
const datasets = [{
label: 'Accumulation Score',
data: scores,
borderColor: '#22d3ee',
backgroundColor: 'rgba(34,211,238,0.08)',
borderWidth: 2,
fill: true,
tension: 0.2,
pointRadius: scores.length > 200 ? 0 : 2,
pointBackgroundColor: '#22d3ee',
yAxisID: 'y',
}];
if (prices && prices.some(p => p != null)) {
datasets.push({
label: 'BTC Price',
data: prices,
borderColor: '#f7931a',
borderWidth: 1.5,
borderDash: [4, 2],
fill: false,
tension: 0.2,
pointRadius: 0,
yAxisID: 'y1',
});
}
// If a metric is selected, add its overlay + highlight similar periods
const highlight = window._highlightMetric;
let metricColor = '#a78bfa';
if (highlight && highlight.series && highlight.series.length) {
// Build a sparse array aligned to current chart labels
const metricByDate = {};
highlight.series.forEach(s => { metricByDate[s.date] = s; });
const metricData = labels.map(l => {
const entry = metricByDate[l];
return entry ? entry.value : null;
});
const hasMetricData = metricData.some(v => v != null);
if (hasMetricData) {
datasets.push({
label: 'Selected Metric',
data: metricData,
borderColor: metricColor,
borderWidth: 1.5,
borderDash: [2, 2],
fill: false,
tension: 0.2,
pointRadius: 0,
yAxisID: 'y2',
});
}
// Highlight similar periods with point dots on the score line
const similarIndices = [];
labels.forEach((l, i) => {
const entry = metricByDate[l];
if (entry && entry.isSimilar) similarIndices.push(i);
});
if (similarIndices.length) {
const highlightData = labels.map((l, i) =>
similarIndices.includes(i) ? scores[i] : null
);
datasets.push({
label: 'Similar Periods',
data: highlightData,
borderColor: 'rgba(167,139,250,0)',
backgroundColor: '#a78bfa',
pointRadius: 3,
pointHoverRadius: 5,
showLine: false,
yAxisID: 'y',
});
}
}
// Determine y2 scale for the metric overlay
const hasMetricDataset = datasets.some(d => d.yAxisID === 'y2');
// Accumulation zone backgrounds + metric highlight bands
const zonePlugin = {
id: 'zones',
beforeDraw(chart) {
const { ctx, chartArea: { top, bottom, left, right }, scales: { y } } = chart;
const zones = [
{ min: 65, max: 100, color: 'rgba(34,197,94,0.06)' },
{ min: 50, max: 65, color: 'rgba(234,179,8,0.04)' },
{ min: 0, max: 35, color: 'rgba(239,68,68,0.04)' },
];
zones.forEach(z => {
const yTop = y.getPixelForValue(z.max);
const yBot = y.getPixelForValue(z.min);
ctx.fillStyle = z.color;
ctx.fillRect(left, yTop, right - left, yBot - yTop);
});
// Draw threshold lines
[65, 50, 35].forEach(val => {
const yPos = y.getPixelForValue(val);
ctx.beginPath();
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
ctx.moveTo(left, yPos);
ctx.lineTo(right, yPos);
ctx.stroke();
ctx.setLineDash([]);
});
// Draw vertical highlight bands for similar periods
if (highlight && highlight.series) {
const metricByDate = {};
highlight.series.forEach(s => { metricByDate[s.date] = s; });
const xScale = chart.scales.x;
labels.forEach((l, i) => {
const entry = metricByDate[l];
if (entry && entry.isSimilar) {
const x = xScale.getPixelForValue(i);
ctx.fillStyle = 'rgba(167,139,250,0.08)';
ctx.fillRect(x - 3, top, 6, bottom - top);
}
});
}
}
};
const scales = {
x: {
ticks: { color: '#64748b', maxTicksLimit: 12, font: { family: 'monospace', size: 10 } },
grid: { color: 'rgba(255,255,255,0.03)' }
},
y: {
min: 0, max: 100,
ticks: { color: '#22d3ee', font: { family: 'monospace', size: 10 } },
grid: { color: 'rgba(255,255,255,0.03)' },
title: { display: true, text: 'Score', color: '#22d3ee', font: { family: 'monospace', size: 11 } }
},
y1: {
position: 'right',
ticks: {
color: '#f7931a',
font: { family: 'monospace', size: 10 },
callback: v => '$' + (v >= 1000 ? (v/1000).toFixed(0) + 'k' : v)
},
grid: { drawOnChartArea: false },
title: { display: true, text: 'BTC Price', color: '#f7931a', font: { family: 'monospace', size: 11 } }
},
};
if (hasMetricDataset) {
scales['y2'] = {
position: 'right',
display: false,
grid: { drawOnChartArea: false },
};
}
histChart = new Chart(ctx, {
type: 'line',
plugins: [zonePlugin],
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { labels: { color: '#94a3b8', font: { size: 11, family: 'monospace' } } },
tooltip: {
backgroundColor: 'rgba(10,10,15,0.95)',
borderColor: 'rgba(255,255,255,0.08)',
borderWidth: 1,
titleFont: { family: 'monospace', size: 11 },
bodyFont: { family: 'monospace', size: 11 },
callbacks: {
label: function(ctx) {
if (ctx.dataset.yAxisID === 'y1') return 'BTC: $' + ctx.raw.toLocaleString();
if (ctx.dataset.yAxisID === 'y2') return 'Metric: ' + (ctx.raw != null ? ctx.raw.toFixed(4) : 'N/A');
if (ctx.dataset.label === 'Similar Periods') return '★ Similar period (Score: ' + ctx.raw.toFixed(1) + ')';
const s = ctx.raw;
let zone = s >= 80 ? 'Extreme Accum' : s >= 65 ? 'Strong Accum' : s >= 50 ? 'Moderate' : s >= 35 ? 'Neutral' : 'Caution';
return 'Score: ' + s.toFixed(1) + ' (' + zone + ')';
}
}
}
},
scales,
}
});
}
// Load backtest daily scores and historical context with one request.
function renderHistoricalContext(ctx) {
if (!ctx) return;
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]
];
const 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';
}
async function loadBacktestChart() {
try {
const r = await fetch('/api/backtest?mode=' + currentMode);
const data = await r.json();
if (data.chart_data && data.chart_data.length) {
fullDailyScores = data.chart_data;
applyChartRange(currentRange);
}
renderHistoricalContext(data.current_context);
} catch(e) { console.error('Backtest chart load failed:', e); }
}
function applyChartRange(days) {
currentRange = days;
if (!fullDailyScores) return;
let filtered = fullDailyScores;
if (days > 0) {
filtered = fullDailyScores.slice(-days);
}
renderHistoryFromData(filtered);
}
// Range button handlers
document.querySelectorAll('.range-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
applyChartRange(parseInt(this.dataset.days));
});
});
// Load backtest data on page load
loadBacktestChart();
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?mode=' + currentMode), 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');
let assessText = scored.assessment || 'Loading...';
if (currentMode === 'ml') {
el.innerHTML = assessText + '<span class="ml-badge">ML</span>';
} else {
el.textContent = assessText;
}
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) {
let countText = scored.scored_count + '/' + scored.total_count + ' metrics active';
if (currentMode === 'ml' && scored.classic_score != null) {
countText += ' · Classic: ' + scored.classic_score;
}
document.getElementById('scoredCount').textContent = countText;
}
// 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);
}
function setMode(mode) {
currentMode = mode;
document.querySelectorAll('.mode-btn').forEach(b => {
b.classList.toggle('active', b.dataset.mode === mode);
});
poll(); // Refresh with new mode
loadBacktestChart(); // Reload chart with new mode
}
drawScoreRing(0);
poll();
setInterval(poll, 30000);
</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 ───────────────────────────────────────────────────────
@app.get("/api/backtest")
def api_backtest(mode: str = "classic"):
"""Run backtest and return full results.
mode=classic (default) or mode=ml for ML-optimized scoring.
"""
try:
from backtesting.engine import run_backtest
return run_backtest(ml_mode=(mode == "ml"))
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", status_code=202)
def api_backtest_collect():
"""Atomically reserve and start historical data collection."""
initial_progress = {"status": "starting", "current": "", "step": 0, "total": 0}
job = _jobs.reserve("history")
if job is None:
active = _jobs.active("history")
return JSONResponse(
{"error": "Collection already in progress", "job": active}, status_code=409
)
_jobs.update_progress(job["id"], initial_progress)
def _run_collector():
from scrapers.history_collector import collect_all_history
def progress_cb(metric, step, total):
_jobs.update_progress(job["id"], {
"status": "scraping", "current": metric,
"step": step + 1, "total": total,
})
collect_all_history(progress_cb=progress_cb)
_jobs.update_progress(job["id"], {"status": "complete"})
return {"collected": True}
_spawn_job(job, _run_collector)
return {"ok": True, "job_id": job["id"], "status": job["status"],
"message": "Collection started"}
@app.get("/api/backtest/status")
def api_backtest_status():
"""Check historical data and expose only the active collection job's progress."""
from scrapers.history_collector import history_status
status = history_status()
active = _jobs.active("history")
status["collecting"] = active is not None
status["job_id"] = active.get("id") if active else None
status["progress"] = active.get("progress", {}) if active else {}
return status
@app.get("/api/metric-context")
def api_metric_context(metric: str, margin: float = 0.0, mode: str = "classic"):
"""Find historical periods where a specific metric was at a similar level.
Returns forward returns for those periods, analogous to the composite-score
current_context but filtered to a single metric's historical similarity.
margin: absolute tolerance for "similar" (auto-computed from metric scale if 0).
"""
try:
from backtesting.engine import run_backtest, HISTORY_PATH, _build_daily_index, _get_all_dates, _last_known_value, METRIC_SCORERS, RATIO_SCORERS, DRAWDOWN_RANGES, _score_range
import os as _os
if not _os.path.exists(HISTORY_PATH):
return JSONResponse({"error": "No historical data. Run history collector first."}, status_code=404)
with open(HISTORY_PATH) as f:
history = json.load(f)
index = _build_daily_index(history)
all_dates = _get_all_dates(index)
# Get current metric value from cache
cache = {}
if _os.path.exists(CACHE_PATH):
with open(CACHE_PATH) as f:
cache = json.load(f)
current_raw = _get_current_metric_raw(metric, cache)
if current_raw is None:
return JSONResponse({"error": f"No current value for metric '{metric}'"}, status_code=404)
# Auto-compute margin from metric scale
if margin <= 0:
margin = _auto_metric_margin(metric, current_raw)
# Build price lookup
price_lookup = {}
for pk in ["btc_price_coingecko", "btc_price", "btc_price_sma", "btc_price_lth"]:
if pk in index:
for d, v in index[pk].items():
if d not in price_lookup:
price_lookup[d] = v
# Find historical days where this metric was similar
comparable = []
for d in all_dates:
raw_val = _get_historical_metric_raw(metric, index, d)
if raw_val is not None and abs(raw_val - current_raw) <= margin:
price = price_lookup.get(d)
fwd = _compute_day_forward_returns(price_lookup, d)
if fwd:
comparable.append({
"date": d,
"raw_value": round(raw_val, 6) if isinstance(raw_val, float) else raw_val,
"price": price,
"forward_returns": fwd,
})
# Compute average returns across comparable periods
avg_returns = {}
for period in ["30d", "90d", "180d", "365d"]:
vals = [c["forward_returns"][period] for c in comparable if period in c["forward_returns"]]
if vals:
avg_returns[period] = round(sum(vals) / len(vals), 2)
# Pick best examples (one per market cycle)
cycle_bins = [
("pre-2016", "2010-01-01", "2015-12-31"),
("2016-17 Bull", "2016-01-01", "2017-12-31"),
("2018-19 Bear", "2018-01-01", "2019-12-31"),
("2020-21 Bull", "2020-01-01", "2021-12-31"),
("2022-23 Bear", "2022-01-01", "2023-12-31"),
("2024+", "2024-01-01", "2099-12-31"),
]
examples = []
used_cycles = set()
sorted_comp = sorted(comparable, key=lambda c: abs(c["raw_value"] - current_raw))
for c in sorted_comp:
for label, start, end in cycle_bins:
if start <= c["date"] <= end and label not in used_cycles:
used_cycles.add(label)
examples.append({
"date": c["date"],
"raw_value": c["raw_value"],
"price": c["price"],
"forward_returns": c["forward_returns"],
"cycle": label,
})
break
if len(examples) >= 6:
break
examples.sort(key=lambda e: e["date"])
# Percentile: what % of all days had this metric at or below current value
all_raw_vals = []
for d in all_dates:
rv = _get_historical_metric_raw(metric, index, d)
if rv is not None:
all_raw_vals.append(rv)
all_raw_vals.sort()
below = len([v for v in all_raw_vals if v <= current_raw])
percentile = round(below / len(all_raw_vals) * 100, 1) if all_raw_vals else 50.0
return {
"metric": metric,
"current_raw": current_raw,
"margin": margin,
"comparable_days": len(comparable),
"percentile": percentile,
"avg_30d_return": avg_returns.get("30d"),
"avg_90d_return": avg_returns.get("90d"),
"avg_180d_return": avg_returns.get("180d"),
"avg_1yr_return": avg_returns.get("365d"),
"examples": examples,
}
except Exception as e:
log.error("Metric context error: %s", traceback.format_exc())
return JSONResponse({"error": str(e)}, status_code=500)
def _get_current_metric_raw(metric, cache):
"""Get the current raw value for a metric from the cache."""
# Direct cache keys
direct_keys = {
"fear_greed": ("fear_greed", "value"),
"puell_multiple": ("puell_multiple", "value"),
"mvrv_zscore": ("mvrv_zscore", "value"),
"reserve_risk": ("reserve_risk", "value"),
"rhodl_ratio": ("rhodl_ratio", "value"),
"nupl": ("nupl", "value"),
"drawdown": ("drawdown", "value"),
"hash_ribbons": ("hash_ribbons", "value"),
"sopr": ("sopr", "value"),
"sellside_risk": ("sellside_risk", "value"),
"active_address_momentum": ("active_address_momentum", "value"),
"txcount_momentum": ("txcount_momentum", "value"),
"nvt_price": ("nvt_price", "value"),
"vdd_multiple": ("vdd_multiple", "value"),
"lth_supply": ("lth_supply", "value"),
}
# Ratio-based metrics: compute from price vs reference
ratio_metrics = {
"price_vs_200w_sma": ("price", "200w_sma"),
"lth_realized_price": ("price", "lth_realized_price"),
}
if metric in direct_keys:
k, sub = direct_keys[metric]
val = cache.get(k, {})
if isinstance(val, dict):
return val.get(sub)
return val
elif metric in ratio_metrics:
price_key, ref_key = ratio_metrics[metric]
price_val = cache.get(price_key, {}).get("price") or cache.get(price_key, {}).get("value")
ref_val = cache.get(ref_key, {}).get("value")
if price_val and ref_val and ref_val > 0:
return ((price_val - ref_val) / ref_val) * 100
return None
def _get_historical_metric_raw(metric, index, date):
"""Get the raw value for a metric on a specific historical date."""
from backtesting.engine import _last_known_value
direct_keys = {
"fear_greed": "fear_greed",
"puell_multiple": "puell_multiple",
"mvrv_zscore": "mvrv_zscore",
"reserve_risk": "reserve_risk",
"rhodl_ratio": "rhodl_ratio",
"nupl": "nupl",
"drawdown": "drawdown",
"hash_ribbons": "hash_ribbons",
"sopr": "sopr",
"sellside_risk": "sellside_risk",
"active_address_momentum": "active_address_momentum",
"txcount_momentum": "txcount_momentum",
"nvt_price": "nvt_price",
"vdd_multiple": "vdd_multiple",
"lth_supply": "lth_supply",
}
if metric in direct_keys:
return _last_known_value(index.get(direct_keys[metric], {}), date)
# Ratio-based
if metric == "price_vs_200w_sma":
price_val = _last_known_value(index.get("btc_price", {}), date)
ref_val = _last_known_value(index.get("200w_sma", {}), date)
if price_val and ref_val and ref_val > 0:
return ((price_val - ref_val) / ref_val) * 100
if metric == "lth_realized_price":
price_val = _last_known_value(index.get("btc_price", {}), date)
ref_val = _last_known_value(index.get("lth_realized_price", {}), date)
if price_val and ref_val and ref_val > 0:
return ((price_val - ref_val) / ref_val) * 100
return None
def _auto_metric_margin(metric, current_val):
"""Compute a reasonable similarity margin based on metric type and scale."""
margins = {
"fear_greed": 5.0,
"puell_multiple": 0.15,
"mvrv_zscore": 0.5,
"reserve_risk": 0.002,
"rhodl_ratio": 300,
"nupl": 0.1,
"drawdown": 8.0,
"sopr": 0.02,
"sellside_risk": 0.001,
"active_address_momentum": 0.05,
"txcount_momentum": 0.05,
"nvt_price": 5000,
"vdd_multiple": 0.15,
"price_vs_200w_sma": 10.0,
"lth_realized_price": 10.0,
}
if metric in margins:
return margins[metric]
# Fallback: 15% of current value
return abs(current_val) * 0.15 if current_val != 0 else 1.0
def _compute_day_forward_returns(price_lookup, date):
"""Compute forward returns for a single date."""
from datetime import datetime as _dt, timedelta as _td
p0 = price_lookup.get(date)
if p0 is None or p0 <= 0:
return {}
r = {}
dt = _dt.strptime(date, "%Y-%m-%d")
for days in [30, 90, 180, 365]:
future = (dt + _td(days=days)).strftime("%Y-%m-%d")
pf = price_lookup.get(future)
if pf is not None:
r[f"{days}d"] = round(((pf - p0) / p0) * 100, 2)
return r
# ── 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"><div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:12px"><h2 style="margin:0">Historical Score vs BTC Price</h2><div id="btRangeBar" style="display:flex;gap:4px"><button class="range-btn" data-days="30">30D</button><button class="range-btn" data-days="90">90D</button><button class="range-btn" data-days="180">6M</button><button class="range-btn" data-days="365">1Y</button><button class="range-btn" data-days="730">2Y</button><button class="range-btn" data-days="1460">4Y</button><button class="range-btn active" data-days="0">ALL</button></div></div><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;
}
}
let btDualChart = null;
let btCurrentRange = 0;
function renderDualChart(days) {
if (days === undefined) days = btCurrentRange;
btCurrentRange = days;
const chart = backtestData.chart_data;
if (!chart || !chart.length) return;
let data = chart;
if (days > 0) data = chart.slice(-days);
const ctx = document.getElementById('dualChart').getContext('2d');
const labels = data.map(d => d.date);
const scores = data.map(d => d.score);
const prices = data.map(d => d.price);
// Use log scale only for ALL or 4Y+ (when price spans multiple orders of magnitude)
const priceMin = Math.min(...prices.filter(p => p > 0));
const priceMax = Math.max(...prices);
const useLog = (priceMax / priceMin) > 20; // >20x range = log makes sense
const zonePlugin = {
id: 'zoneBackground',
beforeDraw(chart) {
const { ctx: c, chartArea: {left, right}, scales: {y} } = chart;
if (!y) return;
const zones = [
{ min: 0, max: 35, color: 'rgba(239,68,68,0.06)' },
{ min: 35, max: 50, color: 'rgba(234,179,8,0.04)' },
{ min: 50, max: 65, color: 'rgba(234,179,8,0.06)' },
{ min: 65, 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);
}
// Threshold lines
[35, 50, 65].forEach(val => {
const yPos = y.getPixelForValue(val);
c.beginPath();
c.setLineDash([4, 4]);
c.strokeStyle = 'rgba(255,255,255,0.06)';
c.lineWidth = 1;
c.moveTo(left, yPos);
c.lineTo(right, yPos);
c.stroke();
c.setLineDash([]);
});
}
};
if (btDualChart) btDualChart.destroy();
btDualChart = 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: data.length < 100 ? 2 : 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, family: 'monospace' } } },
tooltip: {
backgroundColor: 'rgba(10,10,15,0.95)',
borderColor: 'rgba(255,255,255,0.08)',
borderWidth: 1,
titleFont: { family: 'monospace', size: 11 },
bodyFont: { family: 'monospace', size: 11 },
callbacks: {
label: function(ctx) {
if (ctx.datasetIndex === 1) return 'BTC: $' + Math.round(ctx.parsed.y).toLocaleString();
const s = ctx.parsed.y;
let zone = s >= 80 ? 'Extreme Accum' : s >= 65 ? 'Strong Accum' : s >= 50 ? 'Moderate' : s >= 35 ? 'Neutral' : 'Caution';
return 'Score: ' + s.toFixed(1) + ' (' + zone + ')';
}
}
}
},
scales: {
x: { ticks: { color: '#94a3b8', maxTicksLimit: 15, maxRotation: 45, font: { family: 'monospace', size: 10 } }, grid: { color: 'rgba(255,255,255,0.03)' } },
y: { position: 'left', min: 0, max: 100, ticks: { color: '#f7931a', font: { family: 'monospace', size: 10 } }, grid: { color: 'rgba(255,255,255,0.03)' },
title: { display: true, text: 'Score (0-100)', color: '#f7931a', font: { family: 'monospace', size: 11 } } },
y1: {
position: 'right',
type: useLog ? 'logarithmic' : 'linear',
ticks: {
color: '#22d3ee',
font: { family: 'monospace', size: 10 },
callback: v => '$' + (v >= 1000 ? (v/1000).toFixed(v >= 10000 ? 0 : 1) + 'k' : v.toLocaleString())
},
grid: { drawOnChartArea: false },
title: { display: true, text: useLog ? 'BTC Price (log)' : 'BTC Price', color: '#22d3ee', font: { family: 'monospace', size: 11 } }
}
}
}
});
}
// Backtest range button handlers
document.addEventListener('click', function(e) {
if (e.target.closest('#btRangeBar .range-btn')) {
const btn = e.target.closest('.range-btn');
document.querySelectorAll('#btRangeBar .range-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderDualChart(parseInt(btn.dataset.days));
}
});
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)