bifitnex-trading/backtest/signal_generator.py
kroutony 32aa6e40cd Add non-price context filters to backtest (BTC trend, buy pressure, funding)
V3 backtest: add context parameter to signal_generator with three new filters:
- BTC trend filter: skip altcoin BUYs when BTC 1h EMA9<EMA21 + ADX>20
- Buy pressure (OHLCV proxy): penalize BUY score when close near low, boost SELL
- Funding sentiment (BTC perp basis): penalize BUY on overleveraged longs, boost SELL

Results: return -19.07% → -13.48%, max DD -27.19% → -18.25%, BUYs 385 → 189.
Added --no-context CLI flag for A/B comparison.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:54:22 +00:00

263 lines
10 KiB
Python

"""Rule-based signal generator replicating the LLM trading strategy.
Relaxed version: mirrors actual LLM behavior where volume/OBV are bonus factors
rather than hard filters, deep oversold alone can trigger entry, and bearish
trend reversal triggers protective sells on existing positions.
"""
import pandas as pd
def _near_level(price: float, level: float, tolerance: float = 0.008) -> bool:
"""Check if price is within tolerance % of a level."""
if level <= 0:
return False
return abs(price - level) / level <= tolerance
def generate_signal(
symbol: str,
ind_curr: pd.Series,
ind_prev: pd.Series,
htf_last: pd.Series | None,
pivots: dict | None,
has_position: bool = False,
context: dict | None = None,
) -> dict:
"""Generate a trading signal based on codified strategy rules.
Matches real LLM behavior: flexible confirmation counting with bonus scoring
rather than strict AND filters.
"""
hold = {"symbol": symbol, "action": "HOLD", "confidence": 0.0, "reason": "", "suggested_amount_pct": 0.0}
close = ind_curr.get("close", 0)
if close <= 0 or pd.isna(ind_curr.get("rsi")):
return {**hold, "reason": "insufficient data"}
# --- Extract 5m indicators ---
rsi = ind_curr.get("rsi", 50)
stoch_k = ind_curr.get("stoch_rsi_k", 0.5)
stoch_d = ind_curr.get("stoch_rsi_d", 0.5)
macd = ind_curr.get("macd", 0)
macd_signal = ind_curr.get("macd_signal", 0)
macd_hist = ind_curr.get("macd_hist", 0)
prev_macd = ind_prev.get("macd", 0)
prev_macd_signal = ind_prev.get("macd_signal", 0)
bb_lower = ind_curr.get("bb_lower", 0)
bb_upper = ind_curr.get("bb_upper", 0)
ema9 = ind_curr.get("ema9", 0)
ema21 = ind_curr.get("ema21", 0)
adx = ind_curr.get("adx", 0)
volume = ind_curr.get("volume", 0)
vol_ma20 = ind_curr.get("vol_ma20", 0)
obv_slope = ind_curr.get("obv_slope", 0)
cmf = ind_curr.get("cmf", 0)
# --- VWAP filter ---
vwap = ind_curr.get("vwap", 0)
# --- 1h trend context ---
htf_bullish = False
htf_adx_ok = False
htf_strong = False
htf_bearish_strong = False
# Market regime: trending (ADX≥25), weak_trend (15-25), choppy (<15)
market_regime = "choppy"
if htf_last is not None and pd.notna(htf_last.get("ema9")):
htf_bullish = htf_last["ema9"] > htf_last["ema21"]
htf_adx = htf_last.get("adx", 0)
if pd.notna(htf_adx):
htf_adx_ok = htf_adx >= 20
htf_strong = htf_adx > 25
htf_bearish_strong = (not htf_bullish) and htf_adx > 25
if htf_adx >= 25:
market_regime = "trending"
elif htf_adx >= 15:
market_regime = "weak_trend"
# --- Pivot levels ---
s1 = pivots.get("s1", 0) if pivots else 0
s2 = pivots.get("s2", 0) if pivots else 0
r1 = pivots.get("r1", 0) if pivots else 0
r2 = pivots.get("r2", 0) if pivots else 0
# --- Bonus scoring (not hard filters) ---
volume_bonus = 0.05 if (vol_ma20 > 0 and volume > vol_ma20) else 0
obv_buy_bonus = 0.05 if obv_slope > 0 else 0
obv_sell_bonus = 0.05 if obv_slope < 0 else 0
# --- Extract context data ---
ctx = context or {}
btc_trend = ctx.get("btc_trend")
buy_pressure = ctx.get("buy_pressure", 0.5)
funding = ctx.get("funding_sentiment")
# ========== BUY SIGNALS ==========
# Regime filter: choppy market → no buys at all
if market_regime == "choppy":
pass # skip all buy logic
# BTC trend filter: skip altcoin buys during confirmed BTC bearish
elif (btc_trend and btc_trend.get("bearish_confirmed")
and symbol != "tBTCUST"):
pass # skip buy — BTC bearish confirmed
# VWAP filter: don't buy above VWAP (buying above fair value)
elif vwap > 0 and close > vwap:
pass # skip buy
# Require: 1h bullish (core filter)
elif htf_bullish:
buy_confirms = []
reasons = []
# Signal 1: Oversold bounce (tightened: RSI<30 alone no longer triggers)
if stoch_k < 0.2 and rsi < 40:
if stoch_k > stoch_d:
buy_confirms.append(1.0)
reasons.append("超賣反彈(StochRSI K上穿D+RSI)")
else:
buy_confirms.append(0.7)
reasons.append("超賣(StochRSI+RSI)")
# Signal 2: Mean reversion (BB touch) — allowed in weak_trend regime too
if bb_lower > 0 and close <= bb_lower * 1.005:
if rsi < 40 and cmf > 0:
buy_confirms.append(1.0)
reasons.append("均值回歸(BB+RSI+CMF)")
# Signal 3: Trend start (MACD golden cross) — only in trending regime
macd_cross_up = (pd.notna(prev_macd) and pd.notna(prev_macd_signal)
and prev_macd <= prev_macd_signal and macd > macd_signal)
prev_macd_hist = ind_prev.get("macd_hist", 0)
if macd_cross_up and ema9 > ema21 and market_regime == "trending":
# MACD histogram direction confirmation: require histogram rising
if pd.notna(prev_macd_hist) and macd_hist > prev_macd_hist:
buy_confirms.append(1.0 if (pd.notna(adx) and adx > 20) else 0.8)
reasons.append("趨勢啟動(MACD金叉+hist↑+EMA)")
else:
buy_confirms.append(0.6 if (pd.notna(adx) and adx > 20) else 0.4)
reasons.append("趨勢啟動(MACD金叉+EMA)")
# Signal 4: Support bounce — only in trending regime
if market_regime == "trending":
near_support = _near_level(close, s1) or _near_level(close, s2)
if near_support and rsi < 45:
buy_confirms.append(0.7 if obv_slope > 0 else 0.5)
reasons.append("支撐反彈(Pivot+RSI)")
# In weak_trend, only allow mean reversion (Signal 2)
if market_regime == "weak_trend":
buy_confirms = [c for c, r in zip(buy_confirms, reasons) if "均值回歸" in r]
reasons = [r for r in reasons if "均值回歸" in r]
# Score: sum of weighted confirmations
total_score = sum(buy_confirms)
n_confirms = len(buy_confirms)
# Buy pressure filter (OHLCV proxy for order book imbalance)
if buy_pressure < 0.3:
total_score *= 0.5 # heavy sell pressure — halve score
elif buy_pressure < 0.4:
total_score *= 0.8 # moderate sell pressure
# Funding sentiment filter (overleveraged longs → pullback risk)
if funding and funding.get("signal") == "overleveraged_long":
total_score *= 0.6
if total_score >= 1.5 and htf_adx_ok: # require stronger confirmation + trending
base_conf = 0.55
base_conf += 0.10 if htf_adx_ok else 0
base_conf += 0.05 if htf_strong else 0
base_conf += volume_bonus
base_conf += obv_buy_bonus
if n_confirms >= 3:
base_conf += 0.10
conf = min(0.90, max(0.50, base_conf))
return {
"symbol": symbol,
"action": "BUY",
"confidence": round(conf, 2),
"reason": " + ".join(reasons),
"suggested_amount_pct": min(0.20, max(0.05, conf * 0.20)),
}
# ========== SELL SIGNALS ==========
sell_confirms = []
sell_reasons = []
# Signal 1: Overbought reversal (relaxed: RSI>65 or StochRSI>0.8)
if stoch_k > 0.8 and stoch_k < stoch_d:
if rsi > 70 and macd_hist < 0:
sell_confirms.append(1.0)
sell_reasons.append("超買反轉(StochRSI+RSI+MACD)")
elif rsi > 65:
sell_confirms.append(0.7)
sell_reasons.append("超買(StochRSI+RSI)")
# Signal 2: Resistance rejection
near_resistance = _near_level(close, r1) or _near_level(close, r2)
if near_resistance and cmf < 0:
sell_confirms.append(0.7 if obv_slope < 0 else 0.5)
sell_reasons.append("阻力拒絕(Pivot+CMF)")
# Signal 3: Trend reversal (MACD death cross + EMA bearish)
macd_cross_down = (pd.notna(prev_macd) and pd.notna(prev_macd_signal)
and prev_macd >= prev_macd_signal and macd < macd_signal)
if macd_cross_down and ema9 < ema21:
strength = 1.0 if (not htf_bullish) else 0.6
sell_confirms.append(strength)
sell_reasons.append("趨勢反轉(MACD死叉+EMA)")
# Signal 4: 1h bearish strong trend + 5m confirming (protective sell)
if htf_bearish_strong and ema9 < ema21 and rsi < 40 and obv_slope < 0:
sell_confirms.append(0.8)
sell_reasons.append("多TF空頭(1h強空+5m空)")
# Signal 5: OBV massive outflow + CMF bearish (volume divergence, reduced weight)
if obv_slope < 0 and cmf < -0.1 and ema9 < ema21:
sell_confirms.append(0.3)
sell_reasons.append("量價背離(OBV流出+CMF賣壓)")
# VWAP sell bonus: price significantly above VWAP
if vwap > 0 and close > vwap * 1.01:
sell_confirms.append(0.3)
sell_reasons.append("VWAP溢價")
# Sell pressure from OHLCV proxy
if buy_pressure < 0.3:
sell_confirms.append(0.3)
sell_reasons.append("賣壓(OHLCV)")
# Funding: overleveraged longs with high basis → liquidation risk
if funding and funding.get("signal") == "overleveraged_long":
basis_pct = funding.get("basis_pct", 0)
if basis_pct > 0.0008: # > 0.08%
sell_confirms.append(0.4)
sell_reasons.append("資金費率過高")
total_sell = sum(sell_confirms)
if total_sell >= 1.2 and has_position:
base_conf = 0.55
if not htf_bullish:
base_conf += 0.10
if htf_bearish_strong:
base_conf += 0.05
base_conf += obv_sell_bonus
base_conf += volume_bonus
if len(sell_confirms) >= 3:
base_conf += 0.10
# If 1h still bullish, penalize
if htf_bullish:
base_conf -= 0.15
conf = min(0.90, max(0.40, base_conf))
return {
"symbol": symbol,
"action": "SELL",
"confidence": round(conf, 2),
"reason": " + ".join(sell_reasons),
"suggested_amount_pct": 1.0,
}
return {**hold, "reason": "確認不足"}