"""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": "確認不足"}