Jony Ling commited on
Commit
b717bee
·
0 Parent(s):

Clean initial commit for HF Space

Browse files
Files changed (11) hide show
  1. .gitignore +58 -0
  2. README.md +18 -0
  3. app.py +7 -0
  4. requirements.txt +12 -0
  5. src/backtest_utils.py +48 -0
  6. src/config.py +30 -0
  7. src/data_fetch.py +27 -0
  8. src/features.py +60 -0
  9. src/inference_features.py +291 -0
  10. src/model.py +81 -0
  11. 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."