@vizzor/cli 0.13.1 → 0.14.6

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.
Files changed (55) hide show
  1. package/README.md +251 -192
  2. package/chronovisor-engine/pyproject.toml +31 -0
  3. package/chronovisor-engine/src/__init__.py +0 -0
  4. package/chronovisor-engine/src/inference/__init__.py +0 -0
  5. package/chronovisor-engine/src/inference/predict.py +44 -0
  6. package/chronovisor-engine/src/model_catalog.py +219 -0
  7. package/chronovisor-engine/src/models/__init__.py +0 -0
  8. package/chronovisor-engine/src/models/anomaly_detector.py +104 -0
  9. package/chronovisor-engine/src/models/blockchain_cycle_analyzer.py +217 -0
  10. package/chronovisor-engine/src/models/catalyst_event_model.py +70 -0
  11. package/chronovisor-engine/src/models/conformal_interval.py +50 -0
  12. package/chronovisor-engine/src/models/divergence_detector.py +247 -0
  13. package/chronovisor-engine/src/models/drift_monitor.py +51 -0
  14. package/chronovisor-engine/src/models/intent_classifier.py +189 -0
  15. package/chronovisor-engine/src/models/lstm_predictor.py +143 -0
  16. package/chronovisor-engine/src/models/microstructure_specialist.py +65 -0
  17. package/chronovisor-engine/src/models/narrative_detector.py +418 -0
  18. package/chronovisor-engine/src/models/portfolio_optimizer.py +162 -0
  19. package/chronovisor-engine/src/models/project_risk_scorer.py +184 -0
  20. package/chronovisor-engine/src/models/pump_detector.py +344 -0
  21. package/chronovisor-engine/src/models/regime_detector.py +127 -0
  22. package/chronovisor-engine/src/models/rug_detector.py +197 -0
  23. package/chronovisor-engine/src/models/sentiment_analyzer.py +257 -0
  24. package/chronovisor-engine/src/models/signal_classifier.py +191 -0
  25. package/chronovisor-engine/src/models/stacking_meta.py +56 -0
  26. package/chronovisor-engine/src/models/strategy_bandit.py +191 -0
  27. package/chronovisor-engine/src/models/ta_interpreter.py +341 -0
  28. package/chronovisor-engine/src/models/target_quantile.py +96 -0
  29. package/chronovisor-engine/src/models/trend_scorer.py +107 -0
  30. package/chronovisor-engine/src/models/wallet_classifier.py +261 -0
  31. package/chronovisor-engine/src/server.py +1686 -0
  32. package/chronovisor-engine/src/training/__init__.py +0 -0
  33. package/chronovisor-engine/src/training/data_loader.py +635 -0
  34. package/chronovisor-engine/src/training/pipeline.py +130 -0
  35. package/chronovisor-engine/src/training/train_catalyst.py +169 -0
  36. package/chronovisor-engine/src/training/train_classifier.py +159 -0
  37. package/chronovisor-engine/src/training/train_conformal.py +106 -0
  38. package/chronovisor-engine/src/training/train_direction.py +215 -0
  39. package/chronovisor-engine/src/training/train_drift.py +57 -0
  40. package/chronovisor-engine/src/training/train_isotonic.py +58 -0
  41. package/chronovisor-engine/src/training/train_lstm.py +217 -0
  42. package/chronovisor-engine/src/training/train_microstructure.py +102 -0
  43. package/chronovisor-engine/src/training/train_narrative.py +168 -0
  44. package/chronovisor-engine/src/training/train_pump.py +109 -0
  45. package/chronovisor-engine/src/training/train_regime.py +116 -0
  46. package/chronovisor-engine/src/training/train_rug.py +58 -0
  47. package/chronovisor-engine/src/training/train_sentiment.py +63 -0
  48. package/chronovisor-engine/src/training/train_stacking_meta.py +74 -0
  49. package/chronovisor-engine/src/training/train_target_quantile.py +115 -0
  50. package/chronovisor-engine/src/training/train_trend.py +101 -0
  51. package/dist/index.js +22494 -15023
  52. package/dist/index.js.map +1 -1
  53. package/package.json +5 -1
  54. package/vizzor_logodarkicon.png +0 -0
  55. package/vizzor_logoicon.png +0 -0
@@ -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
+ }