26 Commits
Author SHA1 Message Date
Hermes Agent bf77737d88 fix: address pre-push review findings 2026-07-26 23:37:15 +00:00
Hermes Agent 2da5d20ccd fix: isolate on-chain provider outages 2026-07-26 23:32:54 +00:00
Hermes Agent a2b9b431c7 fix: isolate auxiliary price source failures 2026-07-26 23:29:54 +00:00
Hermes Agent 1e50760f27 perf: reuse browser across chart scrapes 2026-07-26 23:27:06 +00:00
Hermes Agent dafc21b352 chore: keep runtime persistence out of git 2026-07-26 23:22:56 +00:00
Hermes Agent 4ad9b38e7f test: require current repository ML artifact 2026-07-26 23:20:05 +00:00
Hermes Agent 741de87ce2 perf: trim persisted ML fold metadata 2026-07-26 23:19:21 +00:00
Hermes Agent 1c46e1ad4b fix: train valid purged ML artifacts 2026-07-26 23:17:41 +00:00
Hermes Agent 6655bcfa5a fix: validate scraper metric semantics 2026-07-26 23:15:37 +00:00
Hermes Agent b06cabf3aa fix: add health checks and repair dashboard contracts 2026-07-26 23:15:37 +00:00
Hermes Agent f9e992c2b4 feat: report bootstrap confidence intervals 2026-07-26 23:15:37 +00:00
Hermes Agent 3a2571df9f chore: ignore uv and Playwright local state 2026-07-26 23:07:36 +00:00
Hermes Agent 63d4b6c86a docs: document locked setup deployment and ML provenance 2026-07-26 23:07:36 +00:00
Hermes Agent a9bdf3b46c chore: add arm64 container deployment and Gitea CI 2026-07-26 23:07:36 +00:00
Hermes Agent 14d3baea90 chore: lock reproducible Python dependency groups 2026-07-26 23:07:36 +00:00
Hermes Agent 99f6e80ea1 perf: cache backtests by input signature 2026-07-26 23:07:30 +00:00
Hermes Agent 111b458ddf fix: reserve and persist background jobs 2026-07-26 23:07:30 +00:00
Hermes Agent 3b1bc9a2bf fix: preserve metrics with atomic persistence 2026-07-26 23:07:30 +00:00
Hermes Agent 661579abf9 fix: publish historical metric coverage 2026-07-26 23:07:24 +00:00
Hermes Agent 510b2587ca fix: distinguish OOS ML backtest weights 2026-07-26 23:07:24 +00:00
Hermes Agent eb8c01611c fix: reject unprovenanced ML artifacts 2026-07-26 23:07:24 +00:00
Hermes Agent 62bff348bf fix: canonicalize score brackets and assessments 2026-07-26 23:07:24 +00:00
Hermes Agent 1f754ed85d feat: add block-bootstrap backtest intervals 2026-07-26 23:05:05 +00:00
Hermes Agent a54dec357f docs: clarify legacy ML target semantics 2026-07-26 23:00:46 +00:00
Hermes Agent 81654b5743 fix: remove leakage from legacy ML evaluation 2026-07-26 22:59:21 +00:00
Hermes Agent aef714d6c7 chore: stop tracking local LLM credentials 2026-07-26 22:59:21 +00:00
43 changed files with 5888 additions and 614 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.gitea
.github
.venv
.playwright
__pycache__
*.py[cod]
*.log
.env
results
screenshots
+28
View File
@@ -0,0 +1,28 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
version: "0.11.6"
enable-cache: true
- name: Validate lockfile and install dependencies
run: uv sync --locked --group runtime --group ml --group dev
- name: Compile Python sources
run: uv run --frozen python -m compileall -q dashboard scrapers scoring backtesting ml ml_engine llm_client scripts orchestrator.py
- name: Run tests
run: uv run --frozen pytest
+6
View File
@@ -1,7 +1,13 @@
__pycache__/ __pycache__/
*.pyc *.pyc
.venv/
.playwright/
.pytest_cache/
data/cache.json data/cache.json
data/history.json data/history.json
data/score_history.jsonl
data/jobs.json
data/*.lock
config/llm_settings.json config/llm_settings.json
results/ results/
*.log *.log
+31
View File
@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1.7
FROM ghcr.io/astral-sh/uv:0.11.6 AS uv
FROM python:3.13.5-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
COPY --from=uv /uv /uvx /bin/
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev --group runtime --group ml \
&& uv run --frozen --no-dev --group runtime --group ml \
playwright install --with-deps chromium \
&& chmod -R a+rX /ms-playwright
COPY --chown=10001:10001 . .
RUN mkdir -p /app/data /app/config \
&& chown -R 10001:10001 /app/data /app/config
USER 10001:10001
EXPOSE 3088
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD ["/app/.venv/bin/python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:3088/health/live', timeout=3)"]
CMD ["/app/.venv/bin/python", "-m", "uvicorn", "dashboard.server:app", "--host", "0.0.0.0", "--port", "3088"]
+76 -29
View File
@@ -145,49 +145,93 @@ Data is collected from free/public sources and cached locally under `data/`.
│ ├── ml_weights.json # Learned ML metric weights │ ├── ml_weights.json # Learned ML metric weights
│ └── llm_settings.json # Optional AI commentary provider config │ └── llm_settings.json # Optional AI commentary provider config
├── screenshots/ # README screenshots ├── screenshots/ # README screenshots
├── scripts/run.sh # Locked local launcher with Playwright path
├── .gitea/workflows/ci.yml # Gitea Actions test/compile gates
├── Dockerfile # Non-root Chromium-enabled image
├── docker-compose.yml # Port, healthcheck, restart, persistent volumes
├── pyproject.toml # Runtime, ML, and development dependency groups
├── uv.lock # Exact reproducible dependency resolution
├── ARCHITECTURE.md ├── ARCHITECTURE.md
└── README.md └── README.md
``` ```
## Running ## Reproducible Setup
### Local / ad-hoc with uv Install [uv](https://docs.astral.sh/uv/) and use Python 3.11-3.13. Dependencies are declared in explicit `runtime`, `ml`, and `dev` groups in `pyproject.toml`; exact cross-platform resolutions are committed in `uv.lock`.
```bash ```bash
cd /opt/data/btc-accumulation-monitor git clone <repository-url>
PYTHONPATH=. uv run \ cd btc-accumulation-monitor
--with fastapi \ uv sync --locked --group runtime --group ml --group dev
--with uvicorn \ ```
--with requests \
--with pandas \ Install the Chromium binary once for full on-chain refreshes. Keep its path explicit so installation and runtime use the same browser cache:
--with numpy \
--with scikit-learn \ ```bash
export PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright"
uv run --frozen playwright install chromium
```
`requirements_vps.txt` is a lock-derived, hash-pinned compatibility export for pip-based hosts. `pyproject.toml` and `uv.lock` remain authoritative; regenerate the compatibility file after dependency changes with:
```bash
uv export --frozen --no-dev --group runtime --group ml \
--no-emit-project --no-header --output-file requirements_vps.txt
```
## Running
The executable launcher fixes `PYTHONPATH`, preserves an explicitly supplied `PLAYWRIGHT_BROWSERS_PATH`, and starts port 3088 from the locked environment:
```bash
./scripts/run.sh
```
Equivalent exact command:
```bash
PLAYWRIGHT_BROWSERS_PATH="$PWD/.playwright" PYTHONPATH=. \
uv run --frozen --no-dev --group runtime --group ml \
python -m uvicorn dashboard.server:app --host 0.0.0.0 --port 3088 python -m uvicorn dashboard.server:app --host 0.0.0.0 --port 3088
``` ```
### VPS-style install Then visit `http://localhost:3088`.
## Container Deployment
The image uses a multi-architecture Python base, installs Playwright Chromium and its OS libraries during the build, and runs the application as non-root UID `10001`. Compose publishes port 3088, restarts unless stopped, and persists `/app/data` and `/app/config` in named volumes.
```bash ```bash
cd /opt/apps/btc-ml-optimizer docker compose build
python3 -m venv .venv docker compose up -d
. .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
``` ```
### pm2 The Docker and Compose healthchecks probe `GET /health/live`. The deployment must include the reliability revision that supplies that endpoint; without it, Docker correctly reports the container unhealthy even if the older application server is accepting requests.
Named volumes are initialized from the image on first use. Back up both before replacing or deleting them:
```bash ```bash
pm2 start "python3 -m uvicorn dashboard.server:app --host 0.0.0.0 --port 3088" --name btc-ml-optimizer docker volume inspect btc-accumulation-monitor_btc-monitor-data
docker volume inspect btc-accumulation-monitor_btc-monitor-config
``` ```
## First Run For bind-mounted deployments, ensure the host directories are writable by UID `10001` and do not replace `config/` with an empty directory.
1. Visit `http://localhost:3088` for the live dashboard. ## First Run and Data Freshness
2. Use **Quick Refresh** for fast price/Fear & Greed updates.
3. Use **Full Refresh** to re-scrape on-chain metrics. 1. Visit `http://localhost:3088` for the dashboard.
4. Visit `http://localhost:3088/backtest` to view historical score performance. 2. Use **Quick Refresh** for price and Fear & Greed updates while retaining cached slow-moving on-chain metrics.
5. If historical data is missing, use the backtest page's collection flow to populate `data/history.json`. 3. Use **Full Refresh** when on-chain source data must be re-scraped; this requires the installed Playwright Chromium browser and external source availability.
4. Visit `http://localhost:3088/backtest` for historical analysis.
5. If historical data is missing, populate `data/history.json` through the existing collection flow.
Freshness is metric-specific. Price and sentiment APIs can update frequently, while public on-chain chart sources commonly update daily and may be reused from cache. A successful refresh is not proof that every upstream metric has a new observation. Check source timestamps/status exposed by the running revision, and treat missing, stale, or scrape-failed metrics as unavailable rather than silently current. `data/` is operational state and should be persisted and backed up.
## ML and Backtest Caveats
ML weights and backtest output are research artifacts, not investment advice or evidence of future performance. Any reported ML result must retain its provenance: source-data snapshot/range, feature and label definitions, training window, purge/embargo policy, code revision, dependency lock, random seed (when applicable), and generated weight/config artifact.
Model selection and threshold tuning must use training/validation data only. Report final performance on a genuinely untouched out-of-sample (OOS) period; do not describe in-sample fit, cross-validation used for selection, or the best result from repeated experiments as OOS. Forward-return labels require purging overlapping label horizons, but purged cross-validation alone does not create an untouched final test set. Results without reproducible provenance and a reserved OOS evaluation should be labeled exploratory.
## Useful API Endpoints ## Useful API Endpoints
@@ -203,16 +247,19 @@ pm2 start "python3 -m uvicorn dashboard.server:app --host 0.0.0.0 --port 3088" -
| `GET /api/metric-context?metric=mvrv_zscore&mode=ml` | Similar historical levels and forward returns for one metric | | `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 | | `GET /api/settings` | Safe LLM settings payload |
## Testing ## Testing and CI
Focused tests can be run with uv: Run the committed test suite and the same static compilation gate used by Gitea Actions:
```bash ```bash
cd /opt/data/btc-accumulation-monitor uv sync --locked --group runtime --group ml --group dev
PYTHONPATH=. uv run --with pytest --with numpy --with scikit-learn --with pandas \ uv run --frozen python -m compileall -q \
pytest -q tests/test_ml_optimizer_validation.py tests/test_scoring_engine_ml.py dashboard scrapers scoring backtesting ml ml_engine llm_client scripts orchestrator.py
uv run --frozen pytest
``` ```
`.gitea/workflows/ci.yml` runs lock validation/install, static compilation, and tests for pull requests and pushes to `main`.
## Architecture ## Architecture
See [ARCHITECTURE.md](ARCHITECTURE.md) for deeper implementation details on scoring, data collection, and backtesting. See [ARCHITECTURE.md](ARCHITECTURE.md) for deeper implementation details on scoring, data collection, and backtesting.
+298 -39
View File
@@ -1,12 +1,18 @@
"""Historical backtest engine for Bitcoin Accumulation Zone scoring.""" """Historical backtest engine for Bitcoin Accumulation Zone scoring."""
import copy
import json import json
import logging import logging
import os import os
import sys import sys
import threading
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from scoring.policy import SCORE_BRACKETS, SCORE_VERSION, score_in_bracket
from ml.artifacts import validate_ml_artifact
from backtesting.statistics import summarize_returns
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -14,16 +20,14 @@ sys.path.insert(0, BASE_DIR)
HISTORY_PATH = os.path.join(BASE_DIR, "data", "history.json") HISTORY_PATH = os.path.join(BASE_DIR, "data", "history.json")
CACHE_PATH = os.path.join(BASE_DIR, "data", "cache.json") CACHE_PATH = os.path.join(BASE_DIR, "data", "cache.json")
ML_WEIGHTS_PATH = os.path.join(BASE_DIR, "config", "ml_weights.json")
_BACKTEST_CACHE = {}
_BACKTEST_CACHE_LOCK = threading.Lock()
_BACKTEST_CACHE_LIMIT = 4
# Score brackets matching the dashboard assessment levels # Score brackets matching the dashboard assessment levels
BRACKETS = [ BRACKETS = SCORE_BRACKETS
(0, 20, "Extreme Caution"),
(21, 40, "Caution"),
(41, 55, "Neutral"),
(56, 70, "Moderate Opportunity"),
(71, 85, "Strong Accumulation"),
(86, 100, "Extreme Accumulation"),
]
# Scoring thresholds — load from config/thresholds.json (single source of truth) # Scoring thresholds — load from config/thresholds.json (single source of truth)
import os as _os import os as _os
@@ -58,6 +62,22 @@ RATIO_SCORERS = {
}, },
} }
BACKTEST_METRIC_PANEL = tuple(METRIC_SCORERS) + tuple(RATIO_SCORERS) + ("drawdown",)
METRIC_MAX_AGE_DAYS = {
"fear_greed": 2,
"puell_multiple": 7,
"mvrv_zscore": 7,
"reserve_risk": 7,
"rhodl_ratio": 7,
"nupl": 7,
"btc_price": 3,
"btc_price_coingecko": 3,
"btc_price_sma": 3,
"btc_price_lth": 3,
"200w_sma": 7,
"lth_realized_price": 7,
}
DRAWDOWN_RANGES = _THRESH.get("drawdown", {}).get("ranges", [[60, None, 10], [40, 60, 8], [25, 40, 6], [15, 25, 4], [5, 15, 2], [None, 5, 0]]) DRAWDOWN_RANGES = _THRESH.get("drawdown", {}).get("ranges", [[60, None, 10], [40, 60, 8], [25, 40, 6], [15, 25, 4], [5, 15, 2], [None, 5, 0]])
@@ -94,8 +114,8 @@ def _get_all_dates(index):
return sorted(all_dates) return sorted(all_dates)
def _last_known_value(lookup, date, max_lookback=30): def _last_known_value(lookup, date, max_lookback=0):
"""Get value for date, or most recent prior value within lookback window.""" """Get value for date, or a prior value within an explicit lookback."""
if date in lookup: if date in lookup:
return lookup[date] return lookup[date]
d = datetime.strptime(date, "%Y-%m-%d") d = datetime.strptime(date, "%Y-%m-%d")
@@ -106,6 +126,17 @@ def _last_known_value(lookup, date, max_lookback=30):
return None return None
def _metric_observation(lookup, date, metric_key):
"""Return value, source date, and age under a metric-specific freshness rule."""
max_age = METRIC_MAX_AGE_DAYS.get(metric_key, 0)
target = datetime.strptime(date, "%Y-%m-%d")
for age in range(max_age + 1):
source_date = (target - timedelta(days=age)).strftime("%Y-%m-%d")
if source_date in lookup:
return lookup[source_date], source_date, age
return None, None, None
def _compute_ath_series(price_lookup, dates): def _compute_ath_series(price_lookup, dates):
"""Compute running ATH and drawdown for each date.""" """Compute running ATH and drawdown for each date."""
ath = 0 ath = 0
@@ -121,15 +152,70 @@ def _compute_ath_series(price_lookup, dates):
return drawdowns return drawdowns
def _load_ml_weights(): def _load_ml_artifact():
"""Load ML weights for ML-optimized scoring mode.""" """Load an ML artifact and return it with validation status."""
ml_path = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))), "config", "ml_weights.json") ml_path = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))), "config", "ml_weights.json")
try: try:
with open(ml_path) as f: with open(ml_path) as f:
data = _json.load(f) data = _json.load(f)
return data.get("weights", {}) status = validate_ml_artifact(data)
except Exception: if not status["valid"]:
return {} log.error("Rejected invalid ML artifact: %s", ", ".join(status["errors"]))
return None, status
return data, status
except Exception as exc:
return None, {"valid": False, "errors": [f"load_error:{exc}"]}
def _build_ml_backtest_plan(artifact):
"""Choose OOS fold weights when available; otherwise mark final weights in-sample."""
status = validate_ml_artifact(artifact)
if not status["valid"]:
raise ValueError("invalid ML artifact: " + ", ".join(status["errors"]))
if status["has_oos_fold_weights"]:
folds = []
for fold in artifact["cv_results"]["folds"]:
start, separator, end = fold["date_ranges"]["validation"].partition(" to ")
if not separator:
raise ValueError("invalid validation date range")
folds.append({
"fold": fold.get("fold"),
"start": start,
"end": end,
"weights": fold["weights"],
})
return {
"evaluation_scope": "out_of_sample_validation_folds",
"is_out_of_sample": True,
"weighting_source": "fold_specific_weights",
"folds": folds,
"weights": None,
}
return {
"evaluation_scope": "in_sample_full_history_weights",
"is_out_of_sample": False,
"weighting_source": "final_full_history_weights",
"folds": [],
"weights": artifact["weights"],
}
def _weights_for_backtest_date(date, plan):
"""Return date-appropriate weights and fold number for an ML plan."""
if plan["is_out_of_sample"]:
for fold in plan["folds"]:
if fold["start"] <= date <= fold["end"]:
return fold["weights"], fold["fold"]
return None, None
return plan["weights"], None
def _load_ml_weights():
"""Compatibility helper returning valid final weights only."""
artifact, _ = _load_ml_artifact()
return artifact.get("weights", {}) if artifact else {}
# ML weight key mapping (backtest metric keys -> ML weight keys) # ML weight key mapping (backtest metric keys -> ML weight keys)
_BT_ML_KEY_MAP = { _BT_ML_KEY_MAP = {
@@ -145,6 +231,58 @@ _BT_ML_KEY_MAP = {
} }
def _common_panel_current_score(scored, ml_weights=None):
"""Recompute the current score using only metrics present historically."""
by_key = {
metric.get("key"): metric.get("score")
for metric in scored.get("metrics", [])
if metric.get("key") in BACKTEST_METRIC_PANEL and metric.get("score") is not None
}
available_keys = [key for key in BACKTEST_METRIC_PANEL if key in by_key]
coverage = {
"available_count": len(available_keys),
"panel_count": len(BACKTEST_METRIC_PANEL),
"available_keys": available_keys,
}
if not available_keys:
return None, coverage
if ml_weights:
weighted = [
(by_key[key], ml_weights.get(_BT_ML_KEY_MAP[key], 0.0))
for key in available_keys
]
weight_total = sum(weight for _, weight in weighted)
if weight_total > 0:
return round(sum(score * weight for score, weight in weighted) / weight_total * 10, 1), coverage
return round(sum(by_key[key] for key in available_keys) / len(available_keys) * 10, 1), coverage
def _backtest_data_quality_metadata(metric_counts):
"""Describe historical panel, coverage, and freshness assumptions."""
coverage = {
"minimum_metrics": min(metric_counts),
"maximum_metrics": max(metric_counts),
"average_metrics": round(sum(metric_counts) / len(metric_counts), 1),
"panel_count": len(BACKTEST_METRIC_PANEL),
} if metric_counts else {
"minimum_metrics": 0,
"maximum_metrics": 0,
"average_metrics": 0,
"panel_count": len(BACKTEST_METRIC_PANEL),
}
return {
"metric_panel": {
"id": "historical-common-v1",
"keys": list(BACKTEST_METRIC_PANEL),
"count": len(BACKTEST_METRIC_PANEL),
},
"coverage": coverage,
"staleness_days": dict(METRIC_MAX_AGE_DAYS),
}
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, details, n_metrics). """Score a single day using all available metrics. Returns (composite_score, details, n_metrics).
@@ -156,29 +294,47 @@ def score_day(date, index, drawdowns, ml_weights=None):
# Simple range-based metrics # Simple range-based metrics
for metric_key, cfg in METRIC_SCORERS.items(): for metric_key, cfg in METRIC_SCORERS.items():
val = _last_known_value(index.get(metric_key, {}), date) val, observed_date, age_days = _metric_observation(
index.get(metric_key, {}), date, metric_key
)
if val is not None: if val is not 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, "raw": val} details[metric_key] = {
"value": val,
"score": s,
"raw": val,
"observed_date": observed_date,
"age_days": age_days,
}
# 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():
price_val = _last_known_value(index.get(cfg["price_key"], {}), date) price_val, price_date, price_age = _metric_observation(
# Try alternate price keys index.get(cfg["price_key"], {}), date, cfg["price_key"]
)
# Try alternate price keys, each with an explicit freshness rule.
if price_val is None: if price_val is None:
for pk in ["btc_price_coingecko", "btc_price_sma", "btc_price_lth"]: for pk in ["btc_price_coingecko", "btc_price_sma", "btc_price_lth"]:
price_val = _last_known_value(index.get(pk, {}), date) price_val, price_date, price_age = _metric_observation(index.get(pk, {}), date, pk)
if price_val is not None: if price_val is not None:
break break
ref_val = _last_known_value(index.get(cfg["ref_key"], {}), date) ref_val, ref_date, ref_age = _metric_observation(
index.get(cfg["ref_key"], {}), date, cfg["ref_key"]
)
if price_val is not None and ref_val is not None and ref_val > 0: if price_val is not None and ref_val is not None and ref_val > 0:
pct_above = ((price_val - ref_val) / ref_val) * 100 pct_above = ((price_val - ref_val) / ref_val) * 100
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, "raw": pct_above} details[metric_key] = {
"value": pct_above,
"score": s,
"raw": pct_above,
"observed_date": min(price_date, ref_date),
"age_days": max(price_age, ref_age),
}
# Drawdown # Drawdown
dd = drawdowns.get(date) dd = drawdowns.get(date)
@@ -250,7 +406,62 @@ def compute_max_drawdown_forward(price_lookup, date, window=90):
return round(max_dd, 2) if max_dd > 0 else 0 return round(max_dd, 2) if max_dd > 0 else 0
def _file_signature(path):
"""Return a cheap signature that invalidates when an input file changes."""
try:
stat = os.stat(path)
return path, stat.st_mtime_ns, stat.st_size
except OSError:
return path, None, None
def clear_backtest_cache():
"""Clear memoized backtest results (primarily for explicit refreshes/tests)."""
with _BACKTEST_CACHE_LOCK:
_BACKTEST_CACHE.clear()
def _add_return_statistics(stats, period, returns):
"""Add return summaries and a moving-block-bootstrap mean interval."""
horizon_days = int(period.removesuffix("d"))
summary = summarize_returns(
returns,
block_size=min(horizon_days, len(returns)),
n_resamples=400,
)
stats[f"avg_{period}"] = summary["mean"]
stats[f"median_{period}"] = summary["median"]
stats[f"win_rate_{period}"] = summary["win_rate"]
stats[f"avg_{period}_ci_low"] = summary["mean_ci_low"]
stats[f"avg_{period}_ci_high"] = summary["mean_ci_high"]
stats[f"max_gain_{period}"] = round(max(returns), 2)
stats[f"max_loss_{period}"] = round(min(returns), 2)
stats[f"n_{period}"] = summary["n"]
def run_backtest(ml_mode=False): def run_backtest(ml_mode=False):
"""Return an isolated cached result keyed by all material input files."""
signature = (
bool(ml_mode),
_file_signature(HISTORY_PATH),
_file_signature(_THRESH_PATH),
_file_signature(ML_WEIGHTS_PATH),
_file_signature(CACHE_PATH),
)
with _BACKTEST_CACHE_LOCK:
cached = _BACKTEST_CACHE.get(signature)
if cached is not None:
return copy.deepcopy(cached)
result = _compute_backtest(ml_mode=ml_mode)
with _BACKTEST_CACHE_LOCK:
_BACKTEST_CACHE[signature] = copy.deepcopy(result)
while len(_BACKTEST_CACHE) > _BACKTEST_CACHE_LIMIT:
_BACKTEST_CACHE.pop(next(iter(_BACKTEST_CACHE)))
return copy.deepcopy(result)
def _compute_backtest(ml_mode=False):
"""Run the full backtest and return comprehensive results. """Run the full backtest and return comprehensive results.
If ml_mode=True, uses ML-optimized metric weights instead of equal weights. If ml_mode=True, uses ML-optimized metric weights instead of equal weights.
@@ -285,16 +496,28 @@ def run_backtest(ml_mode=False):
log.info("Computing forward returns...") log.info("Computing forward returns...")
fwd_returns = compute_forward_returns(price_lookup, all_dates) fwd_returns = compute_forward_returns(price_lookup, all_dates)
# Load ML weights if in ML mode # Build an explicit evaluation plan. Fold-specific validation weights are OOS;
ml_weights = _load_ml_weights() if ml_mode else None # final weights fitted on full history are never represented as OOS.
if ml_mode and not ml_weights: ml_plan = None
log.warning("ML mode requested but no weights found — falling back to equal weights") ml_artifact = None
ml_weights = None ml_artifact_status = None
if ml_mode:
ml_artifact, ml_artifact_status = _load_ml_artifact()
if ml_artifact:
ml_plan = _build_ml_backtest_plan(ml_artifact)
else:
log.warning("ML mode requested with invalid artifact — falling back to equal weights")
# Score each day # Score each day
log.info("Scoring %d days...", len(all_dates)) log.info("Scoring %d days...", len(all_dates))
daily_scores = [] daily_scores = []
for d in all_dates: for d in all_dates:
ml_weights = None
ml_fold = None
if ml_plan:
ml_weights, ml_fold = _weights_for_backtest_date(d, ml_plan)
if ml_plan["is_out_of_sample"] and ml_weights is None:
continue
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)
@@ -312,6 +535,8 @@ def run_backtest(ml_mode=False):
"forward_returns": fwd_returns.get(d, {}), "forward_returns": fwd_returns.get(d, {}),
"metric_values": metric_values, "metric_values": metric_values,
} }
if ml_fold is not None:
entry["ml_fold"] = ml_fold
daily_scores.append(entry) daily_scores.append(entry)
if not daily_scores: if not daily_scores:
@@ -322,7 +547,7 @@ def run_backtest(ml_mode=False):
# --- Bracket statistics --- # --- Bracket statistics ---
bracket_stats = [] bracket_stats = []
for low, high, label in BRACKETS: for low, high, label in BRACKETS:
days_in = [d for d in daily_scores if low <= d["score"] <= high] days_in = [d for d in daily_scores if score_in_bracket(d["score"], (low, high, label))]
if not days_in: if not days_in:
bracket_stats.append({ bracket_stats.append({
"range": f"{low}-{high}", "label": label, "days": 0, "range": f"{low}-{high}", "label": label, "days": 0,
@@ -333,13 +558,7 @@ def run_backtest(ml_mode=False):
for period in ["30d", "90d", "180d", "365d"]: for period in ["30d", "90d", "180d", "365d"]:
returns = [d["forward_returns"][period] for d in days_in if period in d["forward_returns"]] returns = [d["forward_returns"][period] for d in days_in if period in d["forward_returns"]]
if returns: if returns:
returns_sorted = sorted(returns) _add_return_statistics(stats, period, returns)
stats[f"avg_{period}"] = round(sum(returns) / len(returns), 2)
stats[f"median_{period}"] = round(returns_sorted[len(returns_sorted) // 2], 2)
stats[f"win_rate_{period}"] = round(len([r for r in returns if r > 0]) / len(returns) * 100, 1)
stats[f"max_gain_{period}"] = round(max(returns), 2)
stats[f"max_loss_{period}"] = round(min(returns), 2)
stats[f"n_{period}"] = len(returns)
# Average max drawdown within 90 days # Average max drawdown within 90 days
dd_list = [] dd_list = []
@@ -383,23 +602,30 @@ def run_backtest(ml_mode=False):
all_scores_list = [d["score"] for d in daily_scores] all_scores_list = [d["score"] for d in daily_scores]
all_scores_list.sort() all_scores_list.sort()
# Get current score from cache # Get current score from cache, recomputed on the common historical panel.
current_score = None current_score = None
current_price = None current_price = None
current_coverage = None
if os.path.exists(CACHE_PATH): if os.path.exists(CACHE_PATH):
try: try:
with open(CACHE_PATH) as f: with open(CACHE_PATH) as f:
cache = json.load(f) cache = json.load(f)
scored = cache.get("_scored", {}) scored = cache.get("_scored", {})
current_score = scored.get("composite_score") current_ml_weights = ml_artifact.get("weights") if ml_mode and ml_artifact else None
current_score, current_coverage = _common_panel_current_score(scored, current_ml_weights)
current_price = cache.get("price", {}).get("price") current_price = cache.get("price", {}).get("price")
except Exception: except Exception:
pass pass
# If no cache, use latest daily score # If no comparable cache panel is available, use latest historical score.
if current_score is None and daily_scores: if current_score is None and daily_scores:
current_score = daily_scores[-1]["score"] current_score = daily_scores[-1]["score"]
current_price = daily_scores[-1].get("price") current_price = daily_scores[-1].get("price")
current_coverage = {
"available_count": daily_scores[-1]["n_metrics"],
"panel_count": len(BACKTEST_METRIC_PANEL),
"available_keys": list(daily_scores[-1].get("metric_values", {})),
}
current_context = None current_context = None
if current_score is not None: if current_score is not None:
@@ -459,6 +685,12 @@ def run_backtest(ml_mode=False):
current_context = { current_context = {
"current_score": current_score, "current_score": current_score,
"current_price": current_price, "current_price": current_price,
"score_version": SCORE_VERSION,
"metric_panel_id": "historical-common-v1",
"coverage": current_coverage,
"current_weighting_source": (
"final_full_history_weights" if ml_mode and ml_artifact else "equal_weight"
),
"percentile": percentile, "percentile": percentile,
"comparable_days": len(comparable), "comparable_days": len(comparable),
"avg_1yr_return": avg_1yr, "avg_1yr_return": avg_1yr,
@@ -495,17 +727,44 @@ def run_backtest(ml_mode=False):
# Include per-metric values (raw metric value, not score) # Include per-metric values (raw metric value, not score)
metric_vals = d.get("metric_values", {}) metric_vals = d.get("metric_values", {})
if metric_vals: if metric_vals:
entry["metrics"] = metric_vals entry["metric_values"] = metric_vals
chart_data.append(entry) chart_data.append(entry)
if not ml_mode:
ml_evaluation = {"requested": False, "is_out_of_sample": False}
elif ml_plan:
ml_evaluation = {
"requested": True,
"evaluation_scope": ml_plan["evaluation_scope"],
"is_out_of_sample": ml_plan["is_out_of_sample"],
"weighting_source": ml_plan["weighting_source"],
"folds": len(ml_plan["folds"]),
"artifact": ml_artifact_status,
}
else:
ml_evaluation = {
"requested": True,
"evaluation_scope": "equal_weight_fallback",
"is_out_of_sample": False,
"weighting_source": "none_invalid_artifact",
"folds": 0,
"artifact": ml_artifact_status,
}
data_quality = _backtest_data_quality_metadata([day["n_metrics"] for day in daily_scores])
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"]},
"total_days_scored": len(daily_scores), "total_days_scored": len(daily_scores),
"metric_panel": data_quality["metric_panel"],
"coverage": data_quality["coverage"],
"staleness_days": data_quality["staleness_days"],
"bracket_stats": bracket_stats, "bracket_stats": bracket_stats,
"signal_events": signal_events, "signal_events": signal_events,
"current_context": current_context, "current_context": current_context,
"chart_data": chart_data, "chart_data": chart_data,
"ml_mode": ml_mode, "ml_mode": ml_mode,
"ml_evaluation": ml_evaluation,
"score_version": SCORE_VERSION,
"computed_at": datetime.utcnow().isoformat() + "Z", "computed_at": datetime.utcnow().isoformat() + "Z",
} }
+85
View File
@@ -0,0 +1,85 @@
"""Statistical helpers for honest time-series backtest reporting."""
from __future__ import annotations
import math
import random
import statistics as stdlib_statistics
from collections.abc import Iterable
def _quantile(sorted_values: list[float], probability: float) -> float:
position = (len(sorted_values) - 1) * probability
lower = math.floor(position)
upper = math.ceil(position)
if lower == upper:
return sorted_values[lower]
fraction = position - lower
return sorted_values[lower] * (1 - fraction) + sorted_values[upper] * fraction
def moving_block_bootstrap_ci(
values: Iterable[float],
*,
block_size: int = 30,
n_resamples: int = 1_000,
confidence: float = 0.95,
seed: int = 42,
) -> dict[str, float | int]:
"""Estimate a mean and CI while preserving local serial dependence."""
series = [float(value) for value in values]
if not series:
raise ValueError("values must not be empty")
if block_size < 1 or block_size > len(series):
raise ValueError("block_size must be between 1 and the number of values")
if n_resamples < 2:
raise ValueError("n_resamples must be at least 2")
if not 0 < confidence < 1:
raise ValueError("confidence must be between 0 and 1")
rng = random.Random(seed)
sample_means: list[float] = []
final_start = len(series) - block_size
for _ in range(n_resamples):
sample: list[float] = []
while len(sample) < len(series):
start = rng.randint(0, final_start)
sample.extend(series[start:start + block_size])
sample = sample[:len(series)]
sample_means.append(sum(sample) / len(sample))
sample_means.sort()
tail = (1 - confidence) / 2
return {
"estimate": sum(series) / len(series),
"ci_low": _quantile(sample_means, tail),
"ci_high": _quantile(sample_means, 1 - tail),
"n": len(series),
}
def summarize_returns(
values: Iterable[float],
*,
block_size: int = 30,
n_resamples: int = 1_000,
confidence: float = 0.95,
seed: int = 42,
) -> dict[str, float | int]:
"""Summarize realized returns with an autocorrelation-aware mean CI."""
series = [float(value) for value in values]
interval = moving_block_bootstrap_ci(
series,
block_size=min(block_size, len(series)),
n_resamples=n_resamples,
confidence=confidence,
seed=seed,
)
return {
"n": len(series),
"mean": round(float(interval["estimate"]), 2),
"median": round(stdlib_statistics.median(series), 2),
"win_rate": round(sum(value > 0 for value in series) / len(series) * 100, 1),
"mean_ci_low": round(float(interval["ci_low"]), 2),
"mean_ci_high": round(float(interval["ci_high"]), 2),
}
+6 -1
View File
@@ -27,6 +27,11 @@
0.3, 0.3,
0.5 0.5
], ],
"return_scales_pct": [
10.0,
30.0,
60.0
],
"score_range": [ "score_range": [
0, 0,
100 100
@@ -61,7 +66,7 @@
"rolling_test_size": 300, "rolling_test_size": 300,
"walk_forward_windows": 5, "walk_forward_windows": 5,
"train_pct": 0.7, "train_pct": 0.7,
"validation_pct": 0.15, "validation_pct": 0.3,
"test_pct": 0.15 "test_pct": 0.15
}, },
"timeframe": "4h" "timeframe": "4h"
+6 -1
View File
@@ -27,6 +27,11 @@
0.3, 0.3,
0.5 0.5
], ],
"return_scales_pct": [
10.0,
30.0,
60.0
],
"score_range": [ "score_range": [
0, 0,
100 100
@@ -61,7 +66,7 @@
"rolling_test_size": 300, "rolling_test_size": 300,
"walk_forward_windows": 5, "walk_forward_windows": 5,
"train_pct": 0.7, "train_pct": 0.7,
"validation_pct": 0.15, "validation_pct": 0.3,
"test_pct": 0.15 "test_pct": 0.15
}, },
"timeframe": "4h" "timeframe": "4h"
+6 -1
View File
@@ -27,6 +27,11 @@
0.3, 0.3,
0.5 0.5
], ],
"return_scales_pct": [
10.0,
30.0,
60.0
],
"score_range": [ "score_range": [
0, 0,
100 100
@@ -61,7 +66,7 @@
"rolling_test_size": 300, "rolling_test_size": 300,
"walk_forward_windows": 5, "walk_forward_windows": 5,
"train_pct": 0.7, "train_pct": 0.7,
"validation_pct": 0.15, "validation_pct": 0.3,
"test_pct": 0.15 "test_pct": 0.15
}, },
"timeframe": "4h" "timeframe": "4h"
+21
View File
@@ -0,0 +1,21 @@
{
"provider": "ollama",
"model": "qwen3.5:27b",
"providers": {
"ollama": {
"base_url": "http://127.0.0.1:11434"
},
"lmstudio": {
"base_url": "http://127.0.0.1:1234"
},
"openai": {
"api_key": ""
},
"anthropic": {
"api_key": ""
},
"openrouter": {
"api_key": ""
}
}
}
-21
View File
@@ -1,21 +0,0 @@
{
"provider": "ollama",
"model": "gemma4:12b-mlx",
"providers": {
"ollama": {
"base_url": "http://100.79.255.5:11434"
},
"lmstudio": {
"base_url": "http://100.100.242.21:1234"
},
"openai": {
"api_key": ""
},
"anthropic": {
"api_key": ""
},
"openrouter": {
"api_key": "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"
}
}
}
+335 -111
View File
@@ -1,159 +1,383 @@
{ {
"artifact_schema_version": 2,
"score_version": "accumulation-score-v2",
"weights": { "weights": {
"pct_above_200w_sma": 0.5075, "pct_above_200w_sma": 0.5075,
"drawdown": 0.1459, "drawdown": 0.1716,
"pct_above_lth_rp": 0.1095, "pct_above_lth_rp": 0.0934,
"rhodl_ratio": 0.089, "rhodl_ratio": 0.08,
"fear_greed": 0.0515, "fear_greed": 0.0459,
"reserve_risk": 0.046, "puell_multiple": 0.0384,
"puell_multiple": 0.0255, "reserve_risk": 0.0335,
"mvrv_zscore": 0.0182, "mvrv_zscore": 0.018,
"nupl": 0.0068 "nupl": 0.0116
}, },
"feature_importances": { "feature_importances": {
"raw_pct_above_200w_sma": 0.436377, "raw_pct_above_200w_sma": 0.448165,
"days_since_ath": 0.119405, "days_since_ath": 0.138053,
"raw_pct_above_lth_rp": 0.109451, "raw_pct_above_lth_rp": 0.093426,
"raw_rhodl_ratio": 0.088999, "raw_rhodl_ratio": 0.080038,
"score_pct_above_200w_sma": 0.071148, "score_pct_above_200w_sma": 0.059336,
"raw_fear_greed": 0.051475, "raw_fear_greed": 0.04573,
"puell_x_reserve": 0.032886, "puell_x_reserve": 0.034997,
"raw_drawdown": 0.026474, "raw_drawdown": 0.033552,
"raw_reserve_risk": 0.021707, "raw_puell_multiple": 0.02023,
"raw_mvrv_zscore": 0.012429, "raw_reserve_risk": 0.012477,
"raw_puell_multiple": 0.008599, "raw_mvrv_zscore": 0.011366,
"delta_30d_reserve_risk": 0.007865, "mvrv_x_nupl": 0.008204,
"delta_30d_mvrv_zscore": 0.004263, "raw_nupl": 0.003843,
"raw_nupl": 0.003271, "delta_30d_nupl": 0.003624,
"mvrv_x_nupl": 0.002979, "delta_30d_reserve_risk": 0.003541,
"delta_30d_nupl": 0.002056, "delta_30d_mvrv_zscore": 0.002548,
"delta_30d_puell_multiple": 0.000473, "delta_30d_puell_multiple": 0.000677,
"score_fear_greed": 6.8e-05, "score_fear_greed": 0.000182,
"score_mvrv_zscore": 5.4e-05, "score_rhodl_ratio": 1.1e-05,
"score_puell_multiple": 1e-05, "score_puell_multiple": 0.0,
"score_pct_above_lth_rp": 6e-06, "score_mvrv_zscore": 0.0,
"score_rhodl_ratio": 2e-06,
"score_reserve_risk": 0.0, "score_reserve_risk": 0.0,
"score_nupl": 0.0, "score_nupl": 0.0,
"score_drawdown": 0.0 "score_drawdown": 0.0,
"score_pct_above_lth_rp": 0.0
}, },
"cv_results": { "cv_results": {
"mean_auc": 0.6164, "mean_auc": 0.7667,
"std_auc": 0.3317, "std_auc": 0.1734,
"mean_f1": 0.6736, "mean_f1": 0.4085,
"mean_precision": 0.8015, "mean_precision": 0.3708,
"mean_recall": 0.7047 "mean_recall": 0.4898,
"validation_method": "purged_expanding_window",
"label_horizon_days": 365,
"folds": [
{
"fold": 1,
"weights": {
"pct_above_lth_rp": 0.7418,
"drawdown": 0.1523,
"reserve_risk": 0.0339,
"mvrv_zscore": 0.0211,
"puell_multiple": 0.0164,
"rhodl_ratio": 0.0141,
"nupl": 0.0128,
"pct_above_200w_sma": 0.0043,
"fear_greed": 0.0033
},
"metrics": {
"auc": 0.7583,
"f1": 0.0,
"precision": 0.0,
"recall": 0.0
},
"date_ranges": {
"train": "2018-02-01 to 2019-08-05",
"validation": "2020-08-04 to 2021-10-31"
},
"n_train": 548,
"n_validation": 454
},
{
"fold": 2,
"weights": {
"reserve_risk": 0.4125,
"puell_multiple": 0.3942,
"drawdown": 0.085,
"pct_above_lth_rp": 0.0429,
"pct_above_200w_sma": 0.0324,
"rhodl_ratio": 0.0135,
"nupl": 0.0131,
"mvrv_zscore": 0.0053,
"fear_greed": 0.001
},
"metrics": {
"auc": 0.8346,
"f1": 0.6582,
"precision": 0.4906,
"recall": 1.0
},
"date_ranges": {
"train": "2018-02-01 to 2020-11-01",
"validation": "2021-11-01 to 2023-01-28"
},
"n_train": 1002,
"n_validation": 454
},
{
"fold": 3,
"weights": {
"reserve_risk": 0.2919,
"puell_multiple": 0.2495,
"drawdown": 0.1619,
"pct_above_lth_rp": 0.1028,
"pct_above_200w_sma": 0.089,
"fear_greed": 0.0754,
"nupl": 0.0103,
"mvrv_zscore": 0.0099,
"rhodl_ratio": 0.0091
},
"metrics": {
"auc": 0.9755,
"f1": 0.9757,
"precision": 0.9926,
"recall": 0.9593
},
"date_ranges": {
"train": "2018-02-01 to 2022-01-29",
"validation": "2023-01-29 to 2024-04-26"
},
"n_train": 1456,
"n_validation": 454
},
{
"fold": 4,
"weights": {
"drawdown": 0.6216,
"reserve_risk": 0.1302,
"puell_multiple": 0.1161,
"fear_greed": 0.0554,
"rhodl_ratio": 0.0322,
"pct_above_lth_rp": 0.0163,
"mvrv_zscore": 0.0117,
"pct_above_200w_sma": 0.0094,
"nupl": 0.0071
},
"metrics": {
"auc": 0.4983,
"f1": 0.0,
"precision": 0.0,
"recall": 0.0
},
"date_ranges": {
"train": "2018-02-01 to 2023-04-28",
"validation": "2024-04-27 to 2025-07-26"
},
"n_train": 1910,
"n_validation": 454
}
]
}, },
"training_info": { "training_info": {
"n_samples": 2601, "n_samples": 2728,
"n_positive": 1553, "n_positive": 1554,
"positive_rate": 0.5971, "positive_rate": 0.5696,
"n_features": 25, "n_features": 25,
"target_threshold": 30.0, "target_threshold": 30.0,
"date_range": "2018-02-01 to 2025-03-21", "date_range": "2018-02-01 to 2025-07-26",
"model": "GradientBoostingClassifier" "model": "GradientBoostingClassifier"
}, },
"provenance": {
"validation_method": "purged_expanding_window",
"label_horizon_days": 365,
"weight_scope": "full_history_fit",
"training_date_range": {
"start": "2018-02-01",
"end": "2025-07-26"
},
"trained_at": "2026-07-26T23:19:01.231759+00:00"
},
"comparison": { "comparison": {
"equal_weight": [ "equal_weight": [
{ {
"range": "0-20", "range": "0-20",
"label": "Extreme Caution", "label": "EXTREME CAUTION",
"days": 295, "days": 286,
"avg_365d": -5.94, "avg_365d": -7.25,
"median_365d": -11.99, "median_365d": -13.89,
"win_rate_365d": 34.3
},
{
"range": "20-35",
"label": "CAUTION \u2014 OVERHEATED",
"days": 537,
"avg_365d": 8.85,
"median_365d": -21.49,
"win_rate_365d": 35.6 "win_rate_365d": 35.6
}, },
{ {
"range": "21-40", "range": "35-50",
"label": "Caution", "label": "NEUTRAL",
"days": 587, "days": 660,
"avg_365d": 23.84, "avg_365d": 85.33,
"median_365d": -7.2, "median_365d": 16.54,
"win_rate_365d": 45.3 "win_rate_365d": 60.8
}, },
{ {
"range": "41-55", "range": "50-65",
"label": "Neutral", "label": "MODERATE OPPORTUNITY",
"days": 697, "days": 575,
"avg_365d": 108.96, "avg_365d": 113.54,
"median_365d": 75.92, "median_365d": 88.64,
"win_rate_365d": 70.4 "win_rate_365d": 88.5
}, },
{ {
"range": "56-70", "range": "65-80",
"label": "Moderate Opportunity", "label": "STRONG ACCUMULATION ZONE",
"days": 450, "days": 339,
"avg_365d": 128.81, "avg_365d": 183.9,
"median_365d": 109.03, "median_365d": 128.5,
"win_rate_365d": 96.4 "win_rate_365d": 89.7
}, },
{ {
"range": "71-85", "range": "80-100",
"label": "Strong Accumulation", "label": "EXTREME ACCUMULATION ZONE",
"days": 275, "days": 331,
"avg_365d": 175.76, "avg_365d": 120.82,
"median_365d": 117.95, "median_365d": 91.85,
"win_rate_365d": 86.9 "win_rate_365d": 99.7
},
{
"range": "86-100",
"label": "Extreme Accumulation",
"days": 247,
"avg_365d": 115.5,
"median_365d": 90.08,
"win_rate_365d": 100.0
} }
], ],
"ml_weighted": [ "ml_weighted": [
{ {
"range": "0-20", "range": "0-20",
"label": "Extreme Caution", "label": "EXTREME CAUTION",
"days": 577, "days": 642,
"avg_365d": -6.17, "avg_365d": -7.11,
"median_365d": -26.21, "median_365d": -26.48,
"win_rate_365d": 27.0 "win_rate_365d": 25.1
}, },
{ {
"range": "21-40", "range": "20-35",
"label": "Caution", "label": "CAUTION \u2014 OVERHEATED",
"days": 855, "days": 679,
"avg_365d": 77.5, "avg_365d": 18.61,
"median_365d": 39.28, "median_365d": 4.67,
"win_rate_365d": 72.7 "win_rate_365d": 53.2
}, },
{ {
"range": "41-55", "range": "35-50",
"label": "Neutral", "label": "NEUTRAL",
"days": 241, "days": 462,
"avg_365d": 165.77, "avg_365d": 138.2,
"median_365d": 124.05, "median_365d": 95.1,
"win_rate_365d": 92.5 "win_rate_365d": 87.0
}, },
{ {
"range": "56-70", "range": "50-65",
"label": "Moderate Opportunity", "label": "MODERATE OPPORTUNITY",
"days": 328, "days": 277,
"avg_365d": 144.47, "avg_365d": 206.31,
"median_365d": 124.27, "median_365d": 163.37,
"win_rate_365d": 89.6 "win_rate_365d": 87.7
}, },
{ {
"range": "71-85", "range": "65-80",
"label": "Strong Accumulation", "label": "STRONG ACCUMULATION ZONE",
"days": 201, "days": 285,
"avg_365d": 210.2, "avg_365d": 182.42,
"median_365d": 122.22, "median_365d": 123.75,
"win_rate_365d": 99.0 "win_rate_365d": 99.3
}, },
{ {
"range": "86-100", "range": "80-100",
"label": "Extreme Accumulation", "label": "EXTREME ACCUMULATION ZONE",
"days": 287, "days": 383,
"avg_365d": 113.92, "avg_365d": 118.93,
"median_365d": 99.53, "median_365d": 119.03,
"win_rate_365d": 100.0 "win_rate_365d": 100.0
} }
] ]
}, },
"trained_at": "2026-03-21T23:15:38.277703+00:00" "out_of_sample_comparison": {
"folds": 4,
"validation_days": 1816,
"equal_weight": [
{
"range": "0-20",
"label": "EXTREME CAUTION",
"days": 281,
"avg_365d": -6.2,
"median_365d": -13.66,
"win_rate_365d": 34.9
},
{
"range": "20-35",
"label": "CAUTION \u2014 OVERHEATED",
"days": 432,
"avg_365d": 20.09,
"median_365d": -14.6,
"win_rate_365d": 43.1
},
{
"range": "35-50",
"label": "NEUTRAL",
"days": 419,
"avg_365d": 71.09,
"median_365d": -9.19,
"win_rate_365d": 48.9
},
{
"range": "50-65",
"label": "MODERATE OPPORTUNITY",
"days": 255,
"avg_365d": 101.47,
"median_365d": 103.63,
"win_rate_365d": 76.9
},
{
"range": "65-80",
"label": "STRONG ACCUMULATION ZONE",
"days": 237,
"avg_365d": 100.95,
"median_365d": 122.53,
"win_rate_365d": 85.2
},
{
"range": "80-100",
"label": "EXTREME ACCUMULATION ZONE",
"days": 192,
"avg_365d": 76.91,
"median_365d": 51.03,
"win_rate_365d": 99.5
}
],
"ml_weighted": [
{
"range": "0-20",
"label": "EXTREME CAUTION",
"days": 371,
"avg_365d": 9.72,
"median_365d": -19.7,
"win_rate_365d": 31.3
},
{
"range": "20-35",
"label": "CAUTION \u2014 OVERHEATED",
"days": 372,
"avg_365d": 13.85,
"median_365d": -10.98,
"win_rate_365d": 42.2
},
{
"range": "35-50",
"label": "NEUTRAL",
"days": 263,
"avg_365d": 107.81,
"median_365d": 76.78,
"win_rate_365d": 59.7
},
{
"range": "50-65",
"label": "MODERATE OPPORTUNITY",
"days": 348,
"avg_365d": 64.05,
"median_365d": 99.55,
"win_rate_365d": 64.9
},
{
"range": "65-80",
"label": "STRONG ACCUMULATION ZONE",
"days": 223,
"avg_365d": 121.71,
"median_365d": 126.27,
"win_rate_365d": 93.7
},
{
"range": "80-100",
"label": "EXTREME ACCUMULATION ZONE",
"days": 239,
"avg_365d": 61.69,
"median_365d": 42.02,
"win_rate_365d": 89.1
}
]
},
"trained_at": "2026-07-26T23:19:01.231759+00:00"
} }
+111
View File
@@ -0,0 +1,111 @@
"""Persistent, thread-safe background job state."""
from __future__ import annotations
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from dashboard.persistence import atomic_write_json, load_json
_ACTIVE = {"queued", "running"}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
class JobRegistry:
"""Reserve jobs before spawning and persist their lifecycle."""
def __init__(self, path: str | Path, *, history_limit: int = 100):
self.path = Path(path)
self.history_limit = history_limit
self._lock = threading.RLock()
loaded = load_json(self.path, {"jobs": []}) or {"jobs": []}
self._jobs = {
job["id"]: dict(job)
for job in loaded.get("jobs", [])
if isinstance(job, dict) and job.get("id")
}
changed = False
for job in self._jobs.values():
if job.get("status") in _ACTIVE:
job.update(
status="interrupted",
finished_at=_now(),
error="process restarted before job completed",
)
changed = True
if changed:
self._save_locked()
def _save_locked(self) -> None:
jobs = sorted(self._jobs.values(), key=lambda job: job.get("created_at", ""))
if len(jobs) > self.history_limit:
keep = jobs[-self.history_limit :]
self._jobs = {job["id"]: job for job in keep}
jobs = keep
atomic_write_json(self.path, {"jobs": jobs})
def reserve(self, kind: str, *, details: dict[str, Any] | None = None) -> dict[str, Any] | None:
with self._lock:
if any(
job.get("kind") == kind and job.get("status") in _ACTIVE
for job in self._jobs.values()
):
return None
job = {
"id": uuid.uuid4().hex,
"kind": kind,
"status": "queued",
"created_at": _now(),
"started_at": None,
"finished_at": None,
"progress": {},
"details": details or {},
"result": None,
"error": None,
}
self._jobs[job["id"]] = job
self._save_locked()
return dict(job)
def get(self, job_id: str) -> dict[str, Any] | None:
with self._lock:
job = self._jobs.get(job_id)
return dict(job) if job else None
def active(self, kind: str) -> dict[str, Any] | None:
with self._lock:
for job in self._jobs.values():
if job.get("kind") == kind and job.get("status") in _ACTIVE:
return dict(job)
return None
def update_progress(self, job_id: str, progress: dict[str, Any]) -> None:
with self._lock:
job = self._jobs[job_id]
job["progress"] = dict(progress)
self._save_locked()
def run(self, job_id: str, operation: Callable[[], Any]) -> Any:
with self._lock:
job = self._jobs[job_id]
if job["status"] != "queued":
raise RuntimeError(f"job {job_id} is not queued")
job.update(status="running", started_at=_now())
self._save_locked()
try:
result = operation()
except Exception as exc:
with self._lock:
job.update(status="error", error=str(exc), finished_at=_now())
self._save_locked()
raise
with self._lock:
job.update(status="complete", result=result, finished_at=_now())
self._save_locked()
return result
+222
View File
@@ -0,0 +1,222 @@
"""Small, dependency-free persistence primitives for dashboard state."""
from __future__ import annotations
import json
import os
import tempfile
import threading
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterator
try:
import fcntl
except ImportError: # pragma: no cover - Windows fallback uses the process lock
fcntl = None
_LOCKS: dict[str, threading.RLock] = {}
_LOCKS_GUARD = threading.Lock()
_METADATA_KEYS = {"observed_at", "source", "stale", "last_error", "error"}
def _thread_lock(path: Path) -> threading.RLock:
key = str(path.resolve())
with _LOCKS_GUARD:
return _LOCKS.setdefault(key, threading.RLock())
@contextmanager
def file_lock(path: str | os.PathLike[str]) -> Iterator[None]:
"""Serialize readers/writers across threads and, on POSIX, processes."""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
lock_path = target.with_name(f".{target.name}.lock")
with _thread_lock(target):
with lock_path.open("a+b") as lock_file:
if fcntl is not None:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
if fcntl is not None:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
def atomic_write_json(path: str | os.PathLike[str], data: Any, *, indent: int = 2) -> None:
"""Durably replace a JSON file without exposing a partial document."""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
with file_lock(target):
fd, temporary = tempfile.mkstemp(
prefix=f".{target.name}.", suffix=".tmp", dir=target.parent
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=indent, default=str)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, target)
try:
directory_fd = os.open(target.parent, os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
except (AttributeError, OSError):
pass
finally:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
def load_json(path: str | os.PathLike[str], default: Any = None) -> Any:
target = Path(path)
if not target.exists():
return default
with file_lock(target):
try:
with target.open(encoding="utf-8") as handle:
return json.load(handle)
except (OSError, ValueError):
return default
def _tail_bytes(target: Path, *, line_hint: int, chunk_size: int) -> bytes:
with target.open("rb") as handle:
handle.seek(0, os.SEEK_END)
position = handle.tell()
blocks: list[bytes] = []
newlines = 0
while position > 0 and newlines <= line_hint:
size = min(chunk_size, position)
position -= size
handle.seek(position)
block = handle.read(size)
blocks.append(block)
newlines += block.count(b"\n")
return b"".join(reversed(blocks))
def load_jsonl_tail(
path: str | os.PathLike[str], *, limit: int = 90, chunk_size: int = 8192
) -> list[dict[str, Any]]:
"""Read only enough of a JSONL file to return its last valid entries."""
if limit <= 0:
return []
target = Path(path)
if not target.exists():
return []
with file_lock(target):
raw = _tail_bytes(target, line_hint=limit + 8, chunk_size=max(chunk_size, 32))
entries: list[dict[str, Any]] = []
for line in raw.splitlines():
try:
value = json.loads(line)
except (UnicodeDecodeError, ValueError):
continue
if isinstance(value, dict):
entries.append(value)
return entries[-limit:]
def _utc_day(timestamp: Any) -> str | None:
if not isinstance(timestamp, str):
return None
try:
parsed = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc).date().isoformat()
except ValueError:
return None
def append_daily_jsonl(path: str | os.PathLike[str], entry: dict[str, Any]) -> bool:
"""Append at most one record per UTC day, inspecting only the bounded tail."""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
entry_day = _utc_day(entry.get("timestamp"))
if entry_day is None:
raise ValueError("entry timestamp must be an ISO-8601 datetime")
with file_lock(target):
if target.exists():
raw = _tail_bytes(target, line_hint=8, chunk_size=4096)
for line in reversed(raw.splitlines()):
try:
previous = json.loads(line)
except (UnicodeDecodeError, ValueError):
continue
if _utc_day(previous.get("timestamp")) == entry_day:
return False
break
payload = (json.dumps(entry, default=str) + "\n").encode("utf-8")
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
try:
os.write(fd, payload)
os.fsync(fd)
finally:
os.close(fd)
return True
def has_observation(payload: Any) -> bool:
if not isinstance(payload, dict):
return payload is not None
return any(value is not None for key, value in payload.items() if key not in _METADATA_KEYS)
def merge_observation(
previous: Any,
observed: Any,
*,
source: str,
observed_at: str | None = None,
error: str | None = None,
) -> dict[str, Any]:
"""Annotate a fresh observation or retain the last-known-good value as stale."""
if has_observation(observed):
merged = dict(observed) if isinstance(observed, dict) else {"value": observed}
merged.update(
observed_at=observed_at or datetime.now(timezone.utc).isoformat(),
source=source,
stale=False,
last_error=None,
)
return merged
merged = dict(previous) if isinstance(previous, dict) else {}
observed_error = observed.get("error") if isinstance(observed, dict) else None
merged.update(
source=merged.get("source") or source,
stale=True,
last_error=observed_error or error or "metric was not observed",
)
merged.setdefault("observed_at", None)
return merged
def onchain_refresh_due(
timestamp: Any,
*,
now: datetime | None = None,
ttl_seconds: int = 6 * 60 * 60,
) -> bool:
"""Return whether the last successful on-chain observation exceeded its TTL."""
if not isinstance(timestamp, str) or not timestamp:
return True
try:
observed = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
if observed.tzinfo is None:
observed = observed.replace(tzinfo=timezone.utc)
except ValueError:
return True
current = now or datetime.now(timezone.utc)
if current.tzinfo is None:
current = current.replace(tzinfo=timezone.utc)
return (current.astimezone(timezone.utc) - observed.astimezone(timezone.utc)).total_seconds() >= ttl_seconds
+238 -125
View File
@@ -13,6 +13,7 @@ import sys
import threading import threading
import time import time
import traceback import traceback
from contextlib import asynccontextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
import requests import requests
@@ -28,16 +29,53 @@ sys.path.insert(0, BASE_DIR)
from scrapers import fear_greed, price from scrapers import fear_greed, price
from scoring import engine from scoring import engine
from dashboard.persistence import (
append_daily_jsonl,
atomic_write_json,
has_observation,
load_json,
load_jsonl_tail,
merge_observation,
onchain_refresh_due,
)
from dashboard.jobs import JobRegistry
app = FastAPI(title="Bitcoin Accumulation Zone Monitor") _shutdown_event = threading.Event()
_background_threads = []
_threads_lock = threading.Lock()
@asynccontextmanager
async def lifespan(_app):
"""Own background worker startup and graceful shutdown."""
_shutdown_event.clear()
scraper_thread = threading.Thread(target=scraper_loop, name="scraper-scheduler")
with _threads_lock:
_background_threads.append(scraper_thread)
scraper_thread.start()
try:
yield
finally:
_shutdown_event.set()
with _threads_lock:
threads = list(_background_threads)
for thread in threads:
thread.join(timeout=30)
with _threads_lock:
_background_threads.clear()
app = FastAPI(title="Bitcoin Accumulation Zone Monitor", lifespan=lifespan)
CONFIG_DIR = os.path.join(BASE_DIR, "config") CONFIG_DIR = os.path.join(BASE_DIR, "config")
DATA_DIR = os.path.join(BASE_DIR, "data") DATA_DIR = os.path.join(BASE_DIR, "data")
CACHE_PATH = os.path.join(DATA_DIR, "cache.json") CACHE_PATH = os.path.join(DATA_DIR, "cache.json")
HISTORY_PATH = os.path.join(DATA_DIR, "score_history.jsonl") HISTORY_PATH = os.path.join(DATA_DIR, "score_history.jsonl")
LLM_SETTINGS_PATH = os.path.join(CONFIG_DIR, "llm_settings.json") LLM_SETTINGS_PATH = os.path.join(CONFIG_DIR, "llm_settings.json")
JOBS_PATH = os.path.join(DATA_DIR, "jobs.json")
os.makedirs(DATA_DIR, exist_ok=True) os.makedirs(DATA_DIR, exist_ok=True)
_jobs = JobRegistry(JOBS_PATH)
# Background scraper state # Background scraper state
_scraper_lock = threading.Lock() _scraper_lock = threading.Lock()
@@ -45,21 +83,60 @@ _scraper_running = False
_last_update = None _last_update = None
_last_error = None _last_error = None
def _job_worker(job_id, operation):
try:
_jobs.run(job_id, operation)
except Exception:
log.error("Background job %s failed:\n%s", job_id, traceback.format_exc())
finally:
current = threading.current_thread()
with _threads_lock:
if current in _background_threads:
_background_threads.remove(current)
def _spawn_job(job, operation):
"""Start an already-reserved job in a tracked, non-daemon thread."""
thread = threading.Thread(
target=_job_worker,
args=(job["id"], operation),
name=f"{job['kind']}-{job['id'][:8]}",
)
with _threads_lock:
_background_threads.append(thread)
thread.start()
return thread
# ── Cache management ────────────────────────────────────────────────────── # ── Cache management ──────────────────────────────────────────────────────
def load_cache(): def load_cache():
if os.path.exists(CACHE_PATH): return load_json(CACHE_PATH, {})
try:
with open(CACHE_PATH) as f:
return json.load(f)
except Exception:
pass
return {}
def save_cache(data): def save_cache(data):
with open(CACHE_PATH, "w") as f: atomic_write_json(CACHE_PATH, data)
json.dump(data, f, indent=2, default=str)
@app.get("/health/live")
def health_live():
"""Report that the API process is responsive."""
return {"status": "ok"}
@app.get("/health/ready")
def health_ready():
"""Report readiness only after a usable score has been persisted."""
scored = load_cache().get("_scored", {})
score = scored.get("composite_score")
count = scored.get("scored_count", 0)
if score is None or count < 1:
return JSONResponse(
{"status": "not_ready", "reason": "no usable persisted score"},
status_code=503,
)
return {"status": "ready", "score": score, "scored_metrics": count}
def append_history(score_data): def append_history(score_data):
@@ -73,27 +150,36 @@ def append_history(score_data):
for m in score_data.get("metrics", []) for m in score_data.get("metrics", [])
}, },
} }
with open(HISTORY_PATH, "a") as f: append_daily_jsonl(HISTORY_PATH, entry)
f.write(json.dumps(entry) + "\n")
def load_history(): def load_history():
if not os.path.exists(HISTORY_PATH): return load_jsonl_tail(HISTORY_PATH, limit=90)
return []
entries = []
with open(HISTORY_PATH) as f:
for line in f:
line = line.strip()
if line:
try:
entries.append(json.loads(line))
except Exception:
pass
return entries
# ── Background scraper ──────────────────────────────────────────────────── # ── Background scraper ────────────────────────────────────────────────────
def _scrape_onchain_sources():
"""Run independent on-chain providers so one outage cannot mask the other."""
observations = {}
errors = []
successful_sources = 0
providers = (
("LookIntoBitcoin", "scrapers.lookintobitcoin"),
("CheckOnChain", "scrapers.checkonchain"),
)
for display_name, module_name in providers:
try:
module = __import__(module_name, fromlist=["scrape_all"])
observations.update(module.scrape_all())
successful_sources += 1
except Exception as exc:
log.error("%s scraping failed: %s\n%s", display_name, exc, traceback.format_exc())
errors.append(f"{display_name}: {exc}")
return observations, errors, successful_sources
def run_scrape(force_full=False): def run_scrape(force_full=False):
"""Run a scrape cycle and update cache. """Run a scrape cycle and update cache.
@@ -111,21 +197,42 @@ def run_scrape(force_full=False):
_scraper_running = True _scraper_running = True
try: try:
# Load existing cache to preserve on-chain data
existing_cache = load_cache() existing_cache = load_cache()
metrics = {} metrics = {}
cycle_errors = []
# 1. Fear & Greed (fast API call) # Fast metrics fail independently so partial outages retain last-known-good data.
log.info("Fetching Fear & Greed...") log.info("Fetching Fear & Greed...")
metrics["fear_greed"] = fear_greed.fetch() try:
metrics["fear_greed"] = merge_observation(
existing_cache.get("fear_greed"), fear_greed.fetch(), source="alternative.me"
)
except Exception as e:
cycle_errors.append(f"Fear & Greed: {e}")
metrics["fear_greed"] = merge_observation(
existing_cache.get("fear_greed"), None,
source="alternative.me", error=str(e),
)
# 2. BTC Price data (fast API calls)
log.info("Fetching BTC price...") log.info("Fetching BTC price...")
try:
price_current = price.fetch_current() price_current = price.fetch_current()
metrics["price"] = price_current metrics["price"] = merge_observation(
existing_cache.get("price"), price_current, source="coingecko"
)
except Exception as e:
cycle_errors.append(f"Price: {e}")
metrics["price"] = merge_observation(
existing_cache.get("price"), None, source="coingecko", error=str(e)
)
price_current = metrics["price"]
log.info("Fetching BTC ATH...") log.info("Fetching BTC ATH...")
try:
ath_data = price.fetch_ath() ath_data = price.fetch_ath()
except Exception as e:
cycle_errors.append(f"ATH: {e}")
ath_data = {}
ath_val = ath_data.get("ath") or existing_cache.get("drawdown", {}).get("ath") ath_val = ath_data.get("ath") or existing_cache.get("drawdown", {}).get("ath")
if price_current.get("price") and ath_val: if price_current.get("price") and ath_val:
drawdown = price.calculate_drawdown(price_current["price"], ath_val) drawdown = price.calculate_drawdown(price_current["price"], ath_val)
@@ -137,7 +244,11 @@ def run_scrape(force_full=False):
metrics["drawdown"] = {"value": None} metrics["drawdown"] = {"value": None}
log.info("Fetching historical prices for 200D SMA / Mayer...") log.info("Fetching historical prices for 200D SMA / Mayer...")
try:
hist = price.fetch_historical() hist = price.fetch_historical()
except Exception as e:
cycle_errors.append(f"Historical price: {e}")
hist = []
if hist: if hist:
sma_200d = price.calculate_200d_sma(hist) sma_200d = price.calculate_200d_sma(hist)
mayer = price.calculate_mayer_multiple(price_current.get("price"), sma_200d) mayer = price.calculate_mayer_multiple(price_current.get("price"), sma_200d)
@@ -169,28 +280,24 @@ def run_scrape(force_full=False):
"active_address_momentum", "txcount_momentum", "nvt_price", "active_address_momentum", "txcount_momentum", "nvt_price",
"vdd_multiple"] "vdd_multiple"]
has_cached_onchain = any(existing_cache.get(k, {}).get("value") is not None for k in onchain_keys) refresh_onchain = force_full or onchain_refresh_due(existing_cache.get("_onchain_timestamp"))
if force_full or not has_cached_onchain: if refresh_onchain:
# Only do a full Playwright scrape if explicitly requested or no data exists log.info("Refreshing on-chain metrics (forced, missing, or TTL expired)...")
log.info("Scraping on-chain metrics from LookIntoBitcoin (full refresh requested)...") onchain, onchain_errors, successful_sources = _scrape_onchain_sources()
try: cycle_errors.extend(onchain_errors)
from scrapers import lookintobitcoin checkonchain_keys = {"sopr", "sellside_risk", "active_address_momentum",
onchain = lookintobitcoin.scrape_all() "txcount_momentum", "nvt_price", "vdd_multiple"}
metrics.update(onchain) for key in onchain_keys:
try: source = "checkonchain" if key in checkonchain_keys else "lookintobitcoin"
from scrapers import checkonchain metrics[key] = merge_observation(
metrics.update(checkonchain.scrape_all()) existing_cache.get(key), onchain.get(key), source=source,
except Exception as e: error="metric missing from scrape",
log.error("CheckOnChain scraping failed: %s\n%s", e, traceback.format_exc()) )
_last_error = f"CheckOnChain scraping failed: {e}" if successful_sources and any(has_observation(value) for value in onchain.values()):
metrics["_onchain_timestamp"] = datetime.now(timezone.utc).isoformat() metrics["_onchain_timestamp"] = datetime.now(timezone.utc).isoformat()
except Exception as e: elif "_onchain_timestamp" in existing_cache:
log.error("LookIntoBitcoin scraping failed: %s\n%s", e, traceback.format_exc()) metrics["_onchain_timestamp"] = existing_cache["_onchain_timestamp"]
_last_error = f"On-chain scraping failed: {e}"
for k in onchain_keys:
if k in existing_cache:
metrics[k] = existing_cache[k]
else: else:
# Reuse cached on-chain values — they're stored permanently # Reuse cached on-chain values — they're stored permanently
log.info("Reusing cached on-chain data (use Full Refresh to re-scrape)") log.info("Reusing cached on-chain data (use Full Refresh to re-scrape)")
@@ -224,7 +331,7 @@ def run_scrape(force_full=False):
log.warning("History update failed (non-critical): %s", e) log.warning("History update failed (non-critical): %s", e)
_last_update = datetime.now(timezone.utc).isoformat() _last_update = datetime.now(timezone.utc).isoformat()
_last_error = None _last_error = "; ".join(cycle_errors) if cycle_errors else None
log.info("Scrape cycle complete. Composite score: %s", scored["composite_score"]) log.info("Scrape cycle complete. Composite score: %s", scored["composite_score"])
except Exception as e: except Exception as e:
@@ -235,20 +342,20 @@ def run_scrape(force_full=False):
_scraper_running = False _scraper_running = False
def _run_scheduled_refresh(force_full=False):
job = _jobs.reserve("refresh", details={"full": force_full, "scheduled": True})
if job is not None:
_jobs.run(job["id"], lambda: run_scrape(force_full=force_full))
def scraper_loop(): def scraper_loop():
"""Background loop: quick refresh every 15min. Full scrape only on first boot with no data.""" """Background loop: refresh quickly every 15 minutes, with on-chain TTL handling."""
cache = load_cache() cache = load_cache()
has_data = any(cache.get(k, {}).get("value") is not None has_data = any(cache.get(k, {}).get("value") is not None
for k in ["puell_multiple", "mvrv_zscore", "nupl"]) for k in ["puell_multiple", "mvrv_zscore", "nupl"])
run_scrape(force_full=not has_data) # Full only if no cached on-chain data _run_scheduled_refresh(force_full=not has_data)
while True: while not _shutdown_event.wait(900):
time.sleep(900) # 15 minutes _run_scheduled_refresh()
run_scrape() # Quick refresh only
# Start background scraper on import
_scraper_thread = threading.Thread(target=scraper_loop, daemon=True)
_scraper_thread.start()
# ── LLM Settings (preserved from original) ─────────────────────────────── # ── LLM Settings (preserved from original) ───────────────────────────────
@@ -422,16 +529,31 @@ def api_history():
return load_history()[-90:] # Last 90 entries return load_history()[-90:] # Last 90 entries
@app.post("/api/refresh") @app.post("/api/refresh", status_code=202)
def api_refresh(full: bool = False): def api_refresh(full: bool = False):
"""Trigger a scrape. Quick refresh (default) updates price + F&G only (~2s). """Atomically reserve and start a quick or full metric refresh."""
Full refresh (?full=true) also re-scrapes on-chain data via Playwright (~2-3min).""" job = _jobs.reserve("refresh", details={"full": full, "scheduled": False})
if _scraper_running: if job is None:
return JSONResponse({"error": "Scrape already in progress"}, status_code=409) active = _jobs.active("refresh")
t = threading.Thread(target=run_scrape, kwargs={"force_full": full}, daemon=True) return JSONResponse(
t.start() {"error": "Scrape already in progress", "job": active}, status_code=409
)
_spawn_job(job, lambda: run_scrape(force_full=full))
mode = "full (on-chain + price + F&G)" if full else "quick (price + F&G only)" mode = "full (on-chain + price + F&G)" if full else "quick (price + F&G only)"
return {"ok": True, "message": f"Scrape started — {mode}"} return {
"ok": True,
"job_id": job["id"],
"status": job["status"],
"message": f"Scrape started — {mode}",
}
@app.get("/api/jobs/{job_id}")
def api_job_status(job_id: str):
job = _jobs.get(job_id)
if job is None:
return JSONResponse({"error": "Job not found"}, status_code=404)
return job
# Settings routes (preserved) # Settings routes (preserved)
@@ -900,8 +1022,8 @@ function highlightMetricPeriods(metricKey, currentRaw, margin) {
// Build an array of {date, rawValue} for the selected metric // Build an array of {date, rawValue} for the selected metric
const metricSeries = fullDailyScores const metricSeries = fullDailyScores
.filter(d => d.metrics && d.metrics[metricKey] != null) .filter(d => d.metric_values && d.metric_values[metricKey] != null)
.map(d => ({ date: d.date, value: d.metrics[metricKey], isSimilar: Math.abs(d.metrics[metricKey] - currentRaw) <= margin })); .map(d => ({ date: d.date, value: d.metric_values[metricKey], isSimilar: Math.abs(d.metric_values[metricKey] - currentRaw) <= margin }));
// Store for use in chart rendering // Store for use in chart rendering
window._highlightMetric = { key: metricKey, series: metricSeries, currentRaw, margin }; window._highlightMetric = { key: metricKey, series: metricSeries, currentRaw, margin };
@@ -1124,7 +1246,27 @@ function renderHistoryFromData(history) {
}); });
} }
// Load backtest daily scores for the chart // Load backtest daily scores and historical context with one request.
function renderHistoricalContext(ctx) {
if (!ctx) return;
const el = document.getElementById('histContext');
const txt = document.getElementById('histContextText');
let html = 'Score <strong>' + ctx.current_score + '</strong> is in the <strong style="color:#22d3ee">top ' + (100 - ctx.percentile).toFixed(1) + '%</strong> historically.<br>';
const fmtR = (v) => v == null ? null : (v >= 0 ? '+' : '') + v.toFixed(1) + '%';
const cR = (v) => 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]
];
const parts = [];
for (const [label, val] of periods) {
if (val != null) parts.push('<strong style="color:' + cR(val) + '">' + label + ': ' + fmtR(val) + '</strong>');
}
if (parts.length) html += 'Average returns from this level: ' + parts.join(' · ');
txt.innerHTML = html;
el.style.display = 'block';
}
async function loadBacktestChart() { async function loadBacktestChart() {
try { try {
const r = await fetch('/api/backtest?mode=' + currentMode); const r = await fetch('/api/backtest?mode=' + currentMode);
@@ -1133,6 +1275,7 @@ async function loadBacktestChart() {
fullDailyScores = data.chart_data; fullDailyScores = data.chart_data;
applyChartRange(currentRange); applyChartRange(currentRange);
} }
renderHistoricalContext(data.current_context);
} catch(e) { console.error('Backtest chart load failed:', e); } } catch(e) { console.error('Backtest chart load failed:', e); }
} }
@@ -1261,35 +1404,6 @@ function setMode(mode) {
drawScoreRing(0); drawScoreRing(0);
poll(); poll();
setInterval(poll, 30000); setInterval(poll, 30000);
// Load historical context from backtest
(async function() {
try {
const r = await fetch('/api/backtest/status');
const s = await r.json();
if (!s.exists) return;
const br = await fetch('/api/backtest');
const bt = await br.json();
if (bt.error || !bt.current_context) return;
const ctx = bt.current_context;
const el = document.getElementById('histContext');
const txt = document.getElementById('histContextText');
let html = 'Score <strong>' + ctx.current_score + '</strong> is in the <strong style="color:#22d3ee">top ' + (100 - ctx.percentile).toFixed(1) + '%</strong> historically.<br>';
const fmtR = (v) => v == null ? null : (v >= 0 ? '+' : '') + v.toFixed(1) + '%';
const cR = (v) => 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 parts = [];
for (const [label, val] of periods) {
if (val != null) parts.push('<strong style="color:' + cR(val) + '">' + label + ': ' + fmtR(val) + '</strong>');
}
if (parts.length) html += 'Average returns from this level: ' + parts.join(' · ');
txt.innerHTML = html;
el.style.display = 'block';
} catch(e) { /* backtest data not available yet */ }
})();
</script> </script>
</body> </body>
</html>""" </html>"""
@@ -1473,9 +1587,6 @@ loadSettings();
# ── Backtest API ─────────────────────────────────────────────────────── # ── Backtest API ───────────────────────────────────────────────────────
_history_collector_running = False
_history_collector_progress = {}
@app.get("/api/backtest") @app.get("/api/backtest")
def api_backtest(mode: str = "classic"): def api_backtest(mode: str = "classic"):
@@ -1501,43 +1612,45 @@ def api_backtest_history():
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": str(e)}, status_code=500)
@app.post("/api/backtest/collect") @app.post("/api/backtest/collect", status_code=202)
def api_backtest_collect(): def api_backtest_collect():
"""Trigger historical data collection.""" """Atomically reserve and start historical data collection."""
global _history_collector_running, _history_collector_progress initial_progress = {"status": "starting", "current": "", "step": 0, "total": 0}
if _history_collector_running: job = _jobs.reserve("history")
return JSONResponse({"error": "Collection already in progress", "progress": _history_collector_progress}, status_code=409) if job is None:
active = _jobs.active("history")
return JSONResponse(
{"error": "Collection already in progress", "job": active}, status_code=409
)
_jobs.update_progress(job["id"], initial_progress)
def _run_collector(): def _run_collector():
global _history_collector_running, _history_collector_progress
_history_collector_running = True
_history_collector_progress = {"status": "starting", "current": "", "step": 0, "total": 0}
try:
from scrapers.history_collector import collect_all_history from scrapers.history_collector import collect_all_history
def progress_cb(metric, step, total): def progress_cb(metric, step, total):
_history_collector_progress = {"status": "scraping", "current": metric, "step": step + 1, "total": total} _jobs.update_progress(job["id"], {
"status": "scraping", "current": metric,
"step": step + 1, "total": total,
})
collect_all_history(progress_cb=progress_cb) collect_all_history(progress_cb=progress_cb)
_history_collector_progress = {"status": "complete"} _jobs.update_progress(job["id"], {"status": "complete"})
except Exception as e: return {"collected": True}
log.error("History collection error: %s", traceback.format_exc())
_history_collector_progress = {"status": "error", "error": str(e)}
finally:
_history_collector_running = False
t = threading.Thread(target=_run_collector, daemon=True) _spawn_job(job, _run_collector)
t.start() return {"ok": True, "job_id": job["id"], "status": job["status"],
return {"ok": True, "message": "Collection started"} "message": "Collection started"}
@app.get("/api/backtest/status") @app.get("/api/backtest/status")
def api_backtest_status(): def api_backtest_status():
"""Check if historical data exists and collection status.""" """Check historical data and expose only the active collection job's progress."""
from scrapers.history_collector import history_status from scrapers.history_collector import history_status
status = history_status() status = history_status()
status["collecting"] = _history_collector_running active = _jobs.active("history")
status["progress"] = _history_collector_progress status["collecting"] = active is not None
status["job_id"] = active.get("id") if active else None
status["progress"] = active.get("progress", {}) if active else {}
return status return status
-149
View File
@@ -1,149 +0,0 @@
{"timestamp": "2026-03-20T22:26:50.475811+00:00", "composite_score": 32.5, "scored_count": 8, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.891180203045685}, "price_vs_200w_sma": {"score": null, "value": 0.0}, "reserve_risk": {"score": 0, "value": 69871.0}, "rhodl_ratio": {"score": 0, "value": 69871.0}, "nupl": {"score": 0, "value": 69871.0}, "lth_realized_price": {"score": null, "value": null}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-20T22:30:13.547149+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.910215736040605}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-20T22:46:34.952569+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.931630710659896}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-20T22:51:27.724327+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.94907994923858}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-20T23:07:48.303808+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.942734771573605}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-20T23:21:39.705718+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.07439720812183}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-20T23:27:15.835859+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.07122461928934}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-20T23:29:40.370530+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.099777918781726}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-20T23:32:26.885241+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.099777918781726}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-20T23:47:27.138815+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 11}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.07122461928934}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T00:02:27.412395+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.06408629441624}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T00:17:27.737482+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.025222081218274}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T00:32:28.011885+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.98953045685279}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T00:47:28.265430+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.006186548223354}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T01:02:28.558846+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.930837563451774}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T01:17:28.812131+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.964149746192895}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T01:32:29.208821+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.029980964467}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T01:47:29.455146+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.07994923857868}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T02:02:29.737360+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.10057106598985}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T02:17:30.019509+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.07201776649746}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T02:32:30.343202+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.98477157360406}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T02:47:30.599161+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.93797588832487}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T03:02:30.861751+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.90704314720812}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T03:17:31.107047+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.94670050761421}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T03:32:31.337649+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.94670050761421}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T03:47:31.597224+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.98477157360406}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T04:02:31.879811+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.93004441624365}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T04:17:32.138046+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.89435279187817}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T04:32:33.906364+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.900697969543145}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T04:47:34.155485+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.97921954314721}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T05:02:34.407649+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.975253807106604}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T05:17:34.633258+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.97049492385786}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T05:32:35.086231+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.878489847715734}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T05:47:35.345297+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.88880076142132}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T06:02:35.604338+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.92607868020305}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T06:17:35.879813+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.9173540609137}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T06:32:36.158808+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.85231598984772}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T06:47:36.389767+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.8880076142132}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T07:02:36.614994+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.880869289340104}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T07:17:36.932975+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.89435279187817}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T07:32:37.232230+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.8118654822335}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T07:47:37.478904+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.88959390862944}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T08:02:37.705904+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.93242385786802}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T08:17:37.925968+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.93797588832487}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T08:32:38.186918+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.078362944162436}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T08:47:38.410773+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.00697969543147}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T09:02:38.629878+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.00301395939086}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T09:17:38.850286+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.933217005076145}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T09:32:39.127885+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.02046319796955}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T09:47:39.355904+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.983978426395936}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T10:02:39.589009+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.973667512690355}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T10:17:39.865976+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.986357868020306}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T10:32:40.432700+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.0188769035533}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T10:47:40.718041+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.02918781725889}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T11:02:40.931588+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.01253172588832}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T11:17:41.151257+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.037119289340104}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T11:32:41.449918+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.0117385786802}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T11:47:41.719647+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.99666878172589}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T12:02:41.950098+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.95542512690355}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T12:17:42.199534+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.92528553299492}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T12:32:42.694007+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.88721446700507}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T12:47:42.921963+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.89197335025381}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T13:02:43.160452+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.91656091370558}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T13:17:43.406206+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.89355964467005}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T13:32:43.786751+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.881662436548226}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T13:47:44.052238+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.92052664974619}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T14:02:44.304809+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.879282994923855}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T14:17:44.551776+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.67465101522843}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T14:32:44.837983+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.73968908629441}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T14:47:45.081254+00:00", "composite_score": 51.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.74841370558376}, "price_vs_200w_sma": {"score": 3, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T15:02:45.357457+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.99904822335025}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T15:17:45.594051+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.01332487309645}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T15:32:45.890870+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.964942893401016}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T15:47:46.142770+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 43.982392131979694}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T16:02:46.392729+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.00539340101523}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T16:17:46.656840+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.01570431472081}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T16:32:47.020533+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.13467639593909}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T16:47:47.262309+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.18067893401015}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T17:02:47.523039+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.25047588832488}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T17:17:47.803679+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.27982233502538}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T17:32:48.073161+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.192576142131976}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T17:47:48.330230+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.170368020304565}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T18:02:48.615315+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.23857868020305}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T18:17:48.885342+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.202887055837564}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T18:32:49.283593+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.16560913705584}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T18:47:49.583109+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.121192893401016}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T19:02:49.846889+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.16560913705584}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T19:17:50.119141+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.172747461928935}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T19:32:50.417600+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.15609137055838}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T19:47:50.704459+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.21795685279188}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T20:02:50.952361+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.17909263959391}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T20:17:51.183860+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.17909263959391}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T20:32:51.484992+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.121192893401016}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T20:47:51.763248+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.111675126903556}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T21:02:52.006943+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.138642131979694}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T21:17:52.292371+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.17433375634518}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T21:32:52.634501+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.21002538071066}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T21:47:52.933666+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.21637055837564}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:02:53.198903+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.234612944162436}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:04:28.799920+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.251269035533}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:17:53.423421+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.23857868020305}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:21:10.295734+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.23064720812182}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:35:13.975107+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.254441624365484}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:40:38.516771+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.20685279187817}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:41:18.262393+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.20685279187817}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:41:46.036660+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.21875}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:42:33.632103+00:00", "composite_score": 70.0, "scored_count": 9, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": null, "value": null}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:51:08.461576+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.24968274111675}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:53:45.530567+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.26713197969543}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:54:22.144542+00:00", "composite_score": 70.0, "scored_count": 9, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": null, "value": null}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:55:08.385540+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.26554568527919}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:55:33.933753+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.26316624365482}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"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}}}
+27
View File
@@ -0,0 +1,27 @@
services:
btc-monitor:
build:
context: .
image: btc-accumulation-monitor:local
ports:
- "3088:3088"
restart: unless-stopped
environment:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
volumes:
- btc-monitor-data:/app/data
- btc-monitor-config:/app/config
healthcheck:
test:
- CMD
- /app/.venv/bin/python
- -c
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:3088/health/live', timeout=3)"
interval: 30s
timeout: 5s
start_period: 30s
retries: 3
volumes:
btc-monitor-data:
btc-monitor-config:
+91
View File
@@ -0,0 +1,91 @@
"""Validation for persisted ML scoring artifacts."""
import math
from scoring.policy import SCORE_VERSION
ML_ARTIFACT_SCHEMA_VERSION = 2
REQUIRED_WEIGHT_KEYS = frozenset({
"puell_multiple",
"mvrv_zscore",
"reserve_risk",
"rhodl_ratio",
"nupl",
"fear_greed",
"drawdown",
"pct_above_200w_sma",
"pct_above_lth_rp",
})
def _weights_valid(weights):
if not isinstance(weights, dict) or not REQUIRED_WEIGHT_KEYS.issubset(weights):
return False
values = [weights[key] for key in REQUIRED_WEIGHT_KEYS]
return all(
isinstance(value, (int, float))
and not isinstance(value, bool)
and math.isfinite(value)
and value >= 0
for value in values
) and sum(values) > 0
def _has_oos_fold_weights(artifact):
folds = artifact.get("cv_results", {}).get("folds", [])
if not isinstance(folds, list) or not folds:
return False
for fold in folds:
validation_range = fold.get("date_ranges", {}).get("validation")
if not validation_range or not _weights_valid(fold.get("weights")):
return False
return True
def validate_ml_artifact(artifact):
"""Return machine-readable validity and provenance for an ML artifact."""
errors = []
if not isinstance(artifact, dict):
artifact = {}
errors.append("artifact_object")
schema_version = artifact.get("artifact_schema_version")
if schema_version != ML_ARTIFACT_SCHEMA_VERSION:
errors.append("artifact_schema_version")
score_version = artifact.get("score_version")
if score_version != SCORE_VERSION:
errors.append("score_version")
if not _weights_valid(artifact.get("weights")):
errors.append("weights")
provenance = artifact.get("provenance")
if not isinstance(provenance, dict):
provenance = {}
errors.append("provenance")
else:
required_provenance = {
"validation_method",
"label_horizon_days",
"weight_scope",
"training_date_range",
"trained_at",
}
if not required_provenance.issubset(provenance):
errors.append("provenance")
if provenance.get("validation_method") != "purged_expanding_window":
errors.append("purged_validation")
if provenance.get("label_horizon_days") != 365:
errors.append("label_horizon_days")
if provenance.get("weight_scope") != "full_history_fit":
errors.append("weight_scope")
return {
"valid": not errors,
"schema_version": schema_version,
"score_version": score_version,
"weight_scope": provenance.get("weight_scope"),
"has_oos_fold_weights": _has_oos_fold_weights(artifact),
"errors": list(dict.fromkeys(errors)),
}
+40 -14
View File
@@ -26,6 +26,9 @@ from sklearn.metrics import (
from sklearn.model_selection import TimeSeriesSplit from sklearn.model_selection import TimeSeriesSplit
from sklearn.preprocessing import StandardScaler from sklearn.preprocessing import StandardScaler
from scoring.policy import SCORE_BRACKETS, SCORE_VERSION, score_in_bracket
from ml.artifacts import ML_ARTIFACT_SCHEMA_VERSION
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
@@ -117,14 +120,7 @@ INTERACTION_FEATURES = ["mvrv_x_nupl", "puell_x_reserve"]
CYCLE_FEATURES = ["days_since_ath"] CYCLE_FEATURES = ["days_since_ath"]
FEATURE_COLS = SCORE_FEATURES + RAW_FEATURES + DELTA_FEATURES + INTERACTION_FEATURES + CYCLE_FEATURES FEATURE_COLS = SCORE_FEATURES + RAW_FEATURES + DELTA_FEATURES + INTERACTION_FEATURES + CYCLE_FEATURES
BRACKETS = [ BRACKETS = SCORE_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): def _row_date(row):
@@ -160,6 +156,22 @@ def purged_time_series_splits(rows, n_splits=VALIDATION_SPLITS,
yield np.array(purged_train, dtype=int), np.array(val_idx, dtype=int) yield np.array(purged_train, dtype=int), np.array(val_idx, dtype=int)
def viable_classification_splits(y, splits):
"""Yield only folds whose training window contains both target classes."""
for train_idx, val_idx in splits:
if len(np.unique(y[train_idx])) < 2:
continue
yield train_idx, val_idx
def artifact_fold_results(fold_results):
"""Strip training-only row indexes from the persisted ML artifact."""
return [
{key: value for key, value in fold.items() if key not in {"train_idx", "val_idx"}}
for fold in fold_results
]
def _build_model(): def _build_model():
return GradientBoostingClassifier( return GradientBoostingClassifier(
n_estimators=300, n_estimators=300,
@@ -381,12 +393,12 @@ def train_model(rows):
cv_recall = [] cv_recall = []
fold_results = [] fold_results = []
splits = list(purged_time_series_splits( splits = list(viable_classification_splits(y, purged_time_series_splits(
labeled, labeled,
n_splits=VALIDATION_SPLITS, n_splits=VALIDATION_SPLITS,
label_horizon_days=LABEL_HORIZON_DAYS, label_horizon_days=LABEL_HORIZON_DAYS,
embargo_days=0, embargo_days=0,
)) )))
if not splits: if not splits:
log.error("No viable purged validation splits. Need more history for %dd label horizon.", log.error("No viable purged validation splits. Need more history for %dd label horizon.",
LABEL_HORIZON_DAYS) LABEL_HORIZON_DAYS)
@@ -483,8 +495,12 @@ def train_model(rows):
comparison = run_comparison(rows, weights) comparison = run_comparison(rows, weights)
out_of_sample_comparison = run_out_of_sample_comparison(labeled, fold_results) out_of_sample_comparison = run_out_of_sample_comparison(labeled, fold_results)
# Build output # Build output. Final weights are fitted on all labeled history for live use;
# only the fold weights below are valid for OOS comparisons.
trained_at = datetime.now(tz=__import__('datetime').timezone.utc).isoformat()
result = { result = {
"artifact_schema_version": ML_ARTIFACT_SCHEMA_VERSION,
"score_version": SCORE_VERSION,
"weights": weights, "weights": weights,
"feature_importances": {name: round(float(imp), 6) for name, imp in feat_imp}, "feature_importances": {name: round(float(imp), 6) for name, imp in feat_imp},
"cv_results": { "cv_results": {
@@ -495,7 +511,7 @@ def train_model(rows):
"mean_recall": round(float(np.mean(cv_recall)), 4), "mean_recall": round(float(np.mean(cv_recall)), 4),
"validation_method": "purged_expanding_window", "validation_method": "purged_expanding_window",
"label_horizon_days": LABEL_HORIZON_DAYS, "label_horizon_days": LABEL_HORIZON_DAYS,
"folds": fold_results, "folds": artifact_fold_results(fold_results),
}, },
"training_info": { "training_info": {
"n_samples": len(labeled), "n_samples": len(labeled),
@@ -506,9 +522,19 @@ def train_model(rows):
"date_range": f"{labeled[0]['date']} to {labeled[-1]['date']}", "date_range": f"{labeled[0]['date']} to {labeled[-1]['date']}",
"model": "GradientBoostingClassifier", "model": "GradientBoostingClassifier",
}, },
"provenance": {
"validation_method": "purged_expanding_window",
"label_horizon_days": LABEL_HORIZON_DAYS,
"weight_scope": "full_history_fit",
"training_date_range": {
"start": labeled[0]["date"],
"end": labeled[-1]["date"],
},
"trained_at": trained_at,
},
"comparison": comparison, "comparison": comparison,
"out_of_sample_comparison": out_of_sample_comparison, "out_of_sample_comparison": out_of_sample_comparison,
"trained_at": datetime.now(tz=__import__('datetime').timezone.utc).isoformat(), "trained_at": trained_at,
} }
return result return result
@@ -526,7 +552,7 @@ def _composite_score(row, mode, ml_weights=None):
def _summarize_brackets(scored_rows, score_key): def _summarize_brackets(scored_rows, score_key):
results = [] results = []
for low, high, label in BRACKETS: for low, high, label in BRACKETS:
days_in = [r for r in scored_rows if low <= r[score_key] <= high] days_in = [r for r in scored_rows if score_in_bracket(r[score_key], (low, high, label))]
if not days_in: if not days_in:
results.append({ results.append({
"range": f"{low}-{high}", "label": label, "range": f"{low}-{high}", "label": label,
+111 -36
View File
@@ -161,8 +161,8 @@ def compute_features(df, config):
def create_accumulation_target(df, config): def create_accumulation_target(df, config):
"""Create accumulation score target based on forward returns. """Create accumulation score target based on forward returns.
For each candle, compute actual forward returns at multiple horizons, For each candle, compute actual forward returns at multiple horizons and
rank them, and create a weighted accumulation score (0-100). map them through fixed, configured return scales to a weighted 0-100 score.
Times when buying led to the best long-term returns get highest scores. Times when buying led to the best long-term returns get highest scores.
""" """
tgt = config.get("target", {}) tgt = config.get("target", {})
@@ -190,30 +190,22 @@ def create_accumulation_target(df, config):
fwd[i] = (close[i + period] - close[i]) / close[i] * 100 fwd[i] = (close[i + period] - close[i]) / close[i] * 100
forward_returns.append(fwd) forward_returns.append(fwd)
# Rank each forward return (percentile rank, 0-1) # Convert each return to a deterministic 0-100 quality score. Global
# Higher rank = better buy point (higher future return) # percentile ranks leak the distribution of future validation/test rows into
ranked = [] # earlier training labels; a fixed tanh transform is invariant to rows added
for fwd in forward_returns: # outside the observation's own forward horizons.
valid_mask = ~np.isnan(fwd) scales = tgt.get("return_scales_pct", [10.0, 30.0, 60.0])
ranks = np.full(n, np.nan) if len(scales) != len(forward_periods) or any(scale <= 0 for scale in scales):
valid_vals = fwd[valid_mask] raise ValueError("target.return_scales_pct must contain one positive scale per forward period")
if len(valid_vals) > 0:
from scipy.stats import rankdata
r = rankdata(valid_vals, method="average") / len(valid_vals)
ranks[valid_mask] = r
ranked.append(ranks)
# Weighted combination of ranks -> accumulation score (0-100)
score = np.zeros(n) score = np.zeros(n)
valid = np.ones(n, dtype=bool) valid = np.ones(n, dtype=bool)
for r, w in zip(ranked, weights): for fwd, weight, scale in zip(forward_returns, weights, scales):
nan_mask = np.isnan(r) nan_mask = np.isnan(fwd)
valid &= ~nan_mask valid &= ~nan_mask
r_filled = np.where(nan_mask, 0, r) quality = 50.0 + 50.0 * np.tanh(np.where(nan_mask, 0.0, fwd) / scale)
score += w * r_filled score += weight * quality
# Scale to 0-100
score = score * 100
score[~valid] = np.nan score[~valid] = np.nan
return pd.Series(score, index=df.index, name="target") return pd.Series(score, index=df.index, name="target")
@@ -488,6 +480,27 @@ def apply_scaling_pca(X_train, X_val, X_test, config):
return X_train, X_val, X_test, scaler, pca return X_train, X_val, X_test, scaler, pca
# ---------------------------------------------------------------------------
# Walk-forward split helpers
# ---------------------------------------------------------------------------
def _max_forward_horizon(config):
"""Return the longest forward-label horizon in candles."""
target = config.get("target", {})
key = "forward_periods_1h" if config.get("timeframe", "4h") == "1h" else "forward_periods_4h"
periods = target.get(key, [168, 720, 2160] if key.endswith("1h") else [42, 180, 540])
return max(int(period) for period in periods)
def _purge_label_overlap(frame, horizon):
"""Remove rows whose forward-return labels cross the next split boundary."""
if horizon <= 0:
return frame
if len(frame) <= horizon:
return frame.iloc[0:0]
return frame.iloc[:-horizon]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Rolling Window Validation # Rolling Window Validation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -499,6 +512,7 @@ def rolling_window_train_test(df, feature_cols, config):
test_size = training_cfg.get("rolling_test_size", 300) test_size = training_cfg.get("rolling_test_size", 300)
val_pct = training_cfg.get("validation_pct", 0.15) val_pct = training_cfg.get("validation_pct", 0.15)
model_type = config.get("model_type", "xgboost") model_type = config.get("model_type", "xgboost")
purge_horizon = _max_forward_horizon(config)
n = len(df) n = len(df)
all_predictions = [] # list of (predicted_score, actual_score, close_price) all_predictions = [] # list of (predicted_score, actual_score, close_price)
@@ -527,10 +541,16 @@ def rolling_window_train_test(df, feature_cols, config):
start += test_size start += test_size
continue continue
# Split train into train/val # Purge labels whose longest forward-return horizon overlaps the next
# split. Without this embargo, training and validation targets consume
# prices from the following validation/test partition.
val_split = int(len(train_full) * (1.0 - val_pct)) val_split = int(len(train_full) * (1.0 - val_pct))
train_df = train_full.iloc[:val_split] train_df = _purge_label_overlap(train_full.iloc[:val_split], purge_horizon)
val_df = train_full.iloc[val_split:] val_df = _purge_label_overlap(train_full.iloc[val_split:], purge_horizon)
if len(train_df) < 10 or len(val_df) < 1:
start += test_size
continue
X_train = train_df[feature_cols].values X_train = train_df[feature_cols].values
y_train = train_df["target"].values y_train = train_df["target"].values
@@ -632,6 +652,7 @@ def walk_forward_train_test(df, feature_cols, config):
n_windows = training_cfg.get("walk_forward_windows", 5) n_windows = training_cfg.get("walk_forward_windows", 5)
train_pct = training_cfg.get("train_pct", 0.7) train_pct = training_cfg.get("train_pct", 0.7)
val_pct = training_cfg.get("validation_pct", 0.15) val_pct = training_cfg.get("validation_pct", 0.15)
purge_horizon = _max_forward_horizon(config)
n = len(df) n = len(df)
window_size = n // n_windows window_size = n // n_windows
@@ -655,8 +676,8 @@ def walk_forward_train_test(df, feature_cols, config):
train_end = int(wn * train_pct) train_end = int(wn * train_pct)
val_end = int(wn * (train_pct + val_pct)) val_end = int(wn * (train_pct + val_pct))
train_df = window_data.iloc[:train_end] train_df = _purge_label_overlap(window_data.iloc[:train_end], purge_horizon)
val_df = window_data.iloc[train_end:val_end] val_df = _purge_label_overlap(window_data.iloc[train_end:val_end], purge_horizon)
test_df = window_data.iloc[val_end:] test_df = window_data.iloc[val_end:]
if len(test_df) < 10: if len(test_df) < 10:
@@ -842,6 +863,46 @@ def _extract_feature_importances(model, n_features):
# Results Compilation # Results Compilation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def simulate_periodic_accumulation(predicted_scores, close_prices, buy_threshold, contribution=1.0):
"""Compare DCA and signal strategies with equal periodic contributions.
Both strategies receive the same cash on every observation. DCA invests the
contribution immediately; the signal strategy retains cash until a buy
signal, then deploys its available balance. Terminal wealth includes cash.
"""
scores = np.asarray(predicted_scores, dtype=float)
prices = np.asarray(close_prices, dtype=float)
if len(scores) != len(prices):
raise ValueError("predicted_scores and close_prices must have equal length")
if len(prices) == 0 or contribution <= 0 or np.any(prices <= 0):
raise ValueError("prices must be positive and contribution must be greater than zero")
dca_btc = float(np.sum(contribution / prices))
model_btc = 0.0
model_cash = 0.0
for score, price in zip(scores, prices):
model_cash += contribution
if score >= buy_threshold:
model_btc += model_cash / price
model_cash = 0.0
contributed = float(len(prices) * contribution)
terminal_price = float(prices[-1])
dca_terminal = dca_btc * terminal_price
model_terminal = model_btc * terminal_price + model_cash
improvement = (model_terminal - dca_terminal) / dca_terminal * 100 if dca_terminal else 0.0
return {
"dca_contributed": contributed,
"model_contributed": contributed,
"dca_btc": dca_btc,
"model_btc": model_btc,
"model_cash": model_cash,
"dca_terminal_value": dca_terminal,
"model_terminal_value": model_terminal,
"terminal_wealth_improvement_pct": improvement,
}
def compile_results(predictions, per_window_cost_improvement, def compile_results(predictions, per_window_cost_improvement,
fi_sum, fi_count, feature_cols, config): fi_sum, fi_count, feature_cols, config):
"""Compile accumulation signal results into output JSON.""" """Compile accumulation signal results into output JSON."""
@@ -870,10 +931,8 @@ def compile_results(predictions, per_window_cost_improvement,
else: else:
avg_actual_strong = 0.0 avg_actual_strong = 0.0
# We need forward return info. Since actual_score is a rank-based measure (0-100), # The target is a bounded return-quality score, not a realized return.
# and we want to report real forward returns, we approximate: # Report it explicitly as quality rather than approximating a return.
# actual_score > 80 means the buy was in the top 20% of quality.
# For actual forward return stats, we use actual score as a proxy.
# Profitable signals: those where actual score is also above median (50) # Profitable signals: those where actual score is also above median (50)
if strong_buy_count > 0: if strong_buy_count > 0:
@@ -896,18 +955,25 @@ def compile_results(predictions, per_window_cost_improvement,
model_avg = dca_avg model_avg = dca_avg
cost_basis_improvement = 0.0 cost_basis_improvement = 0.0
portfolio = simulate_periodic_accumulation(
pred_scores,
close_prices,
buy_threshold=good_threshold,
contribution=1.0,
)
# --- Signal Frequency --- # --- Signal Frequency ---
signal_frequency = strong_buy_count / total_candles * 100 if total_candles > 0 else 0 signal_frequency = strong_buy_count / total_candles * 100 if total_candles > 0 else 0
# --- Score at actual extremes --- # --- Score at actual extremes ---
# "Actual bottoms" = candles with actual score > 85 (top 15% buy opportunities) # "Actual bottoms" = candles with a high realized return-quality score.
actual_bottom_mask = actual_scores > 85 actual_bottom_mask = actual_scores > 85
if np.any(actual_bottom_mask): if np.any(actual_bottom_mask):
avg_score_at_bottoms = float(np.mean(pred_scores[actual_bottom_mask])) avg_score_at_bottoms = float(np.mean(pred_scores[actual_bottom_mask]))
else: else:
avg_score_at_bottoms = 0.0 avg_score_at_bottoms = 0.0
# "Actual tops" = candles with actual score < 15 (worst 15% buy times) # "Actual tops" = candles with a low realized return-quality score.
actual_top_mask = actual_scores < 15 actual_top_mask = actual_scores < 15
if np.any(actual_top_mask): if np.any(actual_top_mask):
avg_score_at_tops = float(np.mean(pred_scores[actual_top_mask])) avg_score_at_tops = float(np.mean(pred_scores[actual_top_mask]))
@@ -932,10 +998,7 @@ def compile_results(predictions, per_window_cost_improvement,
count = int(np.sum((pred_scores >= lo) & (pred_scores < (hi if hi < 100 else 101)))) count = int(np.sum((pred_scores >= lo) & (pred_scores < (hi if hi < 100 else 101))))
score_distribution[key] = count score_distribution[key] = count
# --- Forward return approximation from actual scores --- # --- Realized return-quality summary ---
# Map actual score to approximate return quality
# Score 90+ = historically best 10% buys, score 10- = worst 10%
# Use actual score as proxy for "quality rank"
if strong_buy_count > 0: if strong_buy_count > 0:
# Average actual quality score for strong buy signals # Average actual quality score for strong buy signals
avg_quality_strong = float(np.mean(actual_scores[strong_buy_mask])) avg_quality_strong = float(np.mean(actual_scores[strong_buy_mask]))
@@ -946,7 +1009,14 @@ def compile_results(predictions, per_window_cost_improvement,
quality_good = False quality_good = False
return { return {
# Retained for backward compatibility; model selection uses the equal-
# capital terminal wealth metric below.
"cost_basis_improvement_pct": round(cost_basis_improvement, 2), "cost_basis_improvement_pct": round(cost_basis_improvement, 2),
"terminal_wealth_improvement_pct": round(portfolio["terminal_wealth_improvement_pct"], 2),
"model_terminal_value": round(portfolio["model_terminal_value"], 6),
"dca_terminal_value": round(portfolio["dca_terminal_value"], 6),
"model_cash": round(portfolio["model_cash"], 6),
"backtest_objective": "equal_periodic_contribution_terminal_wealth",
"avg_cost_basis_model": round(model_avg, 2), "avg_cost_basis_model": round(model_avg, 2),
"avg_cost_basis_dca": round(dca_avg, 2), "avg_cost_basis_dca": round(dca_avg, 2),
"strong_buy_signal_count": strong_buy_count, "strong_buy_signal_count": strong_buy_count,
@@ -968,6 +1038,11 @@ def compile_results(predictions, per_window_cost_improvement,
def _empty_results(per_window): def _empty_results(per_window):
return { return {
"cost_basis_improvement_pct": 0.0, "cost_basis_improvement_pct": 0.0,
"terminal_wealth_improvement_pct": 0.0,
"model_terminal_value": 0.0,
"dca_terminal_value": 0.0,
"model_cash": 0.0,
"backtest_objective": "equal_periodic_contribution_terminal_wealth",
"avg_cost_basis_model": 0.0, "avg_cost_basis_model": 0.0,
"avg_cost_basis_dca": 0.0, "avg_cost_basis_dca": 0.0,
"strong_buy_signal_count": 0, "strong_buy_signal_count": 0,
+18 -8
View File
@@ -28,7 +28,7 @@ MAC_MINI_HOST = "bizzle@bizzles-mac-mini-1"
MAX_ITERATIONS = 50 MAX_ITERATIONS = 50
CONVERGENCE_WINDOW = 5 CONVERGENCE_WINDOW = 5
CONVERGENCE_THRESHOLD = 0.01 # 1% improvement CONVERGENCE_THRESHOLD = 0.01 # 1% improvement
TARGET_COST_IMPROVEMENT = 20.0 # 20% cost basis improvement = exceptional TARGET_COST_IMPROVEMENT = 20.0 # Backward-compatible name: terminal wealth objective
MIN_SIGNAL_COUNT = 30 # Minimum strong buy signals for valid results MIN_SIGNAL_COUNT = 30 # Minimum strong buy signals for valid results
ML_TIMEOUT = 600 # 10 minutes ML_TIMEOUT = 600 # 10 minutes
@@ -49,6 +49,11 @@ def log(msg, color=""):
print(f"{C.DIM}[{ts}]{C.RESET} {color}{msg}{C.RESET}") print(f"{C.DIM}[{ts}]{C.RESET} {color}{msg}{C.RESET}")
def objective_score(results):
"""Return the equal-capital portfolio objective used for model selection."""
return float(results.get("terminal_wealth_improvement_pct", 0.0))
def run_cmd(cmd, timeout=120, check=True): def run_cmd(cmd, timeout=120, check=True):
"""Run a shell command and return stdout.""" """Run a shell command and return stdout."""
result = subprocess.run( result = subprocess.run(
@@ -160,11 +165,12 @@ def print_header():
def print_results(results, iteration): def print_results(results, iteration):
cost_imp = results.get("cost_basis_improvement_pct", 0) objective = objective_score(results)
color = C.GREEN if cost_imp > 15 else C.YELLOW if cost_imp > 10 else C.RED color = C.GREEN if objective > 15 else C.YELLOW if objective > 10 else C.RED
print(f""" print(f"""
{C.BOLD}--- Iteration {iteration} Results ---{C.RESET} {C.BOLD}--- Iteration {iteration} Results ---{C.RESET}
Cost Improvement: {color}{C.BOLD}{cost_imp:.1f}%{C.RESET} Terminal Wealth vs DCA: {color}{C.BOLD}{objective:.1f}%{C.RESET}
Legacy Cost Basis Delta: {results.get('cost_basis_improvement_pct', 0):.1f}%
Avg Cost (Model): ${results.get('avg_cost_basis_model', 0):,.2f} Avg Cost (Model): ${results.get('avg_cost_basis_model', 0):,.2f}
Avg Cost (DCA): ${results.get('avg_cost_basis_dca', 0):,.2f} Avg Cost (DCA): ${results.get('avg_cost_basis_dca', 0):,.2f}
Strong Signals: {results.get('strong_buy_signal_count', 0)} Strong Signals: {results.get('strong_buy_signal_count', 0)}
@@ -244,7 +250,7 @@ def main():
print_results(results, iteration) print_results(results, iteration)
current_score = results.get("cost_basis_improvement_pct", 0) current_score = objective_score(results)
signal_count = results.get("strong_buy_signal_count", 0) signal_count = results.get("strong_buy_signal_count", 0)
is_best = current_score > best_score and signal_count >= MIN_SIGNAL_COUNT is_best = current_score > best_score and signal_count >= MIN_SIGNAL_COUNT
@@ -252,12 +258,14 @@ def main():
best_score = current_score best_score = current_score
with open(best_config_path, "w") as f: with open(best_config_path, "w") as f:
json.dump(config, f, indent=2) json.dump(config, f, indent=2)
log(f"NEW BEST! Cost Improvement: {best_score:.1f}%", f"{C.BOLD}{C.GREEN}") log(f"NEW BEST! Terminal Wealth Improvement: {best_score:.1f}%", f"{C.BOLD}{C.GREEN}")
iter_data = { iter_data = {
"iteration": iteration, "iteration": iteration,
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(timezone.utc).isoformat(),
"cost_improvement": current_score, "cost_improvement": current_score,
"objective_improvement": current_score,
"objective": "equal_periodic_contribution_terminal_wealth",
"avg_30d_return": results.get("avg_quality_score_strong_buy", 0), "avg_30d_return": results.get("avg_quality_score_strong_buy", 0),
"avg_90d_return": results.get("pct_quality_strong_buy", 0), "avg_90d_return": results.get("pct_quality_strong_buy", 0),
"signal_count": signal_count, "signal_count": signal_count,
@@ -312,7 +320,7 @@ def main():
========================================================{C.RESET} ========================================================{C.RESET}
Total Iterations: {len(history)} Total Iterations: {len(history)}
Best Cost Improvement: {C.BOLD}{best_score:.1f}%{C.RESET} Best Terminal Wealth Improvement: {C.BOLD}{best_score:.1f}%{C.RESET}
Best Config: {best_config_path} Best Config: {best_config_path}
Iteration Log: {ITERATIONS_LOG} Iteration Log: {ITERATIONS_LOG}
""") """)
@@ -405,7 +413,7 @@ def run_optimization_loop(callback=None, config_override=None):
with open(results_local) as f: with open(results_local) as f:
results = json.load(f) results = json.load(f)
current_score = results.get("cost_basis_improvement_pct", 0) current_score = objective_score(results)
signal_count = results.get("strong_buy_signal_count", 0) signal_count = results.get("strong_buy_signal_count", 0)
is_best = current_score > best_score and signal_count >= MIN_SIGNAL_COUNT is_best = current_score > best_score and signal_count >= MIN_SIGNAL_COUNT
@@ -419,6 +427,8 @@ def run_optimization_loop(callback=None, config_override=None):
"iteration": iteration, "iteration": iteration,
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(timezone.utc).isoformat(),
"cost_improvement": current_score, "cost_improvement": current_score,
"objective_improvement": current_score,
"objective": "equal_periodic_contribution_terminal_wealth",
"signal_count": signal_count, "signal_count": signal_count,
"signal_frequency": results.get("signal_frequency_pct", 0), "signal_frequency": results.get("signal_frequency_pct", 0),
"r2_score": results.get("model_r2_score", 0), "r2_score": results.get("model_r2_score", 0),
+34
View File
@@ -0,0 +1,34 @@
[project]
name = "btc-accumulation-monitor"
version = "0.1.0"
description = "Bitcoin accumulation metrics dashboard and historical scoring tools"
readme = "README.md"
requires-python = ">=3.11,<3.14"
[dependency-groups]
runtime = [
"fastapi>=0.116,<1",
"playwright>=1.54,<2",
"requests>=2.32,<3",
"uvicorn[standard]>=0.35,<1",
]
ml = [
"ccxt>=4.4,<5",
"numpy>=2.2,<3",
"pandas>=2.2,<3",
"scikit-learn>=1.6,<2",
"ta>=0.11,<1",
]
dev = [
"pytest>=8.4,<9",
]
[tool.uv]
package = false
default-groups = ["runtime", "ml", "dev"]
[tool.pytest.ini_options]
addopts = "-q"
testpaths = ["tests"]
pythonpath = ["."]
+1255 -4
View File
File diff suppressed because it is too large Load Diff
+43 -30
View File
@@ -4,6 +4,9 @@ import json
import os import os
import logging import logging
from scoring.policy import SCORE_VERSION, assessment_for_score
from ml.artifacts import validate_ml_artifact
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
THRESHOLDS_PATH = os.path.join( THRESHOLDS_PATH = os.path.join(
@@ -527,8 +530,9 @@ def score_all(metrics):
vdd = metrics.get("vdd_multiple", {}) vdd = metrics.get("vdd_multiple", {})
vdd_score, vdd_desc = score_momentum_pct(vdd.get("value")) vdd_score, vdd_desc = score_momentum_pct(vdd.get("value"))
results.append({ results.append({
"name": "VDD Multiple", "name": "VDD 30-Period Momentum",
"key": "vdd_multiple", "key": "vdd_multiple",
"transform": "30_period_return",
"value": vdd.get("value"), "value": vdd.get("value"),
"display_value": f"{vdd.get('value') * 100:.1f}%" if vdd.get("value") is not None else "N/A", "display_value": f"{vdd.get('value') * 100:.1f}%" if vdd.get("value") is not None else "N/A",
"score": vdd_score, "score": vdd_score,
@@ -544,19 +548,7 @@ def score_all(metrics):
else: else:
composite = 0 composite = 0
# Assessment text — calibrated for cycle-aware scoring assessment = assessment_for_score(composite)
if composite >= 80:
assessment = "EXTREME ACCUMULATION ZONE"
elif composite >= 65:
assessment = "STRONG ACCUMULATION ZONE"
elif composite >= 50:
assessment = "MODERATE OPPORTUNITY"
elif composite >= 35:
assessment = "NEUTRAL"
elif composite >= 20:
assessment = "CAUTION — OVERHEATED"
else:
assessment = "EXTREME CAUTION"
return { return {
"metrics": results, "metrics": results,
@@ -564,6 +556,17 @@ def score_all(metrics):
"assessment": assessment, "assessment": assessment,
"scored_count": len(valid_scores), "scored_count": len(valid_scores),
"total_count": len(results), "total_count": len(results),
"score_version": SCORE_VERSION,
"metric_panel": {
"id": "live-all-v1",
"keys": [result["key"] for result in results],
"count": len(results),
},
"coverage": {
"available_count": len(valid_scores),
"panel_count": len(results),
"ratio": len(valid_scores) / len(results),
},
} }
@@ -589,14 +592,28 @@ _ML_KEY_MAP = {
} }
_ml_artifact_status = {"valid": False, "errors": ["not_loaded"]}
def load_ml_weights(): def load_ml_weights():
"""Load ML-optimized weights from config.""" """Load weights only when their schema and training provenance are valid."""
global _ml_artifact_status
try: try:
with open(ML_WEIGHTS_PATH) as f: with open(ML_WEIGHTS_PATH) as f:
data = json.load(f) data = json.load(f)
return data.get("weights", {}) _ml_artifact_status = validate_ml_artifact(data)
except Exception: if not _ml_artifact_status["valid"]:
log.error("Rejected invalid ML artifact: %s", ", ".join(_ml_artifact_status["errors"]))
return {} return {}
return data.get("weights", {})
except Exception as exc:
_ml_artifact_status = {"valid": False, "errors": [f"load_error:{exc}"]}
return {}
def get_ml_artifact_status():
"""Return the status from the most recent artifact load attempt."""
return dict(_ml_artifact_status)
def score_all_ml(metrics): def score_all_ml(metrics):
@@ -613,7 +630,12 @@ def score_all_ml(metrics):
if not ml_weights: if not ml_weights:
# Fallback to classic if no ML weights available # Fallback to classic if no ML weights available
classic["ml_mode"] = False classic["ml_mode"] = False
status = get_ml_artifact_status()
if status.get("errors") and status["errors"] != ["not_loaded"]:
classic["ml_error"] = "ML artifact invalid: " + ", ".join(status["errors"])
else:
classic["ml_error"] = "ML weights not found — run ml/optimizer.py" classic["ml_error"] = "ML weights not found — run ml/optimizer.py"
classic["ml_artifact"] = status
return classic return classic
results = classic["metrics"] results = classic["metrics"]
@@ -646,19 +668,7 @@ def score_all_ml(metrics):
m["ml_weight"] = round(effective_weight, 4) m["ml_weight"] = round(effective_weight, 4)
m["ml_contribution"] = round(m["score"] * effective_weight * 10, 2) m["ml_contribution"] = round(m["score"] * effective_weight * 10, 2)
# Assessment text (same thresholds as classic) assessment = assessment_for_score(composite)
if composite >= 80:
assessment = "EXTREME ACCUMULATION ZONE"
elif composite >= 65:
assessment = "STRONG ACCUMULATION ZONE"
elif composite >= 50:
assessment = "MODERATE OPPORTUNITY"
elif composite >= 35:
assessment = "NEUTRAL"
elif composite >= 20:
assessment = "CAUTION — OVERHEATED"
else:
assessment = "EXTREME CAUTION"
return { return {
"metrics": results, "metrics": results,
@@ -669,4 +679,7 @@ def score_all_ml(metrics):
"ml_mode": True, "ml_mode": True,
"classic_score": classic["composite_score"], "classic_score": classic["composite_score"],
"ml_weight_total": round(weight_total, 4), "ml_weight_total": round(weight_total, 4),
"score_version": SCORE_VERSION,
"metric_panel": classic["metric_panel"],
"coverage": classic["coverage"],
} }
+35
View File
@@ -0,0 +1,35 @@
"""Canonical score version, brackets, and assessment semantics."""
SCORE_VERSION = "accumulation-score-v2"
# Half-open intervals [low, high), except the final bracket includes 100.
# Keep labels canonical because they are persisted in live and backtest output.
SCORE_BRACKETS = [
(0, 20, "EXTREME CAUTION"),
(20, 35, "CAUTION — OVERHEATED"),
(35, 50, "NEUTRAL"),
(50, 65, "MODERATE OPPORTUNITY"),
(65, 80, "STRONG ACCUMULATION ZONE"),
(80, 100, "EXTREME ACCUMULATION ZONE"),
]
def score_in_bracket(score, bracket):
"""Return whether a 0-100 score belongs to a canonical bracket."""
low, high, _ = bracket
if not 0 <= score <= 100:
return False
return low <= score < high or (high == 100 and score == 100)
def bracket_for_score(score):
"""Return the one canonical bracket for a 0-100 score."""
for bracket in SCORE_BRACKETS:
if score_in_bracket(score, bracket):
return bracket
raise ValueError(f"score must be between 0 and 100, got {score!r}")
def assessment_for_score(score):
"""Return the canonical assessment label for a score."""
return bracket_for_score(score)[2]
+9 -2
View File
@@ -114,8 +114,15 @@ def collect_onchain_history(progress_cb=None):
for metric_key, trace_name in cfg["traces"].items(): for metric_key, trace_name in cfg["traces"].items():
if trace_name is None: if trace_name is None:
# Grab first trace with numeric data if metric_key == "lth_supply":
for candidate in traces: from scrapers.lookintobitcoin import _find_lth_supply_trace
candidates = [_find_lth_supply_trace(traces)]
else:
candidates = traces
# Grab the first validated trace with numeric data.
for candidate in candidates:
if not candidate:
continue
y = candidate.get("y", []) y = candidate.get("y", [])
if y and any(v is not None for v in y[-10:]): if y and any(v is not None for v in y[-10:]):
dates, values = _extract_series(candidate) dates, values = _extract_series(candidate)
+56 -20
View File
@@ -2,6 +2,7 @@
import logging import logging
import traceback import traceback
from contextlib import contextmanager
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -51,15 +52,26 @@ CHARTS = {
} }
def scrape_chart(chart_path, timeout=25000): @contextmanager
"""Scrape a single chart from LookIntoBitcoin. Returns list of trace dicts or None.""" def browser_page():
"""Open one headless browser page for a batch of chart requests."""
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
store = {"data": None} with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
try:
yield browser.new_page()
finally:
browser.close()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True) def scrape_chart(chart_path, timeout=25000, page=None):
page = browser.new_page() """Scrape one chart, optionally reusing a caller-owned browser page."""
if page is None:
with browser_page() as owned_page:
return scrape_chart(chart_path, timeout=timeout, page=owned_page)
store = {"data": None}
def handle_response(response): def handle_response(response):
if "_dash-update-component" in response.url: if "_dash-update-component" in response.url:
@@ -72,10 +84,10 @@ def scrape_chart(chart_path, timeout=25000):
try: try:
page.goto(f"{BASE_URL}{chart_path}", timeout=timeout) page.goto(f"{BASE_URL}{chart_path}", timeout=timeout)
page.wait_for_timeout(6000) page.wait_for_timeout(6000)
except Exception as e: except Exception as exc:
log.warning("Navigation error for %s: %s", chart_path, e) log.warning("Navigation error for %s: %s", chart_path, exc)
finally: finally:
browser.close() page.remove_listener("response", handle_response)
if store["data"]: if store["data"]:
try: try:
@@ -115,6 +127,30 @@ def _find_trace(traces, name):
return None return None
def _trace_signal_is_active(trace):
"""Return true only when the signal trace is active at its latest point."""
if not trace:
return False
values = trace.get("y", [])
if not values:
return False
latest = values[-1]
try:
return latest is not None and float(latest) != 0
except (TypeError, ValueError):
return bool(latest)
def _find_lth_supply_trace(traces):
"""Select an explicitly named LTH supply series and never a price fallback."""
for trace in traces or []:
name = str(trace.get("name", "")).lower()
is_lth = "long-term holder" in name or "long term holder" in name or "lth" in name
if is_lth and "supply" in name and "price" not in name:
return trace
return None
def _get_latest_value(trace): def _get_latest_value(trace):
"""Get the most recent non-null y value from a trace.""" """Get the most recent non-null y value from a trace."""
if not trace: if not trace:
@@ -148,13 +184,18 @@ def _get_recent_values(trace, n=30):
def scrape_all(): def scrape_all():
"""Scrape all charts and return parsed metric values.""" """Scrape all charts while reusing one browser process and page."""
with browser_page() as page:
return _scrape_all_with_page(page)
def _scrape_all_with_page(page):
results = {} results = {}
for metric_key, chart_info in CHARTS.items(): for metric_key, chart_info in CHARTS.items():
log.info("Scraping %s ...", metric_key) log.info("Scraping %s ...", metric_key)
try: try:
traces = scrape_chart(chart_info["path"]) traces = scrape_chart(chart_info["path"], page=page)
if not traces: if not traces:
log.warning("No data for %s", metric_key) log.warning("No data for %s", metric_key)
results[metric_key] = {"value": None, "error": "No data returned"} results[metric_key] = {"value": None, "error": "No data returned"}
@@ -210,21 +251,16 @@ def scrape_all():
], ],
"value": None, "value": None,
} }
# Try to detect buy signal from trace names/colors # A named signal trace is not itself proof that the signal is active.
for t in traces: for t in traces:
name = t.get("name", "").lower() name = t.get("name", "").lower()
if "buy" in name or "signal" in name: if ("buy" in name or "signal" in name) and _trace_signal_is_active(t):
results[metric_key]["buy_signal"] = True results[metric_key]["buy_signal"] = True
break break
elif metric_key == "lth_supply": elif metric_key == "lth_supply":
# Get main supply trace # Require an explicitly named LTH supply trace; price is not supply.
t = traces[0] if traces else None t = _find_lth_supply_trace(traces)
for candidate in traces:
name = candidate.get("name", "").lower()
if "supply" in name or "lth" in name:
t = candidate
break
recent = _get_recent_values(t, 60) recent = _get_recent_values(t, 60)
# Determine trend: compare recent avg to older avg # Determine trend: compare recent avg to older avg
trend = None trend = None
Executable
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT"
export PYTHONPATH="${PYTHONPATH:-.}"
export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-$ROOT/.playwright}"
exec uv run --frozen --no-dev --group runtime --group ml \
python -m uvicorn dashboard.server:app --host 0.0.0.0 --port "${PORT:-3088}"
+56
View File
@@ -0,0 +1,56 @@
import json
import os
from backtesting import engine
def test_run_backtest_caches_by_input_file_signature(monkeypatch, tmp_path):
history = tmp_path / "history.json"
thresholds = tmp_path / "thresholds.json"
weights = tmp_path / "weights.json"
cache = tmp_path / "cache.json"
for path in (history, thresholds, weights, cache):
path.write_text("{}")
monkeypatch.setattr(engine, "HISTORY_PATH", str(history))
monkeypatch.setattr(engine, "_THRESH_PATH", str(thresholds))
monkeypatch.setattr(engine, "ML_WEIGHTS_PATH", str(weights))
monkeypatch.setattr(engine, "CACHE_PATH", str(cache))
calls = []
monkeypatch.setattr(
engine, "_compute_backtest",
lambda ml_mode=False: calls.append(ml_mode) or {"ml_mode": ml_mode, "calls": len(calls)},
)
engine.clear_backtest_cache()
first = engine.run_backtest()
second = engine.run_backtest()
ml_first = engine.run_backtest(ml_mode=True)
ml_second = engine.run_backtest(ml_mode=True)
classic_after_ml = engine.run_backtest()
assert first == second == {"ml_mode": False, "calls": 1}
assert ml_first == ml_second == {"ml_mode": True, "calls": 2}
assert classic_after_ml == first
assert calls == [False, True]
history.write_text('{"changed": true}')
os.utime(history, None)
invalidated = engine.run_backtest()
assert invalidated == {"ml_mode": False, "calls": 3}
def test_cached_backtest_results_are_isolated_from_caller_mutation(monkeypatch, tmp_path):
history = tmp_path / "history.json"
history.write_text("{}")
monkeypatch.setattr(engine, "HISTORY_PATH", str(history))
monkeypatch.setattr(engine, "_THRESH_PATH", str(tmp_path / "missing-thresholds.json"))
monkeypatch.setattr(engine, "ML_WEIGHTS_PATH", str(tmp_path / "missing-weights.json"))
monkeypatch.setattr(engine, "CACHE_PATH", str(tmp_path / "missing-cache.json"))
monkeypatch.setattr(engine, "_compute_backtest", lambda ml_mode=False: {"chart_data": [{"score": 10}]})
engine.clear_backtest_cache()
first = engine.run_backtest()
first["chart_data"][0]["score"] = 99
assert engine.run_backtest()["chart_data"][0]["score"] == 10
+61
View File
@@ -0,0 +1,61 @@
from backtesting import engine as backtest
from scoring import engine as scoring
def test_metric_specific_staleness_does_not_apply_generic_30_day_fill():
lookup = {"2024-01-01": 42}
assert backtest._metric_observation(lookup, "2024-01-03", "fear_greed") == (42, "2024-01-01", 2)
assert backtest._metric_observation(lookup, "2024-01-04", "fear_greed") == (None, None, None)
assert backtest._metric_observation(lookup, "2024-01-08", "200w_sma") == (42, "2024-01-01", 7)
assert backtest._metric_observation(lookup, "2024-01-02", "unknown_metric") == (None, None, None)
def test_current_context_score_uses_only_the_common_backtest_panel():
cached_scored = {
"composite_score": 100,
"metrics": [
{"key": "fear_greed", "score": 10},
{"key": "puell_multiple", "score": 0},
{"key": "sopr", "score": 10},
{"key": "vdd_multiple", "score": 10},
],
}
score, coverage = backtest._common_panel_current_score(cached_scored)
assert score == 50.0
assert coverage == {
"available_count": 2,
"panel_count": len(backtest.BACKTEST_METRIC_PANEL),
"available_keys": ["fear_greed", "puell_multiple"],
}
def test_live_and_backtest_outputs_publish_panel_and_coverage_metadata():
live = scoring.score_all({"fear_greed": {"value": 10}})
assert live["metric_panel"]["id"] == "live-all-v1"
assert live["metric_panel"]["count"] == live["total_count"]
assert live["coverage"]["available_count"] == live["scored_count"]
assert live["coverage"]["ratio"] == live["scored_count"] / live["total_count"]
assert "score_version" in live
metadata = backtest._backtest_data_quality_metadata([3, 5, 9])
assert metadata["metric_panel"]["id"] == "historical-common-v1"
assert metadata["metric_panel"]["keys"] == list(backtest.BACKTEST_METRIC_PANEL)
assert metadata["coverage"] == {
"minimum_metrics": 3,
"maximum_metrics": 9,
"average_metrics": 5.7,
"panel_count": 9,
}
assert metadata["staleness_days"] == backtest.METRIC_MAX_AGE_DAYS
def test_chart_data_exposes_metric_values_under_frontend_contract():
result = backtest.run_backtest()
entries = [entry for entry in result["chart_data"] if entry.get("metric_values")]
assert entries
assert all("metrics" not in entry for entry in entries)
+64
View File
@@ -0,0 +1,64 @@
from backtesting import engine
from ml.artifacts import ML_ARTIFACT_SCHEMA_VERSION, REQUIRED_WEIGHT_KEYS
from scoring.policy import SCORE_VERSION
def _weights(focus):
weights = {key: 0.0 for key in REQUIRED_WEIGHT_KEYS}
weights[focus] = 1.0
return weights
def _artifact(with_folds=True):
artifact = {
"artifact_schema_version": ML_ARTIFACT_SCHEMA_VERSION,
"score_version": SCORE_VERSION,
"weights": _weights("fear_greed"),
"provenance": {
"validation_method": "purged_expanding_window",
"label_horizon_days": 365,
"weight_scope": "full_history_fit",
"training_date_range": {"start": "2018-01-01", "end": "2024-01-01"},
"trained_at": "2026-07-01T00:00:00+00:00",
},
"cv_results": {"folds": []},
}
if with_folds:
artifact["cv_results"]["folds"] = [
{
"fold": 1,
"weights": _weights("drawdown"),
"date_ranges": {"validation": "2020-01-01 to 2020-12-31"},
},
{
"fold": 2,
"weights": _weights("nupl"),
"date_ranges": {"validation": "2021-01-01 to 2021-12-31"},
},
]
return artifact
def test_ml_backtest_plan_prefers_fold_weights_and_marks_them_oos():
plan = engine._build_ml_backtest_plan(_artifact(with_folds=True))
weights, fold = engine._weights_for_backtest_date("2021-06-01", plan)
assert weights == _weights("nupl")
assert fold == 2
assert plan["evaluation_scope"] == "out_of_sample_validation_folds"
assert plan["is_out_of_sample"] is True
assert plan["weighting_source"] == "fold_specific_weights"
assert engine._weights_for_backtest_date("2019-12-31", plan) == (None, None)
def test_full_history_weights_are_explicitly_not_oos():
plan = engine._build_ml_backtest_plan(_artifact(with_folds=False))
weights, fold = engine._weights_for_backtest_date("2021-06-01", plan)
assert weights == _weights("fear_greed")
assert fold is None
assert plan["evaluation_scope"] == "in_sample_full_history_weights"
assert plan["is_out_of_sample"] is False
assert plan["weighting_source"] == "final_full_history_weights"
+61
View File
@@ -0,0 +1,61 @@
from backtesting import engine, statistics
def test_moving_block_bootstrap_is_deterministic_and_handles_constant_series():
first = statistics.moving_block_bootstrap_ci(
[12.5] * 120,
block_size=15,
n_resamples=200,
seed=7,
)
second = statistics.moving_block_bootstrap_ci(
[12.5] * 120,
block_size=15,
n_resamples=200,
seed=7,
)
assert first == second
assert first == {"estimate": 12.5, "ci_low": 12.5, "ci_high": 12.5, "n": 120}
def test_summarize_returns_reports_observations_and_block_bootstrap_interval():
summary = statistics.summarize_returns(
[10.0, -5.0, 20.0, -10.0],
block_size=2,
n_resamples=200,
seed=3,
)
assert summary["n"] == 4
assert summary["mean"] == 3.75
assert summary["median"] == 2.5
assert summary["win_rate"] == 50.0
assert summary["mean_ci_low"] <= summary["mean"] <= summary["mean_ci_high"]
def test_backtest_brackets_publish_bootstrap_confidence_intervals():
stats = {}
engine._add_return_statistics(stats, "90d", [10.0, -5.0, 20.0, -10.0])
assert stats["avg_90d"] == 3.75
assert stats["median_90d"] == 2.5
assert stats["win_rate_90d"] == 50.0
assert stats["avg_90d_ci_low"] <= stats["avg_90d"] <= stats["avg_90d_ci_high"]
def test_long_horizon_returns_use_a_matching_dependence_block(monkeypatch):
observed = {}
def fake_summary(values, *, block_size, n_resamples):
observed.update(block_size=block_size, n_resamples=n_resamples)
return {
"mean": 1.0, "median": 1.0, "win_rate": 100.0,
"mean_ci_low": 0.5, "mean_ci_high": 1.5, "n": len(values),
}
monkeypatch.setattr(engine, "summarize_returns", fake_summary)
engine._add_return_statistics({}, "365d", [1.0] * 500)
assert observed == {"block_size": 365, "n_resamples": 400}
+53
View File
@@ -0,0 +1,53 @@
import threading
from dashboard.jobs import JobRegistry
def test_job_registry_atomically_reserves_only_one_job_per_kind(tmp_path):
registry = JobRegistry(tmp_path / "jobs.json")
barrier = threading.Barrier(10)
results = []
def reserve():
barrier.wait()
results.append(registry.reserve("refresh", details={"full": False}))
threads = [threading.Thread(target=reserve) for _ in range(10)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
reserved = [job for job in results if job is not None]
assert len(reserved) == 1
assert reserved[0]["id"]
assert reserved[0]["status"] == "queued"
assert registry.active("refresh")["id"] == reserved[0]["id"]
def test_job_registry_tracks_completion_and_result(tmp_path):
registry = JobRegistry(tmp_path / "jobs.json")
job = registry.reserve("history")
result = registry.run(job["id"], lambda: {"records": 42})
assert result == {"records": 42}
saved = registry.get(job["id"])
assert saved["status"] == "complete"
assert saved["result"] == {"records": 42}
assert saved["started_at"]
assert saved["finished_at"]
assert registry.active("history") is None
def test_job_registry_marks_abandoned_active_jobs_interrupted_on_restart(tmp_path):
path = tmp_path / "jobs.json"
first = JobRegistry(path)
job = first.reserve("refresh")
restarted = JobRegistry(path)
recovered = restarted.get(job["id"])
assert recovered["status"] == "interrupted"
assert recovered["finished_at"]
assert restarted.active("refresh") is None
+103
View File
@@ -0,0 +1,103 @@
import numpy as np
import pandas as pd
import orchestrator
from ml_engine import train_and_backtest as legacy
from ml_engine.train_and_backtest import create_accumulation_target
def _frame(prices):
return pd.DataFrame({"close": prices})
def test_accumulation_target_for_existing_row_is_invariant_to_unrelated_future_rows():
config = {
"timeframe": "4h",
"target": {
"forward_periods_4h": [1, 2, 3],
"weights": [0.2, 0.3, 0.5],
"return_scales_pct": [5, 10, 20],
},
}
base = _frame([100, 102, 104, 106, 108, 110, 112, 114])
extended = _frame([100, 102, 104, 106, 108, 110, 112, 114, 1000, 1, 2000])
base_target = create_accumulation_target(base, config)
extended_target = create_accumulation_target(extended, config)
assert np.isclose(base_target.iloc[0], extended_target.iloc[0])
assert 0 <= base_target.iloc[0] <= 100
def test_rolling_validation_purges_forward_label_horizon_at_train_boundaries(monkeypatch):
rows = 200
frame = pd.DataFrame({
"feature": np.linspace(0, 1, rows),
"target": np.arange(rows, dtype=float) % 100,
"close": np.linspace(10_000, 20_000, rows),
})
observed = []
def fake_train(X_train, y_train, X_val, y_val, X_test, *args):
observed.append((len(X_train), len(X_val), len(X_test)))
return np.full(len(X_test), 50.0), np.array([1.0])
monkeypatch.setattr(legacy, "_train_and_predict_window", fake_train)
config = {
"model_type": "xgboost",
"target": {"forward_periods_4h": [1, 2, 3]},
"training": {
"rolling_train_size": 120,
"rolling_test_size": 40,
"validation_pct": 0.25,
},
"features": {"use_scaler": False, "use_pca": False},
"strategy": {},
}
legacy.rolling_window_train_test(frame, ["feature"], config)
assert observed[0] == (87, 27, 40)
def test_periodic_accumulation_compares_equal_contributions_and_retains_cash():
result = legacy.simulate_periodic_accumulation(
predicted_scores=np.array([90, 10, 90, 10], dtype=float),
close_prices=np.array([100, 300, 100, 200], dtype=float),
buy_threshold=70,
contribution=100,
)
assert np.isclose(result["dca_contributed"], 400)
assert np.isclose(result["model_contributed"], 400)
assert np.isclose(result["model_cash"], 100)
assert np.isclose(result["model_btc"], 3)
assert result["model_terminal_value"] > result["dca_terminal_value"]
def test_compiled_results_publish_equal_capital_terminal_wealth_metric():
predictions = [
{"predicted": score, "actual": 50.0, "close": price}
for score, price in zip([90, 10, 90, 10], [100, 300, 100, 200])
]
result = legacy.compile_results(
predictions,
per_window_cost_improvement=[],
fi_sum=np.array([1.0]),
fi_count=1,
feature_cols=["feature"],
config={"model_type": "xgboost", "strategy": {"good_buy_threshold": 70}},
)
assert result["terminal_wealth_improvement_pct"] > 0
assert result["backtest_objective"] == "equal_periodic_contribution_terminal_wealth"
def test_orchestrator_selects_models_by_terminal_wealth_not_cost_basis():
results = {
"terminal_wealth_improvement_pct": 4.5,
"cost_basis_improvement_pct": 99.0,
}
assert orchestrator.objective_score(results) == 4.5
+60
View File
@@ -0,0 +1,60 @@
import json
from pathlib import Path
from ml import artifacts
from scoring import engine
REPO_ROOT = Path(__file__).resolve().parents[1]
def _valid_artifact():
return {
"artifact_schema_version": artifacts.ML_ARTIFACT_SCHEMA_VERSION,
"score_version": artifacts.SCORE_VERSION,
"weights": {key: 1 / len(artifacts.REQUIRED_WEIGHT_KEYS) for key in artifacts.REQUIRED_WEIGHT_KEYS},
"provenance": {
"validation_method": "purged_expanding_window",
"label_horizon_days": 365,
"weight_scope": "full_history_fit",
"training_date_range": {"start": "2018-02-01", "end": "2025-03-21"},
"trained_at": "2026-07-01T00:00:00+00:00",
},
}
def test_repository_artifact_has_current_schema_and_purged_provenance():
artifact_path = REPO_ROOT / "config" / "ml_weights.json"
artifact = json.loads(artifact_path.read_text())
status = artifacts.validate_ml_artifact(artifact)
assert status["valid"] is True
assert status["schema_version"] == artifacts.ML_ARTIFACT_SCHEMA_VERSION
assert status["score_version"] == artifacts.SCORE_VERSION
assert status["has_oos_fold_weights"] is True
assert status["errors"] == []
def test_valid_artifact_requires_schema_score_version_and_purged_provenance():
artifact = _valid_artifact()
status = artifacts.validate_ml_artifact(artifact)
assert status == {
"valid": True,
"schema_version": artifacts.ML_ARTIFACT_SCHEMA_VERSION,
"score_version": artifacts.SCORE_VERSION,
"weight_scope": "full_history_fit",
"has_oos_fold_weights": False,
"errors": [],
}
def test_live_scoring_refuses_schema_less_weights(tmp_path, monkeypatch):
path = tmp_path / "ml_weights.json"
path.write_text(json.dumps({"weights": {"fear_greed": 1.0}}))
monkeypatch.setattr(engine, "ML_WEIGHTS_PATH", str(path))
assert engine.load_ml_weights() == {}
assert engine.get_ml_artifact_status()["valid"] is False
+35 -2
View File
@@ -1,5 +1,7 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
import numpy as np
from ml import optimizer from ml import optimizer
@@ -66,9 +68,40 @@ def test_run_out_of_sample_comparison_scores_only_validation_rows_with_fold_weig
assert sum(bucket["days"] for bucket in comparison["ml_weighted"]) == 2 assert sum(bucket["days"] for bucket in comparison["ml_weighted"]) == 2
assert sum(bucket["days"] for bucket in comparison["equal_weight"]) == 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") extreme_ml = next(bucket for bucket in comparison["ml_weighted"] if bucket["label"] == "EXTREME ACCUMULATION ZONE")
assert extreme_ml["days"] == 2 assert extreme_ml["days"] == 2
assert extreme_ml["avg_365d"] == 110.0 assert extreme_ml["avg_365d"] == 110.0
caution_equal = next(bucket for bucket in comparison["equal_weight"] if bucket["label"] == "Caution") caution_equal = next(bucket for bucket in comparison["equal_weight"] if bucket["label"] == "CAUTION — OVERHEATED")
assert caution_equal["days"] == 2 assert caution_equal["days"] == 2
def test_classification_splits_skip_training_windows_with_one_class():
y = np.array([1, 1, 1, 0, 1, 0])
splits = [
(np.array([0, 1]), np.array([2, 3])),
(np.array([0, 1, 3, 4]), np.array([5])),
]
viable = list(optimizer.viable_classification_splits(y, splits))
assert len(viable) == 1
assert viable[0][0].tolist() == [0, 1, 3, 4]
def test_artifact_folds_omit_large_internal_index_arrays():
folds = [{
"fold": 1,
"train_idx": [0, 1],
"val_idx": [2, 3],
"weights": {"fear_greed": 1.0},
"date_ranges": {"validation": "2024-01-01 to 2024-01-02"},
}]
saved = optimizer.artifact_fold_results(folds)
assert saved == [{
"fold": 1,
"weights": {"fear_greed": 1.0},
"date_ranges": {"validation": "2024-01-01 to 2024-01-02"},
}]
+121
View File
@@ -0,0 +1,121 @@
import json
import threading
from datetime import datetime, timedelta, timezone
import pytest
from dashboard.persistence import (
append_daily_jsonl,
atomic_write_json,
load_jsonl_tail,
merge_observation,
onchain_refresh_due,
)
def test_atomic_write_json_remains_readable_under_concurrent_writers(tmp_path):
path = tmp_path / "cache.json"
threads = [
threading.Thread(target=atomic_write_json, args=(path, {"writer": i, "values": list(range(100))}))
for i in range(12)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
saved = json.loads(path.read_text())
assert saved["writer"] in range(12)
assert saved["values"] == list(range(100))
assert not list(tmp_path.glob(".cache.json.*.tmp"))
def test_append_daily_jsonl_writes_at_most_one_entry_per_utc_day(tmp_path):
path = tmp_path / "scores.jsonl"
first = {"timestamp": "2026-07-26T01:00:00+00:00", "score": 10}
duplicate_day = {"timestamp": "2026-07-26T23:59:00+00:00", "score": 20}
next_day = {"timestamp": "2026-07-27T00:01:00+00:00", "score": 30}
assert append_daily_jsonl(path, first) is True
assert append_daily_jsonl(path, duplicate_day) is False
assert append_daily_jsonl(path, next_day) is True
assert load_jsonl_tail(path, limit=90) == [first, next_day]
def test_load_jsonl_tail_is_bounded_and_ignores_malformed_lines(tmp_path):
path = tmp_path / "scores.jsonl"
path.write_text("".join(json.dumps({"n": i}) + "\n" for i in range(200)) + "partial{")
assert load_jsonl_tail(path, limit=3, chunk_size=64) == [{"n": 197}, {"n": 198}, {"n": 199}]
def test_merge_observation_preserves_last_known_good_with_stale_metadata():
old = {
"value": 1.25,
"observed_at": "2026-07-25T12:00:00+00:00",
"source": "lookintobitcoin",
"stale": False,
"last_error": None,
}
merged = merge_observation(old, None, source="lookintobitcoin", error="timeout")
assert merged == {
"value": 1.25,
"observed_at": "2026-07-25T12:00:00+00:00",
"source": "lookintobitcoin",
"stale": True,
"last_error": "timeout",
}
def test_merge_observation_records_metadata_for_fresh_value():
observed_at = "2026-07-26T12:00:00+00:00"
merged = merge_observation(
{"value": 1.0}, {"value": 2.0, "trend": "up"},
source="checkonchain", observed_at=observed_at,
)
assert merged["value"] == 2.0
assert merged["trend"] == "up"
assert merged["observed_at"] == observed_at
assert merged["source"] == "checkonchain"
assert merged["stale"] is False
assert merged["last_error"] is None
def test_merge_observation_rejects_error_only_payload_as_fresh_data():
old = {
"value": 1.25,
"observed_at": "2026-07-25T12:00:00+00:00",
"source": "lookintobitcoin",
"stale": False,
"last_error": None,
}
merged = merge_observation(
old,
{"value": None, "error": "No data returned"},
source="lookintobitcoin",
error="metric missing from scrape",
)
assert merged["value"] == 1.25
assert merged["observed_at"] == old["observed_at"]
assert merged["stale"] is True
assert merged["last_error"] == "No data returned"
@pytest.mark.parametrize("timestamp", [None, "", "not-a-time"])
def test_onchain_refresh_due_when_timestamp_is_missing_or_invalid(timestamp):
assert onchain_refresh_due(timestamp, now=datetime(2026, 7, 26, tzinfo=timezone.utc)) is True
def test_onchain_refresh_due_after_ttl():
now = datetime(2026, 7, 26, 12, tzinfo=timezone.utc)
assert onchain_refresh_due((now - timedelta(hours=5)).isoformat(), now=now, ttl_seconds=21600) is False
assert onchain_refresh_due((now - timedelta(hours=7)).isoformat(), now=now, ttl_seconds=21600) is True
+38
View File
@@ -0,0 +1,38 @@
import pytest
from backtesting import engine as backtest_engine
from ml import optimizer
from scoring import engine as scoring_engine
from scoring import policy
def test_score_brackets_are_contiguous_and_shared_by_all_scoring_paths():
assert backtest_engine.BRACKETS is policy.SCORE_BRACKETS
assert optimizer.BRACKETS is policy.SCORE_BRACKETS
for left, right in zip(policy.SCORE_BRACKETS, policy.SCORE_BRACKETS[1:]):
assert left[1] == right[0]
for tenth in range(0, 1001):
score = tenth / 10
matches = [bracket for bracket in policy.SCORE_BRACKETS if policy.score_in_bracket(score, bracket)]
assert len(matches) == 1, f"score {score} matched {matches}"
assert policy.assessment_for_score(score) == matches[0][2]
@pytest.mark.parametrize(
("score", "assessment"),
[
(0, "EXTREME CAUTION"),
(19.999, "EXTREME CAUTION"),
(20, "CAUTION — OVERHEATED"),
(35, "NEUTRAL"),
(50, "MODERATE OPPORTUNITY"),
(65, "STRONG ACCUMULATION ZONE"),
(80, "EXTREME ACCUMULATION ZONE"),
(100, "EXTREME ACCUMULATION ZONE"),
],
)
def test_assessment_boundaries_match_live_scoring(score, assessment):
assert policy.assessment_for_score(score) == assessment
assert scoring_engine.assessment_for_score(score) == assessment
+2 -1
View File
@@ -59,5 +59,6 @@ def test_score_all_ml_preserves_classic_fallback_when_weights_missing(monkeypatc
scored = engine.score_all_ml(_complete_metrics()) scored = engine.score_all_ml(_complete_metrics())
assert scored["ml_mode"] is False assert scored["ml_mode"] is False
assert scored["ml_error"] == "ML weights not found — run ml/optimizer.py" assert scored["ml_error"]
assert scored["ml_artifact"]["valid"] is False
assert "classic_score" not in scored assert "classic_score" not in scored
+56
View File
@@ -0,0 +1,56 @@
from contextlib import contextmanager
from scrapers import lookintobitcoin
from scoring import engine
def test_hash_ribbon_signal_requires_a_current_truthy_marker():
named_but_inactive = {"name": "Buy Signal", "y": [1, None, None]}
active = {"name": "Buy Signal", "y": [None, 0, 1]}
assert lookintobitcoin._trace_signal_is_active(named_but_inactive) is False
assert lookintobitcoin._trace_signal_is_active(active) is True
def test_lth_supply_trace_selection_never_falls_back_to_price():
traces = [
{"name": "BTC Price", "y": [60000, 61000]},
{"name": "Long-Term Holder Supply", "y": [14_000_000, 14_100_000]},
]
assert lookintobitcoin._find_lth_supply_trace(traces)["name"] == "Long-Term Holder Supply"
assert lookintobitcoin._find_lth_supply_trace(traces[:1]) is None
def test_vdd_derived_return_is_labeled_as_momentum_not_raw_multiple():
result = engine.score_all({"vdd_multiple": {"value": 0.12}})
vdd = next(metric for metric in result["metrics"] if metric["key"] == "vdd_multiple")
assert vdd["name"] == "VDD 30-Period Momentum"
assert vdd["transform"] == "30_period_return"
def test_scrape_all_reuses_one_browser_page(monkeypatch):
page = object()
seen_pages = []
@contextmanager
def fake_browser_page():
yield page
def fake_scrape_chart(_path, timeout=25000, page=None):
seen_pages.append(page)
return [{"name": "metric", "y": [1.0]}]
monkeypatch.setattr(lookintobitcoin, "CHARTS", {
"first": {"path": "/first", "traces": ["metric"]},
"second": {"path": "/second", "traces": ["metric"]},
})
monkeypatch.setattr(lookintobitcoin, "browser_page", fake_browser_page)
monkeypatch.setattr(lookintobitcoin, "scrape_chart", fake_scrape_chart)
result = lookintobitcoin.scrape_all()
assert seen_pages == [page, page]
assert result["first"]["value"] == 1.0
assert result["second"]["value"] == 1.0
+194
View File
@@ -0,0 +1,194 @@
import importlib
import json
import sys
import types
from datetime import datetime, timedelta, timezone
import pytest
@pytest.fixture
def server(monkeypatch):
started = []
monkeypatch.setattr("threading.Thread.start", lambda self: started.append(self))
sys.modules.pop("dashboard.server", None)
module = importlib.import_module("dashboard.server")
module._threads_started_during_import = started
return module
def test_server_import_does_not_start_scheduler_threads(server):
assert server._threads_started_during_import == []
def test_frontend_uses_backtest_metric_values_contract(server):
assert ".filter(d => d.metric_values && d.metric_values[metricKey] != null)" in server.DASHBOARD_HTML
assert ".map(d => ({ date: d.date, value: d.metric_values[metricKey]" in server.DASHBOARD_HTML
def test_dashboard_does_not_issue_duplicate_initial_backtest_request(server):
assert "const br = await fetch('/api/backtest');" not in server.DASHBOARD_HTML
assert server.DASHBOARD_HTML.count("fetch('/api/backtest?mode=' + currentMode)") == 1
def test_health_endpoints_distinguish_process_liveness_from_data_readiness(server, monkeypatch):
assert server.health_live() == {"status": "ok"}
monkeypatch.setattr(server, "load_cache", lambda: {})
unavailable = server.health_ready()
assert unavailable.status_code == 503
monkeypatch.setattr(
server,
"load_cache",
lambda: {"_scored": {"composite_score": 72, "scored_count": 8}},
)
assert server.health_ready() == {
"status": "ready",
"score": 72,
"scored_metrics": 8,
}
def test_server_cache_and_history_use_reliable_persistence(server, monkeypatch, tmp_path):
monkeypatch.setattr(server, "CACHE_PATH", str(tmp_path / "cache.json"))
monkeypatch.setattr(server, "HISTORY_PATH", str(tmp_path / "scores.jsonl"))
server.save_cache({"metric": {"value": 1}})
server.append_history({
"composite_score": 50,
"scored_count": 1,
"metrics": [{"key": "metric", "score": 5, "value": 1}],
})
server.append_history({
"composite_score": 60,
"scored_count": 1,
"metrics": [{"key": "metric", "score": 6, "value": 2}],
})
assert server.load_cache() == {"metric": {"value": 1}}
assert len(server.load_history()) == 1
def test_partial_fast_scrape_preserves_last_known_good_metric(server, monkeypatch, tmp_path):
cache_path = tmp_path / "cache.json"
history_path = tmp_path / "scores.jsonl"
now = datetime.now(timezone.utc)
cache_path.write_text(json.dumps({
"price": {
"price": 65000,
"observed_at": (now - timedelta(minutes=15)).isoformat(),
"source": "coingecko",
"stale": False,
"last_error": None,
},
"puell_multiple": {"value": 1.2},
"_onchain_timestamp": now.isoformat(),
}))
monkeypatch.setattr(server, "CACHE_PATH", str(cache_path))
monkeypatch.setattr(server, "HISTORY_PATH", str(history_path))
monkeypatch.setattr(server.fear_greed, "fetch", lambda: {"value": 25})
monkeypatch.setattr(server.price, "fetch_current", lambda: (_ for _ in ()).throw(RuntimeError("price timeout")))
monkeypatch.setattr(server.price, "fetch_ath", lambda: (_ for _ in ()).throw(RuntimeError("ATH timeout")))
monkeypatch.setattr(server.price, "fetch_historical", lambda: (_ for _ in ()).throw(RuntimeError("history timeout")))
monkeypatch.setattr(server.engine, "score_all", lambda metrics: {"composite_score": 50, "scored_count": 1, "metrics": []})
monkeypatch.setattr(server.engine, "score_all_ml", lambda metrics: {"composite_score": 50, "scored_count": 1, "metrics": []})
fake_updater = types.ModuleType("scrapers.history_updater")
fake_updater.update_history = lambda: None
monkeypatch.setitem(sys.modules, "scrapers.history_updater", fake_updater)
server.run_scrape()
saved = json.loads(cache_path.read_text())
assert saved["price"]["price"] == 65000
assert saved["price"]["stale"] is True
assert "price timeout" in saved["price"]["last_error"]
assert saved["fear_greed"]["value"] == 25
assert saved["fear_greed"]["stale"] is False
assert saved["fear_greed"]["source"] == "alternative.me"
def test_onchain_sources_fail_independently(server, monkeypatch):
fake_lib = types.ModuleType("scrapers.lookintobitcoin")
setattr(fake_lib, "scrape_all", lambda: (_ for _ in ()).throw(RuntimeError("LIB down")))
fake_coc = types.ModuleType("scrapers.checkonchain")
setattr(fake_coc, "scrape_all", lambda: {"sopr": {"value": 0.99}})
monkeypatch.setitem(sys.modules, "scrapers.lookintobitcoin", fake_lib)
monkeypatch.setitem(sys.modules, "scrapers.checkonchain", fake_coc)
observations, errors, successful_sources = server._scrape_onchain_sources()
assert observations["sopr"]["value"] == 0.99
assert successful_sources == 1
assert any("LookIntoBitcoin" in error for error in errors)
def test_expired_onchain_timestamp_triggers_real_refresh(server, monkeypatch, tmp_path):
old = datetime.now(timezone.utc) - timedelta(hours=7)
cache_path = tmp_path / "cache.json"
cache_path.write_text(json.dumps({
"puell_multiple": {"value": 1.2},
"_onchain_timestamp": old.isoformat(),
}))
monkeypatch.setattr(server, "CACHE_PATH", str(cache_path))
monkeypatch.setattr(server, "HISTORY_PATH", str(tmp_path / "scores.jsonl"))
monkeypatch.setattr(server.fear_greed, "fetch", lambda: {"value": 25})
monkeypatch.setattr(server.price, "fetch_current", lambda: {"price": 65000})
monkeypatch.setattr(server.price, "fetch_ath", lambda: {"ath": 70000})
monkeypatch.setattr(server.price, "fetch_historical", lambda: [])
monkeypatch.setattr(server.engine, "score_all", lambda metrics: {"composite_score": 50, "scored_count": 1, "metrics": []})
monkeypatch.setattr(server.engine, "score_all_ml", lambda metrics: {"composite_score": 50, "scored_count": 1, "metrics": []})
calls = []
fake_lib = types.ModuleType("scrapers.lookintobitcoin")
fake_lib.scrape_all = lambda: calls.append("lib") or {"puell_multiple": {"value": 0.8}}
fake_coc = types.ModuleType("scrapers.checkonchain")
fake_coc.scrape_all = lambda: calls.append("coc") or {"sopr": {"value": 0.99}}
fake_updater = types.ModuleType("scrapers.history_updater")
fake_updater.update_history = lambda: None
import scrapers
monkeypatch.setattr(scrapers, "lookintobitcoin", fake_lib, raising=False)
monkeypatch.setattr(scrapers, "checkonchain", fake_coc, raising=False)
monkeypatch.setitem(sys.modules, "scrapers.lookintobitcoin", fake_lib)
monkeypatch.setitem(sys.modules, "scrapers.checkonchain", fake_coc)
monkeypatch.setitem(sys.modules, "scrapers.history_updater", fake_updater)
server.run_scrape()
assert calls == ["lib", "coc"]
saved = json.loads(cache_path.read_text())
assert saved["puell_multiple"]["value"] == 0.8
assert saved["puell_multiple"]["source"] == "lookintobitcoin"
assert saved["sopr"]["source"] == "checkonchain"
assert saved["_onchain_timestamp"] != old.isoformat()
def test_refresh_job_is_reserved_before_thread_start(server, monkeypatch, tmp_path):
from dashboard.jobs import JobRegistry
registry = JobRegistry(tmp_path / "jobs.json")
monkeypatch.setattr(server, "_jobs", registry, raising=False)
monkeypatch.setattr(server, "_scraper_running", False)
started = server.api_refresh(full=False)
duplicate = server.api_refresh(full=False)
assert started["job_id"]
assert started["status"] == "queued"
assert registry.get(started["job_id"])["status"] == "queued"
assert duplicate.status_code == 409
def test_history_collection_has_job_id_and_job_scoped_progress(server, monkeypatch, tmp_path):
from dashboard.jobs import JobRegistry
registry = JobRegistry(tmp_path / "jobs.json")
monkeypatch.setattr(server, "_jobs", registry, raising=False)
started = server.api_backtest_collect()
job = registry.get(started["job_id"])
assert started["status"] == "queued"
assert job["kind"] == "history"
assert job["progress"] == {"status": "starting", "current": "", "step": 0, "total": 0}
assert server.api_job_status(started["job_id"])["id"] == started["job_id"]
Generated
+1754
View File
File diff suppressed because it is too large Load Diff