💬 Forum

AgentSignalStrategy

🏆 League #1901 / 1937

ruiwang20010702/cryptobot/freqtrade_strategies/AgentSignalStrategy.py · ★16 · ⑂3 · first seen 2026-07-16 · repo updated 2026-02-25

Basics mode: futures timeframe: 5m interface version: 3
Settings stoploss: -0.05 has minimal roi dca custom stoploss process only new candles startup candle count: 100 hyperopt hyperopt params: 3
Indicators ATR Bollinger_Bands EMA MACD RSI pandas_ta
Concepts dca
Other yaml
15 related strategies ( identical code, similar name)

Each tile is a different kind of check — from an instant code lint to full sandboxed backtests and forward tests on recent data. Not sure what a check actually proves? See the FAQ →

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
"""
AgentSignalStrategy - 读取 Agent 团队生成的信号,由 Freqtrade 执行交易

工作模式:
1. Live/Dry-run: 读取 signal.json 中的 Agent 信号 → 执行交易
2. 回测: 使用简单技术指标规则生成信号 (模拟 Agent 决策)

核心回调:
- populate_indicators: 计算基础指标
- populate_entry_trend: 入场信号 (Agent 信号 或 回测规则)
- populate_exit_trend: 出场信号
- custom_stoploss: 动态止损 + 移动止盈
- custom_exit: 最终止盈级别全仓平仓
- adjust_trade_position: 分批止盈减仓
- custom_stake_amount: Agent 仓位控制
- leverage: 杠杆控制
"""

import json
import logging
from datetime import datetime, timezone
from pathlib import Path

import pandas_ta  # noqa: F401
from freqtrade.strategy import IStrategy, DecimalParameter
from pandas import DataFrame

logger = logging.getLogger(__name__)

SIGNAL_FILE = Path("data/output/signals/signal.json")


def _ensure_utc(dt: datetime) -> datetime:
    """确保 datetime 带 UTC 时区信息"""
    if dt.tzinfo is None:
        return dt.replace(tzinfo=timezone.utc)
    return dt


class AgentSignalStrategy(IStrategy):
    """读取 Agent 团队生成的信号,由 Freqtrade 执行交易"""

    INTERFACE_VERSION = 3
    can_short = True

    # ROI (回测用,实盘由 Agent 信号控制)
    minimal_roi = {"0": 0.20, "120": 0.10, "240": 0.05, "480": 0.02}
    stoploss = -0.05
    trailing_stop = False
    timeframe = "5m"
    process_only_new_candles = True
    startup_candle_count = 100  # 需要 100 根 K 线热身

    # 移动止盈参数 (可 Hyperopt 优化)
    ts_profit_1 = DecimalParameter(0.03, 0.08, default=0.05, space="sell")
    ts_profit_2 = DecimalParameter(0.08, 0.15, default=0.10, space="sell")
    ts_profit_3 = DecimalParameter(0.15, 0.30, default=0.20, space="sell")

    def __init__(self, config: dict | None = None) -> None:
        if config is not None:
            super().__init__(config)
        # 信号缓存(基于文件 mtime,避免每根 K 线重复读磁盘)
        self._signal_cache: list[dict] = []
        self._signal_mtime: float = 0.0

    def _read_signals(self) -> list[dict]:
        """读取并过滤有效信号(带 mtime 缓存)"""
        if not SIGNAL_FILE.exists():
            return []
        mtime = SIGNAL_FILE.stat().st_mtime
        if mtime == self._signal_mtime and self._signal_cache is not None:
            return self._signal_cache
        try:
            data = json.loads(SIGNAL_FILE.read_text())
            now = datetime.now(timezone.utc)
            self._signal_cache = [
                s for s in data.get("signals", [])
                if _ensure_utc(datetime.fromisoformat(s["expires_at"])) > now
            ]
            self._signal_mtime = mtime
            return self._signal_cache
        except (json.JSONDecodeError, KeyError, ValueError) as e:
            logger.warning(f"读取信号失败: {e}")
            return []

    def _get_signal_for_pair(self, pair: str) -> dict | None:
        """获取指定交易对的信号"""
        symbol = pair.replace("/", "").replace(":USDT", "")
        for s in self._read_signals():
            if s["symbol"] == symbol:
                return s
        return None

    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        """计算指标 (回测和实盘通用)"""
        # EMA
        dataframe["ema_7"] = dataframe.ta.ema(length=7)
        dataframe["ema_25"] = dataframe.ta.ema(length=25)
        dataframe["ema_99"] = dataframe.ta.ema(length=99)

        # RSI
        dataframe["rsi"] = dataframe.ta.rsi(length=14)

        # MACD
        macd = dataframe.ta.macd(fast=12, slow=26, signal=9)
        if macd is not None:
            dataframe["macd"] = macd.iloc[:, 0]
            dataframe["macd_signal"] = macd.iloc[:, 1]
            dataframe["macd_hist"] = macd.iloc[:, 2]

        # Bollinger Bands
        bbands = dataframe.ta.bbands(length=20, std=2)
        if bbands is not None:
            dataframe["bb_lower"] = bbands.iloc[:, 0]
            dataframe["bb_mid"] = bbands.iloc[:, 1]
            dataframe["bb_upper"] = bbands.iloc[:, 2]

        # ATR
        dataframe["atr"] = dataframe.ta.atr(length=14)

        # Volume EMA
        dataframe["volume_ema"] = dataframe.ta.ema(close=dataframe["volume"], length=20)

        return dataframe

    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        """入场信号"""
        dataframe.loc[:, "enter_long"] = 0
        dataframe.loc[:, "enter_short"] = 0

        # --- 模式 1: 读取 Agent 信号 (实盘/模拟盘) ---
        signal = self._get_signal_for_pair(metadata["pair"])
        if signal is not None:
            # P8: 入场价格范围检查
            entry_range = signal.get("entry_price_range")
            if entry_range and len(entry_range) == 2:
                current_price = dataframe["close"].iloc[-1]
                low, high = entry_range
                tolerance = (high - low) * 0.15  # 15% 容差
                if not (low - tolerance <= current_price <= high + tolerance):
                    logger.info(
                        "[Agent] %s 价格 %.2f 不在范围 [%.2f, %.2f]±15%%, 跳过",
                        metadata["pair"], current_price, low, high,
                    )
                    return dataframe

            if signal["action"] == "long":
                dataframe.loc[dataframe.index[-1], "enter_long"] = 1
                logger.info(
                    f"[Agent] {metadata['pair']} LONG "
                    f"置信度={signal.get('confidence')} "
                    f"杠杆={signal.get('leverage')}"
                )
            elif signal["action"] == "short":
                dataframe.loc[dataframe.index[-1], "enter_short"] = 1
                logger.info(
                    f"[Agent] {metadata['pair']} SHORT "
                    f"置信度={signal.get('confidence')} "
                    f"杠杆={signal.get('leverage')}"
                )
            return dataframe

        # --- 模式 2: 规则化信号 (回测用) ---
        # 做多条件: EMA 多头排列 + RSI 超卖回升 + MACD 金叉
        dataframe.loc[
            (
                (dataframe["ema_7"] > dataframe["ema_25"])
                & (dataframe["ema_25"] > dataframe["ema_99"])
                & (dataframe["rsi"] > 30)
                & (dataframe["rsi"] < 70)
                & (dataframe["macd_hist"] > 0)
                & (dataframe["macd_hist"].shift(1) <= 0)
                & (dataframe["volume"] > dataframe["volume_ema"])
            ),
            "enter_long",
        ] = 1

        # 做空条件: EMA 空头排列 + RSI 超买回落 + MACD 死叉
        dataframe.loc[
            (
                (dataframe["ema_7"] < dataframe["ema_25"])
                & (dataframe["ema_25"] < dataframe["ema_99"])
                & (dataframe["rsi"] < 70)
                & (dataframe["rsi"] > 30)
                & (dataframe["macd_hist"] < 0)
                & (dataframe["macd_hist"].shift(1) >= 0)
                & (dataframe["volume"] > dataframe["volume_ema"])
            ),
            "enter_short",
        ] = 1

        return dataframe

    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        """出场信号"""
        dataframe.loc[:, "exit_long"] = 0
        dataframe.loc[:, "exit_short"] = 0

        # Agent 平仓信号
        signal = self._get_signal_for_pair(metadata["pair"])
        if signal is not None:
            if signal["action"] == "close_long":
                dataframe.loc[dataframe.index[-1], "exit_long"] = 1
            elif signal["action"] == "close_short":
                dataframe.loc[dataframe.index[-1], "exit_short"] = 1
            return dataframe

        # 回测规则出场
        # 多单出场: RSI 超买 或 EMA 死叉
        dataframe.loc[
            (dataframe["rsi"] > 75) | (dataframe["ema_7"] < dataframe["ema_25"]),
            "exit_long",
        ] = 1

        # 空单出场: RSI 超卖 或 EMA 金叉
        dataframe.loc[
            (dataframe["rsi"] < 25) | (dataframe["ema_7"] > dataframe["ema_25"]),
            "exit_short",
        ] = 1

        return dataframe

    def custom_stoploss(
        self,
        pair: str,
        trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
        after_fill: bool,
        **kwargs,
    ) -> float:
        """动态止损 + 移动止盈

        优先级:
        1. Agent trailing_stop_pct (AI 推荐的尾随比例,盈利 > 2% 后激活)
        2. Agent 固定止损价
        3. 三档移动止盈
        4. 默认止损
        """
        signal = self._get_signal_for_pair(pair)

        # 优先级 1: Agent 尾随止盈(盈利 > 2% 后激活)
        if signal and signal.get("trailing_stop_pct") and current_profit > 0.02:
            trail_pct = signal["trailing_stop_pct"] / 100
            return -(current_profit - trail_pct)

        # 优先级 1.5: MFE 自适应尾随 (ATR-based)
        # 浮盈 >= 2×ATR 后保本,之后每 1×ATR 收紧止损
        if signal and current_profit > 0 and trade:
            atr_val = None
            atr_pct = signal.get("atr_pct")
            if atr_pct:
                atr_val = atr_pct / 100
            elif hasattr(trade, "pair"):
                # 从 dataframe 中获取 ATR (如有)
                try:
                    dp = self.dp
                    if dp:
                        df = dp.get_pair_dataframe(trade.pair, self.timeframe)
                        if df is not None and "atr" in df.columns and len(df) > 0:
                            last_atr = df["atr"].iloc[-1]
                            last_close = df["close"].iloc[-1]
                            if last_close > 0 and last_atr > 0:
                                atr_val = last_atr / last_close
                except Exception:
                    pass

            if atr_val and atr_val > 0:
                trigger = atr_val * 2
                if current_profit >= trigger:
                    trail_steps = int((current_profit - trigger) / atr_val)
                    # 保本 + 阶梯收紧 (每步收紧 0.5×ATR)
                    tightened = trail_steps * atr_val * 0.5
                    new_sl = -(current_profit - 0.001 - tightened)
                    return min(new_sl, self.stoploss)

        # 优先级 2: Agent 固定止损价
        if signal and signal.get("stop_loss") and trade:
            sl_price = signal["stop_loss"]
            if trade.is_short:
                sl_pct = (sl_price - current_rate) / current_rate
            else:
                sl_pct = (current_rate - sl_price) / current_rate
            if sl_pct > 0:
                return -sl_pct
            else:
                # 价格已穿越止损价,立即触发止损
                return self.stoploss

        # 优先级 3: 移动止盈(三档尾随)
        # 若有 AI take_profit 且未全部执行完,跳过固定尾随(避免与分批止盈冲突)
        if signal and signal.get("take_profit") and trade:
            tp_list = signal["take_profit"]
            filled_orders = trade.orders or []
            all_tp_filled = all(
                any(
                    o.ft_order_tag == f"tp_{i}_{tp.get('price')}"
                    and o.ft_is_open is False
                    for o in filled_orders
                )
                for i, tp in enumerate(tp_list[:-1])
            ) if len(tp_list) >= 2 else True
            if not all_tp_filled:
                return self.stoploss
            # 全部 TP 完成后,保护尾随
            if current_profit > 0.03:
                return -(current_profit - 0.02)

        if current_profit > self.ts_profit_3.value:      # > 20%: 尾随 3%(收紧保护)
            return -(current_profit - 0.03)
        elif current_profit > self.ts_profit_2.value:     # > 10%: 尾随 5%
            return -(current_profit - 0.05)
        elif current_profit > self.ts_profit_1.value:     # > 5%: 移至成本线
            return -0.001

        return self.stoploss

    def custom_exit(
        self,
        pair: str,
        trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
        **kwargs,
    ) -> str | None:
        """最终止盈级别全仓平仓

        分批止盈的中间级别由 adjust_trade_position 减仓处理,
        最后一级在这里触发全部平仓。
        """
        signal = self._get_signal_for_pair(pair)
        if not signal or not signal.get("take_profit"):
            return None

        tp_list = signal["take_profit"]
        if not tp_list:
            return None

        # 最后一级止盈 → 全仓平仓
        last_tp = tp_list[-1]
        tp_price = last_tp.get("price")
        if tp_price is None:
            return None

        hit = (not trade.is_short and current_rate >= tp_price) or \
              (trade.is_short and current_rate <= tp_price)

        if hit:
            return f"take_profit_full_{tp_price}"

        return None

    def adjust_trade_position(
        self,
        trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
        min_stake: float,
        max_stake: float,
        current_entry_rate: float,
        current_exit_rate: float,
        current_entry_profit: float,
        current_exit_profit: float,
        **kwargs,
    ) -> float | None:
        """分批止盈:按 AI 信号的 take_profit 列表逐级减仓

        返回负数表示减仓金额。最后一级由 custom_exit 全平。
        """
        signal = self._get_signal_for_pair(trade.pair)
        if not signal or not signal.get("take_profit"):
            return None

        tp_list = signal["take_profit"]
        if len(tp_list) < 2:
            # 只有一级止盈,由 custom_exit 全平处理
            return None

        filled_orders = trade.orders or []

        # 遍历除最后一级外的所有止盈级别
        for i, tp in enumerate(tp_list[:-1]):
            tp_price = tp.get("price")
            tp_pct = tp.get("pct", 50)
            if tp_price is None:
                continue

            hit = (not trade.is_short and current_rate >= tp_price) or \
                  (trade.is_short and current_rate <= tp_price)

            # 检查是否已对该级别执行过减仓
            tag = f"tp_{i}_{tp_price}"
            already_filled = any(
                o.ft_order_tag == tag
                for o in filled_orders
                if o.ft_is_open is False
            )

            if hit and not already_filled:
                # P4: 基于剩余仓位计算减仓量
                total_tp_pct_filled = sum(
                    tp_list[j].get("pct", 0)
                    for j in range(i)
                    if any(
                        o.ft_order_tag == f"tp_{j}_{tp_list[j].get('price')}"
                        and o.ft_is_open is False
                        for o in filled_orders
                    )
                )
                remaining_pct = 100 - total_tp_pct_filled
                reduce_pct = min(tp_pct, remaining_pct)
                reduce_amount = trade.amount * current_rate / trade.leverage * reduce_pct / 100
                logger.info(
                    "[Agent] %s 分批止盈 TP%d @ %.2f, 减仓 %.1f%% (剩余 %.1f%%)",
                    trade.pair, i + 1, tp_price, reduce_pct, remaining_pct,
                )
                return -reduce_amount

        return None

    def custom_stake_amount(
        self,
        pair: str,
        current_time: datetime,
        current_rate: float,
        proposed_stake: float,
        min_stake: float | None,
        max_stake: float,
        leverage: float,
        entry_tag: str | None,
        side: str,
        **kwargs,
    ) -> float:
        """控制初始仓位大小"""
        min_stake_amount = min_stake if min_stake else 0
        signal = self._get_signal_for_pair(pair)
        if signal and signal.get("position_size_usdt"):
            stake = min(signal["position_size_usdt"], max_stake)
            return max(stake, min_stake_amount)
        return max(proposed_stake, min_stake_amount)

    def leverage(
        self,
        pair: str,
        current_time: datetime,
        current_rate: float,
        proposed_leverage: float,
        max_leverage: float,
        entry_tag: str | None,
        side: str,
        **kwargs,
    ) -> float:
        """杠杆控制

        优先读取 Agent 信号杠杆,否则使用默认值。
        硬上限从 signal.max_leverage → settings.yaml → 5x 依次 fallback。
        """
        signal = self._get_signal_for_pair(pair)

        # 确定杠杆硬上限: signal.max_leverage → settings → 5
        hard_limit = 5.0
        if signal and signal.get("max_leverage"):
            hard_limit = float(signal["max_leverage"])
        else:
            try:
                import yaml
                with open("config/settings.yaml") as f:
                    settings = yaml.safe_load(f) or {}
                hard_limit = float(
                    settings.get("risk", {}).get("max_leverage", 5)
                )
            except Exception:
                pass

        if signal and signal.get("leverage"):
            lev = float(signal["leverage"])
            return min(lev, max_leverage, hard_limit)
        return min(3.0, max_leverage, hard_limit)