Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf77737d88 | ||
|
|
2da5d20ccd | ||
|
|
a2b9b431c7 | ||
|
|
1e50760f27 | ||
|
|
dafc21b352 | ||
|
|
4ad9b38e7f | ||
|
|
741de87ce2 | ||
|
|
1c46e1ad4b | ||
|
|
6655bcfa5a | ||
|
|
b06cabf3aa | ||
|
|
f9e992c2b4 | ||
|
|
3a2571df9f | ||
|
|
63d4b6c86a | ||
|
|
a9bdf3b46c | ||
|
|
14d3baea90 | ||
|
|
99f6e80ea1 | ||
|
|
111b458ddf | ||
|
|
3b1bc9a2bf | ||
|
|
661579abf9 | ||
|
|
510b2587ca | ||
|
|
eb8c01611c | ||
|
|
62bff348bf | ||
|
|
1f754ed85d | ||
|
|
a54dec357f | ||
|
|
81654b5743 | ||
|
|
aef714d6c7 |
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.venv
|
||||||
|
.playwright
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
results
|
||||||
|
screenshots
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}}}
|
|
||||||
@@ -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:
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+43
-30
@@ -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"],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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
@@ -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
@@ -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}"
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"},
|
||||||
|
}]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user