@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.
- package/README.md +250 -191
- 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 +23803 -14468
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Random Forest signal classifier.
|
|
2
|
+
|
|
3
|
+
Input: AgentSignals + derived features → buy/sell/hold classification.
|
|
4
|
+
Labels derived from outcome: did price go up/down in next N hours?
|
|
5
|
+
Replaces hardcoded thresholds in strategies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
MODEL_DIR = Path(os.getenv("MODEL_DIR", "models"))
|
|
14
|
+
|
|
15
|
+
# Feature keys matching FeatureVector from Node.js side
|
|
16
|
+
FEATURE_KEYS = [
|
|
17
|
+
"rsi", "macdHistogram", "bollingerPercentB", "ema12", "ema26",
|
|
18
|
+
"atr", "obv", "fundingRate", "fearGreed", "priceChange24h",
|
|
19
|
+
"rsiSlope", "volumeRatio", "emaCrossoverPct", "atrPct",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SignalClassifier:
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self.version = "0.1.0"
|
|
26
|
+
self.is_loaded = False
|
|
27
|
+
self.last_trained: str | None = None
|
|
28
|
+
self.accuracy: float | None = None
|
|
29
|
+
self.model = None
|
|
30
|
+
self.xgb_models: dict = {} # profile → XGBClassifier
|
|
31
|
+
|
|
32
|
+
def load(self):
|
|
33
|
+
"""Load trained model: prefer XGBoost > Random Forest > heuristic."""
|
|
34
|
+
# Try XGBoost direction models first (one per profile)
|
|
35
|
+
self._load_xgboost_models()
|
|
36
|
+
if self.xgb_models:
|
|
37
|
+
self.is_loaded = True
|
|
38
|
+
self.version = "0.2.0-xgboost"
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
# Fall back to Random Forest
|
|
42
|
+
model_path = MODEL_DIR / "signal_classifier.joblib"
|
|
43
|
+
if model_path.exists():
|
|
44
|
+
try:
|
|
45
|
+
import joblib
|
|
46
|
+
self.model = joblib.load(model_path)
|
|
47
|
+
self.is_loaded = True
|
|
48
|
+
self.last_trained = str(model_path.stat().st_mtime)
|
|
49
|
+
except Exception:
|
|
50
|
+
self._init_heuristic()
|
|
51
|
+
else:
|
|
52
|
+
self._init_heuristic()
|
|
53
|
+
|
|
54
|
+
def _load_xgboost_models(self):
|
|
55
|
+
"""Load XGBoost direction models for each horizon profile."""
|
|
56
|
+
for profile in ("scalp", "standard", "position"):
|
|
57
|
+
model_path = MODEL_DIR / f"xgb_direction_{profile}.json"
|
|
58
|
+
if model_path.exists():
|
|
59
|
+
try:
|
|
60
|
+
import xgboost as xgb
|
|
61
|
+
m = xgb.XGBClassifier()
|
|
62
|
+
m.load_model(str(model_path))
|
|
63
|
+
self.xgb_models[profile] = m
|
|
64
|
+
self.last_trained = str(model_path.stat().st_mtime)
|
|
65
|
+
except Exception:
|
|
66
|
+
pass # Skip this profile, fall through to RF or heuristic
|
|
67
|
+
|
|
68
|
+
def _init_heuristic(self):
|
|
69
|
+
self.is_loaded = True
|
|
70
|
+
self.version = "0.1.0-heuristic"
|
|
71
|
+
|
|
72
|
+
def predict(self, features: dict) -> dict:
|
|
73
|
+
"""Classify signals into buy/sell/hold.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
dict with keys: direction, probability, model
|
|
77
|
+
"""
|
|
78
|
+
# Prefer XGBoost when available for the given horizon profile
|
|
79
|
+
if self.xgb_models:
|
|
80
|
+
return self._predict_xgboost(features)
|
|
81
|
+
if self.model is not None:
|
|
82
|
+
return self._predict_model(features)
|
|
83
|
+
return self._predict_heuristic(features)
|
|
84
|
+
|
|
85
|
+
def _predict_xgboost(self, features: dict) -> dict:
|
|
86
|
+
"""Run inference through trained XGBoost direction model."""
|
|
87
|
+
horizon = features.get("horizon", "4h")
|
|
88
|
+
# Determine profile from horizon
|
|
89
|
+
scalp = {"5m", "15m", "30m"}
|
|
90
|
+
position = {"1d", "7d"}
|
|
91
|
+
if horizon in scalp:
|
|
92
|
+
profile = "scalp"
|
|
93
|
+
elif horizon in position:
|
|
94
|
+
profile = "position"
|
|
95
|
+
else:
|
|
96
|
+
profile = "standard"
|
|
97
|
+
|
|
98
|
+
model = self.xgb_models.get(profile) or self.xgb_models.get("standard")
|
|
99
|
+
if model is None:
|
|
100
|
+
return self._predict_heuristic(features)
|
|
101
|
+
|
|
102
|
+
from ..training.train_direction import ALL_FEATURE_KEYS
|
|
103
|
+
X = np.array([[features.get(k, 0) for k in ALL_FEATURE_KEYS]])
|
|
104
|
+
proba = model.predict_proba(X)[0]
|
|
105
|
+
idx = int(np.argmax(proba))
|
|
106
|
+
direction_map = {0: "down", 1: "sideways", 2: "up"}
|
|
107
|
+
return {
|
|
108
|
+
"direction": direction_map.get(idx, "sideways"),
|
|
109
|
+
"probability": float(proba[idx]),
|
|
110
|
+
"model": f"xgb-direction-{profile}",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
def _predict_model(self, features: dict) -> dict:
|
|
114
|
+
"""Run inference through trained Random Forest."""
|
|
115
|
+
X = np.array([[features.get(k, 0) for k in FEATURE_KEYS]])
|
|
116
|
+
proba = self.model.predict_proba(X)[0]
|
|
117
|
+
classes = self.model.classes_
|
|
118
|
+
idx = int(np.argmax(proba))
|
|
119
|
+
direction_map = {"buy": "up", "sell": "down", "hold": "sideways"}
|
|
120
|
+
direction = direction_map.get(classes[idx], "sideways")
|
|
121
|
+
return {
|
|
122
|
+
"direction": direction,
|
|
123
|
+
"probability": float(proba[idx]),
|
|
124
|
+
"model": f"signal-classifier-{self.version}",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
def _predict_heuristic(self, features: dict) -> dict:
|
|
128
|
+
"""Rule-based classification until model is trained.
|
|
129
|
+
|
|
130
|
+
Conservative by design: high direction threshold (±40) and capped
|
|
131
|
+
probability (0.70) so the heuristic doesn't feed overconfident
|
|
132
|
+
directional signal into the CF algebra ensemble.
|
|
133
|
+
"""
|
|
134
|
+
rsi = features.get("rsi", 50)
|
|
135
|
+
macd = features.get("macdHistogram", 0)
|
|
136
|
+
bb_pct = features.get("bollingerPercentB", 0.5)
|
|
137
|
+
funding = features.get("fundingRate", 0)
|
|
138
|
+
fg = features.get("fearGreed", 50)
|
|
139
|
+
rsi_slope = features.get("rsiSlope", 0)
|
|
140
|
+
vol_ratio = features.get("volumeRatio", 1)
|
|
141
|
+
ema_cross = features.get("emaCrossoverPct", 0)
|
|
142
|
+
|
|
143
|
+
score = 0.0
|
|
144
|
+
|
|
145
|
+
# RSI signal — only extreme values trigger
|
|
146
|
+
if rsi < 25:
|
|
147
|
+
score += 25
|
|
148
|
+
elif rsi < 35:
|
|
149
|
+
score += 10
|
|
150
|
+
elif rsi > 75:
|
|
151
|
+
score -= 25
|
|
152
|
+
elif rsi > 65:
|
|
153
|
+
score -= 10
|
|
154
|
+
|
|
155
|
+
# MACD — capped contribution to ±15
|
|
156
|
+
macd_contrib = max(-15, min(15, macd * 10))
|
|
157
|
+
score += macd_contrib
|
|
158
|
+
|
|
159
|
+
# Bollinger Bands — only extremes
|
|
160
|
+
if bb_pct < 0.1:
|
|
161
|
+
score += 12
|
|
162
|
+
elif bb_pct > 0.9:
|
|
163
|
+
score -= 12
|
|
164
|
+
|
|
165
|
+
# Funding rate (contrarian)
|
|
166
|
+
if funding > 0.0005:
|
|
167
|
+
score -= 8
|
|
168
|
+
elif funding < -0.0003:
|
|
169
|
+
score += 8
|
|
170
|
+
|
|
171
|
+
# Fear & Greed (contrarian at extremes only)
|
|
172
|
+
if fg < 15:
|
|
173
|
+
score += 8
|
|
174
|
+
elif fg > 85:
|
|
175
|
+
score -= 8
|
|
176
|
+
|
|
177
|
+
# Momentum signals — reduced contribution
|
|
178
|
+
score += rsi_slope * 1.2
|
|
179
|
+
if vol_ratio > 2.5:
|
|
180
|
+
score += 6 if score > 0 else -6
|
|
181
|
+
score += ema_cross * 3
|
|
182
|
+
|
|
183
|
+
# Flatter probability curve: divisor 300 → max ~0.70 at extreme scores
|
|
184
|
+
probability = min(0.70, max(0.3, 0.5 + abs(score) / 300))
|
|
185
|
+
|
|
186
|
+
# Higher direction threshold: ±40 → more sideways classifications
|
|
187
|
+
if score > 40:
|
|
188
|
+
return {"direction": "up", "probability": probability, "model": "rf-heuristic"}
|
|
189
|
+
elif score < -40:
|
|
190
|
+
return {"direction": "down", "probability": probability, "model": "rf-heuristic"}
|
|
191
|
+
return {"direction": "sideways", "probability": probability, "model": "rf-heuristic"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Meta-confidence model for champion/challenger promotion and filtering."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import joblib
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
MODEL_DIR = Path(os.getenv("MODEL_DIR", "models"))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StackingMetaModel:
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self.version = "0.1.0"
|
|
16
|
+
self.is_loaded = False
|
|
17
|
+
self.last_trained: str | None = None
|
|
18
|
+
self.accuracy: float | None = None
|
|
19
|
+
self.model = None
|
|
20
|
+
self.columns: list[str] = []
|
|
21
|
+
|
|
22
|
+
def load(self) -> None:
|
|
23
|
+
path = MODEL_DIR / "meta_stacking.joblib"
|
|
24
|
+
try:
|
|
25
|
+
data = joblib.load(path)
|
|
26
|
+
self.model = data["model"]
|
|
27
|
+
self.columns = data.get("columns", [])
|
|
28
|
+
self.last_trained = data.get("trained_at")
|
|
29
|
+
self.accuracy = data.get("accuracy")
|
|
30
|
+
self.is_loaded = True
|
|
31
|
+
except Exception:
|
|
32
|
+
self.model = None
|
|
33
|
+
self.columns = []
|
|
34
|
+
self.is_loaded = True
|
|
35
|
+
|
|
36
|
+
def predict(self, features: dict) -> dict:
|
|
37
|
+
if self.model is None:
|
|
38
|
+
probability = float(features.get("probability", 0.5))
|
|
39
|
+
return {
|
|
40
|
+
"correctness_probability": round(probability, 4),
|
|
41
|
+
"verdict": "take" if probability >= 0.6 else "skip",
|
|
42
|
+
"model": "heuristic-meta-stacking",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
frame = pd.DataFrame([features])
|
|
46
|
+
frame = pd.get_dummies(frame, columns=["model", "horizon"], dtype=float)
|
|
47
|
+
for column in self.columns:
|
|
48
|
+
if column not in frame:
|
|
49
|
+
frame[column] = 0.0
|
|
50
|
+
frame = frame[self.columns]
|
|
51
|
+
probability = float(self.model.predict_proba(frame)[0][1])
|
|
52
|
+
return {
|
|
53
|
+
"correctness_probability": round(probability, 4),
|
|
54
|
+
"verdict": "take" if probability >= 0.6 else "skip",
|
|
55
|
+
"model": "meta_stacking",
|
|
56
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Strategy Bandit — Contextual bandit for trading action selection."""
|
|
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 StrategyBandit:
|
|
13
|
+
"""Selects buy/sell/hold actions using contextual bandit or heuristic fallback."""
|
|
14
|
+
|
|
15
|
+
FEATURE_KEYS = [
|
|
16
|
+
"rsi",
|
|
17
|
+
"macd_histogram",
|
|
18
|
+
"ema12",
|
|
19
|
+
"ema26",
|
|
20
|
+
"bollinger_pct_b",
|
|
21
|
+
"atr",
|
|
22
|
+
"obv",
|
|
23
|
+
"funding_rate",
|
|
24
|
+
"fear_greed",
|
|
25
|
+
"price_change_24h",
|
|
26
|
+
"price",
|
|
27
|
+
"regime",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
REGIME_MAP = {
|
|
31
|
+
"trending_bull": 1.0,
|
|
32
|
+
"trending_bear": -1.0,
|
|
33
|
+
"ranging": 0.0,
|
|
34
|
+
"volatile": 0.5,
|
|
35
|
+
"capitulation": -2.0,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self.version = "0.1.0"
|
|
40
|
+
self.is_loaded = False
|
|
41
|
+
self.last_trained: str | None = None
|
|
42
|
+
self.accuracy: float | None = None
|
|
43
|
+
self.model = None
|
|
44
|
+
self.epsilon = 0.1
|
|
45
|
+
|
|
46
|
+
def load(self) -> None:
|
|
47
|
+
model_path = MODEL_DIR / "strategy_bandit.joblib"
|
|
48
|
+
try:
|
|
49
|
+
data = joblib.load(model_path)
|
|
50
|
+
self.model = data["model"]
|
|
51
|
+
self.epsilon = data.get("epsilon", 0.1)
|
|
52
|
+
self.last_trained = data.get("trained_at")
|
|
53
|
+
self.accuracy = data.get("accuracy")
|
|
54
|
+
self.is_loaded = True
|
|
55
|
+
except Exception:
|
|
56
|
+
self.model = None
|
|
57
|
+
self.is_loaded = True # heuristic fallback ready
|
|
58
|
+
|
|
59
|
+
def predict(self, features: dict) -> dict:
|
|
60
|
+
if self.model is not None:
|
|
61
|
+
return self._predict_model(features)
|
|
62
|
+
return self._predict_heuristic(features)
|
|
63
|
+
|
|
64
|
+
def _predict_model(self, features: dict) -> dict:
|
|
65
|
+
# Encode regime as numeric
|
|
66
|
+
regime_val = self.REGIME_MAP.get(str(features.get("regime", "ranging")), 0.0)
|
|
67
|
+
x_raw = []
|
|
68
|
+
for k in self.FEATURE_KEYS:
|
|
69
|
+
if k == "regime":
|
|
70
|
+
x_raw.append(regime_val)
|
|
71
|
+
else:
|
|
72
|
+
x_raw.append(features.get(k, 0))
|
|
73
|
+
|
|
74
|
+
x = np.array([x_raw])
|
|
75
|
+
|
|
76
|
+
# Model predicts action probabilities
|
|
77
|
+
proba = self.model.predict_proba(x)[0]
|
|
78
|
+
classes = list(self.model.classes_)
|
|
79
|
+
|
|
80
|
+
pred_idx = int(np.argmax(proba))
|
|
81
|
+
action = classes[pred_idx] if pred_idx < len(classes) else "hold"
|
|
82
|
+
confidence = float(proba[pred_idx]) * 100
|
|
83
|
+
|
|
84
|
+
# Position size based on confidence
|
|
85
|
+
position_size_pct = min(25, confidence * 0.25)
|
|
86
|
+
|
|
87
|
+
reasoning = [
|
|
88
|
+
f"ML bandit: {action} with {confidence:.0f}% confidence",
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
"action": action,
|
|
93
|
+
"confidence": round(min(100, confidence), 2),
|
|
94
|
+
"position_size_pct": round(position_size_pct, 2),
|
|
95
|
+
"reasoning": reasoning,
|
|
96
|
+
"model": "contextual-bandit",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def _predict_heuristic(self, features: dict) -> dict:
|
|
100
|
+
reasoning = []
|
|
101
|
+
score = 0
|
|
102
|
+
|
|
103
|
+
rsi = features.get("rsi", 50)
|
|
104
|
+
macd_hist = features.get("macd_histogram", 0)
|
|
105
|
+
ema12 = features.get("ema12", 0)
|
|
106
|
+
ema26 = features.get("ema26", 0)
|
|
107
|
+
bb_pct_b = features.get("bollinger_pct_b", 0.5)
|
|
108
|
+
funding = features.get("funding_rate", 0)
|
|
109
|
+
fg = features.get("fear_greed", 50)
|
|
110
|
+
pc24h = features.get("price_change_24h", 0)
|
|
111
|
+
|
|
112
|
+
# RSI with adaptive thresholds
|
|
113
|
+
if rsi < 25:
|
|
114
|
+
score += 35
|
|
115
|
+
reasoning.append(f"RSI deeply oversold ({rsi:.0f})")
|
|
116
|
+
elif rsi < 35:
|
|
117
|
+
score += 20
|
|
118
|
+
reasoning.append(f"RSI oversold zone ({rsi:.0f})")
|
|
119
|
+
elif rsi > 75:
|
|
120
|
+
score -= 35
|
|
121
|
+
reasoning.append(f"RSI deeply overbought ({rsi:.0f})")
|
|
122
|
+
elif rsi > 65:
|
|
123
|
+
score -= 20
|
|
124
|
+
reasoning.append(f"RSI overbought zone ({rsi:.0f})")
|
|
125
|
+
|
|
126
|
+
# MACD
|
|
127
|
+
if macd_hist > 0:
|
|
128
|
+
score += 15
|
|
129
|
+
reasoning.append("MACD bullish momentum")
|
|
130
|
+
elif macd_hist < 0:
|
|
131
|
+
score -= 15
|
|
132
|
+
reasoning.append("MACD bearish momentum")
|
|
133
|
+
|
|
134
|
+
# EMA crossover
|
|
135
|
+
if ema26 != 0:
|
|
136
|
+
cross_pct = ((ema12 - ema26) / ema26) * 100
|
|
137
|
+
if cross_pct > 0.5:
|
|
138
|
+
score += 20
|
|
139
|
+
reasoning.append(f"Golden cross (EMA gap {cross_pct:.2f}%)")
|
|
140
|
+
elif cross_pct < -0.5:
|
|
141
|
+
score -= 20
|
|
142
|
+
reasoning.append(f"Death cross (EMA gap {cross_pct:.2f}%)")
|
|
143
|
+
|
|
144
|
+
# Bollinger Bands
|
|
145
|
+
if bb_pct_b < 0.1:
|
|
146
|
+
score += 15
|
|
147
|
+
reasoning.append("Price at lower Bollinger Band")
|
|
148
|
+
elif bb_pct_b > 0.9:
|
|
149
|
+
score -= 15
|
|
150
|
+
reasoning.append("Price at upper Bollinger Band")
|
|
151
|
+
|
|
152
|
+
# Funding rate (contrarian)
|
|
153
|
+
if funding > 0.0005:
|
|
154
|
+
score -= 10
|
|
155
|
+
reasoning.append("High funding rate — overleveraged longs")
|
|
156
|
+
elif funding < -0.0003:
|
|
157
|
+
score += 10
|
|
158
|
+
reasoning.append("Negative funding — capitulation signal")
|
|
159
|
+
|
|
160
|
+
# Fear & Greed
|
|
161
|
+
if fg < 20:
|
|
162
|
+
score += 10
|
|
163
|
+
reasoning.append("Extreme fear — contrarian bullish")
|
|
164
|
+
elif fg > 80:
|
|
165
|
+
score -= 10
|
|
166
|
+
reasoning.append("Extreme greed — contrarian bearish")
|
|
167
|
+
|
|
168
|
+
# 24h trend
|
|
169
|
+
if pc24h > 5:
|
|
170
|
+
score += 10
|
|
171
|
+
elif pc24h < -5:
|
|
172
|
+
score -= 10
|
|
173
|
+
|
|
174
|
+
confidence = min(95, abs(score))
|
|
175
|
+
position_size_pct = min(25, confidence * 0.25)
|
|
176
|
+
|
|
177
|
+
if score > 20:
|
|
178
|
+
action = "buy"
|
|
179
|
+
elif score < -20:
|
|
180
|
+
action = "sell"
|
|
181
|
+
else:
|
|
182
|
+
action = "hold"
|
|
183
|
+
reasoning.append("Mixed signals — holding")
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
"action": action,
|
|
187
|
+
"confidence": round(confidence, 2),
|
|
188
|
+
"position_size_pct": round(position_size_pct, 2),
|
|
189
|
+
"reasoning": reasoning,
|
|
190
|
+
"model": "heuristic-strategy-bandit",
|
|
191
|
+
}
|