# source: https://raw.githubusercontent.com/MurphyHoops/freqtradeOne/d8fe9999ddf54b30b8fd8e9a6e16237f6a583920/user_data/strategies/TaxBrainV29.py
﻿"""Github_MurphyHoops_freqtradeOne__TaxBrainV29__20260102_094148 ����·��������Э���㡣

"""

from __future__ import annotations
from typing import Optional, Any, Dict
import talib.abstract as ta
import copy
import json
import sys
import time
import uuid
from dataclasses import dataclass, field, replace
from datetime import datetime
from pathlib import Path
import types
from typing import Any, Dict, Optional, Set, List, Tuple
import pandas as pd
from pandas import Timestamp
import math

ROOT_PATH = Path(__file__).resolve().parents[2]
if str(ROOT_PATH) not in sys.path:
    sys.path.insert(0, str(ROOT_PATH))
MODULE_ALIAS = 'user_data.strategies.Github_MurphyHoops_freqtradeOne__TaxBrainV29__20260102_094148'
if MODULE_ALIAS not in sys.modules:
    sys.modules[MODULE_ALIAS] = sys.modules.get(__name__)
module_obj = sys.modules.get(__name__)
if module_obj is None:
    module_obj = types.ModuleType(__name__)
    sys.modules[__name__] = module_obj
module_obj.__dict__.update(globals())
try:
    from user_data.strategies.agents.exits.facade import ExitFacade
    from user_data.strategies.agents.exits.router import EXIT_ROUTER, SLContext, TPContext
    from user_data.strategies.agents.exits.exit import ExitTags
except Exception:  # pragma: no cover
    ExitFacade = None  # type: ignore
    EXIT_ROUTER = None  # type: ignore
    SLContext = None    # type: ignore
    TPContext = None    # type: ignore
    ExitTags = None     # type: ignore
try:
    from freqtrade.enums import RunMode

except Exception:
    RunMode = None
try:
    from freqtrade.strategy import (

        IStrategy,
        Trade,
        Order,
        PairLocks,
        informative,  # @informative decorator

        # Hyperopt Parameters

        BooleanParameter,
        CategoricalParameter,
        DecimalParameter,
        IntParameter,
        RealParameter,

        # timeframe helpers

        timeframe_to_minutes,
        timeframe_to_next_date,
        timeframe_to_prev_date,

        # Strategy helper functions

        merge_informative_pair,
        stoploss_from_absolute,
        stoploss_from_open,
    )
except Exception:  # pragma: no cover

    class IStrategy:  # type: ignore

        def __init__(self, *args, **kwargs):
            if not hasattr(self, "dp"):
                self.dp = types.SimpleNamespace()

    class Trade:  # type: ignore
        pass

    class Order:  # type: ignore
        pass

    class PairLocks:  # type: ignore
        pass

    class BooleanParameter:  # type: ignore
        pass

    class CategoricalParameter:  # type: ignore
        pass

    class DecimalParameter:  # type: ignore
        pass

    class IntParameter:  # type: ignore
        pass

    class RealParameter:  # type: ignore
        pass

    def informative(timeframe: str):  # type: ignore

        def decorator(func):
            return func
        return decorator

    def timeframe_to_minutes(timeframe: str):  # type: ignore
        return 0

    def timeframe_to_next_date(timeframe: str, date):  # type: ignore
        return date

    def timeframe_to_prev_date(timeframe: str, date):  # type: ignore
        return date

    def merge_informative_pair(*args, **kwargs):  # type: ignore
        return None

    def stoploss_from_absolute(*args, **kwargs):  # type: ignore
        return 0.0

    def stoploss_from_open(*args, **kwargs):  # type: ignore
        return 0.0

# Github_MurphyHoops_freqtradeOne__TaxBrainV29__20260102_094148.py 顶部（和其它 import 放一起）

from user_data.strategies.agents.exits import rules_threshold  # 注册 SL/TP + 即时退出规则
from user_data.strategies.config.v29_config import V29Config, apply_overrides
from user_data.strategies.agents.portfolio.treasury import TreasuryAgent
from user_data.strategies.agents.portfolio.tier import TierAgent, TierManager
from user_data.strategies.agents.portfolio.sizer import SizerAgent
from user_data.strategies.agents.signals.builder import (

    collect_factor_requirements,
    collect_indicator_requirements,
)
from user_data.strategies.agents.signals import builder, indicators, schemas
from user_data.strategies.agents.portfolio.risk import RiskAgent
from user_data.strategies.agents.portfolio.reservation import ReservationAgent
from user_data.strategies.agents.portfolio.persist import StateStore
from user_data.strategies.agents.exits.exit import ExitPolicyV29
from user_data.strategies.agents.portfolio.execution import ExecutionAgent
from user_data.strategies.agents.portfolio.cycle import CycleAgent
from user_data.strategies.agents.portfolio.analytics import AnalyticsAgent

# safe, optional import – file lives next to your strategy agents

try:
    from . import evaluate_sl_override

except Exception:  # pragma: no cover

    # type: ignore

    def evaluate_sl_override(ctx: Dict[str, Any], base_sl_pct: float | None) -> Optional[float]:
        return None

# 让所有内置入场信号完成“注册”

from user_data.strategies.agents.signals import builtin_signals as _signals  # noqa: F401

class _NoopStateStore:
    """No-op persistence adapter used during backtests/hyperopt to keep state ephemeral."""
    def save(self) -> None:  # pragma: no cover - trivial
        return
    def load_if_exists(self) -> None:  # pragma: no cover - trivial
        return

@dataclass
class ActiveTradeMeta:
    """ActiveTradeMeta 用于记录在市订单在 Github_MurphyHoops_freqtradeOne__TaxBrainV29__20260102_094148 流程中的关键元数据，方便执行、退出与风控代理在不同 hook 间共享统一视图。
    Attributes:
        sl_pct: 建仓时设定的止损百分比（通常为负值，代表最大亏损距离）。
        tp_pct: 建仓时设定的止盈百分比。
        direction: 信号方向，取值为 'long' 或 'short'。
        entry_bar_tick: 建仓发生时的全局 bar_tick 序号，用于与 CycleAgent 对齐。
        entry_price: 建仓价格，供执行/退出时计算盈亏。
        bucket: 拨款桶信息，fast 或 slow。
        icu_bars_left: ICU 强制回收剩余 bar 数，None 表示当前不受 ICU 约束。
    """
    sl_pct: float
    tp_pct: float
    direction: str
    entry_bar_tick: int
    entry_price: float
    bucket: str
    icu_bars_left: Optional[int]
    exit_profile: Optional[str] = None
    recipe: Optional[str] = None
    plan_timeframe: Optional[str] = None
    plan_atr_pct: Optional[float] = None
    tier_name: Optional[str] = None

@dataclass
class PairState:
    """PairState 汇聚单个交易对的即时状态，用于信号评估、财政拨款与风险检查之间的信息同步。

    Attributes:
        closs: 连续亏损计数，驱动 TierPolicy 的升降级与冷却时长。
        local_loss: 累计局部亏损金额，用于疼痛加权财政拨款。
        cooldown_bars_left: 当前剩余冷却 bar 数，>0 时禁止开新仓。
        last_dir: 最近一次信号方向，None 表示暂无有效信号。
        last_score: 最近一次信号期望收益（edge）数值。
        last_squad: 最近一次信号所属 squad（MRL/PBL/TRS 等）。
        last_sl_pct: 最近一次信号建议的止损百分比。
        last_tp_pct: 最近一次信号建议的止盈百分比。
        last_atr_pct: 最近一次记录的 ATR 百分比，供早锁盈估算使用。
        active_trades: 当前在市的交易字典，键为 trade_id，值为 ActiveTradeMeta。
    """
    closs: int = 0
    local_loss: float = 0.0
    cooldown_bars_left: int = 0
    last_dir: Optional[str] = None
    last_score: float = 0.0
    last_kind: Optional[str] = None
    last_squad: Optional[str] = None
    last_sl_pct: float = 0.0
    last_tp_pct: float = 0.0
    last_atr_pct: float = 0.0
    last_exit_profile: Optional[str] = None
    last_recipe: Optional[str] = None
    active_trades: Dict[str, ActiveTradeMeta] = field(default_factory=dict)

@dataclass
class TreasuryState:
    """TreasuryState 持久化 fast/slow 拨款结果以及财政周期起点，保证跨周期及崩溃恢复后的拨款连续性。
    Attributes:
        fast_alloc_risk: fast 拨款桶中每个交易对的名义风险额度。
        slow_alloc_risk: slow 拨款桶中每个交易对的名义风险额度。
        cycle_start_tick: 当前财政周期开始时记录的 bar_tick。
        cycle_start_equity: 当前财政周期开始时的权益快照。
    """
    fast_alloc_risk: Dict[str, float] = field(default_factory=dict)
    slow_alloc_risk: Dict[str, float] = field(default_factory=dict)
    cycle_start_tick: int = 0
    cycle_start_equity: float = 0.0

    def to_snapshot(self) -> Dict[str, Any]:
        """导出财政子模块的可序列化快照数据，供 StateStore 写入磁盘。
        Returns:
            Dict[str, Any]: fast/slow 拨款与周期起点组成的字典。
        """
        return {
            "fast_alloc_risk": self.fast_alloc_risk,
            "slow_alloc_risk": self.slow_alloc_risk,
            "cycle_start_tick": self.cycle_start_tick,
            "cycle_start_equity": self.cycle_start_equity,
        }

    def restore_snapshot(self, payload: Dict[str, Any]) -> None:
        """从 StateStore 读取的字典中恢复财政拨款状态。
        Args:
            payload: 包含 fast/slow 拨款及周期起点信息的字典。
        """
        self.fast_alloc_risk = {k: float(v) for k, v in payload.get(
            "fast_alloc_risk", {}).items()}
        self.slow_alloc_risk = {k: float(v) for k, v in payload.get(
            "slow_alloc_risk", {}).items()}
        self.cycle_start_tick = int(payload.get("cycle_start_tick", 0))
        self.cycle_start_equity = float(payload.get("cycle_start_equity", 0.0))

class GlobalState:
    """GlobalState 负责维护 Github_MurphyHoops_freqtradeOne__TaxBrainV29__20260102_094148 的组合级状态，包括债务池、各交易对风险、财政拨款以及 bar_tick 进度。
    该容器为多个代理提供读写接口，并承担快照持久化与恢复的职责。
    """

    def __init__(self, cfg: V29Config) -> None:
        """初始化全局状态容器。
        Args:
            cfg: 当前加载的 V29Config，用于读取 CAP、税率等参数。
        """
        self.cfg = cfg
        self.per_pair: Dict[str, PairState] = {}
        self.debt_pool: float = 0.0
        self.trade_risk_ledger: Dict[str, float] = {}
        self.pair_risk_open: Dict[str, float] = {}
        self.treasury = TreasuryState()
        self.bar_tick: int = 0
        self.current_cycle_ts: Optional[float] = None
        self.last_finalized_bar_ts: Optional[float] = None
        self.reported_pairs_for_current_cycle: set[str] = set()
        self.last_finalize_walltime: float = time.time()

        # === Nominal position ledgers (amount * price, leverage-agnostic) ===
        self.trade_stake_ledger: Dict[str, float] = {}
        self.pair_stake_open: Dict[str, float] = {}

    def _canonical_pair(self, pair: str | None) -> str:
        if not pair:
            return ""
        return str(pair).split(":")[0]

    def get_pair_state(self, pair: str) -> PairState:
        """获取指定交易对的 PairState，如不存在则初始化默认实例。
        Args:
            pair: 交易对名称，例如 'BTC/USDT'。
        Returns:
            PairState: 可供调用方修改的状态对象。
        """
        key = self._canonical_pair(pair)
        if key not in self.per_pair:
            self.per_pair[key] = PairState()
        return self.per_pair[key]

    def get_total_open_risk(self) -> float:
        """汇总所有交易对当前在市风险，用于组合 CAP 控制。
        Returns:
            float: 当前组合层面的风险敞口总和。
        """
        return sum(self.pair_risk_open.values())

    def get_dynamic_portfolio_cap_pct(self, equity: float) -> float:
        """根据当前权益与债务压力计算组合 VaR 上限占比。
        Args:
            equity: 当前账户权益。
        Returns:
            float: 调整后的组合 CAP 百分比；债务率过高或 equity<=0 时会自动折减。
        """
        base = self.cfg.portfolio_cap_pct_base
        if equity <= 0:
            return base * 0.5
        if (self.debt_pool / equity) > self.cfg.drawdown_threshold_pct:
            return base * 0.5
        return base

    def per_pair_cap_room(self, pair: str, equity: float, tier_pol, reserved: float) -> float:
        """计算指定交易对在单票 CAP 约束下剩余可分配的风险空间。
        Args:
            pair: 交易对名称。
            equity: 当前账户权益。
            tier_pol: 对应 CLOSS 等级的 TierPolicy。
            reserved: 当前交易对已预约的风险额度。
        Returns:
            float: 在 CAP 范围内仍可追加的风险额度。
        """
        key = self._canonical_pair(pair)
        cap = tier_pol.per_pair_risk_cap_pct * equity
        used = self.pair_risk_open.get(key, 0.0) + reserved
        return max(0.0, cap - used)

    def record_trade_open(
        self,
        pair: str,
        trade_id: str,
        real_risk: float,
        sl_pct: float,
        tp_pct: float,
        direction: str,
        bucket: str,
        entry_price: float,
        tier_pol,
        exit_profile: Optional[str] = None,
        recipe: Optional[str] = None,
        plan_timeframe: Optional[str] = None,
        plan_atr_pct: Optional[float] = None,
        tier_name: Optional[str] = None,
        stake_nominal: Optional[float] = None,  # nominal position size (amount * price)
    ) -> None:
        """记录新建仓的风险占用，并创建 ActiveTradeMeta 以便执行与退出逻辑后续引用。
        Args:
            pair: 交易对名称。
            trade_id: Freqtrade 分配的交易标识。
            real_risk: 根据止损距离计算出的真实风险敞口。
            sl_pct: 建仓时的止损百分比。
            tp_pct: 建仓时的止盈百分比。
            direction: 方向标识 'long' 或 'short'。
            bucket: 当前订单使用的拨款桶（fast/slow）。
            entry_price: 建仓价格。
            tier_pol: 所属 TierPolicy，用于初始化 ICU 倒计时与冷却时长。
        """
        pst = self.get_pair_state(pair)
        pair_key = self._canonical_pair(pair)
        tier_name = tier_name or (getattr(tier_pol, "name", None) if tier_pol else None)
        # 记录的风险应为含杠杆的实际损失额
        self.trade_risk_ledger[trade_id] = float(real_risk)
        self.pair_risk_open[pair_key] = self.pair_risk_open.get(
            pair_key, 0.0) + float(real_risk)
        
        # === Derive nominal stake from risk/sl_pct and accumulate per-pair exposure ===
        if stake_nominal is None:
            if sl_pct and sl_pct > 0:
                stake_nominal = real_risk / sl_pct
            else:
                stake_nominal = 0.0

        stake_nominal = float(max(stake_nominal or 0.0, 0.0))
        self.trade_stake_ledger[trade_id] = stake_nominal
        self.pair_stake_open[pair_key] = self.pair_stake_open.get(pair_key, 0.0) + stake_nominal

        icu_left = tier_pol.icu_force_exit_bars if tier_pol.icu_force_exit_bars > 0 else None
        pst.active_trades[trade_id] = ActiveTradeMeta(
            sl_pct=float(sl_pct),
            tp_pct=float(tp_pct),
            direction=str(direction),
            entry_bar_tick=self.bar_tick,
            entry_price=float(entry_price),
            bucket=str(bucket),
            icu_bars_left=icu_left,
            exit_profile=exit_profile,
            recipe=recipe,
            plan_timeframe=plan_timeframe,
            plan_atr_pct=float(plan_atr_pct) if plan_atr_pct is not None else None,
            tier_name=tier_name,
        )
        pst.cooldown_bars_left = max(
            pst.cooldown_bars_left, tier_pol.cooldown_bars)

    def record_trade_close(self, pair: str, trade_id: str, profit_abs: float, tier_mgr) -> None:
        """在平仓时回收风险、更新债务池，并依据盈亏调整冷却与连续亏损计数。
        Args:
            pair: 交易对名称。
            trade_id: 交易标识。
            profit_abs: 本次交易的绝对盈亏（正数表示盈利或打平）。
            tier_mgr: TierManager，用于在状态更新后获取新的策略参数。
        """
        pst = self.get_pair_state(pair)
        pair_key = self._canonical_pair(pair)

        # 1) 回收风险账本
        was_risk = self.trade_risk_ledger.pop(trade_id, 0.0)
        self.pair_risk_open[pair_key] = max(
            0.0,
            self.pair_risk_open.get(pair_key, 0.0) - was_risk,
        )
        if self.pair_risk_open.get(pair_key, 0.0) <= 1e-12:
            self.pair_risk_open[pair_key] = 0.0

        # 2) 回收保证金账本
        was_stake = self.trade_stake_ledger.pop(trade_id, 0.0)
        self.pair_stake_open[pair_key] = max(
            0.0,
            self.pair_stake_open.get(pair_key, 0.0) - was_stake,
        )
        if self.pair_stake_open.get(pair_key, 0.0) <= 1e-12:
            self.pair_stake_open[pair_key] = 0.0

        # 3) 从活跃订单中移除
        pst.active_trades.pop(trade_id, None)
        prev_closs = pst.closs

        # 4) 根据 TierRouting / DEFAULT_TIER_ROUTING_MAP 计算最大 closs 等级
        #    这里使用 TierManager 内部已经构造好的 _routing_map，
        #    实际上就是你在 v29_config.DEFAULT_TIER_ROUTING_MAP / tier_routing.loss_tier_map 配的那一份。
        routing_map = getattr(tier_mgr, "_routing_map", None) or {}
        max_closs = max(routing_map.keys()) if routing_map else 3  # 没配置时退回旧行为 0–3

        # 5) 盈利或打平：一律回到 T0，并使用 after_win 冷却
        if profit_abs >= 0:
            tax = profit_abs * self.cfg.tax_rate_on_wins
            # 盈利用于偿还债务池
            self.debt_pool = max(0.0, self.debt_pool - tax)
            # local_loss 也可以适度回收（避免永远挂着历史亏损）
            pst.local_loss = max(0.0, pst.local_loss - profit_abs)
            # if pst.local_loss<=0: # 债务为0的时候,回到原点
            #     pst.closs = 0
            pol = tier_mgr.get(pst.closs)
            pst.cooldown_bars_left = max(
                pst.cooldown_bars_left,
                pol.cooldown_bars_after_win,
            )
            return

        # 6) 亏损：先统一记账，再按 closs 规则路由
        loss = abs(profit_abs)
        pst.local_loss += loss
        self.debt_pool += loss

        if prev_closs >= max_closs:
            # 处于“最后一级”（例如 ICU 最高级）时再亏一次：
            # 按你的要求，这一笔视为“最后一次”，打完必须回到 T0。
            pst.closs = 0
            pol = tier_mgr.get(pst.closs)
            pst.cooldown_bars_left = max(
                pst.cooldown_bars_left,
                pol.cooldown_bars,
            )
        else:
            # 尚未到达最高 closs：按连续亏损 +1 计数。
            # 例如：0→1，1→2，2→3，3→4 ... 直到 max_closs。
            pst.closs = prev_closs + 1
            pol = tier_mgr.get(pst.closs)
            pst.cooldown_bars_left = max(
                pst.cooldown_bars_left,
                pol.cooldown_bars,
            )


    def to_snapshot(self) -> Dict[str, Any]:
        """序列化全局状态为字典，便于持久化或调试。
        Returns:
            Dict[str, Any]: 包含债务池、各交易对状态与财政信息的快照。
        """
        per_pair_snap: Dict[str, Any] = {}
        for pair, pst in self.per_pair.items():
            per_pair_snap[pair] = {
                "closs": pst.closs,
                "local_loss": pst.local_loss,
                "cooldown_bars_left": pst.cooldown_bars_left,
                "last_dir": pst.last_dir,
                "last_score": pst.last_score,
                "last_kind": pst.last_kind,
                "last_squad": pst.last_squad,
                "last_sl_pct": pst.last_sl_pct,
                "last_tp_pct": pst.last_tp_pct,
                "last_atr_pct": pst.last_atr_pct,
                "last_exit_profile": pst.last_exit_profile,
                "last_recipe": pst.last_recipe,
                "active_trades": {
                    tid: {
                        "sl_pct": meta.sl_pct,
                        "tp_pct": meta.tp_pct,
                        "direction": meta.direction,
                        "entry_bar_tick": meta.entry_bar_tick,
                        "entry_price": meta.entry_price,
                        "bucket": meta.bucket,
                        "icu_bars_left": meta.icu_bars_left,
                        "exit_profile": meta.exit_profile,
                        "recipe": meta.recipe,
                    }
                    for tid, meta in pst.active_trades.items()
                },
            }
        return {
            "debt_pool": self.debt_pool,
            "per_pair": per_pair_snap,
            "trade_risk_ledger": self.trade_risk_ledger,
            "pair_risk_open": self.pair_risk_open,
            "treasury": self.treasury.to_snapshot(),
            "bar_tick": self.bar_tick,
            "current_cycle_ts": self.current_cycle_ts,
            "last_finalized_bar_ts": self.last_finalized_bar_ts,
            "last_finalize_walltime": self.last_finalize_walltime,
        }

    def restore_snapshot(self, payload: Dict[str, Any]) -> None:
        """从 `to_snapshot` 生成的字典恢复全局状态，用于断点续跑或崩溃恢复。
        Args:
            payload: 先前保存的状态字典。
        """
        self.debt_pool = float(payload.get("debt_pool", 0.0))
        self.trade_risk_ledger = {k: float(v) for k, v in payload.get(
            "trade_risk_ledger", {}).items()}
        self.pair_risk_open = {k: float(v) for k, v in payload.get(
            "pair_risk_open", {}).items()}
        self.bar_tick = int(payload.get("bar_tick", 0))
        self.current_cycle_ts = payload.get("current_cycle_ts")
        self.last_finalized_bar_ts = payload.get("last_finalized_bar_ts")
        self.last_finalize_walltime = float(
            payload.get("last_finalize_walltime", time.time()))
        self.per_pair = {}
        for pair, pst_payload in payload.get("per_pair", {}).items():
            pst = PairState(
                closs=int(pst_payload.get("closs", 0)),
                local_loss=float(pst_payload.get("local_loss", 0.0)),
                cooldown_bars_left=int(
                    pst_payload.get("cooldown_bars_left", 0)),
                last_dir=pst_payload.get("last_dir"),
                last_score=float(pst_payload.get("last_score", 0.0)),
                last_kind=pst_payload.get("last_kind"),
                last_squad=pst_payload.get("last_squad"),
                last_sl_pct=float(pst_payload.get("last_sl_pct", 0.0)),
                last_tp_pct=float(pst_payload.get("last_tp_pct", 0.0)),
                last_atr_pct=float(pst_payload.get("last_atr_pct", 0.0)),
                last_exit_profile=pst_payload.get("last_exit_profile"),
                last_recipe=pst_payload.get("last_recipe"),
            )
            for tid, meta_payload in pst_payload.get("active_trades", {}).items():
                pst.active_trades[tid] = ActiveTradeMeta(
                    sl_pct=float(meta_payload.get("sl_pct", 0.0)),
                    tp_pct=float(meta_payload.get("tp_pct", 0.0)),
                    direction=str(meta_payload.get("direction", "")),
                    entry_bar_tick=int(meta_payload.get("entry_bar_tick", 0)),
                    entry_price=float(meta_payload.get("entry_price", 0.0)),
                    bucket=str(meta_payload.get("bucket", "slow")),
                    icu_bars_left=(
                        int(meta_payload["icu_bars_left"])
                        if meta_payload.get("icu_bars_left") is not None
                        else None
                    ),
                    exit_profile=meta_payload.get("exit_profile"),
                    recipe=meta_payload.get("recipe"),
                )
            self.per_pair[pair] = pst
        self.treasury.restore_snapshot(payload.get("treasury", {}))

    def reset_cycle_after_restore(self) -> None:
        """在完成 StateStore 恢复后重置周期追踪计数，使后续 finalize 流程重新对齐当前时间。"""
        self.current_cycle_ts = None
        self.reported_pairs_for_current_cycle = set()
        self.last_finalize_walltime = time.time()

class EquityProvider:
    """EquityProvider 封装权益读写接口，供 CycleAgent、SizerAgent 等组件查询或更新账户权益。"""

    def __init__(self, initial_equity: float) -> None:
        """初始化 EquityProvider。
        Args:
            initial_equity: Dry-run 或实盘环境下的初始权益。
        """
        self.equity_current = float(initial_equity)

    def to_snapshot(self) -> Dict[str, float]:
        """导出权益数值快照，便于持久化。
        Returns:
            Dict[str, float]: 仅包含当前权益的字典。
        """
        return {"equity_current": self.equity_current}

    def restore_snapshot(self, payload: Dict[str, Any]) -> None:
        """从快照字典恢复权益数值。
        Args:
            payload: 持久化层读取的权益字典。
        """
        self.equity_current = float(payload.get(
            "equity_current", self.equity_current))

    def get_equity(self) -> float:
        """返回当前记录的权益数值。
        Returns:
            float: 最新权益。
        """
        return self.equity_current

    def on_trade_closed_update(self, profit_abs: float) -> None:
        """在交易结束后，根据绝对盈亏增减权益。
        Args:
            profit_abs: 交易的绝对盈利（亏损为负值）。
        """
        self.equity_current += float(profit_abs)

class Github_MurphyHoops_freqtradeOne__TaxBrainV29__20260102_094148(IStrategy):
    """Github_MurphyHoops_freqtradeOne__TaxBrainV29__20260102_094148 是遵循 AGENTS.md 规范实现的多代理路由策略类。
    该类负责在 Freqtrade 各个 hook 中调度信号、财政、风险、预约、执行等子代理，
    并保持 V29.1 的五项修订（动态 ADX、盈利周期清债、timeframe/startup 覆盖、早锁盈兜底、仅释放预约）持续生效。
    """
    timeframe = V29Config().timeframe
    can_short = True
    startup_candle_count = V29Config().startup_candle_count
    # minimal_roi = {"0": 0.03}
    # stoploss = -0.2
    use_custom_roi = False
    use_custom_stoploss = False
    trailing_stop = False
    use_exit_signal = True
    exit_profit_only = False
    ignore_buy_signals = False
    ignore_sell_signals = True
    _indicator_requirements_map: Dict[Optional[str], Set[str]] = {}

    def __init__(self, config: Dict[str, Any]) -> None:
        """Initialise strategy state, analytics, and persistence layer."""
        base_cfg = V29Config()
        self.cfg = apply_overrides(base_cfg, config.get("strategy_params", {}))
        self.timeframe = self.cfg.timeframe

        self.startup_candle_count = self.cfg.startup_candle_count
        self.stoploss = self.cfg.sizing.enforce_leverage*-0.2
        self.minimal_roi = {"0": 0.50*self.cfg.sizing.enforce_leverage}
        try:
            self.__class__.timeframe = self.cfg.timeframe
            self.__class__.startup_candle_count = self.cfg.startup_candle_count
        except Exception:
            pass
        extra_signal_factors = getattr(self.cfg, "extra_signal_factors", None)
        self._factor_requirements = collect_factor_requirements(
            extra_signal_factors)
        self._indicator_requirements = collect_indicator_requirements(
            extra_signal_factors)
        self.__class__._indicator_requirements_map = self._indicator_requirements
        config_timeframes = tuple(
            getattr(self.cfg, "informative_timeframes", ()))
        inferred_timeframes = tuple(
            tf for tf in self._factor_requirements.keys() if tf)
        self._informative_timeframes = self._derive_informative_timeframes(
            config_timeframes, inferred_timeframes
        )
        self._informative_last: Dict[str, Dict[str, pd.Series]] = {}
        self._informative_cache: Dict[str, Dict[str, pd.DataFrame]] = {}
        self._register_informative_methods()
        super().__init__(config)
        self.tier_mgr = TierManager(self.cfg)
        self.tier_agent = TierAgent()
        user_data_dir = Path(config.get("user_data_dir", "."))
        initial_equity = float(config.get(
            "dry_run_wallet", self.cfg.dry_run_wallet_fallback))
        self.eq_provider = EquityProvider(initial_equity)
        self.state = GlobalState(self.cfg)
        self.analytics = AnalyticsAgent(user_data_dir / "logs")
        self.reservation = ReservationAgent(self.cfg, analytics=self.analytics)
        self.treasury_agent = TreasuryAgent(self.cfg, self.tier_mgr)
        self.risk_agent = RiskAgent(self.cfg, self.reservation, self.tier_mgr)
        self.exit_facade = ExitFacade(self.cfg, self.tier_mgr) if ExitFacade else None
        if self.exit_facade:
            self.exit_facade.attach_strategy(self)
            self.exit_facade.set_dataprovider(getattr(self, "dp", None))
        self.exit_policy = ExitPolicyV29(self.state, self.eq_provider, self.cfg, dp=getattr(self, "dp", None))
        self.exit_policy.set_strategy(self)
        state_file = (user_data_dir / "taxbrain_v29_state.json").resolve()
        # ���봰�岻�� bot_start() �� backtest/hyperopt ҲҪ����������־û�
        self._runmode_token: str = self._compute_runmode_token()
        self._persist_enabled: bool = not self._is_backtest_like_runmode()
        self.persist = StateStore(
            filepath=str(state_file),
            state=self.state,
            eq_provider=self.eq_provider,
            reservation_agent=self.reservation,
        )
        if not self._persist_enabled:
            # backtest/hyperopt ��չʾ���ڴ�״̬��ֹ���� closs �����ݣ�����ְ�� JSON �ļ�
            self.persist = _NoopStateStore()
        try:
            self._tier_debug(
                f"init runmode_token={self._runmode_token or 'unknown'} backtest_like={self._is_backtest_like_runmode()} persist_enabled={self._persist_enabled}"
            )
        except Exception:
            pass
        self.cycle_agent = CycleAgent(
            cfg=self.cfg,
            state=self.state,
            reservation=self.reservation,
            treasury=self.treasury_agent,
            risk=self.risk_agent,
            analytics=self.analytics,
            persist=self.persist,
            tier_mgr=self.tier_mgr,
        )
        self.sizer = SizerAgent(
            self.state, self.reservation, self.eq_provider, self.cfg, self.tier_mgr)
        self.execution = ExecutionAgent(
            self.state, self.reservation, self.eq_provider, self.cfg)
        self._last_signal: Dict[str, Optional[schemas.Candidate]] = {}
        self._pending_entry_meta: Dict[str, Dict[str, Any]] = {}
        self._tf_sec = self._tf_to_sec(self.cfg.timeframe)
        self._candidate_pool_limit = int(
            max(1, getattr(self.cfg, "candidate_pool_max_per_side", 4))
        )

    def set_dataprovider(self, dp):
        """
        Freqtrade 在策略实例创建后，会回调此方法注入 DataProvider。
        这里顺便把 dp 传给 exit_policy，供 Router 的 Immediate/Vector/Threshold 规则使用。
        """

        # 调用父类实现（如果父类没有该方法也不会出错）
        try:
            super().set_dataprovider(dp)
        except AttributeError:
            pass

        # 桥接给策略本身与退出策略
        self.dp = dp
        if hasattr(self, "exit_policy") and self.exit_policy is not None:
            self.exit_policy.dp = dp
        if self.exit_facade:
            self.exit_facade.set_dataprovider(dp)

    @staticmethod
    def _tf_to_sec(tf: str) -> int:
        """将 Freqtrade 的 timeframe 字符串转换为秒数，用于 CycleAgent 的节奏控制。
        Args:
            tf: 形如 '5m'、'1h' 的时间粒度。
        Returns:
            int: 对应的秒数，未识别时回退到 300 秒。
        """
        if tf.endswith("m"):
            return int(tf[:-1]) * 60
        if tf.endswith("h"):
            return int(tf[:-1]) * 3600
        if tf.endswith("d"):
            return int(tf[:-1]) * 86400
        return 300

    def _tier_debug(self, message: str) -> None:
        """Append lightweight tier debug info into user_data/logs/tier_debug.log."""
        try:
            user_dir = Path(self.config.get("user_data_dir", "user_data"))
            log_path = user_dir / "logs" / "tier_debug.log"
            log_path.parent.mkdir(parents=True, exist_ok=True)
            ts = datetime.utcnow().isoformat()
            with open(log_path, "a", encoding="utf-8") as handle:
                handle.write(f"{ts} {message}\n")
        except Exception:
            pass

    def _compute_runmode_token(self) -> str:
        cfg_mode = self.config.get("runmode") if hasattr(
            self, "config") else None
        if cfg_mode is not None:
            return str(getattr(cfg_mode, "value", cfg_mode)).lower()
        strategy_mode = getattr(self, "runmode", None)
        if strategy_mode is not None:
            return str(getattr(strategy_mode, "value", strategy_mode)).lower()
        dp_mode = getattr(getattr(self, "dp", None), "runmode", None)
        if dp_mode is not None:
            return str(getattr(dp_mode, "value", dp_mode)).lower()
        return ""

    def _ensure_runmode_token(self) -> str:
        if not self._runmode_token:
            self._runmode_token = self._compute_runmode_token()
        return self._runmode_token

    def _is_backtest_like_runmode(self) -> bool:
        token = self._ensure_runmode_token() or ""
        return any(key in token for key in ("backtest", "hyperopt"))

    def _derive_informative_timeframes(
        self, manual: tuple[str, ...], inferred: tuple[str, ...]
    ) -> tuple[str, ...]:
        """  """
        ordered: list[str] = []
        for tf in (*manual, *inferred):
            if not tf or tf == self.timeframe:
                continue
            normalized = str(tf)
            if normalized not in ordered:
                ordered.append(normalized)
        return tuple(ordered)

    @staticmethod
    def _timeframe_suffix_token(timeframe: Optional[str]) -> str:
        token = (timeframe or "").strip()
        return token.replace("/", "_") if token else ""

    def _prepare_informative_frame(self, frame: Optional[pd.DataFrame], timeframe: str) -> Optional[pd.DataFrame]:
        if frame is None or frame.empty:
            return frame
        suffix = self._timeframe_suffix_token(timeframe)
        if not suffix:
            return frame
        rename_map: dict[str, str] = {}
        for col in list(frame.columns):
            if col == "date":
                continue
            if col.endswith(f"_{suffix}"):
                continue
            rename_map[col] = f"{col}_{suffix}"
        if rename_map:
            frame = frame.rename(columns=rename_map)
        return frame

    def _register_informative_methods(self) -> None:
        if not self._informative_timeframes:
            return
        for tf in self._informative_timeframes:
            self.__class__._ensure_informative_method(tf)

    @classmethod
    def _ensure_informative_method(cls, timeframe: str) -> None:
        func_name = f"populate_indicators_{timeframe.replace('/', '_')}"
        if hasattr(cls, func_name):
            return

        @informative(timeframe)
        def _informative_populator(self, dataframe, metadata, tf=timeframe):
            needs = getattr(
                self.__class__, "_indicator_requirements_map", {}).get(tf)
            return indicators.compute_indicators(
                dataframe,
                self.cfg,
                suffix=None,
                required=needs or set(),
                duplicate_ohlc=True,    # 保留原始 OHLC（列名保持 open/close/volume）
            )
        _informative_populator.__name__ = func_name
        setattr(cls, func_name, _informative_populator)

    def _get_informative_dataframe(self, pair: str, timeframe: str) -> Optional[pd.DataFrame]:
        cached = self._informative_cache.get(pair, {}).get(timeframe)
        if cached is not None and not cached.empty:
            return cached
        getter = getattr(self.dp, 'get_informative_dataframe', None)
        if callable(getter):
            result = getter(pair, timeframe)
            if isinstance(result, tuple):
                result = result[0]
            if isinstance(result, pd.DataFrame) and not result.empty:
                prepared = self._prepare_informative_frame(result.copy(), timeframe)
                self._informative_cache.setdefault(pair, {})[timeframe] = prepared
                return prepared
            return self._prepare_informative_frame(result, timeframe)
        result = self.dp.get_analyzed_dataframe(pair, timeframe)
        if isinstance(result, tuple):
            result = result[0]
        if isinstance(result, pd.DataFrame) and not result.empty:
            prepared = self._prepare_informative_frame(result.copy(), timeframe)
            self._informative_cache.setdefault(pair, {})[timeframe] = prepared
            return prepared
        return self._prepare_informative_frame(result, timeframe)

    def informative_pairs(self):
        if not self._informative_timeframes:
            return []
        try:
            whitelist = self.dp.current_whitelist()
        except Exception:
            whitelist = self.config.get(
                "exchange", {}).get("pair_whitelist", [])
        pairs: list[tuple[str, str]] = []
        if not whitelist:
            return pairs
        seen: set[tuple[str, str]] = set()
        for pair in whitelist:
            for tf in self._informative_timeframes:
                key = (pair, tf)
                if key not in seen:
                    seen.add(key)
                    pairs.append(key)
        return pairs

    def bot_start(self, **kwargs) -> None:
        """ """
        self._runmode_token = self._compute_runmode_token()
        backtest_like = self._is_backtest_like_runmode()
        self._persist_enabled = not backtest_like
        try:
            self.logger.info(
                f"[tier] runmode={self._runmode_token or 'unknown'} backtest_like={backtest_like} persist={'on' if self._persist_enabled else 'off'}"
            )
        except Exception:
            pass
        if self._persist_enabled:
            self.persist.load_if_exists()
        else:
            self.persist = _NoopStateStore()
            self.cycle_agent.persist = self.persist
        if self.state.treasury.cycle_start_tick == 0:
            self.state.treasury.cycle_start_tick = self.state.bar_tick
            self.state.treasury.cycle_start_equity = self.eq_provider.get_equity()

    def populate_indicators(self, df: pd.DataFrame, metadata: dict) -> pd.DataFrame:
        """计算指标、生成候选信号并触发周期 finalize。
        Args:
            df: 原始 K 线数据。
            metadata: Freqtrade 提供的上下文字典，至少包含 pair。
        Returns:
            pd.DataFrame: 附加指标列后的数据帧。
        Notes:
            当 cfg.adx_len != 14 时会记录动态列名日志，对应 V29.1 修订 #1。
        """
        pair = metadata["pair"]
        print(">>> base_needs:", self._indicator_requirements.get(None))
        base_needs = self._indicator_requirements.get(None)
        df = indicators.compute_indicators(df, self.cfg, required=base_needs)
        informative_rows: Dict[str, pd.Series] = {}
        if self._informative_timeframes:
            for tf in self._informative_timeframes:
                try:
                    info_df = self._get_informative_dataframe(pair, tf)
                except Exception:
                    continue
                if info_df is None or info_df.empty:
                    continue
                informative_rows[tf] = info_df.iloc[-1].copy()
                cache = self._informative_cache.setdefault(pair, {})
                cache[tf] = info_df.copy()
        if informative_rows:
            self._informative_last[pair] = informative_rows
        elif pair in self._informative_last:
            self._informative_last.pop(pair, None)
            self._informative_cache.pop(pair, None)
        if len(df) == 0:
            return df
        last = df.iloc[-1].copy()
        last_ts = float(pd.to_datetime(
            last.get("date", pd.Timestamp.utcnow())).timestamp())
        try:
            whitelist = self.dp.current_whitelist()
        except Exception:
            whitelist = [pair]
        self.cycle_agent.maybe_finalize(
            pair=pair,
            bar_ts=last_ts,
            whitelist=whitelist,
            timeframe_sec=self._tf_sec,
            eq_provider=self.eq_provider,
        )
        return df

    def get_informative_row(self, pair: str, timeframe: str) -> Optional[pd.Series]:
        """"""
        return self._informative_last.get(pair, {}).get(timeframe)

    def get_informative_value(
        self, pair: str, timeframe: str, column: str, default: Optional[float] = None
    ) -> Optional[float]:
        """"""
        row = self.get_informative_row(pair, timeframe)
        if row is None:
            return default
        try:
            if column in row:
                return row[column]
        except Exception:
            pass
        suffix = self._timeframe_suffix_token(timeframe)
        if suffix:
            alt_col = f"{column}_{suffix}"
            try:
                if alt_col in row:
                    return row[alt_col]
            except Exception:
                pass
        try:
            return row.get(column, default)  # pandas Series get fallback
        except Exception:
            return default

    def _eval_entry_on_row(self,
                           row: pd.Series,
                           inf_rows: Optional[Dict[str, pd.Series]],
                           pst_state) -> Optional[schemas.Candidate]:
        """
        ??????????K??????????? Candidate
        """
        candidates = builder.build_candidates(
            row, self.cfg, informative=inf_rows)
        policy = self.tier_mgr.get(pst_state.closs)
        return self.tier_agent.filter_best(policy, candidates)

    def _candidate_with_plan(
        self,
        pair: str,
        candidate: Optional[schemas.Candidate],
        row: pd.Series,
        inf_rows: Optional[Dict[str, pd.Series]],
    ) -> Optional[schemas.Candidate]:
        if not candidate or not getattr(self, "exit_facade", None):
            return candidate
        try:
            _, _, plan = self.exit_facade.resolve_entry_plan(pair, candidate, row, inf_rows or {})
        except Exception:
            plan = None
        if not plan:
            return candidate
        try:
            return replace(
                candidate,
                sl_pct=float(plan.sl_pct) if getattr(plan, "sl_pct", None) else candidate.sl_pct,
                tp_pct=float(plan.tp_pct) if getattr(plan, "tp_pct", None) else candidate.tp_pct,
                plan_timeframe=getattr(plan, "timeframe", getattr(candidate, "plan_timeframe", None)),
                plan_atr_pct=getattr(plan, "atr_pct", getattr(candidate, "plan_atr_pct", None)),
            )
        except Exception:
            return candidate

    def _aligned_informative_for_df(self, pair: str, df: pd.DataFrame) -> Dict[str, pd.DataFrame]:
        """一次性把所有 informative timeframe merge_asof 到主周期索引，避免每行重复合并。"""
        out = {}
        for tf in getattr(self, "_informative_timeframes", []):
            info_df = self._get_informative_dataframe(pair, tf)
            if info_df is None or info_df.empty:
                continue
            left = pd.DataFrame({"_t": pd.to_datetime(
                df["date"]) if "date" in df.columns else pd.to_datetime(df.index)})
            right = info_df.copy()
            right["_tinfo"] = pd.to_datetime(
                right["date"]) if "date" in right.columns else pd.to_datetime(right.index)
            merged = pd.merge_asof(left.sort_values("_t"),
                                   right.sort_values("_tinfo"),
                                   left_on="_t", right_on="_tinfo",
                                   direction="backward")
            merged.index = df.index
            out[tf] = merged
        return out

    def _informative_rows_for_index(self, aligned_info: Dict[str, pd.DataFrame], idx) -> Dict[str, pd.Series]:
        """"""
        rows: Dict[str, pd.Series] = {}
        for tf, frame in aligned_info.items():
            try:
                rows[tf] = frame.loc[idx]
            except Exception:
                continue
        return rows

    def _candidate_to_payload(self, candidate: schemas.Candidate) -> dict[str, Any]:
        return {
            "direction": candidate.direction,
            "kind": candidate.kind,
            "raw_score": candidate.raw_score,
            "rr_ratio": candidate.rr_ratio,
            "expected_edge": candidate.expected_edge,
            "win_prob": candidate.win_prob,
            "squad": candidate.squad,
            "sl_pct": candidate.sl_pct,
            "tp_pct": candidate.tp_pct,
            "exit_profile": candidate.exit_profile,
            "recipe": candidate.recipe,
            "plan_timeframe": getattr(candidate, "plan_timeframe", None),
            "plan_atr_pct": getattr(candidate, "plan_atr_pct", None),
        }

    def _candidate_allowed_by_policy(self, policy, candidate: Optional[schemas.Candidate]) -> bool:
        if not policy or not candidate:
            return False
        if not policy.permits(kind=candidate.kind, squad=candidate.squad, recipe=candidate.recipe):
            return False
        if candidate.raw_score < policy.min_raw_score:
            return False
        if candidate.rr_ratio < policy.min_rr_ratio:
            return False
        if candidate.expected_edge < policy.min_edge:
            return False
        return True

    def _candidate_allowed_any_tier(self, candidate: Optional[schemas.Candidate]) -> bool:
        if not candidate:
            return False
        iter_policies = getattr(self.tier_mgr, "policies", None)
        policies = iter_policies() if callable(iter_policies) else ()
        if not policies:
            policies = (self.tier_mgr.get(0),)
        for policy in policies:
            if self._candidate_allowed_by_policy(policy, candidate):
                return True
        return False

    def _group_candidates_by_direction(
        self, candidates: list[schemas.Candidate]
    ) -> dict[str, list[schemas.Candidate]]:
        grouped: dict[str, list[schemas.Candidate]] = {"long": [], "short": []}
        for cand in candidates:
            grouped.setdefault(cand.direction, []).append(cand)
        return grouped

    def _trim_candidate_pool(
        self, grouped: dict[str, list[schemas.Candidate]]
    ) -> dict[str, list[schemas.Candidate]]:
        limited: dict[str, list[schemas.Candidate]] = {"long": [], "short": []}
        for direction, items in grouped.items():
            ordered = sorted(items, key=lambda c: (c.expected_edge, c.raw_score), reverse=True)
            limited[direction] = ordered[: self._candidate_pool_limit]
        return limited

    def _apply_entry_signal(
        self, df: pd.DataFrame, idx, candidates_by_dir: dict[str, list[schemas.Candidate]]
    ) -> None:
        has_long = bool(candidates_by_dir.get("long"))
        has_short = bool(candidates_by_dir.get("short"))
        df.at[idx, "enter_long"] = 1 if has_long else 0
        df.at[idx, "enter_short"] = 1 if has_short else 0
        if not has_long and not has_short:
            df.at[idx, "enter_tag"] = None
            return
        payload = {
            "version": 2,
            "candidates": {
                "long": [
                    self._candidate_to_payload(cand) for cand in candidates_by_dir.get("long", [])
                ],
                "short": [
                    self._candidate_to_payload(cand) for cand in candidates_by_dir.get("short", [])
                ],
            },
        }
        df.at[idx, "enter_tag"] = json.dumps(payload)

    def _update_last_signal(self, pair: str, candidate: Optional[schemas.Candidate], row: pd.Series) -> None:
        pst = self.state.get_pair_state(pair)
        try:
            pst.last_atr_pct = float(row.get("atr_pct", 0.0) or 0.0)
        except Exception:
            pst.last_atr_pct = 0.0
        if candidate:
            pst.last_dir = candidate.direction
            pst.last_kind = candidate.kind                           # ← 信号名（mean_rev_long / trend_short / …）
            pst.last_squad = getattr(candidate, "squad", None)       # ← 所属 squad（MRL / PBL / TRS）
            pst.last_score = candidate.expected_edge
            pst.last_sl_pct = candidate.sl_pct
            pst.last_tp_pct = candidate.tp_pct
            pst.last_exit_profile = candidate.exit_profile
            pst.last_recipe = candidate.recipe
        else:
            pst.last_dir = None
            pst.last_squad = None
            pst.last_score = 0.0
            pst.last_sl_pct = 0.0
            pst.last_tp_pct = 0.0
            pst.last_exit_profile = None
            pst.last_recipe = None
        self._last_signal[pair] = candidate

    def populate_entry_trend(self, df: pd.DataFrame, metadata: dict) -> pd.DataFrame:
        pair = metadata["pair"]
        if "BNB" in pair:
            print("BNB")
        df["enter_long"] = 0
        df["enter_short"] = 0
        df["enter_tag"] = None
        aligned_info = self._aligned_informative_for_df(pair, df)
        runmode = getattr(self.dp, "runmode", None)
        is_vector_pass = (RunMode and runmode in {
                          RunMode.BACKTEST, RunMode.HYPEROPT, RunMode.PLOT})
        pst_snapshot = copy.deepcopy(self.state.get_pair_state(pair))

        if is_vector_pass:
            for idx in df.index:
                row = df.loc[idx].copy()
                row["LOSS_TIER_STATE"] = pst_snapshot.closs
                inf_rows = self._informative_rows_for_index(aligned_info, idx)
                raw_candidates = builder.build_candidates(row, self.cfg, informative=inf_rows)
                if not raw_candidates:
                    continue
                planned: list[schemas.Candidate] = []
                for candidate in raw_candidates:
                    cand_with_plan = self._candidate_with_plan(pair, candidate, row, inf_rows)
                    if cand_with_plan:
                        planned.append(cand_with_plan)
                if not planned:
                    continue
                if is_vector_pass:
                    planned = [
                        cand for cand in planned
                        if self._candidate_allowed_any_tier(cand)
                    ]
                    if not planned:
                        continue
                grouped = self._trim_candidate_pool(self._group_candidates_by_direction(planned))
                if not grouped.get("long") and not grouped.get("short"):
                    continue
                self._apply_entry_signal(df, idx, grouped)

                # pst_snapshot = advance_state(pst_snapshot, sig, row)

        actual_state = self.state.get_pair_state(pair)
        last_idx = df.index[-1]
        last_row = df.loc[last_idx].copy()
        last_row["LOSS_TIER_STATE"] = actual_state.closs
        last_inf_rows = self._informative_rows_for_index(aligned_info, last_idx)
        last_candidate = self._eval_entry_on_row(
            last_row, last_inf_rows, actual_state)
        if last_candidate:
            last_candidate = self._candidate_with_plan(pair, last_candidate, last_row, last_inf_rows)
        if not is_vector_pass and last_candidate:
            grouped = self._trim_candidate_pool(
                self._group_candidates_by_direction([last_candidate])
            )
            self._apply_entry_signal(df, last_idx, grouped)
        self._update_last_signal(pair, last_candidate, last_row)
        return df

    def populate_exit_trend(self, df: pd.DataFrame, metadata: dict) -> pd.DataFrame:
        """
        通过 Exit Router 的 VectorExit 渠道应用“可插拔退出规则”。
        - 规则可以直接改 df 并返回 df，或返回一个包含列的 dict（exit_long/exit_short/exit_tag）
        - Router 负责 OR 聚合、exit_tag 覆盖策略（按优先级）
        """

        # 兜底：Freqtrade 要求存在列
        if "exit_long" not in df.columns:
            df["exit_long"] = 0
        if "exit_short" not in df.columns:
            df["exit_short"] = 0
        if "exit_tag" not in df.columns:
            df["exit_tag"] = None
        meta = {
            "pair": metadata.get("pair"),
            "timeframe": getattr(self, "timeframe", None) or self.timeframe,
        }

        # 交给 Router 聚合所有 vector exit 规则
        try:
            if self.exit_facade:
                df = self.exit_facade.apply_vector(
                    df=df,
                    metadata=meta,
                    state=self.state,
                    timeframe_col=None,
                )
            elif EXIT_ROUTER is not None:
                df = EXIT_ROUTER.apply_vector_exits(
                    df=df,
                    metadata=meta,
                    dp=self.dp,
                    cfg=self.cfg,
                    state=self.state,
                    timeframe_col=None,
                )
        except Exception:
            # 安全兜底：如果外部规则出错，不阻断主流程
            pass
        return df

    def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float,
                            time_in_force: str, current_time: datetime,
                            entry_tag: str | None, side: str, **kwargs) -> bool:
        runmode_cfg = self.config.get("runmode", None)
        runmode_token = str(
            getattr(runmode_cfg, "value", runmode_cfg) or "").lower()
        dp_runmode = getattr(self.dp, "runmode", None)
        dp_mode_token = str(
            getattr(dp_runmode, "value", dp_runmode) or "").lower()
        backtest_modes = {"runmode.backtest",
                          "backtest", "hyperopt", "runmode.hyperopt"}
        is_backtest_mode = any(
            token in backtest_modes for token in (runmode_token, dp_mode_token) if token)
        pst = self.state.get_pair_state(pair)
        tier_pol = self.tier_mgr.get(pst.closs)
        try:
            self._tier_debug(
                f"cte called pair={pair} runmode_token={runmode_token} dp_token={dp_mode_token} backtest={is_backtest_mode} closs={pst.closs}"
            )
        except Exception:
            pass
        if is_backtest_mode and pst.cooldown_bars_left > 0:
            try:
                now_ts = float(current_time.timestamp())
            except Exception:
                now_ts = None
            if now_ts is not None:
                last_ts = getattr(pst, "_cooldown_last_ts", None)
                if last_ts is None:
                    pst._cooldown_last_ts = now_ts
                else:
                    bars_elapsed = int((now_ts - last_ts) // self._tf_sec)
                    if bars_elapsed > 0:
                        pst.cooldown_bars_left = max(
                            0, pst.cooldown_bars_left - bars_elapsed)
                        pst._cooldown_last_ts = now_ts
        # 冷却期一律禁止开新仓
        # print(f"pst.closs_{pst.closs}")
        if pst.cooldown_bars_left > 0:
            return False

        # 若当前 tier 配置了 single_position_only，则有仓位时禁止再开
        if tier_pol and getattr(tier_pol, "single_position_only", False) and pst.active_trades:
            return False

        # 实盘 / 干跑：仍用“最新一拍”的 _last_signal 语义
        requested_dir = "long" if side.lower() in ("buy", "long") else "short"
        payload = None
        candidate_from_tag: Optional[schemas.Candidate] = None
        if entry_tag:
            try:
                payload = json.loads(entry_tag)
            except Exception:
                payload = None
            if isinstance(payload, dict):
                candidate_from_tag = self._resolve_candidate_from_tag(pair, payload, side)
            elif payload is not None:
                normalized = self._normalize_entry_meta(payload, side)
                if normalized and pair not in self._pending_entry_meta:
                    self._pending_entry_meta[pair] = normalized

        if is_backtest_mode:
            try:
                self._tier_debug(
                    f"cte precheck pair={pair} candidate_present={bool(candidate_from_tag)} entry_tag_present={bool(entry_tag)}"
                )
                if not candidate_from_tag:
                    tier_name = getattr(tier_pol, "name", None) if tier_pol else None
                    try:
                        self.logger.info(
                            f"[tier] bt-entry-skip pair={pair} closs={pst.closs} tier={tier_name} reason=no_candidate"
                        )
                    except Exception:
                        pass
                    self._tier_debug(
                        f"bt-entry-skip pair={pair} closs={pst.closs} tier={tier_name} reason=no_candidate"
                    )
                    return False
                if pair not in self._pending_entry_meta:
                    self._pending_entry_meta[pair] = self._candidate_meta_from_candidate(candidate_from_tag)
                tier_name = getattr(tier_pol, "name", None) if tier_pol else None
                try:
                    self.logger.info(
                        f"[tier] bt-entry pair={pair} closs={pst.closs} tier={tier_name} recipe={getattr(candidate_from_tag, 'recipe', None)} kind={getattr(candidate_from_tag, 'kind', None)} sl={getattr(candidate_from_tag, 'sl_pct', None)} tp={getattr(candidate_from_tag, 'tp_pct', None)}"
                    )
                except Exception:
                    pass
                self._tier_debug(
                    f"bt-entry pair={pair} closs={pst.closs} tier={tier_name} recipe={getattr(candidate_from_tag, 'recipe', None)} kind={getattr(candidate_from_tag, 'kind', None)} sl={getattr(candidate_from_tag, 'sl_pct', None)} tp={getattr(candidate_from_tag, 'tp_pct', None)}"
                )
                return True
            except Exception as exc:
                self._tier_debug(f"bt-entry-error pair={pair} err={exc}")
                return False

        sig = self._last_signal.get(pair)
        if not sig or sig.direction != requested_dir:
            return False
        self._pending_entry_meta[pair] = {
            "sl_pct": sig.sl_pct,
            "tp_pct": sig.tp_pct,
            "dir": sig.direction,
            "exit_profile": sig.exit_profile,
            "recipe": sig.recipe,
            "plan_timeframe": getattr(sig, "plan_timeframe", None),
            "atr_pct": getattr(sig, "plan_atr_pct", None),
        }
        return True

    @staticmethod
    def _dir_from_side(side: str) -> str:
        return "long" if str(side).lower() in ("buy", "long") else "short"

    def _candidate_meta_from_candidate(self, candidate: schemas.Candidate) -> dict[str, Any]:
        return {
            "dir": candidate.direction,
            "sl_pct": candidate.sl_pct,
            "tp_pct": candidate.tp_pct,
            "exit_profile": candidate.exit_profile,
            "recipe": candidate.recipe,
            "plan_timeframe": getattr(candidate, "plan_timeframe", None),
            "atr_pct": getattr(candidate, "plan_atr_pct", None),
        }

    def _normalize_entry_meta(self, meta: Optional[Dict[str, Any]], side: str) -> Optional[Dict[str, Any]]:
        """  """
        if meta is None:
            return None
        normalized = dict(meta)
        sl_val = normalized.get('sl_pct')
        if sl_val is None and 'sl' in normalized:
            sl_val = normalized.get('sl')
        try:
            normalized['sl_pct'] = float(sl_val) if sl_val is not None else 0.0
        except Exception:
            normalized['sl_pct'] = 0.0
        tp_val = normalized.get('tp_pct')
        if tp_val is None and 'tp' in normalized:
            tp_val = normalized.get('tp')
        try:
            normalized['tp_pct'] = float(tp_val) if tp_val is not None else 0.0
        except Exception:
            normalized['tp_pct'] = 0.0
        direction = normalized.get('dir')
        if not direction:
            direction = self._dir_from_side(side)
        normalized['dir'] = direction
        if 'plan_timeframe' in normalized:
            ptf = normalized.get('plan_timeframe')
            if ptf is None:
                normalized['plan_timeframe'] = None
            else:
                token = str(ptf).strip()
                normalized['plan_timeframe'] = token or None
        atr_val = normalized.get('atr_pct')
        if atr_val is not None:
            try:
                normalized['atr_pct'] = float(atr_val)
            except Exception:
                normalized['atr_pct'] = None
        else:
            normalized['atr_pct'] = None
        return normalized

    def _resolve_candidate_from_tag(
        self, pair: str, payload: Dict[str, Any], side: str
    ) -> Optional[schemas.Candidate]:
        candidates_pool = payload.get("candidates")
        if not isinstance(candidates_pool, dict):
            return None
        direction = self._dir_from_side(side)
        raw_list = candidates_pool.get(direction) or []
        if not raw_list:
            return None
        hydrated: list[schemas.Candidate] = []
        for raw in raw_list:
            try:
                plan_atr_val = raw.get("plan_atr_pct")
                plan_atr = None
                if plan_atr_val not in (None, ""):
                    plan_atr = float(plan_atr_val)
                hydrated.append(
                    schemas.Candidate(
                        direction=direction,
                        kind=str(raw["kind"]),
                        raw_score=float(raw["raw_score"]),
                        rr_ratio=float(raw["rr_ratio"]),
                        win_prob=float(raw.get("win_prob", 0.0)),
                        expected_edge=float(raw["expected_edge"]),
                        squad=str(raw["squad"]),
                        sl_pct=float(raw["sl_pct"]),
                        tp_pct=float(raw["tp_pct"]),
                        exit_profile=raw.get("exit_profile"),
                        recipe=raw.get("recipe"),
                        plan_timeframe=raw.get("plan_timeframe"),
                        plan_atr_pct=plan_atr,
                    )
                )
            except (KeyError, TypeError, ValueError):
                continue
        if not hydrated:
            return None
        pst = self.state.get_pair_state(pair)
        tier_pol = self.tier_mgr.get(pst.closs)
        best = self.tier_agent.filter_best(tier_pol, hydrated)
        if best is None:
            try:
                tier_name = getattr(tier_pol, "name", None) if tier_pol else None
                recipes = [getattr(h, "recipe", None) for h in hydrated]
                kinds = [getattr(h, "kind", None) for h in hydrated]
                self.logger.info(
                    f"[tier] reject_by_tier pair={pair} closs={pst.closs} tier={tier_name} recipes={recipes} kinds={kinds}"
                )
                self._tier_debug(
                    f"reject_by_tier pair={pair} closs={pst.closs} tier={tier_name} recipes={recipes} kinds={kinds}"
                )
            except Exception:
                pass
        return best

    def _compute_profit_abs(self, trade, rate: float) -> float:
        """Compute absolute PnL for a trade, falling back to a simple delta calc."""
        try:
            for attr in ("close_profit_abs", "profit_abs"):
                val = getattr(trade, attr, None)
                if val is not None:
                    return float(val)
        except Exception:
            pass
        try:
            open_rate = float(getattr(trade, "open_rate", rate) or rate)
            amount = float(getattr(trade, "amount", 0.0) or 0.0)
            direction = -1.0 if getattr(trade, "is_short", False) else 1.0
            return direction * (float(rate) - open_rate) * amount
        except Exception:
            return 0.0

    def confirm_trade_exit(
        self,
        pair: str,
        trade,
        order_type: str,
        amount: float,
        rate: float,
        time_in_force: str,
        exit_reason: str,
        current_time: datetime | None = None,
        **kwargs,
    ) -> bool:
        """Ensure backtests/hyperopts update tier state on exits."""

        backtest_like = self._is_backtest_like_runmode()
        if backtest_like and self.tier_mgr:
            trade_id = str(getattr(trade, "trade_id", getattr(trade, "id", "NA")))
            processed = getattr(self, "_bt_closed_trades", None)
            if processed is None:
                processed = set()
                self._bt_closed_trades = processed
            if trade_id not in processed:
                profit_abs = self._compute_profit_abs(trade, rate)
                try:
                    self._tier_debug(
                        f"ctx called pair={pair} id={trade_id} pnl={profit_abs}"
                    )
                    before = self.state.get_pair_state(pair).closs
                    self.state.record_trade_close(pair, trade_id, profit_abs, self.tier_mgr)
                    after = self.state.get_pair_state(pair).closs
                    try:
                        self.logger.info(
                            f"[tier] bt-exit {pair} id={trade_id} pnl={profit_abs:.4f} closs {before}->{after}"
                        )
                        self._tier_debug(
                            f"bt-exit pair={pair} id={trade_id} pnl={profit_abs:.4f} closs {before}->{after}"
                        )
                    except Exception:
                        pass
                except Exception as exc:
                    try:
                        self.logger.warning(
                            f"[tier] failed to record backtest exit {trade_id} {pair}: {exc}"
                        )
                    except Exception:
                        pass
                processed.add(trade_id)
        return True

    def custom_stake_amount(
        self,
        pair: str,
        current_time,
        current_rate: float,
        proposed_stake: float,
        min_stake: float | None,
        max_stake: float,
        leverage: float,
        entry_tag: str | None,
        side: str,
        **kwargs,
    ) -> float:
        """Compute margin stake for Freqtrade while keeping nominal sizing internal.

        self.sizer.compute(ctx) returns:
            - stake_margin: amount passed back to Freqtrade as stake_amount (margin)
            - real_risk: max loss (USDT) at current stop based on the nominal position

        When recording trades via state.record_trade_open(), always use nominal position sizes;
        do not confuse stake_margin with any *_nominal field.
        """
        meta: Optional[Dict[str, Any]] = None
        if entry_tag:
            try:
                parsed = json.loads(entry_tag)
            except Exception:
                parsed = None
            if isinstance(parsed, dict) and "candidates" in parsed:
                selected = self._resolve_candidate_from_tag(pair, parsed, side)
                if selected:
                    meta = self._normalize_entry_meta(
                        self._candidate_meta_from_candidate(selected), side
                    )
            elif parsed is not None:
                meta = self._normalize_entry_meta(parsed, side)

        if meta is None:
            cached = self._pending_entry_meta.get(pair)
            if cached is not None:
                meta = self._normalize_entry_meta(cached, side)
            else:
                sig = self._last_signal.get(pair)
                if sig:
                    meta = self._normalize_entry_meta({
                        'sl_pct': float(sig.sl_pct),
                        'tp_pct': float(sig.tp_pct),
                        'dir': sig.direction,
                        'exit_profile': sig.exit_profile,
                        'recipe': sig.recipe,
                        'plan_timeframe': getattr(sig, "plan_timeframe", None),
                        'atr_pct': getattr(sig, "plan_atr_pct", None),
                    }, side)
        if meta is None:
            return 0.0
        sl = float(meta.get('sl_pct', 0.0))
        tp = float(meta.get('tp_pct', 0.0))
        direction = str(meta.get('dir'))

        stake, risk, bucket = self.sizer.compute(
            pair=pair,
            sl_pct=sl,
            tp_pct=tp,
            direction=direction,
            proposed_stake=proposed_stake,
            min_stake=min_stake,
            max_stake=max_stake,
            leverage=leverage,
            plan_atr_pct=meta.get("atr_pct"),
            exit_profile=meta.get("exit_profile"),
            bucket=meta.get("bucket"),
        )
        if stake <= 0 or risk <= 0:
            return 0.0
        
        # In backtests/hyperopt there is no asynchronous order lifecycle, so skip
        # reservation bookkeeping to avoid leaking reserved_risk and starving sizing.
        if self._is_backtest_like_runmode():
            meta = meta or {}
            meta.update(
                {
                    "sl_pct": sl,
                    "tp_pct": tp,
                    "stake_final": stake,
                    "risk_final": risk,
                    "bucket": bucket,
                    "entry_price": current_rate,
                    "dir": direction,
                }
            )
            self._pending_entry_meta[pair] = meta
            return float(stake)

        rid = f"{pair}:{bucket}:{uuid.uuid4().hex}"
        self.reservation.reserve(pair, rid, risk, bucket)

        meta.update({
            'sl_pct': sl,
            'tp_pct': tp,
            'stake_final': stake,
            'risk_final': risk,
            'reservation_id': rid,
            'bucket': bucket,
            'entry_price': current_rate,
            'dir': direction,
        })
        self._pending_entry_meta[pair] = meta
        return float(stake)

    def leverage(self, *args, **kwargs) -> float:
        """返回配置中的强制杠杆倍数，供 Freqtrade 下单使用。"""
        return self.cfg.sizing.enforce_leverage

    def order_filled(self, pair: str, trade, order, current_time: datetime, **kwargs) -> None:
        """处理开仓或平仓成交事件，更新风险账本、预约状态并触发持久化。"""
        trade_id = str(getattr(trade, "trade_id", getattr(trade, "id", "NA")))
        side = getattr(order, "ft_order_side", None)

        # Freqtrade 约定：entry_side / exit_side 与 ft_order_side 匹配
        is_entry = side == getattr(trade, "entry_side", None)
        is_exit  = side == getattr(trade, "exit_side", None)

        if is_entry:
            # 对 entry：尽量带上 meta，但没有也要能工作
            meta = self._pending_entry_meta.pop(pair, None)

            opened = self.execution.on_open_filled(
                pair=pair,
                trade=trade,
                order=order,
                pending_meta=meta,
                tier_mgr=self.tier_mgr,
            )
            if opened:
                try:
                    self.state.get_pair_state(pair)._cooldown_last_ts = float(
                        current_time.timestamp())
                except Exception:
                    pass
            if opened and self._persist_enabled:
                self.persist.save()

        elif is_exit:
            # 对 exit：完全不依赖 _pending_entry_meta
            closed = False
            if self._is_backtest_like_runmode() and trade_id in getattr(self, "_bt_closed_trades", set()):
                # 已在 confirm_trade_exit 中计数，避免重复累加 closs
                closed = True
            else:
                closed = self.execution.on_close_filled(
                    pair=pair,
                    trade=trade,
                    order=order,
                    tier_mgr=self.tier_mgr,
                )
            if closed:
                try:
                    self.state.get_pair_state(pair)._cooldown_last_ts = float(
                        current_time.timestamp())
                except Exception:
                    pass
            if closed and self._persist_enabled:
                self.persist.save()

        else:
            # 其它类型（比如 reduce_only / 手动改单等），可以先忽略或简单打个日志
            return


    def order_cancelled(self, pair: str, trade, order, current_time: datetime, **kwargs) -> None:
        """当订单被撤销时，释放对应预约并记录分析日志，符合 V29.1 修订 #5（仅释放预约，不回灌财政字段）。"""
        released, meta = self._handle_cancel_or_reject(pair)
        if released:
            trade_id = str(getattr(trade, "trade_id", getattr(trade, "id", meta.get(
                "reservation_id") if meta else "NA"))) if trade else meta.get("reservation_id", "NA") if meta else "NA"
            tag = ExitTags.ORDER_CANCELLED if ExitTags else "order_cancelled"
            self.analytics.log_exit(pair, trade_id, tag)

    def order_rejected(self, pair: str, trade, order, current_time: datetime, **kwargs) -> None:
        """当订单被拒绝时，与撤单路径一致，仅释放预约并写入分析日志。"""
        released, meta = self._handle_cancel_or_reject(pair)
        if released:
            trade_id = str(getattr(trade, "trade_id", getattr(trade, "id", meta.get(
                "reservation_id") if meta else "NA"))) if trade else meta.get("reservation_id", "NA") if meta else "NA"
            tag = ExitTags.ORDER_REJECTED if ExitTags else "order_rejected"
            self.analytics.log_exit(pair, trade_id, tag)

    def _handle_cancel_or_reject(self, pair: str) -> tuple[bool, Optional[dict[str, Any]]]:
        """内部工具：统一处理撤单/拒单路径，释放预约并返回是否释放及原始元数据快照。"""
        meta = self._pending_entry_meta.get(pair)
        meta_snapshot = dict(meta) if meta else None
        rid = meta.get("reservation_id") if meta else None
        released = self.execution.on_cancel_or_reject(pair, rid)
        if released:
            self._pending_entry_meta.pop(pair, None)
            if self._persist_enabled:
                self.persist.save()
        return released, meta_snapshot


    @staticmethod
    def _apply_leverage_pct(pct: Optional[float], trade) -> Optional[float]:
        """
        将基于“价格百分比”的 pct（例如 0.02 表示价格±2%）
        转换为 freqtrade 期望的“含杠杆 ROI 百分比”。

        - 对于期货：roi_pct = pct * trade.leverage
        - 对于现货：leverage=1，相当于不变
        """
        if pct is None:
            return None
        try:
            pct = float(pct)
        except (TypeError, ValueError):
            return None
        if pct <= 0:
            return None

        lev = getattr(trade, "leverage", 1.0) or 1.0
        try:
            lev = float(lev)
        except (TypeError, ValueError):
            lev = 1.0
        if lev <= 0:
            lev = 1.0

        return pct * lev


    def custom_stoploss(
        self,
        pair: str,
        trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
        after_fill: bool,
        **kwargs,
    ) -> Optional[float]:
        """Delegate stoploss calculation to ExitRouter rules."""
        if EXIT_ROUTER is None or SLContext is None:
            return self.stoploss
        try:
            ctx = SLContext(
                pair=pair,
                trade=trade,
                now=current_time,
                profit=float(current_profit or 0.0),
                dp=self.dp,
                cfg=self.cfg,
                state=self.state,
                strategy=self,
            )
            sl_pct = EXIT_ROUTER.sl_best(ctx, base_sl_pct=None)
        except Exception:
            sl_pct = None

        # 转成含杠杆 ROI 百分比
        sl_pct = self._apply_leverage_pct(sl_pct, trade)

        if sl_pct is None or sl_pct <= 0:
            return self.stoploss
        
        return -abs(float(sl_pct))

    def custom_exit(
        self,
        pair: str,
        trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
        **kwargs,
    ) -> Optional[str]:
        """
        自定义离场逻辑：
        1. 先走 ExitPolicyV29 的风控 / flip 等“策略级规则”。
        2. 如果没有触发，再用“持仓均价 ± N×ATR(当前K线)”价格触发离场。
        """
        # 1) 策略级风控 / flip / ICU / 债务风险 等
        tid = str(getattr(trade, "trade_id", getattr(trade, "id", "NA")))
        # reason = self.exit_policy.decide(
        #     pair,
        #     tid,
        #     float(current_profit) if current_profit is not None else None,
        #     trade=trade,
        # )
        reason = None

        # 2) 如果策略级没有给出退出，再用 ATR 价格逻辑
        if not reason:
            reason = self._atr_entry_exit_reason(
                pair,
                trade,
                current_time,
                current_rate,
                current_profit,
            )

        if not reason:
            return None

        # 3) 记录退出原因，方便回测 / 复盘分析
        try:
            if hasattr(trade, "exit_reason"):
                setattr(trade, "exit_reason", reason)
        except Exception:
            pass
        try:
            if hasattr(trade, "set_custom_data"):
                trade.set_custom_data("exit_tag", reason)
        except Exception:
            pass

        try:
            self.analytics.log_exit(pair, tid, reason)
        except Exception:
            pass

        return reason


    def custom_roi(
        self,
        pair: str,
        trade,
        current_time: datetime,
        trade_duration: int,
        entry_tag: Optional[str],
        side: str,
        **kwargs,
    ) -> Optional[float]:
        """
        自定义 ROI 回调：
        - 由 freqtrade 在每次循环、每个未平仓交易上调用
        - 返回当前应使用的最小 ROI 阈值（比例，如 0.05 表示 +5%）
        - 实际阈值由 ExitRouter/ExitFacade 中的 TP 规则聚合得到
        """
        # 如果 Router / TPContext 尚未初始化，则回退到 minimal_roi 逻辑
        if EXIT_ROUTER is None or TPContext is None:
            return None

        try:
            # 当前我们的 TP 规则并未使用 ctx.profit，因此这里给一个占位值即可
            ctx = TPContext(
                pair=pair,
                trade=trade,
                now=current_time,
                profit=0.0,
                dp=self.dp,
                cfg=self.cfg,
                state=self.state,
                strategy=self,
            )
            # 从所有 TP 规则中取“最保守的”（最小的正值）tp_pct
            tp_pct = EXIT_ROUTER.tp_best(ctx, base_tp_pct=None)
        except Exception:
            tp_pct = None

        # 把 tp_pct 换算成“账户收益百分比”的格式（考虑杠杆）
        tp_pct = self._apply_leverage_pct(tp_pct, trade)

        # 返回 None 表示“保持原有 minimal_roi”
        if tp_pct is None or tp_pct <= 0:
            return None

        # freqtrade 期望的是一个 ROI 阈值（比例），由 min_roi_reached() 与当前利润对比后决定是否离场
        return  float(tp_pct)

    def _router_sl_tp_pct(
        self,
        pair: str,
        trade,
        current_time: datetime,
        current_profit: float,
    ) -> tuple[Optional[float], Optional[float]]:
        """
        使用 EXIT_ROUTER + ExitFacade 计算当前 trade 对应的 sl_pct / tp_pct。
        返回值为“相对开仓价的百分比（ΔP / entry_price）”，不含杠杆。
        """
        if EXIT_ROUTER is None or SLContext is None or TPContext is None:
            return None, None

        try:
            sl_ctx = SLContext(
                pair=pair,
                trade=trade,
                now=current_time,
                profit=float(current_profit or 0.0),
                dp=self.dp,
                cfg=self.cfg,
                state=self.state,
                strategy=self,
            )
            sl_pct = EXIT_ROUTER.sl_best(sl_ctx, base_sl_pct=None)
        except Exception:
            sl_pct = None

        try:
            tp_ctx = TPContext(
                pair=pair,
                trade=trade,
                now=current_time,
                profit=float(current_profit or 0.0),
                dp=self.dp,
                cfg=self.cfg,
                state=self.state,
                strategy=self,
            )
            tp_pct = EXIT_ROUTER.tp_best(tp_ctx, base_tp_pct=None)
        except Exception:
            tp_pct = None

        # 这里刻意不调用 self._apply_leverage_pct，保留为“价格百分比”
        return (float(sl_pct) if sl_pct and sl_pct > 0 else None,
                float(tp_pct) if tp_pct and tp_pct > 0 else None)

    def _get_current_atr_abs(
        self,
        pair: str,
        current_time: datetime,
        profile: Optional[Any] = None,
    ) -> Optional[float]:
        """
        从 DataProvider 的指标表里，按 current_time 精确地取这一根K线的 ATR 绝对值。

        优先使用 profile.atr_timeframe；否则退回到策略 / 配置的主 timeframe。
        """
        dp = getattr(self, "dp", None)
        if dp is None:
            return None

        # 1) 决定 ATR 使用的 timeframe
        atr_tf = None
        if profile is not None:
            atr_tf = getattr(profile, "atr_timeframe", None)
        if not atr_tf:
            # cfg.timeframe 一般等于策略主周期
            atr_tf = getattr(self.cfg, "timeframe", None) or self.timeframe

        try:
            analyzed = dp.get_analyzed_dataframe(pair, atr_tf)
        except Exception:
            return None

        df = analyzed[0] if isinstance(analyzed, (list, tuple)) else analyzed
        if df is None or df.empty:
            return None

        # 2) 和 atr_pct_from_dp 一样的时间对齐逻辑：df.loc[:current_time]
        try:
            upto = df.loc[:current_time] if current_time is not None else df
        except Exception:
            try:
                ct = (
                    current_time.replace(tzinfo=None)
                    if getattr(current_time, "tzinfo", None)
                    else current_time
                )
                upto = df.loc[:ct]
            except Exception:
                upto = df

        if upto is None or upto.empty:
            return None

        row = upto.iloc[-1]

        # 3) 优先直接用 ATR 列；没有的话用 atr_pct * close 反推
        try:
            if "atr" in row.index:
                atr_val = float(row["atr"])
                if math.isfinite(atr_val) and atr_val > 0:
                    return atr_val
        except Exception:
            pass

        try:
            atr_pct = float(row.get("atr_pct", 0.0))
            close = float(row.get("close", 0.0))
            if atr_pct > 0 and close > 0:
                atr_val = atr_pct * close
                if math.isfinite(atr_val) and atr_val > 0:
                    return atr_val
        except Exception:
            return None

        return None


    def _atr_entry_exit_reason(
        self,
        pair: str,
        trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
    ) -> Optional[str]:
        """
        仅根据“持仓均价 ± N×ATR(当前K线)”判断是否离场：
        - 多单: SL = entry - atr_mul_sl * ATR(t), TP = entry + atr_mul_tp * ATR(t)
        - 空单: SL = entry + atr_mul_sl * ATR(t), TP = entry - atr_mul_tp * ATR(t)

        N (atr_mul_sl / atr_mul_tp) 完全由 exit_profiles + ExitFacade 决定。
        """
        if self.exit_facade is None:
            return None

        # 1) 解析当前 trade 使用的 exit_profile
        try:
            profile_name, profile_def, _ = self.exit_facade.resolve_trade_plan(
                pair, trade, current_time
            )
        except Exception:
            profile_def = None

        if not profile_def:
            return None

        # 2) 读取配置中的 ATR 倍数
        k_sl = float(getattr(profile_def, "atr_mul_sl", 0.0) or 0.0)
        k_tp = getattr(profile_def, "atr_mul_tp", None)
        if k_tp is None or k_tp <= 0:
            # 和 compute_plan_from_atr 保持一致：没配置 atr_mul_tp 就 >= 2×SL
            k_tp = max(k_sl * 2.0, 0.0)
        k_tp = float(k_tp)

        # 3) 按 current_time 从原始指标数据里取这一根K线的 ATR 绝对值
        atr_abs = self._get_current_atr_abs(pair, current_time, profile_def)
        if atr_abs is None or atr_abs <= 0:
            return None

        entry = float(trade.open_rate)
        price = float(current_rate)
        is_short = bool(getattr(trade, "is_short", False))

        sl_abs = k_sl * atr_abs if k_sl > 0 else None
        tp_abs = k_tp * atr_abs if k_tp > 0 else None

        # 4) 按“持仓均价 ± N×ATR”比较当前价格
        if not is_short:
            # 多单
            if sl_abs is not None:
                sl_price = entry - sl_abs
                if price <= sl_price:
                    # print(f"price_{price},atr_abs{atr_abs} sl_abs{sl_abs} current_time{current_time}\n")
                    return "atr_entry_sl"
            if tp_abs is not None:
                tp_price = entry + tp_abs
                if price >= tp_price:
                    # print(f"price_{price},atr_abs{atr_abs} tp_price{tp_price} current_time{current_time}\n")
                    return "atr_entry_tp"
        else:
            # 空单
            if sl_abs is not None:
                sl_price = entry + sl_abs
                if price >= sl_price:
                    # print(f"price_{price},atr_abs{atr_abs} sl_abs{sl_abs} current_time{current_time}\n")
                    return "atr_entry_sl"
            if tp_abs is not None:
                tp_price = entry - tp_abs
                if price <= tp_price:
                    # print(f"price_{price},atr_abs{atr_abs} tp_price{tp_price} current_time{current_time}\n")
                    return "atr_entry_tp"

        return None
