CryptoNAV_Testnet / engine.py
Nomnommish's picture
Upload 7 files
9da9756 verified
Raw
History Blame Contribute Delete
7.19 kB
"""
engine.py — Feature Engineering + Heavy XGBoost Engine + signal mapping.
Features: RSI, MACD, ATR, VWAP distance, Bollinger position/width, EMA
crossovers, volume delta, candle momentum, support/resistance distance,
volatility compression, regime score.
Model: XGBoost classifier trained per-symbol on the candle history.
Label = does the close 4 candles ahead beat fees+buffer (BUY-worthy)?
Outputs: probability_up, expected_return, downside_risk, confidence,
market_regime, 4-candle forecast path, action (BUY/HOLD/SELL).
"""
import logging
import numpy as np
import pandas as pd
log = logging.getLogger("engine")
try:
from xgboost import XGBClassifier
HAVE_XGB = True
except ImportError: # graceful degrade to logistic-style heuristic
HAVE_XGB = False
FEE_BUFFER_PCT = 0.5 # round-trip fees + slippage threshold, in %
FORECAST_CANDLES = 4
FEATURES = [
"rsi", "macd_hist", "atr_pct", "vwap_dist", "bb_pos", "bb_width",
"ema_cross", "vol_delta", "momentum", "sup_dist", "res_dist",
"vol_compress", "regime_score",
]
# ------------------------------------------------------------------ features
def build_features(df: pd.DataFrame) -> pd.DataFrame:
out = df.copy()
c, h, l, v = out["Close"], out["High"], out["Low"], out["Volume"]
# RSI(14)
delta = c.diff()
gain = delta.clip(lower=0).rolling(14).mean()
loss = (-delta.clip(upper=0)).rolling(14).mean()
out["rsi"] = 100 - 100 / (1 + gain / loss.replace(0, np.nan))
# MACD histogram
ema12, ema26 = c.ewm(span=12).mean(), c.ewm(span=26).mean()
macd = ema12 - ema26
out["macd_hist"] = (macd - macd.ewm(span=9).mean()) / c * 100
# ATR% (14)
tr = pd.concat(
[h - l, (h - c.shift()).abs(), (l - c.shift()).abs()], axis=1
).max(axis=1)
out["atr_pct"] = tr.rolling(14).mean() / c * 100
# VWAP distance (rolling 96 candles)
tp = (h + l + c) / 3
vwap = (tp * v).rolling(96).sum() / v.rolling(96).sum()
out["vwap"] = vwap
out["vwap_dist"] = (c - vwap) / vwap * 100
# Bollinger (20, 2)
mid = c.rolling(20).mean()
sd = c.rolling(20).std()
out["bb_pos"] = (c - mid) / (2 * sd)
out["bb_width"] = 4 * sd / mid * 100
# EMA crossover state (9 vs 21)
out["ema_cross"] = np.sign(c.ewm(span=9).mean() - c.ewm(span=21).mean())
# volume delta: up-candle vs down-candle volume balance (12 candles)
up_v = v.where(c > c.shift(), 0.0).rolling(12).sum()
dn_v = v.where(c <= c.shift(), 0.0).rolling(12).sum()
out["vol_delta"] = (up_v - dn_v) / (up_v + dn_v).replace(0, np.nan)
# candle momentum (3-candle return %)
out["momentum"] = c.pct_change(3) * 100
# support / resistance distance (rolling 48-candle extremes)
out["sup_dist"] = (c - l.rolling(48).min()) / c * 100
out["res_dist"] = (h.rolling(48).max() - c) / c * 100
# volatility compression: current bb_width vs its 96-candle mean
out["vol_compress"] = out["bb_width"] / out["bb_width"].rolling(96).mean()
# regime score: trend + flow composite in [-100, 100]
trend = np.sign(c.ewm(span=21).mean().diff(8))
out["regime_score"] = (
40 * trend + 30 * out["ema_cross"] + 30 * out["vol_delta"].fillna(0)
)
return out
def classify_regime(row) -> str:
s = row["regime_score"]
if s >= 40:
return "MARKUP"
if s >= 10:
return "ACCUMULATION"
if s <= -40:
return "MARKDOWN"
if s <= -10:
return "DISTRIBUTION"
return "RANGE"
# ------------------------------------------------------------------ model
def _label(df: pd.DataFrame) -> pd.Series:
fwd = df["Close"].shift(-FORECAST_CANDLES) / df["Close"] - 1
return (fwd * 100 > FEE_BUFFER_PCT).astype(int)
def train_model(feat_df: pd.DataFrame):
"""Train XGBoost on this symbol's candle history. Returns (model, acc)."""
df = feat_df.dropna(subset=FEATURES).copy()
df["y"] = _label(df)
df = df.iloc[:-FORECAST_CANDLES]
if len(df) < 150:
return None, 0.0
split = int(len(df) * 0.8)
Xtr, ytr = df[FEATURES].iloc[:split], df["y"].iloc[:split]
Xte, yte = df[FEATURES].iloc[split:], df["y"].iloc[split:]
if not HAVE_XGB:
return None, 0.0
model = XGBClassifier(
n_estimators=200, max_depth=4, learning_rate=0.08,
subsample=0.9, colsample_bytree=0.8, eval_metric="logloss",
n_jobs=2,
)
model.fit(Xtr, ytr)
acc = float((model.predict(Xte) == yte).mean()) if len(yte) else 0.0
return model, acc
# ------------------------------------------------------------------ analysis
def _nz(x, default=0.0):
try:
x = float(x)
return x if np.isfinite(x) else default
except Exception:
return default
def analyze(df: pd.DataFrame, model=None, model_acc: float = 0.0) -> dict:
feat = build_features(df)
row = feat.iloc[-1]
price = float(row["Close"])
# probability_up
if model is not None:
X = feat[FEATURES].iloc[[-1]].fillna(0)
prob_up = min(99.0, max(1.0, float(model.predict_proba(X)[0, 1]) * 100))
else: # heuristic fallback if xgboost missing
prob_up = 50 + 25 * float(np.tanh(row["regime_score"] / 60))
atr = _nz(row["atr_pct"], 1.0) or 1.0
expected_return = (prob_up / 100 - 0.5) * 2 * atr * FORECAST_CANDLES ** 0.5
downside_risk = atr * 1.65 # ~95% one-candle stress
confidence = max(0.0, min(100.0, 100 * abs(prob_up - 50) / 50 * 0.6
+ model_acc * 100 * 0.4))
regime = classify_regime(row)
# 4-candle forecast path (drift from expected_return, ATR cone)
drift = expected_return / FORECAST_CANDLES / 100
path = [price * (1 + drift) ** (i + 1) for i in range(FORECAST_CANDLES)]
upper = [p * (1 + atr / 100 * (i + 1) ** 0.5) for i, p in enumerate(path)]
lower = [p * (1 - atr / 100 * (i + 1) ** 0.5) for i, p in enumerate(path)]
# action mapping
bullish = (
prob_up >= 60
and expected_return > FEE_BUFFER_PCT
and regime not in ("MARKDOWN", "DISTRIBUTION")
and _nz(row["vol_delta"]) > 0
)
bearish = prob_up <= 42 or regime == "MARKDOWN"
action = "BUY" if bullish else "SELL" if bearish else "HOLD"
return {
"current_price": price,
"action": action,
"probability_up": round(prob_up, 1),
"expected_return": round(expected_return, 2),
"downside_risk": round(downside_risk, 2),
"confidence": round(confidence, 1),
"market_regime": regime,
"model_acc": round(model_acc * 100, 1),
"rsi": round(_nz(row["rsi"], 50.0), 1),
"macd_hist": round(_nz(row["macd_hist"]), 3),
"atr_pct": round(atr, 2),
"vwap": _nz(row["vwap"], price),
"vol_delta": round(_nz(row["vol_delta"]), 2),
"ema_cross": int(_nz(row["ema_cross"])),
"sup_dist": round(_nz(row["sup_dist"]), 2),
"res_dist": round(_nz(row["res_dist"]), 2),
"regime_score": round(_nz(row["regime_score"]), 0),
"forecast_path": path,
"forecast_upper": upper,
"forecast_lower": lower,
"features": {f: float(np.nan_to_num(row[f])) for f in FEATURES},
}