Spaces:
Runtime error
Runtime error
Jony Ling commited on
Commit ·
b717bee
0
Parent(s):
Clean initial commit for HF Space
Browse files- .gitignore +58 -0
- README.md +18 -0
- app.py +7 -0
- requirements.txt +12 -0
- src/backtest_utils.py +48 -0
- src/config.py +30 -0
- src/data_fetch.py +27 -0
- src/features.py +60 -0
- src/inference_features.py +291 -0
- src/model.py +81 -0
- src/signal.py +112 -0
.gitignore
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Local environments
|
| 2 |
+
.venv/
|
| 3 |
+
venv/
|
| 4 |
+
env/
|
| 5 |
+
ml/
|
| 6 |
+
|
| 7 |
+
# Python cache/build artifacts
|
| 8 |
+
__pycache__/
|
| 9 |
+
*.py[cod]
|
| 10 |
+
*$py.class
|
| 11 |
+
.pytest_cache/
|
| 12 |
+
.mypy_cache/
|
| 13 |
+
.ruff_cache/
|
| 14 |
+
build/
|
| 15 |
+
dist/
|
| 16 |
+
*.egg-info/
|
| 17 |
+
|
| 18 |
+
# Streamlit/local secrets
|
| 19 |
+
.env
|
| 20 |
+
.env.*
|
| 21 |
+
.streamlit/secrets.toml
|
| 22 |
+
|
| 23 |
+
# Jupyter and notebooks not needed by the Space runtime
|
| 24 |
+
.ipynb_checkpoints/
|
| 25 |
+
*.ipynb
|
| 26 |
+
|
| 27 |
+
# Raw or regenerated data artifacts
|
| 28 |
+
*.zip
|
| 29 |
+
*.parquet
|
| 30 |
+
*.pkl
|
| 31 |
+
*.h5
|
| 32 |
+
*.db
|
| 33 |
+
eth_usd_*.csv
|
| 34 |
+
dune_*.csv
|
| 35 |
+
binance_data/
|
| 36 |
+
2026-01_03/
|
| 37 |
+
|
| 38 |
+
# Keep the small feature files used by the Streamlit app
|
| 39 |
+
*.csv
|
| 40 |
+
!data/
|
| 41 |
+
!data/external_market_features.csv
|
| 42 |
+
!data/whale_exchange_flow_features.csv
|
| 43 |
+
|
| 44 |
+
# Logs and temporary outputs
|
| 45 |
+
*.log
|
| 46 |
+
logs/
|
| 47 |
+
tmp/
|
| 48 |
+
temp/
|
| 49 |
+
output/
|
| 50 |
+
catboost_info/
|
| 51 |
+
|
| 52 |
+
# OS/editor files
|
| 53 |
+
.DS_Store
|
| 54 |
+
Thumbs.db
|
| 55 |
+
*.Zone.Identifier
|
| 56 |
+
*Zone.Identifier
|
| 57 |
+
.vscode/
|
| 58 |
+
.idea/
|
README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: ETH USD Predictor
|
| 3 |
+
emoji: 📈
|
| 4 |
+
colorFrom: orange
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
sdk_version: 1.49.0
|
| 8 |
+
app_file: app/streamlit_app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# ETH V5 8h Paper-Trading Signal
|
| 13 |
+
|
| 14 |
+
Streamlit dashboard for the ETH V5 8-hour paper-trading classifier.
|
| 15 |
+
|
| 16 |
+
The app fetches recent ETH/USDT hourly bars, joins the saved external-market features, builds the same live feature set used by the notebook export, and displays the current paper-trading action.
|
| 17 |
+
|
| 18 |
+
This is an experiment/paper-trading tool, not financial advice.
|
app.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compatibility entrypoint for Streamlit hosts that expect app.py at repo root."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import runpy
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
APP_PATH = Path(__file__).resolve().parent / "app" / "streamlit_app.py"
|
| 7 |
+
runpy.run_path(str(APP_PATH), run_name="__main__")
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit==1.49.0
|
| 2 |
+
pandas
|
| 3 |
+
numpy
|
| 4 |
+
polars
|
| 5 |
+
pyarrow
|
| 6 |
+
ccxt
|
| 7 |
+
joblib
|
| 8 |
+
huggingface_hub
|
| 9 |
+
lightgbm
|
| 10 |
+
scikit-learn
|
| 11 |
+
ta
|
| 12 |
+
pyTelegramBotAPI
|
src/backtest_utils.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dual filter utilities — matches cleaned notebook implementation"""
|
| 2 |
+
import numpy as np
|
| 3 |
+
|
| 4 |
+
def dual_filter_backtest(preds, actuals, vols, ret30d, cost=0.0030, K=1.0,
|
| 5 |
+
vol_floor_pct=0.75, ret30d_thresh=0.05, extra_edge=0.0):
|
| 6 |
+
"""Exact dual filter logic from notebook"""
|
| 7 |
+
vols = np.asarray(vols, dtype=float)
|
| 8 |
+
ret30d = np.asarray(ret30d, dtype=float)
|
| 9 |
+
finite_vols = vols[np.isfinite(vols)]
|
| 10 |
+
vol_floor = np.nanpercentile(finite_vols, vol_floor_pct) if len(finite_vols) else np.nan
|
| 11 |
+
|
| 12 |
+
trade_mask = (
|
| 13 |
+
np.isfinite(vols) &
|
| 14 |
+
np.isfinite(ret30d) &
|
| 15 |
+
(np.abs(ret30d) > ret30d_thresh) &
|
| 16 |
+
(vols > vol_floor)
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
preds = np.asarray(preds, dtype=float)
|
| 20 |
+
actuals = np.asarray(actuals, dtype=float)
|
| 21 |
+
valid = np.isfinite(preds) & np.isfinite(actuals) & trade_mask
|
| 22 |
+
strong_signal = np.abs(preds) > (K * cost + extra_edge)
|
| 23 |
+
pos = np.where(valid & strong_signal, np.sign(preds), 0.0)
|
| 24 |
+
|
| 25 |
+
# Reuse notebook backtest result logic
|
| 26 |
+
changes = np.abs(np.diff(pos, prepend=0.0))
|
| 27 |
+
gross = pos * actuals
|
| 28 |
+
net = gross - changes * cost
|
| 29 |
+
eq = np.cumprod(1.0 + net) if len(net) else np.array([1.0])
|
| 30 |
+
n_trades = int(np.sum(changes > 0))
|
| 31 |
+
active = pos != 0
|
| 32 |
+
|
| 33 |
+
sharpe = np.mean(net) / np.std(net) * np.sqrt(365) if np.std(net) > 0 else 0.0
|
| 34 |
+
running_max = np.maximum.accumulate(eq)
|
| 35 |
+
max_dd = np.min((eq - running_max) / running_max) if len(eq) > 0 else 0.0
|
| 36 |
+
wr = np.mean(gross[active] > 0) if active.sum() else 0.0
|
| 37 |
+
|
| 38 |
+
return {
|
| 39 |
+
"sharpe": float(sharpe),
|
| 40 |
+
"cum_return": float(eq[-1] - 1),
|
| 41 |
+
"max_dd": float(max_dd),
|
| 42 |
+
"wr": float(wr),
|
| 43 |
+
"n_trades": n_trades,
|
| 44 |
+
"pct_active": float(active.mean()),
|
| 45 |
+
"eq": eq,
|
| 46 |
+
"net": net,
|
| 47 |
+
"pos": pos,
|
| 48 |
+
}
|
src/config.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration constants — 100% aligned with latest cleaned V5 notebook"""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
# V5 Strategy Parameters (from notebook)
|
| 5 |
+
HORIZON_HOURS = 8
|
| 6 |
+
MODEL_STEP_HOURS = 8
|
| 7 |
+
COST_V5 = 0.0030
|
| 8 |
+
BRT = 0.05 # 30d return threshold
|
| 9 |
+
BVF = 0.75 # volume/volatility percentile floor
|
| 10 |
+
BK5 = 1.0 # base K multiplier
|
| 11 |
+
REGIME_ROUTING = True
|
| 12 |
+
REGIME_SPLIT_THRESH = 0.05
|
| 13 |
+
|
| 14 |
+
# Model settings
|
| 15 |
+
MODEL_TASK = "classification"
|
| 16 |
+
CLASSIFICATION_EDGE_SCALE = 0.006
|
| 17 |
+
|
| 18 |
+
# Paths
|
| 19 |
+
BASE_DIR = Path(__file__).parent.parent
|
| 20 |
+
MODEL_PATH = BASE_DIR / "model_export" / "v5_lgbm_24h_dualfilter.joblib"
|
| 21 |
+
|
| 22 |
+
# Data & Exchange
|
| 23 |
+
SYMBOL = "ETH/USDT"
|
| 24 |
+
TIMEFRAME = "1h" # We fetch hourly, then resample to 8h inside features
|
| 25 |
+
EXCHANGE = "binance"
|
| 26 |
+
|
| 27 |
+
# Hugging Face (change to your username/repo)
|
| 28 |
+
HF_REPO_ID = "yourusername/eth-v5-model" # ← update this
|
| 29 |
+
|
| 30 |
+
print("✅ V5 Config loaded (latest notebook version)")
|
src/data_fetch.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fetch latest hourly ETH data via CCXT — ready for V5 feature pipeline"""
|
| 2 |
+
import ccxt # type: ignore
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from src.config import SYMBOL, TIMEFRAME
|
| 6 |
+
|
| 7 |
+
def fetch_latest_data(limit: int = 2000) -> pd.DataFrame:
|
| 8 |
+
"""Returns hourly OHLCV + placeholder on-chain columns (exactly as notebook)"""
|
| 9 |
+
exchange = ccxt.binance({'enableRateLimit': True})
|
| 10 |
+
|
| 11 |
+
print(f"📡 Fetching latest {TIMEFRAME} ETH data from Binance...")
|
| 12 |
+
ohlcv = exchange.fetch_ohlcv(SYMBOL, timeframe=TIMEFRAME, limit=limit)
|
| 13 |
+
|
| 14 |
+
df = pd.DataFrame(ohlcv, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume'])
|
| 15 |
+
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
|
| 16 |
+
df = df.set_index('timestamp').sort_index()
|
| 17 |
+
df['return'] = df['close'].pct_change().fillna(0.0)
|
| 18 |
+
|
| 19 |
+
# Placeholder on-chain (same scaling as original dataset)
|
| 20 |
+
df['tx_count'] = df['volume'] * 1.5
|
| 21 |
+
df['active_senders'] = df['volume'] * 0.8
|
| 22 |
+
df['active_receivers'] = df['volume'] * 0.7
|
| 23 |
+
df['total_eth_transferred'] = df['volume'] * 1.2
|
| 24 |
+
df['total_gas_used'] = df['volume'] * 0.9
|
| 25 |
+
|
| 26 |
+
print(f"✅ Fetched {len(df)} hourly bars (up to {df.index[-1]})")
|
| 27 |
+
return df
|
src/features.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Full V5 Feature Engineering — mirrors cleaned notebook Part A (pandas version)"""
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import numpy as np
|
| 4 |
+
from ta.momentum import RSIIndicator # type: ignore
|
| 5 |
+
from ta.trend import MACD # type: ignore
|
| 6 |
+
|
| 7 |
+
def add_v5_features(df: pd.DataFrame) -> tuple[pd.DataFrame, list]:
|
| 8 |
+
df = df.copy()
|
| 9 |
+
if not isinstance(df.index, pd.DatetimeIndex):
|
| 10 |
+
df = df.set_index('timestamp').sort_index()
|
| 11 |
+
|
| 12 |
+
c, h, l, v, o = df['close'], df['high'], df['low'], df['volume'], df['open']
|
| 13 |
+
|
| 14 |
+
# === Core V5 blocks from notebook ===
|
| 15 |
+
for lag in [1, 2, 4, 6, 12, 24, 48, 168]:
|
| 16 |
+
df[f'return_{lag}h'] = c.pct_change(lag)
|
| 17 |
+
|
| 18 |
+
df['candle_body_ratio'] = (c - o) / (h - l + 1e-10)
|
| 19 |
+
df['range_pct'] = (h - l) / (c + 1e-10)
|
| 20 |
+
df['close_location_value'] = (c - l) / (h - l + 1e-10)
|
| 21 |
+
|
| 22 |
+
for w in [4, 12, 24, 48, 168]:
|
| 23 |
+
df[f'sma_{w}h'] = c.rolling(w).mean()
|
| 24 |
+
df[f'vol_{w}h'] = c.rolling(w).std()
|
| 25 |
+
df[f'vol_avg_{w}h'] = v.rolling(w).mean()
|
| 26 |
+
df[f'price_vs_sma_{w}h'] = c / df[f'sma_{w}h'] - 1
|
| 27 |
+
|
| 28 |
+
for period in [6, 14, 24]:
|
| 29 |
+
df[f'rsi_{period}h'] = RSIIndicator(c, window=period).rsi()
|
| 30 |
+
|
| 31 |
+
macd = MACD(c, window_slow=26, window_fast=12, window_sign=9)
|
| 32 |
+
df['macd_h'] = macd.macd()
|
| 33 |
+
df['macd_signal_h'] = macd.macd_signal()
|
| 34 |
+
df['macd_hist_h'] = macd.macd_diff()
|
| 35 |
+
|
| 36 |
+
df['vol_ratio_24h'] = v / (v.rolling(24).mean() + 1e-10)
|
| 37 |
+
df['vol_ratio_168h'] = v / (v.rolling(168).mean() + 1e-10)
|
| 38 |
+
|
| 39 |
+
# On-chain momentum
|
| 40 |
+
for col in ['tx_count', 'active_senders', 'active_receivers', 'total_eth_transferred', 'total_gas_used']:
|
| 41 |
+
if col in df.columns:
|
| 42 |
+
df[f'{col}_change_24h'] = df[col] / (df[col].shift(24) + 1e-10) - 1
|
| 43 |
+
df[f'{col}_ma24h'] = df[col].rolling(24).mean()
|
| 44 |
+
|
| 45 |
+
# Cyclical
|
| 46 |
+
df['hour_sin'] = np.sin(2 * np.pi * df.index.hour / 24) # type: ignore
|
| 47 |
+
df['hour_cos'] = np.cos(2 * np.pi * df.index.hour / 24) # type: ignore
|
| 48 |
+
df['dow_sin'] = np.sin(2 * np.pi * df.index.dayofweek / 7) # type: ignore
|
| 49 |
+
df['dow_cos'] = np.cos(2 * np.pi * df.index.dayofweek / 7) # type: ignore
|
| 50 |
+
|
| 51 |
+
# 30d regime features (used by filter & routing)
|
| 52 |
+
df['ret_30d'] = c.pct_change(30*24) # ≈30d on 1h bars
|
| 53 |
+
df['vol_30d'] = df['return'].rolling(30*24).std()
|
| 54 |
+
|
| 55 |
+
df = df.dropna()
|
| 56 |
+
feature_cols = [col for col in df.columns if col not in
|
| 57 |
+
{'open','high','low','close','volume','timestamp'}]
|
| 58 |
+
|
| 59 |
+
print(f"✅ Built {len(feature_cols)} V5 features (matches notebook Part A)")
|
| 60 |
+
return df, feature_cols
|
src/inference_features.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Live V5 feature builder aligned with the cleaned notebook Part A."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import polars as pl
|
| 7 |
+
|
| 8 |
+
EXTERNAL_MARKET_PATHS = [
|
| 9 |
+
Path("data/external_market_features.csv"),
|
| 10 |
+
Path("data/external_market_features.parquet"),
|
| 11 |
+
Path("data/whale_exchange_flow_features.csv"),
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _parse_timestamp_column(frame: pl.DataFrame, timestamp_col: str = "timestamp") -> pl.DataFrame:
|
| 16 |
+
dtype = frame.schema.get(timestamp_col)
|
| 17 |
+
if dtype == pl.Datetime:
|
| 18 |
+
return frame.with_columns(pl.col(timestamp_col).dt.cast_time_unit("us").alias(timestamp_col))
|
| 19 |
+
return frame.with_columns(
|
| 20 |
+
pl.col(timestamp_col).cast(pl.Utf8).str.to_datetime(strict=False).alias(timestamp_col)
|
| 21 |
+
).drop_nulls(timestamp_col)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def load_external_market_features(paths=EXTERNAL_MARKET_PATHS, timestamp_col: str = "timestamp") -> pl.DataFrame | None:
|
| 25 |
+
frames = []
|
| 26 |
+
for raw_path in paths:
|
| 27 |
+
path = Path(raw_path)
|
| 28 |
+
if not path.exists():
|
| 29 |
+
continue
|
| 30 |
+
if path.suffix.lower() == ".parquet":
|
| 31 |
+
frame = pl.read_parquet(path)
|
| 32 |
+
else:
|
| 33 |
+
frame = pl.read_csv(path, try_parse_dates=False, infer_schema_length=10000)
|
| 34 |
+
if timestamp_col not in frame.columns:
|
| 35 |
+
continue
|
| 36 |
+
frame = _parse_timestamp_column(frame, timestamp_col).sort(timestamp_col)
|
| 37 |
+
numeric_cols = [
|
| 38 |
+
col for col, dtype in zip(frame.columns, frame.dtypes)
|
| 39 |
+
if col != timestamp_col and dtype.is_numeric()
|
| 40 |
+
]
|
| 41 |
+
if not numeric_cols:
|
| 42 |
+
continue
|
| 43 |
+
renamed = {col: f"ext_{col}" if not col.startswith("ext_") else col for col in numeric_cols}
|
| 44 |
+
frames.append(frame.select([timestamp_col, *numeric_cols]).rename(renamed))
|
| 45 |
+
|
| 46 |
+
if not frames:
|
| 47 |
+
return None
|
| 48 |
+
|
| 49 |
+
out = frames[0]
|
| 50 |
+
for frame in frames[1:]:
|
| 51 |
+
out = out.join_asof(frame, on=timestamp_col, strategy="backward")
|
| 52 |
+
return out.sort(timestamp_col)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def add_external_feature_transforms(frame: pl.DataFrame, external_cols: list[str]) -> tuple[pl.DataFrame, list[str]]:
|
| 56 |
+
engineered = []
|
| 57 |
+
for col in external_cols:
|
| 58 |
+
for lag in [1, 4, 8, 24]:
|
| 59 |
+
frame = frame.with_columns(pl.col(col).shift(lag).alias(f"{col}_lag_{lag}h"))
|
| 60 |
+
engineered.append(f"{col}_lag_{lag}h")
|
| 61 |
+
frame = frame.with_columns([
|
| 62 |
+
(pl.col(col) - pl.col(col).rolling_mean(24)).alias(f"{col}_dev_24h"),
|
| 63 |
+
(pl.col(col) / (pl.col(col).rolling_mean(168) + 1e-10) - 1).alias(f"{col}_vs_168h"),
|
| 64 |
+
pl.col(col).diff().rolling_mean(8).alias(f"{col}_flow_8h"),
|
| 65 |
+
])
|
| 66 |
+
engineered.extend([f"{col}_dev_24h", f"{col}_vs_168h", f"{col}_flow_8h"])
|
| 67 |
+
return frame, engineered
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _to_hourly_polars(df_raw: pd.DataFrame) -> pl.DataFrame:
|
| 71 |
+
df = df_raw.copy()
|
| 72 |
+
if "timestamp" not in df.columns:
|
| 73 |
+
if isinstance(df.index, pd.DatetimeIndex):
|
| 74 |
+
df = df.reset_index().rename(columns={df.index.name or "index": "timestamp"})
|
| 75 |
+
else:
|
| 76 |
+
raise ValueError("df_raw must have a timestamp column or DatetimeIndex")
|
| 77 |
+
df["timestamp"] = pd.to_datetime(df["timestamp"])
|
| 78 |
+
|
| 79 |
+
for col, scale in {
|
| 80 |
+
"tx_count": 1.5,
|
| 81 |
+
"active_senders": 0.8,
|
| 82 |
+
"active_receivers": 0.7,
|
| 83 |
+
"total_eth_transferred": 1.2,
|
| 84 |
+
"total_gas_used": 0.9,
|
| 85 |
+
}.items():
|
| 86 |
+
if col not in df.columns:
|
| 87 |
+
df[col] = df["volume"] * scale
|
| 88 |
+
|
| 89 |
+
frame = pl.from_pandas(df).with_columns(
|
| 90 |
+
pl.col("timestamp").dt.cast_time_unit("us").alias("timestamp")
|
| 91 |
+
).sort("timestamp")
|
| 92 |
+
diffs = frame["timestamp"].diff().drop_nulls()
|
| 93 |
+
is_hourly = len(diffs) > 0 and diffs.dt.total_minutes().median() >= 55
|
| 94 |
+
if is_hourly:
|
| 95 |
+
return frame.select([
|
| 96 |
+
"timestamp", "open", "high", "low", "close", "volume",
|
| 97 |
+
"tx_count", "active_senders", "active_receivers",
|
| 98 |
+
"total_eth_transferred", "total_gas_used",
|
| 99 |
+
])
|
| 100 |
+
|
| 101 |
+
return frame.group_by_dynamic("timestamp", every="1h").agg([
|
| 102 |
+
pl.col("open").first().alias("open"),
|
| 103 |
+
pl.col("high").max().alias("high"),
|
| 104 |
+
pl.col("low").min().alias("low"),
|
| 105 |
+
pl.col("close").last().alias("close"),
|
| 106 |
+
pl.col("volume").sum().alias("volume"),
|
| 107 |
+
pl.col("tx_count").first().alias("tx_count"),
|
| 108 |
+
pl.col("active_senders").first().alias("active_senders"),
|
| 109 |
+
pl.col("active_receivers").first().alias("active_receivers"),
|
| 110 |
+
pl.col("total_eth_transferred").first().alias("total_eth_transferred"),
|
| 111 |
+
pl.col("total_gas_used").first().alias("total_gas_used"),
|
| 112 |
+
pl.col("close").count().alias("tick_count_1h"),
|
| 113 |
+
]).filter(pl.col("tick_count_1h") >= 30).drop("tick_count_1h").sort("timestamp")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def build_live_v5_features(df_raw: pd.DataFrame) -> tuple[pd.DataFrame, list[str]]:
|
| 117 |
+
df_h5 = _to_hourly_polars(df_raw)
|
| 118 |
+
|
| 119 |
+
external_features = load_external_market_features()
|
| 120 |
+
external_feature_cols: list[str] = []
|
| 121 |
+
if external_features is not None:
|
| 122 |
+
df_h5 = df_h5.join_asof(external_features, on="timestamp", strategy="backward")
|
| 123 |
+
external_feature_cols = [col for col in external_features.columns if col != "timestamp"]
|
| 124 |
+
df_h5 = df_h5.with_columns([
|
| 125 |
+
pl.col(col).fill_null(strategy="forward").fill_null(0.0).alias(col)
|
| 126 |
+
for col in external_feature_cols
|
| 127 |
+
])
|
| 128 |
+
df_h5, external_engineered_cols = add_external_feature_transforms(df_h5, external_feature_cols)
|
| 129 |
+
df_h5 = df_h5.with_columns([
|
| 130 |
+
pl.col(col).fill_null(strategy="forward").fill_null(0.0).alias(col)
|
| 131 |
+
for col in external_engineered_cols
|
| 132 |
+
])
|
| 133 |
+
external_feature_cols = external_feature_cols + external_engineered_cols
|
| 134 |
+
|
| 135 |
+
c, h, l, v, o = pl.col("close"), pl.col("high"), pl.col("low"), pl.col("volume"), pl.col("open")
|
| 136 |
+
|
| 137 |
+
for lag in [1, 2, 4, 6, 12, 24, 48, 168]:
|
| 138 |
+
df_h5 = df_h5.with_columns((c / c.shift(lag) - 1).alias(f"return_{lag}h"))
|
| 139 |
+
|
| 140 |
+
df_h5 = df_h5.with_columns([
|
| 141 |
+
((c - o) / (h - l + 1e-10)).alias("candle_body_ratio"),
|
| 142 |
+
((h - l) / (c + 1e-10)).alias("range_pct"),
|
| 143 |
+
((c - l) / (h - l + 1e-10)).alias("close_location_value"),
|
| 144 |
+
((h - c.shift(1)) / (c.shift(1) + 1e-10)).alias("gap_high"),
|
| 145 |
+
((l - c.shift(1)) / (c.shift(1) + 1e-10)).alias("gap_low"),
|
| 146 |
+
])
|
| 147 |
+
|
| 148 |
+
for w in [4, 12, 24, 48, 168]:
|
| 149 |
+
df_h5 = df_h5.with_columns([
|
| 150 |
+
c.rolling_mean(w).alias(f"sma_{w}h"),
|
| 151 |
+
c.rolling_std(w).alias(f"vol_{w}h"),
|
| 152 |
+
v.rolling_mean(w).alias(f"vol_avg_{w}h"),
|
| 153 |
+
(h - l).rolling_mean(w).alias(f"range_avg_{w}h"),
|
| 154 |
+
(c / c.rolling_mean(w) - 1).alias(f"price_vs_sma_{w}h"),
|
| 155 |
+
])
|
| 156 |
+
|
| 157 |
+
for period in [6, 14, 24]:
|
| 158 |
+
delta = c.diff()
|
| 159 |
+
gain = delta.clip(lower_bound=0).rolling_mean(period)
|
| 160 |
+
loss = (-delta.clip(upper_bound=0)).rolling_mean(period)
|
| 161 |
+
df_h5 = df_h5.with_columns((100 - 100 / (1 + gain / (loss + 1e-10))).alias(f"rsi_{period}h"))
|
| 162 |
+
|
| 163 |
+
ema12 = c.ewm_mean(span=12)
|
| 164 |
+
ema26 = c.ewm_mean(span=26)
|
| 165 |
+
macd = ema12 - ema26
|
| 166 |
+
macd_signal = macd.ewm_mean(span=9)
|
| 167 |
+
df_h5 = df_h5.with_columns([
|
| 168 |
+
macd.alias("macd_h"),
|
| 169 |
+
macd_signal.alias("macd_signal_h"),
|
| 170 |
+
(macd - macd_signal).alias("macd_hist_h"),
|
| 171 |
+
])
|
| 172 |
+
|
| 173 |
+
df_h5 = df_h5.with_columns([
|
| 174 |
+
(v / (v.rolling_mean(24) + 1e-10)).alias("vol_ratio_24h"),
|
| 175 |
+
(v / (v.rolling_mean(168) + 1e-10)).alias("vol_ratio_168h"),
|
| 176 |
+
v.rolling_std(24).alias("vol_volatility_24h"),
|
| 177 |
+
(v * (c - c.shift(1)).sign()).rolling_sum(24).alias("obv_24h"),
|
| 178 |
+
])
|
| 179 |
+
|
| 180 |
+
bb_mid = c.rolling_mean(24)
|
| 181 |
+
bb_std = c.rolling_std(24)
|
| 182 |
+
df_h5 = df_h5.with_columns([
|
| 183 |
+
((c - bb_mid) / (bb_std + 1e-10)).alias("bb_zscore_24h"),
|
| 184 |
+
(bb_std / (bb_mid + 1e-10)).alias("bb_width_24h"),
|
| 185 |
+
])
|
| 186 |
+
|
| 187 |
+
for col in ["tx_count", "active_senders", "active_receivers", "total_eth_transferred", "total_gas_used"]:
|
| 188 |
+
df_h5 = df_h5.with_columns([
|
| 189 |
+
(pl.col(col) / (pl.col(col).shift(24) + 1e-10) - 1).alias(f"{col}_change_24h"),
|
| 190 |
+
pl.col(col).rolling_mean(24).alias(f"{col}_ma24h"),
|
| 191 |
+
pl.col(col).rolling_mean(168).alias(f"{col}_ma168h"),
|
| 192 |
+
])
|
| 193 |
+
|
| 194 |
+
for col in ["tx_count", "active_senders", "total_eth_transferred"]:
|
| 195 |
+
df_h5 = df_h5.with_columns(
|
| 196 |
+
(pl.col(f"{col}_ma24h") / (pl.col(f"{col}_ma168h") + 1e-10) - 1).alias(f"{col}_momentum")
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
df_h5 = df_h5.with_columns([
|
| 200 |
+
(2 * np.pi * pl.col("timestamp").dt.hour() / 24).sin().alias("hour_sin"),
|
| 201 |
+
(2 * np.pi * pl.col("timestamp").dt.hour() / 24).cos().alias("hour_cos"),
|
| 202 |
+
(2 * np.pi * pl.col("timestamp").dt.weekday() / 7).sin().alias("dow_sin"),
|
| 203 |
+
(2 * np.pi * pl.col("timestamp").dt.weekday() / 7).cos().alias("dow_cos"),
|
| 204 |
+
])
|
| 205 |
+
|
| 206 |
+
for lag in [1, 2, 3, 4, 6, 12]:
|
| 207 |
+
df_h5 = df_h5.with_columns([
|
| 208 |
+
(c / c.shift(1) - 1).shift(lag).alias(f"ret_lag_{lag}"),
|
| 209 |
+
pl.col("range_pct").shift(lag).alias(f"range_lag_{lag}"),
|
| 210 |
+
])
|
| 211 |
+
|
| 212 |
+
hr_ret = c / c.shift(1) - 1
|
| 213 |
+
vol_short = hr_ret.rolling_std(window_size=24)
|
| 214 |
+
vol_long = hr_ret.rolling_std(window_size=720)
|
| 215 |
+
df_h5 = df_h5.with_columns([
|
| 216 |
+
(vol_short / (vol_long + 1e-10)).alias("vol_regime_ratio"),
|
| 217 |
+
vol_long.alias("vol_30d"),
|
| 218 |
+
(vol_short - vol_long).alias("vol_shift"),
|
| 219 |
+
])
|
| 220 |
+
|
| 221 |
+
obv = (v * (c - c.shift(1)).sign()).cum_sum()
|
| 222 |
+
obv_slope_24 = obv - obv.shift(24)
|
| 223 |
+
price_slope_24 = c - c.shift(24)
|
| 224 |
+
df_h5 = df_h5.with_columns([
|
| 225 |
+
obv_slope_24.alias("obv_slope_24h"),
|
| 226 |
+
(obv_slope_24.sign() - price_slope_24.sign()).alias("vol_price_divergence"),
|
| 227 |
+
])
|
| 228 |
+
|
| 229 |
+
avg_xfer = pl.col("total_eth_transferred") / (pl.col("active_senders") + 1e-10)
|
| 230 |
+
df_h5 = df_h5.with_columns([
|
| 231 |
+
avg_xfer.alias("avg_transfer_size"),
|
| 232 |
+
(avg_xfer / (avg_xfer.shift(24) + 1e-10) - 1).alias("transfer_size_change_24h"),
|
| 233 |
+
(avg_xfer.rolling_mean(24) / (avg_xfer.rolling_mean(168) + 1e-10) - 1).alias("transfer_size_momentum"),
|
| 234 |
+
])
|
| 235 |
+
|
| 236 |
+
gpv = pl.col("total_gas_used") / (v + 1e-10)
|
| 237 |
+
df_h5 = df_h5.with_columns([
|
| 238 |
+
gpv.alias("gas_per_volume"),
|
| 239 |
+
gpv.rolling_mean(24).alias("gas_per_volume_ma24h"),
|
| 240 |
+
(gpv / (gpv.shift(24) + 1e-10) - 1).alias("gas_per_volume_change_24h"),
|
| 241 |
+
])
|
| 242 |
+
|
| 243 |
+
ret_4h = c / c.shift(4) - 1
|
| 244 |
+
ret_48h = c / c.shift(48) - 1
|
| 245 |
+
ret_168h = c / c.shift(168) - 1
|
| 246 |
+
df_h5 = df_h5.with_columns([
|
| 247 |
+
(ret_4h - ret_48h).alias("momentum_divergence_4_48"),
|
| 248 |
+
(ret_48h - ret_168h).alias("momentum_divergence_48_168"),
|
| 249 |
+
(ret_4h.sign() - ret_168h.sign()).alias("trend_alignment"),
|
| 250 |
+
vol_short.rolling_std(window_size=48).alias("vol_of_vol_48h"),
|
| 251 |
+
])
|
| 252 |
+
|
| 253 |
+
for period in [6, 14, 24]:
|
| 254 |
+
df_h5 = df_h5.with_columns(
|
| 255 |
+
(pl.col(f"rsi_{period}h") * pl.col("vol_ratio_24h")).alias(f"rsi{period}_x_vol")
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
df_h5 = df_h5.with_columns([
|
| 259 |
+
(pl.col("active_senders") / (pl.col("active_senders").shift(24) + 1e-10) - 1).alias("senders_mom_24h"),
|
| 260 |
+
(pl.col("active_senders").rolling_mean(24) / (pl.col("active_senders").rolling_mean(72) + 1e-10) - 1)
|
| 261 |
+
.alias("senders_mom_24_vs_72"),
|
| 262 |
+
pl.col("active_senders").diff().rolling_mean(24).alias("senders_accel_24h"),
|
| 263 |
+
])
|
| 264 |
+
|
| 265 |
+
df_h5 = df_h5.with_columns([
|
| 266 |
+
vol_short.alias("vol_24h_filter"),
|
| 267 |
+
(c / c.shift(720) - 1).alias("ret_30d_filter"),
|
| 268 |
+
])
|
| 269 |
+
|
| 270 |
+
vol_arr = df_h5["vol_24h_filter"].to_numpy()
|
| 271 |
+
ratio_arr = (df_h5["vol_24h_filter"] / (df_h5["vol_30d"] + 1e-10)).to_numpy()
|
| 272 |
+
finite_vol = vol_arr[np.isfinite(vol_arr)]
|
| 273 |
+
vol_p25 = np.nanpercentile(finite_vol, 25) if len(finite_vol) else np.nan
|
| 274 |
+
vol_p75 = np.nanpercentile(finite_vol, 75) if len(finite_vol) else np.nan
|
| 275 |
+
|
| 276 |
+
df_h5 = df_h5.with_columns([
|
| 277 |
+
pl.Series("vol_regime_low", (vol_arr < vol_p25).astype(np.float64)),
|
| 278 |
+
pl.Series("vol_regime_high", (vol_arr > vol_p75).astype(np.float64)),
|
| 279 |
+
pl.Series("vol_regime_expanding", (ratio_arr > 1.2).astype(np.float64)),
|
| 280 |
+
pl.Series("vol_regime_compressing", (ratio_arr < 0.8).astype(np.float64)),
|
| 281 |
+
])
|
| 282 |
+
|
| 283 |
+
exclude = {
|
| 284 |
+
"timestamp", "open", "high", "low", "close", "volume",
|
| 285 |
+
"tx_count", "active_senders", "active_receivers",
|
| 286 |
+
"total_eth_transferred", "total_gas_used",
|
| 287 |
+
"vol_24h_filter", "ret_30d_filter",
|
| 288 |
+
}
|
| 289 |
+
feature_cols = [col for col in df_h5.columns if col not in exclude and not col.startswith("target_ret_")]
|
| 290 |
+
df_feat = df_h5.drop_nulls().to_pandas()
|
| 291 |
+
return df_feat, feature_cols
|
src/model.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Load calibrated V5 model + exact predict_return_signal from notebook"""
|
| 2 |
+
import joblib
|
| 3 |
+
from huggingface_hub import hf_hub_download # type: ignore
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pandas as pd
|
| 6 |
+
from src.config import MODEL_PATH, HF_REPO_ID
|
| 7 |
+
from src.inference_features import build_live_v5_features
|
| 8 |
+
|
| 9 |
+
def load_model():
|
| 10 |
+
if not HF_REPO_ID or HF_REPO_ID.startswith("yourusername/"):
|
| 11 |
+
model = joblib.load(MODEL_PATH)
|
| 12 |
+
print("Model loaded from local file")
|
| 13 |
+
return model
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
path = hf_hub_download(repo_id=HF_REPO_ID, filename="v5_lgbm_24h_dualfilter.joblib")
|
| 17 |
+
model = joblib.load(path)
|
| 18 |
+
print("✅ Model loaded from Hugging Face Hub")
|
| 19 |
+
except Exception:
|
| 20 |
+
model = joblib.load(MODEL_PATH)
|
| 21 |
+
print("✅ Model loaded from local file")
|
| 22 |
+
return model
|
| 23 |
+
|
| 24 |
+
def predict_return_signal(model, X):
|
| 25 |
+
"""Exact function from latest notebook (handles calibrated classifier)"""
|
| 26 |
+
if isinstance(model, dict) and "model" in model: # calibrated classifier
|
| 27 |
+
proba = model["model"].predict_proba(X)[:, 1]
|
| 28 |
+
calibrator = model.get("calibrator")
|
| 29 |
+
if calibrator is None:
|
| 30 |
+
return (proba - 0.5) * 2.0 * 0.006
|
| 31 |
+
# Apply calibrator
|
| 32 |
+
idx = np.searchsorted(calibrator["edges"][1:-1], proba, side="right")
|
| 33 |
+
return calibrator["values"][idx]
|
| 34 |
+
else:
|
| 35 |
+
return model.predict(X) # type: ignore
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def core_model(model):
|
| 39 |
+
return model.get("model") if isinstance(model, dict) and "model" in model else model
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def model_feature_cols(model) -> list[str] | None:
|
| 43 |
+
if isinstance(model, dict):
|
| 44 |
+
cols = model.get("feature_cols") or model.get("features")
|
| 45 |
+
if cols is not None:
|
| 46 |
+
return list(cols)
|
| 47 |
+
return None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def build_live_matrix(df_raw: pd.DataFrame, model):
|
| 51 |
+
df_feat, feature_cols = build_live_v5_features(df_raw)
|
| 52 |
+
latest = df_feat.iloc[-1:].copy()
|
| 53 |
+
trained_feature_cols = model_feature_cols(model)
|
| 54 |
+
if trained_feature_cols is not None:
|
| 55 |
+
missing = [col for col in trained_feature_cols if col not in latest.columns]
|
| 56 |
+
if missing:
|
| 57 |
+
raise ValueError(f"Live data is missing {len(missing)} trained features, e.g. {missing[:10]}")
|
| 58 |
+
X = latest[trained_feature_cols]
|
| 59 |
+
else:
|
| 60 |
+
X = latest[feature_cols]
|
| 61 |
+
expected_features = getattr(core_model(model), "n_features_in_", None)
|
| 62 |
+
if expected_features is not None and X.shape[1] != expected_features:
|
| 63 |
+
raise ValueError(
|
| 64 |
+
f"Live feature count is {X.shape[1]}, but the model expects {expected_features}. "
|
| 65 |
+
"Re-export the notebook model as a dict containing feature_cols=feat_v5."
|
| 66 |
+
)
|
| 67 |
+
return df_feat, latest, X
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def classifier_positive_proba(model, X) -> float | None:
|
| 71 |
+
base_model = core_model(model)
|
| 72 |
+
if hasattr(base_model, "predict_proba"):
|
| 73 |
+
return float(base_model.predict_proba(X)[:, 1][0])
|
| 74 |
+
return None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def predict_next_8h(df_raw: pd.DataFrame, model): # type: ignore
|
| 78 |
+
"""Main inference entry point"""
|
| 79 |
+
_, _, X = build_live_matrix(df_raw, model)
|
| 80 |
+
pred = predict_return_signal(model, X)
|
| 81 |
+
return float(pred[0])
|
src/signal.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Paper-trading signal snapshot shared by Streamlit and Telegram."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pandas as pd
|
| 6 |
+
|
| 7 |
+
from src.config import BRT, BVF, COST_V5
|
| 8 |
+
from src.data_fetch import fetch_latest_data
|
| 9 |
+
from src.model import build_live_matrix, classifier_positive_proba, predict_return_signal
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def confidence_label(confidence: float | None) -> str:
|
| 13 |
+
if confidence is None:
|
| 14 |
+
return "n/a"
|
| 15 |
+
if confidence >= 0.50:
|
| 16 |
+
return "high"
|
| 17 |
+
if confidence >= 0.25:
|
| 18 |
+
return "medium"
|
| 19 |
+
return "low"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def regime_label(ret_30d: float, vol_percentile: float) -> str:
|
| 23 |
+
trend = "bull" if ret_30d > BRT else "bear" if ret_30d < -BRT else "sideways"
|
| 24 |
+
vol = "high-vol" if vol_percentile >= BVF else "normal-vol"
|
| 25 |
+
return f"{trend}, {vol}"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def build_signal_snapshot(model, limit: int = 2000, threshold: float = 0.006) -> dict:
|
| 29 |
+
df_raw = fetch_latest_data(limit=limit)
|
| 30 |
+
df_feat, latest, X = build_live_matrix(df_raw, model)
|
| 31 |
+
|
| 32 |
+
pred = float(predict_return_signal(model, X)[0])
|
| 33 |
+
proba_up = classifier_positive_proba(model, X)
|
| 34 |
+
confidence = abs(proba_up - 0.5) * 2.0 if proba_up is not None else None
|
| 35 |
+
signal_strength = abs(pred) / threshold if threshold > 0 else np.nan
|
| 36 |
+
|
| 37 |
+
row = latest.iloc[0]
|
| 38 |
+
ret_30d = float(row["ret_30d_filter"])
|
| 39 |
+
vol_24h = float(row["vol_24h_filter"])
|
| 40 |
+
vol_series = pd.to_numeric(df_feat["vol_24h_filter"], errors="coerce").dropna()
|
| 41 |
+
vol_percentile = float((vol_series <= vol_24h).mean()) if len(vol_series) else 0.0
|
| 42 |
+
|
| 43 |
+
trend_ok = abs(ret_30d) >= BRT
|
| 44 |
+
vol_ok = vol_percentile >= BVF
|
| 45 |
+
signal_ok = abs(pred) >= threshold
|
| 46 |
+
dual_filter_ok = trend_ok and vol_ok
|
| 47 |
+
|
| 48 |
+
if signal_ok and dual_filter_ok:
|
| 49 |
+
action = "LONG" if pred > 0 else "SHORT"
|
| 50 |
+
elif signal_ok:
|
| 51 |
+
action = "WATCH"
|
| 52 |
+
else:
|
| 53 |
+
action = "FLAT"
|
| 54 |
+
|
| 55 |
+
return {
|
| 56 |
+
"timestamp": df_raw.index[-1],
|
| 57 |
+
"price": float(df_raw["close"].iloc[-1]),
|
| 58 |
+
"pred": pred,
|
| 59 |
+
"proba_up": proba_up,
|
| 60 |
+
"confidence": confidence,
|
| 61 |
+
"confidence_label": confidence_label(confidence),
|
| 62 |
+
"signal_strength": signal_strength,
|
| 63 |
+
"threshold": threshold,
|
| 64 |
+
"net_edge": abs(pred) - COST_V5,
|
| 65 |
+
"ret_30d": ret_30d,
|
| 66 |
+
"vol_24h": vol_24h,
|
| 67 |
+
"vol_percentile": vol_percentile,
|
| 68 |
+
"trend_ok": trend_ok,
|
| 69 |
+
"vol_ok": vol_ok,
|
| 70 |
+
"dual_filter_ok": dual_filter_ok,
|
| 71 |
+
"signal_ok": signal_ok,
|
| 72 |
+
"action": action,
|
| 73 |
+
"direction": "up" if pred > 0 else "down" if pred < 0 else "flat",
|
| 74 |
+
"regime": regime_label(ret_30d, vol_percentile),
|
| 75 |
+
"df_raw": df_raw,
|
| 76 |
+
"df_feat": df_feat,
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def format_signal(snapshot: dict, include_header: bool = True) -> str:
|
| 81 |
+
proba = snapshot["proba_up"]
|
| 82 |
+
proba_text = "n/a" if proba is None else f"{proba:.1%} up / {1 - proba:.1%} down"
|
| 83 |
+
conf = snapshot["confidence"]
|
| 84 |
+
conf_text = "n/a" if conf is None else f"{conf:.1%} ({snapshot['confidence_label']})"
|
| 85 |
+
|
| 86 |
+
header = "<b>ETH V5 8h Paper Signal</b>\n" if include_header else ""
|
| 87 |
+
return (
|
| 88 |
+
f"{header}"
|
| 89 |
+
f"Action: <b>{snapshot['action']}</b>\n"
|
| 90 |
+
f"Expected 8h return: <b>{snapshot['pred']:+.2%}</b>\n"
|
| 91 |
+
f"Classifier probability: {proba_text}\n"
|
| 92 |
+
f"Probability confidence: {conf_text}\n"
|
| 93 |
+
f"Signal strength: {snapshot['signal_strength']:.2f}x threshold\n"
|
| 94 |
+
f"Net edge after cost: {snapshot['net_edge']:+.2%}\n"
|
| 95 |
+
f"Price: ${snapshot['price']:,.2f}\n"
|
| 96 |
+
f"Regime: {snapshot['regime']}\n"
|
| 97 |
+
f"30d return: {snapshot['ret_30d']:+.2%} "
|
| 98 |
+
f"({'pass' if snapshot['trend_ok'] else 'fail'})\n"
|
| 99 |
+
f"24h vol: {snapshot['vol_24h']:.2%}; "
|
| 100 |
+
f"vol percentile: {snapshot['vol_percentile']:.0%} "
|
| 101 |
+
f"({'pass' if snapshot['vol_ok'] else 'fail'})\n"
|
| 102 |
+
f"Signal threshold: {snapshot['threshold']:.2%}\n"
|
| 103 |
+
f"Time: {snapshot['timestamp']}"
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def plain_interpretation(snapshot: dict) -> str:
|
| 108 |
+
if snapshot["action"] in {"LONG", "SHORT"}:
|
| 109 |
+
return "Paper-trade candidate: model signal and both filters agree."
|
| 110 |
+
if snapshot["action"] == "WATCH":
|
| 111 |
+
return "Watch only: model magnitude is large enough, but the regime filters do not both pass."
|
| 112 |
+
return "Flat: expected move is below the paper-trading threshold."
|