Compare commits

..

12 Commits

Author SHA1 Message Date
BizzleBot
4647c596b3 feat: ML-optimized accumulation scoring with dashboard toggle
Train GradientBoostedClassifier on 2,601 days of historical data
(2018-2025) to find optimal metric weights for identifying the best
long-term buying opportunities. Uses time-series cross-validation
to prevent look-ahead bias.

Key results:
- pct_above_200w_sma: 50.7% weight (was 11.1% equal)
- drawdown: 14.6%, lth_rp: 10.9%, rhodl: 8.9%
- fear_greed demoted from 11.1% to 5.1%
- nupl/mvrv nearly eliminated (0.7-1.8%)

ML Strong Accumulation bracket: avg +210% 1yr (vs +176% classic)

New files: ml/optimizer.py, config/ml_weights.json
Modified: scoring/engine.py (score_all_ml), backtesting/engine.py
(ml_mode), dashboard/server.py (Classic/ML toggle)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 23:18:29 +00:00
BizzleBot
f1d38f9abb fix: backtest chart auto-switches linear/log based on price range
- Added time range buttons (30D/90D/6M/1Y/2Y/4Y/ALL) to backtest chart
- Auto-detects: if price range spans >20x → log scale, else linear
- Short ranges (30D-2Y) now show meaningful price movement instead of flat line
- Zone backgrounds updated to match new thresholds (35/50/65)
- Monospace font, better tooltips with zone labels
- Chart properly destroys and recreates on range change
2026-03-21 23:00:46 +00:00
BizzleBot
fb590105ce fix: preserve ATH/Mayer/200D SMA when CoinGecko rate-limits
- ATH: fall back to cached value when fetch fails
- 200D SMA: compute from history.json when CoinGecko blocks us
- Mayer Multiple: derived from 200D SMA fallback
- Drawdown: preserve cached value on ATH fetch failure
- Fixes N/A Drawdown and -- header stats after quick refresh
2026-03-21 22:55:37 +00:00
BizzleBot
85e0a6839f fix: backtest engine uses thresholds.json (single source of truth)
Previously the backtest engine had hardcoded OLD thresholds that
diverged from scoring/engine.py + config/thresholds.json. Now loads
from thresholds.json directly, ensuring the chart matches the dashboard.
2026-03-21 22:42:37 +00:00
BizzleBot
ececd65a22 feat: interactive score history chart with time range selector + BTC price overlay
- Time range buttons: 30D, 90D, 6M, 1Y, 2Y, 4Y, ALL
- BTC price overlay on right y-axis (orange dashed line)
- Accumulation zone backgrounds (green/yellow/red shading)
- Threshold lines at 65, 50, 35
- Tooltip shows score + zone label + BTC price
- Uses backtest daily_scores for full history (not just score_history.jsonl)
- Smart downsampling: daily for last 2yr, weekly before that
- Chart height increased to 320px
2026-03-21 22:41:22 +00:00
BizzleBot
5538f666c5 fix: cycle-aware scoring thresholds for diminishing returns
PROBLEM: Fixed thresholds based on 2015-2018 extremes meant the score
could barely reach 65 in the current cycle. MVRV Z-Score bottoms are
getting shallower (-0.6 → -0.4 → -0.3), Puell floors are rising,
NUPL extremes are compressing. A 'good buy' in 2024+ looks different
than 2018.

SOLUTION: Widened scoring ranges across all metrics:
- MVRV Z-Score: 0-1.0 now scores 8/10 (was 0-0.5)
- Puell Multiple: 0.4-0.7 scores 8/10 (was 0.3-0.5)
- NUPL: 0-0.3 scores 8/10 (was 0-0.25)
- LTH Realized Price: 0-30% above scores 7/10 (was 0-20%)
- 200W SMA: 0-30% above scores 7/10 (was 0-20%)
- Drawdown: 40-60% scores 8/10 (was 50-70%)
- Fear & Greed: 0-15 scores 10/10 (was 0-10)
- RHODL: 0-200 scores 10/10 (was 0-100)

RESULT:
- Today: 75/100 Strong Accumulation (was 56)
- Nov 2022 bottom: 91/100 (still extreme)
- 2024-2026 now has meaningful signal variation
- Each threshold has a note explaining the cycle compression logic
2026-03-21 22:35:13 +00:00
BizzleBot
6bfbd30e3d fix: comparable periods pick one example per market cycle
Instead of showing 5 recent days with similar scores (all from the same
2-week window), now picks one example per cycle:
- pre-2016, 2016-17 Bull, 2018-19 Bear, 2020-21 Bull, 2022-23 Bear, 2024+
- Sorted by closest score match, then picks one per cycle
- Shows cycle label in brackets next to each example
- Much more representative of how the score performed across different eras
2026-03-21 22:21:14 +00:00
BizzleBot
6398c6c8f4 fix: main dashboard historical context shows all 4 timeframes (30d/90d/180d/1yr) 2026-03-20 23:32:30 +00:00
BizzleBot
22fc7fc6cd fix: historical data stored permanently, only append new daily values
- Historical data (5693+ points per metric) saved in history.json permanently
- Quick refresh: only updates price + Fear & Greed from APIs (~2 seconds)
- Full refresh: only needed for FIRST-TIME setup or if data is missing
- Daily append: new values added to history.json from cache, not re-scraped
- Startup: uses cached on-chain data if it exists, no unnecessary Playwright launches
- On-chain metrics only update once per day, no reason to re-scrape them
2026-03-20 23:29:39 +00:00
BizzleBot
28b5240a81 perf: smart refresh — quick updates price/F&G only, full scrape every 6h
- Quick Refresh button: updates price + Fear & Greed only (~2 seconds)
- Full Refresh button: re-scrapes all on-chain data from LookIntoBitcoin (~2-3 min)
- Background auto-refresh: quick every 15min, full only when on-chain data >6h old
- Cached on-chain data preserved between quick refreshes
- On-chain metrics only update daily anyway, no need to re-scrape every 15min
2026-03-20 23:25:54 +00:00
BizzleBot
e385765fda add: 30d/90d/180d/365d forward returns in all backtest views
- Bracket table now shows Avg 30d, 90d, 180d, and 1yr columns
- Signal events show all 4 timeframes
- Current context shows all 4 average returns
- Comparable examples show all available timeframes
- Updated backtest screenshot
2026-03-20 23:20:42 +00:00
BizzleBot
0ddb4ab01b add: screenshots + comprehensive README with images
Dashboard main view, backtest page, and settings screenshots.
README includes tech stack table, project structure, run instructions,
score interpretation, and all metric descriptions.
2026-03-20 23:10:45 +00:00
14 changed files with 1772 additions and 333 deletions

283
README.md
View File

@ -1,160 +1,135 @@
# BTC ML Trading Strategy Optimizer # Bitcoin Accumulation Zone Monitor
An automated optimization loop that trains ML models on BTC/USDT data, backtests trading strategies, and uses an LLM to iteratively improve the configuration. > On-chain metrics dashboard with historical backtesting for long-term BTC holders. No ML, no black box — pure signal monitoring from proven indicators.
![Dashboard](screenshots/dashboard-main.png)
## What It Does
Monitors 10 proven Bitcoin on-chain metrics that have historically identified optimal buying zones for long-term holders. Each metric scores 0-10, producing a composite accumulation score of 0-100.
**Current reading example:** Fear & Greed at 11 (Extreme Fear), MVRV Z-Score at 0.52 (undervalued), Puell Multiple at 0.66 — the kind of conditions that preceded every major BTC rally.
## Screenshots
### Main Dashboard
![Main Dashboard](screenshots/dashboard-main.png)
*Live accumulation score with all 10 metrics, current BTC price, and individual metric breakdowns*
### Historical Backtest
![Backtest](screenshots/dashboard-backtest.png)
*Historical score vs BTC price overlay, score bracket performance table, and major signal events*
### Settings
![Settings](screenshots/dashboard-settings.png)
*LLM provider configuration for optional AI-powered signal commentary*
## Metrics
| # | Metric | Source | Accumulation Signal |
|---|--------|--------|-------------------|
| 1 | Fear & Greed Index | alternative.me API | Extreme Fear (< 10) |
| 2 | Puell Multiple | LookIntoBitcoin (scraped) | Miner capitulation (< 0.5) |
| 3 | MVRV Z-Score | LookIntoBitcoin (scraped) | Below realized value (< 0) |
| 4 | Drawdown from ATH | Calculated | Deep correction (> 50%) |
| 5 | Price vs 200W SMA | LookIntoBitcoin (scraped) | Below 200-week average |
| 6 | Reserve Risk | LookIntoBitcoin (scraped) | High holder confidence (< 0.002) |
| 7 | RHODL Ratio | LookIntoBitcoin (scraped) | Long-term holder dominance (< 100) |
| 8 | NUPL | LookIntoBitcoin (scraped) | Market capitulation (< 0) |
| 9 | LTH Realized Price | LookIntoBitcoin (scraped) | Price below LTH cost basis |
| 10 | Hash Ribbons | LookIntoBitcoin (scraped) | Miner capitulation recovery |
## Score Interpretation
| Score | Assessment | Historical Outcome |
|-------|-----------|-------------------|
| 85-100 | 🟢 Extreme Accumulation | Rare (~4x per decade). Historically: 200%+ 1yr returns |
| 70-84 | 🟢 Strong Accumulation | Excellent long-term entry point |
| 55-69 | 🟡 Moderate Opportunity | Decent entry, DCA appropriate |
| 40-54 | 🟡 Neutral | Hold — not compelling either way |
| 25-39 | 🔴 Caution | Market heating up |
| 0-24 | 🔴 Extreme Caution | Historically worst times to buy |
## Tech Stack
| Component | Technology |
|-----------|-----------|
| Backend | Python 3.13 + FastAPI |
| Frontend | Inline HTML/CSS/JS (dark trading terminal theme) |
| Charts | Chart.js |
| Scraping | Playwright (headless Chromium) |
| Data APIs | alternative.me (F&G), CoinGecko (price) |
| Process Manager | pm2 |
| Port | 3088 |
## How Data Is Collected
All data is scraped from free, public sources — **no API keys required**.
LookIntoBitcoin charts use Plotly Dash. We intercept the chart data XHR response which contains full historical time series (5000+ points back to 2010). The scraper runs every 15 minutes for live data and weekly for full historical updates.
## Project Structure
```
├── dashboard/
│ └── server.py # FastAPI server + inline dashboard HTML
├── scrapers/
│ ├── lookintobitcoin.py # Playwright scraper for on-chain charts
│ ├── history_collector.py # Full historical data collection
│ ├── fear_greed.py # Fear & Greed Index API
│ └── price.py # BTC price API
├── scoring/
│ └── engine.py # Metric scoring logic (0-10 per metric)
├── backtesting/
│ └── engine.py # Historical backtest engine
├── data/
│ ├── cache.json # Live metric cache (auto-generated)
│ └── history.json # Historical data (auto-generated)
├── config/
│ └── thresholds.json # Scoring thresholds (customizable)
├── screenshots/ # Dashboard screenshots
├── ARCHITECTURE.md # Detailed architecture & scoring logic
└── README.md
```
## Running
```bash
# Install dependencies
pip install fastapi uvicorn playwright requests
# Install Playwright browsers (first time only)
playwright install chromium
# Start the dashboard
cd /opt/apps/btc-ml-optimizer
python3 -m uvicorn dashboard.server:app --host 0.0.0.0 --port 3088
# Or with pm2
pm2 start "python3 -m uvicorn dashboard.server:app --host 0.0.0.0 --port 3088" --name btc-ml-optimizer
```
### First Run
1. Visit `http://localhost:3088` — the dashboard will auto-scrape current metrics
2. Visit `http://localhost:3088/backtest` — triggers historical data collection (takes ~5 min first time)
3. Data auto-refreshes every 15 minutes after initial scrape
## Backtest Methodology
The backtest engine reconstructs the composite score for every historical day and compares against actual BTC forward returns.
**Key feature: Recency weighting** — Bitcoin's cycle returns diminish over time (100x → 30x → 8x → 3-4x). The backtest weights recent cycles more heavily:
- 2022-present: 4x weight
- 2020-2021: 3x weight
- 2018-2019: 2x weight
- Pre-2018: 1x weight
Results are shown per-cycle so you see realistic expectations for the current cycle, not averages inflated by early moonshots.
## Architecture ## Architecture
``` See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed documentation of every metric's scoring logic, data pipeline, and backtest methodology.
┌─────────────────────────────────────────────────────────────────┐
│ Optimization Loop │
│ │
│ ┌──────────┐ ┌───────────────┐ ┌──────────────────────┐ │
│ │ VPS │───>│ Windows PC │───>│ Mac Mini │ │
│ │ (Orch.) │<───│ (GPU/ML) │ │ (LLM) │ │
│ │ │<───────────────────────>│ │ │
│ │ - Fetch │ │ - XGBoost │ │ - Ollama │ │
│ │ data │ │ - LightGBM │ │ - qwen3.5:27b │ │
│ │ - Coord │ │ - CatBoost │ │ - Analyze results │ │
│ │ - Store │ │ - RTX 4070 Ti │ │ - Suggest changes │ │
│ └──────────┘ └───────────────┘ └──────────────────────┘ │
│ ▲ │ │
│ └────────────────────────────────────────┘ │
│ Modified config │
└─────────────────────────────────────────────────────────────────┘
```
### Machines (Tailscale) ## License
| Machine | Role | Address | Key Resources | Private — not for public distribution.
|------------|-------------|-------------------|---------------------|
| VPS | Orchestrator | localhost | Coordination, data |
| Windows PC | ML Engine | 100.76.218.38 | RTX 4070 Ti GPU |
| Mac Mini | LLM | 100.100.242.21 | Ollama, qwen3.5:27b |
## Directory Structure
```
btc-ml-optimizer/
├── orchestrator.py # Main loop — coordinates everything
├── ml_engine/
│ └── train_and_backtest.py # Self-contained ML script (runs on Windows)
├── llm_client/
│ └── analyzer.py # LLM strategy analyzer (calls Mac Mini)
├── scripts/
│ ├── fetch_data.py # BTC/USDT data fetcher (ccxt)
│ └── setup_windows.sh # Install deps on Windows PC
├── config/
│ └── initial_config.json # Starting configuration
├── data/ # OHLCV CSV files
├── results/ # Iteration results + logs
├── requirements_vps.txt # VPS Python dependencies
└── requirements_windows.txt # Windows PC Python dependencies
```
## Setup
### 1. VPS (this machine)
```bash
pip install -r requirements_vps.txt
```
### 2. Windows PC
```bash
# From VPS — installs all ML deps on Windows via SSH
bash scripts/setup_windows.sh
```
Or manually on Windows:
```bash
pip install -r requirements_windows.txt
```
### 3. Mac Mini
Ensure Ollama is running with the qwen3.5:27b model:
```bash
ollama pull qwen3.5:27b
ollama serve # should already be running
```
## Usage
### Fetch Data
```bash
python3 scripts/fetch_data.py
```
Downloads 2 years of BTC/USDT 1h and 4h OHLCV data from Binance.
### Run the Optimizer
```bash
python3 orchestrator.py
```
The optimizer will:
1. Ensure data is fetched
2. Upload ML engine + data to Windows PC
3. Train model and backtest on GPU
4. Send results to LLM for analysis
5. Apply LLM-suggested config changes
6. Repeat until convergence (or 50 iterations)
### Run ML Engine Standalone (on Windows)
```bash
python train_and_backtest.py --config config.json --data btc_4h.csv --output results.json
```
## Configuration Reference
### `model_type`
- `xgboost` — XGBoost with GPU (default, generally best)
- `lightgbm` — LightGBM with GPU (faster training)
- `catboost` — CatBoost with GPU (handles interactions well)
- `ensemble` — Soft voting of all three
### `features`
- `technical_indicators` — List of indicators to compute
- `lookback_periods` — Windows for return/volatility features
- `use_volume_features` — Include volume-derived features
- `use_volatility_features` — Include volatility features
- `use_candle_patterns` — Include candlestick pattern features
- `use_lag_features` — Include lagged feature values
- `lag_periods` — Specific lag periods to use
### `target`
- `direction``"long"` or `"both"`
- `horizon_candles` — Forward-looking prediction window
- `threshold_pct` — Minimum % move to label as positive
### `hyperparameters`
Standard gradient boosting params: `learning_rate`, `max_depth`, `n_estimators`, `subsample`, `colsample_bytree`, `min_child_weight`, `gamma`, `reg_alpha`, `reg_lambda`
### `strategy`
- `entry_threshold` — Min probability to enter trade (0.5-0.8)
- `stop_loss_pct` — Stop loss percentage
- `take_profit_pct` — Take profit percentage
- `trailing_stop_pct` — Trailing stop distance
- `position_sizing``"confidence_scaled"` or `"fixed"`
- `min_confidence_to_trade` — Absolute minimum confidence
### `training`
- `walk_forward_windows` — Number of walk-forward splits (3-10)
- `train_pct` / `validation_pct` / `test_pct` — Data split ratios
## Convergence Criteria
The optimizer stops when:
- Sharpe ratio exceeds 3.0
- Sharpe improvement < 1% over 5 consecutive iterations
- Maximum 50 iterations reached
## Output
- `config/best_config.json` — Best configuration found
- `results/iterations.jsonl` — Full log of every iteration
- `results/results_iter_N.json` — Detailed results per iteration

View File

@ -25,45 +25,40 @@ BRACKETS = [
(86, 100, "Extreme Accumulation"), (86, 100, "Extreme Accumulation"),
] ]
# Scoring thresholds — replicated from scoring/engine.py for standalone use # Scoring thresholds — load from config/thresholds.json (single source of truth)
import os as _os
import json as _json
_THRESH_PATH = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))), "config", "thresholds.json")
try:
with open(_THRESH_PATH) as _f:
_THRESH = _json.load(_f)
except Exception:
_THRESH = {}
METRIC_SCORERS = { METRIC_SCORERS = {
"fear_greed": { "fear_greed": {"ranges": _THRESH.get("fear_greed", {}).get("ranges", [[0, 15, 10], [15, 30, 8], [30, 45, 5], [45, 55, 3], [55, 75, 1], [75, None, 0]])},
"ranges": [[None, 10, 10], [10, 25, 7], [25, 45, 4], [45, 55, 2], [55, 75, 1], [75, None, 0]], "puell_multiple": {"ranges": _THRESH.get("puell_multiple", {}).get("ranges", [[None, 0.4, 10], [0.4, 0.7, 8], [0.7, 1.0, 5], [1.0, 1.5, 3], [1.5, 2.0, 1], [2.0, None, 0]])},
}, "mvrv_zscore": {"ranges": _THRESH.get("mvrv_zscore", {}).get("ranges", [[None, 0, 10], [0, 1.0, 8], [1.0, 2.0, 5], [2.0, 3.0, 3], [3.0, 5.0, 1], [5.0, None, 0]])},
"puell_multiple": { "reserve_risk": {"ranges": _THRESH.get("reserve_risk", {}).get("ranges", [[None, 0.002, 10], [0.002, 0.005, 7], [0.005, 0.01, 4], [0.01, 0.02, 2], [0.02, None, 0]])},
"ranges": [[None, 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, None, 0]], "rhodl_ratio": {"ranges": _THRESH.get("rhodl_ratio", {}).get("ranges", [[None, 200, 10], [200, 1000, 7], [1000, 5000, 4], [5000, 20000, 1], [20000, None, 0]])},
}, "nupl": {"ranges": _THRESH.get("nupl", {}).get("ranges", [[None, 0, 10], [0, 0.3, 8], [0.3, 0.5, 4], [0.5, 0.75, 1], [0.75, None, 0]])},
"mvrv_zscore": {
"ranges": [[None, 0, 10], [0, 0.5, 8], [0.5, 1.5, 5], [1.5, 3, 2], [3, 5, 1], [5, None, 0]],
},
"reserve_risk": {
"ranges": [[None, 0.002, 10], [0.002, 0.005, 7], [0.005, 0.01, 4], [0.01, 0.02, 2], [0.02, None, 0]],
},
"rhodl_ratio": {
"ranges": [[None, 100, 10], [100, 500, 7], [500, 2000, 4], [2000, 10000, 1], [10000, None, 0]],
},
"nupl": {
"ranges": [[None, 0, 10], [0, 0.25, 7], [0.25, 0.5, 4], [0.5, 0.75, 1], [0.75, None, 0]],
},
} }
# Ratio-based metrics: score based on price vs reference value
RATIO_SCORERS = { RATIO_SCORERS = {
"price_vs_200w_sma": { "price_vs_200w_sma": {
# pct_above ranges "ranges": _THRESH.get("price_vs_200w_sma", {}).get("ranges", [[None, 0, 10], [0, 30, 7], [30, 60, 5], [60, 100, 2], [100, None, 0]]),
"ranges": [[None, 0, 10], [0, 20, 6], [20, 50, 3], [50, 100, 1], [100, None, 0]],
"price_key": "btc_price", "price_key": "btc_price",
"ref_key": "200w_sma", "ref_key": "200w_sma",
}, },
"lth_realized_price": { "lth_realized_price": {
"ranges": [[None, 0, 10], [0, 20, 6], [20, 50, 3], [50, None, 1]], "ranges": _THRESH.get("lth_realized_price", {}).get("ranges", [[None, 0, 10], [0, 30, 7], [30, 80, 5], [80, 150, 3], [150, None, 1]]),
"price_key": "btc_price", "price_key": "btc_price",
"ref_key": "lth_realized_price", "ref_key": "lth_realized_price",
}, },
} }
# Drawdown scoring DRAWDOWN_RANGES = _THRESH.get("drawdown", {}).get("ranges", [[60, None, 10], [40, 60, 8], [25, 40, 6], [15, 25, 4], [5, 15, 2], [None, 5, 0]])
DRAWDOWN_RANGES = [[70, None, 10], [50, 70, 8], [30, 50, 6], [20, 30, 4], [10, 20, 2], [None, 10, 0]]
def _score_range(value, ranges): def _score_range(value, ranges):
@ -126,8 +121,35 @@ def _compute_ath_series(price_lookup, dates):
return drawdowns return drawdowns
def score_day(date, index, drawdowns): def _load_ml_weights():
"""Score a single day using all available metrics. Returns (composite_score, individual_scores, n_metrics).""" """Load ML weights for ML-optimized scoring mode."""
ml_path = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))), "config", "ml_weights.json")
try:
with open(ml_path) as f:
data = _json.load(f)
return data.get("weights", {})
except Exception:
return {}
# ML weight key mapping (backtest metric keys -> ML weight keys)
_BT_ML_KEY_MAP = {
"fear_greed": "fear_greed",
"puell_multiple": "puell_multiple",
"mvrv_zscore": "mvrv_zscore",
"reserve_risk": "reserve_risk",
"rhodl_ratio": "rhodl_ratio",
"nupl": "nupl",
"price_vs_200w_sma": "pct_above_200w_sma",
"lth_realized_price": "pct_above_lth_rp",
"drawdown": "drawdown",
}
def score_day(date, index, drawdowns, ml_weights=None):
"""Score a single day using all available metrics. Returns (composite_score, individual_scores, n_metrics).
If ml_weights is provided, uses ML-optimized weighting instead of equal weights.
"""
scores = [] scores = []
details = {} details = {}
@ -168,7 +190,21 @@ def score_day(date, index, drawdowns):
if not scores: if not scores:
return None, details, 0 return None, details, 0
composite = sum(scores) / len(scores) * 10 if ml_weights:
# ML-weighted composite
weighted_sum = 0.0
weight_total = 0.0
for metric_key, info in details.items():
ml_key = _BT_ML_KEY_MAP.get(metric_key, metric_key)
w = ml_weights.get(ml_key, 0.0)
weighted_sum += info["score"] * w
weight_total += w
if weight_total > 0:
composite = weighted_sum / weight_total * 10
else:
composite = sum(scores) / len(scores) * 10
else:
composite = sum(scores) / len(scores) * 10
return round(composite, 1), details, len(scores) return round(composite, 1), details, len(scores)
@ -213,9 +249,12 @@ def compute_max_drawdown_forward(price_lookup, date, window=90):
return round(max_dd, 2) if max_dd > 0 else 0 return round(max_dd, 2) if max_dd > 0 else 0
def run_backtest(): def run_backtest(ml_mode=False):
"""Run the full backtest and return comprehensive results.""" """Run the full backtest and return comprehensive results.
log.info("Loading historical data...")
If ml_mode=True, uses ML-optimized metric weights instead of equal weights.
"""
log.info("Loading historical data... (ml_mode=%s)", ml_mode)
if not os.path.exists(HISTORY_PATH): if not os.path.exists(HISTORY_PATH):
return {"error": "No historical data found. Run history collector first."} return {"error": "No historical data found. Run history collector first."}
@ -245,11 +284,17 @@ def run_backtest():
log.info("Computing forward returns...") log.info("Computing forward returns...")
fwd_returns = compute_forward_returns(price_lookup, all_dates) fwd_returns = compute_forward_returns(price_lookup, all_dates)
# Load ML weights if in ML mode
ml_weights = _load_ml_weights() if ml_mode else None
if ml_mode and not ml_weights:
log.warning("ML mode requested but no weights found — falling back to equal weights")
ml_weights = None
# Score each day # Score each day
log.info("Scoring %d days...", len(all_dates)) log.info("Scoring %d days...", len(all_dates))
daily_scores = [] daily_scores = []
for d in all_dates: for d in all_dates:
composite, details, n_metrics = score_day(d, index, drawdowns) composite, details, n_metrics = score_day(d, index, drawdowns, ml_weights=ml_weights)
if composite is not None and n_metrics >= 3: # Require at least 3 metrics if composite is not None and n_metrics >= 3: # Require at least 3 metrics
price = price_lookup.get(d) price = price_lookup.get(d)
entry = { entry = {
@ -361,21 +406,47 @@ def run_backtest():
if abs(d["score"] - current_score) <= margin and d["forward_returns"]: if abs(d["score"] - current_score) <= margin and d["forward_returns"]:
comparable.append(d) comparable.append(d)
avg_1yr = None avg_returns = {}
if comparable: if comparable:
yr_returns = [d["forward_returns"]["365d"] for d in comparable if "365d" in d["forward_returns"]] for period in ["30d", "90d", "180d", "365d"]:
if yr_returns: vals = [d["forward_returns"][period] for d in comparable if period in d["forward_returns"]]
avg_1yr = round(sum(yr_returns) / len(yr_returns), 2) if vals:
avg_returns[period] = round(sum(vals) / len(vals), 2)
avg_1yr = avg_returns.get("365d")
# Best comparable examples (most recent 5) # Best comparable examples — one per market cycle for diversity
# Cycles: pre-2016, 2016-2017 bull, 2018-2019 bear, 2020-2021 bull, 2022-2023 bear, 2024+
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 = [] examples = []
for d in comparable[-5:]: used_cycles = set()
examples.append({ # Sort comparable by closest score first, then pick one per cycle
"date": d["date"], sorted_comp = sorted(comparable, key=lambda d: abs(d["score"] - current_score))
"score": d["score"], for d in sorted_comp:
"price": d["price"], cycle_label = None
"forward_returns": d["forward_returns"], for label, start, end in cycle_bins:
}) if start <= d["date"] <= end:
cycle_label = label
break
if cycle_label and cycle_label not in used_cycles:
used_cycles.add(cycle_label)
examples.append({
"date": d["date"],
"score": d["score"],
"price": d["price"],
"forward_returns": d["forward_returns"],
"cycle": cycle_label,
})
if len(examples) >= 6:
break
# Sort examples chronologically
examples.sort(key=lambda d: d["date"])
current_context = { current_context = {
"current_score": current_score, "current_score": current_score,
@ -383,15 +454,24 @@ def run_backtest():
"percentile": percentile, "percentile": percentile,
"comparable_days": len(comparable), "comparable_days": len(comparable),
"avg_1yr_return": avg_1yr, "avg_1yr_return": avg_1yr,
"avg_30d_return": avg_returns.get("30d"),
"avg_90d_return": avg_returns.get("90d"),
"avg_180d_return": avg_returns.get("180d"),
"examples": examples, "examples": examples,
} }
# --- Build time series for charting --- # --- Build time series for charting ---
# Downsample to weekly for chart efficiency # Smart downsampling: daily for last 2 years, weekly before that
chart_data = [] chart_data = []
import datetime as _dt
try:
last_date = _dt.datetime.strptime(daily_scores[-1]["date"], "%Y-%m-%d")
cutoff_date = (last_date - _dt.timedelta(days=730)).strftime("%Y-%m-%d")
except Exception:
cutoff_date = "2024-01-01"
for i, d in enumerate(daily_scores): for i, d in enumerate(daily_scores):
# Include every 7th day + last day is_recent = d["date"] >= cutoff_date
if i % 7 == 0 or i == len(daily_scores) - 1: if is_recent or i % 7 == 0 or i == len(daily_scores) - 1:
chart_data.append({ chart_data.append({
"date": d["date"], "date": d["date"],
"score": d["score"], "score": d["score"],
@ -405,6 +485,7 @@ def run_backtest():
"signal_events": signal_events, "signal_events": signal_events,
"current_context": current_context, "current_context": current_context,
"chart_data": chart_data, "chart_data": chart_data,
"ml_mode": ml_mode,
"computed_at": datetime.utcnow().isoformat() + "Z", "computed_at": datetime.utcnow().isoformat() + "Z",
} }

View File

@ -1,6 +1,6 @@
{ {
"provider": "ollama", "provider": "openrouter",
"model": "qwen3.5:27b", "model": "minimax/minimax-m2.5",
"providers": { "providers": {
"ollama": { "ollama": {
"base_url": "http://100.100.242.21:11434" "base_url": "http://100.100.242.21:11434"
@ -15,7 +15,7 @@
"api_key": "" "api_key": ""
}, },
"openrouter": { "openrouter": {
"api_key": "" "api_key": "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"
} }
} }
} }

159
config/ml_weights.json Normal file
View File

@ -0,0 +1,159 @@
{
"weights": {
"pct_above_200w_sma": 0.5075,
"drawdown": 0.1459,
"pct_above_lth_rp": 0.1095,
"rhodl_ratio": 0.089,
"fear_greed": 0.0515,
"reserve_risk": 0.046,
"puell_multiple": 0.0255,
"mvrv_zscore": 0.0182,
"nupl": 0.0068
},
"feature_importances": {
"raw_pct_above_200w_sma": 0.436377,
"days_since_ath": 0.119405,
"raw_pct_above_lth_rp": 0.109451,
"raw_rhodl_ratio": 0.088999,
"score_pct_above_200w_sma": 0.071148,
"raw_fear_greed": 0.051475,
"puell_x_reserve": 0.032886,
"raw_drawdown": 0.026474,
"raw_reserve_risk": 0.021707,
"raw_mvrv_zscore": 0.012429,
"raw_puell_multiple": 0.008599,
"delta_30d_reserve_risk": 0.007865,
"delta_30d_mvrv_zscore": 0.004263,
"raw_nupl": 0.003271,
"mvrv_x_nupl": 0.002979,
"delta_30d_nupl": 0.002056,
"delta_30d_puell_multiple": 0.000473,
"score_fear_greed": 6.8e-05,
"score_mvrv_zscore": 5.4e-05,
"score_puell_multiple": 1e-05,
"score_pct_above_lth_rp": 6e-06,
"score_rhodl_ratio": 2e-06,
"score_reserve_risk": 0.0,
"score_nupl": 0.0,
"score_drawdown": 0.0
},
"cv_results": {
"mean_auc": 0.6164,
"std_auc": 0.3317,
"mean_f1": 0.6736,
"mean_precision": 0.8015,
"mean_recall": 0.7047
},
"training_info": {
"n_samples": 2601,
"n_positive": 1553,
"positive_rate": 0.5971,
"n_features": 25,
"target_threshold": 30.0,
"date_range": "2018-02-01 to 2025-03-21",
"model": "GradientBoostingClassifier"
},
"comparison": {
"equal_weight": [
{
"range": "0-20",
"label": "Extreme Caution",
"days": 295,
"avg_365d": -5.94,
"median_365d": -11.99,
"win_rate_365d": 35.6
},
{
"range": "21-40",
"label": "Caution",
"days": 587,
"avg_365d": 23.84,
"median_365d": -7.2,
"win_rate_365d": 45.3
},
{
"range": "41-55",
"label": "Neutral",
"days": 697,
"avg_365d": 108.96,
"median_365d": 75.92,
"win_rate_365d": 70.4
},
{
"range": "56-70",
"label": "Moderate Opportunity",
"days": 450,
"avg_365d": 128.81,
"median_365d": 109.03,
"win_rate_365d": 96.4
},
{
"range": "71-85",
"label": "Strong Accumulation",
"days": 275,
"avg_365d": 175.76,
"median_365d": 117.95,
"win_rate_365d": 86.9
},
{
"range": "86-100",
"label": "Extreme Accumulation",
"days": 247,
"avg_365d": 115.5,
"median_365d": 90.08,
"win_rate_365d": 100.0
}
],
"ml_weighted": [
{
"range": "0-20",
"label": "Extreme Caution",
"days": 577,
"avg_365d": -6.17,
"median_365d": -26.21,
"win_rate_365d": 27.0
},
{
"range": "21-40",
"label": "Caution",
"days": 855,
"avg_365d": 77.5,
"median_365d": 39.28,
"win_rate_365d": 72.7
},
{
"range": "41-55",
"label": "Neutral",
"days": 241,
"avg_365d": 165.77,
"median_365d": 124.05,
"win_rate_365d": 92.5
},
{
"range": "56-70",
"label": "Moderate Opportunity",
"days": 328,
"avg_365d": 144.47,
"median_365d": 124.27,
"win_rate_365d": 89.6
},
{
"range": "71-85",
"label": "Strong Accumulation",
"days": 201,
"avg_365d": 210.2,
"median_365d": 122.22,
"win_rate_365d": 99.0
},
{
"range": "86-100",
"label": "Extreme Accumulation",
"days": 287,
"avg_365d": 113.92,
"median_365d": 99.53,
"win_rate_365d": 100.0
}
]
},
"trained_at": "2026-03-21T23:15:38.277703+00:00"
}

View File

@ -1,30 +1,38 @@
{ {
"_comment": "Cycle-aware thresholds — widened ranges to account for BTC maturing and diminishing cycle extremes",
"fear_greed": { "fear_greed": {
"ranges": [[0, 10, 10], [11, 25, 7], [26, 45, 4], [46, 55, 2], [56, 75, 1], [76, 100, 0]] "ranges": [[0, 15, 10], [15, 30, 8], [30, 45, 5], [45, 55, 3], [55, 75, 1], [75, 100, 0]]
}, },
"puell_multiple": { "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]] "_note": "Post-halving floors rising: 2016=0.15, 2020=0.3, 2024=0.5+",
"ranges": [[null, 0.4, 10], [0.4, 0.7, 8], [0.7, 1.0, 5], [1.0, 1.5, 3], [1.5, 2.0, 1], [2.0, null, 0]]
}, },
"mvrv_zscore": { "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]] "_note": "Bottoms getting shallower: 2015=-0.6, 2018=-0.4, 2022=-0.3, next may be ~0",
"ranges": [[null, 0, 10], [0, 1.0, 8], [1.0, 2.0, 5], [2.0, 3.0, 3], [3.0, 5.0, 1], [5.0, null, 0]]
}, },
"drawdown": { "drawdown": {
"ranges": [[70, null, 10], [50, 70, 8], [30, 50, 6], [20, 30, 4], [10, 20, 2], [null, 10, 0]] "_note": "Drawdowns compressing: 2014=86%, 2018=84%, 2022=77%, future may max at 50-60%",
"ranges": [[60, null, 10], [40, 60, 8], [25, 40, 6], [15, 25, 4], [5, 15, 2], [null, 5, 0]]
}, },
"price_vs_200w_sma": { "price_vs_200w_sma": {
"ranges": [[null, 0, 10], [0, 20, 6], [20, 50, 3], [50, 100, 1], [100, null, 0]] "_note": "BTC spends more time above 200W SMA as it matures",
"ranges": [[null, 0, 10], [0, 30, 7], [30, 60, 5], [60, 100, 2], [100, null, 0]]
}, },
"reserve_risk": { "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]] "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": { "rhodl_ratio": {
"ranges": [[null, 100, 10], [100, 500, 7], [500, 2000, 4], [2000, 10000, 1], [10000, null, 0]] "_note": "RHODL baseline rising with institutional adoption",
"ranges": [[null, 200, 10], [200, 1000, 7], [1000, 5000, 4], [5000, 20000, 1], [20000, null, 0]]
}, },
"nupl": { "nupl": {
"ranges": [[null, 0, 10], [0, 0.25, 7], [0.25, 0.5, 4], [0.5, 0.75, 1], [0.75, null, 0]] "_note": "NUPL bottoms getting shallower as BTC matures",
"ranges": [[null, 0, 10], [0, 0.3, 8], [0.3, 0.5, 4], [0.5, 0.75, 1], [0.75, null, 0]]
}, },
"lth_realized_price": { "lth_realized_price": {
"ranges": [[null, 0, 10], [0, 20, 6], [20, 50, 3], [50, null, 1]] "_note": "Price stays further above LTH RP as BTC matures — 60% above is still a good entry in 2024+",
"ranges": [[null, 0, 10], [0, 30, 7], [30, 80, 5], [80, 150, 3], [150, null, 1]]
}, },
"hash_ribbons": { "hash_ribbons": {
"buy_signal": 10, "buy_signal": 10,

View File

@ -94,8 +94,15 @@ def load_history():
# ── Background scraper ──────────────────────────────────────────────────── # ── Background scraper ────────────────────────────────────────────────────
def run_scrape(): def run_scrape(force_full=False):
"""Run a full scrape cycle and update cache.""" """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 global _last_update, _last_error, _scraper_running
with _scraper_lock: with _scraper_lock:
@ -104,7 +111,8 @@ def run_scrape():
_scraper_running = True _scraper_running = True
try: try:
log.info("Starting scrape cycle...") # Load existing cache to preserve on-chain data
existing_cache = load_cache()
metrics = {} metrics = {}
# 1. Fear & Greed (fast API call) # 1. Fear & Greed (fast API call)
@ -118,38 +126,95 @@ def run_scrape():
log.info("Fetching BTC ATH...") log.info("Fetching BTC ATH...")
ath_data = price.fetch_ath() ath_data = price.fetch_ath()
if price_current.get("price") and ath_data.get("ath"): ath_val = ath_data.get("ath") or existing_cache.get("drawdown", {}).get("ath")
drawdown = price.calculate_drawdown(price_current["price"], ath_data["ath"]) if price_current.get("price") and ath_val:
metrics["drawdown"] = {"value": drawdown, "ath": ath_data["ath"]} 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: else:
metrics["drawdown"] = {"value": None} metrics["drawdown"] = {"value": None}
log.info("Fetching historical prices...") log.info("Fetching historical prices for 200D SMA / Mayer...")
hist = price.fetch_historical() hist = price.fetch_historical()
if hist: if hist:
sma_200d = price.calculate_200d_sma(hist) sma_200d = price.calculate_200d_sma(hist)
mayer = price.calculate_mayer_multiple(price_current.get("price"), sma_200d) mayer = price.calculate_mayer_multiple(price_current.get("price"), sma_200d)
metrics["price_extras"] = {"sma_200d": sma_200d, "mayer_multiple": mayer} 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 via Playwright (slow) # 3. On-chain metrics — use cached values (historical data is permanent)
log.info("Scraping on-chain metrics from LookIntoBitcoin...") onchain_keys = ["puell_multiple", "mvrv_zscore", "reserve_risk", "rhodl_ratio",
try: "nupl", "200w_sma", "lth_realized_price", "hash_ribbons",
from scrapers import lookintobitcoin "pi_cycle_bottom", "lth_supply"]
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 has_cached_onchain = any(existing_cache.get(k, {}).get("value") is not None for k in onchain_keys)
if force_full or not has_cached_onchain:
# Only do a full Playwright scrape if explicitly requested or no data exists
log.info("Scraping on-chain metrics from LookIntoBitcoin (full refresh requested)...")
try:
from scrapers import lookintobitcoin
onchain = lookintobitcoin.scrape_all()
metrics.update(onchain)
metrics["_onchain_timestamp"] = datetime.now(timezone.utc).isoformat()
except Exception as e:
log.error("LookIntoBitcoin scraping failed: %s\n%s", e, traceback.format_exc())
_last_error = f"On-chain scraping failed: {e}"
for k in onchain_keys:
if k in existing_cache:
metrics[k] = existing_cache[k]
else:
# Reuse cached on-chain values — they're stored permanently
log.info("Reusing cached on-chain data (use Full Refresh to re-scrape)")
for k in onchain_keys:
if k in existing_cache:
metrics[k] = existing_cache[k]
if "_onchain_timestamp" in existing_cache:
metrics["_onchain_timestamp"] = existing_cache["_onchain_timestamp"]
# 4. Score everything (classic + ML)
log.info("Scoring metrics...") log.info("Scoring metrics...")
scored = engine.score_all(metrics) scored = engine.score_all(metrics)
metrics["_scored"] = scored 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() metrics["_timestamp"] = datetime.now(timezone.utc).isoformat()
save_cache(metrics) save_cache(metrics)
append_history(scored) 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_update = datetime.now(timezone.utc).isoformat()
_last_error = None _last_error = None
log.info("Scrape cycle complete. Composite score: %s", scored["composite_score"]) log.info("Scrape cycle complete. Composite score: %s", scored["composite_score"])
@ -163,10 +228,14 @@ def run_scrape():
def scraper_loop(): def scraper_loop():
"""Background loop that runs scrape every 15 minutes.""" """Background loop: quick refresh every 15min. Full scrape only on first boot with no data."""
cache = load_cache()
has_data = any(cache.get(k, {}).get("value") is not None
for k in ["puell_multiple", "mvrv_zscore", "nupl"])
run_scrape(force_full=not has_data) # Full only if no cached on-chain data
while True: while True:
run_scrape()
time.sleep(900) # 15 minutes time.sleep(900) # 15 minutes
run_scrape() # Quick refresh only
# Start background scraper on import # Start background scraper on import
@ -273,10 +342,15 @@ def _fetch_models(provider, providers):
# ── API Routes ──────────────────────────────────────────────────────────── # ── API Routes ────────────────────────────────────────────────────────────
@app.get("/api/data") @app.get("/api/data")
def api_data(): def api_data(mode: str = "classic"):
"""Return current cached metrics + scores.""" """Return current cached metrics + scores.
mode=classic (default) or mode=ml for ML-optimized scoring.
"""
cache = load_cache() cache = load_cache()
scored = cache.get("_scored", {}) if mode == "ml":
scored = cache.get("_scored_ml", cache.get("_scored", {}))
else:
scored = cache.get("_scored", {})
price_data = cache.get("price", {}) price_data = cache.get("price", {})
drawdown_data = cache.get("drawdown", {}) drawdown_data = cache.get("drawdown", {})
extras = cache.get("price_extras", {}) extras = cache.get("price_extras", {})
@ -290,6 +364,7 @@ def api_data():
"last_update": cache.get("_timestamp"), "last_update": cache.get("_timestamp"),
"scraper_running": _scraper_running, "scraper_running": _scraper_running,
"last_error": _last_error, "last_error": _last_error,
"mode": mode,
} }
@ -299,13 +374,15 @@ def api_history():
@app.post("/api/refresh") @app.post("/api/refresh")
def api_refresh(): def api_refresh(full: bool = False):
"""Trigger a manual scrape.""" """Trigger a scrape. Quick refresh (default) updates price + F&G only (~2s).
Full refresh (?full=true) also re-scrapes on-chain data via Playwright (~2-3min)."""
if _scraper_running: if _scraper_running:
return JSONResponse({"error": "Scrape already in progress"}, status_code=409) return JSONResponse({"error": "Scrape already in progress"}, status_code=409)
t = threading.Thread(target=run_scrape, daemon=True) t = threading.Thread(target=run_scrape, kwargs={"force_full": full}, daemon=True)
t.start() t.start()
return {"ok": True, "message": "Scrape started"} mode = "full (on-chain + price + F&G)" if full else "quick (price + F&G only)"
return {"ok": True, "message": f"Scrape started — {mode}"}
# Settings routes (preserved) # Settings routes (preserved)
@ -439,13 +516,23 @@ DASHBOARD_HTML = """<!DOCTYPE html>
.metric-sparkline{margin-top:8px;height:30px} .metric-sparkline{margin-top:8px;height:30px}
.metric-sparkline canvas{width:100%;height:30px} .metric-sparkline canvas{width:100%;height:30px}
.chart-section{margin-bottom:20px} .chart-section{margin-bottom:20px}
.chart-container{position:relative;height:280px} .chart-container{position:relative;height:320px}
.range-btn{padding:4px 10px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--muted);font-size:.75rem;font-family:var(--mono);cursor:pointer;transition:all .2s}
.range-btn:hover{color:var(--text);border-color:var(--accent)}
.range-btn.active{background:var(--accent);color:#000;border-color:var(--accent);font-weight:600}
.status-line{display:flex;align-items:center;gap:8px;font-size:.78rem;color:var(--text-dim)} .status-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{width:8px;height:8px;border-radius:50%}
.status-dot.live{background:var(--green);animation:pulse 1.5s infinite} .status-dot.live{background:var(--green);animation:pulse 1.5s infinite}
.status-dot.stale{background:var(--yellow)} .status-dot.stale{background:var(--yellow)}
.status-dot.error{background:var(--red)} .status-dot.error{background:var(--red)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}} @keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.mode-toggle{display:flex;border-radius:6px;overflow:hidden;border:1px solid var(--border)}
.mode-btn{padding:6px 14px;border:none;background:transparent;color:var(--text-dim);font-family:inherit;font-weight:600;font-size:.8rem;cursor:pointer;transition:all .15s}
.mode-btn:hover{color:var(--text)}
.mode-btn.active[data-mode="classic"]{background:var(--accent);color:#000}
.mode-btn.active[data-mode="ml"]{background:#8b5cf6;color:#fff}
.ml-badge{display:inline-block;font-size:.6rem;font-weight:700;padding:2px 6px;border-radius:3px;background:#8b5cf6;color:#fff;vertical-align:super;margin-left:4px}
.ml-weight{font-size:.65rem;color:#8b5cf6;font-family:var(--mono);margin-top:2px}
</style> </style>
</head> </head>
<body> <body>
@ -463,7 +550,12 @@ DASHBOARD_HTML = """<!DOCTYPE html>
<span class="status-dot" id="statusDot"></span> <span class="status-dot" id="statusDot"></span>
<span id="statusText">Loading...</span> <span id="statusText">Loading...</span>
</div> </div>
<button class="btn btn-accent" onclick="doRefresh()" id="btnRefresh">Refresh Data</button> <div class="mode-toggle" id="modeToggle" title="Switch between Classic (equal-weight) and ML-optimized scoring">
<button class="mode-btn active" data-mode="classic" onclick="setMode('classic')">Classic</button>
<button class="mode-btn" data-mode="ml" onclick="setMode('ml')">ML</button>
</div>
<button class="btn btn-accent" onclick="doRefresh(false)" id="btnRefresh"> Quick Refresh</button>
<button class="btn btn-secondary" onclick="doRefresh(true)" id="btnFullRefresh" title="Re-scrape on-chain metrics from LookIntoBitcoin (~2-3 min)">🔄 Full Refresh</button>
</div> </div>
</div> </div>
@ -504,7 +596,18 @@ DASHBOARD_HTML = """<!DOCTYPE html>
<!-- Historical Chart --> <!-- Historical Chart -->
<div class="card chart-section"> <div class="card chart-section">
<h2>Composite Score History</h2> <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:12px">
<h2 style="margin:0">Composite Score History</h2>
<div id="chartRangeBar" style="display:flex;gap:4px">
<button class="range-btn" data-days="30">30D</button>
<button class="range-btn" data-days="90">90D</button>
<button class="range-btn" data-days="180">6M</button>
<button class="range-btn" data-days="365">1Y</button>
<button class="range-btn" data-days="730">2Y</button>
<button class="range-btn" data-days="1460">4Y</button>
<button class="range-btn active" data-days="0">ALL</button>
</div>
</div>
<div class="chart-container"> <div class="chart-container">
<canvas id="historyChart"></canvas> <canvas id="historyChart"></canvas>
</div> </div>
@ -610,6 +713,11 @@ function renderMetrics(metrics) {
html += '</div></div>'; html += '</div></div>';
html += '<div class="metric-value">' + (m.display_value || 'N/A') + '</div>'; html += '<div class="metric-value">' + (m.display_value || 'N/A') + '</div>';
html += '<div class="metric-desc">' + (m.description || '') + '</div>'; html += '<div class="metric-desc">' + (m.description || '') + '</div>';
if (currentMode === 'ml' && m.ml_weight != null) {
const wpct = (m.ml_weight * 100).toFixed(1);
const contrib = m.ml_contribution != null ? m.ml_contribution.toFixed(1) : '--';
html += '<div class="ml-weight">ML weight: ' + wpct + '% · contribution: ' + contrib + ' pts</div>';
}
if (hasSparkline) { if (hasSparkline) {
html += '<div class="metric-sparkline"><canvas id="spark-' + idx + '"></canvas></div>'; html += '<div class="metric-sparkline"><canvas id="spark-' + idx + '"></canvas></div>';
} }
@ -628,51 +736,171 @@ function renderMetrics(metrics) {
} }
let histChart = null; let histChart = null;
let fullDailyScores = null;
let currentRange = 0; // 0 = ALL
function renderHistory(history) { function renderHistory(history) {
// Legacy: still called by loadData but we'll use backtest data instead
if (!fullDailyScores) {
// Fallback to score_history.jsonl if backtest hasn't loaded
renderHistoryFromData(history);
}
}
function renderHistoryFromData(history) {
const ctx = document.getElementById('historyChart').getContext('2d'); const ctx = document.getElementById('historyChart').getContext('2d');
if (!history || !history.length) return; if (!history || !history.length) return;
const labels = history.map(h => { const labels = history.map(h => h.date || h.timestamp);
const d = new Date(h.timestamp); const scores = history.map(h => h.composite_score || h.score);
return (d.getMonth()+1) + '/' + d.getDate(); const prices = history.map(h => h.price || null);
});
const scores = history.map(h => h.composite_score);
if (histChart) histChart.destroy(); if (histChart) histChart.destroy();
const datasets = [{
label: 'Accumulation Score',
data: scores,
borderColor: '#22d3ee',
backgroundColor: 'rgba(34,211,238,0.08)',
borderWidth: 2,
fill: true,
tension: 0.2,
pointRadius: scores.length > 200 ? 0 : 2,
pointBackgroundColor: '#22d3ee',
yAxisID: 'y',
}];
if (prices && prices.some(p => p != null)) {
datasets.push({
label: 'BTC Price',
data: prices,
borderColor: '#f7931a',
borderWidth: 1.5,
borderDash: [4, 2],
fill: false,
tension: 0.2,
pointRadius: 0,
yAxisID: 'y1',
});
}
// Accumulation zone backgrounds
const zonePlugin = {
id: 'zones',
beforeDraw(chart) {
const { ctx, chartArea: { top, bottom, left, right }, scales: { y } } = chart;
const zones = [
{ min: 65, max: 100, color: 'rgba(34,197,94,0.06)' },
{ min: 50, max: 65, color: 'rgba(234,179,8,0.04)' },
{ min: 0, max: 35, color: 'rgba(239,68,68,0.04)' },
];
zones.forEach(z => {
const yTop = y.getPixelForValue(z.max);
const yBot = y.getPixelForValue(z.min);
ctx.fillStyle = z.color;
ctx.fillRect(left, yTop, right - left, yBot - yTop);
});
// Draw threshold lines
[65, 50, 35].forEach(val => {
const yPos = y.getPixelForValue(val);
ctx.beginPath();
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
ctx.moveTo(left, yPos);
ctx.lineTo(right, yPos);
ctx.stroke();
ctx.setLineDash([]);
});
}
};
histChart = new Chart(ctx, { histChart = new Chart(ctx, {
type: 'line', type: 'line',
data: { plugins: [zonePlugin],
labels, data: { labels, datasets },
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: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: { plugins: {
legend: { labels: { color: '#94a3b8', font: { size: 11 } } }, legend: { labels: { color: '#94a3b8', font: { size: 11, family: 'monospace' } } },
annotation: null, tooltip: {
backgroundColor: 'rgba(10,10,15,0.95)',
borderColor: 'rgba(255,255,255,0.08)',
borderWidth: 1,
titleFont: { family: 'monospace', size: 11 },
bodyFont: { family: 'monospace', size: 11 },
callbacks: {
label: function(ctx) {
if (ctx.dataset.yAxisID === 'y1') return 'BTC: $' + ctx.raw.toLocaleString();
const s = ctx.raw;
let zone = s >= 80 ? 'Extreme Accum' : s >= 65 ? 'Strong Accum' : s >= 50 ? 'Moderate' : s >= 35 ? 'Neutral' : 'Caution';
return 'Score: ' + s.toFixed(1) + ' (' + zone + ')';
}
}
}
}, },
scales: { scales: {
x: { ticks: { color: '#94a3b8', maxTicksLimit: 15 }, grid: { color: '#1e293b' } }, x: {
y: { min: 0, max: 100, ticks: { color: '#f7931a' }, grid: { color: '#1e293b' }, ticks: { color: '#64748b', maxTicksLimit: 12, font: { family: 'monospace', size: 10 } },
title: { display: true, text: 'Score (0-100)', color: '#f7931a' } grid: { color: 'rgba(255,255,255,0.03)' }
},
y: {
min: 0, max: 100,
ticks: { color: '#22d3ee', font: { family: 'monospace', size: 10 } },
grid: { color: 'rgba(255,255,255,0.03)' },
title: { display: true, text: 'Score', color: '#22d3ee', font: { family: 'monospace', size: 11 } }
},
y1: {
position: 'right',
ticks: {
color: '#f7931a',
font: { family: 'monospace', size: 10 },
callback: v => '$' + (v >= 1000 ? (v/1000).toFixed(0) + 'k' : v)
},
grid: { drawOnChartArea: false },
title: { display: true, text: 'BTC Price', color: '#f7931a', font: { family: 'monospace', size: 11 } }
} }
} }
} }
}); });
} }
// Load backtest daily scores for the chart
async function loadBacktestChart() {
try {
const r = await fetch('/api/backtest?mode=' + currentMode);
const data = await r.json();
if (data.chart_data && data.chart_data.length) {
fullDailyScores = data.chart_data;
applyChartRange(currentRange);
}
} catch(e) { console.error('Backtest chart load failed:', e); }
}
function applyChartRange(days) {
currentRange = days;
if (!fullDailyScores) return;
let filtered = fullDailyScores;
if (days > 0) {
filtered = fullDailyScores.slice(-days);
}
renderHistoryFromData(filtered);
}
// Range button handlers
document.querySelectorAll('.range-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
applyChartRange(parseInt(this.dataset.days));
});
});
// Load backtest data on page load
loadBacktestChart();
function updateStatus(data) { function updateStatus(data) {
const dot = document.getElementById('statusDot'); const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText'); const text = document.getElementById('statusText');
@ -695,7 +923,7 @@ function updateStatus(data) {
async function poll() { async function poll() {
try { try {
const [dataRes, histRes] = await Promise.all([ const [dataRes, histRes] = await Promise.all([
fetch('/api/data'), fetch('/api/history') fetch('/api/data?mode=' + currentMode), fetch('/api/history')
]); ]);
const data = await dataRes.json(); const data = await dataRes.json();
const history = await histRes.json(); const history = await histRes.json();
@ -707,7 +935,12 @@ async function poll() {
// Assessment // Assessment
const el = document.getElementById('assessment'); const el = document.getElementById('assessment');
el.textContent = scored.assessment || 'Loading...'; let assessText = scored.assessment || 'Loading...';
if (currentMode === 'ml') {
el.innerHTML = assessText + '<span class="ml-badge">ML</span>';
} else {
el.textContent = assessText;
}
el.style.color = assessmentColor(composite); el.style.color = assessmentColor(composite);
// Price // Price
@ -726,7 +959,11 @@ async function poll() {
if (data.mayer_multiple) document.getElementById('mayerDisplay').textContent = data.mayer_multiple.toFixed(2); 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 (data.sma_200d) document.getElementById('sma200dDisplay').textContent = '$' + Math.round(data.sma_200d).toLocaleString();
if (scored.scored_count != null) { if (scored.scored_count != null) {
document.getElementById('scoredCount').textContent = scored.scored_count + '/' + scored.total_count + ' metrics active'; let countText = scored.scored_count + '/' + scored.total_count + ' metrics active';
if (currentMode === 'ml' && scored.classic_score != null) {
countText += ' · Classic: ' + scored.classic_score;
}
document.getElementById('scoredCount').textContent = countText;
} }
// Metrics // Metrics
@ -740,17 +977,30 @@ async function poll() {
} catch(e) { console.error('Poll error:', e); } } catch(e) { console.error('Poll error:', e); }
} }
async function doRefresh() { async function doRefresh(full) {
const btn = document.getElementById('btnRefresh'); const btn = document.getElementById(full ? 'btnFullRefresh' : 'btnRefresh');
const origText = btn.textContent;
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Refreshing...'; btn.textContent = full ? 'Scraping...' : 'Refreshing...';
try { try {
const r = await fetch('/api/refresh', { method: 'POST' }); const r = await fetch('/api/refresh' + (full ? '?full=true' : ''), { method: 'POST' });
const d = await r.json(); const d = await r.json();
if (d.error) showToast(d.error, 'error'); if (d.error) showToast(d.error, 'error');
else showToast('Scrape started — results in ~2 min', 'success'); else showToast(d.message || 'Refresh started', 'success');
} catch(e) { showToast('Failed: ' + e, 'error'); } } catch(e) { showToast('Failed: ' + e, 'error'); }
setTimeout(() => { btn.disabled = false; btn.textContent = 'Refresh Data'; }, 5000); const delay = full ? 180000 : 5000;
setTimeout(() => { btn.disabled = false; btn.textContent = origText; }, delay);
}
let currentMode = 'classic';
function setMode(mode) {
currentMode = mode;
document.querySelectorAll('.mode-btn').forEach(b => {
b.classList.toggle('active', b.dataset.mode === mode);
});
poll(); // Refresh with new mode
loadBacktestChart(); // Reload chart with new mode
} }
drawScoreRing(0); drawScoreRing(0);
@ -769,11 +1019,18 @@ setInterval(poll, 30000);
const ctx = bt.current_context; const ctx = bt.current_context;
const el = document.getElementById('histContext'); const el = document.getElementById('histContext');
const txt = document.getElementById('histContextText'); const txt = document.getElementById('histContextText');
let html = 'Score <strong>' + ctx.current_score + '</strong> is in the <strong style="color:#22d3ee">top ' + (100 - ctx.percentile).toFixed(1) + '%</strong> historically.'; let html = 'Score <strong>' + ctx.current_score + '</strong> is in the <strong style="color:#22d3ee">top ' + (100 - ctx.percentile).toFixed(1) + '%</strong> historically.<br>';
if (ctx.avg_1yr_return != null) { const fmtR = (v) => v == null ? null : (v >= 0 ? '+' : '') + v.toFixed(1) + '%';
const c = ctx.avg_1yr_return >= 0 ? '#22c55e' : '#ef4444'; const cR = (v) => v >= 0 ? '#22c55e' : '#ef4444';
html += ' Average 1-year return from this level: <strong style="color:' + c + '">' + (ctx.avg_1yr_return >= 0 ? '+' : '') + ctx.avg_1yr_return.toFixed(1) + '%</strong>'; const periods = [
['30d', ctx.avg_30d_return], ['90d', ctx.avg_90d_return],
['180d', ctx.avg_180d_return], ['1yr', ctx.avg_1yr_return]
];
let parts = [];
for (const [label, val] of periods) {
if (val != null) parts.push('<strong style="color:' + cR(val) + '">' + label + ': ' + fmtR(val) + '</strong>');
} }
if (parts.length) html += 'Average returns from this level: ' + parts.join(' · ');
txt.innerHTML = html; txt.innerHTML = html;
el.style.display = 'block'; el.style.display = 'block';
} catch(e) { /* backtest data not available yet */ } } catch(e) { /* backtest data not available yet */ }
@ -966,11 +1223,13 @@ _history_collector_progress = {}
@app.get("/api/backtest") @app.get("/api/backtest")
def api_backtest(): def api_backtest(mode: str = "classic"):
"""Run backtest and return full results.""" """Run backtest and return full results.
mode=classic (default) or mode=ml for ML-optimized scoring.
"""
try: try:
from backtesting.engine import run_backtest from backtesting.engine import run_backtest
return run_backtest() return run_backtest(ml_mode=(mode == "ml"))
except Exception as e: except Exception as e:
log.error("Backtest error: %s", traceback.format_exc()) log.error("Backtest error: %s", traceback.format_exc())
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": str(e)}, status_code=500)
@ -1121,7 +1380,7 @@ tr:hover td{background:var(--card-hover)}
<thead> <thead>
<tr> <tr>
<th>Score Range</th><th>Label</th><th>Days</th> <th>Score Range</th><th>Label</th><th>Days</th>
<th>Avg 30d</th><th>Avg 90d</th><th>Avg 1yr</th> <th>Avg 30d</th><th>Avg 90d</th><th>Avg 180d</th><th>Avg 1yr</th>
<th>Win Rate (1yr)</th><th>Max Gain (1yr)</th><th>Max Loss (1yr)</th> <th>Win Rate (1yr)</th><th>Max Gain (1yr)</th><th>Max Loss (1yr)</th>
<th>Avg Max DD</th> <th>Avg Max DD</th>
</tr> </tr>
@ -1227,8 +1486,8 @@ function renderAll() {
// Rebuild the main content // Rebuild the main content
document.getElementById('mainContent').innerHTML = ` document.getElementById('mainContent').innerHTML = `
<div class="section"><div class="context-box" id="contextBox"><h2>Current Signal Context</h2><div class="context-score" id="ctxScore">--</div><div class="context-percentile" id="ctxPercentile"></div><div class="context-return" id="ctxReturn"></div><div class="comparable-list" id="ctxComparables"></div></div></div> <div class="section"><div class="context-box" id="contextBox"><h2>Current Signal Context</h2><div class="context-score" id="ctxScore">--</div><div class="context-percentile" id="ctxPercentile"></div><div class="context-return" id="ctxReturn"></div><div class="comparable-list" id="ctxComparables"></div></div></div>
<div class="section"><div class="card"><h2>Historical Score vs BTC Price</h2><div class="chart-dual"><canvas id="dualChart"></canvas></div></div></div> <div class="section"><div class="card"><div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:12px"><h2 style="margin:0">Historical Score vs BTC Price</h2><div id="btRangeBar" style="display:flex;gap:4px"><button class="range-btn" data-days="30">30D</button><button class="range-btn" data-days="90">90D</button><button class="range-btn" data-days="180">6M</button><button class="range-btn" data-days="365">1Y</button><button class="range-btn" data-days="730">2Y</button><button class="range-btn" data-days="1460">4Y</button><button class="range-btn active" data-days="0">ALL</button></div></div><div class="chart-dual"><canvas id="dualChart"></canvas></div></div></div>
<div class="section"><div class="card"><h2>Score Bracket Performance</h2><div style="overflow-x:auto"><table><thead><tr><th>Score Range</th><th>Label</th><th>Days</th><th>Avg 30d</th><th>Avg 90d</th><th>Avg 1yr</th><th>Win Rate (1yr)</th><th>Max Gain</th><th>Max Loss</th><th>Avg Max DD</th></tr></thead><tbody id="bracketBody"></tbody></table></div></div></div> <div class="section"><div class="card"><h2>Score Bracket Performance</h2><div style="overflow-x:auto"><table><thead><tr><th>Score Range</th><th>Label</th><th>Days</th><th>Avg 30d</th><th>Avg 90d</th><th>Avg 180d</th><th>Avg 1yr</th><th>Win Rate (1yr)</th><th>Max Gain</th><th>Max Loss</th><th>Avg Max DD</th></tr></thead><tbody id="bracketBody"></tbody></table></div></div></div>
<div class="section"><div class="card"><h2>Major Signal Events (Score Crossed 70/80/90+)</h2><div id="signalEvents"></div></div></div> <div class="section"><div class="card"><h2>Major Signal Events (Score Crossed 70/80/90+)</h2><div id="signalEvents"></div></div></div>
`; `;
renderContext(); renderContext();
@ -1246,19 +1505,25 @@ function renderContext() {
document.getElementById('ctxPercentile').textContent = document.getElementById('ctxPercentile').textContent =
'Historical percentile: top ' + (100 - ctx.percentile).toFixed(1) + '% of all days (' + ctx.comparable_days + ' comparable days found)'; 'Historical percentile: top ' + (100 - ctx.percentile).toFixed(1) + '% of all days (' + ctx.comparable_days + ' comparable days found)';
if (ctx.avg_1yr_return != null) { if (ctx.avg_1yr_return != null) {
document.getElementById('ctxReturn').textContent = let retHtml = 'Average returns from this score level: ';
'Average 1-year return from this score level: ' + fmtPct(ctx.avg_1yr_return); if (ctx.avg_30d_return != null) retHtml += '<span class="' + retClass(ctx.avg_30d_return) + '">30d: ' + fmtPct(ctx.avg_30d_return) + '</span> · ';
document.getElementById('ctxReturn').className = 'context-return ' + retClass(ctx.avg_1yr_return); if (ctx.avg_90d_return != null) retHtml += '<span class="' + retClass(ctx.avg_90d_return) + '">90d: ' + fmtPct(ctx.avg_90d_return) + '</span> · ';
if (ctx.avg_180d_return != null) retHtml += '<span class="' + retClass(ctx.avg_180d_return) + '">180d: ' + fmtPct(ctx.avg_180d_return) + '</span> · ';
retHtml += '<span class="' + retClass(ctx.avg_1yr_return) + '"><strong>1yr: ' + fmtPct(ctx.avg_1yr_return) + '</strong></span>';
document.getElementById('ctxReturn').innerHTML = retHtml;
} }
// Examples // Examples
const list = document.getElementById('ctxComparables'); const list = document.getElementById('ctxComparables');
if (ctx.examples && ctx.examples.length) { if (ctx.examples && ctx.examples.length) {
let html = '<h2 style="margin-top:12px">Comparable Historical Periods</h2>'; let html = '<h2 style="margin-top:12px">Comparable Historical Periods <span style="color:var(--muted);font-size:.7em;font-weight:normal">(one per market cycle)</span></h2>';
for (const ex of ctx.examples) { for (const ex of ctx.examples) {
const fr = ex.forward_returns || {}; const fr = ex.forward_returns || {};
html += '<div class="comparable-item"><span>' + ex.date + ' — Score ' + ex.score + '' + fmtPrice(ex.price) + '</span>'; const cycleTag = ex.cycle ? '<span style="color:var(--accent);font-size:.75em;opacity:.7;margin-left:6px">[' + ex.cycle + ']</span>' : '';
html += '<div class="comparable-item"><span>' + ex.date + ' — Score ' + ex.score + '' + fmtPrice(ex.price) + cycleTag + '</span>';
html += '<span>'; html += '<span>';
if (fr['30d'] != null) html += '<span class="' + retClass(fr['30d']) + '">30d: ' + fmtPct(fr['30d']) + '</span> '; if (fr['30d'] != null) html += '<span class="' + retClass(fr['30d']) + '">30d: ' + fmtPct(fr['30d']) + '</span> ';
if (fr['90d'] != null) html += '<span class="' + retClass(fr['90d']) + '">90d: ' + fmtPct(fr['90d']) + '</span> ';
if (fr['180d'] != null) html += '<span class="' + retClass(fr['180d']) + '">180d: ' + fmtPct(fr['180d']) + '</span> ';
if (fr['365d'] != null) html += '<span class="' + retClass(fr['365d']) + '">1yr: ' + fmtPct(fr['365d']) + '</span>'; if (fr['365d'] != null) html += '<span class="' + retClass(fr['365d']) + '">1yr: ' + fmtPct(fr['365d']) + '</span>';
html += '</span></div>'; html += '</span></div>';
} }
@ -1266,24 +1531,38 @@ function renderContext() {
} }
} }
function renderDualChart() { let btDualChart = null;
let btCurrentRange = 0;
function renderDualChart(days) {
if (days === undefined) days = btCurrentRange;
btCurrentRange = days;
const chart = backtestData.chart_data; const chart = backtestData.chart_data;
if (!chart || !chart.length) return; if (!chart || !chart.length) return;
const ctx = document.getElementById('dualChart').getContext('2d');
const labels = chart.map(d => d.date);
const scores = chart.map(d => d.score);
const prices = chart.map(d => d.price);
// Zone backgrounds via plugin let data = chart;
if (days > 0) data = chart.slice(-days);
const ctx = document.getElementById('dualChart').getContext('2d');
const labels = data.map(d => d.date);
const scores = data.map(d => d.score);
const prices = data.map(d => d.price);
// Use log scale only for ALL or 4Y+ (when price spans multiple orders of magnitude)
const priceMin = Math.min(...prices.filter(p => p > 0));
const priceMax = Math.max(...prices);
const useLog = (priceMax / priceMin) > 20; // >20x range = log makes sense
const zonePlugin = { const zonePlugin = {
id: 'zoneBackground', id: 'zoneBackground',
beforeDraw(chart) { beforeDraw(chart) {
const { ctx: c, chartArea: {left, right, top, bottom}, scales: {y} } = chart; const { ctx: c, chartArea: {left, right}, scales: {y} } = chart;
if (!y) return; if (!y) return;
const zones = [ const zones = [
{ min: 0, max: 40, color: 'rgba(239,68,68,0.06)' }, { min: 0, max: 35, color: 'rgba(239,68,68,0.06)' },
{ min: 40, max: 70, color: 'rgba(234,179,8,0.06)' }, { min: 35, max: 50, color: 'rgba(234,179,8,0.04)' },
{ min: 70, max: 100, color: 'rgba(34,197,94,0.08)' }, { min: 50, max: 65, color: 'rgba(234,179,8,0.06)' },
{ min: 65, max: 100, color: 'rgba(34,197,94,0.08)' },
]; ];
for (const z of zones) { for (const z of zones) {
const yTop = y.getPixelForValue(Math.min(z.max, 100)); const yTop = y.getPixelForValue(Math.min(z.max, 100));
@ -1291,10 +1570,24 @@ function renderDualChart() {
c.fillStyle = z.color; c.fillStyle = z.color;
c.fillRect(left, yTop, right - left, yBot - yTop); c.fillRect(left, yTop, right - left, yBot - yTop);
} }
// Threshold lines
[35, 50, 65].forEach(val => {
const yPos = y.getPixelForValue(val);
c.beginPath();
c.setLineDash([4, 4]);
c.strokeStyle = 'rgba(255,255,255,0.06)';
c.lineWidth = 1;
c.moveTo(left, yPos);
c.lineTo(right, yPos);
c.stroke();
c.setLineDash([]);
});
} }
}; };
new Chart(ctx, { if (btDualChart) btDualChart.destroy();
btDualChart = new Chart(ctx, {
type: 'line', type: 'line',
plugins: [zonePlugin], plugins: [zonePlugin],
data: { data: {
@ -1308,7 +1601,7 @@ function renderDualChart() {
borderWidth: 1.5, borderWidth: 1.5,
fill: false, fill: false,
tension: 0.2, tension: 0.2,
pointRadius: 0, pointRadius: data.length < 100 ? 2 : 0,
yAxisID: 'y', yAxisID: 'y',
}, },
{ {
@ -1328,27 +1621,53 @@ function renderDualChart() {
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false }, interaction: { mode: 'index', intersect: false },
plugins: { plugins: {
legend: { labels: { color: '#94a3b8', font: { size: 11 } } }, legend: { labels: { color: '#94a3b8', font: { size: 11, family: 'monospace' } } },
tooltip: { tooltip: {
backgroundColor: 'rgba(10,10,15,0.95)',
borderColor: 'rgba(255,255,255,0.08)',
borderWidth: 1,
titleFont: { family: 'monospace', size: 11 },
bodyFont: { family: 'monospace', size: 11 },
callbacks: { callbacks: {
label: function(ctx) { label: function(ctx) {
if (ctx.datasetIndex === 1) return 'BTC: $' + Math.round(ctx.parsed.y).toLocaleString(); if (ctx.datasetIndex === 1) return 'BTC: $' + Math.round(ctx.parsed.y).toLocaleString();
return 'Score: ' + ctx.parsed.y; const s = ctx.parsed.y;
let zone = s >= 80 ? 'Extreme Accum' : s >= 65 ? 'Strong Accum' : s >= 50 ? 'Moderate' : s >= 35 ? 'Neutral' : 'Caution';
return 'Score: ' + s.toFixed(1) + ' (' + zone + ')';
} }
} }
} }
}, },
scales: { scales: {
x: { ticks: { color: '#94a3b8', maxTicksLimit: 20, maxRotation: 45 }, grid: { color: '#1e293b' } }, x: { ticks: { color: '#94a3b8', maxTicksLimit: 15, maxRotation: 45, font: { family: 'monospace', size: 10 } }, grid: { color: 'rgba(255,255,255,0.03)' } },
y: { position: 'left', min: 0, max: 100, ticks: { color: '#f7931a' }, grid: { color: '#1e293b' }, y: { position: 'left', min: 0, max: 100, ticks: { color: '#f7931a', font: { family: 'monospace', size: 10 } }, grid: { color: 'rgba(255,255,255,0.03)' },
title: { display: true, text: 'Score (0-100)', color: '#f7931a' } }, title: { display: true, text: 'Score (0-100)', color: '#f7931a', font: { family: 'monospace', size: 11 } } },
y1: { position: 'right', type: 'logarithmic', ticks: { color: '#22d3ee', callback: v => '$' + v.toLocaleString() }, y1: {
grid: { drawOnChartArea: false }, title: { display: true, text: 'BTC Price (log)', color: '#22d3ee' } } position: 'right',
type: useLog ? 'logarithmic' : 'linear',
ticks: {
color: '#22d3ee',
font: { family: 'monospace', size: 10 },
callback: v => '$' + (v >= 1000 ? (v/1000).toFixed(v >= 10000 ? 0 : 1) + 'k' : v.toLocaleString())
},
grid: { drawOnChartArea: false },
title: { display: true, text: useLog ? 'BTC Price (log)' : 'BTC Price', color: '#22d3ee', font: { family: 'monospace', size: 11 } }
}
} }
} }
}); });
} }
// Backtest range button handlers
document.addEventListener('click', function(e) {
if (e.target.closest('#btRangeBar .range-btn')) {
const btn = e.target.closest('.range-btn');
document.querySelectorAll('#btRangeBar .range-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderDualChart(parseInt(btn.dataset.days));
}
});
function renderBracketTable() { function renderBracketTable() {
const brackets = backtestData.bracket_stats; const brackets = backtestData.bracket_stats;
if (!brackets) return; if (!brackets) return;
@ -1362,6 +1681,7 @@ function renderBracketTable() {
html += '<td>' + b.days + '</td>'; html += '<td>' + b.days + '</td>';
html += '<td class="' + retClass(b.avg_30d) + '">' + fmtPct(b.avg_30d) + '</td>'; html += '<td class="' + retClass(b.avg_30d) + '">' + fmtPct(b.avg_30d) + '</td>';
html += '<td class="' + retClass(b.avg_90d) + '">' + fmtPct(b.avg_90d) + '</td>'; html += '<td class="' + retClass(b.avg_90d) + '">' + fmtPct(b.avg_90d) + '</td>';
html += '<td class="' + retClass(b.avg_180d) + '">' + fmtPct(b.avg_180d) + '</td>';
html += '<td class="' + retClass(b.avg_365d) + '">' + fmtPct(b.avg_365d) + '</td>'; html += '<td class="' + retClass(b.avg_365d) + '">' + fmtPct(b.avg_365d) + '</td>';
html += '<td>' + (b.win_rate_365d != null ? b.win_rate_365d + '%' : '--') + '</td>'; html += '<td>' + (b.win_rate_365d != null ? b.win_rate_365d + '%' : '--') + '</td>';
html += '<td class="t-green">' + fmtPct(b.max_gain_365d) + '</td>'; html += '<td class="t-green">' + fmtPct(b.max_gain_365d) + '</td>';
@ -1390,6 +1710,7 @@ function renderSignalEvents() {
const fr = ev.forward_returns || {}; const fr = ev.forward_returns || {};
if (fr['30d'] != null) html += '<span class="' + retClass(fr['30d']) + '">30d: ' + fmtPct(fr['30d']) + '</span>'; if (fr['30d'] != null) html += '<span class="' + retClass(fr['30d']) + '">30d: ' + fmtPct(fr['30d']) + '</span>';
if (fr['90d'] != null) html += '<span class="' + retClass(fr['90d']) + '">90d: ' + fmtPct(fr['90d']) + '</span>'; if (fr['90d'] != null) html += '<span class="' + retClass(fr['90d']) + '">90d: ' + fmtPct(fr['90d']) + '</span>';
if (fr['180d'] != null) html += '<span class="' + retClass(fr['180d']) + '">180d: ' + fmtPct(fr['180d']) + '</span>';
if (fr['365d'] != null) html += '<span class="' + retClass(fr['365d']) + '">1yr: ' + fmtPct(fr['365d']) + '</span>'; if (fr['365d'] != null) html += '<span class="' + retClass(fr['365d']) + '">1yr: ' + fmtPct(fr['365d']) + '</span>';
if (ev.price_365d) html += '<span style="color:var(--text-dim)">Price 1yr: ' + fmtPrice(ev.price_365d) + '</span>'; if (ev.price_365d) html += '<span style="color:var(--text-dim)">Price 1yr: ' + fmtPrice(ev.price_365d) + '</span>';
html += '</div></div>'; html += '</div></div>';

View File

@ -2,3 +2,111 @@
{"timestamp": "2026-03-20T22:30:13.547149+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.910215736040605}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}} {"timestamp": "2026-03-20T22:30:13.547149+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.910215736040605}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-20T22:46:34.952569+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.931630710659896}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}} {"timestamp": "2026-03-20T22:46:34.952569+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.931630710659896}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-20T22:51:27.724327+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.94907994923858}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}} {"timestamp": "2026-03-20T22:51:27.724327+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.94907994923858}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-20T23:07:48.303808+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.942734771573605}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-20T23:21:39.705718+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.07439720812183}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-20T23:27:15.835859+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.07122461928934}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-20T23:29:40.370530+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.099777918781726}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-20T23:32:26.885241+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.099777918781726}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-20T23:47:27.138815+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.07122461928934}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T00:02:27.412395+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.06408629441624}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T00:17:27.737482+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.025222081218274}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T00:32:28.011885+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.98953045685279}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T00:47:28.265430+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.006186548223354}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T01:02:28.558846+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.930837563451774}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T01:17:28.812131+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.964149746192895}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T01:32:29.208821+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.029980964467}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T01:47:29.455146+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.07994923857868}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T02:02:29.737360+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.10057106598985}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T02:17:30.019509+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.07201776649746}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T02:32:30.343202+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.98477157360406}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T02:47:30.599161+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.93797588832487}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T03:02:30.861751+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.90704314720812}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T03:17:31.107047+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.94670050761421}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T03:32:31.337649+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.94670050761421}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T03:47:31.597224+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.98477157360406}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T04:02:31.879811+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.93004441624365}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T04:17:32.138046+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.89435279187817}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T04:32:33.906364+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.900697969543145}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T04:47:34.155485+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.97921954314721}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T05:02:34.407649+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.975253807106604}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T05:17:34.633258+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.97049492385786}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T05:32:35.086231+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.878489847715734}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T05:47:35.345297+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.88880076142132}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T06:02:35.604338+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.92607868020305}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T06:17:35.879813+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.9173540609137}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T06:32:36.158808+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.85231598984772}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T06:47:36.389767+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.8880076142132}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T07:02:36.614994+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.880869289340104}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T07:17:36.932975+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.89435279187817}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T07:32:37.232230+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.8118654822335}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T07:47:37.478904+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.88959390862944}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T08:02:37.705904+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.93242385786802}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T08:17:37.925968+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.93797588832487}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T08:32:38.186918+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.078362944162436}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T08:47:38.410773+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.00697969543147}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T09:02:38.629878+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.00301395939086}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T09:17:38.850286+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.933217005076145}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T09:32:39.127885+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.02046319796955}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T09:47:39.355904+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.983978426395936}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T10:02:39.589009+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.973667512690355}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T10:17:39.865976+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.986357868020306}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T10:32:40.432700+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.0188769035533}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T10:47:40.718041+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.02918781725889}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T11:02:40.931588+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.01253172588832}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T11:17:41.151257+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.037119289340104}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T11:32:41.449918+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.0117385786802}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T11:47:41.719647+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.99666878172589}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T12:02:41.950098+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.95542512690355}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T12:17:42.199534+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.92528553299492}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T12:32:42.694007+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.88721446700507}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T12:47:42.921963+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.89197335025381}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T13:02:43.160452+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.91656091370558}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T13:17:43.406206+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.89355964467005}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T13:32:43.786751+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.881662436548226}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T13:47:44.052238+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.92052664974619}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T14:02:44.304809+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.879282994923855}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T14:17:44.551776+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.67465101522843}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T14:32:44.837983+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.73968908629441}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T14:47:45.081254+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.74841370558376}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T15:02:45.357457+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.99904822335025}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T15:17:45.594051+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.01332487309645}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T15:32:45.890870+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.964942893401016}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T15:47:46.142770+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.982392131979694}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T16:02:46.392729+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.00539340101523}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T16:17:46.656840+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.01570431472081}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T16:32:47.020533+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.13467639593909}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T16:47:47.262309+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.18067893401015}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T17:02:47.523039+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.25047588832488}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T17:17:47.803679+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.27982233502538}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T17:32:48.073161+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.192576142131976}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T17:47:48.330230+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.170368020304565}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T18:02:48.615315+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.23857868020305}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T18:17:48.885342+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.202887055837564}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T18:32:49.283593+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.16560913705584}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T18:47:49.583109+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.121192893401016}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T19:02:49.846889+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.16560913705584}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T19:17:50.119141+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.172747461928935}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T19:32:50.417600+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.15609137055838}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T19:47:50.704459+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.21795685279188}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T20:02:50.952361+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.17909263959391}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T20:17:51.183860+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.17909263959391}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T20:32:51.484992+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.121192893401016}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T20:47:51.763248+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.111675126903556}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T21:02:52.006943+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.138642131979694}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T21:17:52.292371+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.17433375634518}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T21:32:52.634501+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.21002538071066}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T21:47:52.933666+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.21637055837564}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:02:53.198903+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.234612944162436}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:04:28.799920+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.251269035533}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:17:53.423421+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.23857868020305}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:21:10.295734+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.23064720812182}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:35:13.975107+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.254441624365484}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:40:38.516771+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.20685279187817}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:41:18.262393+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.20685279187817}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:41:46.036660+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.21875}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:42:33.632103+00:00", "composite_score": 70.0, "scored_count": 9, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": null, "value": null}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:51:08.461576+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.24968274111675}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:53:45.530567+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.26713197969543}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:54:22.144542+00:00", "composite_score": 70.0, "scored_count": 9, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": null, "value": null}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:55:08.385540+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.26554568527919}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:55:33.933753+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.26316624365482}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}

0
ml/__init__.py Normal file
View File

562
ml/optimizer.py Normal file
View File

@ -0,0 +1,562 @@
#!/usr/bin/env python3
"""
ML Optimizer for Bitcoin Accumulation Zone Scoring.
Trains a gradient boosted tree model on historical on-chain metrics to find
optimal metric weights for identifying the best long-term buying opportunities.
Output: config/ml_weights.json with optimized weights and feature importances.
"""
import json
import logging
import os
import sys
from datetime import datetime, timedelta
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import (
classification_report,
f1_score,
precision_score,
recall_score,
roc_auc_score,
)
from sklearn.model_selection import TimeSeriesSplit
from sklearn.preprocessing import StandardScaler
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
)
log = logging.getLogger("ml-optimizer")
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HISTORY_PATH = os.path.join(BASE_DIR, "data", "history.json")
OUTPUT_PATH = os.path.join(BASE_DIR, "config", "ml_weights.json")
THRESHOLDS_PATH = os.path.join(BASE_DIR, "config", "thresholds.json")
# Date range: 2018-02-01 onward (when all 8 metrics + fear_greed available)
START_DATE = "2018-02-01"
# Training cutoff: need 1yr forward data for labels
TRAIN_CUTOFF_DAYS = 365
# Target: forward 365d return > 30% = "good time to buy"
GOOD_BUY_THRESHOLD = 30.0
# The 8 core metrics we score
METRIC_KEYS = [
"puell_multiple",
"mvrv_zscore",
"reserve_risk",
"rhodl_ratio",
"nupl",
"fear_greed",
]
# Ratio-based metrics (derived from price vs reference)
RATIO_METRICS = {
"pct_above_200w_sma": {"price_key": "btc_price", "ref_key": "200w_sma"},
"pct_above_lth_rp": {"price_key": "btc_price", "ref_key": "lth_realized_price"},
}
def load_history():
"""Load historical data and build date-aligned lookup."""
with open(HISTORY_PATH) as f:
raw = json.load(f)
index = {}
for key, data in raw.items():
if not isinstance(data, dict) or "dates" not in data:
continue
lookup = {}
for d, v in zip(data["dates"], data["values"]):
if v is not None:
lookup[d] = v
index[key] = lookup
return index
def load_thresholds():
"""Load scoring thresholds for converting raw values to 0-10 scores."""
with open(THRESHOLDS_PATH) as f:
return json.load(f)
def score_range(value, ranges):
"""Score a value using range-based thresholds (same logic as scoring/engine.py)."""
if value is None:
return None
for low, high, score in ranges:
low_ok = low is None or value >= low
high_ok = high is None or value < high
if low_ok and high_ok:
return score
return 0
def build_dataset(index, thresholds):
"""Build aligned training dataset: metric scores + forward returns."""
# Get all dates from 2018-02-01 onward
all_dates = set()
for lookup in index.values():
all_dates.update(lookup.keys())
dates = sorted(d for d in all_dates if d >= START_DATE)
# Build price lookup for forward returns
price_lookup = {}
for pk in ["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
# Compute ATH series for drawdown
all_dates_sorted = sorted(all_dates)
ath = 0
drawdowns = {}
for d in all_dates_sorted:
p = price_lookup.get(d)
if p is None:
continue
if p > ath:
ath = p
if ath > 0:
drawdowns[d] = ((ath - p) / ath) * 100
# Get threshold ranges for scoring raw values
metric_ranges = {
"puell_multiple": thresholds.get("puell_multiple", {}).get("ranges", []),
"mvrv_zscore": thresholds.get("mvrv_zscore", {}).get("ranges", []),
"reserve_risk": thresholds.get("reserve_risk", {}).get("ranges", []),
"rhodl_ratio": thresholds.get("rhodl_ratio", {}).get("ranges", []),
"nupl": thresholds.get("nupl", {}).get("ranges", []),
"fear_greed": thresholds.get("fear_greed", {}).get("ranges", []),
"drawdown": thresholds.get("drawdown", {}).get("ranges", []),
"price_vs_200w_sma": thresholds.get("price_vs_200w_sma", {}).get("ranges", []),
"lth_realized_price": thresholds.get("lth_realized_price", {}).get("ranges", []),
}
log.info("Building dataset from %d dates (%s to %s)", len(dates), dates[0], dates[-1])
rows = []
for d in dates:
# Get raw metric values
vals = {}
skip = False
for key in METRIC_KEYS:
v = index.get(key, {}).get(d)
if v is None:
skip = True
break
vals[key] = v
if skip:
continue
# Compute ratio metrics
price = price_lookup.get(d)
sma_200w = index.get("200w_sma", {}).get(d)
lth_rp = index.get("lth_realized_price", {}).get(d)
if price is None or sma_200w is None or lth_rp is None:
continue
if sma_200w == 0 or lth_rp == 0:
continue
pct_200w = ((price - sma_200w) / sma_200w) * 100
pct_lth = ((price - lth_rp) / lth_rp) * 100
dd = drawdowns.get(d, 0)
vals["pct_above_200w_sma"] = pct_200w
vals["pct_above_lth_rp"] = pct_lth
vals["drawdown"] = dd
# Score each metric (0-10) using existing thresholds
scores = {}
scores["puell_multiple"] = score_range(vals["puell_multiple"], metric_ranges["puell_multiple"])
scores["mvrv_zscore"] = score_range(vals["mvrv_zscore"], metric_ranges["mvrv_zscore"])
scores["reserve_risk"] = score_range(vals["reserve_risk"], metric_ranges["reserve_risk"])
scores["rhodl_ratio"] = score_range(vals["rhodl_ratio"], metric_ranges["rhodl_ratio"])
scores["nupl"] = score_range(vals["nupl"], metric_ranges["nupl"])
scores["fear_greed"] = score_range(vals["fear_greed"], metric_ranges["fear_greed"])
scores["drawdown"] = score_range(dd, metric_ranges["drawdown"])
scores["pct_above_200w_sma"] = score_range(pct_200w, metric_ranges["price_vs_200w_sma"])
scores["pct_above_lth_rp"] = score_range(pct_lth, metric_ranges["lth_realized_price"])
if any(s is None for s in scores.values()):
continue
# Forward returns
dt = datetime.strptime(d, "%Y-%m-%d")
fwd = {}
for days in [30, 90, 180, 365]:
future_d = (dt + timedelta(days=days)).strftime("%Y-%m-%d")
fp = price_lookup.get(future_d)
if fp is not None and price > 0:
fwd[f"fwd_{days}d"] = ((fp - price) / price) * 100
# Compute rate-of-change features (30d deltas)
deltas = {}
d_30ago = (dt - timedelta(days=30)).strftime("%Y-%m-%d")
for key in ["mvrv_zscore", "nupl", "puell_multiple", "reserve_risk"]:
v_now = vals[key]
v_prev = index.get(key, {}).get(d_30ago)
if v_prev is not None and v_prev != 0:
deltas[f"delta_30d_{key}"] = v_now - v_prev
else:
deltas[f"delta_30d_{key}"] = 0.0
# Interaction terms
interactions = {
"mvrv_x_nupl": vals["mvrv_zscore"] * vals["nupl"],
"puell_x_reserve": vals["puell_multiple"] * vals["reserve_risk"],
}
# Days since last ATH
days_since_ath = 0
for i in range(1, 2000):
check_d = (dt - timedelta(days=i)).strftime("%Y-%m-%d")
check_dd = drawdowns.get(check_d, 100)
if check_dd < 0.1: # essentially at ATH
days_since_ath = i
break
else:
days_since_ath = 2000
row = {
"date": d,
"price": price,
**{f"score_{k}": v for k, v in scores.items()},
**{f"raw_{k}": v for k, v in vals.items()},
**deltas,
**interactions,
"days_since_ath": days_since_ath,
**fwd,
}
rows.append(row)
log.info("Built %d complete data rows", len(rows))
return rows
def train_model(rows):
"""Train gradient boosted classifier to identify good buying opportunities."""
# Filter to rows that have 365d forward return (for labeling)
labeled = [r for r in rows if "fwd_365d" in r]
log.info("Rows with 365d forward data: %d", len(labeled))
if len(labeled) < 100:
log.error("Not enough labeled data. Need at least 100 rows, got %d", len(labeled))
return None
# Create binary target: forward 365d return > threshold
for r in labeled:
r["target"] = 1 if r["fwd_365d"] > GOOD_BUY_THRESHOLD else 0
positive = sum(r["target"] for r in labeled)
log.info("Target distribution: %d positive (%.1f%%), %d negative",
positive, positive / len(labeled) * 100, len(labeled) - positive)
# Feature columns: scores + raw values + deltas + interactions + cycle position
score_features = [
"score_puell_multiple", "score_mvrv_zscore", "score_reserve_risk",
"score_rhodl_ratio", "score_nupl", "score_fear_greed",
"score_drawdown", "score_pct_above_200w_sma", "score_pct_above_lth_rp",
]
raw_features = [
"raw_puell_multiple", "raw_mvrv_zscore", "raw_reserve_risk",
"raw_rhodl_ratio", "raw_nupl", "raw_fear_greed",
"raw_pct_above_200w_sma", "raw_pct_above_lth_rp", "raw_drawdown",
]
delta_features = [
"delta_30d_mvrv_zscore", "delta_30d_nupl",
"delta_30d_puell_multiple", "delta_30d_reserve_risk",
]
interaction_features = ["mvrv_x_nupl", "puell_x_reserve"]
cycle_features = ["days_since_ath"]
feature_cols = score_features + raw_features + delta_features + interaction_features + cycle_features
X = np.array([[r[f] for f in feature_cols] for r in labeled])
y = np.array([r["target"] for r in labeled])
log.info("Feature matrix: %d samples x %d features", X.shape[0], X.shape[1])
# Time-series cross-validation (expanding window, 5 splits)
tscv = TimeSeriesSplit(n_splits=5)
cv_scores = []
cv_f1 = []
cv_precision = []
cv_recall = []
for fold, (train_idx, val_idx) in enumerate(tscv.split(X)):
X_train, X_val = X[train_idx], X[val_idx]
y_train, y_val = y[train_idx], y[val_idx]
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_val_s = scaler.transform(X_val)
model = GradientBoostingClassifier(
n_estimators=300,
learning_rate=0.05,
max_depth=4,
subsample=0.8,
min_samples_leaf=20,
random_state=42,
)
model.fit(X_train_s, y_train)
y_pred = model.predict(X_val_s)
y_prob = model.predict_proba(X_val_s)[:, 1]
auc = roc_auc_score(y_val, y_prob) if len(np.unique(y_val)) > 1 else 0
f1 = f1_score(y_val, y_pred, zero_division=0)
prec = precision_score(y_val, y_pred, zero_division=0)
rec = recall_score(y_val, y_pred, zero_division=0)
cv_scores.append(auc)
cv_f1.append(f1)
cv_precision.append(prec)
cv_recall.append(rec)
train_dates = f"{labeled[train_idx[0]]['date']} to {labeled[train_idx[-1]]['date']}"
val_dates = f"{labeled[val_idx[0]]['date']} to {labeled[val_idx[-1]]['date']}"
log.info("Fold %d: Train %s | Val %s | AUC=%.3f F1=%.3f P=%.3f R=%.3f",
fold + 1, train_dates, val_dates, auc, f1, prec, rec)
log.info("CV Mean AUC: %.3f (+/- %.3f)", np.mean(cv_scores), np.std(cv_scores))
log.info("CV Mean F1: %.3f (+/- %.3f)", np.mean(cv_f1), np.std(cv_f1))
# Train final model on all labeled data
log.info("Training final model on all %d labeled samples...", len(labeled))
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
final_model = GradientBoostingClassifier(
n_estimators=300,
learning_rate=0.05,
max_depth=4,
subsample=0.8,
min_samples_leaf=20,
random_state=42,
)
final_model.fit(X_scaled, y)
# Feature importances
importances = final_model.feature_importances_
feat_imp = sorted(
zip(feature_cols, importances),
key=lambda x: x[1],
reverse=True,
)
log.info("\nFeature Importance Ranking:")
log.info("-" * 50)
for name, imp in feat_imp:
bar = "#" * int(imp * 200)
log.info(" %-30s %.4f %s", name, imp, bar)
# Extract optimal weights by aggregating importance per metric
# Map each feature back to its parent metric
metric_names = [
"puell_multiple", "mvrv_zscore", "reserve_risk", "rhodl_ratio",
"nupl", "fear_greed", "drawdown", "pct_above_200w_sma", "pct_above_lth_rp",
]
feature_to_metric = {}
for m in metric_names:
feature_to_metric[f"score_{m}"] = m
feature_to_metric[f"raw_{m}"] = m
# Delta features map to their base metric
feature_to_metric["delta_30d_mvrv_zscore"] = "mvrv_zscore"
feature_to_metric["delta_30d_nupl"] = "nupl"
feature_to_metric["delta_30d_puell_multiple"] = "puell_multiple"
feature_to_metric["delta_30d_reserve_risk"] = "reserve_risk"
# Interaction terms split evenly between constituent metrics
# mvrv_x_nupl -> mvrv_zscore + nupl
# puell_x_reserve -> puell_multiple + reserve_risk
metric_importances = {m: 0.0 for m in metric_names}
for name, imp in feat_imp:
if name in feature_to_metric:
metric_importances[feature_to_metric[name]] += imp
elif name == "mvrv_x_nupl":
metric_importances["mvrv_zscore"] += imp / 2
metric_importances["nupl"] += imp / 2
elif name == "puell_x_reserve":
metric_importances["puell_multiple"] += imp / 2
metric_importances["reserve_risk"] += imp / 2
# days_since_ath maps to drawdown conceptually
elif name == "days_since_ath":
metric_importances["drawdown"] += imp
# Normalize weights to sum to 1
total_imp = sum(metric_importances.values())
if total_imp > 0:
weights = {k: round(v / total_imp, 4) for k, v in metric_importances.items()}
else:
weights = {k: round(1 / len(metric_importances), 4) for k in metric_importances}
# Sort by weight descending
weights = dict(sorted(weights.items(), key=lambda x: x[1], reverse=True))
log.info("\nOptimal Metric Weights:")
log.info("-" * 50)
equal_weight = round(1 / len(weights), 4)
for metric, w in weights.items():
change = "+" if w > equal_weight else ""
diff = (w - equal_weight) / equal_weight * 100
log.info(" %-25s %.4f (%s%.0f%% vs equal)", metric, w, change, diff)
# Run comparison backtest: ML-weighted vs equal-weight
log.info("\n" + "=" * 60)
log.info("COMPARISON BACKTEST: ML-Weighted vs Equal-Weight")
log.info("=" * 60)
comparison = run_comparison(rows, weights)
# Build output
result = {
"weights": weights,
"feature_importances": {name: round(float(imp), 6) for name, imp in feat_imp},
"cv_results": {
"mean_auc": round(float(np.mean(cv_scores)), 4),
"std_auc": round(float(np.std(cv_scores)), 4),
"mean_f1": round(float(np.mean(cv_f1)), 4),
"mean_precision": round(float(np.mean(cv_precision)), 4),
"mean_recall": round(float(np.mean(cv_recall)), 4),
},
"training_info": {
"n_samples": len(labeled),
"n_positive": int(positive),
"positive_rate": round(positive / len(labeled), 4),
"n_features": len(feature_cols),
"target_threshold": GOOD_BUY_THRESHOLD,
"date_range": f"{labeled[0]['date']} to {labeled[-1]['date']}",
"model": "GradientBoostingClassifier",
},
"comparison": comparison,
"trained_at": datetime.now(tz=__import__('datetime').timezone.utc).isoformat(),
}
return result
def run_comparison(rows, ml_weights):
"""Compare ML-weighted scoring vs equal-weight scoring across score brackets."""
# Metrics used in scoring (maps to score_* columns)
score_keys = [
"puell_multiple", "mvrv_zscore", "reserve_risk", "rhodl_ratio",
"nupl", "fear_greed", "drawdown", "pct_above_200w_sma", "pct_above_lth_rp",
]
n_metrics = len(score_keys)
equal_weight = 1.0 / n_metrics
brackets = [
(0, 20, "Extreme Caution"),
(21, 40, "Caution"),
(41, 55, "Neutral"),
(56, 70, "Moderate Opportunity"),
(71, 85, "Strong Accumulation"),
(86, 100, "Extreme Accumulation"),
]
# Only use rows with forward returns
scored_rows = [r for r in rows if "fwd_365d" in r]
results = {"equal_weight": [], "ml_weighted": []}
for mode in ["equal_weight", "ml_weighted"]:
for r in scored_rows:
scores = [r[f"score_{k}"] for k in score_keys]
if mode == "equal_weight":
composite = sum(scores) / n_metrics * 10
else:
weighted_sum = sum(r[f"score_{k}"] * ml_weights.get(k, equal_weight) for k in score_keys)
composite = weighted_sum * 10
r[f"composite_{mode}"] = composite
for low, high, label in brackets:
days_in = [r for r in scored_rows if low <= r[f"composite_{mode}"] <= high]
if not days_in:
results[mode].append({
"range": f"{low}-{high}", "label": label,
"days": 0, "avg_365d": None,
})
continue
returns_365 = [r["fwd_365d"] for r in days_in]
win_rate = len([r for r in returns_365 if r > 0]) / len(returns_365) * 100
results[mode].append({
"range": f"{low}-{high}",
"label": label,
"days": len(days_in),
"avg_365d": round(sum(returns_365) / len(returns_365), 2),
"median_365d": round(sorted(returns_365)[len(returns_365) // 2], 2),
"win_rate_365d": round(win_rate, 1),
})
# Print comparison
log.info("\n%-18s | %-8s %-8s %-8s | %-8s %-8s %-8s",
"Bracket", "EQ Avg", "EQ Med", "EQ Win%", "ML Avg", "ML Med", "ML Win%")
log.info("-" * 80)
for eq, ml in zip(results["equal_weight"], results["ml_weighted"]):
eq_avg = f"{eq['avg_365d']:.1f}%" if eq["avg_365d"] is not None else "--"
eq_med = f"{eq['median_365d']:.1f}%" if eq.get("median_365d") is not None else "--"
eq_win = f"{eq['win_rate_365d']:.0f}%" if eq.get("win_rate_365d") is not None else "--"
ml_avg = f"{ml['avg_365d']:.1f}%" if ml["avg_365d"] is not None else "--"
ml_med = f"{ml['median_365d']:.1f}%" if ml.get("median_365d") is not None else "--"
ml_win = f"{ml['win_rate_365d']:.0f}%" if ml.get("win_rate_365d") is not None else "--"
log.info("%-18s | %-8s %-8s %-8s | %-8s %-8s %-8s",
eq["label"], eq_avg, eq_med, eq_win, ml_avg, ml_med, ml_win)
return results
def main():
log.info("=" * 60)
log.info("Bitcoin Accumulation Zone ML Optimizer")
log.info("=" * 60)
if not os.path.exists(HISTORY_PATH):
log.error("No historical data at %s. Run history collector first.", HISTORY_PATH)
sys.exit(1)
# Load data
log.info("Loading historical data...")
index = load_history()
thresholds = load_thresholds()
# Build dataset
log.info("Building training dataset...")
rows = build_dataset(index, thresholds)
# Train model
log.info("Training ML model...")
result = train_model(rows)
if result is None:
log.error("Training failed.")
sys.exit(1)
# Save weights
with open(OUTPUT_PATH, "w") as f:
json.dump(result, f, indent=2)
log.info("\nSaved ML weights to %s", OUTPUT_PATH)
# Print summary
log.info("\n" + "=" * 60)
log.info("SUMMARY")
log.info("=" * 60)
log.info("Model: %s", result["training_info"]["model"])
log.info("Samples: %d (%d positive)", result["training_info"]["n_samples"], result["training_info"]["n_positive"])
log.info("CV AUC: %.3f (+/- %.3f)", result["cv_results"]["mean_auc"], result["cv_results"]["std_auc"])
log.info("CV F1: %.3f", result["cv_results"]["mean_f1"])
log.info("\nTop 5 Feature Importances:")
for name, imp in list(result["feature_importances"].items())[:5]:
log.info(" %-30s %.4f", name, imp)
log.info("\nMetric Weights (ML-Optimized):")
for metric, weight in result["weights"].items():
log.info(" %-25s %.1f%%", metric, weight * 100)
if __name__ == "__main__":
main()

View File

@ -75,7 +75,8 @@ def score_puell_multiple(value, thresholds=None):
if value is None: if value is None:
return None, "No data" return None, "No data"
t = (thresholds or load_thresholds()).get("puell_multiple", {}) t = (thresholds or load_thresholds()).get("puell_multiple", {})
ranges = t.get("ranges", [[None, 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, None, 0]]) # Widened: post-halving Puell floors are rising (2016: 0.15, 2020: 0.3, 2024: 0.5+)
ranges = t.get("ranges", [[None, 0.4, 10], [0.4, 0.7, 8], [0.7, 1.0, 5], [1.0, 1.5, 3], [1.5, 2.0, 1], [2.0, None, 0]])
score = _score_range(value, ranges) score = _score_range(value, ranges)
if value < 0.3: if value < 0.3:
@ -97,15 +98,17 @@ def score_mvrv_zscore(value, thresholds=None):
if value is None: if value is None:
return None, "No data" return None, "No data"
t = (thresholds or load_thresholds()).get("mvrv_zscore", {}) t = (thresholds or load_thresholds()).get("mvrv_zscore", {})
ranges = t.get("ranges", [[None, 0, 10], [0, 0.5, 8], [0.5, 1.5, 5], [1.5, 3, 2], [3, 5, 1], [5, None, 0]]) # Widened ranges: BTC cycles compress — Z-Score bottoms are getting shallower
# 2015 bottom: -0.6, 2018 bottom: -0.4, 2022 bottom: -0.3, next may be ~0
ranges = t.get("ranges", [[None, 0, 10], [0, 1.0, 8], [1.0, 2.0, 5], [2.0, 3, 3], [3, 5, 1], [5, None, 0]])
score = _score_range(value, ranges) score = _score_range(value, ranges)
if value < 0: if value < 0:
desc = "Below realized value — historically perfect buy zone" desc = "Below realized value — historically perfect buy zone"
elif value < 0.5: elif value < 1.0:
desc = "Near realized value — strong accumulation" desc = "Near realized value — strong accumulation zone"
elif value < 1.5: elif value < 2.0:
desc = "Fair value range" desc = "Fair value — decent entry territory"
elif value < 3: elif value < 3:
desc = "Above fair value" desc = "Above fair value"
elif value < 5: elif value < 5:
@ -144,15 +147,16 @@ def score_price_vs_200w_sma(price, sma_200w, thresholds=None):
return None, "No data" return None, "No data"
pct_above = ((price - sma_200w) / sma_200w) * 100 pct_above = ((price - sma_200w) / sma_200w) * 100
t = (thresholds or load_thresholds()).get("price_vs_200w_sma", {}) t = (thresholds or load_thresholds()).get("price_vs_200w_sma", {})
ranges = t.get("ranges", [[None, 0, 10], [0, 20, 6], [20, 50, 3], [50, 100, 1], [100, None, 0]]) # Widened: BTC increasingly stays above 200W SMA as it matures
ranges = t.get("ranges", [[None, 0, 10], [0, 30, 7], [30, 60, 5], [60, 100, 2], [100, None, 0]])
score = _score_range(pct_above, ranges) score = _score_range(pct_above, ranges)
if pct_above < 0: if pct_above < 0:
desc = f"Below 200W SMA — historically rare buy zone" desc = f"Below 200W SMA — historically rare buy zone"
elif pct_above < 20: elif pct_above < 30:
desc = f"{pct_above:.0f}% above 200W SMA — good value" desc = f"{pct_above:.0f}% above 200W SMA — strong value"
elif pct_above < 50: elif pct_above < 60:
desc = f"{pct_above:.0f}% above 200W SMA — moderate" desc = f"{pct_above:.0f}% above 200W SMA — fair value"
elif pct_above < 100: elif pct_above < 100:
desc = f"{pct_above:.0f}% above 200W SMA — extended" desc = f"{pct_above:.0f}% above 200W SMA — extended"
else: else:
@ -204,13 +208,15 @@ def score_nupl(value, thresholds=None):
if value is None: if value is None:
return None, "No data" return None, "No data"
t = (thresholds or load_thresholds()).get("nupl", {}) t = (thresholds or load_thresholds()).get("nupl", {})
ranges = t.get("ranges", [[None, 0, 10], [0, 0.25, 7], [0.25, 0.5, 4], [0.5, 0.75, 1], [0.75, None, 0]]) # Widened: NUPL bottoms getting shallower as BTC matures
# 2015: -0.3, 2018: -0.28, 2022: -0.28, future may only dip to 0-0.1
ranges = t.get("ranges", [[None, 0, 10], [0, 0.3, 8], [0.3, 0.5, 4], [0.5, 0.75, 1], [0.75, None, 0]])
score = _score_range(value, ranges) score = _score_range(value, ranges)
if value < 0: if value < 0:
desc = "Capitulation — holders underwater" desc = "Capitulation — holders underwater"
elif value < 0.25: elif value < 0.3:
desc = "Hope/Fear — early recovery" desc = "Hope/Fear — early recovery, good accumulation"
elif value < 0.5: elif value < 0.5:
desc = "Optimism — moderate profit taking" desc = "Optimism — moderate profit taking"
elif value < 0.75: elif value < 0.75:
@ -226,14 +232,18 @@ def score_lth_realized_price(price, lth_rp, thresholds=None):
return None, "No data" return None, "No data"
pct_above = ((price - lth_rp) / lth_rp) * 100 pct_above = ((price - lth_rp) / lth_rp) * 100
t = (thresholds or load_thresholds()).get("lth_realized_price", {}) t = (thresholds or load_thresholds()).get("lth_realized_price", {})
ranges = t.get("ranges", [[None, 0, 10], [0, 20, 6], [20, 50, 3], [50, None, 1]]) # Widened: as BTC matures, price spends more time above LTH RP
# In 2024+, even "good" entries are 30-80% above LTH RP
ranges = t.get("ranges", [[None, 0, 10], [0, 30, 7], [30, 80, 5], [80, 150, 3], [150, None, 1]])
score = _score_range(pct_above, ranges) score = _score_range(pct_above, ranges)
if pct_above < 0: if pct_above < 0:
desc = f"Below LTH cost basis — LTHs underwater (extreme value)" desc = f"Below LTH cost basis — LTHs underwater (extreme value)"
elif pct_above < 20: elif pct_above < 30:
desc = f"{pct_above:.0f}% above LTH cost basis — good value" desc = f"{pct_above:.0f}% above LTH cost basis — strong value"
elif pct_above < 50: elif pct_above < 80:
desc = f"{pct_above:.0f}% above LTH cost basis — fair value"
elif pct_above < 150:
desc = f"{pct_above:.0f}% above LTH cost basis — moderate" desc = f"{pct_above:.0f}% above LTH cost basis — moderate"
else: else:
desc = f"{pct_above:.0f}% above LTH cost basis — extended" desc = f"{pct_above:.0f}% above LTH cost basis — extended"
@ -397,14 +407,16 @@ def score_all(metrics):
else: else:
composite = 0 composite = 0
# Assessment text # Assessment text — calibrated for cycle-aware scoring
if composite >= 71: if composite >= 80:
assessment = "EXTREME ACCUMULATION ZONE"
elif composite >= 65:
assessment = "STRONG ACCUMULATION ZONE" assessment = "STRONG ACCUMULATION ZONE"
elif composite >= 51: elif composite >= 50:
assessment = "MODERATE OPPORTUNITY" assessment = "MODERATE OPPORTUNITY"
elif composite >= 31: elif composite >= 35:
assessment = "NEUTRAL" assessment = "NEUTRAL"
elif composite >= 15: elif composite >= 20:
assessment = "CAUTION — OVERHEATED" assessment = "CAUTION — OVERHEATED"
else: else:
assessment = "EXTREME CAUTION" assessment = "EXTREME CAUTION"
@ -416,3 +428,104 @@ def score_all(metrics):
"scored_count": len(valid_scores), "scored_count": len(valid_scores),
"total_count": len(results), "total_count": len(results),
} }
# ── ML-Optimized Scoring ──────────────────────────────────────────────
ML_WEIGHTS_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"config",
"ml_weights.json",
)
# Maps scoring engine metric keys to ML weight keys
_ML_KEY_MAP = {
"fear_greed": "fear_greed",
"puell_multiple": "puell_multiple",
"mvrv_zscore": "mvrv_zscore",
"drawdown": "drawdown",
"price_vs_200w_sma": "pct_above_200w_sma",
"reserve_risk": "reserve_risk",
"rhodl_ratio": "rhodl_ratio",
"nupl": "nupl",
"lth_realized_price": "pct_above_lth_rp",
}
def load_ml_weights():
"""Load ML-optimized weights from config."""
try:
with open(ML_WEIGHTS_PATH) as f:
data = json.load(f)
return data.get("weights", {})
except Exception:
return {}
def score_all_ml(metrics):
"""Score all metrics using ML-optimized weights.
Same output format as score_all() but uses learned weights
instead of equal weighting. Each metric still shows its
individual 0-10 score plus the ML weight applied to it.
"""
# Get classic scores first (reuses all individual scoring logic)
classic = score_all(metrics)
ml_weights = load_ml_weights()
if not ml_weights:
# Fallback to classic if no ML weights available
classic["ml_mode"] = False
classic["ml_error"] = "ML weights not found — run ml/optimizer.py"
return classic
results = classic["metrics"]
# Compute ML-weighted composite
weighted_sum = 0.0
weight_total = 0.0
for m in results:
if m["score"] is None:
continue
ml_key = _ML_KEY_MAP.get(m["key"])
if ml_key is None:
# Hash ribbons or unknown metric — use small default weight
w = 0.01
else:
w = ml_weights.get(ml_key, 0.0)
m["ml_weight"] = round(w, 4)
m["ml_contribution"] = round(m["score"] * w * 10, 2)
weighted_sum += m["score"] * w
weight_total += w
# Normalize if weights don't sum to 1 (e.g., missing metrics)
if weight_total > 0:
composite = weighted_sum / weight_total * 10
else:
composite = 0
# Assessment text (same thresholds as classic)
if composite >= 80:
assessment = "EXTREME ACCUMULATION ZONE"
elif composite >= 65:
assessment = "STRONG ACCUMULATION ZONE"
elif composite >= 50:
assessment = "MODERATE OPPORTUNITY"
elif composite >= 35:
assessment = "NEUTRAL"
elif composite >= 20:
assessment = "CAUTION — OVERHEATED"
else:
assessment = "EXTREME CAUTION"
return {
"metrics": results,
"composite_score": round(composite, 1),
"assessment": assessment,
"scored_count": classic["scored_count"],
"total_count": classic["total_count"],
"ml_mode": True,
"classic_score": classic["composite_score"],
}

112
scrapers/history_updater.py Normal file
View File

@ -0,0 +1,112 @@
"""Incremental history updater — appends new daily data to history.json from cache."""
import json
import logging
import os
from datetime import datetime, timezone
log = logging.getLogger(__name__)
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
HISTORY_PATH = os.path.join(DATA_DIR, "history.json")
CACHE_PATH = os.path.join(DATA_DIR, "cache.json")
def update_history():
"""Append today's values from cache to history.json. Only adds NEW dates."""
if not os.path.exists(HISTORY_PATH):
log.warning("No history.json found — run full collection first")
return False
if not os.path.exists(CACHE_PATH):
log.warning("No cache.json found — run a scrape first")
return False
with open(HISTORY_PATH) as f:
history = json.load(f)
with open(CACHE_PATH) as f:
cache = json.load(f)
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
updated = False
# Map of cache keys to history keys and how to extract the value
mappings = {
"puell_multiple": {"history_key": "puell_multiple", "value_key": "value"},
"mvrv_zscore": {"history_key": "mvrv_zscore", "value_key": "value"},
"reserve_risk": {"history_key": "reserve_risk", "value_key": "value"},
"rhodl_ratio": {"history_key": "rhodl_ratio", "value_key": "value"},
"nupl": {"history_key": "nupl", "value_key": "value"},
"200w_sma": {"history_key": "200w_sma", "value_key": "value"},
"lth_realized_price": {"history_key": "lth_realized_price", "value_key": "value"},
"lth_supply": {"history_key": "lth_supply", "value_key": "value"},
}
for cache_key, mapping in mappings.items():
hkey = mapping["history_key"]
if hkey not in history:
continue
h = history[hkey]
dates = h.get("dates", [])
values = h.get("values", [])
# Skip if today already in history
if dates and dates[-1] >= today:
continue
# Get value from cache
cached = cache.get(cache_key, {})
val = cached.get(mapping["value_key"])
if val is not None:
dates.append(today)
values.append(val)
h["dates"] = dates
h["values"] = values
updated = True
log.info("Appended %s: %s = %s", hkey, today, val)
# Also update btc_price from cache
price_data = cache.get("price", {})
btc_price = price_data.get("price")
if btc_price and "btc_price" in history:
h = history["btc_price"]
if h["dates"][-1] < today:
h["dates"].append(today)
h["values"].append(btc_price)
updated = True
# BTC price for SMA chart
if btc_price and "btc_price_sma" in history:
h = history["btc_price_sma"]
if h["dates"][-1] < today:
h["dates"].append(today)
h["values"].append(btc_price)
updated = True
# BTC price for LTH chart
if btc_price and "btc_price_lth" in history:
h = history["btc_price_lth"]
if h["dates"][-1] < today:
h["dates"].append(today)
h["values"].append(btc_price)
updated = True
# Fear & Greed
fg = cache.get("fear_greed", {})
fg_val = fg.get("value")
if fg_val is not None and "fear_greed" in history:
h = history["fear_greed"]
if h["dates"][-1] < today:
h["dates"].append(today)
h["values"].append(int(fg_val))
updated = True
if updated:
with open(HISTORY_PATH, "w") as f:
json.dump(history, f)
log.info("History updated with %s data", today)
else:
log.info("History already up to date (last date >= %s)", today)
return updated

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB