@vizzor/cli 0.13.0 → 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.
Files changed (53) hide show
  1. package/README.md +250 -191
  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 +23803 -14468
  52. package/dist/index.js.map +1 -1
  53. package/package.json +6 -4
@@ -0,0 +1,162 @@
1
+ """Portfolio Optimizer — Mean-Variance optimization + dynamic Kelly criterion."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import joblib
7
+ import numpy as np
8
+
9
+ MODEL_DIR = Path(os.getenv("MODEL_DIR", "models"))
10
+
11
+ REGIME_MULTIPLIERS = {
12
+ "trending_bull": 1.2,
13
+ "trending_bear": 0.6,
14
+ "ranging": 0.8,
15
+ "volatile": 0.5,
16
+ "capitulation": 0.3,
17
+ }
18
+
19
+
20
+ class PortfolioOptimizer:
21
+ """Optimizes position sizing, stop-loss, and take-profit using MVO + Kelly."""
22
+
23
+ def __init__(self) -> None:
24
+ self.version = "0.1.0"
25
+ self.is_loaded = False
26
+ self.last_trained: str | None = None
27
+ self.accuracy: float | None = None
28
+ self.model = None
29
+
30
+ def load(self) -> None:
31
+ model_path = MODEL_DIR / "portfolio_optimizer.joblib"
32
+ try:
33
+ data = joblib.load(model_path)
34
+ self.model = data["model"]
35
+ self.last_trained = data.get("trained_at")
36
+ self.accuracy = data.get("accuracy")
37
+ self.is_loaded = True
38
+ except Exception:
39
+ self.model = None
40
+ self.is_loaded = True
41
+
42
+ def optimize(self, features: dict) -> dict:
43
+ """Optimize position sizing and risk parameters."""
44
+ if self.model is not None:
45
+ return self._optimize_model(features)
46
+ return self._optimize_heuristic(features)
47
+
48
+ def forecast(self, features: dict) -> dict:
49
+ """Predict forward-looking portfolio metrics."""
50
+ return self._forecast_heuristic(features)
51
+
52
+ def _optimize_model(self, features: dict) -> dict:
53
+ # Model-based optimization would use trained MVO
54
+ # For now, delegate to heuristic with model adjustments
55
+ result = self._optimize_heuristic(features)
56
+ result["model"] = "mvo-portfolio-optimizer"
57
+ return result
58
+
59
+ def _optimize_heuristic(self, features: dict) -> dict:
60
+ win_rate = features.get("win_rate", 0.5)
61
+ max_drawdown = features.get("max_drawdown", 10)
62
+ regime = features.get("regime", "ranging")
63
+ total_value = features.get("total_value", 10000)
64
+ cash = features.get("cash", total_value)
65
+
66
+ reasoning = []
67
+
68
+ # Kelly criterion for position sizing
69
+ avg_win = features.get("avg_win", 0.05)
70
+ avg_loss = features.get("avg_loss", 0.03)
71
+ win_loss_ratio = avg_win / max(0.001, avg_loss)
72
+ kelly = win_rate - (1 - win_rate) / max(0.1, win_loss_ratio)
73
+ kelly = max(0, min(0.25, kelly))
74
+
75
+ # Regime adjustment
76
+ regime_mult = REGIME_MULTIPLIERS.get(regime, 0.8)
77
+ position_size_pct = kelly * 100 * regime_mult
78
+
79
+ reasoning.append(f"Kelly fraction: {kelly:.2%}")
80
+ reasoning.append(f"Regime '{regime}' multiplier: {regime_mult}")
81
+
82
+ # Drawdown protection
83
+ if max_drawdown > 15:
84
+ position_size_pct *= 0.5
85
+ reasoning.append(f"Drawdown protection: halved size (DD={max_drawdown:.1f}%)")
86
+ elif max_drawdown > 10:
87
+ position_size_pct *= 0.75
88
+ reasoning.append(f"Moderate drawdown adjustment (DD={max_drawdown:.1f}%)")
89
+
90
+ # Stop-loss multiplier (ATR-based)
91
+ atr_pct = features.get("atr_pct", 3)
92
+ if regime in ("volatile", "capitulation"):
93
+ stop_loss_multiplier = 3.0
94
+ reasoning.append("Wide stops for volatile regime")
95
+ elif regime == "trending_bull":
96
+ stop_loss_multiplier = 1.5
97
+ reasoning.append("Tight stops for trending bull")
98
+ else:
99
+ stop_loss_multiplier = 2.0
100
+
101
+ # Take-profit multiplier (reward:risk)
102
+ take_profit_multiplier = stop_loss_multiplier * max(1.5, win_loss_ratio)
103
+
104
+ # Max allocation cap
105
+ max_allocation_pct = 25 if regime in ("trending_bull",) else 15
106
+
107
+ return {
108
+ "position_size_pct": round(min(max_allocation_pct, position_size_pct), 2),
109
+ "stop_loss_multiplier": round(stop_loss_multiplier, 2),
110
+ "take_profit_multiplier": round(take_profit_multiplier, 2),
111
+ "max_allocation_pct": max_allocation_pct,
112
+ "reasoning": reasoning,
113
+ "model": "heuristic-portfolio-optimizer",
114
+ }
115
+
116
+ def _forecast_heuristic(self, features: dict) -> dict:
117
+ returns_history = features.get("returns_history", [])
118
+ sharpe_history = features.get("sharpe_history", [])
119
+ drawdown_history = features.get("drawdown_history", [])
120
+
121
+ if len(returns_history) < 5:
122
+ return {
123
+ "predicted_return": 0,
124
+ "predicted_sharpe": 0,
125
+ "predicted_max_drawdown": 0,
126
+ "confidence": 0,
127
+ "model": "insufficient-data",
128
+ }
129
+
130
+ # Simple exponential weighted average for forward prediction
131
+ weights = np.array([0.5 ** i for i in range(len(returns_history))])
132
+ weights = weights[::-1] # more weight to recent
133
+ weights /= weights.sum()
134
+
135
+ pred_return = float(np.average(returns_history, weights=weights))
136
+
137
+ pred_sharpe = 0.0
138
+ if sharpe_history:
139
+ sw = weights[: len(sharpe_history)]
140
+ sw /= sw.sum()
141
+ pred_sharpe = float(np.average(sharpe_history, weights=sw))
142
+
143
+ pred_dd = 0.0
144
+ if drawdown_history:
145
+ dw = weights[: len(drawdown_history)]
146
+ dw /= dw.sum()
147
+ pred_dd = float(np.average(drawdown_history, weights=dw))
148
+
149
+ # Confidence based on data consistency
150
+ if len(returns_history) >= 20:
151
+ std = float(np.std(returns_history))
152
+ confidence = max(20, min(80, 80 - std * 100))
153
+ else:
154
+ confidence = max(20, min(60, len(returns_history) * 3))
155
+
156
+ return {
157
+ "predicted_return": round(pred_return, 4),
158
+ "predicted_sharpe": round(pred_sharpe, 4),
159
+ "predicted_max_drawdown": round(abs(pred_dd), 4),
160
+ "confidence": round(confidence, 2),
161
+ "model": "ewma-portfolio-forecast",
162
+ }
@@ -0,0 +1,184 @@
1
+ """Project Risk Scorer — GBM classifier for project-level risk assessment."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import joblib
7
+ import numpy as np
8
+
9
+ MODEL_DIR = Path(os.getenv("MODEL_DIR", "models"))
10
+
11
+
12
+ class ProjectRiskScorer:
13
+ """Predicts overall project risk probability from contract + market features."""
14
+
15
+ FEATURE_KEYS = [
16
+ "bytecode_size",
17
+ "is_verified",
18
+ "holder_concentration",
19
+ "has_proxy",
20
+ "has_mint",
21
+ "has_pause",
22
+ "has_blacklist",
23
+ "liquidity_locked",
24
+ "buy_tax",
25
+ "sell_tax",
26
+ "contract_age_days",
27
+ "total_transfers",
28
+ "owner_balance_pct",
29
+ "is_open_source",
30
+ "top10_holder_pct",
31
+ "has_token_info",
32
+ ]
33
+
34
+ def __init__(self) -> None:
35
+ self.version = "0.1.0"
36
+ self.is_loaded = False
37
+ self.last_trained: str | None = None
38
+ self.accuracy: float | None = None
39
+ self.model = None
40
+
41
+ def load(self) -> None:
42
+ model_path = MODEL_DIR / "project_risk_scorer.joblib"
43
+ try:
44
+ data = joblib.load(model_path)
45
+ self.model = data["model"]
46
+ self.last_trained = data.get("trained_at")
47
+ self.accuracy = data.get("accuracy")
48
+ self.is_loaded = True
49
+ except Exception:
50
+ self.model = None
51
+ self.is_loaded = True
52
+
53
+ def predict(self, features: dict) -> dict:
54
+ if self.model is not None:
55
+ return self._predict_model(features)
56
+ return self._predict_heuristic(features)
57
+
58
+ def _predict_model(self, features: dict) -> dict:
59
+ x = np.array([[features.get(k, 0) for k in self.FEATURE_KEYS]])
60
+ prob = float(self.model.predict_proba(x)[0][1]) # P(risky)
61
+
62
+ risk_factors = []
63
+ if hasattr(self.model, "feature_importances_"):
64
+ imp = self.model.feature_importances_
65
+ pairs = list(zip(self.FEATURE_KEYS, imp))
66
+ pairs.sort(key=lambda p: p[1], reverse=True)
67
+ for key, importance in pairs[:5]:
68
+ risk_factors.append(
69
+ {
70
+ "factor": key,
71
+ "importance": round(float(importance), 4),
72
+ "value": float(features.get(key, 0)),
73
+ }
74
+ )
75
+
76
+ return {
77
+ "risk_probability": round(prob, 4),
78
+ "risk_level": self._level(prob),
79
+ "risk_factors": risk_factors,
80
+ "model": "gbm-project-risk",
81
+ }
82
+
83
+ def _predict_heuristic(self, features: dict) -> dict:
84
+ score = 0
85
+ risk_factors = []
86
+
87
+ # Unverified contract
88
+ if not features.get("is_verified", 0):
89
+ score += 30
90
+ risk_factors.append(
91
+ {"factor": "is_verified", "importance": 0.20, "value": 0}
92
+ )
93
+
94
+ # High holder concentration
95
+ concentration = features.get("holder_concentration", 0)
96
+ if concentration > 50:
97
+ score += 25
98
+ risk_factors.append(
99
+ {
100
+ "factor": "holder_concentration",
101
+ "importance": 0.18,
102
+ "value": concentration,
103
+ }
104
+ )
105
+ elif concentration > 30:
106
+ score += 15
107
+
108
+ # No token info
109
+ if not features.get("has_token_info", 1):
110
+ score += 15
111
+ risk_factors.append(
112
+ {"factor": "has_token_info", "importance": 0.10, "value": 0}
113
+ )
114
+
115
+ # Mint capability
116
+ if features.get("has_mint", 0):
117
+ score += 20
118
+ risk_factors.append(
119
+ {"factor": "has_mint", "importance": 0.15, "value": 1}
120
+ )
121
+
122
+ # Sell tax > 10%
123
+ sell_tax = features.get("sell_tax", 0)
124
+ if sell_tax > 10:
125
+ score += 15
126
+ risk_factors.append(
127
+ {"factor": "sell_tax", "importance": 0.12, "value": sell_tax}
128
+ )
129
+
130
+ # Blacklist capability
131
+ if features.get("has_blacklist", 0):
132
+ score += 15
133
+ risk_factors.append(
134
+ {"factor": "has_blacklist", "importance": 0.10, "value": 1}
135
+ )
136
+
137
+ # Owner balance > 20%
138
+ owner_pct = features.get("owner_balance_pct", 0)
139
+ if owner_pct > 20:
140
+ score += 10
141
+ risk_factors.append(
142
+ {
143
+ "factor": "owner_balance_pct",
144
+ "importance": 0.08,
145
+ "value": owner_pct,
146
+ }
147
+ )
148
+
149
+ # Top10 holder concentration > 70%
150
+ top10 = features.get("top10_holder_pct", 0)
151
+ if top10 > 70:
152
+ score += 10
153
+
154
+ # Very new contract (< 7 days)
155
+ age = features.get("contract_age_days", 365)
156
+ if age < 7:
157
+ score += 10
158
+
159
+ # Not open source
160
+ if not features.get("is_open_source", 0):
161
+ score += 5
162
+
163
+ score = min(100, score)
164
+ prob = score / 100
165
+
166
+ # Sort by importance
167
+ risk_factors.sort(key=lambda f: f["importance"], reverse=True)
168
+
169
+ return {
170
+ "risk_probability": round(prob, 4),
171
+ "risk_level": self._level(prob),
172
+ "risk_factors": risk_factors[:5],
173
+ "model": "heuristic-project-risk",
174
+ }
175
+
176
+ @staticmethod
177
+ def _level(prob: float) -> str:
178
+ if prob >= 0.75:
179
+ return "critical"
180
+ if prob >= 0.5:
181
+ return "high"
182
+ if prob >= 0.25:
183
+ return "medium"
184
+ return "low"
@@ -0,0 +1,344 @@
1
+ """CUSUM-based pump/dump detection model for micro-timeframe analysis.
2
+
3
+ Uses Cumulative Sum (CUSUM) change detection on 1-minute returns to identify
4
+ sudden price manipulation events such as pumps, dumps, and volume spikes.
5
+ """
6
+
7
+ import os
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ import numpy as np
13
+
14
+ MODEL_DIR = Path(os.getenv("MODEL_DIR", "models"))
15
+
16
+
17
+ @dataclass
18
+ class CUSUMState:
19
+ """Per-token tracking state for the CUSUM algorithm."""
20
+
21
+ cusum_up: float = 0.0
22
+ cusum_down: float = 0.0
23
+ prices: list[float] = field(default_factory=list)
24
+ volumes: list[float] = field(default_factory=list)
25
+ baseline_return: float = 0.0
26
+ baseline_volume: float = 0.0
27
+ samples: int = 0
28
+
29
+
30
+ @dataclass
31
+ class PumpDetectionResult:
32
+ """Result of pump/dump detection analysis."""
33
+
34
+ detected: bool
35
+ type: str # 'pump', 'dump', 'volume_spike', 'none'
36
+ severity: str # 'low', 'medium', 'high', 'critical'
37
+ cusum_value: float
38
+ threshold: float
39
+ price_change_pct: float
40
+ volume_spike: float
41
+ confidence: float
42
+
43
+
44
+ class PumpDetectorModel:
45
+ """CUSUM-based pump/dump detection model.
46
+
47
+ Maintains per-token state and uses the Cumulative Sum algorithm to detect
48
+ statistically significant deviations in price returns and volume.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ target_mean: float = 0,
54
+ allowance: float = 0.5,
55
+ threshold: float = 4,
56
+ window_size: int = 60,
57
+ volume_spike_threshold: float = 5.0,
58
+ ) -> None:
59
+ self.target_mean = target_mean
60
+ self.allowance = allowance
61
+ self.threshold = threshold
62
+ self.window_size = window_size
63
+ self.volume_spike_threshold = volume_spike_threshold
64
+ self.states: dict[str, CUSUMState] = {}
65
+ self.version = "0.1.0"
66
+ self.is_loaded = False
67
+ self.last_trained: str | None = None
68
+ self.accuracy: float | None = None
69
+ self.model = None
70
+ self.engine = "cusum"
71
+
72
+ def load(self) -> None:
73
+ """Load an optional supervised classifier on top of the CUSUM baseline."""
74
+ model_path = MODEL_DIR / "pump_detector.joblib"
75
+ try:
76
+ import joblib
77
+
78
+ data = joblib.load(model_path)
79
+ self.model = data.get("model")
80
+ self.engine = data.get("engine", "xgboost")
81
+ self.last_trained = data.get("trained_at")
82
+ self.accuracy = data.get("accuracy")
83
+ except Exception:
84
+ self.model = None
85
+ self.engine = "cusum"
86
+ self.is_loaded = True
87
+
88
+ def feed(
89
+ self, token: str, price: float, volume: float
90
+ ) -> Optional[PumpDetectionResult]:
91
+ """Feed a single price/volume observation for a token.
92
+
93
+ Maintains per-token CUSUM state and returns a detection result
94
+ when enough samples have been collected (>= 2 for returns).
95
+ """
96
+ if token not in self.states:
97
+ self.states[token] = CUSUMState()
98
+
99
+ state = self.states[token]
100
+ state.prices.append(price)
101
+ state.volumes.append(volume)
102
+ state.samples += 1
103
+
104
+ # Trim to window size
105
+ if len(state.prices) > self.window_size:
106
+ state.prices = state.prices[-self.window_size :]
107
+ if len(state.volumes) > self.window_size:
108
+ state.volumes = state.volumes[-self.window_size :]
109
+
110
+ # Need at least 2 prices to compute a return
111
+ if len(state.prices) < 2:
112
+ return None
113
+
114
+ # Calculate latest return
115
+ prev_price = state.prices[-2]
116
+ if prev_price == 0:
117
+ return None
118
+ current_return = (price - prev_price) / prev_price
119
+
120
+ # Update baseline statistics using exponential moving average
121
+ alpha = 2.0 / (min(state.samples, self.window_size) + 1)
122
+ state.baseline_return = (
123
+ alpha * current_return + (1 - alpha) * state.baseline_return
124
+ )
125
+ state.baseline_volume = alpha * volume + (1 - alpha) * state.baseline_volume
126
+
127
+ # Normalize return relative to baseline
128
+ deviation = current_return - self.target_mean
129
+
130
+ # Update CUSUM statistics
131
+ state.cusum_up = max(0, state.cusum_up + deviation - self.allowance * 0.01)
132
+ state.cusum_down = max(
133
+ 0, state.cusum_down - deviation - self.allowance * 0.01
134
+ )
135
+
136
+ # Check for pump/dump signals
137
+ detected = False
138
+ detection_type = "none"
139
+ cusum_value = 0.0
140
+
141
+ if state.cusum_up > self.threshold * 0.01:
142
+ detected = True
143
+ detection_type = "pump"
144
+ cusum_value = state.cusum_up
145
+ elif state.cusum_down > self.threshold * 0.01:
146
+ detected = True
147
+ detection_type = "dump"
148
+ cusum_value = state.cusum_down
149
+
150
+ # Check for volume spike
151
+ volume_spike = 0.0
152
+ if state.baseline_volume > 0:
153
+ volume_spike = volume / state.baseline_volume
154
+ if volume_spike > self.volume_spike_threshold and not detected:
155
+ detected = True
156
+ detection_type = "volume_spike"
157
+ cusum_value = max(state.cusum_up, state.cusum_down)
158
+
159
+ # Calculate overall price change in the window
160
+ price_change_pct = 0.0
161
+ if len(state.prices) >= 2 and state.prices[0] != 0:
162
+ price_change_pct = (
163
+ (state.prices[-1] - state.prices[0]) / state.prices[0]
164
+ ) * 100
165
+
166
+ # Determine severity and confidence
167
+ severity = self._classify_severity(
168
+ cusum_value, price_change_pct, volume_spike
169
+ )
170
+ confidence = self._calculate_confidence(
171
+ cusum_value, price_change_pct, volume_spike, state.samples
172
+ )
173
+
174
+ # Reset CUSUM after detection to avoid repeated alerts
175
+ if detected:
176
+ state.cusum_up = 0.0
177
+ state.cusum_down = 0.0
178
+
179
+ return PumpDetectionResult(
180
+ detected=detected,
181
+ type=detection_type,
182
+ severity=severity,
183
+ cusum_value=round(cusum_value, 6),
184
+ threshold=self.threshold * 0.01,
185
+ price_change_pct=round(price_change_pct, 4),
186
+ volume_spike=round(volume_spike, 4),
187
+ confidence=round(confidence, 4),
188
+ )
189
+
190
+ def predict(self, features: dict) -> PumpDetectionResult:
191
+ """Batch prediction from feature dict (API compatibility).
192
+
193
+ Accepts either:
194
+ - {"token": str, "prices": [float], "volumes": [float]} for batch
195
+ - {"token": str, "price": float, "volume": float} for single feed
196
+ """
197
+ token = features.get("token", "unknown")
198
+ prices = features.get("prices", [])
199
+ volumes = features.get("volumes", [])
200
+
201
+ # Single feed mode
202
+ if not prices and "price" in features:
203
+ price = float(features["price"])
204
+ volume = float(features.get("volume", 0))
205
+ result = self.feed(token, price, volume)
206
+ if result is None:
207
+ return PumpDetectionResult(
208
+ detected=False,
209
+ type="none",
210
+ severity="low",
211
+ cusum_value=0.0,
212
+ threshold=self.threshold * 0.01,
213
+ price_change_pct=0.0,
214
+ volume_spike=0.0,
215
+ confidence=0.0,
216
+ )
217
+ return result
218
+
219
+ # Batch mode — feed all prices/volumes and return last result
220
+ if len(volumes) < len(prices):
221
+ volumes = volumes + [0.0] * (len(prices) - len(volumes))
222
+
223
+ if self.model is not None and prices:
224
+ return self._predict_model_batch(prices, volumes)
225
+
226
+ last_result: Optional[PumpDetectionResult] = None
227
+ for p, v in zip(prices, volumes):
228
+ result = self.feed(token, float(p), float(v))
229
+ if result is not None:
230
+ last_result = result
231
+
232
+ if last_result is None:
233
+ return PumpDetectionResult(
234
+ detected=False,
235
+ type="none",
236
+ severity="low",
237
+ cusum_value=0.0,
238
+ threshold=self.threshold * 0.01,
239
+ price_change_pct=0.0,
240
+ volume_spike=0.0,
241
+ confidence=0.0,
242
+ )
243
+ return last_result
244
+
245
+ def _predict_model_batch(
246
+ self,
247
+ prices: list[float],
248
+ volumes: list[float],
249
+ ) -> PumpDetectionResult:
250
+ if len(prices) < 6:
251
+ return PumpDetectionResult(
252
+ detected=False,
253
+ type="none",
254
+ severity="low",
255
+ cusum_value=0.0,
256
+ threshold=self.threshold * 0.01,
257
+ price_change_pct=0.0,
258
+ volume_spike=0.0,
259
+ confidence=0.0,
260
+ )
261
+
262
+ returns = []
263
+ for idx in range(1, len(prices)):
264
+ prev = prices[idx - 1]
265
+ current = prices[idx]
266
+ returns.append(((current - prev) / prev) * 100 if prev else 0.0)
267
+
268
+ volume_avg = np.mean(volumes[:-1]) if len(volumes) > 1 and np.mean(volumes[:-1]) > 0 else 1.0
269
+ volume_ratio = volumes[-1] / volume_avg if volume_avg > 0 else 1.0
270
+ cusum_up = float(np.sum(np.maximum(returns[-8:], 0)))
271
+ cusum_down = float(np.sum(np.maximum(-np.array(returns[-8:]), 0)))
272
+ volatility_5 = float(np.std(returns[-5:])) if len(returns) >= 5 else 0.0
273
+ x = np.array([[returns[-1], volume_ratio, cusum_up, cusum_down, volatility_5]], dtype=np.float32)
274
+
275
+ proba = self.model.predict_proba(x)[0]
276
+ idx = int(np.argmax(proba))
277
+ detection_type = {0: "none", 1: "pump", 2: "dump"}.get(idx, "none")
278
+ price_change_pct = ((prices[-1] - prices[0]) / prices[0]) * 100 if prices[0] else 0.0
279
+ confidence = float(proba[idx])
280
+ severity = self._classify_severity(max(cusum_up, cusum_down), price_change_pct, volume_ratio)
281
+
282
+ return PumpDetectionResult(
283
+ detected=detection_type != "none",
284
+ type=detection_type,
285
+ severity=severity,
286
+ cusum_value=round(max(cusum_up, cusum_down), 6),
287
+ threshold=self.threshold * 0.01,
288
+ price_change_pct=round(price_change_pct, 4),
289
+ volume_spike=round(volume_ratio, 4),
290
+ confidence=round(confidence, 4),
291
+ )
292
+
293
+ def get_state(self, token: str) -> Optional[CUSUMState]:
294
+ """Get current CUSUM state for a token."""
295
+ return self.states.get(token)
296
+
297
+ def reset(self, token: str | None = None) -> None:
298
+ """Reset state for a token, or all tokens if None."""
299
+ if token is None:
300
+ self.states.clear()
301
+ elif token in self.states:
302
+ del self.states[token]
303
+
304
+ def _classify_severity(
305
+ self, cusum_value: float, price_change_pct: float, volume_spike: float
306
+ ) -> str:
307
+ """Classify detection severity based on multiple signals."""
308
+ score = 0.0
309
+ score += min(1.0, cusum_value / (self.threshold * 0.02)) * 0.4
310
+ score += min(1.0, abs(price_change_pct) / 20.0) * 0.35
311
+ score += min(1.0, volume_spike / (self.volume_spike_threshold * 2)) * 0.25
312
+
313
+ if score >= 0.75:
314
+ return "critical"
315
+ if score >= 0.5:
316
+ return "high"
317
+ if score >= 0.25:
318
+ return "medium"
319
+ return "low"
320
+
321
+ def _calculate_confidence(
322
+ self,
323
+ cusum_value: float,
324
+ price_change_pct: float,
325
+ volume_spike: float,
326
+ samples: int,
327
+ ) -> float:
328
+ """Calculate detection confidence score (0-1)."""
329
+ # Base confidence from CUSUM deviation
330
+ cusum_conf = min(1.0, cusum_value / (self.threshold * 0.015))
331
+
332
+ # Price change contribution
333
+ price_conf = min(1.0, abs(price_change_pct) / 15.0)
334
+
335
+ # Volume spike contribution
336
+ vol_conf = min(1.0, volume_spike / (self.volume_spike_threshold * 1.5))
337
+
338
+ # Sample size penalty — less confident with fewer samples
339
+ sample_factor = min(1.0, samples / self.window_size)
340
+
341
+ raw = (cusum_conf * 0.4 + price_conf * 0.3 + vol_conf * 0.2) * (
342
+ 0.5 + 0.5 * sample_factor
343
+ )
344
+ return min(1.0, max(0.0, raw))