Compare commits
12 Commits
v4.0-accum
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4647c596b3 | ||
|
|
f1d38f9abb | ||
|
|
fb590105ce | ||
|
|
85e0a6839f | ||
|
|
ececd65a22 | ||
|
|
5538f666c5 | ||
|
|
6bfbd30e3d | ||
|
|
6398c6c8f4 | ||
|
|
22fc7fc6cd | ||
|
|
28b5240a81 | ||
|
|
e385765fda | ||
|
|
0ddb4ab01b |
283
README.md
283
README.md
@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
|
||||||
|

|
||||||
|
*Live accumulation score with all 10 metrics, current BTC price, and individual metric breakdowns*
|
||||||
|
|
||||||
|
### Historical Backtest
|
||||||
|

|
||||||
|
*Historical score vs BTC price overlay, score bracket performance table, and major signal events*
|
||||||
|
|
||||||
|
### Settings
|
||||||
|

|
||||||
|
*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
|
|
||||||
|
|||||||
@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
159
config/ml_weights.json
Normal 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"
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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>';
|
||||||
|
|||||||
@ -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
0
ml/__init__.py
Normal file
562
ml/optimizer.py
Normal file
562
ml/optimizer.py
Normal 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()
|
||||||
@ -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
112
scrapers/history_updater.py
Normal 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
|
||||||
BIN
screenshots/dashboard-backtest.png
Normal file
BIN
screenshots/dashboard-backtest.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 633 KiB |
BIN
screenshots/dashboard-main.png
Normal file
BIN
screenshots/dashboard-main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
BIN
screenshots/dashboard-settings.png
Normal file
BIN
screenshots/dashboard-settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Loading…
x
Reference in New Issue
Block a user