@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.
- package/README.md +251 -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 +22494 -15023
- package/dist/index.js.map +1 -1
- package/package.json +5 -1
- package/vizzor_logodarkicon.png +0 -0
- package/vizzor_logoicon.png +0 -0
|
@@ -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))
|