@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,1686 @@
|
|
|
1
|
+
"""Vizzor ML Sidecar — FastAPI server for ML inference."""
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from fastapi import FastAPI, Request
|
|
12
|
+
from fastapi.responses import JSONResponse
|
|
13
|
+
from pydantic import AliasChoices, BaseModel, Field, ConfigDict
|
|
14
|
+
|
|
15
|
+
from .model_catalog import get_model_catalog, get_training_roadmap
|
|
16
|
+
from .models.lstm_predictor import LSTMPredictor
|
|
17
|
+
from .models.signal_classifier import SignalClassifier
|
|
18
|
+
from .models.anomaly_detector import AnomalyDetector
|
|
19
|
+
from .models.rug_detector import RugDetector
|
|
20
|
+
from .models.wallet_classifier import WalletClassifier
|
|
21
|
+
from .models.sentiment_analyzer import SentimentAnalyzer
|
|
22
|
+
from .models.trend_scorer import TrendScorer
|
|
23
|
+
from .models.ta_interpreter import TAInterpreter
|
|
24
|
+
from .models.regime_detector import RegimeDetector
|
|
25
|
+
from .models.strategy_bandit import StrategyBandit
|
|
26
|
+
from .models.project_risk_scorer import ProjectRiskScorer
|
|
27
|
+
from .models.portfolio_optimizer import PortfolioOptimizer
|
|
28
|
+
from .models.intent_classifier import IntentClassifier
|
|
29
|
+
from .models.pump_detector import PumpDetectorModel
|
|
30
|
+
from .models.narrative_detector import NarrativeDetectorModel
|
|
31
|
+
from .models.divergence_detector import DivergenceDetectorModel
|
|
32
|
+
from .models.blockchain_cycle_analyzer import BlockchainCycleAnalyzer
|
|
33
|
+
from .models.target_quantile import TargetQuantileModel
|
|
34
|
+
from .models.conformal_interval import ConformalIntervalModel
|
|
35
|
+
from .models.stacking_meta import StackingMetaModel
|
|
36
|
+
from .models.drift_monitor import DriftMonitor
|
|
37
|
+
from .models.microstructure_specialist import MicrostructureSpecialist
|
|
38
|
+
from .models.catalyst_event_model import CatalystEventModel
|
|
39
|
+
from .training.train_rug import RugTrainer
|
|
40
|
+
from .training.train_trend import TrendTrainer
|
|
41
|
+
from .training.train_regime import RegimeTrainer
|
|
42
|
+
from .training.train_sentiment import SentimentTrainer
|
|
43
|
+
from .training.train_pump import PumpTrainer
|
|
44
|
+
from .training.train_narrative import NarrativeTrainer
|
|
45
|
+
from .training.train_direction import DirectionTrainer
|
|
46
|
+
from .training.train_target_quantile import TargetQuantileTrainer
|
|
47
|
+
from .training.train_conformal import ConformalIntervalTrainer
|
|
48
|
+
from .training.train_stacking_meta import StackingMetaTrainer
|
|
49
|
+
from .training.train_isotonic import IsotonicTrainer
|
|
50
|
+
from .training.train_drift import DriftMonitorTrainer
|
|
51
|
+
from .training.train_microstructure import MicrostructureTrainer
|
|
52
|
+
from .training.train_catalyst import CatalystTrainer
|
|
53
|
+
from .training.data_loader import load_direction_outcomes, load_meta_prediction_frame
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Request / Response models
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
VALID_HORIZONS = {"1m", "5m", "15m", "30m", "1h", "4h", "1d", "1w", "1mo", "1y"}
|
|
61
|
+
FLEX_HORIZON_PATTERN = r"^(?:\d+(?:mo|y|w|d|h|m))+$"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FeatureVector(BaseModel):
|
|
65
|
+
model_config = ConfigDict(str_max_length=32)
|
|
66
|
+
|
|
67
|
+
rsi: float = Field(50.0, ge=0, le=100)
|
|
68
|
+
macdHistogram: float = Field(0.0, ge=-1e6, le=1e6)
|
|
69
|
+
bollingerPercentB: float = Field(0.5, ge=-5, le=5)
|
|
70
|
+
ema12: float = Field(0.0, ge=-1e9, le=1e9)
|
|
71
|
+
ema26: float = Field(0.0, ge=-1e9, le=1e9)
|
|
72
|
+
atr: float = Field(0.0, ge=0, le=1e9)
|
|
73
|
+
obv: float = Field(0.0, ge=-1e15, le=1e15)
|
|
74
|
+
fundingRate: float = Field(0.0, ge=-1, le=1)
|
|
75
|
+
fearGreed: float = Field(50.0, ge=0, le=100)
|
|
76
|
+
priceChange24h: float = Field(0.0, ge=-100, le=10000)
|
|
77
|
+
rsiSlope: float = Field(0.0, ge=-100, le=100)
|
|
78
|
+
volumeRatio: float = Field(1.0, ge=0, le=1000)
|
|
79
|
+
emaCrossoverPct: float = Field(0.0, ge=-100, le=100)
|
|
80
|
+
atrPct: float = Field(0.0, ge=0, le=100)
|
|
81
|
+
symbol: str = Field("BTC", max_length=20, pattern=r"^[A-Za-z0-9]{1,20}$")
|
|
82
|
+
timestamp: int = Field(0, ge=0)
|
|
83
|
+
horizon: str = Field(
|
|
84
|
+
"4h",
|
|
85
|
+
max_length=32,
|
|
86
|
+
pattern=FLEX_HORIZON_PATTERN,
|
|
87
|
+
description="Arbitrary minute/hour/day/week/month/year horizon, e.g. 15m, 20m, 2h, 6h, 2w, 3mo, 1y6mo",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PredictionResponse(BaseModel):
|
|
92
|
+
symbol: str
|
|
93
|
+
direction: str # up | down | sideways
|
|
94
|
+
probability: float
|
|
95
|
+
model: str
|
|
96
|
+
horizon: str
|
|
97
|
+
confidence: int
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class BatchRequest(BaseModel):
|
|
101
|
+
features: list[FeatureVector] = Field(max_length=100)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class BatchResponse(BaseModel):
|
|
105
|
+
predictions: list[PredictionResponse]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TokenFlow(BaseModel):
|
|
109
|
+
model_config = ConfigDict(str_max_length=100)
|
|
110
|
+
|
|
111
|
+
symbol: str = Field(max_length=20)
|
|
112
|
+
amount: float = Field(ge=0, le=1e18)
|
|
113
|
+
from_addr: str = Field("", max_length=100)
|
|
114
|
+
to_addr: str = Field("", max_length=100)
|
|
115
|
+
timestamp: int = Field(0, ge=0)
|
|
116
|
+
type: str = Field("transfer", max_length=30)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class FlowRequest(BaseModel):
|
|
120
|
+
flows: list[TokenFlow] = Field(max_length=500)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class AnomalyResponse(BaseModel):
|
|
124
|
+
symbol: str
|
|
125
|
+
score: float
|
|
126
|
+
isAnomaly: bool
|
|
127
|
+
type: str
|
|
128
|
+
details: str
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class AnomaliesResponse(BaseModel):
|
|
132
|
+
anomalies: list[AnomalyResponse]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --- Rug Detection ---
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class RugFeatures(BaseModel):
|
|
139
|
+
bytecode_size: int = Field(0, ge=0, le=10_000_000)
|
|
140
|
+
is_verified: int = Field(0, ge=0, le=1)
|
|
141
|
+
holder_concentration: float = Field(0.0, ge=0, le=100)
|
|
142
|
+
has_proxy: int = Field(0, ge=0, le=1)
|
|
143
|
+
has_mint: int = Field(0, ge=0, le=1)
|
|
144
|
+
has_pause: int = Field(0, ge=0, le=1)
|
|
145
|
+
has_blacklist: int = Field(0, ge=0, le=1)
|
|
146
|
+
liquidity_locked: int = Field(0, ge=0, le=1)
|
|
147
|
+
buy_tax: float = Field(0.0, ge=0, le=100)
|
|
148
|
+
sell_tax: float = Field(0.0, ge=0, le=100)
|
|
149
|
+
contract_age_days: int = Field(0, ge=0, le=100_000)
|
|
150
|
+
total_transfers: int = Field(0, ge=0, le=1_000_000_000)
|
|
151
|
+
owner_balance_pct: float = Field(0.0, ge=0, le=100)
|
|
152
|
+
is_open_source: int = Field(0, ge=0, le=1)
|
|
153
|
+
top10_holder_pct: float = Field(0.0, ge=0, le=100)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class RugRiskFactor(BaseModel):
|
|
157
|
+
factor: str
|
|
158
|
+
importance: float
|
|
159
|
+
value: float
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class RugResponse(BaseModel):
|
|
163
|
+
rug_probability: float
|
|
164
|
+
risk_level: str
|
|
165
|
+
risk_factors: list[RugRiskFactor]
|
|
166
|
+
model: str
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# --- Wallet Classification ---
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class WalletFeatures(BaseModel):
|
|
173
|
+
tx_count: int = Field(0, ge=0, le=10_000_000)
|
|
174
|
+
avg_value_eth: float = Field(0.0, ge=0, le=1e12)
|
|
175
|
+
max_value_eth: float = Field(0.0, ge=0, le=1e12)
|
|
176
|
+
avg_gas_used: float = Field(0.0, ge=0, le=1e12)
|
|
177
|
+
unique_recipients: int = Field(0, ge=0, le=10_000_000)
|
|
178
|
+
unique_methods: int = Field(0, ge=0, le=100_000)
|
|
179
|
+
time_span_hours: float = Field(0.0, ge=0, le=1e8)
|
|
180
|
+
avg_interval_seconds: float = Field(3600.0, ge=0, le=1e9)
|
|
181
|
+
min_interval_seconds: float = Field(60.0, ge=0, le=1e9)
|
|
182
|
+
contract_interaction_pct: float = Field(0.0, ge=0, le=100)
|
|
183
|
+
self_transfer_pct: float = Field(0.0, ge=0, le=100)
|
|
184
|
+
high_value_tx_pct: float = Field(0.0, ge=0, le=100)
|
|
185
|
+
failed_tx_pct: float = Field(0.0, ge=0, le=100)
|
|
186
|
+
token_diversity: int = Field(0, ge=0, le=1_000_000)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class WalletResponse(BaseModel):
|
|
190
|
+
behavior_type: str
|
|
191
|
+
confidence: float
|
|
192
|
+
risk_score: float
|
|
193
|
+
secondary_type: str | None
|
|
194
|
+
indicators: list[str]
|
|
195
|
+
model: str
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# --- Sentiment NLP ---
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class SentimentRequest(BaseModel):
|
|
202
|
+
text: str = Field(max_length=10_000)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class SentimentBatchRequest(BaseModel):
|
|
206
|
+
texts: list[str] = Field(max_length=100)
|
|
207
|
+
# Each text max 10k chars enforced at handler level
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class SentimentResponse(BaseModel):
|
|
211
|
+
sentiment: str # bullish | bearish | neutral
|
|
212
|
+
confidence: float
|
|
213
|
+
score: float # -1 to +1
|
|
214
|
+
key_topics: list[str]
|
|
215
|
+
model: str
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class SentimentBatchResponse(BaseModel):
|
|
219
|
+
results: list[SentimentResponse]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# --- Trend Scoring ---
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class TrendFeatures(BaseModel):
|
|
226
|
+
price_change_24h: float = Field(0.0, ge=-100, le=100_000)
|
|
227
|
+
price_change_7d: float = Field(0.0, ge=-100, le=100_000)
|
|
228
|
+
volume_24h: float = Field(0.0, ge=0, le=1e15)
|
|
229
|
+
market_cap: float = Field(0.0, ge=0, le=1e15)
|
|
230
|
+
volume_to_mcap_ratio: float = Field(0.0, ge=0, le=1000)
|
|
231
|
+
rank: float = Field(0.0, ge=0, le=100_000)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class TrendScoreResponse(BaseModel):
|
|
235
|
+
score: float
|
|
236
|
+
direction: str
|
|
237
|
+
confidence: float
|
|
238
|
+
feature_importances: dict[str, float]
|
|
239
|
+
model: str
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# --- TA Interpretation ---
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class TAFeatures(BaseModel):
|
|
246
|
+
rsi: float = Field(50.0, ge=0, le=100)
|
|
247
|
+
macd_histogram: float = Field(0.0, ge=-1e6, le=1e6)
|
|
248
|
+
macd_line: float = Field(0.0, ge=-1e6, le=1e6)
|
|
249
|
+
macd_signal: float = Field(0.0, ge=-1e6, le=1e6)
|
|
250
|
+
bb_percent_b: float = Field(0.5, ge=-5, le=5)
|
|
251
|
+
bb_bandwidth: float = Field(0.0, ge=0, le=1000)
|
|
252
|
+
ema12: float = Field(0.0, ge=-1e9, le=1e9)
|
|
253
|
+
ema26: float = Field(0.0, ge=-1e9, le=1e9)
|
|
254
|
+
ema_cross_pct: float = Field(0.0, ge=-100, le=100)
|
|
255
|
+
atr: float = Field(0.0, ge=0, le=1e9)
|
|
256
|
+
atr_pct: float = Field(0.0, ge=0, le=100)
|
|
257
|
+
obv: float = Field(0.0, ge=-1e15, le=1e15)
|
|
258
|
+
price_change: float = Field(0.0, ge=-100, le=100_000)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class TASignal(BaseModel):
|
|
262
|
+
name: str
|
|
263
|
+
direction: str
|
|
264
|
+
strength: float
|
|
265
|
+
description: str
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class TAResponse(BaseModel):
|
|
269
|
+
signals: list[TASignal]
|
|
270
|
+
weights: dict[str, float]
|
|
271
|
+
composite: dict[str, object]
|
|
272
|
+
model: str
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# --- Strategy Bandit ---
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class StrategyFeatures(BaseModel):
|
|
279
|
+
model_config = ConfigDict(str_max_length=30)
|
|
280
|
+
|
|
281
|
+
rsi: float = Field(50.0, ge=0, le=100)
|
|
282
|
+
macd_histogram: float = Field(0.0, ge=-1e6, le=1e6)
|
|
283
|
+
ema12: float = Field(0.0, ge=-1e9, le=1e9)
|
|
284
|
+
ema26: float = Field(0.0, ge=-1e9, le=1e9)
|
|
285
|
+
bollinger_pct_b: float = Field(0.5, ge=-5, le=5)
|
|
286
|
+
atr: float = Field(0.0, ge=0, le=1e9)
|
|
287
|
+
obv: float = Field(0.0, ge=-1e15, le=1e15)
|
|
288
|
+
funding_rate: float = Field(0.0, ge=-1, le=1)
|
|
289
|
+
fear_greed: float = Field(50.0, ge=0, le=100)
|
|
290
|
+
price_change_24h: float = Field(0.0, ge=-100, le=100_000)
|
|
291
|
+
price: float = Field(0.0, ge=0, le=1e9)
|
|
292
|
+
regime: str = Field("ranging", max_length=30)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class StrategyResponse(BaseModel):
|
|
296
|
+
action: str
|
|
297
|
+
confidence: float
|
|
298
|
+
position_size_pct: float
|
|
299
|
+
reasoning: list[str]
|
|
300
|
+
model: str
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# --- Regime Detection ---
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class RegimeFeatures(BaseModel):
|
|
307
|
+
returns_1d: float = Field(0.0, ge=-100, le=10000)
|
|
308
|
+
returns_7d: float = Field(0.0, ge=-100, le=10000)
|
|
309
|
+
volatility_14d: float = Field(3.0, ge=0, le=1000)
|
|
310
|
+
volume_ratio: float = Field(1.0, ge=0, le=10000)
|
|
311
|
+
rsi: float = Field(50.0, ge=0, le=100)
|
|
312
|
+
bb_width: float = Field(0.0, ge=0, le=1000)
|
|
313
|
+
fear_greed: float = Field(50.0, ge=0, le=100)
|
|
314
|
+
funding_rate: float = Field(0.0, ge=-1, le=1)
|
|
315
|
+
price_vs_sma200: float = Field(0.0, ge=-100, le=10000)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class RegimeResponse(BaseModel):
|
|
319
|
+
regime: str
|
|
320
|
+
confidence: float
|
|
321
|
+
probabilities: dict[str, float]
|
|
322
|
+
model: str
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# --- Project Risk ---
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class ProjectRiskFeatures(BaseModel):
|
|
329
|
+
bytecode_size: int = Field(0, ge=0, le=10_000_000)
|
|
330
|
+
is_verified: int = Field(0, ge=0, le=1)
|
|
331
|
+
holder_concentration: float = Field(0.0, ge=0, le=100)
|
|
332
|
+
has_proxy: int = Field(0, ge=0, le=1)
|
|
333
|
+
has_mint: int = Field(0, ge=0, le=1)
|
|
334
|
+
has_pause: int = Field(0, ge=0, le=1)
|
|
335
|
+
has_blacklist: int = Field(0, ge=0, le=1)
|
|
336
|
+
liquidity_locked: int = Field(0, ge=0, le=1)
|
|
337
|
+
buy_tax: float = Field(0.0, ge=0, le=100)
|
|
338
|
+
sell_tax: float = Field(0.0, ge=0, le=100)
|
|
339
|
+
contract_age_days: int = Field(0, ge=0, le=100_000)
|
|
340
|
+
total_transfers: int = Field(0, ge=0, le=1_000_000_000)
|
|
341
|
+
owner_balance_pct: float = Field(0.0, ge=0, le=100)
|
|
342
|
+
is_open_source: int = Field(0, ge=0, le=1)
|
|
343
|
+
top10_holder_pct: float = Field(0.0, ge=0, le=100)
|
|
344
|
+
has_token_info: int = Field(1, ge=0, le=1)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class ProjectRiskResponse(BaseModel):
|
|
348
|
+
risk_probability: float
|
|
349
|
+
risk_level: str
|
|
350
|
+
risk_factors: list[RugRiskFactor]
|
|
351
|
+
model: str
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# --- Portfolio Optimization ---
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class PortfolioFeatures(BaseModel):
|
|
358
|
+
model_config = ConfigDict(str_max_length=30)
|
|
359
|
+
|
|
360
|
+
total_value: float = Field(10000.0, ge=0, le=1e15)
|
|
361
|
+
cash: float = Field(10000.0, ge=0, le=1e15)
|
|
362
|
+
win_rate: float = Field(0.5, ge=0, le=1)
|
|
363
|
+
max_drawdown: float = Field(0.0, ge=0, le=100)
|
|
364
|
+
avg_win: float = Field(0.05, ge=0, le=1e6)
|
|
365
|
+
avg_loss: float = Field(0.03, ge=0, le=1e6)
|
|
366
|
+
regime: str = Field("ranging", max_length=30)
|
|
367
|
+
atr_pct: float = Field(3.0, ge=0, le=100)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class PortfolioOptResponse(BaseModel):
|
|
371
|
+
position_size_pct: float
|
|
372
|
+
stop_loss_multiplier: float
|
|
373
|
+
take_profit_multiplier: float
|
|
374
|
+
max_allocation_pct: int
|
|
375
|
+
reasoning: list[str]
|
|
376
|
+
model: str
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# --- Intent Classification ---
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class IntentRequest(BaseModel):
|
|
383
|
+
text: str = Field(max_length=10_000)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class IntentResponse(BaseModel):
|
|
387
|
+
intent: str
|
|
388
|
+
confidence: float
|
|
389
|
+
secondary_intent: str | None
|
|
390
|
+
detected_tokens: list[str]
|
|
391
|
+
detected_addresses: list[str]
|
|
392
|
+
model: str
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# --- Bytecode Risk ---
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class BytecodeFeatures(BaseModel):
|
|
399
|
+
bytecode_size: int = Field(0, ge=0, le=10_000_000)
|
|
400
|
+
is_verified: int = Field(0, ge=0, le=1)
|
|
401
|
+
has_selfdestruct: int = Field(0, ge=0, le=1)
|
|
402
|
+
has_delegatecall: int = Field(0, ge=0, le=1)
|
|
403
|
+
selector_count: int = Field(0, ge=0, le=100_000)
|
|
404
|
+
opcode_entropy: float = Field(0.0, ge=0, le=10)
|
|
405
|
+
has_mint: int = Field(0, ge=0, le=1)
|
|
406
|
+
has_pause: int = Field(0, ge=0, le=1)
|
|
407
|
+
has_blacklist: int = Field(0, ge=0, le=1)
|
|
408
|
+
has_proxy: int = Field(0, ge=0, le=1)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class BytecodeRiskResponse(BaseModel):
|
|
412
|
+
rug_probability: float
|
|
413
|
+
risk_level: str
|
|
414
|
+
risk_factors: list[RugRiskFactor]
|
|
415
|
+
model: str
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# --- Portfolio Forward Prediction ---
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class PortfolioPredFeatures(BaseModel):
|
|
422
|
+
returns_history: list[float] = Field(default_factory=list, max_length=1000)
|
|
423
|
+
sharpe_history: list[float] = Field(default_factory=list, max_length=1000)
|
|
424
|
+
drawdown_history: list[float] = Field(default_factory=list, max_length=1000)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class PortfolioPredResponse(BaseModel):
|
|
428
|
+
predicted_return: float
|
|
429
|
+
predicted_sharpe: float
|
|
430
|
+
predicted_max_drawdown: float
|
|
431
|
+
confidence: float
|
|
432
|
+
model: str
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# --- Target Quantile Prediction ---
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
class TargetFeatures(FeatureVector):
|
|
439
|
+
current_price: float = Field(0.0, ge=0, le=1e12)
|
|
440
|
+
probability_hint: float = Field(0.5, ge=0, le=1)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class TargetResponse(BaseModel):
|
|
444
|
+
low_change_pct: float
|
|
445
|
+
base_change_pct: float
|
|
446
|
+
high_change_pct: float
|
|
447
|
+
low_price: float | None = None
|
|
448
|
+
base_price: float | None = None
|
|
449
|
+
high_price: float | None = None
|
|
450
|
+
direction_bias: str
|
|
451
|
+
interval_width_pct: float
|
|
452
|
+
model: str
|
|
453
|
+
conformal_model: str | None = None
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# --- Meta Confidence ---
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
class MetaConfidenceFeatures(BaseModel):
|
|
460
|
+
model: str = Field("direction_ensemble", max_length=50)
|
|
461
|
+
horizon: str = Field("4h", max_length=20)
|
|
462
|
+
probability: float = Field(0.5, ge=0, le=1)
|
|
463
|
+
rsi: float = Field(50.0, ge=0, le=100)
|
|
464
|
+
macdHistogram: float = Field(0.0, ge=-1e6, le=1e6)
|
|
465
|
+
volumeRatio: float = Field(1.0, ge=0, le=1000)
|
|
466
|
+
atrPct: float = Field(0.0, ge=0, le=100)
|
|
467
|
+
fearGreed: float = Field(50.0, ge=0, le=100)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class MetaConfidenceResponse(BaseModel):
|
|
471
|
+
correctness_probability: float
|
|
472
|
+
verdict: str
|
|
473
|
+
model: str
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# --- Microstructure Specialist ---
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class MicrostructureFeatures(BaseModel):
|
|
480
|
+
return_1: float = Field(0.0, ge=-100, le=1000)
|
|
481
|
+
volume_ratio: float = Field(1.0, ge=0, le=1000)
|
|
482
|
+
range_pct: float = Field(0.0, ge=0, le=1000)
|
|
483
|
+
wick_imbalance: float = Field(0.0, ge=-1000, le=1000)
|
|
484
|
+
trade_intensity: float = Field(0.0, ge=0, le=1000)
|
|
485
|
+
price_vs_sma20: float = Field(0.0, ge=-1000, le=1000)
|
|
486
|
+
volatility_5: float = Field(0.0, ge=0, le=1000)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
class MicrostructureResponse(BaseModel):
|
|
490
|
+
direction: str
|
|
491
|
+
probability: float
|
|
492
|
+
confidence: float
|
|
493
|
+
model: str
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
# --- Catalyst Event Model ---
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
class CatalystFeatures(BaseModel):
|
|
500
|
+
days_to_event: float = Field(30.0, ge=0, le=365)
|
|
501
|
+
event_risk: float = Field(0.0, ge=0, le=1)
|
|
502
|
+
within_24h: float = Field(0.0, ge=0, le=1)
|
|
503
|
+
within_72h: float = Field(0.0, ge=0, le=1)
|
|
504
|
+
within_7d: float = Field(0.0, ge=0, le=1)
|
|
505
|
+
is_fomc: float = Field(0.0, ge=0, le=1)
|
|
506
|
+
is_cpi: float = Field(0.0, ge=0, le=1)
|
|
507
|
+
is_nfp: float = Field(0.0, ge=0, le=1)
|
|
508
|
+
returns_1d: float = Field(0.0, ge=-1000, le=1000)
|
|
509
|
+
returns_7d: float = Field(0.0, ge=-1000, le=1000)
|
|
510
|
+
volatility_14d: float = Field(0.0, ge=0, le=1000)
|
|
511
|
+
fear_greed: float = Field(50.0, ge=0, le=100)
|
|
512
|
+
funding_rate: float = Field(0.0, ge=-1, le=1)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class CatalystResponse(BaseModel):
|
|
516
|
+
direction: str
|
|
517
|
+
probability: float
|
|
518
|
+
event_risk: float
|
|
519
|
+
model: str
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# --- Training Pipeline ---
|
|
523
|
+
|
|
524
|
+
TRAINABLE_MODEL_PATTERN = (
|
|
525
|
+
r"^(rug_detector|trend_scorer|regime_detector|sentiment_nlp|pump_detector|"
|
|
526
|
+
r"narrative_detector|direction_ensemble|ensemble|target_xgb_quantile|"
|
|
527
|
+
r"interval_conformal_calibrator|meta_stacking|meta_isotonic|meta_drift_detector|"
|
|
528
|
+
r"microstructure_specialist|catalyst_event)$"
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
class TrainRequest(BaseModel):
|
|
533
|
+
model_config = ConfigDict(populate_by_name=True, str_max_length=50)
|
|
534
|
+
|
|
535
|
+
model_name: str = Field(
|
|
536
|
+
...,
|
|
537
|
+
max_length=50,
|
|
538
|
+
pattern=TRAINABLE_MODEL_PATTERN,
|
|
539
|
+
validation_alias=AliasChoices("model_name", "model"),
|
|
540
|
+
description=(
|
|
541
|
+
"Model to train: rug_detector, trend_scorer, regime_detector, "
|
|
542
|
+
"sentiment_nlp, pump_detector, narrative_detector, direction_ensemble, ensemble, "
|
|
543
|
+
"target_xgb_quantile, interval_conformal_calibrator, meta_stacking, meta_isotonic, "
|
|
544
|
+
"meta_drift_detector, microstructure_specialist, catalyst_event"
|
|
545
|
+
),
|
|
546
|
+
)
|
|
547
|
+
lookback_days: int = Field(90, ge=7, le=3650)
|
|
548
|
+
reason: str | None = Field(None, max_length=200)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
class TrainResponse(BaseModel):
|
|
552
|
+
model: str
|
|
553
|
+
status: str
|
|
554
|
+
metrics: dict | None = None
|
|
555
|
+
duration_seconds: float
|
|
556
|
+
artifact_path: str
|
|
557
|
+
error: str | None = None
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
class EvaluateRequest(BaseModel):
|
|
561
|
+
model_config = ConfigDict(populate_by_name=True, str_max_length=50)
|
|
562
|
+
|
|
563
|
+
model_name: str = Field(
|
|
564
|
+
...,
|
|
565
|
+
max_length=50,
|
|
566
|
+
pattern=TRAINABLE_MODEL_PATTERN,
|
|
567
|
+
validation_alias=AliasChoices("model_name", "model"),
|
|
568
|
+
description=(
|
|
569
|
+
"Model to evaluate: rug_detector, trend_scorer, regime_detector, "
|
|
570
|
+
"sentiment_nlp, pump_detector, narrative_detector, direction_ensemble, ensemble, "
|
|
571
|
+
"target_xgb_quantile, interval_conformal_calibrator, meta_stacking, meta_isotonic, "
|
|
572
|
+
"meta_drift_detector, microstructure_specialist, catalyst_event"
|
|
573
|
+
),
|
|
574
|
+
)
|
|
575
|
+
lookback_days: int = Field(90, ge=7, le=3650)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
class RetrainRequest(BaseModel):
|
|
579
|
+
model_config = ConfigDict(str_max_length=50)
|
|
580
|
+
|
|
581
|
+
models: list[str] = Field(default_factory=lambda: ["direction_ensemble"], max_length=20)
|
|
582
|
+
lookback_days: int = Field(90, ge=7, le=3650)
|
|
583
|
+
reason: str | None = Field(None, max_length=200)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
class RetrainResponse(BaseModel):
|
|
587
|
+
job_id: str
|
|
588
|
+
status: str
|
|
589
|
+
models: list[str]
|
|
590
|
+
results: list[dict]
|
|
591
|
+
duration_seconds: float
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
class EvaluateResponse(BaseModel):
|
|
595
|
+
model: str
|
|
596
|
+
status: str
|
|
597
|
+
metrics: dict | None = None
|
|
598
|
+
error: str | None = None
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# ---------------------------------------------------------------------------
|
|
602
|
+
# Model instances
|
|
603
|
+
# ---------------------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
lstm = LSTMPredictor()
|
|
606
|
+
classifier = SignalClassifier()
|
|
607
|
+
anomaly_detector = AnomalyDetector()
|
|
608
|
+
rug_detector = RugDetector()
|
|
609
|
+
wallet_classifier = WalletClassifier()
|
|
610
|
+
sentiment_analyzer = SentimentAnalyzer()
|
|
611
|
+
trend_scorer = TrendScorer()
|
|
612
|
+
ta_interpreter = TAInterpreter()
|
|
613
|
+
regime_detector = RegimeDetector()
|
|
614
|
+
strategy_bandit = StrategyBandit()
|
|
615
|
+
project_risk_scorer = ProjectRiskScorer()
|
|
616
|
+
portfolio_optimizer = PortfolioOptimizer()
|
|
617
|
+
intent_classifier = IntentClassifier()
|
|
618
|
+
pump_detector = PumpDetectorModel()
|
|
619
|
+
narrative_detector = NarrativeDetectorModel()
|
|
620
|
+
divergence_detector = DivergenceDetectorModel()
|
|
621
|
+
blockchain_cycle_analyzer = BlockchainCycleAnalyzer()
|
|
622
|
+
target_quantile_model = TargetQuantileModel()
|
|
623
|
+
conformal_interval_model = ConformalIntervalModel()
|
|
624
|
+
stacking_meta_model = StackingMetaModel()
|
|
625
|
+
drift_monitor = DriftMonitor()
|
|
626
|
+
microstructure_specialist = MicrostructureSpecialist()
|
|
627
|
+
catalyst_event_model = CatalystEventModel()
|
|
628
|
+
start_time = time.time()
|
|
629
|
+
predictions_served = 0
|
|
630
|
+
MODEL_DIR = Path(os.getenv("MODEL_DIR", "models"))
|
|
631
|
+
training_status = "idle"
|
|
632
|
+
training_job_id: str | None = None
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
@asynccontextmanager
|
|
636
|
+
async def lifespan(_app: FastAPI):
|
|
637
|
+
"""Load models on startup."""
|
|
638
|
+
lstm.load()
|
|
639
|
+
classifier.load()
|
|
640
|
+
anomaly_detector.load()
|
|
641
|
+
rug_detector.load()
|
|
642
|
+
wallet_classifier.load()
|
|
643
|
+
sentiment_analyzer.load()
|
|
644
|
+
trend_scorer.load()
|
|
645
|
+
ta_interpreter.load()
|
|
646
|
+
regime_detector.load()
|
|
647
|
+
strategy_bandit.load()
|
|
648
|
+
project_risk_scorer.load()
|
|
649
|
+
portfolio_optimizer.load()
|
|
650
|
+
intent_classifier.load()
|
|
651
|
+
pump_detector.load()
|
|
652
|
+
narrative_detector.load()
|
|
653
|
+
divergence_detector.load()
|
|
654
|
+
blockchain_cycle_analyzer.load()
|
|
655
|
+
target_quantile_model.load()
|
|
656
|
+
conformal_interval_model.load()
|
|
657
|
+
stacking_meta_model.load()
|
|
658
|
+
drift_monitor.load()
|
|
659
|
+
microstructure_specialist.load()
|
|
660
|
+
catalyst_event_model.load()
|
|
661
|
+
_load_isotonic_artifact()
|
|
662
|
+
yield
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
app = FastAPI(
|
|
666
|
+
title="Vizzor ML Sidecar",
|
|
667
|
+
version="0.14.5",
|
|
668
|
+
lifespan=lifespan,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
# ---------------------------------------------------------------------------
|
|
672
|
+
# Security middleware — require ML_API_SECRET if set
|
|
673
|
+
# ---------------------------------------------------------------------------
|
|
674
|
+
|
|
675
|
+
ML_API_SECRET = os.getenv("ML_API_SECRET", "")
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
@app.middleware("http")
|
|
679
|
+
async def check_api_secret(request: Request, call_next):
|
|
680
|
+
if ML_API_SECRET and request.url.path != "/health":
|
|
681
|
+
token = request.headers.get("x-api-secret", "")
|
|
682
|
+
if not hmac.compare_digest(token.encode(), ML_API_SECRET.encode()):
|
|
683
|
+
return JSONResponse(status_code=403, content={"detail": "Forbidden"})
|
|
684
|
+
return await call_next(request)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
# ---------------------------------------------------------------------------
|
|
688
|
+
# Global exception handler — suppress stack traces in responses
|
|
689
|
+
# ---------------------------------------------------------------------------
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
@app.exception_handler(Exception)
|
|
693
|
+
async def global_exception_handler(_request: Request, exc: Exception):
|
|
694
|
+
return JSONResponse(
|
|
695
|
+
status_code=500,
|
|
696
|
+
content={"detail": "Internal inference error"},
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
# ---------------------------------------------------------------------------
|
|
701
|
+
# Endpoints
|
|
702
|
+
# ---------------------------------------------------------------------------
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
@app.post("/predict", response_model=PredictionResponse)
|
|
706
|
+
async def predict(features: FeatureVector) -> PredictionResponse:
|
|
707
|
+
global predictions_served
|
|
708
|
+
predictions_served += 1
|
|
709
|
+
|
|
710
|
+
# Use signal classifier for primary prediction
|
|
711
|
+
result = classifier.predict(features.model_dump())
|
|
712
|
+
|
|
713
|
+
# Scalping horizons (5m/15m/30m) get dampened — short timeframes are noisier
|
|
714
|
+
horizon = features.horizon if features.horizon in VALID_HORIZONS else "4h"
|
|
715
|
+
probability = result["probability"]
|
|
716
|
+
if horizon in ("5m", "15m", "30m"):
|
|
717
|
+
probability = 0.5 + (probability - 0.5) * 0.85
|
|
718
|
+
|
|
719
|
+
return PredictionResponse(
|
|
720
|
+
symbol=features.symbol,
|
|
721
|
+
direction=result["direction"],
|
|
722
|
+
probability=probability,
|
|
723
|
+
model=result["model"],
|
|
724
|
+
horizon=horizon,
|
|
725
|
+
confidence=int(probability * 100),
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
@app.post("/predict/batch", response_model=BatchResponse)
|
|
730
|
+
async def predict_batch(req: BatchRequest) -> BatchResponse:
|
|
731
|
+
global predictions_served
|
|
732
|
+
predictions_served += len(req.features)
|
|
733
|
+
|
|
734
|
+
preds = []
|
|
735
|
+
for fv in req.features:
|
|
736
|
+
result = classifier.predict(fv.model_dump())
|
|
737
|
+
horizon = fv.horizon if fv.horizon in VALID_HORIZONS else "4h"
|
|
738
|
+
probability = result["probability"]
|
|
739
|
+
if horizon in ("5m", "15m", "30m"):
|
|
740
|
+
probability = 0.5 + (probability - 0.5) * 0.85
|
|
741
|
+
preds.append(
|
|
742
|
+
PredictionResponse(
|
|
743
|
+
symbol=fv.symbol,
|
|
744
|
+
direction=result["direction"],
|
|
745
|
+
probability=probability,
|
|
746
|
+
model=result["model"],
|
|
747
|
+
horizon=horizon,
|
|
748
|
+
confidence=int(probability * 100),
|
|
749
|
+
)
|
|
750
|
+
)
|
|
751
|
+
return BatchResponse(predictions=preds)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
@app.post("/anomalies", response_model=AnomaliesResponse)
|
|
755
|
+
async def detect_anomalies(req: FlowRequest) -> AnomaliesResponse:
|
|
756
|
+
results = []
|
|
757
|
+
for flow in req.flows:
|
|
758
|
+
result = anomaly_detector.detect(flow.model_dump())
|
|
759
|
+
results.append(
|
|
760
|
+
AnomalyResponse(
|
|
761
|
+
symbol=flow.symbol,
|
|
762
|
+
score=result["score"],
|
|
763
|
+
isAnomaly=result["is_anomaly"],
|
|
764
|
+
type=result["type"],
|
|
765
|
+
details=result["details"],
|
|
766
|
+
)
|
|
767
|
+
)
|
|
768
|
+
return AnomaliesResponse(anomalies=results)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
@app.post("/predict/rug", response_model=RugResponse)
|
|
772
|
+
async def predict_rug(features: RugFeatures) -> RugResponse:
|
|
773
|
+
global predictions_served
|
|
774
|
+
predictions_served += 1
|
|
775
|
+
|
|
776
|
+
result = rug_detector.predict(features.model_dump())
|
|
777
|
+
|
|
778
|
+
return RugResponse(
|
|
779
|
+
rug_probability=result["rug_probability"],
|
|
780
|
+
risk_level=result["risk_level"],
|
|
781
|
+
risk_factors=[RugRiskFactor(**f) for f in result["risk_factors"]],
|
|
782
|
+
model=result["model"],
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
@app.post("/predict/wallet", response_model=WalletResponse)
|
|
787
|
+
async def predict_wallet(features: WalletFeatures) -> WalletResponse:
|
|
788
|
+
global predictions_served
|
|
789
|
+
predictions_served += 1
|
|
790
|
+
|
|
791
|
+
result = wallet_classifier.classify(features.model_dump())
|
|
792
|
+
|
|
793
|
+
return WalletResponse(
|
|
794
|
+
behavior_type=result["behavior_type"],
|
|
795
|
+
confidence=result["confidence"],
|
|
796
|
+
risk_score=result["risk_score"],
|
|
797
|
+
secondary_type=result["secondary_type"],
|
|
798
|
+
indicators=result["indicators"],
|
|
799
|
+
model=result["model"],
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
@app.post("/predict/sentiment", response_model=SentimentResponse)
|
|
804
|
+
async def predict_sentiment(req: SentimentRequest) -> SentimentResponse:
|
|
805
|
+
global predictions_served
|
|
806
|
+
predictions_served += 1
|
|
807
|
+
|
|
808
|
+
result = sentiment_analyzer.analyze(req.text)
|
|
809
|
+
|
|
810
|
+
return SentimentResponse(
|
|
811
|
+
sentiment=result["sentiment"],
|
|
812
|
+
confidence=result["confidence"],
|
|
813
|
+
score=result["score"],
|
|
814
|
+
key_topics=result["key_topics"],
|
|
815
|
+
model=result["model"],
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
@app.post("/predict/sentiment/batch", response_model=SentimentBatchResponse)
|
|
820
|
+
async def predict_sentiment_batch(req: SentimentBatchRequest) -> SentimentBatchResponse:
|
|
821
|
+
global predictions_served
|
|
822
|
+
# Truncate individual texts to 10k chars
|
|
823
|
+
texts = [t[:10_000] for t in req.texts]
|
|
824
|
+
predictions_served += len(texts)
|
|
825
|
+
|
|
826
|
+
results = sentiment_analyzer.analyze_batch(texts)
|
|
827
|
+
|
|
828
|
+
return SentimentBatchResponse(
|
|
829
|
+
results=[
|
|
830
|
+
SentimentResponse(
|
|
831
|
+
sentiment=r["sentiment"],
|
|
832
|
+
confidence=r["confidence"],
|
|
833
|
+
score=r["score"],
|
|
834
|
+
key_topics=r["key_topics"],
|
|
835
|
+
model=r["model"],
|
|
836
|
+
)
|
|
837
|
+
for r in results
|
|
838
|
+
]
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
# --- New v0.11.0 Endpoints ---
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
@app.post("/predict/trend", response_model=TrendScoreResponse)
|
|
846
|
+
async def predict_trend(features: TrendFeatures) -> TrendScoreResponse:
|
|
847
|
+
global predictions_served
|
|
848
|
+
predictions_served += 1
|
|
849
|
+
result = trend_scorer.predict(features.model_dump())
|
|
850
|
+
return TrendScoreResponse(**result)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
@app.post("/predict/ta", response_model=TAResponse)
|
|
854
|
+
async def predict_ta(features: TAFeatures) -> TAResponse:
|
|
855
|
+
global predictions_served
|
|
856
|
+
predictions_served += 1
|
|
857
|
+
result = ta_interpreter.predict(features.model_dump())
|
|
858
|
+
return TAResponse(
|
|
859
|
+
signals=[TASignal(**s) for s in result["signals"]],
|
|
860
|
+
weights=result["weights"],
|
|
861
|
+
composite=result["composite"],
|
|
862
|
+
model=result["model"],
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
@app.post("/predict/regime", response_model=RegimeResponse)
|
|
867
|
+
async def predict_regime(features: RegimeFeatures) -> RegimeResponse:
|
|
868
|
+
global predictions_served
|
|
869
|
+
predictions_served += 1
|
|
870
|
+
result = regime_detector.predict(features.model_dump())
|
|
871
|
+
return RegimeResponse(**result)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
@app.post("/predict/strategy", response_model=StrategyResponse)
|
|
875
|
+
async def predict_strategy(features: StrategyFeatures) -> StrategyResponse:
|
|
876
|
+
global predictions_served
|
|
877
|
+
predictions_served += 1
|
|
878
|
+
result = strategy_bandit.predict(features.model_dump())
|
|
879
|
+
return StrategyResponse(**result)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
@app.post("/predict/project-risk", response_model=ProjectRiskResponse)
|
|
883
|
+
async def predict_project_risk(features: ProjectRiskFeatures) -> ProjectRiskResponse:
|
|
884
|
+
global predictions_served
|
|
885
|
+
predictions_served += 1
|
|
886
|
+
result = project_risk_scorer.predict(features.model_dump())
|
|
887
|
+
return ProjectRiskResponse(
|
|
888
|
+
risk_probability=result["risk_probability"],
|
|
889
|
+
risk_level=result["risk_level"],
|
|
890
|
+
risk_factors=[RugRiskFactor(**f) for f in result["risk_factors"]],
|
|
891
|
+
model=result["model"],
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
@app.post("/predict/portfolio-opt", response_model=PortfolioOptResponse)
|
|
896
|
+
async def predict_portfolio_opt(features: PortfolioFeatures) -> PortfolioOptResponse:
|
|
897
|
+
global predictions_served
|
|
898
|
+
predictions_served += 1
|
|
899
|
+
result = portfolio_optimizer.optimize(features.model_dump())
|
|
900
|
+
return PortfolioOptResponse(**result)
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
@app.post("/predict/intent", response_model=IntentResponse)
|
|
904
|
+
async def predict_intent(req: IntentRequest) -> IntentResponse:
|
|
905
|
+
global predictions_served
|
|
906
|
+
predictions_served += 1
|
|
907
|
+
result = intent_classifier.classify(req.text)
|
|
908
|
+
return IntentResponse(**result)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
@app.post("/predict/bytecode-risk", response_model=BytecodeRiskResponse)
|
|
912
|
+
async def predict_bytecode_risk(features: BytecodeFeatures) -> BytecodeRiskResponse:
|
|
913
|
+
global predictions_served
|
|
914
|
+
predictions_served += 1
|
|
915
|
+
# Extend rug detector with bytecode-specific features
|
|
916
|
+
rug_features = {
|
|
917
|
+
"bytecode_size": features.bytecode_size,
|
|
918
|
+
"is_verified": features.is_verified,
|
|
919
|
+
"holder_concentration": 0,
|
|
920
|
+
"has_proxy": features.has_proxy,
|
|
921
|
+
"has_mint": features.has_mint,
|
|
922
|
+
"has_pause": features.has_pause,
|
|
923
|
+
"has_blacklist": features.has_blacklist,
|
|
924
|
+
"liquidity_locked": 0,
|
|
925
|
+
"buy_tax": 0,
|
|
926
|
+
"sell_tax": 0,
|
|
927
|
+
"contract_age_days": 0,
|
|
928
|
+
"total_transfers": 0,
|
|
929
|
+
"owner_balance_pct": 0,
|
|
930
|
+
"is_open_source": features.is_verified,
|
|
931
|
+
"top10_holder_pct": 0,
|
|
932
|
+
}
|
|
933
|
+
result = rug_detector.predict(rug_features)
|
|
934
|
+
|
|
935
|
+
# Adjust based on bytecode-specific features
|
|
936
|
+
prob = result["rug_probability"]
|
|
937
|
+
if features.has_selfdestruct:
|
|
938
|
+
prob = min(1.0, prob + 0.2)
|
|
939
|
+
if features.has_delegatecall:
|
|
940
|
+
prob = min(1.0, prob + 0.1)
|
|
941
|
+
if features.opcode_entropy < 3.0 and features.bytecode_size > 100:
|
|
942
|
+
prob = min(1.0, prob + 0.05)
|
|
943
|
+
|
|
944
|
+
risk_level = (
|
|
945
|
+
"critical" if prob >= 0.75
|
|
946
|
+
else "high" if prob >= 0.5
|
|
947
|
+
else "medium" if prob >= 0.25
|
|
948
|
+
else "low"
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
factors = result["risk_factors"]
|
|
952
|
+
if features.has_selfdestruct:
|
|
953
|
+
factors.append({"factor": "has_selfdestruct", "importance": 0.25, "value": 1})
|
|
954
|
+
if features.has_delegatecall:
|
|
955
|
+
factors.append({"factor": "has_delegatecall", "importance": 0.15, "value": 1})
|
|
956
|
+
|
|
957
|
+
return BytecodeRiskResponse(
|
|
958
|
+
rug_probability=round(prob, 4),
|
|
959
|
+
risk_level=risk_level,
|
|
960
|
+
risk_factors=[RugRiskFactor(**f) for f in factors[:5]],
|
|
961
|
+
model=result["model"] + "+bytecode",
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
@app.post("/predict/portfolio-forward", response_model=PortfolioPredResponse)
|
|
966
|
+
async def predict_portfolio_forward(features: PortfolioPredFeatures) -> PortfolioPredResponse:
|
|
967
|
+
global predictions_served
|
|
968
|
+
predictions_served += 1
|
|
969
|
+
result = portfolio_optimizer.forecast(features.model_dump())
|
|
970
|
+
return PortfolioPredResponse(**result)
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
@app.post("/predict/target", response_model=TargetResponse)
|
|
974
|
+
async def predict_target(features: TargetFeatures) -> TargetResponse:
|
|
975
|
+
global predictions_served
|
|
976
|
+
predictions_served += 1
|
|
977
|
+
|
|
978
|
+
raw = target_quantile_model.predict(features.model_dump())
|
|
979
|
+
calibrated = conformal_interval_model.apply(
|
|
980
|
+
raw["low_change_pct"],
|
|
981
|
+
raw["base_change_pct"],
|
|
982
|
+
raw["high_change_pct"],
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
current_price = features.current_price if features.current_price > 0 else None
|
|
986
|
+
low_price = (
|
|
987
|
+
round(current_price * (1 + calibrated["low_change_pct"] / 100), 8)
|
|
988
|
+
if current_price is not None
|
|
989
|
+
else None
|
|
990
|
+
)
|
|
991
|
+
base_price = (
|
|
992
|
+
round(current_price * (1 + calibrated["base_change_pct"] / 100), 8)
|
|
993
|
+
if current_price is not None
|
|
994
|
+
else None
|
|
995
|
+
)
|
|
996
|
+
high_price = (
|
|
997
|
+
round(current_price * (1 + calibrated["high_change_pct"] / 100), 8)
|
|
998
|
+
if current_price is not None
|
|
999
|
+
else None
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
return TargetResponse(
|
|
1003
|
+
low_change_pct=calibrated["low_change_pct"],
|
|
1004
|
+
base_change_pct=calibrated["base_change_pct"],
|
|
1005
|
+
high_change_pct=calibrated["high_change_pct"],
|
|
1006
|
+
low_price=low_price,
|
|
1007
|
+
base_price=base_price,
|
|
1008
|
+
high_price=high_price,
|
|
1009
|
+
direction_bias=raw["direction_bias"],
|
|
1010
|
+
interval_width_pct=round(
|
|
1011
|
+
calibrated["high_change_pct"] - calibrated["low_change_pct"], 4
|
|
1012
|
+
),
|
|
1013
|
+
model=raw["model"],
|
|
1014
|
+
conformal_model=calibrated["model"],
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
@app.post("/predict/meta-confidence", response_model=MetaConfidenceResponse)
|
|
1019
|
+
async def predict_meta_confidence(
|
|
1020
|
+
features: MetaConfidenceFeatures,
|
|
1021
|
+
) -> MetaConfidenceResponse:
|
|
1022
|
+
global predictions_served
|
|
1023
|
+
predictions_served += 1
|
|
1024
|
+
result = stacking_meta_model.predict(features.model_dump())
|
|
1025
|
+
return MetaConfidenceResponse(**result)
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
@app.post("/predict/microstructure", response_model=MicrostructureResponse)
|
|
1029
|
+
async def predict_microstructure(
|
|
1030
|
+
features: MicrostructureFeatures,
|
|
1031
|
+
) -> MicrostructureResponse:
|
|
1032
|
+
global predictions_served
|
|
1033
|
+
predictions_served += 1
|
|
1034
|
+
result = microstructure_specialist.predict(features.model_dump())
|
|
1035
|
+
return MicrostructureResponse(**result)
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
@app.post("/predict/catalyst", response_model=CatalystResponse)
|
|
1039
|
+
async def predict_catalyst(features: CatalystFeatures) -> CatalystResponse:
|
|
1040
|
+
global predictions_served
|
|
1041
|
+
predictions_served += 1
|
|
1042
|
+
result = catalyst_event_model.predict(features.model_dump())
|
|
1043
|
+
return CatalystResponse(**result)
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
@app.post("/drift/check")
|
|
1047
|
+
async def check_drift(features: FeatureVector):
|
|
1048
|
+
return drift_monitor.check(features.model_dump())
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
# --- v0.12.0 Endpoints ---
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
@app.post("/detect-pump")
|
|
1055
|
+
async def detect_pump(request: Request):
|
|
1056
|
+
"""Detect pump/dump activity using CUSUM."""
|
|
1057
|
+
global predictions_served
|
|
1058
|
+
predictions_served += 1
|
|
1059
|
+
|
|
1060
|
+
data = await request.json()
|
|
1061
|
+
token = data.get("token", "unknown")
|
|
1062
|
+
prices = data.get("prices", [])
|
|
1063
|
+
volumes = data.get("volumes", [])
|
|
1064
|
+
|
|
1065
|
+
# Single feed mode
|
|
1066
|
+
if not prices and "price" in data:
|
|
1067
|
+
result = pump_detector.feed(
|
|
1068
|
+
token, float(data["price"]), float(data.get("volume", 0))
|
|
1069
|
+
)
|
|
1070
|
+
if result is None:
|
|
1071
|
+
return {
|
|
1072
|
+
"detected": False,
|
|
1073
|
+
"type": "none",
|
|
1074
|
+
"severity": "low",
|
|
1075
|
+
"cusum_value": 0.0,
|
|
1076
|
+
"threshold": pump_detector.threshold * 0.01,
|
|
1077
|
+
"price_change_pct": 0.0,
|
|
1078
|
+
"volume_spike": 0.0,
|
|
1079
|
+
"confidence": 0.0,
|
|
1080
|
+
}
|
|
1081
|
+
return {
|
|
1082
|
+
"detected": result.detected,
|
|
1083
|
+
"type": result.type,
|
|
1084
|
+
"severity": result.severity,
|
|
1085
|
+
"cusum_value": result.cusum_value,
|
|
1086
|
+
"threshold": result.threshold,
|
|
1087
|
+
"price_change_pct": result.price_change_pct,
|
|
1088
|
+
"volume_spike": result.volume_spike,
|
|
1089
|
+
"confidence": result.confidence,
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
# Batch mode
|
|
1093
|
+
result = pump_detector.predict(
|
|
1094
|
+
{"token": token, "prices": prices, "volumes": volumes}
|
|
1095
|
+
)
|
|
1096
|
+
return {
|
|
1097
|
+
"detected": result.detected,
|
|
1098
|
+
"type": result.type,
|
|
1099
|
+
"severity": result.severity,
|
|
1100
|
+
"cusum_value": result.cusum_value,
|
|
1101
|
+
"threshold": result.threshold,
|
|
1102
|
+
"price_change_pct": result.price_change_pct,
|
|
1103
|
+
"volume_spike": result.volume_spike,
|
|
1104
|
+
"confidence": result.confidence,
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
@app.post("/detect-narrative")
|
|
1109
|
+
async def detect_narrative(request: Request):
|
|
1110
|
+
"""Detect trending narratives from text corpus."""
|
|
1111
|
+
global predictions_served
|
|
1112
|
+
predictions_served += 1
|
|
1113
|
+
|
|
1114
|
+
data = await request.json()
|
|
1115
|
+
texts = data.get("texts", [])
|
|
1116
|
+
top_k = data.get("top_k", 5)
|
|
1117
|
+
|
|
1118
|
+
results = narrative_detector.get_trending_narratives(texts, top_k=top_k)
|
|
1119
|
+
return {
|
|
1120
|
+
"narratives": [
|
|
1121
|
+
{
|
|
1122
|
+
"narrative": r.narrative,
|
|
1123
|
+
"confidence": r.confidence,
|
|
1124
|
+
"related_tokens": r.related_tokens,
|
|
1125
|
+
"keywords": r.keywords,
|
|
1126
|
+
"trend_direction": r.trend_direction,
|
|
1127
|
+
"mention_count": r.mention_count,
|
|
1128
|
+
}
|
|
1129
|
+
for r in results
|
|
1130
|
+
]
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
@app.post("/detect-divergence")
|
|
1135
|
+
async def detect_divergence(request: Request):
|
|
1136
|
+
"""Detect prediction market vs price divergence."""
|
|
1137
|
+
global predictions_served
|
|
1138
|
+
predictions_served += 1
|
|
1139
|
+
|
|
1140
|
+
data = await request.json()
|
|
1141
|
+
market_odds = data.get("market_odds", [])
|
|
1142
|
+
prices = data.get("prices", [])
|
|
1143
|
+
|
|
1144
|
+
result = divergence_detector.detect(market_odds, prices)
|
|
1145
|
+
return {
|
|
1146
|
+
"divergence_score": result.divergence_score,
|
|
1147
|
+
"type": result.type,
|
|
1148
|
+
"prediction_market_signal": result.prediction_market_signal,
|
|
1149
|
+
"price_action_signal": result.price_action_signal,
|
|
1150
|
+
"confidence": result.confidence,
|
|
1151
|
+
"interpretation": result.interpretation,
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
# --- v0.12.5 Blockchain Cycle Endpoint ---
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
class BlockchainCycleFeatures(BaseModel):
|
|
1159
|
+
halving_cycle_progress: float = Field(0.0, ge=0, le=100)
|
|
1160
|
+
days_since_halving: float = Field(0.0, ge=0, le=2000)
|
|
1161
|
+
days_to_next_halving: float = Field(0.0, ge=0, le=2000)
|
|
1162
|
+
block_reward: float = Field(3.125, ge=0, le=50)
|
|
1163
|
+
hashrate_change_30d: float = Field(0.0, ge=-100, le=1000)
|
|
1164
|
+
difficulty_change_14d: float = Field(0.0, ge=-100, le=1000)
|
|
1165
|
+
nvt_ratio: float = Field(55.0, ge=0, le=1000)
|
|
1166
|
+
mvrv_z_score: float = Field(1.5, ge=-10, le=20)
|
|
1167
|
+
inflation_rate: float = Field(0.83, ge=0, le=100)
|
|
1168
|
+
fee_revenue_share: float = Field(5.0, ge=0, le=100)
|
|
1169
|
+
mempool_size_mb: float = Field(0.0, ge=0, le=10000)
|
|
1170
|
+
avg_fee_rate: float = Field(0.0, ge=0, le=10000)
|
|
1171
|
+
hash_ribbon_signal: float = Field(0.0, ge=-1, le=1)
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
class BlockchainCycleRiskFactor(BaseModel):
|
|
1175
|
+
factor: str
|
|
1176
|
+
importance: float
|
|
1177
|
+
value: float
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
class BlockchainCycleResponse(BaseModel):
|
|
1181
|
+
cycle_phase: str
|
|
1182
|
+
phase_confidence: float
|
|
1183
|
+
fair_value_estimate: float
|
|
1184
|
+
deviation_from_fair: float
|
|
1185
|
+
risk_factors: list[BlockchainCycleRiskFactor]
|
|
1186
|
+
model: str
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
@app.post("/predict/blockchain-cycle", response_model=BlockchainCycleResponse)
|
|
1190
|
+
async def predict_blockchain_cycle(features: BlockchainCycleFeatures) -> BlockchainCycleResponse:
|
|
1191
|
+
global predictions_served
|
|
1192
|
+
predictions_served += 1
|
|
1193
|
+
|
|
1194
|
+
result = blockchain_cycle_analyzer.predict(features.model_dump())
|
|
1195
|
+
|
|
1196
|
+
return BlockchainCycleResponse(
|
|
1197
|
+
cycle_phase=result["cycle_phase"],
|
|
1198
|
+
phase_confidence=result["phase_confidence"],
|
|
1199
|
+
fair_value_estimate=result["fair_value_estimate"],
|
|
1200
|
+
deviation_from_fair=result["deviation_from_fair"],
|
|
1201
|
+
risk_factors=[BlockchainCycleRiskFactor(**f) for f in result["risk_factors"]],
|
|
1202
|
+
model=result["model"],
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
# --- Isotonic Calibration Endpoints ---
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
class CalibrationTrainRequest(BaseModel):
|
|
1210
|
+
predicted: list[float] = Field(max_length=10000)
|
|
1211
|
+
actual: list[float] = Field(max_length=10000)
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
class CalibrationPredictRequest(BaseModel):
|
|
1215
|
+
probabilities: list[float] = Field(max_length=1000)
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
# Global calibrator — trained lazily from prediction outcomes
|
|
1219
|
+
_calibrator = None
|
|
1220
|
+
_calibrator_lock = False
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def _load_isotonic_artifact() -> None:
|
|
1224
|
+
global _calibrator
|
|
1225
|
+
try:
|
|
1226
|
+
import joblib
|
|
1227
|
+
|
|
1228
|
+
path = MODEL_DIR / "meta_isotonic.joblib"
|
|
1229
|
+
if path.exists():
|
|
1230
|
+
data = joblib.load(path)
|
|
1231
|
+
_calibrator = data.get("model")
|
|
1232
|
+
except Exception:
|
|
1233
|
+
_calibrator = None
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
@app.post("/calibrate/train")
|
|
1237
|
+
async def calibrate_train(req: CalibrationTrainRequest):
|
|
1238
|
+
"""Train isotonic regression calibrator from prediction outcomes."""
|
|
1239
|
+
global _calibrator, _calibrator_lock
|
|
1240
|
+
if _calibrator_lock:
|
|
1241
|
+
return {"status": "busy", "message": "Training already in progress"}
|
|
1242
|
+
if len(req.predicted) != len(req.actual):
|
|
1243
|
+
return {"status": "error", "message": "predicted and actual must have the same length"}
|
|
1244
|
+
if len(req.predicted) < 10:
|
|
1245
|
+
return {"status": "error", "message": "Need at least 10 samples"}
|
|
1246
|
+
|
|
1247
|
+
_calibrator_lock = True
|
|
1248
|
+
try:
|
|
1249
|
+
import joblib
|
|
1250
|
+
from sklearn.isotonic import IsotonicRegression
|
|
1251
|
+
ir = IsotonicRegression(y_min=0.05, y_max=0.95, out_of_bounds="clip")
|
|
1252
|
+
ir.fit(req.predicted, req.actual)
|
|
1253
|
+
_calibrator = ir
|
|
1254
|
+
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
|
1255
|
+
joblib.dump(
|
|
1256
|
+
{
|
|
1257
|
+
"model": ir,
|
|
1258
|
+
"trained_at": str(int(time.time())),
|
|
1259
|
+
"accuracy": None,
|
|
1260
|
+
"samples": len(req.predicted),
|
|
1261
|
+
},
|
|
1262
|
+
MODEL_DIR / "meta_isotonic.joblib",
|
|
1263
|
+
)
|
|
1264
|
+
return {"status": "ok", "samples": len(req.predicted)}
|
|
1265
|
+
except Exception as e:
|
|
1266
|
+
return {"status": "error", "message": str(e)}
|
|
1267
|
+
finally:
|
|
1268
|
+
_calibrator_lock = False
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
@app.post("/calibrate/predict")
|
|
1272
|
+
async def calibrate_predict(req: CalibrationPredictRequest):
|
|
1273
|
+
"""Return calibrated probabilities using trained isotonic regression."""
|
|
1274
|
+
if _calibrator is None:
|
|
1275
|
+
return {"calibrated": req.probabilities, "model": "identity"}
|
|
1276
|
+
|
|
1277
|
+
import numpy as np
|
|
1278
|
+
arr = np.array(req.probabilities, dtype=np.float64)
|
|
1279
|
+
calibrated = _calibrator.predict(arr).tolist()
|
|
1280
|
+
return {"calibrated": calibrated, "model": "isotonic"}
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
# --- Training Pipeline Endpoints ---
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
TRAINERS = {
|
|
1287
|
+
"rug_detector": RugTrainer,
|
|
1288
|
+
"trend_scorer": TrendTrainer,
|
|
1289
|
+
"regime_detector": RegimeTrainer,
|
|
1290
|
+
"sentiment_nlp": SentimentTrainer,
|
|
1291
|
+
"pump_detector": PumpTrainer,
|
|
1292
|
+
"narrative_detector": NarrativeTrainer,
|
|
1293
|
+
"target_xgb_quantile": TargetQuantileTrainer,
|
|
1294
|
+
"interval_conformal_calibrator": ConformalIntervalTrainer,
|
|
1295
|
+
"meta_stacking": StackingMetaTrainer,
|
|
1296
|
+
"meta_isotonic": IsotonicTrainer,
|
|
1297
|
+
"meta_drift_detector": DriftMonitorTrainer,
|
|
1298
|
+
"microstructure_specialist": MicrostructureTrainer,
|
|
1299
|
+
"catalyst_event": CatalystTrainer,
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
MODEL_RELOADERS = {
|
|
1303
|
+
"rug_detector": rug_detector,
|
|
1304
|
+
"trend_scorer": trend_scorer,
|
|
1305
|
+
"regime_detector": regime_detector,
|
|
1306
|
+
"sentiment_nlp": sentiment_analyzer,
|
|
1307
|
+
"pump_detector": pump_detector,
|
|
1308
|
+
"narrative_detector": narrative_detector,
|
|
1309
|
+
"target_xgb_quantile": target_quantile_model,
|
|
1310
|
+
"interval_conformal_calibrator": conformal_interval_model,
|
|
1311
|
+
"meta_stacking": stacking_meta_model,
|
|
1312
|
+
"meta_drift_detector": drift_monitor,
|
|
1313
|
+
"microstructure_specialist": microstructure_specialist,
|
|
1314
|
+
"catalyst_event": catalyst_event_model,
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def _reload_model(model_name: str) -> None:
|
|
1319
|
+
if model_name in {"direction_ensemble", "ensemble"}:
|
|
1320
|
+
classifier.load()
|
|
1321
|
+
return
|
|
1322
|
+
if model_name == "meta_isotonic":
|
|
1323
|
+
_load_isotonic_artifact()
|
|
1324
|
+
return
|
|
1325
|
+
|
|
1326
|
+
loader = MODEL_RELOADERS.get(model_name)
|
|
1327
|
+
if loader is not None and hasattr(loader, "load"):
|
|
1328
|
+
loader.load()
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def _train_direction_ensemble(lookback_days: int) -> dict:
|
|
1332
|
+
start = time.time()
|
|
1333
|
+
outcomes = load_direction_outcomes(lookback_days)
|
|
1334
|
+
|
|
1335
|
+
if len(outcomes) < 30:
|
|
1336
|
+
return {
|
|
1337
|
+
"model": "direction_ensemble",
|
|
1338
|
+
"status": "skipped",
|
|
1339
|
+
"metrics": {"samples": len(outcomes)},
|
|
1340
|
+
"duration_seconds": round(time.time() - start, 2),
|
|
1341
|
+
"artifact_path": "",
|
|
1342
|
+
"error": f"Need at least 30 labeled outcomes, got {len(outcomes)}",
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
results: list[dict] = []
|
|
1346
|
+
for profile in ("scalp", "standard", "position"):
|
|
1347
|
+
trainer = DirectionTrainer(profile)
|
|
1348
|
+
results.append(trainer.run(outcomes))
|
|
1349
|
+
|
|
1350
|
+
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
|
1351
|
+
manifest_path = MODEL_DIR / "direction_ensemble_manifest.json"
|
|
1352
|
+
manifest = {
|
|
1353
|
+
"trained_at": int(time.time()),
|
|
1354
|
+
"lookback_days": lookback_days,
|
|
1355
|
+
"profiles": results,
|
|
1356
|
+
}
|
|
1357
|
+
with open(manifest_path, "w", encoding="utf-8") as handle:
|
|
1358
|
+
json.dump(manifest, handle, indent=2)
|
|
1359
|
+
|
|
1360
|
+
success_count = sum(1 for item in results if item.get("status") == "success")
|
|
1361
|
+
status = "success" if success_count > 0 else "failed"
|
|
1362
|
+
|
|
1363
|
+
metrics = {
|
|
1364
|
+
"profiles_trained": success_count,
|
|
1365
|
+
"profiles_total": len(results),
|
|
1366
|
+
"samples": len(outcomes),
|
|
1367
|
+
}
|
|
1368
|
+
for item in results:
|
|
1369
|
+
name = str(item.get("model", "unknown"))
|
|
1370
|
+
acc = item.get("metrics", {}).get("accuracy") if isinstance(item.get("metrics"), dict) else None
|
|
1371
|
+
if acc is not None:
|
|
1372
|
+
metrics[f"{name}_accuracy"] = acc
|
|
1373
|
+
|
|
1374
|
+
if success_count > 0:
|
|
1375
|
+
_reload_model("direction_ensemble")
|
|
1376
|
+
|
|
1377
|
+
return {
|
|
1378
|
+
"model": "direction_ensemble",
|
|
1379
|
+
"status": status,
|
|
1380
|
+
"metrics": metrics,
|
|
1381
|
+
"duration_seconds": round(time.time() - start, 2),
|
|
1382
|
+
"artifact_path": str(manifest_path),
|
|
1383
|
+
"error": None if success_count > 0 else "No direction profile completed successfully",
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
def _run_training_job(model_name: str, lookback_days: int) -> dict:
|
|
1388
|
+
if model_name in {"direction_ensemble", "ensemble"}:
|
|
1389
|
+
return _train_direction_ensemble(lookback_days)
|
|
1390
|
+
|
|
1391
|
+
trainer_cls = TRAINERS.get(model_name)
|
|
1392
|
+
if not trainer_cls:
|
|
1393
|
+
return {
|
|
1394
|
+
"model": model_name,
|
|
1395
|
+
"status": "failed",
|
|
1396
|
+
"metrics": None,
|
|
1397
|
+
"duration_seconds": 0,
|
|
1398
|
+
"artifact_path": "",
|
|
1399
|
+
"error": f"Unknown model: {model_name}. Available: {', '.join(TRAINERS.keys())}, direction_ensemble, ensemble",
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
trainer = trainer_cls()
|
|
1403
|
+
try:
|
|
1404
|
+
result = trainer.run(lookback_days)
|
|
1405
|
+
except TypeError:
|
|
1406
|
+
result = trainer.run()
|
|
1407
|
+
if result.get("status") == "success":
|
|
1408
|
+
_reload_model(model_name)
|
|
1409
|
+
return result
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
@app.post("/train", response_model=TrainResponse)
|
|
1413
|
+
async def train_model(req: TrainRequest) -> TrainResponse:
|
|
1414
|
+
result = _run_training_job(req.model_name, req.lookback_days)
|
|
1415
|
+
return TrainResponse(
|
|
1416
|
+
model=result["model"],
|
|
1417
|
+
status=result["status"],
|
|
1418
|
+
metrics=result.get("metrics"),
|
|
1419
|
+
duration_seconds=result["duration_seconds"],
|
|
1420
|
+
artifact_path=result["artifact_path"],
|
|
1421
|
+
error=result.get("error"),
|
|
1422
|
+
)
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
@app.post("/evaluate", response_model=EvaluateResponse)
|
|
1426
|
+
async def evaluate_model(req: EvaluateRequest) -> EvaluateResponse:
|
|
1427
|
+
if req.model_name in {"direction_ensemble", "ensemble"}:
|
|
1428
|
+
result = _train_direction_ensemble(req.lookback_days)
|
|
1429
|
+
return EvaluateResponse(
|
|
1430
|
+
model=req.model_name,
|
|
1431
|
+
status=result["status"],
|
|
1432
|
+
metrics=result.get("metrics"),
|
|
1433
|
+
error=result.get("error"),
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
trainer_cls = TRAINERS.get(req.model_name)
|
|
1437
|
+
if not trainer_cls:
|
|
1438
|
+
return EvaluateResponse(
|
|
1439
|
+
model=req.model_name,
|
|
1440
|
+
status="failed",
|
|
1441
|
+
error=f"Unknown model: {req.model_name}. Available: {', '.join(TRAINERS.keys())}",
|
|
1442
|
+
)
|
|
1443
|
+
trainer = trainer_cls()
|
|
1444
|
+
try:
|
|
1445
|
+
if all(
|
|
1446
|
+
hasattr(trainer, method)
|
|
1447
|
+
for method in ("load_data", "preprocess", "train", "evaluate")
|
|
1448
|
+
):
|
|
1449
|
+
data = trainer.load_data()
|
|
1450
|
+
splits = trainer.preprocess(data)
|
|
1451
|
+
X_train, X_val, X_test, y_train, y_val, y_test = splits
|
|
1452
|
+
model = trainer.train(X_train, y_train, X_val, y_val)
|
|
1453
|
+
metrics = trainer.evaluate(model, X_test, y_test)
|
|
1454
|
+
status = "success"
|
|
1455
|
+
else:
|
|
1456
|
+
result = _run_training_job(req.model_name, req.lookback_days)
|
|
1457
|
+
metrics = result.get("metrics")
|
|
1458
|
+
status = result.get("status", "failed")
|
|
1459
|
+
return EvaluateResponse(
|
|
1460
|
+
model=req.model_name,
|
|
1461
|
+
status=status,
|
|
1462
|
+
metrics=metrics,
|
|
1463
|
+
)
|
|
1464
|
+
except Exception as e:
|
|
1465
|
+
return EvaluateResponse(
|
|
1466
|
+
model=req.model_name,
|
|
1467
|
+
status="failed",
|
|
1468
|
+
error=str(e),
|
|
1469
|
+
)
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
@app.post("/retrain", response_model=RetrainResponse)
|
|
1473
|
+
async def retrain_models(req: RetrainRequest) -> RetrainResponse:
|
|
1474
|
+
global training_status, training_job_id
|
|
1475
|
+
|
|
1476
|
+
start = time.time()
|
|
1477
|
+
job_id = str(uuid4())
|
|
1478
|
+
training_status = "training"
|
|
1479
|
+
training_job_id = job_id
|
|
1480
|
+
|
|
1481
|
+
normalized_models = [
|
|
1482
|
+
"direction_ensemble" if model == "ensemble" else model for model in req.models
|
|
1483
|
+
]
|
|
1484
|
+
|
|
1485
|
+
try:
|
|
1486
|
+
results = [_run_training_job(model, req.lookback_days) for model in normalized_models]
|
|
1487
|
+
status = "success" if any(r.get("status") == "success" for r in results) else "failed"
|
|
1488
|
+
return RetrainResponse(
|
|
1489
|
+
job_id=job_id,
|
|
1490
|
+
status=status,
|
|
1491
|
+
models=normalized_models,
|
|
1492
|
+
results=results,
|
|
1493
|
+
duration_seconds=round(time.time() - start, 2),
|
|
1494
|
+
)
|
|
1495
|
+
finally:
|
|
1496
|
+
training_status = "idle"
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
@app.get("/catalog/models")
|
|
1500
|
+
async def catalog_models():
|
|
1501
|
+
return get_model_catalog()
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
@app.get("/catalog/roadmap")
|
|
1505
|
+
async def catalog_roadmap():
|
|
1506
|
+
return get_training_roadmap()
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
@app.get("/health")
|
|
1510
|
+
async def health():
|
|
1511
|
+
return {
|
|
1512
|
+
"models": [
|
|
1513
|
+
{
|
|
1514
|
+
"name": "lstm-predictor",
|
|
1515
|
+
"version": lstm.version,
|
|
1516
|
+
"loaded": lstm.is_loaded,
|
|
1517
|
+
"lastTrained": lstm.last_trained,
|
|
1518
|
+
"accuracy": lstm.accuracy,
|
|
1519
|
+
},
|
|
1520
|
+
{
|
|
1521
|
+
"name": "signal-classifier",
|
|
1522
|
+
"version": classifier.version,
|
|
1523
|
+
"loaded": classifier.is_loaded,
|
|
1524
|
+
"lastTrained": classifier.last_trained,
|
|
1525
|
+
"accuracy": classifier.accuracy,
|
|
1526
|
+
},
|
|
1527
|
+
{
|
|
1528
|
+
"name": "anomaly-detector",
|
|
1529
|
+
"version": anomaly_detector.version,
|
|
1530
|
+
"loaded": anomaly_detector.is_loaded,
|
|
1531
|
+
"lastTrained": anomaly_detector.last_trained,
|
|
1532
|
+
"accuracy": None,
|
|
1533
|
+
},
|
|
1534
|
+
{
|
|
1535
|
+
"name": "rug-detector",
|
|
1536
|
+
"version": rug_detector.version,
|
|
1537
|
+
"loaded": rug_detector.is_loaded,
|
|
1538
|
+
"lastTrained": rug_detector.last_trained,
|
|
1539
|
+
"accuracy": rug_detector.accuracy,
|
|
1540
|
+
},
|
|
1541
|
+
{
|
|
1542
|
+
"name": "wallet-classifier",
|
|
1543
|
+
"version": wallet_classifier.version,
|
|
1544
|
+
"loaded": wallet_classifier.is_loaded,
|
|
1545
|
+
"lastTrained": wallet_classifier.last_trained,
|
|
1546
|
+
"accuracy": wallet_classifier.accuracy,
|
|
1547
|
+
},
|
|
1548
|
+
{
|
|
1549
|
+
"name": "sentiment-analyzer",
|
|
1550
|
+
"version": sentiment_analyzer.version,
|
|
1551
|
+
"loaded": sentiment_analyzer.is_loaded,
|
|
1552
|
+
"lastTrained": sentiment_analyzer.last_trained,
|
|
1553
|
+
"accuracy": sentiment_analyzer.accuracy,
|
|
1554
|
+
},
|
|
1555
|
+
{
|
|
1556
|
+
"name": "trend-scorer",
|
|
1557
|
+
"version": trend_scorer.version,
|
|
1558
|
+
"loaded": trend_scorer.is_loaded,
|
|
1559
|
+
"lastTrained": trend_scorer.last_trained,
|
|
1560
|
+
"accuracy": trend_scorer.accuracy,
|
|
1561
|
+
},
|
|
1562
|
+
{
|
|
1563
|
+
"name": "ta-interpreter",
|
|
1564
|
+
"version": ta_interpreter.version,
|
|
1565
|
+
"loaded": ta_interpreter.is_loaded,
|
|
1566
|
+
"lastTrained": ta_interpreter.last_trained,
|
|
1567
|
+
"accuracy": ta_interpreter.accuracy,
|
|
1568
|
+
},
|
|
1569
|
+
{
|
|
1570
|
+
"name": "regime-detector",
|
|
1571
|
+
"version": regime_detector.version,
|
|
1572
|
+
"loaded": regime_detector.is_loaded,
|
|
1573
|
+
"lastTrained": regime_detector.last_trained,
|
|
1574
|
+
"accuracy": regime_detector.accuracy,
|
|
1575
|
+
},
|
|
1576
|
+
{
|
|
1577
|
+
"name": "strategy-bandit",
|
|
1578
|
+
"version": strategy_bandit.version,
|
|
1579
|
+
"loaded": strategy_bandit.is_loaded,
|
|
1580
|
+
"lastTrained": strategy_bandit.last_trained,
|
|
1581
|
+
"accuracy": strategy_bandit.accuracy,
|
|
1582
|
+
},
|
|
1583
|
+
{
|
|
1584
|
+
"name": "project-risk-scorer",
|
|
1585
|
+
"version": project_risk_scorer.version,
|
|
1586
|
+
"loaded": project_risk_scorer.is_loaded,
|
|
1587
|
+
"lastTrained": project_risk_scorer.last_trained,
|
|
1588
|
+
"accuracy": project_risk_scorer.accuracy,
|
|
1589
|
+
},
|
|
1590
|
+
{
|
|
1591
|
+
"name": "portfolio-optimizer",
|
|
1592
|
+
"version": portfolio_optimizer.version,
|
|
1593
|
+
"loaded": portfolio_optimizer.is_loaded,
|
|
1594
|
+
"lastTrained": portfolio_optimizer.last_trained,
|
|
1595
|
+
"accuracy": portfolio_optimizer.accuracy,
|
|
1596
|
+
},
|
|
1597
|
+
{
|
|
1598
|
+
"name": "intent-classifier",
|
|
1599
|
+
"version": intent_classifier.version,
|
|
1600
|
+
"loaded": intent_classifier.is_loaded,
|
|
1601
|
+
"lastTrained": intent_classifier.last_trained,
|
|
1602
|
+
"accuracy": intent_classifier.accuracy,
|
|
1603
|
+
},
|
|
1604
|
+
{
|
|
1605
|
+
"name": "pump-detector",
|
|
1606
|
+
"version": pump_detector.version,
|
|
1607
|
+
"loaded": pump_detector.is_loaded,
|
|
1608
|
+
"lastTrained": pump_detector.last_trained,
|
|
1609
|
+
"accuracy": pump_detector.accuracy,
|
|
1610
|
+
},
|
|
1611
|
+
{
|
|
1612
|
+
"name": "narrative-detector",
|
|
1613
|
+
"version": narrative_detector.version,
|
|
1614
|
+
"loaded": narrative_detector.is_loaded,
|
|
1615
|
+
"lastTrained": narrative_detector.last_trained,
|
|
1616
|
+
"accuracy": narrative_detector.accuracy,
|
|
1617
|
+
},
|
|
1618
|
+
{
|
|
1619
|
+
"name": "divergence-detector",
|
|
1620
|
+
"version": divergence_detector.version,
|
|
1621
|
+
"loaded": divergence_detector.is_loaded,
|
|
1622
|
+
"lastTrained": divergence_detector.last_trained,
|
|
1623
|
+
"accuracy": divergence_detector.accuracy,
|
|
1624
|
+
},
|
|
1625
|
+
{
|
|
1626
|
+
"name": "blockchain-cycle-analyzer",
|
|
1627
|
+
"version": blockchain_cycle_analyzer.version,
|
|
1628
|
+
"loaded": blockchain_cycle_analyzer.is_loaded,
|
|
1629
|
+
"lastTrained": blockchain_cycle_analyzer.last_trained,
|
|
1630
|
+
"accuracy": blockchain_cycle_analyzer.accuracy,
|
|
1631
|
+
},
|
|
1632
|
+
{
|
|
1633
|
+
"name": "target-quantile",
|
|
1634
|
+
"version": target_quantile_model.version,
|
|
1635
|
+
"loaded": target_quantile_model.is_loaded,
|
|
1636
|
+
"lastTrained": target_quantile_model.last_trained,
|
|
1637
|
+
"accuracy": target_quantile_model.accuracy,
|
|
1638
|
+
},
|
|
1639
|
+
{
|
|
1640
|
+
"name": "conformal-interval",
|
|
1641
|
+
"version": conformal_interval_model.version,
|
|
1642
|
+
"loaded": conformal_interval_model.is_loaded,
|
|
1643
|
+
"lastTrained": conformal_interval_model.last_trained,
|
|
1644
|
+
"accuracy": conformal_interval_model.coverage,
|
|
1645
|
+
},
|
|
1646
|
+
{
|
|
1647
|
+
"name": "meta-stacking",
|
|
1648
|
+
"version": stacking_meta_model.version,
|
|
1649
|
+
"loaded": stacking_meta_model.is_loaded,
|
|
1650
|
+
"lastTrained": stacking_meta_model.last_trained,
|
|
1651
|
+
"accuracy": stacking_meta_model.accuracy,
|
|
1652
|
+
},
|
|
1653
|
+
{
|
|
1654
|
+
"name": "meta-isotonic",
|
|
1655
|
+
"version": "0.1.0",
|
|
1656
|
+
"loaded": _calibrator is not None,
|
|
1657
|
+
"lastTrained": None,
|
|
1658
|
+
"accuracy": None,
|
|
1659
|
+
},
|
|
1660
|
+
{
|
|
1661
|
+
"name": "meta-drift-detector",
|
|
1662
|
+
"version": drift_monitor.version,
|
|
1663
|
+
"loaded": drift_monitor.is_loaded,
|
|
1664
|
+
"lastTrained": drift_monitor.last_trained,
|
|
1665
|
+
"accuracy": None,
|
|
1666
|
+
},
|
|
1667
|
+
{
|
|
1668
|
+
"name": "microstructure-specialist",
|
|
1669
|
+
"version": microstructure_specialist.version,
|
|
1670
|
+
"loaded": microstructure_specialist.is_loaded,
|
|
1671
|
+
"lastTrained": microstructure_specialist.last_trained,
|
|
1672
|
+
"accuracy": microstructure_specialist.accuracy,
|
|
1673
|
+
},
|
|
1674
|
+
{
|
|
1675
|
+
"name": "catalyst-event",
|
|
1676
|
+
"version": catalyst_event_model.version,
|
|
1677
|
+
"loaded": catalyst_event_model.is_loaded,
|
|
1678
|
+
"lastTrained": catalyst_event_model.last_trained,
|
|
1679
|
+
"accuracy": catalyst_event_model.accuracy,
|
|
1680
|
+
},
|
|
1681
|
+
],
|
|
1682
|
+
"uptime": int(time.time() - start_time),
|
|
1683
|
+
"predictionsServed": predictions_served,
|
|
1684
|
+
"training_status": training_status,
|
|
1685
|
+
"training_job_id": training_job_id,
|
|
1686
|
+
}
|