From 62e32fc6552b03392026ff67302d9367baaab183 Mon Sep 17 00:00:00 2001 From: BizzleBot Date: Fri, 20 Mar 2026 22:31:29 +0000 Subject: [PATCH] feat: replace ML optimizer with on-chain accumulation zone monitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- config/thresholds.json | 35 + dashboard/server.py | 1176 ++++++++--------- scoring/__init__.py | 0 scoring/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 143 bytes scoring/__pycache__/engine.cpython-313.pyc | Bin 0 -> 17081 bytes scoring/engine.py | 418 ++++++ scrapers/__init__.py | 0 scrapers/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 144 bytes .../__pycache__/fear_greed.cpython-313.pyc | Bin 0 -> 1569 bytes .../lookintobitcoin.cpython-313.pyc | Bin 0 -> 9838 bytes scrapers/__pycache__/price.cpython-313.pyc | Bin 0 -> 4119 bytes scrapers/fear_greed.py | 34 + scrapers/lookintobitcoin.py | 265 ++++ scrapers/price.py | 80 ++ 14 files changed, 1374 insertions(+), 634 deletions(-) create mode 100644 config/thresholds.json create mode 100644 scoring/__init__.py create mode 100644 scoring/__pycache__/__init__.cpython-313.pyc create mode 100644 scoring/__pycache__/engine.cpython-313.pyc create mode 100644 scoring/engine.py create mode 100644 scrapers/__init__.py create mode 100644 scrapers/__pycache__/__init__.cpython-313.pyc create mode 100644 scrapers/__pycache__/fear_greed.cpython-313.pyc create mode 100644 scrapers/__pycache__/lookintobitcoin.cpython-313.pyc create mode 100644 scrapers/__pycache__/price.cpython-313.pyc create mode 100644 scrapers/fear_greed.py create mode 100644 scrapers/lookintobitcoin.py create mode 100644 scrapers/price.py diff --git a/config/thresholds.json b/config/thresholds.json new file mode 100644 index 0000000..6a923d6 --- /dev/null +++ b/config/thresholds.json @@ -0,0 +1,35 @@ +{ + "fear_greed": { + "ranges": [[0, 10, 10], [11, 25, 7], [26, 45, 4], [46, 55, 2], [56, 75, 1], [76, 100, 0]] + }, + "puell_multiple": { + "ranges": [[null, 0.3, 10], [0.3, 0.5, 8], [0.5, 0.8, 5], [0.8, 1.2, 3], [1.2, 2.0, 1], [2.0, null, 0]] + }, + "mvrv_zscore": { + "ranges": [[null, 0, 10], [0, 0.5, 8], [0.5, 1.5, 5], [1.5, 3, 2], [3, 5, 1], [5, null, 0]] + }, + "drawdown": { + "ranges": [[70, null, 10], [50, 70, 8], [30, 50, 6], [20, 30, 4], [10, 20, 2], [null, 10, 0]] + }, + "price_vs_200w_sma": { + "ranges": [[null, 0, 10], [0, 20, 6], [20, 50, 3], [50, 100, 1], [100, null, 0]] + }, + "reserve_risk": { + "ranges": [[null, 0.002, 10], [0.002, 0.005, 7], [0.005, 0.01, 4], [0.01, 0.02, 2], [0.02, null, 0]] + }, + "rhodl_ratio": { + "ranges": [[null, 100, 10], [100, 500, 7], [500, 2000, 4], [2000, 10000, 1], [10000, null, 0]] + }, + "nupl": { + "ranges": [[null, 0, 10], [0, 0.25, 7], [0.25, 0.5, 4], [0.5, 0.75, 1], [0.75, null, 0]] + }, + "lth_realized_price": { + "ranges": [[null, 0, 10], [0, 20, 6], [20, 50, 3], [50, null, 1]] + }, + "hash_ribbons": { + "buy_signal": 10, + "recent_recovery": 6, + "normal": 3, + "euphoria": 0 + } +} \ No newline at end of file diff --git a/dashboard/server.py b/dashboard/server.py index 1df07ab..67f8711 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -1,38 +1,181 @@ #!/usr/bin/env python3 """ -BTC Accumulation Signal Optimizer -- Web Dashboard +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 FileResponse, HTMLResponse, JSONResponse +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) -import orchestrator +from scrapers import fear_greed, price +from scoring import engine -app = FastAPI(title="BTC Accumulation Signal Optimizer") +app = FastAPI(title="Bitcoin Accumulation Zone Monitor") CONFIG_DIR = os.path.join(BASE_DIR, "config") -RESULTS_DIR = os.path.join(BASE_DIR, "results") -ITERATIONS_LOG = os.path.join(RESULTS_DIR, "iterations.jsonl") +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") -_opt_thread = None +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 {} -class ConfigUpdate(BaseModel): - config: dict +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 @@ -73,7 +216,6 @@ def _mask_api_key(key): def _safe_settings(settings): - """Return settings with API keys masked.""" out = json.loads(json.dumps(settings)) for name, cfg in out.get("providers", {}).items(): if "api_key" in cfg: @@ -82,7 +224,6 @@ def _safe_settings(settings): def _merge_api_keys(new_providers, existing_providers): - """Preserve existing API keys when the incoming value is masked.""" for name, cfg in new_providers.items(): if "api_key" in cfg: masked = cfg["api_key"] @@ -91,105 +232,92 @@ def _merge_api_keys(new_providers, existing_providers): cfg["api_key"] = existing_key -@app.get("/api/status") -def api_status(): - return orchestrator.get_status() +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}") -@app.get("/api/iterations") -def api_iterations(): - iterations = orchestrator.load_iteration_history() - slim = [] - for it in iterations: - entry = {k: v for k, v in it.items() if k not in ("config", "results")} - slim.append(entry) - return slim +# ── 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/config") -def api_config(): - best = os.path.join(CONFIG_DIR, "best_config.json") - initial = os.path.join(CONFIG_DIR, "initial_config.json") - path = best if os.path.exists(best) else initial - with open(path) as f: - return json.load(f) +@app.get("/api/history") +def api_history(): + return load_history()[-90:] # Last 90 entries -@app.put("/api/config") -def api_update_config(body: ConfigUpdate): - path = os.path.join(CONFIG_DIR, "current_config.json") - with open(path, "w") as f: - json.dump(body.config, f, indent=2) - return {"ok": True} +@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"} -@app.post("/api/start") -def api_start(): - global _opt_thread - status = orchestrator.get_status() - if status["state"] == "running": - return JSONResponse({"error": "Already running"}, status_code=409) - - _opt_thread = threading.Thread( - target=orchestrator.run_optimization_loop, daemon=True - ) - _opt_thread.start() - return {"ok": True, "message": "Optimization started"} - - -@app.post("/api/stop") -def api_stop(): - orchestrator.request_stop() - return {"ok": True, "message": "Stop requested"} - - -@app.get("/api/best") -def api_best(): - best_path = os.path.join(CONFIG_DIR, "best_config.json") - if not os.path.exists(best_path): - return {"config": None, "best_score": 0} - with open(best_path) as f: - config = json.load(f) - iterations = orchestrator.load_iteration_history() - best_iter = ( - max(iterations, key=lambda x: x.get("cost_improvement", 0)) - if iterations - else {} - ) - return {"config": config, "best_iteration": best_iter} - - -@app.get("/api/download/iterations") -def api_download_iterations(): - if os.path.exists(ITERATIONS_LOG): - return FileResponse(ITERATIONS_LOG, filename="iterations.jsonl") - return JSONResponse({"error": "No iterations yet"}, status_code=404) - - -@app.get("/api/download/best-config") -def api_download_best_config(): - path = os.path.join(CONFIG_DIR, "best_config.json") - if os.path.exists(path): - return FileResponse(path, filename="best_config.json") - return JSONResponse({"error": "No best config yet"}, status_code=404) - - -# ── Settings API ────────────────────────────────────────────────────────── - +# Settings routes (preserved) @app.get("/api/settings") def api_get_settings(): - settings = _load_llm_settings() - return _safe_settings(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, - } + 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) @@ -198,39 +326,23 @@ def api_save_settings(body: LLMSettingsUpdate): @app.post("/api/settings/test") def api_test_connection(body: TestConnectionRequest): - """Test connection to a provider and return available models.""" existing = _load_llm_settings() providers = json.loads(json.dumps(body.providers)) _merge_api_keys(providers, existing.get("providers", {})) - provider = body.provider - try: - models = _fetch_models(provider, providers) + 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 — is the server running?"}, - status_code=502, - ) - except requests.exceptions.Timeout: - return JSONResponse( - {"ok": False, "error": "Connection timed out"}, - status_code=504, - ) + return JSONResponse({"ok": False, "error": "Connection refused"}, status_code=502) except Exception as e: - return JSONResponse( - {"ok": False, "error": str(e)}, - status_code=500, - ) + return JSONResponse({"ok": False, "error": str(e)}, status_code=500) @app.post("/api/settings/models") def api_fetch_models(body: FetchModelsRequest): - """Fetch available models for a provider (proxied to avoid CORS).""" 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} @@ -238,102 +350,30 @@ def api_fetch_models(body: FetchModelsRequest): return JSONResponse({"ok": False, "error": str(e)}, status_code=500) -def _fetch_models(provider, providers): - """Fetch model list from a provider. Returns list of {id, name}.""" - 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() - data = resp.json() - return [{"id": m["name"], "name": m["name"]} for m in data.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() - data = resp.json() - return [{"id": m["id"], "name": m["id"]} for m in data.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() - data = resp.json() - models = [m for m in data.get("data", []) if m["id"].startswith("gpt-") or "chat" in m.get("id", "")] - 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() - data = resp.json() - return [{"id": m["id"], "name": m.get("display_name", m["id"])} for m in data.get("data", [])] - - elif provider == "openrouter": - resp = requests.get("https://openrouter.ai/api/v1/models", timeout=15) - resp.raise_for_status() - data = resp.json() - models = data.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}") - - # ── HTML Pages ──────────────────────────────────────────────────────────── -# Shared CSS used by both 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} +: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:1rem;font-weight:600;color:var(--text-dim);margin-bottom:12px;text-transform:uppercase;letter-spacing:.05em;font-size:.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)} -.controls{display:flex;gap:8px;align-items:center} .btn{padding:8px 18px;border:none;border-radius:6px;font-family:inherit;font-weight:600;font-size:.85rem;cursor:pointer;transition:all .15s} -.btn-start{background:var(--green);color:#000}.btn-start:hover{background:#16a34a} -.btn-stop{background:var(--red);color:#fff}.btn-stop:hover{background:#dc2626} -.btn-secondary{background:var(--border);color:var(--text)}.btn-secondary:hover{background:var(--card-hover)} .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} -.status-badge{display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:20px;font-size:.8rem;font-weight:600} -.status-idle{background:#1e3a5f;color:#60a5fa} -.status-running{background:#1a3a2a;color:var(--green)} -.status-completed{background:#3a2a1a;color:var(--accent)} -.status-error{background:#3a1a1a;color:var(--red)} -.pulse{width:8px;height:8px;border-radius:50%;background:currentColor;animation:pulse 1.5s infinite} -@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}} -.best-score{text-align:right} -.best-score .label{font-size:.7rem;text-transform:uppercase;letter-spacing:.1em;color:var(--text-dim)} -.best-score .value{font-size:2.2rem;font-weight:700;color:var(--accent);font-family:var(--mono)} -.best-score .unit{font-size:1rem;color:var(--text-dim)} .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} @@ -342,67 +382,69 @@ h2{font-size:1rem;font-weight:600;color:var(--text-dim);margin-bottom:12px;text- .toast-error{background:var(--red);color:#fff} """ -# Shared HTML head SHARED_HEAD = """ """ -# Navigation bar HTML (parameterized via JS to highlight active page) NAV_HTML = """""" -# Toast JS helper (shared) 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); - } + 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); + setTimeout(() => t.classList.remove('show'), 3500); } """ - DASHBOARD_HTML = """ """ + SHARED_HEAD + """ -BTC Accumulation Signal Optimizer +Bitcoin Accumulation Zone Monitor @@ -410,278 +452,302 @@ textarea.config-editor{width:100%;height:300px;background:var(--bg);color:var(--
-

Accumulation Signal Optimizer

+

Accumulation Zone Monitor

- Idle """ + NAV_HTML + """
-
-
- - - +
+
+ + Loading...
-
-
Best Cost Improvement
-
0.0%
+ +
+
+ + +
+
+ +
+
--
+
of 100
+
+
+
+
Loading...
+
--
+
+
+ ATH: -- + Mayer: -- + 200D SMA: -- +
-
-
-
-

Iterations

-
- - - - - -
#Cost Imp%SignalsFrequencyR2BottomsTopsModel
-
-
-
-

Cost Improvement Over Iterations

-
- -
-
-
+ +

On-Chain Metrics

+
+
Loading metrics...
+
-
-
-

LLM Analysis

-
-
No suggestions yet.
-
-
-
-

Downloads

- -
+ +
+

Composite Score History

+
+
-
-
- -

Configuration Editor

-
-
- -
- - -
-
-
- - +
""" @@ -691,7 +757,7 @@ SETTINGS_HTML = """ """ + SHARED_HEAD + """ -Settings — BTC Accumulation Signal Optimizer +Settings — Bitcoin Accumulation Zone Monitor