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>
263 lines
10 KiB
Python
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": "確認不足"}
|