@vizzor/cli 0.13.1 → 0.14.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +250 -192
- package/chronovisor-engine/pyproject.toml +31 -0
- package/chronovisor-engine/src/__init__.py +0 -0
- package/chronovisor-engine/src/inference/__init__.py +0 -0
- package/chronovisor-engine/src/inference/predict.py +44 -0
- package/chronovisor-engine/src/model_catalog.py +219 -0
- package/chronovisor-engine/src/models/__init__.py +0 -0
- package/chronovisor-engine/src/models/anomaly_detector.py +104 -0
- package/chronovisor-engine/src/models/blockchain_cycle_analyzer.py +217 -0
- package/chronovisor-engine/src/models/catalyst_event_model.py +70 -0
- package/chronovisor-engine/src/models/conformal_interval.py +50 -0
- package/chronovisor-engine/src/models/divergence_detector.py +247 -0
- package/chronovisor-engine/src/models/drift_monitor.py +51 -0
- package/chronovisor-engine/src/models/intent_classifier.py +189 -0
- package/chronovisor-engine/src/models/lstm_predictor.py +143 -0
- package/chronovisor-engine/src/models/microstructure_specialist.py +65 -0
- package/chronovisor-engine/src/models/narrative_detector.py +418 -0
- package/chronovisor-engine/src/models/portfolio_optimizer.py +162 -0
- package/chronovisor-engine/src/models/project_risk_scorer.py +184 -0
- package/chronovisor-engine/src/models/pump_detector.py +344 -0
- package/chronovisor-engine/src/models/regime_detector.py +127 -0
- package/chronovisor-engine/src/models/rug_detector.py +197 -0
- package/chronovisor-engine/src/models/sentiment_analyzer.py +257 -0
- package/chronovisor-engine/src/models/signal_classifier.py +191 -0
- package/chronovisor-engine/src/models/stacking_meta.py +56 -0
- package/chronovisor-engine/src/models/strategy_bandit.py +191 -0
- package/chronovisor-engine/src/models/ta_interpreter.py +341 -0
- package/chronovisor-engine/src/models/target_quantile.py +96 -0
- package/chronovisor-engine/src/models/trend_scorer.py +107 -0
- package/chronovisor-engine/src/models/wallet_classifier.py +261 -0
- package/chronovisor-engine/src/server.py +1686 -0
- package/chronovisor-engine/src/training/__init__.py +0 -0
- package/chronovisor-engine/src/training/data_loader.py +635 -0
- package/chronovisor-engine/src/training/pipeline.py +130 -0
- package/chronovisor-engine/src/training/train_catalyst.py +169 -0
- package/chronovisor-engine/src/training/train_classifier.py +159 -0
- package/chronovisor-engine/src/training/train_conformal.py +106 -0
- package/chronovisor-engine/src/training/train_direction.py +215 -0
- package/chronovisor-engine/src/training/train_drift.py +57 -0
- package/chronovisor-engine/src/training/train_isotonic.py +58 -0
- package/chronovisor-engine/src/training/train_lstm.py +217 -0
- package/chronovisor-engine/src/training/train_microstructure.py +102 -0
- package/chronovisor-engine/src/training/train_narrative.py +168 -0
- package/chronovisor-engine/src/training/train_pump.py +109 -0
- package/chronovisor-engine/src/training/train_regime.py +116 -0
- package/chronovisor-engine/src/training/train_rug.py +58 -0
- package/chronovisor-engine/src/training/train_sentiment.py +63 -0
- package/chronovisor-engine/src/training/train_stacking_meta.py +74 -0
- package/chronovisor-engine/src/training/train_target_quantile.py +115 -0
- package/chronovisor-engine/src/training/train_trend.py +101 -0
- package/dist/index.js +19124 -11698
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""TA Interpreter — Random Forest classifier for technical signal interpretation."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import joblib
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
MODEL_DIR = Path(os.getenv("MODEL_DIR", "models"))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TAInterpreter:
|
|
13
|
+
"""Interprets technical indicators into actionable signals with learned weights."""
|
|
14
|
+
|
|
15
|
+
FEATURE_KEYS = [
|
|
16
|
+
"rsi",
|
|
17
|
+
"macd_histogram",
|
|
18
|
+
"macd_line",
|
|
19
|
+
"macd_signal",
|
|
20
|
+
"bb_percent_b",
|
|
21
|
+
"bb_bandwidth",
|
|
22
|
+
"ema12",
|
|
23
|
+
"ema26",
|
|
24
|
+
"ema_cross_pct",
|
|
25
|
+
"atr",
|
|
26
|
+
"atr_pct",
|
|
27
|
+
"obv",
|
|
28
|
+
"price_change",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
SIGNAL_NAMES = ["RSI", "MACD", "Bollinger Bands", "EMA Crossover", "ATR", "OBV"]
|
|
32
|
+
|
|
33
|
+
DEFAULT_WEIGHTS = {
|
|
34
|
+
"RSI": 20,
|
|
35
|
+
"MACD": 20,
|
|
36
|
+
"Bollinger Bands": 15,
|
|
37
|
+
"EMA Crossover": 20,
|
|
38
|
+
"ATR": 10,
|
|
39
|
+
"OBV": 15,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
self.version = "0.1.0"
|
|
44
|
+
self.is_loaded = False
|
|
45
|
+
self.last_trained: str | None = None
|
|
46
|
+
self.accuracy: float | None = None
|
|
47
|
+
self.model = None
|
|
48
|
+
|
|
49
|
+
def load(self) -> None:
|
|
50
|
+
model_path = MODEL_DIR / "ta_interpreter.joblib"
|
|
51
|
+
try:
|
|
52
|
+
data = joblib.load(model_path)
|
|
53
|
+
self.model = data["model"]
|
|
54
|
+
self.last_trained = data.get("trained_at")
|
|
55
|
+
self.accuracy = data.get("accuracy")
|
|
56
|
+
self.is_loaded = True
|
|
57
|
+
except Exception:
|
|
58
|
+
self.model = None
|
|
59
|
+
self.is_loaded = True # heuristic fallback ready
|
|
60
|
+
|
|
61
|
+
def predict(self, features: dict) -> dict:
|
|
62
|
+
if self.model is not None:
|
|
63
|
+
return self._predict_model(features)
|
|
64
|
+
return self._predict_heuristic(features)
|
|
65
|
+
|
|
66
|
+
def _predict_model(self, features: dict) -> dict:
|
|
67
|
+
x = np.array([[features.get(k, 0) for k in self.FEATURE_KEYS]])
|
|
68
|
+
|
|
69
|
+
# Get probabilities for composite direction
|
|
70
|
+
proba = self.model.predict_proba(x)[0]
|
|
71
|
+
classes = list(self.model.classes_)
|
|
72
|
+
|
|
73
|
+
# Build weights from feature importances
|
|
74
|
+
weights = dict(self.DEFAULT_WEIGHTS)
|
|
75
|
+
if hasattr(self.model, "feature_importances_"):
|
|
76
|
+
imp = self.model.feature_importances_
|
|
77
|
+
# Map feature importances to signal groups
|
|
78
|
+
signal_imp = {
|
|
79
|
+
"RSI": float(imp[0]) if len(imp) > 0 else 0.2,
|
|
80
|
+
"MACD": float(np.mean(imp[1:4])) if len(imp) > 3 else 0.2,
|
|
81
|
+
"Bollinger Bands": float(np.mean(imp[4:6])) if len(imp) > 5 else 0.15,
|
|
82
|
+
"EMA Crossover": float(np.mean(imp[6:9])) if len(imp) > 8 else 0.2,
|
|
83
|
+
"ATR": float(np.mean(imp[9:11])) if len(imp) > 10 else 0.1,
|
|
84
|
+
"OBV": float(imp[11]) if len(imp) > 11 else 0.15,
|
|
85
|
+
}
|
|
86
|
+
total = sum(signal_imp.values()) or 1
|
|
87
|
+
weights = {k: round(v / total * 100, 1) for k, v in signal_imp.items()}
|
|
88
|
+
|
|
89
|
+
# Generate signals from heuristic (structure), but use ML weights/composite
|
|
90
|
+
signals = self._interpret_signals(features)
|
|
91
|
+
|
|
92
|
+
# Composite from ML
|
|
93
|
+
pred_idx = int(np.argmax(proba))
|
|
94
|
+
direction = classes[pred_idx] if pred_idx < len(classes) else "neutral"
|
|
95
|
+
confidence = float(proba[pred_idx]) * 100
|
|
96
|
+
|
|
97
|
+
# Score: positive = bullish, negative = bearish
|
|
98
|
+
bull_prob = proba[classes.index("bullish")] if "bullish" in classes else 0
|
|
99
|
+
bear_prob = proba[classes.index("bearish")] if "bearish" in classes else 0
|
|
100
|
+
score = (bull_prob - bear_prob) * 100
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
"signals": signals,
|
|
104
|
+
"weights": weights,
|
|
105
|
+
"composite": {
|
|
106
|
+
"direction": direction,
|
|
107
|
+
"score": round(score, 2),
|
|
108
|
+
"confidence": round(min(100, confidence), 2),
|
|
109
|
+
},
|
|
110
|
+
"model": "rf-ta-interpreter",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
def _predict_heuristic(self, features: dict) -> dict:
|
|
114
|
+
signals = self._interpret_signals(features)
|
|
115
|
+
weights = dict(self.DEFAULT_WEIGHTS)
|
|
116
|
+
|
|
117
|
+
# Composite: weighted average of signal strengths
|
|
118
|
+
total_weight = 0
|
|
119
|
+
weighted_score = 0
|
|
120
|
+
for sig in signals:
|
|
121
|
+
w = weights.get(sig["name"], 10)
|
|
122
|
+
total_weight += w
|
|
123
|
+
dir_score = (
|
|
124
|
+
sig["strength"]
|
|
125
|
+
if sig["direction"] == "bullish"
|
|
126
|
+
else -sig["strength"]
|
|
127
|
+
if sig["direction"] == "bearish"
|
|
128
|
+
else 0
|
|
129
|
+
)
|
|
130
|
+
weighted_score += dir_score * w
|
|
131
|
+
|
|
132
|
+
score = weighted_score / total_weight if total_weight > 0 else 0
|
|
133
|
+
direction = "bullish" if score > 15 else "bearish" if score < -15 else "neutral"
|
|
134
|
+
|
|
135
|
+
# Confidence from signal agreement
|
|
136
|
+
bull_count = sum(1 for s in signals if s["direction"] == "bullish")
|
|
137
|
+
bear_count = sum(1 for s in signals if s["direction"] == "bearish")
|
|
138
|
+
total_dir = bull_count + bear_count
|
|
139
|
+
agreement = max(bull_count, bear_count) / total_dir if total_dir > 0 else 0
|
|
140
|
+
confidence = agreement * 100 * (len(signals) / 6)
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
"signals": signals,
|
|
144
|
+
"weights": {k: float(v) for k, v in weights.items()},
|
|
145
|
+
"composite": {
|
|
146
|
+
"direction": direction,
|
|
147
|
+
"score": round(score, 2),
|
|
148
|
+
"confidence": round(min(100, confidence), 2),
|
|
149
|
+
},
|
|
150
|
+
"model": "heuristic-ta-interpreter",
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
def _interpret_signals(self, features: dict) -> list[dict]:
|
|
154
|
+
signals = []
|
|
155
|
+
|
|
156
|
+
# RSI
|
|
157
|
+
rsi = features.get("rsi", 50)
|
|
158
|
+
if rsi > 70:
|
|
159
|
+
signals.append(
|
|
160
|
+
{
|
|
161
|
+
"name": "RSI",
|
|
162
|
+
"direction": "bearish",
|
|
163
|
+
"strength": min(100, 50 + (rsi - 70) * 1.5),
|
|
164
|
+
"description": f"RSI {rsi:.1f} — overbought territory",
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
elif rsi < 30:
|
|
168
|
+
signals.append(
|
|
169
|
+
{
|
|
170
|
+
"name": "RSI",
|
|
171
|
+
"direction": "bullish",
|
|
172
|
+
"strength": min(100, 50 + (30 - rsi) * 1.5),
|
|
173
|
+
"description": f"RSI {rsi:.1f} — oversold territory",
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
direction = (
|
|
178
|
+
"bullish" if rsi > 60 else "bearish" if rsi < 40 else "neutral"
|
|
179
|
+
)
|
|
180
|
+
strength = 40 + abs(rsi - 50) if direction != "neutral" else 30
|
|
181
|
+
signals.append(
|
|
182
|
+
{
|
|
183
|
+
"name": "RSI",
|
|
184
|
+
"direction": direction,
|
|
185
|
+
"strength": strength,
|
|
186
|
+
"description": f"RSI {rsi:.1f} — {'bullish' if rsi > 60 else 'bearish' if rsi < 40 else 'neutral'} zone",
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# MACD
|
|
191
|
+
histogram = features.get("macd_histogram", 0)
|
|
192
|
+
if histogram > 0:
|
|
193
|
+
signals.append(
|
|
194
|
+
{
|
|
195
|
+
"name": "MACD",
|
|
196
|
+
"direction": "bullish",
|
|
197
|
+
"strength": min(90, 50 + abs(histogram) * 100),
|
|
198
|
+
"description": f"MACD histogram positive ({histogram:.4f}) — bullish momentum",
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
elif histogram < 0:
|
|
202
|
+
signals.append(
|
|
203
|
+
{
|
|
204
|
+
"name": "MACD",
|
|
205
|
+
"direction": "bearish",
|
|
206
|
+
"strength": min(90, 50 + abs(histogram) * 100),
|
|
207
|
+
"description": f"MACD histogram negative ({histogram:.4f}) — bearish momentum",
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
signals.append(
|
|
212
|
+
{
|
|
213
|
+
"name": "MACD",
|
|
214
|
+
"direction": "neutral",
|
|
215
|
+
"strength": 30,
|
|
216
|
+
"description": "MACD at signal line — no clear direction",
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Bollinger Bands
|
|
221
|
+
pct_b = features.get("bb_percent_b", 0.5)
|
|
222
|
+
if pct_b > 0.8:
|
|
223
|
+
signals.append(
|
|
224
|
+
{
|
|
225
|
+
"name": "Bollinger Bands",
|
|
226
|
+
"direction": "bearish",
|
|
227
|
+
"strength": 55,
|
|
228
|
+
"description": f"Price near upper band (%B: {pct_b:.2f}) — potential pullback",
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
elif pct_b < 0.2:
|
|
232
|
+
signals.append(
|
|
233
|
+
{
|
|
234
|
+
"name": "Bollinger Bands",
|
|
235
|
+
"direction": "bullish",
|
|
236
|
+
"strength": 55,
|
|
237
|
+
"description": f"Price near lower band (%B: {pct_b:.2f}) — potential bounce",
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
signals.append(
|
|
242
|
+
{
|
|
243
|
+
"name": "Bollinger Bands",
|
|
244
|
+
"direction": "neutral",
|
|
245
|
+
"strength": 30,
|
|
246
|
+
"description": f"Price within bands (%B: {pct_b:.2f})",
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# EMA Crossover
|
|
251
|
+
ema_cross = features.get("ema_cross_pct", 0)
|
|
252
|
+
if ema_cross > 0:
|
|
253
|
+
signals.append(
|
|
254
|
+
{
|
|
255
|
+
"name": "EMA Crossover",
|
|
256
|
+
"direction": "bullish",
|
|
257
|
+
"strength": min(90, 50 + abs(ema_cross) * 10),
|
|
258
|
+
"description": f"EMA(12) above EMA(26) by {ema_cross:.2f}% — bullish trend",
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
elif ema_cross < 0:
|
|
262
|
+
signals.append(
|
|
263
|
+
{
|
|
264
|
+
"name": "EMA Crossover",
|
|
265
|
+
"direction": "bearish",
|
|
266
|
+
"strength": min(90, 50 + abs(ema_cross) * 10),
|
|
267
|
+
"description": f"EMA(12) below EMA(26) by {abs(ema_cross):.2f}% — bearish trend",
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
else:
|
|
271
|
+
signals.append(
|
|
272
|
+
{
|
|
273
|
+
"name": "EMA Crossover",
|
|
274
|
+
"direction": "neutral",
|
|
275
|
+
"strength": 30,
|
|
276
|
+
"description": "EMA(12) = EMA(26) — no trend",
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# ATR
|
|
281
|
+
atr_pct = features.get("atr_pct", 0)
|
|
282
|
+
if atr_pct > 5:
|
|
283
|
+
desc = f"ATR {atr_pct:.2f}% — high volatility"
|
|
284
|
+
elif atr_pct > 2:
|
|
285
|
+
desc = f"ATR {atr_pct:.2f}% — moderate volatility"
|
|
286
|
+
else:
|
|
287
|
+
desc = f"ATR {atr_pct:.2f}% — low volatility"
|
|
288
|
+
signals.append(
|
|
289
|
+
{"name": "ATR", "direction": "neutral", "strength": 40, "description": desc}
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# OBV
|
|
293
|
+
obv = features.get("obv", 0)
|
|
294
|
+
price_change = features.get("price_change", 0)
|
|
295
|
+
if obv > 0 and price_change > 0:
|
|
296
|
+
signals.append(
|
|
297
|
+
{
|
|
298
|
+
"name": "OBV",
|
|
299
|
+
"direction": "bullish",
|
|
300
|
+
"strength": 65,
|
|
301
|
+
"description": "OBV positive with rising price — confirmed uptrend",
|
|
302
|
+
}
|
|
303
|
+
)
|
|
304
|
+
elif obv > 0 and price_change <= 0:
|
|
305
|
+
signals.append(
|
|
306
|
+
{
|
|
307
|
+
"name": "OBV",
|
|
308
|
+
"direction": "bullish",
|
|
309
|
+
"strength": 70,
|
|
310
|
+
"description": "OBV positive but price flat/down — accumulation",
|
|
311
|
+
}
|
|
312
|
+
)
|
|
313
|
+
elif obv < 0 and price_change < 0:
|
|
314
|
+
signals.append(
|
|
315
|
+
{
|
|
316
|
+
"name": "OBV",
|
|
317
|
+
"direction": "bearish",
|
|
318
|
+
"strength": 65,
|
|
319
|
+
"description": "OBV negative with falling price — confirmed downtrend",
|
|
320
|
+
}
|
|
321
|
+
)
|
|
322
|
+
elif obv < 0 and price_change >= 0:
|
|
323
|
+
signals.append(
|
|
324
|
+
{
|
|
325
|
+
"name": "OBV",
|
|
326
|
+
"direction": "bearish",
|
|
327
|
+
"strength": 70,
|
|
328
|
+
"description": "OBV negative but price flat/up — distribution",
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
else:
|
|
332
|
+
signals.append(
|
|
333
|
+
{
|
|
334
|
+
"name": "OBV",
|
|
335
|
+
"direction": "neutral",
|
|
336
|
+
"strength": 30,
|
|
337
|
+
"description": "OBV neutral",
|
|
338
|
+
}
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
return signals
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Quantile target model for price-delta forecasts."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import joblib
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ..training.train_direction import ALL_FEATURE_KEYS
|
|
11
|
+
|
|
12
|
+
MODEL_DIR = Path(os.getenv("MODEL_DIR", "models"))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _horizon_to_minutes(horizon: str) -> float:
|
|
16
|
+
value = str(horizon or "4h").strip().lower()
|
|
17
|
+
total = 0.0
|
|
18
|
+
for amount, unit in re.findall(r"(\d+)(mo|y|w|d|h|m)", value):
|
|
19
|
+
quantity = float(amount or 0)
|
|
20
|
+
if unit == "m":
|
|
21
|
+
total += quantity
|
|
22
|
+
elif unit == "h":
|
|
23
|
+
total += quantity * 60
|
|
24
|
+
elif unit == "d":
|
|
25
|
+
total += quantity * 1440
|
|
26
|
+
elif unit == "w":
|
|
27
|
+
total += quantity * 10080
|
|
28
|
+
elif unit == "mo":
|
|
29
|
+
total += quantity * 43200
|
|
30
|
+
elif unit == "y":
|
|
31
|
+
total += quantity * 525600
|
|
32
|
+
return total if total > 0 else 240.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TargetQuantileModel:
|
|
36
|
+
FEATURE_KEYS = ALL_FEATURE_KEYS + ["probability_hint", "horizon_minutes"]
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self.version = "0.1.0"
|
|
40
|
+
self.is_loaded = False
|
|
41
|
+
self.last_trained: str | None = None
|
|
42
|
+
self.accuracy: float | None = None
|
|
43
|
+
self.models: dict[str, object] = {}
|
|
44
|
+
|
|
45
|
+
def load(self) -> None:
|
|
46
|
+
path = MODEL_DIR / "target_xgb_quantile.joblib"
|
|
47
|
+
try:
|
|
48
|
+
data = joblib.load(path)
|
|
49
|
+
self.models = data.get("models", {})
|
|
50
|
+
self.last_trained = data.get("trained_at")
|
|
51
|
+
self.accuracy = data.get("accuracy")
|
|
52
|
+
self.is_loaded = True
|
|
53
|
+
except Exception:
|
|
54
|
+
self.models = {}
|
|
55
|
+
self.is_loaded = True
|
|
56
|
+
|
|
57
|
+
def predict(self, features: dict) -> dict:
|
|
58
|
+
if self.models:
|
|
59
|
+
return self._predict_model(features)
|
|
60
|
+
return self._predict_heuristic(features)
|
|
61
|
+
|
|
62
|
+
def _predict_model(self, features: dict) -> dict:
|
|
63
|
+
enriched = dict(features)
|
|
64
|
+
enriched["probability_hint"] = float(features.get("probability_hint", 0.5))
|
|
65
|
+
enriched["horizon_minutes"] = _horizon_to_minutes(str(features.get("horizon", "4h")))
|
|
66
|
+
x = np.array([[enriched.get(k, 0.0) for k in self.FEATURE_KEYS]], dtype=np.float32)
|
|
67
|
+
q10 = float(self.models["q10"].predict(x)[0])
|
|
68
|
+
q50 = float(self.models["q50"].predict(x)[0])
|
|
69
|
+
q90 = float(self.models["q90"].predict(x)[0])
|
|
70
|
+
low, base, high = sorted([q10, q50, q90])
|
|
71
|
+
return {
|
|
72
|
+
"low_change_pct": round(low, 4),
|
|
73
|
+
"base_change_pct": round(base, 4),
|
|
74
|
+
"high_change_pct": round(high, 4),
|
|
75
|
+
"direction_bias": "up" if base > 0.1 else "down" if base < -0.1 else "sideways",
|
|
76
|
+
"interval_width_pct": round(high - low, 4),
|
|
77
|
+
"model": "target_xgb_quantile",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
def _predict_heuristic(self, features: dict) -> dict:
|
|
81
|
+
base = (
|
|
82
|
+
float(features.get("emaCrossoverPct", 0.0)) * 0.35
|
|
83
|
+
+ float(features.get("macdHistogram", 0.0)) * 2.0
|
|
84
|
+
+ float(features.get("rsiSlope", 0.0)) * 0.08
|
|
85
|
+
)
|
|
86
|
+
width = max(0.35, float(features.get("atrPct", 0.5)) * 1.4)
|
|
87
|
+
low = base - width
|
|
88
|
+
high = base + width
|
|
89
|
+
return {
|
|
90
|
+
"low_change_pct": round(low, 4),
|
|
91
|
+
"base_change_pct": round(base, 4),
|
|
92
|
+
"high_change_pct": round(high, 4),
|
|
93
|
+
"direction_bias": "up" if base > 0.1 else "down" if base < -0.1 else "sideways",
|
|
94
|
+
"interval_width_pct": round(high - low, 4),
|
|
95
|
+
"model": "heuristic-target-quantile",
|
|
96
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Trend Scorer — XGBoost regressor for market trend strength."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import joblib
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
MODEL_DIR = Path(os.getenv("MODEL_DIR", "models"))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TrendScorer:
|
|
13
|
+
"""Predicts trend strength (0-100) and direction from market features."""
|
|
14
|
+
|
|
15
|
+
FEATURE_KEYS = [
|
|
16
|
+
"price_change_24h",
|
|
17
|
+
"price_change_7d",
|
|
18
|
+
"volume_24h",
|
|
19
|
+
"market_cap",
|
|
20
|
+
"volume_to_mcap_ratio",
|
|
21
|
+
"rank",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self.version = "0.1.0"
|
|
26
|
+
self.is_loaded = False
|
|
27
|
+
self.last_trained: str | None = None
|
|
28
|
+
self.accuracy: float | None = None
|
|
29
|
+
self.model = None
|
|
30
|
+
|
|
31
|
+
def load(self) -> None:
|
|
32
|
+
model_path = MODEL_DIR / "trend_scorer.joblib"
|
|
33
|
+
try:
|
|
34
|
+
data = joblib.load(model_path)
|
|
35
|
+
self.model = data["model"]
|
|
36
|
+
self.last_trained = data.get("trained_at")
|
|
37
|
+
self.accuracy = data.get("accuracy")
|
|
38
|
+
self.is_loaded = True
|
|
39
|
+
except Exception:
|
|
40
|
+
self.model = None
|
|
41
|
+
self.is_loaded = True # heuristic fallback ready
|
|
42
|
+
|
|
43
|
+
def predict(self, features: dict) -> dict:
|
|
44
|
+
if self.model is not None:
|
|
45
|
+
return self._predict_model(features)
|
|
46
|
+
return self._predict_heuristic(features)
|
|
47
|
+
|
|
48
|
+
def _predict_model(self, features: dict) -> dict:
|
|
49
|
+
x = np.array([[features.get(k, 0) for k in self.FEATURE_KEYS]])
|
|
50
|
+
score = float(np.clip(self.model.predict(x)[0], 0, 100))
|
|
51
|
+
|
|
52
|
+
# Feature importances
|
|
53
|
+
importances = {}
|
|
54
|
+
if hasattr(self.model, "feature_importances_"):
|
|
55
|
+
for key, imp in zip(self.FEATURE_KEYS, self.model.feature_importances_):
|
|
56
|
+
importances[key] = round(float(imp), 4)
|
|
57
|
+
|
|
58
|
+
direction = "bullish" if score > 60 else "bearish" if score < 40 else "neutral"
|
|
59
|
+
confidence = abs(score - 50) * 2 # 0-100 scale
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
"score": round(score, 2),
|
|
63
|
+
"direction": direction,
|
|
64
|
+
"confidence": round(min(100, confidence), 2),
|
|
65
|
+
"feature_importances": importances,
|
|
66
|
+
"model": "xgboost-trend-scorer",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
def _predict_heuristic(self, features: dict) -> dict:
|
|
70
|
+
score = 50.0
|
|
71
|
+
|
|
72
|
+
pc24h = features.get("price_change_24h", 0)
|
|
73
|
+
pc7d = features.get("price_change_7d", 0)
|
|
74
|
+
vol = features.get("volume_24h", 0)
|
|
75
|
+
mcap = features.get("market_cap", 0)
|
|
76
|
+
|
|
77
|
+
signals = []
|
|
78
|
+
|
|
79
|
+
if pc24h > 5:
|
|
80
|
+
score += 15
|
|
81
|
+
signals.append(f"Strong 24h gain: +{pc24h:.2f}%")
|
|
82
|
+
elif pc24h < -5:
|
|
83
|
+
score -= 15
|
|
84
|
+
signals.append(f"Significant 24h drop: {pc24h:.2f}%")
|
|
85
|
+
|
|
86
|
+
if pc7d > 10:
|
|
87
|
+
score += 20
|
|
88
|
+
signals.append(f"Bullish weekly trend: +{pc7d:.2f}%")
|
|
89
|
+
elif pc7d < -10:
|
|
90
|
+
score -= 20
|
|
91
|
+
signals.append(f"Bearish weekly trend: {pc7d:.2f}%")
|
|
92
|
+
|
|
93
|
+
if mcap > 0 and vol > mcap * 0.1:
|
|
94
|
+
score += 5
|
|
95
|
+
signals.append("High volume relative to market cap")
|
|
96
|
+
|
|
97
|
+
score = max(0, min(100, score))
|
|
98
|
+
direction = "bullish" if score > 60 else "bearish" if score < 40 else "neutral"
|
|
99
|
+
confidence = abs(score - 50) * 2
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"score": round(score, 2),
|
|
103
|
+
"direction": direction,
|
|
104
|
+
"confidence": round(min(100, confidence), 2),
|
|
105
|
+
"feature_importances": {},
|
|
106
|
+
"model": "heuristic-trend-scorer",
|
|
107
|
+
}
|