#!/usr/bin/env python3 """ Bitcoin Accumulation Zone Monitor — Web Dashboard FastAPI server with inline HTML/CSS/JS dashboard. Monitors on-chain metrics to identify optimal BTC accumulation zones. """ import asyncio import json import logging import os import sys import threading import time import traceback from contextlib import asynccontextmanager from datetime import datetime, timezone import requests from fastapi import FastAPI from fastapi.responses import HTMLResponse, JSONResponse from pydantic import BaseModel logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s") log = logging.getLogger("btc-monitor") BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, BASE_DIR) from scrapers import fear_greed, price from scoring import engine from dashboard.persistence import ( append_daily_jsonl, atomic_write_json, load_json, load_jsonl_tail, merge_observation, onchain_refresh_due, ) from dashboard.jobs import JobRegistry _shutdown_event = threading.Event() _background_threads = [] _threads_lock = threading.Lock() @asynccontextmanager async def lifespan(_app): """Own background worker startup and graceful shutdown.""" _shutdown_event.clear() scraper_thread = threading.Thread(target=scraper_loop, name="scraper-scheduler") with _threads_lock: _background_threads.append(scraper_thread) scraper_thread.start() try: yield finally: _shutdown_event.set() with _threads_lock: threads = list(_background_threads) for thread in threads: thread.join(timeout=30) with _threads_lock: _background_threads.clear() app = FastAPI(title="Bitcoin Accumulation Zone Monitor", lifespan=lifespan) CONFIG_DIR = os.path.join(BASE_DIR, "config") DATA_DIR = os.path.join(BASE_DIR, "data") CACHE_PATH = os.path.join(DATA_DIR, "cache.json") HISTORY_PATH = os.path.join(DATA_DIR, "score_history.jsonl") LLM_SETTINGS_PATH = os.path.join(CONFIG_DIR, "llm_settings.json") JOBS_PATH = os.path.join(DATA_DIR, "jobs.json") os.makedirs(DATA_DIR, exist_ok=True) _jobs = JobRegistry(JOBS_PATH) # Background scraper state _scraper_lock = threading.Lock() _scraper_running = False _last_update = None _last_error = None def _job_worker(job_id, operation): try: _jobs.run(job_id, operation) except Exception: log.error("Background job %s failed:\n%s", job_id, traceback.format_exc()) finally: current = threading.current_thread() with _threads_lock: if current in _background_threads: _background_threads.remove(current) def _spawn_job(job, operation): """Start an already-reserved job in a tracked, non-daemon thread.""" thread = threading.Thread( target=_job_worker, args=(job["id"], operation), name=f"{job['kind']}-{job['id'][:8]}", ) with _threads_lock: _background_threads.append(thread) thread.start() return thread # ── Cache management ────────────────────────────────────────────────────── def load_cache(): return load_json(CACHE_PATH, {}) def save_cache(data): atomic_write_json(CACHE_PATH, data) @app.get("/health/live") def health_live(): """Report that the API process is responsive.""" return {"status": "ok"} @app.get("/health/ready") def health_ready(): """Report readiness only after a usable score has been persisted.""" scored = load_cache().get("_scored", {}) score = scored.get("composite_score") count = scored.get("scored_count", 0) if score is None or count < 1: return JSONResponse( {"status": "not_ready", "reason": "no usable persisted score"}, status_code=503, ) return {"status": "ready", "score": score, "scored_metrics": count} def append_history(score_data): """Append a daily score entry to history.""" entry = { "timestamp": datetime.now(timezone.utc).isoformat(), "composite_score": score_data.get("composite_score", 0), "scored_count": score_data.get("scored_count", 0), "metrics": { m["key"]: {"score": m["score"], "value": m["value"]} for m in score_data.get("metrics", []) }, } append_daily_jsonl(HISTORY_PATH, entry) def load_history(): return load_jsonl_tail(HISTORY_PATH, limit=90) # ── Background scraper ──────────────────────────────────────────────────── def _scrape_onchain_sources(): """Run independent on-chain providers so one outage cannot mask the other.""" observations = {} errors = [] successful_sources = 0 providers = ( ("LookIntoBitcoin", "scrapers.lookintobitcoin"), ("CheckOnChain", "scrapers.checkonchain"), ) for display_name, module_name in providers: try: module = __import__(module_name, fromlist=["scrape_all"]) observations.update(module.scrape_all()) successful_sources += 1 except Exception as exc: log.error("%s scraping failed: %s\n%s", display_name, exc, traceback.format_exc()) errors.append(f"{display_name}: {exc}") return observations, errors, successful_sources def run_scrape(force_full=False): """Run a scrape cycle and update cache. By default, only refreshes fast data (price, F&G) and reuses cached on-chain data. On-chain metrics (Playwright scrapes) only refresh if: - force_full=True (manual full refresh) - No cached on-chain data exists - Cached on-chain data is >6 hours old (they update daily) """ global _last_update, _last_error, _scraper_running with _scraper_lock: if _scraper_running: return _scraper_running = True try: existing_cache = load_cache() metrics = {} cycle_errors = [] # Fast metrics fail independently so partial outages retain last-known-good data. log.info("Fetching Fear & Greed...") try: metrics["fear_greed"] = merge_observation( existing_cache.get("fear_greed"), fear_greed.fetch(), source="alternative.me" ) except Exception as e: cycle_errors.append(f"Fear & Greed: {e}") metrics["fear_greed"] = merge_observation( existing_cache.get("fear_greed"), None, source="alternative.me", error=str(e), ) log.info("Fetching BTC price...") try: price_current = price.fetch_current() metrics["price"] = merge_observation( existing_cache.get("price"), price_current, source="coingecko" ) except Exception as e: cycle_errors.append(f"Price: {e}") metrics["price"] = merge_observation( existing_cache.get("price"), None, source="coingecko", error=str(e) ) price_current = metrics["price"] log.info("Fetching BTC ATH...") try: ath_data = price.fetch_ath() except Exception as e: cycle_errors.append(f"ATH: {e}") ath_data = {} ath_val = ath_data.get("ath") or existing_cache.get("drawdown", {}).get("ath") if price_current.get("price") and ath_val: drawdown = price.calculate_drawdown(price_current["price"], ath_val) metrics["drawdown"] = {"value": drawdown, "ath": ath_val} elif existing_cache.get("drawdown", {}).get("value") is not None: log.info("ATH fetch failed — reusing cached drawdown") metrics["drawdown"] = existing_cache["drawdown"] else: metrics["drawdown"] = {"value": None} log.info("Fetching historical prices for 200D SMA / Mayer...") try: hist = price.fetch_historical() except Exception as e: cycle_errors.append(f"Historical price: {e}") hist = [] if hist: sma_200d = price.calculate_200d_sma(hist) mayer = price.calculate_mayer_multiple(price_current.get("price"), sma_200d) metrics["price_extras"] = {"sma_200d": sma_200d, "mayer_multiple": mayer} else: # CoinGecko rate-limited — compute from history.json instead try: hist_path = os.path.join(DATA_DIR, "history.json") with open(hist_path) as f: hdata = json.load(f) btc_vals = hdata.get("btc_price", {}).get("values", []) if len(btc_vals) >= 200: sma_200d = sum(btc_vals[-200:]) / 200 cur_p = price_current.get("price") or btc_vals[-1] mayer = cur_p / sma_200d if sma_200d else None metrics["price_extras"] = {"sma_200d": sma_200d, "mayer_multiple": round(mayer, 4) if mayer else None} log.info("Computed 200D SMA from history.json (CoinGecko rate-limited)") elif existing_cache.get("price_extras"): metrics["price_extras"] = existing_cache["price_extras"] except Exception: if existing_cache.get("price_extras"): metrics["price_extras"] = existing_cache["price_extras"] log.info("Reusing cached price_extras") # 3. On-chain metrics — use cached values (historical data is permanent) onchain_keys = ["puell_multiple", "mvrv_zscore", "reserve_risk", "rhodl_ratio", "nupl", "200w_sma", "lth_realized_price", "hash_ribbons", "pi_cycle_bottom", "lth_supply", "sopr", "sellside_risk", "active_address_momentum", "txcount_momentum", "nvt_price", "vdd_multiple"] refresh_onchain = force_full or onchain_refresh_due(existing_cache.get("_onchain_timestamp")) if refresh_onchain: log.info("Refreshing on-chain metrics (forced, missing, or TTL expired)...") onchain, onchain_errors, successful_sources = _scrape_onchain_sources() cycle_errors.extend(onchain_errors) checkonchain_keys = {"sopr", "sellside_risk", "active_address_momentum", "txcount_momentum", "nvt_price", "vdd_multiple"} for key in onchain_keys: source = "checkonchain" if key in checkonchain_keys else "lookintobitcoin" metrics[key] = merge_observation( existing_cache.get(key), onchain.get(key), source=source, error="metric missing from scrape", ) if successful_sources: metrics["_onchain_timestamp"] = datetime.now(timezone.utc).isoformat() elif "_onchain_timestamp" in existing_cache: metrics["_onchain_timestamp"] = existing_cache["_onchain_timestamp"] else: # Reuse cached on-chain values — they're stored permanently log.info("Reusing cached on-chain data (use Full Refresh to re-scrape)") for k in onchain_keys: if k in existing_cache: metrics[k] = existing_cache[k] if "_onchain_timestamp" in existing_cache: metrics["_onchain_timestamp"] = existing_cache["_onchain_timestamp"] # 4. Score everything (classic + ML) log.info("Scoring metrics...") scored = engine.score_all(metrics) metrics["_scored"] = scored # ML-optimized scoring (parallel) try: scored_ml = engine.score_all_ml(metrics) metrics["_scored_ml"] = scored_ml except Exception as e: log.warning("ML scoring failed (non-critical): %s", e) metrics["_timestamp"] = datetime.now(timezone.utc).isoformat() save_cache(metrics) append_history(scored) # Append today's values to permanent history (incremental, not full re-scrape) try: from scrapers.history_updater import update_history update_history() except Exception as e: log.warning("History update failed (non-critical): %s", e) _last_update = datetime.now(timezone.utc).isoformat() _last_error = "; ".join(cycle_errors) if cycle_errors else None log.info("Scrape cycle complete. Composite score: %s", scored["composite_score"]) except Exception as e: log.error("Scrape cycle error: %s\n%s", e, traceback.format_exc()) _last_error = str(e) finally: with _scraper_lock: _scraper_running = False def _run_scheduled_refresh(force_full=False): job = _jobs.reserve("refresh", details={"full": force_full, "scheduled": True}) if job is not None: _jobs.run(job["id"], lambda: run_scrape(force_full=force_full)) def scraper_loop(): """Background loop: refresh quickly every 15 minutes, with on-chain TTL handling.""" cache = load_cache() has_data = any(cache.get(k, {}).get("value") is not None for k in ["puell_multiple", "mvrv_zscore", "nupl"]) _run_scheduled_refresh(force_full=not has_data) while not _shutdown_event.wait(900): _run_scheduled_refresh() # ── LLM Settings (preserved from original) ─────────────────────────────── class LLMSettingsUpdate(BaseModel): provider: str model: str providers: dict class TestConnectionRequest(BaseModel): provider: str providers: dict class FetchModelsRequest(BaseModel): provider: str providers: dict def _load_llm_settings(): if os.path.exists(LLM_SETTINGS_PATH): with open(LLM_SETTINGS_PATH) as f: return json.load(f) return { "provider": "ollama", "model": "qwen3.5:27b", "providers": { "ollama": {"base_url": "http://100.100.242.21:11434"}, "lmstudio": {"base_url": "http://100.100.242.21:1234"}, "openai": {"api_key": ""}, "anthropic": {"api_key": ""}, "openrouter": {"api_key": ""}, }, } def _mask_api_key(key): if not key or len(key) < 8: return "" return "••••••••" + key[-4:] def _safe_settings(settings): out = json.loads(json.dumps(settings)) for name, cfg in out.get("providers", {}).items(): if "api_key" in cfg: cfg["api_key"] = _mask_api_key(cfg["api_key"]) return out def _merge_api_keys(new_providers, existing_providers): for name, cfg in new_providers.items(): if "api_key" in cfg: masked = cfg["api_key"] if masked.startswith("••••") or masked == "": existing_key = existing_providers.get(name, {}).get("api_key", "") cfg["api_key"] = existing_key def _fetch_models(provider, providers): cfg = providers.get(provider, {}) if provider == "ollama": base_url = cfg.get("base_url", "http://100.100.242.21:11434") resp = requests.get(f"{base_url}/api/tags", timeout=10) resp.raise_for_status() return [{"id": m["name"], "name": m["name"]} for m in resp.json().get("models", [])] elif provider == "lmstudio": base_url = cfg.get("base_url", "http://100.100.242.21:1234") resp = requests.get(f"{base_url}/v1/models", timeout=10) resp.raise_for_status() return [{"id": m["id"], "name": m["id"]} for m in resp.json().get("data", [])] elif provider == "openai": api_key = cfg.get("api_key", "") if not api_key: raise ValueError("OpenAI API key is required") resp = requests.get("https://api.openai.com/v1/models", headers={"Authorization": f"Bearer {api_key}"}, timeout=15) resp.raise_for_status() models = [m for m in resp.json().get("data", []) if m["id"].startswith("gpt-")] models.sort(key=lambda m: m["id"]) return [{"id": m["id"], "name": m["id"]} for m in models] elif provider == "anthropic": api_key = cfg.get("api_key", "") if not api_key: raise ValueError("Anthropic API key is required") resp = requests.get("https://api.anthropic.com/v1/models", headers={"x-api-key": api_key, "anthropic-version": "2023-06-01"}, timeout=15) resp.raise_for_status() return [{"id": m["id"], "name": m.get("display_name", m["id"])} for m in resp.json().get("data", [])] elif provider == "openrouter": resp = requests.get("https://openrouter.ai/api/v1/models", timeout=15) resp.raise_for_status() models = resp.json().get("data", []) models.sort(key=lambda m: m.get("id", "")) return [{"id": m["id"], "name": m.get("name", m["id"])} for m in models[:200]] else: raise ValueError(f"Unknown provider: {provider}") # ── API Routes ──────────────────────────────────────────────────────────── def _with_informational_onchain_metrics(scored, cache): """Add non-scored on-chain data cards without changing composite scoring.""" if not isinstance(scored, dict): return scored enriched = dict(scored) metrics = [dict(m) for m in scored.get("metrics", [])] existing_keys = {m.get("key") for m in metrics} lth_supply = cache.get("lth_supply", {}) lth_value = lth_supply.get("value") if lth_value is not None and "lth_supply" not in existing_keys: trend = lth_supply.get("trend") trend_text = f" — {trend}" if trend else "" metrics.append({ "name": "Long-Term Holder Supply", "key": "lth_supply", "value": lth_value, "display_value": f"{lth_value:,.0f} BTC", "score": None, "description": "Informational on-chain metric; not included in the composite score" + trend_text, "recent": lth_supply.get("recent", []), }) pi_cycle = cache.get("pi_cycle_bottom", {}) pi_value = pi_cycle.get("value") if pi_value is not None and "pi_cycle_bottom" not in existing_keys: metrics.append({ "name": "Pi Cycle Bottom", "key": "pi_cycle_bottom", "value": pi_value, "display_value": f"{pi_value:,.2f}" if isinstance(pi_value, (int, float)) else str(pi_value), "score": None, "description": "Informational on-chain cycle metric; not included in the composite score", "recent": pi_cycle.get("recent", []), }) enriched["metrics"] = metrics return enriched @app.get("/api/data") def api_data(mode: str = "classic"): """Return current cached metrics + scores. mode=classic (default) or mode=ml for ML-optimized scoring. """ cache = load_cache() if mode == "ml": scored = cache.get("_scored_ml", cache.get("_scored", {})) else: scored = cache.get("_scored", {}) scored = _with_informational_onchain_metrics(scored, cache) price_data = cache.get("price", {}) drawdown_data = cache.get("drawdown", {}) extras = cache.get("price_extras", {}) return { "scored": scored, "price": price_data.get("price"), "change_24h": price_data.get("change_24h"), "ath": drawdown_data.get("ath"), "mayer_multiple": extras.get("mayer_multiple"), "sma_200d": extras.get("sma_200d"), "last_update": cache.get("_timestamp"), "scraper_running": _scraper_running, "last_error": _last_error, "mode": mode, } @app.get("/api/history") def api_history(): return load_history()[-90:] # Last 90 entries @app.post("/api/refresh", status_code=202) def api_refresh(full: bool = False): """Atomically reserve and start a quick or full metric refresh.""" job = _jobs.reserve("refresh", details={"full": full, "scheduled": False}) if job is None: active = _jobs.active("refresh") return JSONResponse( {"error": "Scrape already in progress", "job": active}, status_code=409 ) _spawn_job(job, lambda: run_scrape(force_full=full)) mode = "full (on-chain + price + F&G)" if full else "quick (price + F&G only)" return { "ok": True, "job_id": job["id"], "status": job["status"], "message": f"Scrape started — {mode}", } @app.get("/api/jobs/{job_id}") def api_job_status(job_id: str): job = _jobs.get(job_id) if job is None: return JSONResponse({"error": "Job not found"}, status_code=404) return job # Settings routes (preserved) @app.get("/api/settings") def api_get_settings(): return _safe_settings(_load_llm_settings()) @app.post("/api/settings") def api_save_settings(body: LLMSettingsUpdate): existing = _load_llm_settings() new_settings = {"provider": body.provider, "model": body.model, "providers": body.providers} _merge_api_keys(new_settings["providers"], existing.get("providers", {})) with open(LLM_SETTINGS_PATH, "w") as f: json.dump(new_settings, f, indent=2) return {"ok": True, "message": "Settings saved"} @app.post("/api/settings/test") def api_test_connection(body: TestConnectionRequest): existing = _load_llm_settings() providers = json.loads(json.dumps(body.providers)) _merge_api_keys(providers, existing.get("providers", {})) try: models = _fetch_models(body.provider, providers) return {"ok": True, "models": models, "message": f"Connected — {len(models)} model(s) found"} except requests.exceptions.ConnectionError: return JSONResponse({"ok": False, "error": "Connection refused"}, status_code=502) except Exception as e: return JSONResponse({"ok": False, "error": str(e)}, status_code=500) @app.post("/api/settings/models") def api_fetch_models(body: FetchModelsRequest): existing = _load_llm_settings() providers = json.loads(json.dumps(body.providers)) _merge_api_keys(providers, existing.get("providers", {})) try: models = _fetch_models(body.provider, providers) return {"ok": True, "models": models} except Exception as e: return JSONResponse({"ok": False, "error": str(e)}, status_code=500) # ── HTML Pages ──────────────────────────────────────────────────────────── SHARED_CSS = """ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} :root{--bg:#0f172a;--card:#1e293b;--card-hover:#253349;--text:#e2e8f0;--text-dim:#94a3b8; --accent:#f7931a;--green:#22c55e;--red:#ef4444;--yellow:#eab308;--border:#334155; --mono:'JetBrains Mono','Fira Code','Courier New',monospace;--cyan:#22d3ee; --bright-green:#4ade80;--score-excellent:#22c55e;--score-good:#4ade80; --score-neutral:#eab308;--score-bad:#f97316;--score-terrible:#ef4444} body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh} .container{max-width:1400px;margin:0 auto;padding:16px} h1{font-size:1.5rem;font-weight:700;display:flex;align-items:center;gap:10px} h1 .btc{color:var(--accent);font-size:1.8rem} h2{font-size:.8rem;font-weight:600;color:var(--text-dim);margin-bottom:12px;text-transform:uppercase;letter-spacing:.05em} .header{display:flex;justify-content:space-between;align-items:center;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:16px;flex-wrap:wrap;gap:12px} .nav{display:flex;gap:4px;align-items:center} .nav a{color:var(--text-dim);text-decoration:none;font-size:.85rem;font-weight:600;padding:6px 14px;border-radius:6px;transition:all .15s} .nav a:hover{color:var(--text);background:var(--card)} .nav a.active{color:var(--cyan);background:var(--card);border:1px solid var(--border)} .btn{padding:8px 18px;border:none;border-radius:6px;font-family:inherit;font-weight:600;font-size:.85rem;cursor:pointer;transition:all .15s} .btn-accent{background:var(--accent);color:#000}.btn-accent:hover{background:#e8850f} .btn-secondary{background:var(--border);color:var(--text)}.btn-secondary:hover{background:var(--card-hover)} .btn-cyan{background:var(--cyan);color:#000}.btn-cyan:hover{background:#06b6d4} .btn:disabled{opacity:.4;cursor:not-allowed} .card{background:var(--card);border-radius:10px;padding:16px;border:1px solid var(--border)} .footer{text-align:center;color:var(--text-dim);font-size:.75rem;padding:20px 0;margin-top:16px;border-top:1px solid var(--border)} .toast{position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:8px;font-size:.85rem;font-weight:600;z-index:9999;opacity:0;transform:translateY(-10px);transition:all .3s;pointer-events:none} .toast.show{opacity:1;transform:translateY(0)} .toast-success{background:var(--green);color:#000} .toast-error{background:var(--red);color:#fff} """ SHARED_HEAD = """ """ NAV_HTML = """""" 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 = """ """ + SHARED_HEAD + """ Bitcoin Accumulation Zone Monitor

Accumulation Zone Monitor

""" + NAV_HTML + """
Loading...
--
of 100
Loading...
--
ATH: -- Mayer: -- 200D SMA: --

On-Chain Metrics

Loading metrics...

Composite Score History

""" SETTINGS_HTML = """ """ + SHARED_HEAD + """ Settings — Bitcoin Accumulation Zone Monitor

Accumulation Zone Monitor

""" + NAV_HTML + """

⚙ LLM Provider Settings

Provider

Connection

""" # ── Backtest API ─────────────────────────────────────────────────────── @app.get("/api/backtest") def api_backtest(mode: str = "classic"): """Run backtest and return full results. mode=classic (default) or mode=ml for ML-optimized scoring. """ try: from backtesting.engine import run_backtest return run_backtest(ml_mode=(mode == "ml")) except Exception as e: log.error("Backtest error: %s", traceback.format_exc()) return JSONResponse({"error": str(e)}, status_code=500) @app.get("/api/backtest/history") def api_backtest_history(): """Return historical daily scores + prices for charting.""" try: from backtesting.engine import run_backtest result = run_backtest() return {"chart_data": result.get("chart_data", []), "date_range": result.get("date_range")} except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) @app.post("/api/backtest/collect", status_code=202) def api_backtest_collect(): """Atomically reserve and start historical data collection.""" initial_progress = {"status": "starting", "current": "", "step": 0, "total": 0} job = _jobs.reserve("history") if job is None: active = _jobs.active("history") return JSONResponse( {"error": "Collection already in progress", "job": active}, status_code=409 ) _jobs.update_progress(job["id"], initial_progress) def _run_collector(): from scrapers.history_collector import collect_all_history def progress_cb(metric, step, total): _jobs.update_progress(job["id"], { "status": "scraping", "current": metric, "step": step + 1, "total": total, }) collect_all_history(progress_cb=progress_cb) _jobs.update_progress(job["id"], {"status": "complete"}) return {"collected": True} _spawn_job(job, _run_collector) return {"ok": True, "job_id": job["id"], "status": job["status"], "message": "Collection started"} @app.get("/api/backtest/status") def api_backtest_status(): """Check historical data and expose only the active collection job's progress.""" from scrapers.history_collector import history_status status = history_status() active = _jobs.active("history") status["collecting"] = active is not None status["job_id"] = active.get("id") if active else None status["progress"] = active.get("progress", {}) if active else {} return status @app.get("/api/metric-context") def api_metric_context(metric: str, margin: float = 0.0, mode: str = "classic"): """Find historical periods where a specific metric was at a similar level. Returns forward returns for those periods, analogous to the composite-score current_context but filtered to a single metric's historical similarity. margin: absolute tolerance for "similar" (auto-computed from metric scale if 0). """ try: from backtesting.engine import run_backtest, HISTORY_PATH, _build_daily_index, _get_all_dates, _last_known_value, METRIC_SCORERS, RATIO_SCORERS, DRAWDOWN_RANGES, _score_range import os as _os if not _os.path.exists(HISTORY_PATH): return JSONResponse({"error": "No historical data. Run history collector first."}, status_code=404) with open(HISTORY_PATH) as f: history = json.load(f) index = _build_daily_index(history) all_dates = _get_all_dates(index) # Get current metric value from cache cache = {} if _os.path.exists(CACHE_PATH): with open(CACHE_PATH) as f: cache = json.load(f) current_raw = _get_current_metric_raw(metric, cache) if current_raw is None: return JSONResponse({"error": f"No current value for metric '{metric}'"}, status_code=404) # Auto-compute margin from metric scale if margin <= 0: margin = _auto_metric_margin(metric, current_raw) # Build price lookup price_lookup = {} for pk in ["btc_price_coingecko", "btc_price", "btc_price_sma", "btc_price_lth"]: if pk in index: for d, v in index[pk].items(): if d not in price_lookup: price_lookup[d] = v # Find historical days where this metric was similar comparable = [] for d in all_dates: raw_val = _get_historical_metric_raw(metric, index, d) if raw_val is not None and abs(raw_val - current_raw) <= margin: price = price_lookup.get(d) fwd = _compute_day_forward_returns(price_lookup, d) if fwd: comparable.append({ "date": d, "raw_value": round(raw_val, 6) if isinstance(raw_val, float) else raw_val, "price": price, "forward_returns": fwd, }) # Compute average returns across comparable periods avg_returns = {} for period in ["30d", "90d", "180d", "365d"]: vals = [c["forward_returns"][period] for c in comparable if period in c["forward_returns"]] if vals: avg_returns[period] = round(sum(vals) / len(vals), 2) # Pick best examples (one per market cycle) cycle_bins = [ ("pre-2016", "2010-01-01", "2015-12-31"), ("2016-17 Bull", "2016-01-01", "2017-12-31"), ("2018-19 Bear", "2018-01-01", "2019-12-31"), ("2020-21 Bull", "2020-01-01", "2021-12-31"), ("2022-23 Bear", "2022-01-01", "2023-12-31"), ("2024+", "2024-01-01", "2099-12-31"), ] examples = [] used_cycles = set() sorted_comp = sorted(comparable, key=lambda c: abs(c["raw_value"] - current_raw)) for c in sorted_comp: for label, start, end in cycle_bins: if start <= c["date"] <= end and label not in used_cycles: used_cycles.add(label) examples.append({ "date": c["date"], "raw_value": c["raw_value"], "price": c["price"], "forward_returns": c["forward_returns"], "cycle": label, }) break if len(examples) >= 6: break examples.sort(key=lambda e: e["date"]) # Percentile: what % of all days had this metric at or below current value all_raw_vals = [] for d in all_dates: rv = _get_historical_metric_raw(metric, index, d) if rv is not None: all_raw_vals.append(rv) all_raw_vals.sort() below = len([v for v in all_raw_vals if v <= current_raw]) percentile = round(below / len(all_raw_vals) * 100, 1) if all_raw_vals else 50.0 return { "metric": metric, "current_raw": current_raw, "margin": margin, "comparable_days": len(comparable), "percentile": percentile, "avg_30d_return": avg_returns.get("30d"), "avg_90d_return": avg_returns.get("90d"), "avg_180d_return": avg_returns.get("180d"), "avg_1yr_return": avg_returns.get("365d"), "examples": examples, } except Exception as e: log.error("Metric context error: %s", traceback.format_exc()) return JSONResponse({"error": str(e)}, status_code=500) def _get_current_metric_raw(metric, cache): """Get the current raw value for a metric from the cache.""" # Direct cache keys direct_keys = { "fear_greed": ("fear_greed", "value"), "puell_multiple": ("puell_multiple", "value"), "mvrv_zscore": ("mvrv_zscore", "value"), "reserve_risk": ("reserve_risk", "value"), "rhodl_ratio": ("rhodl_ratio", "value"), "nupl": ("nupl", "value"), "drawdown": ("drawdown", "value"), "hash_ribbons": ("hash_ribbons", "value"), "sopr": ("sopr", "value"), "sellside_risk": ("sellside_risk", "value"), "active_address_momentum": ("active_address_momentum", "value"), "txcount_momentum": ("txcount_momentum", "value"), "nvt_price": ("nvt_price", "value"), "vdd_multiple": ("vdd_multiple", "value"), "lth_supply": ("lth_supply", "value"), } # Ratio-based metrics: compute from price vs reference ratio_metrics = { "price_vs_200w_sma": ("price", "200w_sma"), "lth_realized_price": ("price", "lth_realized_price"), } if metric in direct_keys: k, sub = direct_keys[metric] val = cache.get(k, {}) if isinstance(val, dict): return val.get(sub) return val elif metric in ratio_metrics: price_key, ref_key = ratio_metrics[metric] price_val = cache.get(price_key, {}).get("price") or cache.get(price_key, {}).get("value") ref_val = cache.get(ref_key, {}).get("value") if price_val and ref_val and ref_val > 0: return ((price_val - ref_val) / ref_val) * 100 return None def _get_historical_metric_raw(metric, index, date): """Get the raw value for a metric on a specific historical date.""" from backtesting.engine import _last_known_value direct_keys = { "fear_greed": "fear_greed", "puell_multiple": "puell_multiple", "mvrv_zscore": "mvrv_zscore", "reserve_risk": "reserve_risk", "rhodl_ratio": "rhodl_ratio", "nupl": "nupl", "drawdown": "drawdown", "hash_ribbons": "hash_ribbons", "sopr": "sopr", "sellside_risk": "sellside_risk", "active_address_momentum": "active_address_momentum", "txcount_momentum": "txcount_momentum", "nvt_price": "nvt_price", "vdd_multiple": "vdd_multiple", "lth_supply": "lth_supply", } if metric in direct_keys: return _last_known_value(index.get(direct_keys[metric], {}), date) # Ratio-based if metric == "price_vs_200w_sma": price_val = _last_known_value(index.get("btc_price", {}), date) ref_val = _last_known_value(index.get("200w_sma", {}), date) if price_val and ref_val and ref_val > 0: return ((price_val - ref_val) / ref_val) * 100 if metric == "lth_realized_price": price_val = _last_known_value(index.get("btc_price", {}), date) ref_val = _last_known_value(index.get("lth_realized_price", {}), date) if price_val and ref_val and ref_val > 0: return ((price_val - ref_val) / ref_val) * 100 return None def _auto_metric_margin(metric, current_val): """Compute a reasonable similarity margin based on metric type and scale.""" margins = { "fear_greed": 5.0, "puell_multiple": 0.15, "mvrv_zscore": 0.5, "reserve_risk": 0.002, "rhodl_ratio": 300, "nupl": 0.1, "drawdown": 8.0, "sopr": 0.02, "sellside_risk": 0.001, "active_address_momentum": 0.05, "txcount_momentum": 0.05, "nvt_price": 5000, "vdd_multiple": 0.15, "price_vs_200w_sma": 10.0, "lth_realized_price": 10.0, } if metric in margins: return margins[metric] # Fallback: 15% of current value return abs(current_val) * 0.15 if current_val != 0 else 1.0 def _compute_day_forward_returns(price_lookup, date): """Compute forward returns for a single date.""" from datetime import datetime as _dt, timedelta as _td p0 = price_lookup.get(date) if p0 is None or p0 <= 0: return {} r = {} dt = _dt.strptime(date, "%Y-%m-%d") for days in [30, 90, 180, 365]: future = (dt + _td(days=days)).strftime("%Y-%m-%d") pf = price_lookup.get(future) if pf is not None: r[f"{days}d"] = round(((pf - p0) / p0) * 100, 2) return r # ── Backtest HTML Page ───────────────────────────────────────────────── BACKTEST_HTML = """ """ + SHARED_HEAD + """ Historical Backtest — Bitcoin Accumulation Zone Monitor

Accumulation Zone Monitor

""" + NAV_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)