- Time range buttons: 30D, 90D, 6M, 1Y, 2Y, 4Y, ALL - BTC price overlay on right y-axis (orange dashed line) - Accumulation zone backgrounds (green/yellow/red shading) - Threshold lines at 65, 50, 35 - Tooltip shows score + zone label + BTC price - Uses backtest daily_scores for full history (not just score_history.jsonl) - Smart downsampling: daily for last 2yr, weekly before that - Chart height increased to 320px
1618 lines
66 KiB
Python
1618 lines
66 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">📊 Backtest</a>
|
|
<a href="/settings" id="nav-settings">⚙ 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: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}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
|
|
<div class="header">
|
|
<div>
|
|
<h1><span class="btc">₿</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 →</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 — 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;
|
|
let fullDailyScores = null;
|
|
let currentRange = 0; // 0 = ALL
|
|
|
|
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',
|
|
});
|
|
}
|
|
|
|
// Accumulation zone backgrounds
|
|
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([]);
|
|
});
|
|
}
|
|
};
|
|
|
|
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();
|
|
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: {
|
|
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 } }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Load backtest daily scores for the chart
|
|
async function loadBacktestChart() {
|
|
try {
|
|
const r = await fetch('/api/backtest');
|
|
const data = await r.json();
|
|
if (data.chart_data && data.chart_data.length) {
|
|
fullDailyScores = data.chart_data;
|
|
applyChartRange(currentRange);
|
|
}
|
|
} 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'), 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">₿</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>⚙ 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 — 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">₿</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 — 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 + ' — ' + 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)
|