BizzleBot 62e32fc655 feat: replace ML optimizer with on-chain accumulation zone monitor
Complete rewrite — replaces the ML-based signal optimizer with a transparent
on-chain metric monitoring dashboard. Scrapes 10 metrics from LookIntoBitcoin
(Playwright) and free APIs, scores each 0-10, composite 0-100.

Metrics: Fear & Greed, Puell Multiple, MVRV Z-Score, Drawdown from ATH,
Price vs 200W SMA, Reserve Risk, RHODL Ratio, NUPL, LTH Realized Price,
Hash Ribbons. Auto-refreshes every 15 minutes. Settings page preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 22:31:29 +00:00

945 lines
37 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():
"""Run a full scrape cycle and update cache."""
global _last_update, _last_error, _scraper_running
with _scraper_lock:
if _scraper_running:
return
_scraper_running = True
try:
log.info("Starting scrape cycle...")
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 via Playwright (slow)
log.info("Scraping on-chain metrics from LookIntoBitcoin...")
try:
from scrapers import lookintobitcoin
onchain = lookintobitcoin.scrape_all()
metrics.update(onchain)
except Exception as e:
log.error("LookIntoBitcoin scraping failed: %s\n%s", e, traceback.format_exc())
_last_error = f"On-chain scraping failed: {e}"
# 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)
_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 that runs scrape every 15 minutes."""
while True:
run_scrape()
time.sleep(900) # 15 minutes
# 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():
"""Trigger a manual scrape."""
if _scraper_running:
return JSONResponse({"error": "Scrape already in progress"}, status_code=409)
t = threading.Thread(target=run_scrape, daemon=True)
t.start()
return {"ok": True, "message": "Scrape started"}
# 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="/settings" id="nav-settings">&#9881; Settings</a>
</div>"""
TOAST_JS = """
function showToast(msg, type) {
let t = document.getElementById('toast');
if (!t) { t = document.createElement('div'); t.id = 'toast'; t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.className = 'toast toast-' + type + ' show';
setTimeout(() => t.classList.remove('show'), 3500);
}
"""
DASHBOARD_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
""" + SHARED_HEAD + """
<title>Bitcoin Accumulation Zone Monitor</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<style>
""" + SHARED_CSS + """
.hero{display:flex;align-items:center;gap:24px;flex-wrap:wrap;margin-bottom:20px}
.score-ring{position:relative;width:160px;height:160px;flex-shrink:0}
.score-ring canvas{width:160px;height:160px}
.score-ring .score-text{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center}
.score-ring .score-number{font-size:2.8rem;font-weight:800;font-family:var(--mono);line-height:1}
.score-ring .score-label{font-size:.65rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.08em;margin-top:2px}
.hero-info{flex:1;min-width:200px}
.assessment{font-size:1.3rem;font-weight:700;letter-spacing:.02em;margin-bottom:4px}
.price-display{font-size:2rem;font-weight:700;font-family:var(--mono);color:var(--accent);margin-bottom:4px}
.price-change{font-size:.9rem;font-family:var(--mono)}
.price-change.up{color:var(--green)}
.price-change.down{color:var(--red)}
.meta-row{display:flex;gap:16px;flex-wrap:wrap;margin-top:8px;font-size:.8rem;color:var(--text-dim)}
.meta-row span{display:flex;align-items:center;gap:4px}
.metrics-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px;margin-bottom:20px}
.metric-card{background:var(--card);border-radius:10px;padding:14px;border:1px solid var(--border);transition:border-color .15s}
.metric-card:hover{border-color:var(--text-dim)}
.metric-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px}
.metric-name{font-size:.85rem;font-weight:600}
.metric-score{display:flex;align-items:center;gap:6px}
.metric-score-num{font-size:1.1rem;font-weight:800;font-family:var(--mono)}
.metric-score-bar{width:60px;height:6px;background:var(--bg);border-radius:3px;overflow:hidden}
.metric-score-fill{height:100%;border-radius:3px;transition:width .3s}
.metric-value{font-size:.95rem;font-family:var(--mono);color:var(--accent);margin-bottom:4px}
.metric-desc{font-size:.78rem;color:var(--text-dim);line-height:1.4}
.metric-sparkline{margin-top:8px;height:30px}
.metric-sparkline canvas{width:100%;height:30px}
.chart-section{margin-bottom:20px}
.chart-container{position:relative;height:280px}
.status-line{display:flex;align-items:center;gap:8px;font-size:.78rem;color:var(--text-dim)}
.status-dot{width:8px;height:8px;border-radius:50%}
.status-dot.live{background:var(--green);animation:pulse 1.5s infinite}
.status-dot.stale{background:var(--yellow)}
.status-dot.error{background:var(--red)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<h1><span class="btc">&#x20BF;</span> Accumulation Zone Monitor</h1>
<div style="margin-top:8px;display:flex;align-items:center;gap:12px">
""" + NAV_HTML + """
</div>
</div>
<div style="display:flex;align-items:center;gap:12px">
<div class="status-line" id="statusLine">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">Loading...</span>
</div>
<button class="btn btn-accent" onclick="doRefresh()" id="btnRefresh">Refresh Data</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>
<!-- Metrics Grid -->
<h2>On-Chain Metrics</h2>
<div class="metrics-grid" id="metricsGrid">
<div class="card" style="text-align:center;color:var(--text-dim);padding:40px">Loading metrics...</div>
</div>
<!-- Historical Chart -->
<div class="card chart-section">
<h2>Composite Score History</h2>
<div class="chart-container">
<canvas id="historyChart"></canvas>
</div>
</div>
<div class="footer">Bitcoin Accumulation Zone Monitor &mdash; On-chain metrics updated every 15 minutes</div>
</div>
<script>
document.getElementById('nav-dashboard').classList.add('active');
""" + TOAST_JS + """
function scoreColor(score, max) {
const pct = max > 0 ? score / max : 0;
if (pct >= 0.7) return '#22c55e';
if (pct >= 0.5) return '#4ade80';
if (pct >= 0.3) return '#eab308';
if (pct >= 0.15) return '#f97316';
return '#ef4444';
}
function assessmentColor(score) {
if (score >= 71) return '#22c55e';
if (score >= 51) return '#4ade80';
if (score >= 31) return '#eab308';
if (score >= 15) return '#f97316';
return '#ef4444';
}
function drawScoreRing(score) {
const canvas = document.getElementById('scoreRing');
const ctx = canvas.getContext('2d');
const cx = 80, cy = 80, r = 65, lw = 12;
ctx.clearRect(0, 0, 160, 160);
// Background ring
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = '#1e293b';
ctx.lineWidth = lw;
ctx.stroke();
// Score arc
const pct = Math.min(score / 100, 1);
const startAngle = -Math.PI / 2;
const endAngle = startAngle + pct * Math.PI * 2;
ctx.beginPath();
ctx.arc(cx, cy, r, startAngle, endAngle);
ctx.strokeStyle = assessmentColor(score);
ctx.lineWidth = lw;
ctx.lineCap = 'round';
ctx.stroke();
document.getElementById('scoreNumber').textContent = Math.round(score);
document.getElementById('scoreNumber').style.color = assessmentColor(score);
}
function drawSparkline(canvasId, data, color) {
const canvas = document.getElementById(canvasId);
if (!canvas || !data || data.length < 2) return;
const ctx = canvas.getContext('2d');
const w = canvas.width = canvas.offsetWidth * 2;
const h = canvas.height = 60;
ctx.clearRect(0, 0, w, h);
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const pad = 4;
ctx.beginPath();
for (let i = 0; i < data.length; i++) {
const x = pad + (i / (data.length - 1)) * (w - pad * 2);
const y = h - pad - ((data[i] - min) / range) * (h - pad * 2);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
function renderMetrics(metrics) {
const grid = document.getElementById('metricsGrid');
if (!metrics || !metrics.length) {
grid.innerHTML = '<div class="card" style="text-align:center;color:var(--text-dim);padding:40px">No metrics available yet. Data is being scraped...</div>';
return;
}
let html = '';
metrics.forEach((m, idx) => {
const score = m.score != null ? m.score : '--';
const color = m.score != null ? scoreColor(m.score, 10) : '#64748b';
const fillPct = m.score != null ? (m.score / 10 * 100) : 0;
const hasSparkline = m.recent && m.recent.length > 2;
html += '<div class="metric-card">';
html += '<div class="metric-header">';
html += '<div class="metric-name">' + m.name + '</div>';
html += '<div class="metric-score">';
html += '<div class="metric-score-bar"><div class="metric-score-fill" style="width:' + fillPct + '%;background:' + color + '"></div></div>';
html += '<div class="metric-score-num" style="color:' + color + '">' + score + '</div>';
html += '</div></div>';
html += '<div class="metric-value">' + (m.display_value || 'N/A') + '</div>';
html += '<div class="metric-desc">' + (m.description || '') + '</div>';
if (hasSparkline) {
html += '<div class="metric-sparkline"><canvas id="spark-' + idx + '"></canvas></div>';
}
html += '</div>';
});
grid.innerHTML = html;
// Draw sparklines after DOM update
requestAnimationFrame(() => {
metrics.forEach((m, idx) => {
if (m.recent && m.recent.length > 2) {
drawSparkline('spark-' + idx, m.recent, scoreColor(m.score || 0, 10));
}
});
});
}
let histChart = null;
function renderHistory(history) {
const ctx = document.getElementById('historyChart').getContext('2d');
if (!history || !history.length) return;
const labels = history.map(h => {
const d = new Date(h.timestamp);
return (d.getMonth()+1) + '/' + d.getDate();
});
const scores = history.map(h => h.composite_score);
if (histChart) histChart.destroy();
histChart = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
label: 'Composite Score',
data: scores,
borderColor: '#f7931a',
backgroundColor: 'rgba(247,147,26,0.1)',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#f7931a',
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: '#94a3b8', font: { size: 11 } } },
annotation: null,
},
scales: {
x: { ticks: { color: '#94a3b8', maxTicksLimit: 15 }, grid: { color: '#1e293b' } },
y: { min: 0, max: 100, ticks: { color: '#f7931a' }, grid: { color: '#1e293b' },
title: { display: true, text: 'Score (0-100)', color: '#f7931a' }
}
}
}
});
}
function updateStatus(data) {
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
if (data.scraper_running) {
dot.className = 'status-dot live';
text.textContent = 'Scraping in progress...';
} else if (data.last_error) {
dot.className = 'status-dot error';
text.textContent = 'Error: ' + data.last_error.substring(0, 50);
} else if (data.last_update) {
dot.className = 'status-dot live';
const ago = Math.round((Date.now() - new Date(data.last_update).getTime()) / 60000);
text.textContent = 'Updated ' + (ago < 1 ? 'just now' : ago + 'm ago');
} else {
dot.className = 'status-dot stale';
text.textContent = 'Waiting for first scrape...';
}
}
async function poll() {
try {
const [dataRes, histRes] = await Promise.all([
fetch('/api/data'), fetch('/api/history')
]);
const data = await dataRes.json();
const history = await histRes.json();
// Update score ring
const scored = data.scored || {};
const composite = scored.composite_score || 0;
drawScoreRing(composite);
// Assessment
const el = document.getElementById('assessment');
el.textContent = scored.assessment || 'Loading...';
el.style.color = assessmentColor(composite);
// Price
if (data.price) {
document.getElementById('priceDisplay').textContent = '$' + data.price.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0});
}
const chEl = document.getElementById('priceChange');
if (data.change_24h != null) {
const ch = data.change_24h;
chEl.textContent = (ch >= 0 ? '+' : '') + ch.toFixed(2) + '% (24h)';
chEl.className = 'price-change ' + (ch >= 0 ? 'up' : 'down');
}
// Meta
if (data.ath) document.getElementById('athDisplay').textContent = '$' + data.ath.toLocaleString();
if (data.mayer_multiple) document.getElementById('mayerDisplay').textContent = data.mayer_multiple.toFixed(2);
if (data.sma_200d) document.getElementById('sma200dDisplay').textContent = '$' + Math.round(data.sma_200d).toLocaleString();
if (scored.scored_count != null) {
document.getElementById('scoredCount').textContent = scored.scored_count + '/' + scored.total_count + ' metrics active';
}
// Metrics
renderMetrics(scored.metrics || []);
// Status
updateStatus(data);
// History chart
renderHistory(history);
} catch(e) { console.error('Poll error:', e); }
}
async function doRefresh() {
const btn = document.getElementById('btnRefresh');
btn.disabled = true;
btn.textContent = 'Refreshing...';
try {
const r = await fetch('/api/refresh', { method: 'POST' });
const d = await r.json();
if (d.error) showToast(d.error, 'error');
else showToast('Scrape started — results in ~2 min', 'success');
} catch(e) { showToast('Failed: ' + e, 'error'); }
setTimeout(() => { btn.disabled = false; btn.textContent = 'Refresh Data'; }, 5000);
}
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>"""
@app.get("/", response_class=HTMLResponse)
def dashboard():
return DASHBOARD_HTML
@app.get("/settings", response_class=HTMLResponse)
def settings_page():
return SETTINGS_HTML
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=3088)