diff --git a/config.py b/config.py index b9b720c..e509128 100644 --- a/config.py +++ b/config.py @@ -32,6 +32,8 @@ SYMBOL_NAMES = { # Candle settings CANDLE_TIMEFRAME = "5m" CANDLE_LIMIT = 100 +HTF_TIMEFRAME = "1h" +HTF_CANDLE_LIMIT = 50 # Trading parameters MIN_ORDER_USDT = 5 diff --git a/data_fetcher.py b/data_fetcher.py index 78efb3f..ece87d1 100644 --- a/data_fetcher.py +++ b/data_fetcher.py @@ -169,17 +169,20 @@ def fetch_active_orders() -> list[dict]: # --------------------------------------------------------------------------- def fetch_all_market_data(symbols: list[str] | None = None) -> dict: - """Fetch tickers + candles for all symbols. Returns dict keyed by symbol.""" + """Fetch tickers + candles (5m and 1h) for all symbols. Returns dict keyed by symbol.""" symbols = symbols or config.TOP_15_SYMBOLS tickers = fetch_tickers(symbols) data = {} for sym in symbols: try: candles = fetch_candles(sym) + candles_htf = fetch_candles(sym, timeframe=config.HTF_TIMEFRAME, limit=config.HTF_CANDLE_LIMIT) data[sym] = { "ticker": tickers.get(sym, {}), "candles": candles, + "candles_htf": candles_htf, } + time.sleep(0.15) # Avoid Bitfinex rate limits except Exception as e: logger.warning("Failed to fetch data for %s: %s", sym, e) return data diff --git a/indicators.py b/indicators.py index 1e6b854..27f9f29 100644 --- a/indicators.py +++ b/indicators.py @@ -15,7 +15,7 @@ def calculate_indicators(candles_df: pd.DataFrame) -> pd.DataFrame: Returns the DataFrame with additional indicator columns. """ df = candles_df.copy() - if df.empty or len(df) < 26: + if df.empty or len(df) < 30: logger.warning("Not enough candle data to compute indicators (got %d rows)", len(df)) return df @@ -54,9 +54,73 @@ def calculate_indicators(candles_df: pd.DataFrame) -> pd.DataFrame: # Volume moving average (20) df["vol_ma20"] = volume.rolling(window=20).mean() + # ADX(14) — trend strength + df["adx"] = ta.trend.adx(high, low, close, window=14) + + # Stochastic RSI(14, 14, 3, 3) + df["stoch_rsi_k"] = ta.momentum.stochrsi_k(close, window=14, smooth1=3, smooth2=3) + df["stoch_rsi_d"] = ta.momentum.stochrsi_d(close, window=14, smooth1=3, smooth2=3) + + # OBV (On-Balance Volume) + slope + df["obv"] = ta.volume.on_balance_volume(close, volume) + df["obv_slope"] = df["obv"].diff(5) + + # CMF (Chaikin Money Flow, 20-period) + df["cmf"] = ta.volume.chaikin_money_flow(high, low, close, volume, window=20) + return df +def calculate_htf_indicators(candles_df: pd.DataFrame) -> pd.DataFrame: + """Calculate trend-context indicators on higher timeframe (1h) candles.""" + df = candles_df.copy() + if df.empty or len(df) < 30: + return df + + close = df["close"] + high = df["high"] + low = df["low"] + + df["ema9"] = ta.trend.ema_indicator(close, window=9) + df["ema21"] = ta.trend.ema_indicator(close, window=21) + df["adx"] = ta.trend.adx(high, low, close, window=14) + df["rsi"] = ta.momentum.rsi(close, window=14) + + return df + + +def calculate_pivot_points(htf_df: pd.DataFrame) -> dict | None: + """Calculate classic pivot points from previous day's price action using 1h candles.""" + if htf_df.empty or len(htf_df) < 24: + return None + + df = htf_df.copy() + df["date"] = df["timestamp"].dt.date + + dates = sorted(df["date"].unique()) + if len(dates) < 2: + return None + + prev_day = df[df["date"] == dates[-2]] + if prev_day.empty: + return None + + h = prev_day["high"].max() + l = prev_day["low"].min() + c = prev_day["close"].iloc[-1] + + pivot = (h + l + c) / 3 + return { + "pivot": pivot, + "r1": 2 * pivot - l, + "r2": pivot + (h - l), + "r3": h + 2 * (pivot - l), + "s1": 2 * pivot - h, + "s2": pivot - (h - l), + "s3": l - 2 * (h - pivot), + } + + def summarize_indicators(df: pd.DataFrame, symbol: str) -> str: """Produce a concise text summary of the latest indicator values for LLM consumption.""" if df.empty or len(df) < 2: @@ -89,6 +153,61 @@ def summarize_indicators(df: pd.DataFrame, symbol: str) -> str: lines.append(f"ATR(14): {last['atr']:.6g}") if pd.notna(last.get("vwap")): lines.append(f"VWAP: {last['vwap']:.6g}") + if pd.notna(last.get("adx")): + adx_label = "趨勢" if last["adx"] > 25 else "盤整" if last["adx"] < 20 else "弱" + lines.append(f"ADX(14): {last['adx']:.1f} ({adx_label})") + if pd.notna(last.get("stoch_rsi_k")): + lines.append(f"StochRSI: K={last['stoch_rsi_k']:.2f} D={last['stoch_rsi_d']:.2f}") + if pd.notna(last.get("obv_slope")): + obv_dir = "流入" if last["obv_slope"] > 0 else "流出" + lines.append(f"OBV趨勢: {obv_dir} (slope={last['obv_slope']:.0f})") + if pd.notna(last.get("cmf")): + cmf_label = "買壓" if last["cmf"] > 0.05 else "賣壓" if last["cmf"] < -0.05 else "中性" + lines.append(f"CMF(20): {last['cmf']:.3f} ({cmf_label})") + + return "\n".join(lines) + "\n" + + +def summarize_htf(htf_by_symbol: dict[str, pd.DataFrame]) -> str: + """Produce a compact 1h trend context summary for LLM.""" + lines = ["## 1小時趨勢背景"] + for sym in sorted(htf_by_symbol): + df = htf_by_symbol[sym] + if df.empty or len(df) < 2: + continue + last = df.iloc[-1] + name = config.SYMBOL_NAMES.get(sym, sym) + + trend = "多頭" if last.get("ema9", 0) > last.get("ema21", 0) else "空頭" + adx_val = last.get("adx", 0) + strength = "強趨勢" if adx_val > 25 else "盤整" if adx_val < 20 else "弱趨勢" + rsi_1h = last.get("rsi", 50) + + lines.append(f"- {name}: {trend} ({strength}, ADX={adx_val:.0f}, RSI={rsi_1h:.0f})") + return "\n".join(lines) + "\n" + + +def summarize_pivots(pivots_by_symbol: dict[str, dict], current_prices: dict) -> str: + """Summarize pivot point levels relative to current price.""" + lines = ["## 關鍵支撐/阻力 (日線樞紐)"] + for sym in sorted(pivots_by_symbol): + p = pivots_by_symbol[sym] + if not p: + continue + name = config.SYMBOL_NAMES.get(sym, sym) + price = current_prices.get(sym, 0) + if price <= 0: + continue + + supports = [("S3", p["s3"]), ("S2", p["s2"]), ("S1", p["s1"]), ("P", p["pivot"])] + resistances = [("P", p["pivot"]), ("R1", p["r1"]), ("R2", p["r2"]), ("R3", p["r3"])] + + nearest_sup = max([(n, v) for n, v in supports if v < price], key=lambda x: x[1], default=None) + nearest_res = min([(n, v) for n, v in resistances if v > price], key=lambda x: x[1], default=None) + + sup_str = f"{nearest_sup[0]}={nearest_sup[1]:.6g}" if nearest_sup else "N/A" + res_str = f"{nearest_res[0]}={nearest_res[1]:.6g}" if nearest_res else "N/A" + lines.append(f"- {name}: 支撐 {sup_str} | 阻力 {res_str}") return "\n".join(lines) + "\n" diff --git a/llm_analyzer.py b/llm_analyzer.py index 71baa14..0477054 100644 --- a/llm_analyzer.py +++ b/llm_analyzer.py @@ -14,24 +14,41 @@ def analyze_market(indicator_summary: str, account_status: str) -> list[dict]: ## 帳戶狀態 {account_status} -## 目前市場指標摘要(5分鐘K線最新值) +## 目前市場指標摘要 {indicator_summary} 請根據以下策略分析每個幣種並給出交易建議: ## 交易策略 -1. **趨勢確認**: EMA(9) > EMA(21) 為多頭,反之為空頭 -2. **進場訊號 (BUY)**: - - RSI < 35 且 MACD histogram 由負轉正(超賣反彈) - - 價格觸及 Bollinger 下軌且 RSI < 40(均值回歸) - - MACD 金叉 + EMA(9) 上穿 EMA(21)(趨勢啟動) -3. **進場訊號 (SELL)**: - - RSI > 70 且 MACD histogram 由正轉負(超買回落) - - 價格觸及 Bollinger 上軌且 RSI > 65 - - MACD 死叉 + EMA(9) 下穿 EMA(21) -4. **過濾條件**: - - 成交量需高於 20 期平均(確認動能) - - ATR 過高時降低倉位(波動風控) + +### 最高優先:多時間框架過濾(現貨,只做多) +- 1小時趨勢為多頭(EMA9 > EMA21)時,才考慮 BUY +- 1小時為空頭時,只持有或平倉(SELL),不開新倉 +- 1小時 ADX < 20(盤整)時,避免進場 +- 1小時 ADX > 25 且方向一致 = 高信心交易 + +### 進場訊號 (BUY) — 需至少2個確認 +1. **超賣反彈**: StochRSI K < 0.2 且 K 上穿 D + RSI < 40 +2. **均值回歸**: 價格觸及 BB 下軌 + RSI < 40 + CMF > 0(有買壓) +3. **趨勢啟動**: MACD 金叉 + EMA9 上穿 EMA21 + ADX > 20(有趨勢) +4. **支撐反彈**: 價格接近樞紐支撐位(S1/S2)+ OBV 流入 + RSI < 45 + +### 出場訊號 (SELL) +1. **超買回落**: StochRSI K > 0.8 且 K 下穿 D + RSI > 65 +2. **阻力拒絕**: 價格接近樞紐阻力位(R1/R2)+ OBV 流出 +3. **趨勢反轉**: MACD 死叉 + EMA9 下穿 EMA21 + CMF < 0 + +### 過濾條件(必須全部滿足) +- 成交量需高於 20 期平均(確認動能) +- 5分鐘 ADX > 15(排除極度盤整) +- OBV 方向需與交易方向一致(量價確認) +- ATR 過高時降低 suggested_amount_pct(波動風控) + +### 信心分數指引 +- 0.8+: 多時間框架對齊 + 3個以上確認指標 +- 0.6-0.8: 多時間框架對齊 + 2個確認 +- 0.4-0.6: 單一時間框架訊號,降低倉位 +- < 0.4: 不建議交易,回傳 HOLD 請以 JSON 格式回傳,每個幣種一個物件: ```json @@ -40,8 +57,8 @@ def analyze_market(indicator_summary: str, account_status: str) -> list[dict]: "symbol": "tBTCUST", "action": "BUY" | "SELL" | "HOLD", "confidence": 0.0-1.0, - "reason": "簡短理由", - "suggested_amount_pct": 0.10-0.20 + "reason": "簡短理由(含觸發的指標)", + "suggested_amount_pct": 0.05-0.20 }} ] ``` diff --git a/main.py b/main.py index 9c00f71..98f12c2 100644 --- a/main.py +++ b/main.py @@ -179,7 +179,7 @@ def run_cycle(): slack_notifier.send_error_alert(f"Market data fetch failed: {e}") return - # 4. Calculate indicators + # 4. Calculate indicators (5m) indicators_by_symbol = {} current_prices = {} for sym, md in market_data.items(): @@ -191,6 +191,15 @@ def run_cycle(): if ticker: current_prices[sym] = ticker.get("last_price", 0) + # 4b. Calculate HTF indicators (1h) + htf_by_symbol = {} + pivots_by_symbol = {} + for sym, md in market_data.items(): + candles_htf = md.get("candles_htf") + if candles_htf is not None and not candles_htf.empty: + htf_by_symbol[sym] = indicators.calculate_htf_indicators(candles_htf) + pivots_by_symbol[sym] = indicators.calculate_pivot_points(candles_htf) + # 5. Cache data (persisted for next crontab run) try: data_fetcher.cache_market_data(market_data, indicators_by_symbol) @@ -301,8 +310,12 @@ def run_cycle(): logger.info("Take profit executed: %s, P&L: %.2f USDT (%.2f%%)", sym, realized_pnl or 0, realized_pnl_pct or 0) - # 7. Build indicator summary for LLM + # 7. Build indicator summary for LLM (5m + 1h context + pivots) indicator_summary = indicators.summarize_all(indicators_by_symbol) + if htf_by_symbol: + indicator_summary += "\n" + indicators.summarize_htf(htf_by_symbol) + if pivots_by_symbol: + indicator_summary += "\n" + indicators.summarize_pivots(pivots_by_symbol, current_prices) # 8. Build account status string for LLM account_str = _build_account_string(port, current_prices) diff --git a/stop_orders.json b/stop_orders.json index b634e42..ae16bfc 100644 --- a/stop_orders.json +++ b/stop_orders.json @@ -1,15 +1,9 @@ { - "tADAUST": { - "order_id": 233273720461, - "stop_price": 0.2626, - "entry_price": 0.2707080505480357, - "amount": 201.94477684 - }, "tDOGE:UST": { "order_id": 233282701621, - "stop_price": 0.09394531, + "stop_price": 0.093946, "entry_price": 0.09685083602369626, - "amount": 144.10790954030196 + "amount": 144.10790954 }, "tSUIUST": { "order_id": 233265250698,