Compare commits
3
Commits
4647c596b3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
573884a1c2 | ||
|
|
de2cd512cd | ||
|
|
8fca6181d5 |
@@ -1,134 +1,221 @@
|
|||||||
# Bitcoin Accumulation Zone Monitor
|
# Bitcoin Accumulation Zone Monitor
|
||||||
|
|
||||||
> On-chain metrics dashboard with historical backtesting for long-term BTC holders. No ML, no black box — pure signal monitoring from proven indicators.
|
> Bitcoin on-chain metrics dashboard with classic equal-weight scoring, ML-optimized scoring, historical backtesting, and click-to-select metric context for long-term BTC accumulation decisions.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## What It Does
|
## 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.
|
Monitors Bitcoin accumulation conditions using 16 scored market/on-chain indicators plus optional informational cycle metrics. Each scored metric receives a 0-10 score and rolls into a 0-100 accumulation score.
|
||||||
|
|
||||||
**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.
|
The dashboard now supports two scoring modes:
|
||||||
|
|
||||||
|
- **Classic** — transparent equal-weight scoring across every active metric.
|
||||||
|
- **ML** — feature-importance weights trained against historical 365-day forward returns, with displayed per-metric weights and point contributions.
|
||||||
|
|
||||||
|
Historical backtests show score-vs-price behavior, score bracket performance, major signal events, and current-score context. Metric cards are clickable: selecting a metric overlays its historical series on the score chart and shows comparable historical periods with forward returns.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
### Main Dashboard
|
### Main Dashboard — ML mode + metric context
|
||||||

|

|
||||||
*Live accumulation score with all 10 metrics, current BTC price, and individual metric breakdowns*
|
*Live BTC price, Classic/ML scoring toggle, 16 active scored metrics, ML weights/contributions, metric sparklines, and click-to-select historical context.*
|
||||||
|
|
||||||
### Historical Backtest
|
### Historical Backtest
|
||||||

|

|
||||||
*Historical score vs BTC price overlay, score bracket performance table, and major signal events*
|
*Current signal percentile, comparable historical periods by cycle, score-vs-BTC chart, bracket performance, and major signal events.*
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||

|

|
||||||
*LLM provider configuration for optional AI-powered signal commentary*
|
*LLM provider configuration for optional AI-powered signal commentary and local/cloud model selection.*
|
||||||
|
|
||||||
|
## Feature Highlights
|
||||||
|
|
||||||
|
- **16 scored metrics** from market sentiment, miner stress, valuation, holder behavior, network activity, and velocity signals.
|
||||||
|
- **Classic vs ML scoring toggle** on the dashboard and backtest API.
|
||||||
|
- **ML score explainability**: metric cards show learned weight and contribution in points.
|
||||||
|
- **Leakage-resistant ML validation**: training uses purged time-series splits so 365-day forward-return labels do not overlap validation windows.
|
||||||
|
- **Historical context panel**: compares the current composite score against historical periods and forward returns.
|
||||||
|
- **Clickable metric cards**: select any metric to see percentile, similar historical levels, forward returns, example dates by market cycle, and highlighted chart periods.
|
||||||
|
- **Score history chart** with BTC price overlay, range controls, and selected-metric overlay.
|
||||||
|
- **Backtest dashboard** with current signal context, score bracket performance, and signal-crossing events.
|
||||||
|
- **Quick vs full refresh**: quick refresh updates BTC price and Fear & Greed; full refresh re-scrapes on-chain sources.
|
||||||
|
- **LLM settings UI** for Ollama, LM Studio, OpenAI, Anthropic, and OpenRouter.
|
||||||
|
|
||||||
## Metrics
|
## Metrics
|
||||||
|
|
||||||
| # | Metric | Source | Accumulation Signal |
|
| # | Metric | Source | Accumulation Signal |
|
||||||
|---|--------|--------|-------------------|
|
|---|--------|--------|-------------------|
|
||||||
| 1 | Fear & Greed Index | alternative.me API | Extreme Fear (< 10) |
|
| 1 | Fear & Greed Index | alternative.me API | Extreme fear / capitulation sentiment |
|
||||||
| 2 | Puell Multiple | LookIntoBitcoin (scraped) | Miner capitulation (< 0.5) |
|
| 2 | Puell Multiple | LookIntoBitcoin | Miner revenue stress |
|
||||||
| 3 | MVRV Z-Score | LookIntoBitcoin (scraped) | Below realized value (< 0) |
|
| 3 | MVRV Z-Score | LookIntoBitcoin | Market near/below realized value |
|
||||||
| 4 | Drawdown from ATH | Calculated | Deep correction (> 50%) |
|
| 4 | Drawdown from ATH | Calculated from BTC price | Deep correction from cycle high |
|
||||||
| 5 | Price vs 200W SMA | LookIntoBitcoin (scraped) | Below 200-week average |
|
| 5 | Price vs 200W SMA | LookIntoBitcoin + BTC price | Price near/below long-term trend |
|
||||||
| 6 | Reserve Risk | LookIntoBitcoin (scraped) | High holder confidence (< 0.002) |
|
| 6 | Reserve Risk | LookIntoBitcoin | High holder confidence relative to price |
|
||||||
| 7 | RHODL Ratio | LookIntoBitcoin (scraped) | Long-term holder dominance (< 100) |
|
| 7 | RHODL Ratio | LookIntoBitcoin | Long-term holder dominance |
|
||||||
| 8 | NUPL | LookIntoBitcoin (scraped) | Market capitulation (< 0) |
|
| 8 | Net Unrealized Profit/Loss (NUPL) | LookIntoBitcoin | Capitulation / early recovery zones |
|
||||||
| 9 | LTH Realized Price | LookIntoBitcoin (scraped) | Price below LTH cost basis |
|
| 9 | LTH Realized Price | LookIntoBitcoin | Price near long-term holder cost basis |
|
||||||
| 10 | Hash Ribbons | LookIntoBitcoin (scraped) | Miner capitulation recovery |
|
| 10 | Hash Ribbons | LookIntoBitcoin | Miner capitulation/recovery signal |
|
||||||
|
| 11 | SOPR | CheckOnChain | Spent outputs near loss / reset territory |
|
||||||
|
| 12 | Sell-side Risk Ratio | CheckOnChain | Low realized profit/loss pressure |
|
||||||
|
| 13 | Active Address Momentum | CheckOnChain | Network activity momentum extremes |
|
||||||
|
| 14 | Transaction Count Momentum | CheckOnChain | Transaction activity momentum extremes |
|
||||||
|
| 15 | NVT Price | CheckOnChain | Network-value valuation discount/premium |
|
||||||
|
| 16 | VDD Multiple | CheckOnChain | Coin-days/velocity reset conditions |
|
||||||
|
|
||||||
|
Informational cards may also appear when data is available, such as **Long-Term Holder Supply** and **Pi Cycle Bottom**. These are displayed for context and are not included in the composite score.
|
||||||
|
|
||||||
## Score Interpretation
|
## Score Interpretation
|
||||||
|
|
||||||
| Score | Assessment | Historical Outcome |
|
| Score | Assessment | Interpretation |
|
||||||
|-------|-----------|-------------------|
|
|-------|-----------|----------------|
|
||||||
| 85-100 | 🟢 Extreme Accumulation | Rare (~4x per decade). Historically: 200%+ 1yr returns |
|
| 80-100 | 🟢 Extreme Accumulation Zone | Broad capitulation/value conditions across active metrics |
|
||||||
| 70-84 | 🟢 Strong Accumulation | Excellent long-term entry point |
|
| 65-79 | 🟢 Strong Accumulation Zone | Historically attractive long-term entry territory |
|
||||||
| 55-69 | 🟡 Moderate Opportunity | Decent entry, DCA appropriate |
|
| 50-64 | 🟡 Moderate Opportunity | DCA-friendly, but not maximum-signal conditions |
|
||||||
| 40-54 | 🟡 Neutral | Hold — not compelling either way |
|
| 35-49 | 🟡 Neutral | Mixed signals; not compelling either direction |
|
||||||
| 25-39 | 🔴 Caution | Market heating up |
|
| 20-34 | 🔴 Caution — Overheated | Market conditions becoming less favorable |
|
||||||
| 0-24 | 🔴 Extreme Caution | Historically worst times to buy |
|
| 0-19 | 🔴 Extreme Caution | Historically poor accumulation setup |
|
||||||
|
|
||||||
|
Backtest tables provide actual historical forward-return statistics per score bracket, including 30d/90d/180d/1yr averages, win rate, max gain/loss, and average max drawdown.
|
||||||
|
|
||||||
|
## ML-Optimized Scoring
|
||||||
|
|
||||||
|
The ML mode uses a `GradientBoostingClassifier` trained on historical feature rows to predict whether a day was a good long-term buy based on 365-day forward return. Training features include:
|
||||||
|
|
||||||
|
- Classic metric scores.
|
||||||
|
- Raw metric values.
|
||||||
|
- 30-day metric deltas.
|
||||||
|
- Interaction features such as MVRV × NUPL and Puell × Reserve Risk.
|
||||||
|
- Cycle-position context such as days since ATH.
|
||||||
|
|
||||||
|
The resulting feature importances are aggregated back into transparent metric weights stored in `config/ml_weights.json`. The UI displays normalized weight and contribution for each active metric.
|
||||||
|
|
||||||
|
Validation uses purged expanding time-series splits: because each label uses a 365-day forward-return window, training rows whose label windows overlap validation are removed before scoring validation folds.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Component | Technology |
|
| Component | Technology |
|
||||||
|-----------|-----------|
|
|-----------|-----------|
|
||||||
| Backend | Python 3.13 + FastAPI |
|
| Backend | Python 3.13 + FastAPI |
|
||||||
| Frontend | Inline HTML/CSS/JS (dark trading terminal theme) |
|
| Frontend | Inline HTML/CSS/JS dark trading-terminal UI |
|
||||||
| Charts | Chart.js |
|
| Charts | Chart.js |
|
||||||
| Scraping | Playwright (headless Chromium) |
|
| Scraping | requests + Playwright-style browser scraping where needed |
|
||||||
| Data APIs | alternative.me (F&G), CoinGecko (price) |
|
| Data APIs | alternative.me, CoinGecko, LookIntoBitcoin, CheckOnChain |
|
||||||
| Process Manager | pm2 |
|
| ML | NumPy + pandas + scikit-learn GradientBoostingClassifier |
|
||||||
| Port | 3088 |
|
| Process Manager | pm2 or uvicorn |
|
||||||
|
| Default Port | 3088 |
|
||||||
|
|
||||||
## How Data Is Collected
|
## How Data Is Collected
|
||||||
|
|
||||||
All data is scraped from free, public sources — **no API keys required**.
|
Data is collected from free/public sources and cached locally under `data/`.
|
||||||
|
|
||||||
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.
|
- Fast live refreshes update BTC price, ATH/drawdown, 200D SMA/Mayer where possible, and Fear & Greed.
|
||||||
|
- On-chain metrics are cached and reused because they update slowly.
|
||||||
|
- Full refresh re-scrapes on-chain metrics from LookIntoBitcoin/CheckOnChain.
|
||||||
|
- Historical backtest data lives in `data/history.json` and supports charting, backtests, and metric-context lookups.
|
||||||
|
- Score history appends to `data/score_history.jsonl`.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
├── dashboard/
|
├── dashboard/
|
||||||
│ └── server.py # FastAPI server + inline dashboard HTML
|
│ └── server.py # FastAPI server + inline dashboard/backtest/settings UI
|
||||||
├── scrapers/
|
├── scrapers/
|
||||||
│ ├── lookintobitcoin.py # Playwright scraper for on-chain charts
|
│ ├── lookintobitcoin.py # LookIntoBitcoin metric scraping
|
||||||
│ ├── history_collector.py # Full historical data collection
|
│ ├── checkonchain.py # CheckOnChain metric scraping
|
||||||
│ ├── fear_greed.py # Fear & Greed Index API
|
│ ├── history_collector.py # Full historical data collection
|
||||||
│ └── price.py # BTC price API
|
│ ├── history_updater.py # Incremental historical updates
|
||||||
|
│ ├── fear_greed.py # Fear & Greed Index API
|
||||||
|
│ └── price.py # BTC price, ATH, drawdown, SMA helpers
|
||||||
├── scoring/
|
├── scoring/
|
||||||
│ └── engine.py # Metric scoring logic (0-10 per metric)
|
│ └── engine.py # Classic + ML-weighted scoring logic
|
||||||
├── backtesting/
|
├── backtesting/
|
||||||
│ └── engine.py # Historical backtest engine
|
│ └── engine.py # Historical backtest engine
|
||||||
|
├── ml/
|
||||||
|
│ └── optimizer.py # ML training, purged CV, weight export
|
||||||
|
├── tests/
|
||||||
|
│ ├── test_ml_optimizer_validation.py
|
||||||
|
│ └── test_scoring_engine_ml.py
|
||||||
├── data/
|
├── data/
|
||||||
│ ├── cache.json # Live metric cache (auto-generated)
|
│ ├── cache.json # Live metric cache
|
||||||
│ └── history.json # Historical data (auto-generated)
|
│ ├── history.json # Historical metric/time-series data
|
||||||
|
│ └── score_history.jsonl # Live score history
|
||||||
├── config/
|
├── config/
|
||||||
│ └── thresholds.json # Scoring thresholds (customizable)
|
│ ├── thresholds.json # Classic scoring thresholds
|
||||||
├── screenshots/ # Dashboard screenshots
|
│ ├── ml_weights.json # Learned ML metric weights
|
||||||
├── ARCHITECTURE.md # Detailed architecture & scoring logic
|
│ └── llm_settings.json # Optional AI commentary provider config
|
||||||
|
├── screenshots/ # README screenshots
|
||||||
|
├── ARCHITECTURE.md
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
|
### Local / ad-hoc with uv
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
cd /opt/data/btc-accumulation-monitor
|
||||||
pip install fastapi uvicorn playwright requests
|
PYTHONPATH=. uv run \
|
||||||
|
--with fastapi \
|
||||||
|
--with uvicorn \
|
||||||
|
--with requests \
|
||||||
|
--with pandas \
|
||||||
|
--with numpy \
|
||||||
|
--with scikit-learn \
|
||||||
|
python -m uvicorn dashboard.server:app --host 0.0.0.0 --port 3088
|
||||||
|
```
|
||||||
|
|
||||||
# Install Playwright browsers (first time only)
|
### VPS-style install
|
||||||
playwright install chromium
|
|
||||||
|
|
||||||
# Start the dashboard
|
```bash
|
||||||
cd /opt/apps/btc-ml-optimizer
|
cd /opt/apps/btc-ml-optimizer
|
||||||
python3 -m uvicorn dashboard.server:app --host 0.0.0.0 --port 3088
|
python3 -m venv .venv
|
||||||
|
. .venv/bin/activate
|
||||||
|
pip install -r requirements_vps.txt pandas numpy scikit-learn
|
||||||
|
python -m uvicorn dashboard.server:app --host 0.0.0.0 --port 3088
|
||||||
|
```
|
||||||
|
|
||||||
# Or with pm2
|
### pm2
|
||||||
|
|
||||||
|
```bash
|
||||||
pm2 start "python3 -m uvicorn dashboard.server:app --host 0.0.0.0 --port 3088" --name btc-ml-optimizer
|
pm2 start "python3 -m uvicorn dashboard.server:app --host 0.0.0.0 --port 3088" --name btc-ml-optimizer
|
||||||
```
|
```
|
||||||
|
|
||||||
### First Run
|
## 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
|
1. Visit `http://localhost:3088` for the live dashboard.
|
||||||
|
2. Use **Quick Refresh** for fast price/Fear & Greed updates.
|
||||||
|
3. Use **Full Refresh** to re-scrape on-chain metrics.
|
||||||
|
4. Visit `http://localhost:3088/backtest` to view historical score performance.
|
||||||
|
5. If historical data is missing, use the backtest page's collection flow to populate `data/history.json`.
|
||||||
|
|
||||||
The backtest engine reconstructs the composite score for every historical day and compares against actual BTC forward returns.
|
## Useful API Endpoints
|
||||||
|
|
||||||
**Key feature: Recency weighting** — Bitcoin's cycle returns diminish over time (100x → 30x → 8x → 3-4x). The backtest weights recent cycles more heavily:
|
| Endpoint | Description |
|
||||||
- 2022-present: 4x weight
|
|----------|-------------|
|
||||||
- 2020-2021: 3x weight
|
| `GET /api/data?mode=classic` | Current metrics using equal-weight scoring |
|
||||||
- 2018-2019: 2x weight
|
| `GET /api/data?mode=ml` | Current metrics using ML-optimized weights |
|
||||||
- Pre-2018: 1x weight
|
| `GET /api/history` | Recent live score history |
|
||||||
|
| `POST /api/refresh` | Quick refresh |
|
||||||
|
| `POST /api/refresh?full=true` | Full on-chain refresh |
|
||||||
|
| `GET /api/backtest?mode=classic` | Historical backtest with classic scoring |
|
||||||
|
| `GET /api/backtest?mode=ml` | Historical backtest with ML scoring |
|
||||||
|
| `GET /api/metric-context?metric=mvrv_zscore&mode=ml` | Similar historical levels and forward returns for one metric |
|
||||||
|
| `GET /api/settings` | Safe LLM settings payload |
|
||||||
|
|
||||||
Results are shown per-cycle so you see realistic expectations for the current cycle, not averages inflated by early moonshots.
|
## Testing
|
||||||
|
|
||||||
|
Focused tests can be run with uv:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/data/btc-accumulation-monitor
|
||||||
|
PYTHONPATH=. uv run --with pytest --with numpy --with scikit-learn --with pandas \
|
||||||
|
pytest -q tests/test_ml_optimizer_validation.py tests/test_scoring_engine_ml.py
|
||||||
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed documentation of every metric's scoring logic, data pipeline, and backtest methodology.
|
See [ARCHITECTURE.md](ARCHITECTURE.md) for deeper implementation details on scoring, data collection, and backtesting.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+26
-6
@@ -146,9 +146,10 @@ _BT_ML_KEY_MAP = {
|
|||||||
|
|
||||||
|
|
||||||
def score_day(date, index, drawdowns, ml_weights=None):
|
def score_day(date, index, drawdowns, ml_weights=None):
|
||||||
"""Score a single day using all available metrics. Returns (composite_score, individual_scores, n_metrics).
|
"""Score a single day using all available metrics. Returns (composite_score, details, n_metrics).
|
||||||
|
|
||||||
If ml_weights is provided, uses ML-optimized weighting instead of equal weights.
|
If ml_weights is provided, uses ML-optimized weighting instead of equal weights.
|
||||||
|
details includes both "score" and "raw" (the actual metric value before scoring).
|
||||||
"""
|
"""
|
||||||
scores = []
|
scores = []
|
||||||
details = {}
|
details = {}
|
||||||
@@ -160,7 +161,7 @@ def score_day(date, index, drawdowns, ml_weights=None):
|
|||||||
s = _score_range(val, cfg["ranges"])
|
s = _score_range(val, cfg["ranges"])
|
||||||
if s is not None:
|
if s is not None:
|
||||||
scores.append(s)
|
scores.append(s)
|
||||||
details[metric_key] = {"value": val, "score": s}
|
details[metric_key] = {"value": val, "score": s, "raw": val}
|
||||||
|
|
||||||
# Ratio-based metrics (price vs reference)
|
# Ratio-based metrics (price vs reference)
|
||||||
for metric_key, cfg in RATIO_SCORERS.items():
|
for metric_key, cfg in RATIO_SCORERS.items():
|
||||||
@@ -177,7 +178,7 @@ def score_day(date, index, drawdowns, ml_weights=None):
|
|||||||
s = _score_range(pct_above, cfg["ranges"])
|
s = _score_range(pct_above, cfg["ranges"])
|
||||||
if s is not None:
|
if s is not None:
|
||||||
scores.append(s)
|
scores.append(s)
|
||||||
details[metric_key] = {"value": pct_above, "score": s}
|
details[metric_key] = {"value": pct_above, "score": s, "raw": pct_above}
|
||||||
|
|
||||||
# Drawdown
|
# Drawdown
|
||||||
dd = drawdowns.get(date)
|
dd = drawdowns.get(date)
|
||||||
@@ -185,7 +186,7 @@ def score_day(date, index, drawdowns, ml_weights=None):
|
|||||||
s = _score_range(dd, DRAWDOWN_RANGES)
|
s = _score_range(dd, DRAWDOWN_RANGES)
|
||||||
if s is not None:
|
if s is not None:
|
||||||
scores.append(s)
|
scores.append(s)
|
||||||
details["drawdown"] = {"value": dd, "score": s}
|
details["drawdown"] = {"value": dd, "score": s, "raw": dd}
|
||||||
|
|
||||||
if not scores:
|
if not scores:
|
||||||
return None, details, 0
|
return None, details, 0
|
||||||
@@ -297,12 +298,19 @@ def run_backtest(ml_mode=False):
|
|||||||
composite, details, n_metrics = score_day(d, index, drawdowns, ml_weights=ml_weights)
|
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)
|
||||||
|
# Collect raw metric values for per-metric historical exploration
|
||||||
|
metric_values = {}
|
||||||
|
for mk, info in details.items():
|
||||||
|
raw = info.get("raw")
|
||||||
|
if raw is not None:
|
||||||
|
metric_values[mk] = round(raw, 6) if isinstance(raw, float) else raw
|
||||||
entry = {
|
entry = {
|
||||||
"date": d,
|
"date": d,
|
||||||
"score": composite,
|
"score": composite,
|
||||||
"n_metrics": n_metrics,
|
"n_metrics": n_metrics,
|
||||||
"price": price,
|
"price": price,
|
||||||
"forward_returns": fwd_returns.get(d, {}),
|
"forward_returns": fwd_returns.get(d, {}),
|
||||||
|
"metric_values": metric_values,
|
||||||
}
|
}
|
||||||
daily_scores.append(entry)
|
daily_scores.append(entry)
|
||||||
|
|
||||||
@@ -462,6 +470,7 @@ def run_backtest(ml_mode=False):
|
|||||||
|
|
||||||
# --- Build time series for charting ---
|
# --- Build time series for charting ---
|
||||||
# Smart downsampling: daily for last 2 years, weekly before that
|
# Smart downsampling: daily for last 2 years, weekly before that
|
||||||
|
# Include per-metric values so the frontend can plot any metric.
|
||||||
chart_data = []
|
chart_data = []
|
||||||
import datetime as _dt
|
import datetime as _dt
|
||||||
try:
|
try:
|
||||||
@@ -469,14 +478,25 @@ def run_backtest(ml_mode=False):
|
|||||||
cutoff_date = (last_date - _dt.timedelta(days=730)).strftime("%Y-%m-%d")
|
cutoff_date = (last_date - _dt.timedelta(days=730)).strftime("%Y-%m-%d")
|
||||||
except Exception:
|
except Exception:
|
||||||
cutoff_date = "2024-01-01"
|
cutoff_date = "2024-01-01"
|
||||||
|
|
||||||
|
# Collect all metric keys that were ever scored (for per-metric series)
|
||||||
|
all_metric_keys = set()
|
||||||
|
for d in daily_scores:
|
||||||
|
all_metric_keys.update(d.get("metric_values", {}).keys())
|
||||||
|
|
||||||
for i, d in enumerate(daily_scores):
|
for i, d in enumerate(daily_scores):
|
||||||
is_recent = d["date"] >= cutoff_date
|
is_recent = d["date"] >= cutoff_date
|
||||||
if is_recent or 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({
|
entry = {
|
||||||
"date": d["date"],
|
"date": d["date"],
|
||||||
"score": d["score"],
|
"score": d["score"],
|
||||||
"price": d["price"],
|
"price": d["price"],
|
||||||
})
|
}
|
||||||
|
# Include per-metric values (raw metric value, not score)
|
||||||
|
metric_vals = d.get("metric_values", {})
|
||||||
|
if metric_vals:
|
||||||
|
entry["metrics"] = metric_vals
|
||||||
|
chart_data.append(entry)
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"date_range": {"start": daily_scores[0]["date"], "end": daily_scores[-1]["date"]},
|
"date_range": {"start": daily_scores[0]["date"], "end": daily_scores[-1]["date"]},
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"provider": "openrouter",
|
"provider": "ollama",
|
||||||
"model": "minimax/minimax-m2.5",
|
"model": "gemma4:12b-mlx",
|
||||||
"providers": {
|
"providers": {
|
||||||
"ollama": {
|
"ollama": {
|
||||||
"base_url": "http://100.100.242.21:11434"
|
"base_url": "http://100.79.255.5:11434"
|
||||||
},
|
},
|
||||||
"lmstudio": {
|
"lmstudio": {
|
||||||
"base_url": "http://100.100.242.21:1234"
|
"base_url": "http://100.100.242.21:1234"
|
||||||
|
|||||||
+523
-29
@@ -165,7 +165,9 @@ def run_scrape(force_full=False):
|
|||||||
# 3. On-chain metrics — use cached values (historical data is permanent)
|
# 3. On-chain metrics — use cached values (historical data is permanent)
|
||||||
onchain_keys = ["puell_multiple", "mvrv_zscore", "reserve_risk", "rhodl_ratio",
|
onchain_keys = ["puell_multiple", "mvrv_zscore", "reserve_risk", "rhodl_ratio",
|
||||||
"nupl", "200w_sma", "lth_realized_price", "hash_ribbons",
|
"nupl", "200w_sma", "lth_realized_price", "hash_ribbons",
|
||||||
"pi_cycle_bottom", "lth_supply"]
|
"pi_cycle_bottom", "lth_supply", "sopr", "sellside_risk",
|
||||||
|
"active_address_momentum", "txcount_momentum", "nvt_price",
|
||||||
|
"vdd_multiple"]
|
||||||
|
|
||||||
has_cached_onchain = any(existing_cache.get(k, {}).get("value") is not None for k in onchain_keys)
|
has_cached_onchain = any(existing_cache.get(k, {}).get("value") is not None for k in onchain_keys)
|
||||||
|
|
||||||
@@ -176,6 +178,12 @@ def run_scrape(force_full=False):
|
|||||||
from scrapers import lookintobitcoin
|
from scrapers import lookintobitcoin
|
||||||
onchain = lookintobitcoin.scrape_all()
|
onchain = lookintobitcoin.scrape_all()
|
||||||
metrics.update(onchain)
|
metrics.update(onchain)
|
||||||
|
try:
|
||||||
|
from scrapers import checkonchain
|
||||||
|
metrics.update(checkonchain.scrape_all())
|
||||||
|
except Exception as e:
|
||||||
|
log.error("CheckOnChain scraping failed: %s\n%s", e, traceback.format_exc())
|
||||||
|
_last_error = f"CheckOnChain scraping failed: {e}"
|
||||||
metrics["_onchain_timestamp"] = datetime.now(timezone.utc).isoformat()
|
metrics["_onchain_timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("LookIntoBitcoin scraping failed: %s\n%s", e, traceback.format_exc())
|
log.error("LookIntoBitcoin scraping failed: %s\n%s", e, traceback.format_exc())
|
||||||
@@ -341,6 +349,46 @@ def _fetch_models(provider, providers):
|
|||||||
|
|
||||||
# ── API Routes ────────────────────────────────────────────────────────────
|
# ── API Routes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _with_informational_onchain_metrics(scored, cache):
|
||||||
|
"""Add non-scored on-chain data cards without changing composite scoring."""
|
||||||
|
if not isinstance(scored, dict):
|
||||||
|
return scored
|
||||||
|
|
||||||
|
enriched = dict(scored)
|
||||||
|
metrics = [dict(m) for m in scored.get("metrics", [])]
|
||||||
|
existing_keys = {m.get("key") for m in metrics}
|
||||||
|
|
||||||
|
lth_supply = cache.get("lth_supply", {})
|
||||||
|
lth_value = lth_supply.get("value")
|
||||||
|
if lth_value is not None and "lth_supply" not in existing_keys:
|
||||||
|
trend = lth_supply.get("trend")
|
||||||
|
trend_text = f" — {trend}" if trend else ""
|
||||||
|
metrics.append({
|
||||||
|
"name": "Long-Term Holder Supply",
|
||||||
|
"key": "lth_supply",
|
||||||
|
"value": lth_value,
|
||||||
|
"display_value": f"{lth_value:,.0f} BTC",
|
||||||
|
"score": None,
|
||||||
|
"description": "Informational on-chain metric; not included in the composite score" + trend_text,
|
||||||
|
"recent": lth_supply.get("recent", []),
|
||||||
|
})
|
||||||
|
|
||||||
|
pi_cycle = cache.get("pi_cycle_bottom", {})
|
||||||
|
pi_value = pi_cycle.get("value")
|
||||||
|
if pi_value is not None and "pi_cycle_bottom" not in existing_keys:
|
||||||
|
metrics.append({
|
||||||
|
"name": "Pi Cycle Bottom",
|
||||||
|
"key": "pi_cycle_bottom",
|
||||||
|
"value": pi_value,
|
||||||
|
"display_value": f"{pi_value:,.2f}" if isinstance(pi_value, (int, float)) else str(pi_value),
|
||||||
|
"score": None,
|
||||||
|
"description": "Informational on-chain cycle metric; not included in the composite score",
|
||||||
|
"recent": pi_cycle.get("recent", []),
|
||||||
|
})
|
||||||
|
|
||||||
|
enriched["metrics"] = metrics
|
||||||
|
return enriched
|
||||||
|
|
||||||
@app.get("/api/data")
|
@app.get("/api/data")
|
||||||
def api_data(mode: str = "classic"):
|
def api_data(mode: str = "classic"):
|
||||||
"""Return current cached metrics + scores.
|
"""Return current cached metrics + scores.
|
||||||
@@ -351,6 +399,7 @@ def api_data(mode: str = "classic"):
|
|||||||
scored = cache.get("_scored_ml", cache.get("_scored", {}))
|
scored = cache.get("_scored_ml", cache.get("_scored", {}))
|
||||||
else:
|
else:
|
||||||
scored = cache.get("_scored", {})
|
scored = cache.get("_scored", {})
|
||||||
|
scored = _with_informational_onchain_metrics(scored, cache)
|
||||||
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", {})
|
||||||
@@ -503,8 +552,17 @@ DASHBOARD_HTML = """<!DOCTYPE html>
|
|||||||
.meta-row{display:flex;gap:16px;flex-wrap:wrap;margin-top:8px;font-size:.8rem;color:var(--text-dim)}
|
.meta-row{display:flex;gap:16px;flex-wrap:wrap;margin-top:8px;font-size:.8rem;color:var(--text-dim)}
|
||||||
.meta-row span{display:flex;align-items:center;gap:4px}
|
.meta-row span{display:flex;align-items:center;gap:4px}
|
||||||
.metrics-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px;margin-bottom:20px}
|
.metrics-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px;margin-bottom:20px}
|
||||||
.metric-card{background:var(--card);border-radius:10px;padding:14px;border:1px solid var(--border);transition:border-color .15s}
|
.metric-card{background:var(--card);border-radius:10px;padding:14px;border:1px solid var(--border);transition:border-color .15s;cursor:pointer}
|
||||||
.metric-card:hover{border-color:var(--text-dim)}
|
.metric-card:hover{border-color:var(--text-dim)}
|
||||||
|
.metric-card.selected{border-color:#a78bfa;box-shadow:0 0 0 1px #a78bfa,0 0 12px rgba(167,139,250,0.15)}
|
||||||
|
.metric-click-hint{font-size:.6rem;margin-left:4px;opacity:0;transition:opacity .15s}
|
||||||
|
.metric-card:hover .metric-click-hint{opacity:.5}
|
||||||
|
.metric-card.selected .metric-click-hint{opacity:1}
|
||||||
|
.mc-examples-title{font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
|
||||||
|
.mc-example{font-size:.8rem;font-family:var(--mono);padding:4px 0;border-bottom:1px solid rgba(255,255,255,0.03)}
|
||||||
|
.mc-ex-date{color:#e2e8f0}
|
||||||
|
.mc-ex-cycle{color:#a78bfa;font-size:.7rem}
|
||||||
|
.mc-ex-price{color:#94a3b8}
|
||||||
.metric-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px}
|
.metric-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px}
|
||||||
.metric-name{font-size:.85rem;font-weight:600}
|
.metric-name{font-size:.85rem;font-weight:600}
|
||||||
.metric-score{display:flex;align-items:center;gap:6px}
|
.metric-score{display:flex;align-items:center;gap:6px}
|
||||||
@@ -588,6 +646,20 @@ DASHBOARD_HTML = """<!DOCTYPE html>
|
|||||||
<a href="/backtest" style="font-size:.8rem;color:#22d3ee;text-decoration:none;margin-top:8px;display:inline-block">View full backtest →</a>
|
<a href="/backtest" style="font-size:.8rem;color:#22d3ee;text-decoration:none;margin-top:8px;display:inline-block">View full backtest →</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Metric Context Panel (shown when a metric is selected) -->
|
||||||
|
<div class="card" id="metricContextPanel" style="margin-bottom:20px;display:none;border-color:#a78bfa">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||||
|
<h2 style="color:#a78bfa" id="mcTitle">Metric Context</h2>
|
||||||
|
<button onclick="if(selectedMetric) selectMetric(selectedMetric, '')" style="background:none;border:1px solid #a78bfa;color:#a78bfa;padding:4px 10px;border-radius:4px;cursor:pointer;font-family:var(--mono);font-size:.75rem">✕ Clear</button>
|
||||||
|
</div>
|
||||||
|
<div id="mcCurrent" style="font-size:1rem;font-family:var(--mono);margin-bottom:4px"></div>
|
||||||
|
<div id="mcPercentile" style="font-size:.8rem;color:#94a3b8;font-family:var(--mono);margin-bottom:4px"></div>
|
||||||
|
<div id="mcComparable" style="font-size:.8rem;color:#94a3b8;font-family:var(--mono);margin-bottom:8px"></div>
|
||||||
|
<div id="mcReturns" style="font-size:.9rem;font-family:var(--mono);line-height:1.6;margin-bottom:8px"></div>
|
||||||
|
<div id="mcExamples" style="display:none"></div>
|
||||||
|
<a href="/backtest" style="font-size:.8rem;color:#a78bfa;text-decoration:none;margin-top:8px;display:inline-block">View full backtest →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Metrics Grid -->
|
<!-- Metrics Grid -->
|
||||||
<h2>On-Chain Metrics</h2>
|
<h2>On-Chain Metrics</h2>
|
||||||
<div class="metrics-grid" id="metricsGrid">
|
<div class="metrics-grid" id="metricsGrid">
|
||||||
@@ -690,6 +762,8 @@ function drawSparkline(canvasId, data, color) {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let selectedMetric = null;
|
||||||
|
|
||||||
function renderMetrics(metrics) {
|
function renderMetrics(metrics) {
|
||||||
const grid = document.getElementById('metricsGrid');
|
const grid = document.getElementById('metricsGrid');
|
||||||
if (!metrics || !metrics.length) {
|
if (!metrics || !metrics.length) {
|
||||||
@@ -703,10 +777,11 @@ function renderMetrics(metrics) {
|
|||||||
const color = m.score != null ? scoreColor(m.score, 10) : '#64748b';
|
const color = m.score != null ? scoreColor(m.score, 10) : '#64748b';
|
||||||
const fillPct = m.score != null ? (m.score / 10 * 100) : 0;
|
const fillPct = m.score != null ? (m.score / 10 * 100) : 0;
|
||||||
const hasSparkline = m.recent && m.recent.length > 2;
|
const hasSparkline = m.recent && m.recent.length > 2;
|
||||||
|
const isSelected = selectedMetric === m.key ? ' selected' : '';
|
||||||
|
|
||||||
html += '<div class="metric-card">';
|
html += '<div class="metric-card' + isSelected + '" data-key="' + m.key + '" data-name="' + m.name.replace('"', '"') + '">';
|
||||||
html += '<div class="metric-header">';
|
html += '<div class="metric-header">';
|
||||||
html += '<div class="metric-name">' + m.name + '</div>';
|
html += '<div class="metric-name">' + m.name + '<span class="metric-click-hint">👆</span></div>';
|
||||||
html += '<div class="metric-score">';
|
html += '<div class="metric-score">';
|
||||||
html += '<div class="metric-score-bar"><div class="metric-score-fill" style="width:' + fillPct + '%;background:' + color + '"></div></div>';
|
html += '<div class="metric-score-bar"><div class="metric-score-fill" style="width:' + fillPct + '%;background:' + color + '"></div></div>';
|
||||||
html += '<div class="metric-score-num" style="color:' + color + '">' + score + '</div>';
|
html += '<div class="metric-score-num" style="color:' + color + '">' + score + '</div>';
|
||||||
@@ -733,11 +808,112 @@ function renderMetrics(metrics) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Attach click handlers to metric cards
|
||||||
|
document.querySelectorAll('.metric-card').forEach(card => {
|
||||||
|
card.addEventListener('click', function() {
|
||||||
|
const key = this.getAttribute('data-key');
|
||||||
|
const name = this.getAttribute('data-name');
|
||||||
|
if (key) selectMetric(key, name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metric selection + context panel
|
||||||
|
function selectMetric(metricKey, metricName) {
|
||||||
|
if (selectedMetric === metricKey) {
|
||||||
|
// Deselect if clicking the same one
|
||||||
|
selectedMetric = null;
|
||||||
|
window._highlightMetric = null;
|
||||||
|
document.getElementById('metricContextPanel').style.display = 'none';
|
||||||
|
const panel = document.getElementById('histContext');
|
||||||
|
if (panel) panel.style.display = 'block';
|
||||||
|
applyChartRange(currentRange); // Re-render chart without highlight
|
||||||
|
} else {
|
||||||
|
selectedMetric = metricKey;
|
||||||
|
loadMetricContext(metricKey, metricName);
|
||||||
|
}
|
||||||
|
poll(); // Re-render metric cards with highlight
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMetricContext(metricKey, metricName) {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/metric-context?metric=' + encodeURIComponent(metricKey) + '&mode=' + currentMode);
|
||||||
|
const ctx = await r.json();
|
||||||
|
if (ctx.error) {
|
||||||
|
showToast(ctx.error, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show metric context panel, hide composite context
|
||||||
|
const panel = document.getElementById('histContext');
|
||||||
|
if (panel) panel.style.display = 'none';
|
||||||
|
|
||||||
|
const mcp = document.getElementById('metricContextPanel');
|
||||||
|
mcp.style.display = 'block';
|
||||||
|
|
||||||
|
document.getElementById('mcTitle').textContent = metricName;
|
||||||
|
document.getElementById('mcCurrent').textContent = 'Current: ' + (ctx.current_raw != null ? ctx.current_raw : 'N/A');
|
||||||
|
document.getElementById('mcPercentile').textContent = 'Metric value in top ' + (100 - ctx.percentile).toFixed(1) + '% historically';
|
||||||
|
document.getElementById('mcComparable').textContent = ctx.comparable_days + ' comparable days found';
|
||||||
|
|
||||||
|
const fmtR = (v) => v == null ? '--' : (v >= 0 ? '+' : '') + v.toFixed(1) + '%';
|
||||||
|
const cR = v => v != null && v >= 0 ? '#22c55e' : '#ef4444';
|
||||||
|
const periods = [['30d', ctx.avg_30d_return], ['90d', ctx.avg_90d_return], ['180d', ctx.avg_180d_return], ['1yr', ctx.avg_1yr_return]];
|
||||||
|
let retHtml = '';
|
||||||
|
for (const [label, val] of periods) {
|
||||||
|
if (val != null) retHtml += '<strong style="color:' + cR(val) + '">' + label + ': ' + fmtR(val) + '</strong> · ';
|
||||||
|
}
|
||||||
|
document.getElementById('mcReturns').innerHTML = retHtml ? 'Avg returns when ' + metricName + ' was similar: ' + retHtml : 'No forward return data available';
|
||||||
|
|
||||||
|
// Examples
|
||||||
|
const exEl = document.getElementById('mcExamples');
|
||||||
|
if (ctx.examples && ctx.examples.length) {
|
||||||
|
let exHtml = '<div class="mc-examples-title">Historical examples:</div>';
|
||||||
|
ctx.examples.forEach(ex => {
|
||||||
|
const fwd30 = ex.forward_returns['30d'];
|
||||||
|
const fwd365 = ex.forward_returns['365d'];
|
||||||
|
exHtml += '<div class="mc-example">';
|
||||||
|
exHtml += '<span class="mc-ex-date">' + ex.date + '</span> ';
|
||||||
|
exHtml += '<span class="mc-ex-cycle">' + ex.cycle + '</span> ';
|
||||||
|
exHtml += '<span class="mc-ex-price">$' + (ex.price ? ex.price.toLocaleString() : 'N/A') + '</span>';
|
||||||
|
if (fwd30 != null) exHtml += ' <span style="color:' + cR(fwd30) + '">30d: ' + fmtR(fwd30) + '</span>';
|
||||||
|
if (fwd365 != null) exHtml += ' <span style="color:' + cR(fwd365) + '">1yr: ' + fmtR(fwd365) + '</span>';
|
||||||
|
exHtml += '</div>';
|
||||||
|
});
|
||||||
|
exEl.innerHTML = exHtml;
|
||||||
|
exEl.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
exEl.innerHTML = '';
|
||||||
|
exEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight matching periods on the chart
|
||||||
|
highlightMetricPeriods(metricKey, ctx.current_raw, ctx.margin);
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Metric context load failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightMetricPeriods(metricKey, currentRaw, margin) {
|
||||||
|
if (!fullDailyScores || !currentRaw || margin == null) return;
|
||||||
|
|
||||||
|
// Build an array of {date, rawValue} for the selected metric
|
||||||
|
const metricSeries = fullDailyScores
|
||||||
|
.filter(d => d.metrics && d.metrics[metricKey] != null)
|
||||||
|
.map(d => ({ date: d.date, value: d.metrics[metricKey], isSimilar: Math.abs(d.metrics[metricKey] - currentRaw) <= margin }));
|
||||||
|
|
||||||
|
// Store for use in chart rendering
|
||||||
|
window._highlightMetric = { key: metricKey, series: metricSeries, currentRaw, margin };
|
||||||
|
|
||||||
|
// Re-render chart with highlight
|
||||||
|
applyChartRange(currentRange);
|
||||||
}
|
}
|
||||||
|
|
||||||
let histChart = null;
|
let histChart = null;
|
||||||
let fullDailyScores = null;
|
let fullDailyScores = null;
|
||||||
let currentRange = 0; // 0 = ALL
|
let currentRange = 0; // 0 = ALL
|
||||||
|
let currentMode = 'classic';
|
||||||
|
|
||||||
function renderHistory(history) {
|
function renderHistory(history) {
|
||||||
// Legacy: still called by loadData but we'll use backtest data instead
|
// Legacy: still called by loadData but we'll use backtest data instead
|
||||||
@@ -784,7 +960,61 @@ function renderHistoryFromData(history) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accumulation zone backgrounds
|
// If a metric is selected, add its overlay + highlight similar periods
|
||||||
|
const highlight = window._highlightMetric;
|
||||||
|
let metricColor = '#a78bfa';
|
||||||
|
if (highlight && highlight.series && highlight.series.length) {
|
||||||
|
// Build a sparse array aligned to current chart labels
|
||||||
|
const metricByDate = {};
|
||||||
|
highlight.series.forEach(s => { metricByDate[s.date] = s; });
|
||||||
|
const metricData = labels.map(l => {
|
||||||
|
const entry = metricByDate[l];
|
||||||
|
return entry ? entry.value : null;
|
||||||
|
});
|
||||||
|
const hasMetricData = metricData.some(v => v != null);
|
||||||
|
|
||||||
|
if (hasMetricData) {
|
||||||
|
datasets.push({
|
||||||
|
label: 'Selected Metric',
|
||||||
|
data: metricData,
|
||||||
|
borderColor: metricColor,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderDash: [2, 2],
|
||||||
|
fill: false,
|
||||||
|
tension: 0.2,
|
||||||
|
pointRadius: 0,
|
||||||
|
yAxisID: 'y2',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight similar periods with point dots on the score line
|
||||||
|
const similarIndices = [];
|
||||||
|
labels.forEach((l, i) => {
|
||||||
|
const entry = metricByDate[l];
|
||||||
|
if (entry && entry.isSimilar) similarIndices.push(i);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (similarIndices.length) {
|
||||||
|
const highlightData = labels.map((l, i) =>
|
||||||
|
similarIndices.includes(i) ? scores[i] : null
|
||||||
|
);
|
||||||
|
datasets.push({
|
||||||
|
label: 'Similar Periods',
|
||||||
|
data: highlightData,
|
||||||
|
borderColor: 'rgba(167,139,250,0)',
|
||||||
|
backgroundColor: '#a78bfa',
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
showLine: false,
|
||||||
|
yAxisID: 'y',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine y2 scale for the metric overlay
|
||||||
|
const hasMetricDataset = datasets.some(d => d.yAxisID === 'y2');
|
||||||
|
|
||||||
|
// Accumulation zone backgrounds + metric highlight bands
|
||||||
const zonePlugin = {
|
const zonePlugin = {
|
||||||
id: 'zones',
|
id: 'zones',
|
||||||
beforeDraw(chart) {
|
beforeDraw(chart) {
|
||||||
@@ -812,9 +1042,55 @@ function renderHistoryFromData(history) {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Draw vertical highlight bands for similar periods
|
||||||
|
if (highlight && highlight.series) {
|
||||||
|
const metricByDate = {};
|
||||||
|
highlight.series.forEach(s => { metricByDate[s.date] = s; });
|
||||||
|
const xScale = chart.scales.x;
|
||||||
|
labels.forEach((l, i) => {
|
||||||
|
const entry = metricByDate[l];
|
||||||
|
if (entry && entry.isSimilar) {
|
||||||
|
const x = xScale.getPixelForValue(i);
|
||||||
|
ctx.fillStyle = 'rgba(167,139,250,0.08)';
|
||||||
|
ctx.fillRect(x - 3, top, 6, bottom - top);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scales = {
|
||||||
|
x: {
|
||||||
|
ticks: { color: '#64748b', maxTicksLimit: 12, font: { family: 'monospace', size: 10 } },
|
||||||
|
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 } }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasMetricDataset) {
|
||||||
|
scales['y2'] = {
|
||||||
|
position: 'right',
|
||||||
|
display: false,
|
||||||
|
grid: { drawOnChartArea: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
histChart = new Chart(ctx, {
|
histChart = new Chart(ctx, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
plugins: [zonePlugin],
|
plugins: [zonePlugin],
|
||||||
@@ -834,6 +1110,8 @@ function renderHistoryFromData(history) {
|
|||||||
callbacks: {
|
callbacks: {
|
||||||
label: function(ctx) {
|
label: function(ctx) {
|
||||||
if (ctx.dataset.yAxisID === 'y1') return 'BTC: $' + ctx.raw.toLocaleString();
|
if (ctx.dataset.yAxisID === 'y1') return 'BTC: $' + ctx.raw.toLocaleString();
|
||||||
|
if (ctx.dataset.yAxisID === 'y2') return 'Metric: ' + (ctx.raw != null ? ctx.raw.toFixed(4) : 'N/A');
|
||||||
|
if (ctx.dataset.label === 'Similar Periods') return '★ Similar period (Score: ' + ctx.raw.toFixed(1) + ')';
|
||||||
const s = ctx.raw;
|
const s = ctx.raw;
|
||||||
let zone = s >= 80 ? 'Extreme Accum' : s >= 65 ? 'Strong Accum' : s >= 50 ? 'Moderate' : s >= 35 ? 'Neutral' : 'Caution';
|
let zone = s >= 80 ? 'Extreme Accum' : s >= 65 ? 'Strong Accum' : s >= 50 ? 'Moderate' : s >= 35 ? 'Neutral' : 'Caution';
|
||||||
return 'Score: ' + s.toFixed(1) + ' (' + zone + ')';
|
return 'Score: ' + s.toFixed(1) + ' (' + zone + ')';
|
||||||
@@ -841,28 +1119,7 @@ function renderHistoryFromData(history) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
scales,
|
||||||
x: {
|
|
||||||
ticks: { color: '#64748b', maxTicksLimit: 12, font: { family: 'monospace', size: 10 } },
|
|
||||||
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 } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -992,8 +1249,6 @@ async function doRefresh(full) {
|
|||||||
setTimeout(() => { btn.disabled = false; btn.textContent = origText; }, delay);
|
setTimeout(() => { btn.disabled = false; btn.textContent = origText; }, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentMode = 'classic';
|
|
||||||
|
|
||||||
function setMode(mode) {
|
function setMode(mode) {
|
||||||
currentMode = mode;
|
currentMode = mode;
|
||||||
document.querySelectorAll('.mode-btn').forEach(b => {
|
document.querySelectorAll('.mode-btn').forEach(b => {
|
||||||
@@ -1286,6 +1541,245 @@ def api_backtest_status():
|
|||||||
return status
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/metric-context")
|
||||||
|
def api_metric_context(metric: str, margin: float = 0.0, mode: str = "classic"):
|
||||||
|
"""Find historical periods where a specific metric was at a similar level.
|
||||||
|
|
||||||
|
Returns forward returns for those periods, analogous to the composite-score
|
||||||
|
current_context but filtered to a single metric's historical similarity.
|
||||||
|
|
||||||
|
margin: absolute tolerance for "similar" (auto-computed from metric scale if 0).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from backtesting.engine import run_backtest, HISTORY_PATH, _build_daily_index, _get_all_dates, _last_known_value, METRIC_SCORERS, RATIO_SCORERS, DRAWDOWN_RANGES, _score_range
|
||||||
|
import os as _os
|
||||||
|
|
||||||
|
if not _os.path.exists(HISTORY_PATH):
|
||||||
|
return JSONResponse({"error": "No historical data. Run history collector first."}, status_code=404)
|
||||||
|
|
||||||
|
with open(HISTORY_PATH) as f:
|
||||||
|
history = json.load(f)
|
||||||
|
|
||||||
|
index = _build_daily_index(history)
|
||||||
|
all_dates = _get_all_dates(index)
|
||||||
|
|
||||||
|
# Get current metric value from cache
|
||||||
|
cache = {}
|
||||||
|
if _os.path.exists(CACHE_PATH):
|
||||||
|
with open(CACHE_PATH) as f:
|
||||||
|
cache = json.load(f)
|
||||||
|
|
||||||
|
current_raw = _get_current_metric_raw(metric, cache)
|
||||||
|
if current_raw is None:
|
||||||
|
return JSONResponse({"error": f"No current value for metric '{metric}'"}, status_code=404)
|
||||||
|
|
||||||
|
# Auto-compute margin from metric scale
|
||||||
|
if margin <= 0:
|
||||||
|
margin = _auto_metric_margin(metric, current_raw)
|
||||||
|
|
||||||
|
# Build price lookup
|
||||||
|
price_lookup = {}
|
||||||
|
for pk in ["btc_price_coingecko", "btc_price", "btc_price_sma", "btc_price_lth"]:
|
||||||
|
if pk in index:
|
||||||
|
for d, v in index[pk].items():
|
||||||
|
if d not in price_lookup:
|
||||||
|
price_lookup[d] = v
|
||||||
|
|
||||||
|
# Find historical days where this metric was similar
|
||||||
|
comparable = []
|
||||||
|
for d in all_dates:
|
||||||
|
raw_val = _get_historical_metric_raw(metric, index, d)
|
||||||
|
if raw_val is not None and abs(raw_val - current_raw) <= margin:
|
||||||
|
price = price_lookup.get(d)
|
||||||
|
fwd = _compute_day_forward_returns(price_lookup, d)
|
||||||
|
if fwd:
|
||||||
|
comparable.append({
|
||||||
|
"date": d,
|
||||||
|
"raw_value": round(raw_val, 6) if isinstance(raw_val, float) else raw_val,
|
||||||
|
"price": price,
|
||||||
|
"forward_returns": fwd,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Compute average returns across comparable periods
|
||||||
|
avg_returns = {}
|
||||||
|
for period in ["30d", "90d", "180d", "365d"]:
|
||||||
|
vals = [c["forward_returns"][period] for c in comparable if period in c["forward_returns"]]
|
||||||
|
if vals:
|
||||||
|
avg_returns[period] = round(sum(vals) / len(vals), 2)
|
||||||
|
|
||||||
|
# Pick best examples (one per market cycle)
|
||||||
|
cycle_bins = [
|
||||||
|
("pre-2016", "2010-01-01", "2015-12-31"),
|
||||||
|
("2016-17 Bull", "2016-01-01", "2017-12-31"),
|
||||||
|
("2018-19 Bear", "2018-01-01", "2019-12-31"),
|
||||||
|
("2020-21 Bull", "2020-01-01", "2021-12-31"),
|
||||||
|
("2022-23 Bear", "2022-01-01", "2023-12-31"),
|
||||||
|
("2024+", "2024-01-01", "2099-12-31"),
|
||||||
|
]
|
||||||
|
examples = []
|
||||||
|
used_cycles = set()
|
||||||
|
sorted_comp = sorted(comparable, key=lambda c: abs(c["raw_value"] - current_raw))
|
||||||
|
for c in sorted_comp:
|
||||||
|
for label, start, end in cycle_bins:
|
||||||
|
if start <= c["date"] <= end and label not in used_cycles:
|
||||||
|
used_cycles.add(label)
|
||||||
|
examples.append({
|
||||||
|
"date": c["date"],
|
||||||
|
"raw_value": c["raw_value"],
|
||||||
|
"price": c["price"],
|
||||||
|
"forward_returns": c["forward_returns"],
|
||||||
|
"cycle": label,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
if len(examples) >= 6:
|
||||||
|
break
|
||||||
|
examples.sort(key=lambda e: e["date"])
|
||||||
|
|
||||||
|
# Percentile: what % of all days had this metric at or below current value
|
||||||
|
all_raw_vals = []
|
||||||
|
for d in all_dates:
|
||||||
|
rv = _get_historical_metric_raw(metric, index, d)
|
||||||
|
if rv is not None:
|
||||||
|
all_raw_vals.append(rv)
|
||||||
|
all_raw_vals.sort()
|
||||||
|
below = len([v for v in all_raw_vals if v <= current_raw])
|
||||||
|
percentile = round(below / len(all_raw_vals) * 100, 1) if all_raw_vals else 50.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"metric": metric,
|
||||||
|
"current_raw": current_raw,
|
||||||
|
"margin": margin,
|
||||||
|
"comparable_days": len(comparable),
|
||||||
|
"percentile": percentile,
|
||||||
|
"avg_30d_return": avg_returns.get("30d"),
|
||||||
|
"avg_90d_return": avg_returns.get("90d"),
|
||||||
|
"avg_180d_return": avg_returns.get("180d"),
|
||||||
|
"avg_1yr_return": avg_returns.get("365d"),
|
||||||
|
"examples": examples,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Metric context error: %s", traceback.format_exc())
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_current_metric_raw(metric, cache):
|
||||||
|
"""Get the current raw value for a metric from the cache."""
|
||||||
|
# Direct cache keys
|
||||||
|
direct_keys = {
|
||||||
|
"fear_greed": ("fear_greed", "value"),
|
||||||
|
"puell_multiple": ("puell_multiple", "value"),
|
||||||
|
"mvrv_zscore": ("mvrv_zscore", "value"),
|
||||||
|
"reserve_risk": ("reserve_risk", "value"),
|
||||||
|
"rhodl_ratio": ("rhodl_ratio", "value"),
|
||||||
|
"nupl": ("nupl", "value"),
|
||||||
|
"drawdown": ("drawdown", "value"),
|
||||||
|
"hash_ribbons": ("hash_ribbons", "value"),
|
||||||
|
"sopr": ("sopr", "value"),
|
||||||
|
"sellside_risk": ("sellside_risk", "value"),
|
||||||
|
"active_address_momentum": ("active_address_momentum", "value"),
|
||||||
|
"txcount_momentum": ("txcount_momentum", "value"),
|
||||||
|
"nvt_price": ("nvt_price", "value"),
|
||||||
|
"vdd_multiple": ("vdd_multiple", "value"),
|
||||||
|
"lth_supply": ("lth_supply", "value"),
|
||||||
|
}
|
||||||
|
# Ratio-based metrics: compute from price vs reference
|
||||||
|
ratio_metrics = {
|
||||||
|
"price_vs_200w_sma": ("price", "200w_sma"),
|
||||||
|
"lth_realized_price": ("price", "lth_realized_price"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if metric in direct_keys:
|
||||||
|
k, sub = direct_keys[metric]
|
||||||
|
val = cache.get(k, {})
|
||||||
|
if isinstance(val, dict):
|
||||||
|
return val.get(sub)
|
||||||
|
return val
|
||||||
|
elif metric in ratio_metrics:
|
||||||
|
price_key, ref_key = ratio_metrics[metric]
|
||||||
|
price_val = cache.get(price_key, {}).get("price") or cache.get(price_key, {}).get("value")
|
||||||
|
ref_val = cache.get(ref_key, {}).get("value")
|
||||||
|
if price_val and ref_val and ref_val > 0:
|
||||||
|
return ((price_val - ref_val) / ref_val) * 100
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_historical_metric_raw(metric, index, date):
|
||||||
|
"""Get the raw value for a metric on a specific historical date."""
|
||||||
|
from backtesting.engine import _last_known_value
|
||||||
|
direct_keys = {
|
||||||
|
"fear_greed": "fear_greed",
|
||||||
|
"puell_multiple": "puell_multiple",
|
||||||
|
"mvrv_zscore": "mvrv_zscore",
|
||||||
|
"reserve_risk": "reserve_risk",
|
||||||
|
"rhodl_ratio": "rhodl_ratio",
|
||||||
|
"nupl": "nupl",
|
||||||
|
"drawdown": "drawdown",
|
||||||
|
"hash_ribbons": "hash_ribbons",
|
||||||
|
"sopr": "sopr",
|
||||||
|
"sellside_risk": "sellside_risk",
|
||||||
|
"active_address_momentum": "active_address_momentum",
|
||||||
|
"txcount_momentum": "txcount_momentum",
|
||||||
|
"nvt_price": "nvt_price",
|
||||||
|
"vdd_multiple": "vdd_multiple",
|
||||||
|
"lth_supply": "lth_supply",
|
||||||
|
}
|
||||||
|
if metric in direct_keys:
|
||||||
|
return _last_known_value(index.get(direct_keys[metric], {}), date)
|
||||||
|
# Ratio-based
|
||||||
|
if metric == "price_vs_200w_sma":
|
||||||
|
price_val = _last_known_value(index.get("btc_price", {}), date)
|
||||||
|
ref_val = _last_known_value(index.get("200w_sma", {}), date)
|
||||||
|
if price_val and ref_val and ref_val > 0:
|
||||||
|
return ((price_val - ref_val) / ref_val) * 100
|
||||||
|
if metric == "lth_realized_price":
|
||||||
|
price_val = _last_known_value(index.get("btc_price", {}), date)
|
||||||
|
ref_val = _last_known_value(index.get("lth_realized_price", {}), date)
|
||||||
|
if price_val and ref_val and ref_val > 0:
|
||||||
|
return ((price_val - ref_val) / ref_val) * 100
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_metric_margin(metric, current_val):
|
||||||
|
"""Compute a reasonable similarity margin based on metric type and scale."""
|
||||||
|
margins = {
|
||||||
|
"fear_greed": 5.0,
|
||||||
|
"puell_multiple": 0.15,
|
||||||
|
"mvrv_zscore": 0.5,
|
||||||
|
"reserve_risk": 0.002,
|
||||||
|
"rhodl_ratio": 300,
|
||||||
|
"nupl": 0.1,
|
||||||
|
"drawdown": 8.0,
|
||||||
|
"sopr": 0.02,
|
||||||
|
"sellside_risk": 0.001,
|
||||||
|
"active_address_momentum": 0.05,
|
||||||
|
"txcount_momentum": 0.05,
|
||||||
|
"nvt_price": 5000,
|
||||||
|
"vdd_multiple": 0.15,
|
||||||
|
"price_vs_200w_sma": 10.0,
|
||||||
|
"lth_realized_price": 10.0,
|
||||||
|
}
|
||||||
|
if metric in margins:
|
||||||
|
return margins[metric]
|
||||||
|
# Fallback: 15% of current value
|
||||||
|
return abs(current_val) * 0.15 if current_val != 0 else 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_day_forward_returns(price_lookup, date):
|
||||||
|
"""Compute forward returns for a single date."""
|
||||||
|
from datetime import datetime as _dt, timedelta as _td
|
||||||
|
p0 = price_lookup.get(date)
|
||||||
|
if p0 is None or p0 <= 0:
|
||||||
|
return {}
|
||||||
|
r = {}
|
||||||
|
dt = _dt.strptime(date, "%Y-%m-%d")
|
||||||
|
for days in [30, 90, 180, 365]:
|
||||||
|
future = (dt + _td(days=days)).strftime("%Y-%m-%d")
|
||||||
|
pf = price_lookup.get(future)
|
||||||
|
if pf is not None:
|
||||||
|
r[f"{days}d"] = round(((pf - p0) / p0) * 100, 2)
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
# ── Backtest HTML Page ─────────────────────────────────────────────────
|
# ── Backtest HTML Page ─────────────────────────────────────────────────
|
||||||
|
|
||||||
BACKTEST_HTML = """<!DOCTYPE html>
|
BACKTEST_HTML = """<!DOCTYPE html>
|
||||||
|
|||||||
@@ -110,3 +110,40 @@
|
|||||||
{"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: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: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}}}
|
{"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}}}
|
||||||
|
{"timestamp": "2026-06-27T18:10:22.517545+00:00", "composite_score": 63.3, "scored_count": 3, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": null, "value": null}, "mvrv_zscore": {"score": null, "value": null}, "drawdown": {"score": 8, "value": 52.07407994923858}, "price_vs_200w_sma": {"score": null, "value": null}, "reserve_risk": {"score": null, "value": null}, "rhodl_ratio": {"score": null, "value": null}, "nupl": {"score": null, "value": null}, "lth_realized_price": {"score": null, "value": null}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T18:25:30.189079+00:00", "composite_score": 63.3, "scored_count": 3, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": null, "value": null}, "mvrv_zscore": {"score": null, "value": null}, "drawdown": {"score": 8, "value": 52.098667512690355}, "price_vs_200w_sma": {"score": null, "value": null}, "reserve_risk": {"score": null, "value": null}, "rhodl_ratio": {"score": null, "value": null}, "nupl": {"score": null, "value": null}, "lth_realized_price": {"score": null, "value": null}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T18:26:00.556392+00:00", "composite_score": 63.3, "scored_count": 3, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": null, "value": null}, "mvrv_zscore": {"score": null, "value": null}, "drawdown": {"score": 8, "value": 52.098667512690355}, "price_vs_200w_sma": {"score": null, "value": null}, "reserve_risk": {"score": null, "value": null}, "rhodl_ratio": {"score": null, "value": null}, "nupl": {"score": null, "value": null}, "lth_realized_price": {"score": null, "value": null}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T18:40:30.805472+00:00", "composite_score": 63.3, "scored_count": 3, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": null, "value": null}, "mvrv_zscore": {"score": null, "value": null}, "drawdown": {"score": 8, "value": 52.04473350253808}, "price_vs_200w_sma": {"score": null, "value": null}, "reserve_risk": {"score": null, "value": null}, "rhodl_ratio": {"score": null, "value": null}, "nupl": {"score": null, "value": null}, "lth_realized_price": {"score": null, "value": null}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T18:55:31.544772+00:00", "composite_score": 63.3, "scored_count": 3, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": null, "value": null}, "mvrv_zscore": {"score": null, "value": null}, "drawdown": {"score": 8, "value": 52.066148477157356}, "price_vs_200w_sma": {"score": null, "value": null}, "reserve_risk": {"score": null, "value": null}, "rhodl_ratio": {"score": null, "value": null}, "nupl": {"score": null, "value": null}, "lth_realized_price": {"score": null, "value": null}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T18:57:42.639217+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.04790609137056}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T19:12:44.307793+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.02014593908629}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T19:27:45.019177+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.22874365482234}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T19:42:45.679921+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.35009517766498}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T19:57:46.474768+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.34850888324873}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T20:12:47.228456+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.2604695431472}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T20:27:47.904578+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.27633248730964}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T20:42:48.631663+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.26919416243655}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T20:57:49.365132+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.264435279187815}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T21:12:50.093194+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.202569796954315}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T21:27:50.802146+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.16132614213198}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T21:42:51.460898+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.24857233502538}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T21:57:52.223283+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.318369289340104}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T22:12:52.898551+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.37071700507614}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T22:27:54.711182+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.429409898477154}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-27T22:42:55.510257+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 15}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.31043781725888}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-28T20:31:14.232026+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.70066624365482}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-28T20:35:38.591732+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.73080583756345}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-28T20:42:21.081121+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.721288071065985}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-28T20:57:21.749415+00:00", "composite_score": 74.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.75301395939086}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}}}
|
||||||
|
{"timestamp": "2026-06-28T21:09:38.418891+00:00", "composite_score": 68.1, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.73159898477158}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 4, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}}
|
||||||
|
{"timestamp": "2026-06-28T21:11:19.117377+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.723667512690355}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}}
|
||||||
|
{"timestamp": "2026-06-28T21:26:19.843356+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.723667512690355}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}}
|
||||||
|
{"timestamp": "2026-06-28T21:39:03.697500+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.68797588832488}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}}
|
||||||
|
{"timestamp": "2026-06-28T21:42:37.143098+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.71890862944163}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}}
|
||||||
|
{"timestamp": "2026-06-28T21:57:37.803751+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.96002538071066}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}}
|
||||||
|
{"timestamp": "2026-06-28T22:12:38.557670+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.72049492385786}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}}
|
||||||
|
{"timestamp": "2026-06-28T22:27:39.293792+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.94733502538072}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}}
|
||||||
|
{"timestamp": "2026-06-28T22:42:40.175733+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 53.05678934010152}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}}
|
||||||
|
{"timestamp": "2026-06-28T22:57:40.874889+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 53.22255710659899}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}}
|
||||||
|
{"timestamp": "2026-06-28T23:12:41.665346+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 53.02744289340101}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}}
|
||||||
|
{"timestamp": "2026-06-28T23:27:42.349311+00:00", "composite_score": 69.4, "scored_count": 16, "metrics": {"fear_greed": {"score": 8, "value": 18}, "puell_multiple": {"score": 5, "value": 0.7044739567707577}, "mvrv_zscore": {"score": 8, "value": 0.22409759021503936}, "drawdown": {"score": 8, "value": 52.95923223350254}, "price_vs_200w_sma": {"score": 10, "value": 62284.65298428873}, "reserve_risk": {"score": 10, "value": 0.0010258831016337609}, "rhodl_ratio": {"score": 7, "value": 882.5868942025234}, "nupl": {"score": 8, "value": 0.11309045542914359}, "lth_realized_price": {"score": 7, "value": 49767.33015910989}, "hash_ribbons": {"score": 3, "value": null}, "sopr": {"score": 8, "value": 0.990007085019496}, "sellside_risk": {"score": 10, "value": 0.000734882488382521}, "active_address_momentum": {"score": 4, "value": -0.09386733861382784}, "txcount_momentum": {"score": 6, "value": 0.04454611494295241}, "nvt_price": {"score": 5, "value": 54673.815447216126}, "vdd_multiple": {"score": 4, "value": -0.030495759573562122}}}
|
||||||
|
|||||||
+228
-134
@@ -43,6 +43,9 @@ START_DATE = "2018-02-01"
|
|||||||
TRAIN_CUTOFF_DAYS = 365
|
TRAIN_CUTOFF_DAYS = 365
|
||||||
# Target: forward 365d return > 30% = "good time to buy"
|
# Target: forward 365d return > 30% = "good time to buy"
|
||||||
GOOD_BUY_THRESHOLD = 30.0
|
GOOD_BUY_THRESHOLD = 30.0
|
||||||
|
# Validation embargo/purge horizon: labels use 365-day forward returns.
|
||||||
|
LABEL_HORIZON_DAYS = 365
|
||||||
|
VALIDATION_SPLITS = 5
|
||||||
|
|
||||||
# The 8 core metrics we score
|
# The 8 core metrics we score
|
||||||
METRIC_KEYS = [
|
METRIC_KEYS = [
|
||||||
@@ -95,6 +98,112 @@ def score_range(value, ranges):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
SCORE_KEYS = [
|
||||||
|
"puell_multiple", "mvrv_zscore", "reserve_risk", "rhodl_ratio",
|
||||||
|
"nupl", "fear_greed", "drawdown", "pct_above_200w_sma", "pct_above_lth_rp",
|
||||||
|
]
|
||||||
|
|
||||||
|
SCORE_FEATURES = [f"score_{k}" for k in SCORE_KEYS]
|
||||||
|
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
|
||||||
|
|
||||||
|
BRACKETS = [
|
||||||
|
(0, 20, "Extreme Caution"),
|
||||||
|
(21, 40, "Caution"),
|
||||||
|
(41, 55, "Neutral"),
|
||||||
|
(56, 70, "Moderate Opportunity"),
|
||||||
|
(71, 85, "Strong Accumulation"),
|
||||||
|
(86, 100, "Extreme Accumulation"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _row_date(row):
|
||||||
|
return datetime.strptime(row["date"], "%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def purged_time_series_splits(rows, n_splits=VALIDATION_SPLITS,
|
||||||
|
label_horizon_days=LABEL_HORIZON_DAYS,
|
||||||
|
embargo_days=0):
|
||||||
|
"""Yield expanding-window splits with overlapping forward-label windows removed.
|
||||||
|
|
||||||
|
A row dated T with a 365-day forward-return label consumes information up to
|
||||||
|
T+365. For validation beginning at V, any training row whose label window
|
||||||
|
reaches V is removed. This keeps validation metrics out-of-sample for the
|
||||||
|
forward-return label, not just for features.
|
||||||
|
"""
|
||||||
|
base_splitter = TimeSeriesSplit(n_splits=n_splits)
|
||||||
|
row_dates = [_row_date(r) for r in rows]
|
||||||
|
horizon = timedelta(days=label_horizon_days)
|
||||||
|
embargo = timedelta(days=embargo_days)
|
||||||
|
|
||||||
|
for train_idx, val_idx in base_splitter.split(np.arange(len(rows))):
|
||||||
|
val_start = row_dates[val_idx[0]]
|
||||||
|
val_end = row_dates[val_idx[-1]]
|
||||||
|
purged_train = []
|
||||||
|
for idx in train_idx:
|
||||||
|
label_end = row_dates[idx] + horizon
|
||||||
|
before_validation_label_window = label_end <= val_start - embargo
|
||||||
|
after_validation_embargo = row_dates[idx] > val_end + embargo
|
||||||
|
if before_validation_label_window or after_validation_embargo:
|
||||||
|
purged_train.append(idx)
|
||||||
|
if purged_train:
|
||||||
|
yield np.array(purged_train, dtype=int), np.array(val_idx, dtype=int)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_model():
|
||||||
|
return GradientBoostingClassifier(
|
||||||
|
n_estimators=300,
|
||||||
|
learning_rate=0.05,
|
||||||
|
max_depth=4,
|
||||||
|
subsample=0.8,
|
||||||
|
min_samples_leaf=20,
|
||||||
|
random_state=42,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def derive_metric_weights(feature_cols, importances):
|
||||||
|
"""Aggregate feature importances back to transparent score metric weights."""
|
||||||
|
metric_names = list(SCORE_KEYS)
|
||||||
|
feature_to_metric = {}
|
||||||
|
for m in metric_names:
|
||||||
|
feature_to_metric[f"score_{m}"] = m
|
||||||
|
feature_to_metric[f"raw_{m}"] = m
|
||||||
|
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"
|
||||||
|
|
||||||
|
metric_importances = {m: 0.0 for m in metric_names}
|
||||||
|
for name, imp in zip(feature_cols, importances):
|
||||||
|
if name in feature_to_metric:
|
||||||
|
metric_importances[feature_to_metric[name]] += float(imp)
|
||||||
|
elif name == "mvrv_x_nupl":
|
||||||
|
metric_importances["mvrv_zscore"] += float(imp) / 2
|
||||||
|
metric_importances["nupl"] += float(imp) / 2
|
||||||
|
elif name == "puell_x_reserve":
|
||||||
|
metric_importances["puell_multiple"] += float(imp) / 2
|
||||||
|
metric_importances["reserve_risk"] += float(imp) / 2
|
||||||
|
elif name == "days_since_ath":
|
||||||
|
metric_importances["drawdown"] += float(imp)
|
||||||
|
|
||||||
|
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}
|
||||||
|
return dict(sorted(weights.items(), key=lambda x: x[1], reverse=True))
|
||||||
|
|
||||||
|
|
||||||
def build_dataset(index, thresholds):
|
def build_dataset(index, thresholds):
|
||||||
"""Build aligned training dataset: metric scores + forward returns."""
|
"""Build aligned training dataset: metric scores + forward returns."""
|
||||||
# Get all dates from 2018-02-01 onward
|
# Get all dates from 2018-02-01 onward
|
||||||
@@ -257,39 +366,33 @@ def train_model(rows):
|
|||||||
log.info("Target distribution: %d positive (%.1f%%), %d negative",
|
log.info("Target distribution: %d positive (%.1f%%), %d negative",
|
||||||
positive, positive / len(labeled) * 100, len(labeled) - positive)
|
positive, positive / len(labeled) * 100, len(labeled) - positive)
|
||||||
|
|
||||||
# Feature columns: scores + raw values + deltas + interactions + cycle position
|
feature_cols = FEATURE_COLS
|
||||||
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])
|
X = np.array([[r[f] for f in feature_cols] for r in labeled])
|
||||||
y = np.array([r["target"] 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])
|
log.info("Feature matrix: %d samples x %d features", X.shape[0], X.shape[1])
|
||||||
|
|
||||||
# Time-series cross-validation (expanding window, 5 splits)
|
# Purged time-series cross-validation. Standard TimeSeriesSplit is not
|
||||||
tscv = TimeSeriesSplit(n_splits=5)
|
# enough here because each label consumes the next 365 days of returns.
|
||||||
cv_scores = []
|
cv_scores = []
|
||||||
cv_f1 = []
|
cv_f1 = []
|
||||||
cv_precision = []
|
cv_precision = []
|
||||||
cv_recall = []
|
cv_recall = []
|
||||||
|
fold_results = []
|
||||||
|
|
||||||
for fold, (train_idx, val_idx) in enumerate(tscv.split(X)):
|
splits = list(purged_time_series_splits(
|
||||||
|
labeled,
|
||||||
|
n_splits=VALIDATION_SPLITS,
|
||||||
|
label_horizon_days=LABEL_HORIZON_DAYS,
|
||||||
|
embargo_days=0,
|
||||||
|
))
|
||||||
|
if not splits:
|
||||||
|
log.error("No viable purged validation splits. Need more history for %dd label horizon.",
|
||||||
|
LABEL_HORIZON_DAYS)
|
||||||
|
return None
|
||||||
|
|
||||||
|
for fold, (train_idx, val_idx) in enumerate(splits):
|
||||||
X_train, X_val = X[train_idx], X[val_idx]
|
X_train, X_val = X[train_idx], X[val_idx]
|
||||||
y_train, y_val = y[train_idx], y[val_idx]
|
y_train, y_val = y[train_idx], y[val_idx]
|
||||||
|
|
||||||
@@ -297,14 +400,7 @@ def train_model(rows):
|
|||||||
X_train_s = scaler.fit_transform(X_train)
|
X_train_s = scaler.fit_transform(X_train)
|
||||||
X_val_s = scaler.transform(X_val)
|
X_val_s = scaler.transform(X_val)
|
||||||
|
|
||||||
model = GradientBoostingClassifier(
|
model = _build_model()
|
||||||
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)
|
model.fit(X_train_s, y_train)
|
||||||
|
|
||||||
y_pred = model.predict(X_val_s)
|
y_pred = model.predict(X_val_s)
|
||||||
@@ -320,27 +416,40 @@ def train_model(rows):
|
|||||||
cv_precision.append(prec)
|
cv_precision.append(prec)
|
||||||
cv_recall.append(rec)
|
cv_recall.append(rec)
|
||||||
|
|
||||||
train_dates = f"{labeled[train_idx[0]]['date']} to {labeled[train_idx[-1]]['date']}"
|
fold_weights = derive_metric_weights(feature_cols, model.feature_importances_)
|
||||||
val_dates = f"{labeled[val_idx[0]]['date']} to {labeled[val_idx[-1]]['date']}"
|
fold_results.append({
|
||||||
|
"fold": fold + 1,
|
||||||
|
"train_idx": train_idx.tolist(),
|
||||||
|
"val_idx": val_idx.tolist(),
|
||||||
|
"weights": fold_weights,
|
||||||
|
"metrics": {
|
||||||
|
"auc": round(float(auc), 4),
|
||||||
|
"f1": round(float(f1), 4),
|
||||||
|
"precision": round(float(prec), 4),
|
||||||
|
"recall": round(float(rec), 4),
|
||||||
|
},
|
||||||
|
"date_ranges": {
|
||||||
|
"train": f"{labeled[train_idx[0]]['date']} to {labeled[train_idx[-1]]['date']}",
|
||||||
|
"validation": f"{labeled[val_idx[0]]['date']} to {labeled[val_idx[-1]]['date']}",
|
||||||
|
},
|
||||||
|
"n_train": len(train_idx),
|
||||||
|
"n_validation": len(val_idx),
|
||||||
|
})
|
||||||
|
|
||||||
|
train_dates = fold_results[-1]["date_ranges"]["train"]
|
||||||
|
val_dates = fold_results[-1]["date_ranges"]["validation"]
|
||||||
log.info("Fold %d: Train %s | Val %s | AUC=%.3f F1=%.3f P=%.3f R=%.3f",
|
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)
|
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("Purged 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))
|
log.info("Purged CV Mean F1: %.3f (+/- %.3f)", np.mean(cv_f1), np.std(cv_f1))
|
||||||
|
|
||||||
# Train final model on all labeled data
|
# Train final model on all labeled data
|
||||||
log.info("Training final model on all %d labeled samples...", len(labeled))
|
log.info("Training final model on all %d labeled samples...", len(labeled))
|
||||||
scaler = StandardScaler()
|
scaler = StandardScaler()
|
||||||
X_scaled = scaler.fit_transform(X)
|
X_scaled = scaler.fit_transform(X)
|
||||||
|
|
||||||
final_model = GradientBoostingClassifier(
|
final_model = _build_model()
|
||||||
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)
|
final_model.fit(X_scaled, y)
|
||||||
|
|
||||||
# Feature importances
|
# Feature importances
|
||||||
@@ -357,48 +466,7 @@ def train_model(rows):
|
|||||||
bar = "#" * int(imp * 200)
|
bar = "#" * int(imp * 200)
|
||||||
log.info(" %-30s %.4f %s", name, imp, bar)
|
log.info(" %-30s %.4f %s", name, imp, bar)
|
||||||
|
|
||||||
# Extract optimal weights by aggregating importance per metric
|
weights = derive_metric_weights(feature_cols, importances)
|
||||||
# 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("\nOptimal Metric Weights:")
|
||||||
log.info("-" * 50)
|
log.info("-" * 50)
|
||||||
@@ -413,6 +481,7 @@ def train_model(rows):
|
|||||||
log.info("COMPARISON BACKTEST: ML-Weighted vs Equal-Weight")
|
log.info("COMPARISON BACKTEST: ML-Weighted vs Equal-Weight")
|
||||||
log.info("=" * 60)
|
log.info("=" * 60)
|
||||||
comparison = run_comparison(rows, weights)
|
comparison = run_comparison(rows, weights)
|
||||||
|
out_of_sample_comparison = run_out_of_sample_comparison(labeled, fold_results)
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
result = {
|
result = {
|
||||||
@@ -424,6 +493,9 @@ def train_model(rows):
|
|||||||
"mean_f1": round(float(np.mean(cv_f1)), 4),
|
"mean_f1": round(float(np.mean(cv_f1)), 4),
|
||||||
"mean_precision": round(float(np.mean(cv_precision)), 4),
|
"mean_precision": round(float(np.mean(cv_precision)), 4),
|
||||||
"mean_recall": round(float(np.mean(cv_recall)), 4),
|
"mean_recall": round(float(np.mean(cv_recall)), 4),
|
||||||
|
"validation_method": "purged_expanding_window",
|
||||||
|
"label_horizon_days": LABEL_HORIZON_DAYS,
|
||||||
|
"folds": fold_results,
|
||||||
},
|
},
|
||||||
"training_info": {
|
"training_info": {
|
||||||
"n_samples": len(labeled),
|
"n_samples": len(labeled),
|
||||||
@@ -435,66 +507,47 @@ def train_model(rows):
|
|||||||
"model": "GradientBoostingClassifier",
|
"model": "GradientBoostingClassifier",
|
||||||
},
|
},
|
||||||
"comparison": comparison,
|
"comparison": comparison,
|
||||||
|
"out_of_sample_comparison": out_of_sample_comparison,
|
||||||
"trained_at": datetime.now(tz=__import__('datetime').timezone.utc).isoformat(),
|
"trained_at": datetime.now(tz=__import__('datetime').timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def run_comparison(rows, ml_weights):
|
def _composite_score(row, mode, ml_weights=None):
|
||||||
"""Compare ML-weighted scoring vs equal-weight scoring across score brackets."""
|
scores = [row[f"score_{k}"] for k in SCORE_KEYS]
|
||||||
# Metrics used in scoring (maps to score_* columns)
|
if mode == "equal_weight" or not ml_weights:
|
||||||
score_keys = [
|
return sum(scores) / len(SCORE_KEYS) * 10
|
||||||
"puell_multiple", "mvrv_zscore", "reserve_risk", "rhodl_ratio",
|
equal_weight = 1.0 / len(SCORE_KEYS)
|
||||||
"nupl", "fear_greed", "drawdown", "pct_above_200w_sma", "pct_above_lth_rp",
|
weighted_sum = sum(row[f"score_{k}"] * ml_weights.get(k, equal_weight) for k in SCORE_KEYS)
|
||||||
]
|
return weighted_sum * 10
|
||||||
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
|
def _summarize_brackets(scored_rows, score_key):
|
||||||
scored_rows = [r for r in rows if "fwd_365d" in r]
|
results = []
|
||||||
|
for low, high, label in BRACKETS:
|
||||||
results = {"equal_weight": [], "ml_weighted": []}
|
days_in = [r for r in scored_rows if low <= r[score_key] <= high]
|
||||||
|
if not days_in:
|
||||||
for mode in ["equal_weight", "ml_weighted"]:
|
results.append({
|
||||||
for r in scored_rows:
|
"range": f"{low}-{high}", "label": label,
|
||||||
scores = [r[f"score_{k}"] for k in score_keys]
|
"days": 0, "avg_365d": None,
|
||||||
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),
|
|
||||||
})
|
})
|
||||||
|
continue
|
||||||
|
returns_365 = [r["fwd_365d"] for r in days_in]
|
||||||
|
returns_sorted = sorted(returns_365)
|
||||||
|
win_rate = len([r for r in returns_365 if r > 0]) / len(returns_365) * 100
|
||||||
|
results.append({
|
||||||
|
"range": f"{low}-{high}",
|
||||||
|
"label": label,
|
||||||
|
"days": len(days_in),
|
||||||
|
"avg_365d": round(sum(returns_365) / len(returns_365), 2),
|
||||||
|
"median_365d": round(returns_sorted[len(returns_sorted) // 2], 2),
|
||||||
|
"win_rate_365d": round(win_rate, 1),
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
# Print comparison
|
|
||||||
|
def _log_comparison_table(results):
|
||||||
log.info("\n%-18s | %-8s %-8s %-8s | %-8s %-8s %-8s",
|
log.info("\n%-18s | %-8s %-8s %-8s | %-8s %-8s %-8s",
|
||||||
"Bracket", "EQ Avg", "EQ Med", "EQ Win%", "ML Avg", "ML Med", "ML Win%")
|
"Bracket", "EQ Avg", "EQ Med", "EQ Win%", "ML Avg", "ML Med", "ML Win%")
|
||||||
log.info("-" * 80)
|
log.info("-" * 80)
|
||||||
@@ -508,6 +561,47 @@ def run_comparison(rows, ml_weights):
|
|||||||
log.info("%-18s | %-8s %-8s %-8s | %-8s %-8s %-8s",
|
log.info("%-18s | %-8s %-8s %-8s | %-8s %-8s %-8s",
|
||||||
eq["label"], eq_avg, eq_med, eq_win, ml_avg, ml_med, ml_win)
|
eq["label"], eq_avg, eq_med, eq_win, ml_avg, ml_med, ml_win)
|
||||||
|
|
||||||
|
|
||||||
|
def run_comparison(rows, ml_weights):
|
||||||
|
"""Compare final ML-weighted scoring vs equal-weight scoring across all labeled rows.
|
||||||
|
|
||||||
|
This is retained for backwards compatibility with existing output. It is an
|
||||||
|
in-sample/full-history comparison; prefer out_of_sample_comparison for model
|
||||||
|
selection decisions.
|
||||||
|
"""
|
||||||
|
scored_rows = [dict(r) for r in rows if "fwd_365d" in r]
|
||||||
|
for r in scored_rows:
|
||||||
|
r["composite_equal_weight"] = _composite_score(r, "equal_weight")
|
||||||
|
r["composite_ml_weighted"] = _composite_score(r, "ml_weighted", ml_weights)
|
||||||
|
|
||||||
|
results = {
|
||||||
|
"equal_weight": _summarize_brackets(scored_rows, "composite_equal_weight"),
|
||||||
|
"ml_weighted": _summarize_brackets(scored_rows, "composite_ml_weighted"),
|
||||||
|
}
|
||||||
|
_log_comparison_table(results)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def run_out_of_sample_comparison(rows, fold_results):
|
||||||
|
"""Compare fold-specific ML weights on validation rows only."""
|
||||||
|
validation_rows = []
|
||||||
|
for fold in fold_results:
|
||||||
|
weights = fold.get("weights", {})
|
||||||
|
for idx in fold.get("val_idx", []):
|
||||||
|
if idx >= len(rows) or "fwd_365d" not in rows[idx]:
|
||||||
|
continue
|
||||||
|
r = dict(rows[idx])
|
||||||
|
r["fold"] = fold.get("fold")
|
||||||
|
r["composite_equal_weight"] = _composite_score(r, "equal_weight")
|
||||||
|
r["composite_ml_weighted"] = _composite_score(r, "ml_weighted", weights)
|
||||||
|
validation_rows.append(r)
|
||||||
|
|
||||||
|
results = {
|
||||||
|
"folds": len(fold_results),
|
||||||
|
"validation_days": len(validation_rows),
|
||||||
|
"equal_weight": _summarize_brackets(validation_rows, "composite_equal_weight"),
|
||||||
|
"ml_weighted": _summarize_brackets(validation_rows, "composite_ml_weighted"),
|
||||||
|
}
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+154
-13
@@ -259,6 +259,65 @@ def score_hash_ribbons(data, thresholds=None):
|
|||||||
return 3, "Normal mining activity"
|
return 3, "Normal mining activity"
|
||||||
|
|
||||||
|
|
||||||
|
def score_sopr(value, thresholds=None):
|
||||||
|
if value is None:
|
||||||
|
return None, "No data"
|
||||||
|
if value < 0.98:
|
||||||
|
return 10, "Deep loss realization — capitulation, strong accumulation"
|
||||||
|
if value < 1.0:
|
||||||
|
return 8, "Below breakeven — capitulation, good accumulation"
|
||||||
|
if value < 1.02:
|
||||||
|
return 5, "Near breakeven — neutral"
|
||||||
|
if value < 1.05:
|
||||||
|
return 2, "Moderate profit taking"
|
||||||
|
return 0, "Elevated profit taking — caution"
|
||||||
|
|
||||||
|
|
||||||
|
def score_sellside_risk(value, thresholds=None):
|
||||||
|
if value is None:
|
||||||
|
return None, "No data"
|
||||||
|
if value < 0.001:
|
||||||
|
return 10, "Very low sell-side risk — strong accumulation"
|
||||||
|
if value < 0.002:
|
||||||
|
return 8, "Low sell-side risk — good accumulation"
|
||||||
|
if value < 0.005:
|
||||||
|
return 5, "Moderate sell-side risk"
|
||||||
|
if value < 0.01:
|
||||||
|
return 2, "Elevated sell-side risk"
|
||||||
|
return 0, "High sell-side risk"
|
||||||
|
|
||||||
|
|
||||||
|
def score_momentum_pct(value):
|
||||||
|
if value is None:
|
||||||
|
return None, "No data"
|
||||||
|
pct = value * 100
|
||||||
|
if pct >= 20:
|
||||||
|
return 10, f"Strong positive momentum (+{pct:.0f}%)"
|
||||||
|
if pct >= 0:
|
||||||
|
return 6, f"Mild positive momentum (+{pct:.0f}%)"
|
||||||
|
if pct >= -10:
|
||||||
|
return 4, f"Slightly negative momentum ({pct:.0f}%)"
|
||||||
|
if pct >= -25:
|
||||||
|
return 2, f"Weak momentum ({pct:.0f}%)"
|
||||||
|
return 1, f"Strong negative momentum ({pct:.0f}%)"
|
||||||
|
|
||||||
|
|
||||||
|
def score_nvt_price(nvt_price, spot_price):
|
||||||
|
if nvt_price is None or spot_price is None or spot_price <= 0:
|
||||||
|
return None, "No data"
|
||||||
|
premium = (nvt_price - spot_price) / spot_price * 100
|
||||||
|
if premium < -25:
|
||||||
|
return 10, f"NVT price {abs(premium):.0f}% below spot — deep value"
|
||||||
|
if premium < -10:
|
||||||
|
return 8, f"NVT price {abs(premium):.0f}% below spot — undervalued"
|
||||||
|
if premium < 10:
|
||||||
|
relation = "below" if premium < 0 else "above"
|
||||||
|
return 5, f"NVT price {abs(premium):.0f}% {relation} spot — fair value"
|
||||||
|
if premium < 30:
|
||||||
|
return 2, f"NVT price {premium:.0f}% above spot — extended"
|
||||||
|
return 0, f"NVT price {premium:.0f}% above spot — overheated"
|
||||||
|
|
||||||
|
|
||||||
def score_all(metrics):
|
def score_all(metrics):
|
||||||
"""Score all metrics and return individual + composite scores."""
|
"""Score all metrics and return individual + composite scores."""
|
||||||
thresholds = load_thresholds()
|
thresholds = load_thresholds()
|
||||||
@@ -399,6 +458,84 @@ def score_all(metrics):
|
|||||||
"recent": [],
|
"recent": [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# SOPR
|
||||||
|
sopr = metrics.get("sopr", {})
|
||||||
|
sopr_score, sopr_desc = score_sopr(sopr.get("value"), thresholds)
|
||||||
|
results.append({
|
||||||
|
"name": "SOPR",
|
||||||
|
"key": "sopr",
|
||||||
|
"value": sopr.get("value"),
|
||||||
|
"display_value": f"{sopr.get('value', 'N/A'):.4f}" if sopr.get("value") is not None else "N/A",
|
||||||
|
"score": sopr_score,
|
||||||
|
"description": sopr_desc,
|
||||||
|
"recent": sopr.get("recent", []),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sell-side Risk Ratio
|
||||||
|
ssr = metrics.get("sellside_risk", {})
|
||||||
|
ssr_score, ssr_desc = score_sellside_risk(ssr.get("value"), thresholds)
|
||||||
|
results.append({
|
||||||
|
"name": "Sell-side Risk Ratio",
|
||||||
|
"key": "sellside_risk",
|
||||||
|
"value": ssr.get("value"),
|
||||||
|
"display_value": f"{ssr.get('value', 'N/A'):.6f}" if ssr.get("value") is not None else "N/A",
|
||||||
|
"score": ssr_score,
|
||||||
|
"description": ssr_desc,
|
||||||
|
"recent": ssr.get("recent", []),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Active Address Momentum
|
||||||
|
aam = metrics.get("active_address_momentum", {})
|
||||||
|
aam_score, aam_desc = score_momentum_pct(aam.get("value"))
|
||||||
|
results.append({
|
||||||
|
"name": "Active Address Momentum",
|
||||||
|
"key": "active_address_momentum",
|
||||||
|
"value": aam.get("value"),
|
||||||
|
"display_value": f"{aam.get('value') * 100:.1f}%" if aam.get("value") is not None else "N/A",
|
||||||
|
"score": aam_score,
|
||||||
|
"description": aam_desc,
|
||||||
|
"recent": aam.get("recent", []),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Transaction Count Momentum
|
||||||
|
txm = metrics.get("txcount_momentum", {})
|
||||||
|
txm_score, txm_desc = score_momentum_pct(txm.get("value"))
|
||||||
|
results.append({
|
||||||
|
"name": "Transaction Count Momentum",
|
||||||
|
"key": "txcount_momentum",
|
||||||
|
"value": txm.get("value"),
|
||||||
|
"display_value": f"{txm.get('value') * 100:.1f}%" if txm.get("value") is not None else "N/A",
|
||||||
|
"score": txm_score,
|
||||||
|
"description": txm_desc,
|
||||||
|
"recent": txm.get("recent", []),
|
||||||
|
})
|
||||||
|
|
||||||
|
# NVT Price
|
||||||
|
nvt = metrics.get("nvt_price", {})
|
||||||
|
nvt_score, nvt_desc = score_nvt_price(nvt.get("value"), current_price)
|
||||||
|
results.append({
|
||||||
|
"name": "NVT Price",
|
||||||
|
"key": "nvt_price",
|
||||||
|
"value": nvt.get("value"),
|
||||||
|
"display_value": f"${nvt.get('value'):,.0f}" if nvt.get("value") is not None else "N/A",
|
||||||
|
"score": nvt_score,
|
||||||
|
"description": nvt_desc,
|
||||||
|
"recent": nvt.get("recent", []),
|
||||||
|
})
|
||||||
|
|
||||||
|
# VDD Multiple
|
||||||
|
vdd = metrics.get("vdd_multiple", {})
|
||||||
|
vdd_score, vdd_desc = score_momentum_pct(vdd.get("value"))
|
||||||
|
results.append({
|
||||||
|
"name": "VDD Multiple",
|
||||||
|
"key": "vdd_multiple",
|
||||||
|
"value": vdd.get("value"),
|
||||||
|
"display_value": f"{vdd.get('value') * 100:.1f}%" if vdd.get("value") is not None else "N/A",
|
||||||
|
"score": vdd_score,
|
||||||
|
"description": vdd_desc,
|
||||||
|
"recent": vdd.get("recent", []),
|
||||||
|
})
|
||||||
|
|
||||||
# Compute composite
|
# Compute composite
|
||||||
valid_scores = [r["score"] for r in results if r["score"] is not None]
|
valid_scores = [r["score"] for r in results if r["score"] is not None]
|
||||||
if valid_scores:
|
if valid_scores:
|
||||||
@@ -481,31 +618,34 @@ def score_all_ml(metrics):
|
|||||||
|
|
||||||
results = classic["metrics"]
|
results = classic["metrics"]
|
||||||
|
|
||||||
# Compute ML-weighted composite
|
# Compute raw ML weights first, then normalize across only the currently
|
||||||
weighted_sum = 0.0
|
# scored metrics. This keeps the dashboard's displayed per-metric weights and
|
||||||
weight_total = 0.0
|
# contribution points consistent with the normalized composite score even
|
||||||
|
# when optional metrics are missing or hash ribbons receives its fallback.
|
||||||
|
weighted_metrics = []
|
||||||
for m in results:
|
for m in results:
|
||||||
if m["score"] is None:
|
if m["score"] is None:
|
||||||
continue
|
continue
|
||||||
ml_key = _ML_KEY_MAP.get(m["key"])
|
ml_key = _ML_KEY_MAP.get(m["key"])
|
||||||
if ml_key is None:
|
if ml_key is None:
|
||||||
# Hash ribbons or unknown metric — use small default weight
|
# Hash ribbons or unknown metric — use small default weight
|
||||||
w = 0.01
|
raw_weight = 0.01
|
||||||
else:
|
else:
|
||||||
w = ml_weights.get(ml_key, 0.0)
|
raw_weight = ml_weights.get(ml_key, 0.0)
|
||||||
|
weighted_metrics.append((m, raw_weight))
|
||||||
|
|
||||||
m["ml_weight"] = round(w, 4)
|
weight_total = sum(raw_weight for _, raw_weight in weighted_metrics)
|
||||||
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:
|
if weight_total > 0:
|
||||||
composite = weighted_sum / weight_total * 10
|
composite = sum(m["score"] * raw_weight for m, raw_weight in weighted_metrics) / weight_total * 10
|
||||||
else:
|
else:
|
||||||
composite = 0
|
composite = 0
|
||||||
|
|
||||||
|
for m, raw_weight in weighted_metrics:
|
||||||
|
effective_weight = raw_weight / weight_total if weight_total > 0 else 0.0
|
||||||
|
m["ml_raw_weight"] = round(raw_weight, 4)
|
||||||
|
m["ml_weight"] = round(effective_weight, 4)
|
||||||
|
m["ml_contribution"] = round(m["score"] * effective_weight * 10, 2)
|
||||||
|
|
||||||
# Assessment text (same thresholds as classic)
|
# Assessment text (same thresholds as classic)
|
||||||
if composite >= 80:
|
if composite >= 80:
|
||||||
assessment = "EXTREME ACCUMULATION ZONE"
|
assessment = "EXTREME ACCUMULATION ZONE"
|
||||||
@@ -528,4 +668,5 @@ def score_all_ml(metrics):
|
|||||||
"total_count": classic["total_count"],
|
"total_count": classic["total_count"],
|
||||||
"ml_mode": True,
|
"ml_mode": True,
|
||||||
"classic_score": classic["composite_score"],
|
"classic_score": classic["composite_score"],
|
||||||
|
"ml_weight_total": round(weight_total, 4),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Scraper for static CheckOnChain Plotly chart HTML pages."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import array
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from html import unescape
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CHARTS = {
|
||||||
|
"sopr": {
|
||||||
|
"url": "https://charts.checkonchain.com/btconchain/realised/sopr/sopr_light.html",
|
||||||
|
"traces": ["SOPR"],
|
||||||
|
},
|
||||||
|
"sellside_risk": {
|
||||||
|
"url": "https://charts.checkonchain.com/btconchain/realised/sellsideriskratio_all/sellsideriskratio_all_light.html",
|
||||||
|
"traces": ["Sell-side Risk Ratio", "Sellside Risk Ratio", "SSR"],
|
||||||
|
},
|
||||||
|
"active_address_momentum": {
|
||||||
|
"url": "https://charts.checkonchain.com/btconchain/adoption/actaddress_momentum/actaddress_momentum_light.html",
|
||||||
|
"traces": ["30DMA", "30 Day", "Active Address"],
|
||||||
|
},
|
||||||
|
"txcount_momentum": {
|
||||||
|
"url": "https://charts.checkonchain.com/btconchain/adoption/txcount_momentum/txcount_momentum_light.html",
|
||||||
|
"traces": ["30DMA", "30 Day", "Transaction"],
|
||||||
|
},
|
||||||
|
"nvt_price": {
|
||||||
|
"url": "https://charts.checkonchain.com/btconchain/pricing/pricing_nvtprice/pricing_nvtprice_light.html",
|
||||||
|
"traces": ["NVT Price", "NVT"],
|
||||||
|
},
|
||||||
|
"vdd_multiple": {
|
||||||
|
"url": "https://charts.checkonchain.com/btconchain/lifespan/vddmultiple/vddmultiple_light.html",
|
||||||
|
"traces": ["VDD Multiple", "Value Days Destroyed"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_plotly_traces(html_text: str):
|
||||||
|
"""Extract first Plotly.newPlot trace array from a static Plotly HTML page."""
|
||||||
|
marker = "Plotly.newPlot("
|
||||||
|
start = html_text.find(marker)
|
||||||
|
if start < 0:
|
||||||
|
return []
|
||||||
|
first_array = html_text.find("[", start)
|
||||||
|
if first_array < 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
depth = 0
|
||||||
|
in_string = False
|
||||||
|
escape = False
|
||||||
|
quote = ""
|
||||||
|
for idx in range(first_array, len(html_text)):
|
||||||
|
ch = html_text[idx]
|
||||||
|
if in_string:
|
||||||
|
if escape:
|
||||||
|
escape = False
|
||||||
|
elif ch == "\\":
|
||||||
|
escape = True
|
||||||
|
elif ch == quote:
|
||||||
|
in_string = False
|
||||||
|
continue
|
||||||
|
if ch in {'"', "'"}:
|
||||||
|
in_string = True
|
||||||
|
quote = ch
|
||||||
|
elif ch == "[":
|
||||||
|
depth += 1
|
||||||
|
elif ch == "]":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
raw = html_text[first_array:idx + 1]
|
||||||
|
return json.loads(raw)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_chart(url: str, timeout=30):
|
||||||
|
resp = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=timeout)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return _extract_plotly_traces(unescape(resp.text))
|
||||||
|
|
||||||
|
|
||||||
|
def _find_trace(traces, names):
|
||||||
|
names = [n.lower() for n in names if n]
|
||||||
|
# Prefer non-price traces with the requested terms.
|
||||||
|
for trace in traces:
|
||||||
|
trace_name = str(trace.get("name", "")).lower()
|
||||||
|
if "price" in trace_name and not any("price" in n for n in names):
|
||||||
|
continue
|
||||||
|
if any(n in trace_name for n in names):
|
||||||
|
return trace
|
||||||
|
# Fallback: first numeric non-price trace.
|
||||||
|
for trace in traces:
|
||||||
|
trace_name = str(trace.get("name", "")).lower()
|
||||||
|
if "price" in trace_name:
|
||||||
|
continue
|
||||||
|
y = trace.get("y") or []
|
||||||
|
if any(v is not None for v in y[-30:]):
|
||||||
|
return trace
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_plotly_array(values):
|
||||||
|
"""Decode Plotly typed-array JSON ({dtype, bdata}) or return plain values."""
|
||||||
|
if not isinstance(values, dict) or "bdata" not in values:
|
||||||
|
return values or []
|
||||||
|
|
||||||
|
dtype = values.get("dtype")
|
||||||
|
typecodes = {
|
||||||
|
"f8": "d", "float64": "d",
|
||||||
|
"f4": "f", "float32": "f",
|
||||||
|
"i8": "q", "int64": "q",
|
||||||
|
"i4": "i", "int32": "i",
|
||||||
|
"u8": "Q", "uint64": "Q",
|
||||||
|
"u4": "I", "uint32": "I",
|
||||||
|
}
|
||||||
|
typecode = typecodes.get(dtype)
|
||||||
|
if not typecode:
|
||||||
|
return []
|
||||||
|
decoded = base64.b64decode(values["bdata"])
|
||||||
|
arr = array.array(typecode)
|
||||||
|
arr.frombytes(decoded)
|
||||||
|
if values.get("byteorder") == "big":
|
||||||
|
arr.byteswap()
|
||||||
|
return arr.tolist()
|
||||||
|
|
||||||
|
|
||||||
|
def _numeric_values(trace):
|
||||||
|
values = []
|
||||||
|
for value in _decode_plotly_array((trace or {}).get("y", [])):
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
values.append(float(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _latest(values):
|
||||||
|
return values[-1] if values else None
|
||||||
|
|
||||||
|
|
||||||
|
def _momentum(values, window=30):
|
||||||
|
if len(values) <= window or values[-window] == 0:
|
||||||
|
return None
|
||||||
|
return (values[-1] - values[-window]) / values[-window]
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_all():
|
||||||
|
results = {}
|
||||||
|
for key, cfg in CHARTS.items():
|
||||||
|
log.info("Scraping CheckOnChain %s ...", key)
|
||||||
|
try:
|
||||||
|
traces = scrape_chart(cfg["url"])
|
||||||
|
trace = _find_trace(traces, cfg.get("traces", []))
|
||||||
|
values = _numeric_values(trace)
|
||||||
|
value = _latest(values)
|
||||||
|
if key in {"active_address_momentum", "txcount_momentum", "vdd_multiple"}:
|
||||||
|
# The card value is momentum, while the sparkline shows the raw metric.
|
||||||
|
value = _momentum(values)
|
||||||
|
results[key] = {"value": value, "recent": values[-30:]}
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("CheckOnChain scrape failed for %s: %s", key, exc)
|
||||||
|
results[key] = {"value": None, "error": str(exc)}
|
||||||
|
return results
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 633 KiB After Width: | Height: | Size: 492 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 298 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,74 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from ml import optimizer
|
||||||
|
|
||||||
|
|
||||||
|
def _row(date, returns=10.0, score_200w=10, score_drawdown=0):
|
||||||
|
row = {
|
||||||
|
"date": date,
|
||||||
|
"price": 100.0,
|
||||||
|
"fwd_365d": returns,
|
||||||
|
"score_puell_multiple": 0,
|
||||||
|
"score_mvrv_zscore": 0,
|
||||||
|
"score_reserve_risk": 0,
|
||||||
|
"score_rhodl_ratio": 0,
|
||||||
|
"score_nupl": 0,
|
||||||
|
"score_fear_greed": 0,
|
||||||
|
"score_drawdown": score_drawdown,
|
||||||
|
"score_pct_above_200w_sma": score_200w,
|
||||||
|
"score_pct_above_lth_rp": 0,
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def test_purged_time_series_splits_remove_overlapping_forward_label_windows():
|
||||||
|
start = datetime(2020, 1, 1)
|
||||||
|
rows = [_row((start + timedelta(days=i)).strftime("%Y-%m-%d")) for i in range(900)]
|
||||||
|
|
||||||
|
splits = list(
|
||||||
|
optimizer.purged_time_series_splits(
|
||||||
|
rows,
|
||||||
|
n_splits=3,
|
||||||
|
label_horizon_days=365,
|
||||||
|
embargo_days=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert splits, "expected at least one viable split"
|
||||||
|
for train_idx, val_idx in splits:
|
||||||
|
val_start = datetime.strptime(rows[val_idx[0]]["date"], "%Y-%m-%d")
|
||||||
|
latest_allowed_train_date = val_start - timedelta(days=365)
|
||||||
|
assert len(train_idx) > 0, "purging should keep non-overlapping expanding-window training rows"
|
||||||
|
for idx in train_idx:
|
||||||
|
train_date = datetime.strptime(rows[idx]["date"], "%Y-%m-%d")
|
||||||
|
assert train_date <= latest_allowed_train_date
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_out_of_sample_comparison_scores_only_validation_rows_with_fold_weights():
|
||||||
|
rows = [
|
||||||
|
_row("2020-01-01", returns=-10, score_200w=0, score_drawdown=10),
|
||||||
|
_row("2020-01-02", returns=-5, score_200w=0, score_drawdown=10),
|
||||||
|
_row("2020-01-03", returns=100, score_200w=10, score_drawdown=10),
|
||||||
|
_row("2020-01-04", returns=120, score_200w=10, score_drawdown=10),
|
||||||
|
]
|
||||||
|
fold_results = [
|
||||||
|
{
|
||||||
|
"fold": 1,
|
||||||
|
"val_idx": [2, 3],
|
||||||
|
"weights": {"pct_above_200w_sma": 1.0, "drawdown": 0.0},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
comparison = optimizer.run_out_of_sample_comparison(rows, fold_results)
|
||||||
|
|
||||||
|
assert comparison["validation_days"] == 2
|
||||||
|
assert comparison["folds"] == 1
|
||||||
|
assert sum(bucket["days"] for bucket in comparison["ml_weighted"]) == 2
|
||||||
|
assert sum(bucket["days"] for bucket in comparison["equal_weight"]) == 2
|
||||||
|
|
||||||
|
extreme_ml = next(bucket for bucket in comparison["ml_weighted"] if bucket["label"] == "Extreme Accumulation")
|
||||||
|
assert extreme_ml["days"] == 2
|
||||||
|
assert extreme_ml["avg_365d"] == 110.0
|
||||||
|
|
||||||
|
caution_equal = next(bucket for bucket in comparison["equal_weight"] if bucket["label"] == "Caution")
|
||||||
|
assert caution_equal["days"] == 2
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
|
from scoring import engine
|
||||||
|
|
||||||
|
|
||||||
|
def _complete_metrics():
|
||||||
|
return {
|
||||||
|
"fear_greed": {"value": 10, "classification": "Extreme Fear"},
|
||||||
|
"puell_multiple": {"value": 0.3},
|
||||||
|
"mvrv_zscore": {"value": -0.1},
|
||||||
|
"drawdown": {"value": 60.0, "ath": 250.0},
|
||||||
|
"price": {"price": 100.0},
|
||||||
|
"200w_sma": {"value": 120.0},
|
||||||
|
"reserve_risk": {"value": 0.001},
|
||||||
|
"rhodl_ratio": {"value": 50.0},
|
||||||
|
"nupl": {"value": -0.1},
|
||||||
|
"lth_realized_price": {"value": 120.0},
|
||||||
|
"hash_ribbons": {"buy_signal": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_score_all_ml_normalizes_displayed_weights_and_contributions(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
engine,
|
||||||
|
"load_ml_weights",
|
||||||
|
lambda: {
|
||||||
|
"fear_greed": 0.40,
|
||||||
|
"puell_multiple": 0.20,
|
||||||
|
"mvrv_zscore": 0.15,
|
||||||
|
"drawdown": 0.10,
|
||||||
|
"pct_above_200w_sma": 0.05,
|
||||||
|
"reserve_risk": 0.04,
|
||||||
|
"rhodl_ratio": 0.03,
|
||||||
|
"nupl": 0.02,
|
||||||
|
"pct_above_lth_rp": 0.01,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
scored = engine.score_all_ml(_complete_metrics())
|
||||||
|
|
||||||
|
assert scored["ml_mode"] is True
|
||||||
|
valid_metrics = [m for m in scored["metrics"] if m["score"] is not None]
|
||||||
|
assert scored["ml_weight_total"] == 1.01 # trained weights + small hash-ribbons fallback
|
||||||
|
assert math.isclose(sum(m["ml_weight"] for m in valid_metrics), 1.0, abs_tol=0.001)
|
||||||
|
assert math.isclose(
|
||||||
|
sum(m["ml_contribution"] for m in valid_metrics),
|
||||||
|
scored["composite_score"],
|
||||||
|
abs_tol=0.05,
|
||||||
|
)
|
||||||
|
|
||||||
|
hash_ribbons = next(m for m in valid_metrics if m["key"] == "hash_ribbons")
|
||||||
|
assert hash_ribbons["ml_raw_weight"] == 0.01
|
||||||
|
assert hash_ribbons["ml_weight"] == round(0.01 / 1.01, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_score_all_ml_preserves_classic_fallback_when_weights_missing(monkeypatch):
|
||||||
|
monkeypatch.setattr(engine, "load_ml_weights", lambda: {})
|
||||||
|
|
||||||
|
scored = engine.score_all_ml(_complete_metrics())
|
||||||
|
|
||||||
|
assert scored["ml_mode"] is False
|
||||||
|
assert scored["ml_error"] == "ML weights not found — run ml/optimizer.py"
|
||||||
|
assert "classic_score" not in scored
|
||||||
Reference in New Issue
Block a user