diff --git a/.env.example b/.env.example index c8c7a4e..a694a7d 100644 --- a/.env.example +++ b/.env.example @@ -123,6 +123,21 @@ ALPHAX_PAPER_TRAILING_VOL_DISTANCE_MULT=0.7 ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_INTERVAL_SECONDS=300 ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_STEP_PCT=2 +# 策略交易持仓健康保护。用于处理“开仓后长时间不启动、浮盈回吐、大盘转弱但未触发移动止盈”的仓位。 +ALPHAX_PAPER_POSITION_GUARD_ENABLED=1 +ALPHAX_PAPER_POSITION_GUARD_SOFT_HOURS=6 +ALPHAX_PAPER_POSITION_GUARD_SOFT_MIN_MAX_PNL_PCT=1.5 +ALPHAX_PAPER_POSITION_GUARD_HARD_HOURS=18 +ALPHAX_PAPER_POSITION_GUARD_HARD_MIN_MAX_PNL_PCT=2.5 +ALPHAX_PAPER_POSITION_GUARD_TIGHTEN_LOCK_PROFIT_PCT=0.15 +ALPHAX_PAPER_POSITION_GUARD_PROFIT_GIVEBACK_ENABLED=1 +ALPHAX_PAPER_POSITION_GUARD_GIVEBACK_MIN_MAX_PNL_PCT=2 +ALPHAX_PAPER_POSITION_GUARD_GIVEBACK_EXIT_PCT=70 +ALPHAX_PAPER_POSITION_GUARD_GIVEBACK_EXIT_CURRENT_PNL_PCT=0.6 +ALPHAX_PAPER_POSITION_GUARD_CRITICAL_EXIT_ENABLED=1 +ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MIN_AGE_HOURS=0.5 +ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MAX_PNL_PCT=1 + # 实盘准备模块。默认关闭且 dry-run,只生成订单意图,不真实下单。 # 多 API 账号请在页面中配置不同 account_code 和不同 env key 名。 ALPHAX_LIVE_TRADING_ENABLED=0 diff --git a/AGENTS.md b/AGENTS.md index e8a7fcc..33e7ac6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 6. `app/services/price_tracker.py` 负责可执行推荐的价格跟踪、状态迁移和动态风险提示。 7. `app/services/paper_trader.py` - 负责模拟交易账本同步,真实 TP/SL、移动止盈、杠杆和资金口径在 paper trading 层管理。 + 负责策略交易账本同步和 paper 执行适配。TP/SL、移动止盈、仓位健康、仓位 sizing、账户级风控等可复用交易能力不应长期绑定在 paper trading 层;新增能力优先沉到 `app/core/*` 或独立 execution/risk 模块,再由 paper/live 适配调用。 8. `app/db/live_trading.py` / `app/web/routes_live_trading.py` 负责实盘控制台:多交易所/多 API 账户配置、账号级风控、交易所接口验收和执行审计事件。页面不再使用“订单意图”作为产品概念,也不区分 Demo/正式环境,实际环境由 endpoint/API key 配置决定。 9. `app/services/review_engine.py` @@ -180,6 +180,13 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 - 市场环境识别中心,第一版基于市场快照、BTC/ETH 涨跌、山寨涨跌广度、强势/大跌数量和 funding 热度识别 `risk_off`、`btc_main_uptrend`、`altcoin_rotation`、`sideways_chop`、`meme_frenzy`、`unknown`。 - `app/core/global_risk.py` - paper trading 全局风控门禁。单币机会进入开仓或挂单成交前,需要先检查市场环境和账户风险;critical 禁止新开仓,high 只允许高质量机会。 +- `app/core/order_lifecycle.py` + - 挂单生命周期决策中心。负责限价单是否触价、是否过期、是否远离入场、RR 与入场距离计算;不写 DB、不取消订单、不调用交易所。paper/live 适配层只能消费它的 `OrderLifecycleDecision`。 +- `app/core/trailing_stop.py` + - 移动止盈决策中心。负责动态波动率启动阈值、保护距离、分层距离、启动/上移判断和结构化 decision;不写账本、不发通知、不调用交易所。paper/live 只能消费它的决策结果后做各自适配。 +- `app/core/position_health.py` + - 已开仓仓位的健康检查中心。它不是入场规则,而是持仓后判断“机会是否仍按原计划运行”:超时未启动会收紧保护价或提前退出,浮盈大幅回吐且未触发移动止盈会提前退出,市场进入 critical 时未受保护的微盈利/弱势仓位可提前退出。 +- 交易执行能力必须按“决策核心 -> 执行适配 -> 账本记录”分层:移动止盈、仓位失效保护、动态杠杆、仓位 sizing、订单触发、账户风控都应有可被 paper trading 和 live trading 复用的核心模块。不要把这些规则直接写死在 `app/db/paper_trading.py`、`routes_live_trading.py` 或页面 JS 中。 - 多策略基础设施当前内置 `main_composite_v1`、`box_retest_4h_v1`、`box_retest_1h_v1`、`volume_ignition_1h_v1`、`compression_breakout_4h_v1`、`intraday_momentum_15m_v1`。`box_breakout_pullback_4h` / `box_breakout_pullback_1h`、`vp_fly_1h_current`、`short_tf_15m_ignition` 等只是因子,只有和入场确认、风控、失效条件组成完整剧本后,才作为对应策略信号写入 `strategy_signals`。 - 确认层也会应用同一市场风控语义:`risk_level=critical` 且 `position_multiplier=0` 时,强势发现仍可记录为观察,但不能输出 `buy_now` 或新挂单动作;已有活跃可交易推荐会被降级为观察并写入 `market_risk_gate`。 @@ -446,6 +453,21 @@ docker compose run --rm alphax-web python scripts/postgres/validate_import.py -- 6. 复盘统计是否会被污染。 7. 相关测试是否需要补齐。 +### 9.3.1 交易执行能力要可复用 + +后续涉及策略交易、实盘同步或交易风控时,必须先按下面顺序设计: + +1. 先定义领域决策模块:如移动止盈、仓位健康、账户风险、订单触发、仓位 sizing。 +2. 再定义 paper/live 适配层:paper 负责模拟成交和账本,live 负责交易所 API、订单状态和审计事件。 +3. 最后做页面/API 展示:页面只能消费决策结果和账本状态,不应自己推导交易规则。 + +特别注意: + +- `paper_trading.py` 不能继续变成所有交易逻辑的大杂烩。它可以编排账本、事件和适配,但复杂规则要抽到 `app/core` 或独立服务模块。 +- 移动止盈核心决策已在 `app/core/trailing_stop.py`。`paper_trading.py` 只保留账本写入、通知节流、事件记录和推送适配;live trading 后续应复用同一 decision,而不是重新实现止盈算法。 +- 每个可复用交易能力都要输出结构化 decision/detail,方便 paper/live/review/UI 使用同一套解释口径。 +- 任何新交易规则都要同时考虑:paper 账本、live 执行、飞书通知、复盘归因、前端展示和测试覆盖。 + ### 9.4 推荐链路当前特别注意点 当前多策略发现与确认链路已经能持续产生筛选和确认样本,但后半段仍需要重点盯住: @@ -463,6 +485,8 @@ docker compose run --rm alphax-web python scripts/postgres/validate_import.py -- - 确认评分不再应被理解为固定技术分;确认层通过 `FactorScorer` 读取复盘后的 `signal_performance.weight`,高胜率因子会升权,低胜率/负收益因子会降权或淘汰。 - 评分因子必须保留 `factor_score_breakdown`,否则复盘无法知道一次推荐具体由哪些因子贡献、哪些因子拖累。 - `paper_trader.py` 只应处理可执行推荐,不能把观察池样本当成已成交。 +- 已成交持仓会通过 `app/core/position_health.py` 做二次风控:已有移动止盈保护的仓位优先交给移动止盈;未启动保护且长时间不发酵、利润明显回吐或大盘 critical 的仓位,会被标记为 `position_timeout_soft`、`position_timeout_weak`、`profit_giveback_before_trailing`、`market_risk_unprotected` 等事件/退出原因。后续不要把这类逻辑散落在页面或 route 中。 +- 挂单触价、过期、远离入场、RR 和入场距离计算统一走 `app/core/order_lifecycle.py`;`paper_trading.py` 只负责把决策转换成账本状态,live trading 后续应把同一决策转换成交易所挂单/撤单/成交同步。 - `review_engine.py` 的可信度依赖跟踪数据质量;如果 PnL 没更新,复盘结论也会失真。 - `missed_explosions` 历史数据可能存在同一 symbol 多次记录,读模型/KPI 需要保持去重口径,写入侧后续仍建议加唯一性或冷却约束。 @@ -496,6 +520,8 @@ docker compose run --rm alphax-web python scripts/postgres/validate_import.py -- 5. 继续梳理推送链路,把“是否推送”的判断、推送内容组装、通道发送彻底分层。 6. 对 `missed_explosions` 写入侧建立唯一性或冷却约束,避免重复样本继续进入历史表。 7. 梳理 price-streamer、tracker、paper-trader 三者边界,确保实时价格、推荐跟踪、模拟成交各自语义清晰。 +8. 将 `app/core/trailing_stop.py` 接入 live trading 同步链路,让实盘跟单使用和 paper trading 一致的移动止盈触发、上移、平仓原因和复盘事件语义。 +9. 将 `app/core/order_lifecycle.py` 接入 live trading 同步链路,让实盘挂单触价、过期、远离入场撤单和 RR/距离解释与 paper trading 保持一致。 ## 12. 给后续 Agent 的工作方式建议 diff --git a/app/config/system_config.py b/app/config/system_config.py index 22dfd63..fd914ab 100644 --- a/app/config/system_config.py +++ b/app/config/system_config.py @@ -187,6 +187,19 @@ def default_paper_trading_config(): "pause_after_weak_entries": _env_int("ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES", 3), "weak_entry_window_hours": _env_float("ALPHAX_PAPER_WEAK_ENTRY_WINDOW_HOURS", 6.0), "weak_entry_min_max_pnl_pct": _env_float("ALPHAX_PAPER_WEAK_ENTRY_MIN_MAX_PNL_PCT", 1.0), + "position_guard_enabled": _env_bool("ALPHAX_PAPER_POSITION_GUARD_ENABLED", True), + "position_guard_soft_hours": _env_float("ALPHAX_PAPER_POSITION_GUARD_SOFT_HOURS", 6.0), + "position_guard_soft_min_max_pnl_pct": _env_float("ALPHAX_PAPER_POSITION_GUARD_SOFT_MIN_MAX_PNL_PCT", 1.5), + "position_guard_hard_hours": _env_float("ALPHAX_PAPER_POSITION_GUARD_HARD_HOURS", 18.0), + "position_guard_hard_min_max_pnl_pct": _env_float("ALPHAX_PAPER_POSITION_GUARD_HARD_MIN_MAX_PNL_PCT", 2.5), + "position_guard_tighten_lock_profit_pct": _env_float("ALPHAX_PAPER_POSITION_GUARD_TIGHTEN_LOCK_PROFIT_PCT", 0.15), + "position_guard_profit_giveback_enabled": _env_bool("ALPHAX_PAPER_POSITION_GUARD_PROFIT_GIVEBACK_ENABLED", True), + "position_guard_giveback_min_max_pnl_pct": _env_float("ALPHAX_PAPER_POSITION_GUARD_GIVEBACK_MIN_MAX_PNL_PCT", 2.0), + "position_guard_giveback_exit_pct": _env_float("ALPHAX_PAPER_POSITION_GUARD_GIVEBACK_EXIT_PCT", 70.0), + "position_guard_giveback_exit_current_pnl_pct": _env_float("ALPHAX_PAPER_POSITION_GUARD_GIVEBACK_EXIT_CURRENT_PNL_PCT", 0.6), + "position_guard_critical_exit_enabled": _env_bool("ALPHAX_PAPER_POSITION_GUARD_CRITICAL_EXIT_ENABLED", True), + "position_guard_critical_min_age_hours": _env_float("ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MIN_AGE_HOURS", 0.5), + "position_guard_critical_max_pnl_pct": _env_float("ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MAX_PNL_PCT", 1.0), "global_risk_gate_enabled": _env_bool("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", True), "global_risk_block_critical": _env_bool("ALPHAX_PAPER_GLOBAL_RISK_BLOCK_CRITICAL", False), "global_risk_critical_min_rec_score": _env_float("ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_MIN_REC_SCORE", 80.0), @@ -242,6 +255,19 @@ def _paper_trading_env_overrides(): "ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES": ("pause_after_weak_entries", lambda: _env_int("ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES", 3)), "ALPHAX_PAPER_WEAK_ENTRY_WINDOW_HOURS": ("weak_entry_window_hours", lambda: _env_float("ALPHAX_PAPER_WEAK_ENTRY_WINDOW_HOURS", 6.0)), "ALPHAX_PAPER_WEAK_ENTRY_MIN_MAX_PNL_PCT": ("weak_entry_min_max_pnl_pct", lambda: _env_float("ALPHAX_PAPER_WEAK_ENTRY_MIN_MAX_PNL_PCT", 1.0)), + "ALPHAX_PAPER_POSITION_GUARD_ENABLED": ("position_guard_enabled", lambda: _env_bool("ALPHAX_PAPER_POSITION_GUARD_ENABLED", True)), + "ALPHAX_PAPER_POSITION_GUARD_SOFT_HOURS": ("position_guard_soft_hours", lambda: _env_float("ALPHAX_PAPER_POSITION_GUARD_SOFT_HOURS", 6.0)), + "ALPHAX_PAPER_POSITION_GUARD_SOFT_MIN_MAX_PNL_PCT": ("position_guard_soft_min_max_pnl_pct", lambda: _env_float("ALPHAX_PAPER_POSITION_GUARD_SOFT_MIN_MAX_PNL_PCT", 1.5)), + "ALPHAX_PAPER_POSITION_GUARD_HARD_HOURS": ("position_guard_hard_hours", lambda: _env_float("ALPHAX_PAPER_POSITION_GUARD_HARD_HOURS", 18.0)), + "ALPHAX_PAPER_POSITION_GUARD_HARD_MIN_MAX_PNL_PCT": ("position_guard_hard_min_max_pnl_pct", lambda: _env_float("ALPHAX_PAPER_POSITION_GUARD_HARD_MIN_MAX_PNL_PCT", 2.5)), + "ALPHAX_PAPER_POSITION_GUARD_TIGHTEN_LOCK_PROFIT_PCT": ("position_guard_tighten_lock_profit_pct", lambda: _env_float("ALPHAX_PAPER_POSITION_GUARD_TIGHTEN_LOCK_PROFIT_PCT", 0.15)), + "ALPHAX_PAPER_POSITION_GUARD_PROFIT_GIVEBACK_ENABLED": ("position_guard_profit_giveback_enabled", lambda: _env_bool("ALPHAX_PAPER_POSITION_GUARD_PROFIT_GIVEBACK_ENABLED", True)), + "ALPHAX_PAPER_POSITION_GUARD_GIVEBACK_MIN_MAX_PNL_PCT": ("position_guard_giveback_min_max_pnl_pct", lambda: _env_float("ALPHAX_PAPER_POSITION_GUARD_GIVEBACK_MIN_MAX_PNL_PCT", 2.0)), + "ALPHAX_PAPER_POSITION_GUARD_GIVEBACK_EXIT_PCT": ("position_guard_giveback_exit_pct", lambda: _env_float("ALPHAX_PAPER_POSITION_GUARD_GIVEBACK_EXIT_PCT", 70.0)), + "ALPHAX_PAPER_POSITION_GUARD_GIVEBACK_EXIT_CURRENT_PNL_PCT": ("position_guard_giveback_exit_current_pnl_pct", lambda: _env_float("ALPHAX_PAPER_POSITION_GUARD_GIVEBACK_EXIT_CURRENT_PNL_PCT", 0.6)), + "ALPHAX_PAPER_POSITION_GUARD_CRITICAL_EXIT_ENABLED": ("position_guard_critical_exit_enabled", lambda: _env_bool("ALPHAX_PAPER_POSITION_GUARD_CRITICAL_EXIT_ENABLED", True)), + "ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MIN_AGE_HOURS": ("position_guard_critical_min_age_hours", lambda: _env_float("ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MIN_AGE_HOURS", 0.5)), + "ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MAX_PNL_PCT": ("position_guard_critical_max_pnl_pct", lambda: _env_float("ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MAX_PNL_PCT", 1.0)), "ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED": ("global_risk_gate_enabled", lambda: _env_bool("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", True)), "ALPHAX_PAPER_GLOBAL_RISK_BLOCK_CRITICAL": ("global_risk_block_critical", lambda: _env_bool("ALPHAX_PAPER_GLOBAL_RISK_BLOCK_CRITICAL", False)), "ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_MIN_REC_SCORE": ("global_risk_critical_min_rec_score", lambda: _env_float("ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_MIN_REC_SCORE", 80.0)), diff --git a/app/core/order_lifecycle.py b/app/core/order_lifecycle.py new file mode 100644 index 0000000..910cb53 --- /dev/null +++ b/app/core/order_lifecycle.py @@ -0,0 +1,133 @@ +"""Reusable order lifecycle decisions for strategy execution. + +The functions here are pure decision helpers. They do not know whether an +order is simulated or live; adapters decide how to persist, cancel, fill, or +send exchange API calls. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timedelta + + +def _safe_float(value, default: float = 0.0) -> float: + try: + if value is None or value == "": + return default + return float(value) + except Exception: + return default + + +def _parse_time(value: str) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except Exception: + return None + + +@dataclass(frozen=True) +class OrderLifecycleDecision: + action: str = "hold" + reason: str = "" + detail: dict = field(default_factory=dict) + + def as_dict(self) -> dict: + return {"action": self.action, "reason": self.reason, "detail": self.detail} + + +def order_expires_at(event_time: str, expire_hours: float = 24.0) -> str: + hours = max(0.25, _safe_float(expire_hours, 24.0)) + base = _parse_time(event_time) or datetime.now() + return (base + timedelta(hours=hours)).isoformat() + + +def order_touched(order: dict, current_price: float) -> bool: + side = str(order.get("side") or "long").lower() + target = _safe_float(order.get("target_price")) + price = _safe_float(current_price) + if target <= 0 or price <= 0: + return False + if side == "short": + return price >= target + return price <= target + + +def order_too_far(order: dict, current_price: float, threshold_pct: float = 12.0) -> bool: + threshold = max(0.0, _safe_float(threshold_pct, 12.0)) + if threshold <= 0: + return False + side = str(order.get("side") or "long").lower() + target = _safe_float(order.get("target_price")) + price = _safe_float(current_price) + if target <= 0 or price <= 0: + return False + if side == "short": + return price < target * (1 - threshold / 100) + return price > target * (1 + threshold / 100) + + +def order_rr(side: str, target: float, stop_loss: float, tp1: float) -> float: + if str(side or "long").lower() == "short": + risk = _safe_float(stop_loss) - _safe_float(target) + reward = _safe_float(target) - _safe_float(tp1) + else: + risk = _safe_float(target) - _safe_float(stop_loss) + reward = _safe_float(tp1) - _safe_float(target) + if risk <= 0 or reward <= 0: + return 0.0 + return reward / risk + + +def order_distance_pct(side: str, current_price: float, target: float) -> float: + price = _safe_float(current_price) + target_price = _safe_float(target) + if target_price <= 0 or price <= 0: + return 999.0 + if str(side or "long").lower() == "short": + return max(0.0, (target_price / price - 1) * 100) + return max(0.0, (price / target_price - 1) * 100) + + +def evaluate_limit_order( + *, + order: dict, + current_price: float, + event_time: str, + config: dict | None = None, +) -> OrderLifecycleDecision: + cfg = config if isinstance(config, dict) else {} + detail = { + "order_id": order.get("id"), + "side": order.get("side") or "long", + "target_price": _safe_float(order.get("target_price")), + "current_price": _safe_float(current_price), + "expires_at": order.get("expires_at") or "", + } + if order_touched(order, current_price): + return OrderLifecycleDecision(action="fill", reason="touched", detail=detail) + + expires_at = _parse_time(str(order.get("expires_at") or "")) + now = _parse_time(event_time) or datetime.now() + if expires_at and now > expires_at: + return OrderLifecycleDecision(action="cancel", reason="expired", detail=detail) + + threshold = max(0.0, _safe_float(cfg.get("order_cancel_far_from_entry_pct"), 12.0)) + if order_too_far(order, current_price, threshold): + return OrderLifecycleDecision(action="cancel", reason="too_far_from_entry", detail={**detail, "threshold_pct": threshold}) + + return OrderLifecycleDecision(action="hold", reason="pending", detail=detail) + + +__all__ = [ + "OrderLifecycleDecision", + "evaluate_limit_order", + "order_distance_pct", + "order_expires_at", + "order_rr", + "order_too_far", + "order_touched", +] diff --git a/app/core/position_health.py b/app/core/position_health.py new file mode 100644 index 0000000..2c20479 --- /dev/null +++ b/app/core/position_health.py @@ -0,0 +1,195 @@ +"""Position health guard for paper/live trade lifecycle management. + +The entry signal answers "can we enter?". This module answers the later +question: "is this open position still behaving like the original setup?". +Keep the decision logic pure so paper trading, live sync, and future review +jobs can reuse the same vocabulary. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime + + +def _safe_float(value, default: float = 0.0) -> float: + try: + if value is None or value == "": + return default + return float(value) + except Exception: + return default + + +def _parse_time(value: str) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except Exception: + return None + + +def _pnl_pct(side: str, entry_price: float, price: float) -> float: + if entry_price <= 0 or price <= 0: + return 0.0 + if str(side or "long").lower() == "short": + return (entry_price / price - 1) * 100 + return (price / entry_price - 1) * 100 + + +def _age_hours(opened_at: str, event_time: str) -> float: + start = _parse_time(opened_at) + end = _parse_time(event_time) or datetime.now() + if not start: + return 0.0 + return max(0.0, (end - start).total_seconds() / 3600) + + +def _cfg_bool(config: dict, key: str, default: bool) -> bool: + value = config.get(key, default) + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +@dataclass(frozen=True) +class PositionHealthDecision: + action: str = "hold" + reason: str = "" + health_score: float = 100.0 + guard_stop: float = 0.0 + detail: dict = field(default_factory=dict) + + def as_dict(self) -> dict: + return { + "action": self.action, + "reason": self.reason, + "health_score": round(self.health_score, 4), + "guard_stop": round(self.guard_stop, 12) if self.guard_stop > 0 else 0, + "detail": self.detail, + } + + +def position_health_metrics(trade: dict, current_price: float, event_time: str) -> dict: + side = str(trade.get("side") or "long").strip().lower() or "long" + entry = _safe_float(trade.get("entry_price")) + current = _safe_float(current_price) + max_price = max(_safe_float(trade.get("max_price")) or entry, current, entry) + min_price = min(_safe_float(trade.get("min_price")) or entry, current, entry) + current_pnl = _pnl_pct(side, entry, current) + if side == "short": + max_pnl = _pnl_pct(side, entry, min_price) + min_pnl = _pnl_pct(side, entry, max_price) + else: + max_pnl = _pnl_pct(side, entry, max_price) + min_pnl = _pnl_pct(side, entry, min_price) + profit_giveback_pct = 0.0 + if max_pnl > 0: + profit_giveback_pct = max(0.0, (max_pnl - current_pnl) / max_pnl * 100) + return { + "side": side, + "entry_price": entry, + "current_price": current, + "age_hours": round(_age_hours(str(trade.get("opened_at") or ""), event_time), 6), + "pnl_pct": round(current_pnl, 6), + "max_pnl_pct": round(max_pnl, 6), + "min_pnl_pct": round(min_pnl, 6), + "profit_giveback_pct": round(profit_giveback_pct, 6), + "trailing_active": _safe_float(trade.get("trailing_stop")) > 0, + } + + +def evaluate_position_health( + *, + trade: dict, + current_price: float, + event_time: str, + config: dict | None = None, + global_risk: dict | None = None, +) -> PositionHealthDecision: + """Return a deterministic action for an open position. + + Actions: + - hold: keep normal management. + - tighten_stop: raise the protective stop near breakeven. + - close: exit early because the original setup is no longer healthy. + """ + cfg = config if isinstance(config, dict) else {} + if not _cfg_bool(cfg, "position_guard_enabled", True): + return PositionHealthDecision(action="hold", reason="disabled", detail={"enabled": False}) + + metrics = position_health_metrics(trade, current_price, event_time) + detail = {"enabled": True, **metrics} + if metrics["entry_price"] <= 0 or metrics["current_price"] <= 0: + return PositionHealthDecision(action="hold", reason="missing_price", detail=detail) + if metrics["trailing_active"]: + return PositionHealthDecision(action="hold", reason="already_protected_by_trailing", health_score=90, detail=detail) + + age = _safe_float(metrics["age_hours"]) + pnl = _safe_float(metrics["pnl_pct"]) + max_pnl = _safe_float(metrics["max_pnl_pct"]) + giveback = _safe_float(metrics["profit_giveback_pct"]) + health_score = 100.0 + health_score -= min(35.0, max(0.0, age - 2.0) * 2.0) + if max_pnl <= 0: + health_score -= 15.0 + if giveback > 0: + health_score -= min(30.0, giveback * 0.25) + if pnl < 0: + health_score -= min(20.0, abs(pnl) * 3.0) + + risk_level = str((global_risk or {}).get("risk_level") or "").strip().lower() + critical_exit_enabled = _cfg_bool(cfg, "position_guard_critical_exit_enabled", True) + critical_min_age = max(0.0, _safe_float(cfg.get("position_guard_critical_min_age_hours"), 0.5)) + critical_max_pnl = _safe_float(cfg.get("position_guard_critical_max_pnl_pct"), 1.0) + if critical_exit_enabled and risk_level == "critical" and age >= critical_min_age and pnl <= critical_max_pnl: + detail["global_risk"] = global_risk or {} + return PositionHealthDecision( + action="close", + reason="market_risk_unprotected", + health_score=max(0.0, health_score - 25.0), + detail=detail, + ) + + giveback_enabled = _cfg_bool(cfg, "position_guard_profit_giveback_enabled", True) + giveback_start = max(0.0, _safe_float(cfg.get("position_guard_giveback_min_max_pnl_pct"), 2.0)) + giveback_exit = max(0.0, _safe_float(cfg.get("position_guard_giveback_exit_pct"), 70.0)) + giveback_exit_pnl = _safe_float(cfg.get("position_guard_giveback_exit_current_pnl_pct"), 0.6) + if giveback_enabled and max_pnl >= giveback_start and giveback >= giveback_exit and pnl <= giveback_exit_pnl: + return PositionHealthDecision( + action="close", + reason="profit_giveback_before_trailing", + health_score=max(0.0, health_score - 20.0), + detail=detail, + ) + + hard_hours = max(0.0, _safe_float(cfg.get("position_guard_hard_hours"), 18.0)) + hard_min_max_pnl = max(0.0, _safe_float(cfg.get("position_guard_hard_min_max_pnl_pct"), 2.5)) + if hard_hours > 0 and age >= hard_hours and max_pnl < hard_min_max_pnl: + return PositionHealthDecision( + action="close", + reason="position_timeout_weak", + health_score=max(0.0, health_score - 30.0), + detail={**detail, "hard_hours": hard_hours, "hard_min_max_pnl_pct": hard_min_max_pnl}, + ) + + soft_hours = max(0.0, _safe_float(cfg.get("position_guard_soft_hours"), 6.0)) + soft_min_max_pnl = max(0.0, _safe_float(cfg.get("position_guard_soft_min_max_pnl_pct"), 1.5)) + lock_profit = _safe_float(cfg.get("position_guard_tighten_lock_profit_pct"), 0.15) + if soft_hours > 0 and age >= soft_hours and max_pnl < soft_min_max_pnl: + guard_stop = metrics["entry_price"] * (1 + lock_profit / 100) + if metrics["side"] == "short": + guard_stop = metrics["entry_price"] * (1 - lock_profit / 100) + return PositionHealthDecision( + action="tighten_stop", + reason="position_timeout_soft", + health_score=max(0.0, health_score - 15.0), + guard_stop=guard_stop, + detail={**detail, "soft_hours": soft_hours, "soft_min_max_pnl_pct": soft_min_max_pnl}, + ) + + return PositionHealthDecision(action="hold", reason="healthy", health_score=max(0.0, health_score), detail=detail) + + +__all__ = ["PositionHealthDecision", "evaluate_position_health", "position_health_metrics"] diff --git a/app/core/trailing_stop.py b/app/core/trailing_stop.py new file mode 100644 index 0000000..428c5dd --- /dev/null +++ b/app/core/trailing_stop.py @@ -0,0 +1,195 @@ +"""Reusable trailing-stop decision engine. + +This module is intentionally side-effect free: it does not write the ledger, +send notifications, or call an exchange. Paper trading and live trading should +both consume the same decision object, then adapt it to their execution layer. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +def _safe_float(value, default: float = 0.0) -> float: + try: + if value is None or value == "": + return default + return float(value) + except Exception: + return default + + +def _cfg_bool(config: dict, key: str, default: bool) -> bool: + value = config.get(key, default) + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +def _clamp(value: float, min_value: float, max_value: float) -> float: + low = min(min_value, max_value) + high = max(min_value, max_value) + return max(low, min(high, value)) + + +@dataclass(frozen=True) +class TrailingStopDecision: + action: str = "hold" + event_type: str = "" + trailing_stop: float = 0.0 + previous_trailing_stop: float = 0.0 + activated: bool = False + moved: bool = False + profile: dict = field(default_factory=dict) + detail: dict = field(default_factory=dict) + + def as_dict(self) -> dict: + return { + "action": self.action, + "event_type": self.event_type, + "trailing_stop": round(self.trailing_stop, 12) if self.trailing_stop > 0 else 0, + "previous_trailing_stop": round(self.previous_trailing_stop, 12) if self.previous_trailing_stop > 0 else 0, + "activated": self.activated, + "moved": self.moved, + **self.profile, + **self.detail, + } + + +def normalize_trailing_config(config: dict | None = None) -> dict: + cfg = config if isinstance(config, dict) else {} + return { + "enabled": _cfg_bool(cfg, "trailing_stop_enabled", cfg.get("enabled", True)), + "mode": str(cfg.get("trailing_mode") or cfg.get("mode") or "volatility").strip().lower(), + "activate_pnl_pct": max(0.0, _safe_float(cfg.get("trailing_activate_pnl_pct", cfg.get("activate_pnl_pct")), 3.0)), + "min_lock_profit_pct": max(0.0, _safe_float(cfg.get("trailing_min_lock_profit_pct", cfg.get("min_lock_profit_pct")), 0.5)), + "distance_pct": max(0.1, _safe_float(cfg.get("trailing_distance_pct", cfg.get("distance_pct")), 1.5)), + "vol_min_activation_pct": max(0.0, _safe_float(cfg.get("trailing_volatility_min_activation_pct", cfg.get("vol_min_activation_pct")), 2.5)), + "vol_max_activation_pct": max(0.1, _safe_float(cfg.get("trailing_volatility_max_activation_pct", cfg.get("vol_max_activation_pct")), 8.0)), + "vol_activation_mult": max(0.0, _safe_float(cfg.get("trailing_volatility_activation_mult", cfg.get("vol_activation_mult")), 0.6)), + "vol_min_distance_pct": max(0.1, _safe_float(cfg.get("trailing_volatility_min_distance_pct", cfg.get("vol_min_distance_pct")), 1.2)), + "vol_max_distance_pct": max(0.1, _safe_float(cfg.get("trailing_volatility_max_distance_pct", cfg.get("vol_max_distance_pct")), 8.0)), + "vol_distance_mult": max(0.0, _safe_float(cfg.get("trailing_volatility_distance_mult", cfg.get("vol_distance_mult")), 0.7)), + "tiers": cfg.get("trailing_tiers") if isinstance(cfg.get("trailing_tiers"), list) else (cfg.get("tiers") if isinstance(cfg.get("tiers"), list) else []), + } + + +def trailing_distance_pct(pnl_pct: float, config: dict) -> tuple[float, str]: + distance = _safe_float(config.get("distance_pct"), 1.5) + label = "" + tiers = config.get("tiers") or [] + for tier in sorted((t for t in tiers if isinstance(t, dict)), key=lambda x: _safe_float(x.get("min_pnl_pct")), reverse=True): + if pnl_pct >= _safe_float(tier.get("min_pnl_pct")): + distance = max(0.1, _safe_float(tier.get("distance_pct"), distance)) + label = str(tier.get("label") or "") + break + return distance, label + + +def observed_volatility_pct(position: dict, current_price: float) -> float: + entry = _safe_float(position.get("entry_price")) + if entry <= 0 or current_price <= 0: + return 0.0 + high = max(_safe_float(position.get("max_price")) or entry, current_price, entry) + low = min(_safe_float(position.get("min_price")) or entry, current_price, entry) + return round(max(0.0, (high - low) / entry * 100), 6) + + +def trailing_profile(position: dict, current_price: float, pnl_pct: float, config: dict | None = None) -> dict: + cfg = normalize_trailing_config(config) + base_activate = _safe_float(cfg.get("activate_pnl_pct"), 3.0) + base_distance, tier_label = trailing_distance_pct(pnl_pct, cfg) + volatility_pct = observed_volatility_pct(position, current_price) + if str(cfg.get("mode") or "volatility").lower() != "volatility": + return { + "trailing_mode": "fixed", + "volatility_pct": volatility_pct, + "activate_pnl_pct": base_activate, + "distance_pct": base_distance, + "tier_label": tier_label, + } + + dynamic_activate = max(base_activate, volatility_pct * _safe_float(cfg.get("vol_activation_mult"), 0.6)) + dynamic_activate = _clamp( + dynamic_activate, + _safe_float(cfg.get("vol_min_activation_pct"), 2.5), + _safe_float(cfg.get("vol_max_activation_pct"), 8.0), + ) + dynamic_distance = max(base_distance, volatility_pct * _safe_float(cfg.get("vol_distance_mult"), 0.7)) + dynamic_distance = _clamp( + dynamic_distance, + _safe_float(cfg.get("vol_min_distance_pct"), 1.2), + _safe_float(cfg.get("vol_max_distance_pct"), 8.0), + ) + return { + "trailing_mode": "volatility", + "volatility_pct": volatility_pct, + "activate_pnl_pct": round(dynamic_activate, 6), + "distance_pct": round(dynamic_distance, 6), + "tier_label": tier_label or "波动率", + "base_activate_pnl_pct": base_activate, + "base_distance_pct": base_distance, + } + + +def evaluate_trailing_stop( + *, + position: dict, + current_price: float, + pnl_pct: float, + config: dict | None = None, +) -> TrailingStopDecision: + cfg = normalize_trailing_config(config) + current_trail = _safe_float(position.get("trailing_stop")) + if not cfg.get("enabled"): + return TrailingStopDecision(action="disabled", previous_trailing_stop=current_trail, detail={"enabled": False}) + + entry_price = _safe_float(position.get("entry_price")) + if entry_price <= 0 or current_price <= 0: + return TrailingStopDecision(action="hold", previous_trailing_stop=current_trail, detail={"reason": "missing_price"}) + + profile = trailing_profile(position, current_price, pnl_pct, cfg) + activate_pnl_pct = _safe_float(profile.get("activate_pnl_pct"), cfg.get("activate_pnl_pct")) + if pnl_pct < activate_pnl_pct: + return TrailingStopDecision( + action="hold", + previous_trailing_stop=current_trail, + profile=profile, + detail={"reason": "below_activation"}, + ) + + distance_pct = _safe_float(profile.get("distance_pct"), cfg.get("distance_pct")) + protection_floor = entry_price * (1 + _safe_float(cfg.get("min_lock_profit_pct")) / 100) + candidate = current_price * (1 - distance_pct / 100) + new_trail = round(max(current_trail, protection_floor, candidate), 12) + activated = current_trail <= 0 and new_trail > 0 + moved = current_trail > 0 and new_trail > current_trail + 1e-12 + if not activated and not moved: + return TrailingStopDecision( + action="hold", + trailing_stop=current_trail, + previous_trailing_stop=current_trail, + profile=profile, + detail={"reason": "unchanged"}, + ) + + return TrailingStopDecision( + action="activate" if activated else "move", + event_type="trailing_activate" if activated else "trailing_move", + trailing_stop=new_trail, + previous_trailing_stop=current_trail, + activated=activated, + moved=moved, + profile=profile, + detail={"min_lock_profit_pct": cfg.get("min_lock_profit_pct")}, + ) + + +__all__ = [ + "TrailingStopDecision", + "evaluate_trailing_stop", + "normalize_trailing_config", + "observed_volatility_pct", + "trailing_distance_pct", + "trailing_profile", +] diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index 24ec2fb..6277a60 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -8,6 +8,14 @@ from datetime import datetime, timedelta from app.config.system_config import paper_trading_config from app.core.global_risk import evaluate_global_risk +from app.core.order_lifecycle import ( + evaluate_limit_order, + order_distance_pct, + order_expires_at, + order_rr, +) +from app.core.position_health import evaluate_position_health +from app.core.trailing_stop import evaluate_trailing_stop, normalize_trailing_config from app.core.strategy_registry import normalize_strategy_code, strategy_label, strategy_paper_config from app.db.schema import get_conn from app.db.system_logs import record_system_error @@ -191,86 +199,10 @@ def _market_risk_adjusted_notional(base_notional: float, risk_detail: dict | Non def _trailing_config() -> dict: cfg = paper_trading_config() - return { - "enabled": bool(cfg.get("trailing_stop_enabled", True)), - "mode": str(cfg.get("trailing_mode") or "volatility").strip().lower(), - "activate_pnl_pct": max(0.0, _safe_float(cfg.get("trailing_activate_pnl_pct"), 3.0)), - "min_lock_profit_pct": max(0.0, _safe_float(cfg.get("trailing_min_lock_profit_pct"), 0.5)), - "distance_pct": max(0.1, _safe_float(cfg.get("trailing_distance_pct"), 1.5)), - "vol_min_activation_pct": max(0.0, _safe_float(cfg.get("trailing_volatility_min_activation_pct"), 2.5)), - "vol_max_activation_pct": max(0.1, _safe_float(cfg.get("trailing_volatility_max_activation_pct"), 8.0)), - "vol_activation_mult": max(0.0, _safe_float(cfg.get("trailing_volatility_activation_mult"), 0.6)), - "vol_min_distance_pct": max(0.1, _safe_float(cfg.get("trailing_volatility_min_distance_pct"), 1.2)), - "vol_max_distance_pct": max(0.1, _safe_float(cfg.get("trailing_volatility_max_distance_pct"), 8.0)), - "vol_distance_mult": max(0.0, _safe_float(cfg.get("trailing_volatility_distance_mult"), 0.7)), - "move_push_min_interval_seconds": max(0, _safe_int(cfg.get("trailing_move_push_min_interval_seconds"), 300)), - "move_push_min_step_pct": max(0.0, _safe_float(cfg.get("trailing_move_push_min_step_pct"), 2.0)), - "tiers": cfg.get("trailing_tiers") if isinstance(cfg.get("trailing_tiers"), list) else [], - } - - -def _trailing_distance_pct(pnl_pct: float, cfg: dict) -> tuple[float, str]: - distance = _safe_float(cfg.get("distance_pct"), 1.5) - label = "" - tiers = cfg.get("tiers") or [] - for tier in sorted((t for t in tiers if isinstance(t, dict)), key=lambda x: _safe_float(x.get("min_pnl_pct")), reverse=True): - if pnl_pct >= _safe_float(tier.get("min_pnl_pct")): - distance = max(0.1, _safe_float(tier.get("distance_pct"), distance)) - label = str(tier.get("label") or "") - break - return distance, label - - -def _clamp(value: float, min_value: float, max_value: float) -> float: - low = min(min_value, max_value) - high = max(min_value, max_value) - return max(low, min(high, value)) - - -def _trade_observed_volatility_pct(trade: dict, current_price: float) -> float: - entry = _safe_float(trade.get("entry_price")) - if entry <= 0 or current_price <= 0: - return 0.0 - high = max(_safe_float(trade.get("max_price")) or entry, current_price, entry) - low = min(_safe_float(trade.get("min_price")) or entry, current_price, entry) - return round(max(0.0, (high - low) / entry * 100), 6) - - -def _dynamic_trailing_profile(trade: dict, current_price: float, pnl_pct: float, cfg: dict) -> dict: - base_activate = _safe_float(cfg.get("activate_pnl_pct"), 3.0) - base_distance, tier_label = _trailing_distance_pct(pnl_pct, cfg) - volatility_pct = _trade_observed_volatility_pct(trade, current_price) - if str(cfg.get("mode") or "volatility").lower() != "volatility": - return { - "mode": "fixed", - "volatility_pct": volatility_pct, - "activate_pnl_pct": base_activate, - "distance_pct": base_distance, - "tier_label": tier_label, - } - - dynamic_activate = max(base_activate, volatility_pct * _safe_float(cfg.get("vol_activation_mult"), 0.6)) - dynamic_activate = _clamp( - dynamic_activate, - _safe_float(cfg.get("vol_min_activation_pct"), 2.5), - _safe_float(cfg.get("vol_max_activation_pct"), 8.0), - ) - dynamic_distance = max(base_distance, volatility_pct * _safe_float(cfg.get("vol_distance_mult"), 0.7)) - dynamic_distance = _clamp( - dynamic_distance, - _safe_float(cfg.get("vol_min_distance_pct"), 1.2), - _safe_float(cfg.get("vol_max_distance_pct"), 8.0), - ) - label = tier_label or "波动率" - return { - "mode": "volatility", - "volatility_pct": volatility_pct, - "activate_pnl_pct": round(dynamic_activate, 6), - "distance_pct": round(dynamic_distance, 6), - "tier_label": label, - "base_activate_pnl_pct": base_activate, - "base_distance_pct": base_distance, - } + normalized = normalize_trailing_config(cfg) + normalized["move_push_min_interval_seconds"] = max(0, _safe_int(cfg.get("trailing_move_push_min_interval_seconds"), 300)) + normalized["move_push_min_step_pct"] = max(0.0, _safe_float(cfg.get("trailing_move_push_min_step_pct"), 2.0)) + return normalized def _parse_time(value: str) -> datetime | None: @@ -903,53 +835,15 @@ def _paper_order_target_price(rec: dict) -> float: def _paper_order_expires_at(event_time: str, config: dict | None = None) -> str: cfg = _paper_cfg(config) - hours = max(0.25, _safe_float(cfg.get("order_expire_hours"), 24.0)) - base = _parse_time(event_time) or datetime.now() - return (base + timedelta(hours=hours)).isoformat() - - -def _paper_order_touched(order: dict, current_price: float) -> bool: - side = str(order.get("side") or "long").lower() - target = _safe_float(order.get("target_price")) - if target <= 0 or current_price <= 0: - return False - if side == "short": - return current_price >= target - return current_price <= target - - -def _paper_order_too_far(order: dict, current_price: float, config: dict | None = None) -> bool: - cfg = _paper_cfg(config) - threshold_pct = max(0.0, _safe_float(cfg.get("order_cancel_far_from_entry_pct"), 12.0)) - if threshold_pct <= 0: - return False - side = str(order.get("side") or "long").lower() - target = _safe_float(order.get("target_price")) - if target <= 0 or current_price <= 0: - return False - if side == "short": - return current_price < target * (1 - threshold_pct / 100) - return current_price > target * (1 + threshold_pct / 100) + return order_expires_at(event_time, _safe_float(cfg.get("order_expire_hours"), 24.0)) def _paper_order_rr(side: str, target: float, stop_loss: float, tp1: float) -> float: - if side == "short": - risk = stop_loss - target - reward = target - tp1 - else: - risk = target - stop_loss - reward = tp1 - target - if risk <= 0 or reward <= 0: - return 0.0 - return reward / risk + return order_rr(side, target, stop_loss, tp1) def _paper_order_distance_pct(side: str, current_price: float, target: float) -> float: - if target <= 0 or current_price <= 0: - return 999.0 - if side == "short": - return max(0.0, (target / current_price - 1) * 100) - return max(0.0, (current_price / target - 1) * 100) + return order_distance_pct(side, current_price, target) def _paper_order_gate(rec: dict, current_price: float, config: dict | None = None, conn=None) -> tuple[bool, list[str], dict]: @@ -1204,11 +1098,10 @@ def _sync_wait_pullback_order(conn, rec: dict, current_price: float, event_time: cancel_reason = _order_recommendation_cancel_reason(conn, rec, order) if cancel_reason: return _cancel_paper_order(conn, order, cancel_reason, event_time) - if _paper_order_touched(order, current_price): + lifecycle = evaluate_limit_order(order=order, current_price=current_price, event_time=event_time, config=cfg).as_dict() + if lifecycle["action"] == "fill": return _fill_paper_order(conn, order, rec, current_price, event_time, cfg) - expires_at = _parse_time(order.get("expires_at")) - now = _parse_time(event_time) or datetime.now() - if expires_at and now > expires_at: + if lifecycle["action"] == "cancel" and lifecycle["reason"] == "expired": conn.execute( """ UPDATE paper_orders @@ -1218,8 +1111,8 @@ def _sync_wait_pullback_order(conn, rec: dict, current_price: float, event_time: (event_time, event_time, order["id"]), ) return {"skipped": True, "reason": "paper_order_expired", "paper_order_id": order["id"]} - if _paper_order_too_far(order, current_price, cfg): - return _cancel_paper_order(conn, order, "too_far_from_entry", event_time) + if lifecycle["action"] == "cancel": + return _cancel_paper_order(conn, order, str(lifecycle["reason"] or "canceled"), event_time) conn.execute("UPDATE paper_orders SET updated_at=%s WHERE id=%s", (event_time, order["id"])) return { "skipped": True, @@ -1227,6 +1120,7 @@ def _sync_wait_pullback_order(conn, rec: dict, current_price: float, event_time: "paper_order_id": order["id"], "target_price": order.get("target_price"), "current_price": current_price, + "order_lifecycle": lifecycle, } gate_ok, gate_reasons, gate_detail = _paper_order_gate(rec, current_price, cfg, conn=conn) @@ -1282,13 +1176,14 @@ def _sync_wait_pullback_order(conn, rec: dict, current_price: float, event_time: ).fetchone() order_id = row["id"] if row else None order = {"id": order_id, **payload} - if _paper_order_touched(order, current_price): + lifecycle = evaluate_limit_order(order=order, current_price=current_price, event_time=event_time, config=cfg).as_dict() + if lifecycle["action"] == "fill": return _fill_paper_order(conn, order, rec, current_price, event_time, cfg) cancel_reason = _order_recommendation_cancel_reason(conn, rec, order) if cancel_reason: return _cancel_paper_order(conn, order, cancel_reason, event_time) - if _paper_order_too_far(order, current_price, cfg): - return _cancel_paper_order(conn, order, "too_far_from_entry", event_time) + if lifecycle["action"] == "cancel": + return _cancel_paper_order(conn, order, str(lifecycle["reason"] or "canceled"), event_time) result = { "skipped": True, "reason": "paper_order_created", @@ -1296,12 +1191,13 @@ def _sync_wait_pullback_order(conn, rec: dict, current_price: float, event_time: "target_price": payload["target_price"], "current_price": current_price, "gate_detail": gate_detail, + "order_lifecycle": lifecycle, } _push_order_created_card(order, event_time) return result -def _close_trade(conn, trade: dict, current_price: float, reason: str, event_time: str) -> dict: +def _close_trade(conn, trade: dict, current_price: float, reason: str, event_time: str, detail: dict | None = None) -> dict: entry_price = _safe_float(trade.get("entry_price")) exit_price = _close_price(current_price) pnl_pct = _trade_pnl_pct(entry_price, exit_price) @@ -1348,7 +1244,7 @@ def _close_trade(conn, trade: dict, current_price: float, reason: str, event_tim exit_price, pnl_pct, f"交易平仓:{reason}", - {"realized_pnl_usdt": pnl_usdt, "fee_usdt": total_fee}, + {"realized_pnl_usdt": pnl_usdt, "fee_usdt": total_fee, **(detail or {})}, now, ) _push_event_card( @@ -1360,40 +1256,70 @@ def _close_trade(conn, trade: dict, current_price: float, reason: str, event_tim return {"closed": True, "trade_id": trade["id"], "exit_reason": reason, "pnl_pct": pnl_pct, "pnl_usdt": pnl_usdt} +def _apply_position_health_guard( + conn, + trade: dict, + current_price: float, + pnl_pct: float, + event_time: str, + config: dict | None = None, +) -> dict: + cfg = _paper_cfg(config) + risk_detail = {} + if bool(cfg.get("position_guard_critical_exit_enabled", True)) and bool(cfg.get("global_risk_gate_enabled", True)): + try: + risk_detail = evaluate_global_risk(conn=conn, config=cfg, rec=trade, additional_notional=0.0) + except Exception as exc: + risk_detail = {"error": exc.__class__.__name__, "message": str(exc)[:200]} + decision = evaluate_position_health( + trade=trade, + current_price=current_price, + event_time=event_time, + config=cfg, + global_risk=risk_detail, + ).as_dict() + action = decision.get("action") + if action == "close": + return _close_trade(conn, trade, current_price, str(decision.get("reason") or "position_health_exit"), event_time, {"position_health": decision}) + if action == "tighten_stop": + guard_stop = _safe_float(decision.get("guard_stop")) + current_trail = _safe_float(trade.get("trailing_stop")) + if guard_stop > 0 and guard_stop > current_trail + 1e-12: + _record_event( + conn, + trade["id"], + trade["recommendation_id"], + trade["symbol"], + "position_guard_tighten", + guard_stop, + pnl_pct, + f"仓位健康保护:收紧保护价 {guard_stop:.8g}", + {"position_health": decision}, + event_time, + ) + return {"updated": True, "tightened": True, "trailing_stop": guard_stop, "position_health": decision} + return {"updated": True, "tightened": False, "position_health": decision} + + def _update_trailing_stop(conn, trade: dict, current_price: float, pnl_pct: float, event_time: str) -> tuple[float, dict]: cfg = _trailing_config() current_trail = _safe_float(trade.get("trailing_stop")) - if not cfg.get("enabled"): - return current_trail, {"activated": False, "moved": False} - - entry_price = _safe_float(trade.get("entry_price")) - if entry_price <= 0 or current_price <= 0: - return current_trail, {"activated": False, "moved": False} - - profile = _dynamic_trailing_profile(trade, current_price, pnl_pct, cfg) - activate_pnl_pct = _safe_float(profile.get("activate_pnl_pct"), cfg.get("activate_pnl_pct")) - if pnl_pct < activate_pnl_pct: - return current_trail, { + decision = evaluate_trailing_stop(position=trade, current_price=current_price, pnl_pct=pnl_pct, config=cfg).as_dict() + if not decision.get("activated") and not decision.get("moved"): + return _safe_float(decision.get("trailing_stop")) or current_trail, { "activated": False, "moved": False, - "trailing_mode": profile.get("mode"), - "volatility_pct": profile.get("volatility_pct"), - "activate_pnl_pct": activate_pnl_pct, + "trailing_mode": decision.get("trailing_mode"), + "volatility_pct": decision.get("volatility_pct"), + "activate_pnl_pct": decision.get("activate_pnl_pct"), + "distance_pct": decision.get("distance_pct"), + "trailing_decision": decision, } - distance_pct = _safe_float(profile.get("distance_pct"), cfg.get("distance_pct")) - tier_label = str(profile.get("tier_label") or "") - protection_floor = entry_price * (1 + _safe_float(cfg.get("min_lock_profit_pct")) / 100) - candidate = current_price * (1 - distance_pct / 100) - new_trail = round(max(current_trail, protection_floor, candidate), 12) - activated = current_trail <= 0 and new_trail > 0 - moved = current_trail > 0 and new_trail > current_trail + 1e-12 - if not activated and not moved: - return current_trail, {"activated": False, "moved": False} - - event_type = "trailing_activate" if activated else "trailing_move" - action_text = "激活" if activated else "上移" - should_emit = activated or _should_emit_trailing_move(conn, trade, new_trail, event_time, cfg) + new_trail = _safe_float(decision.get("trailing_stop")) + event_type = str(decision.get("event_type") or ("trailing_activate" if decision.get("activated") else "trailing_move")) + action_text = "激活" if decision.get("activated") else "上移" + should_emit = bool(decision.get("activated")) or _should_emit_trailing_move(conn, trade, new_trail, event_time, cfg) if should_emit: message = f"移动止盈{action_text}:保护价 {new_trail:.8g}" _record_event( @@ -1407,32 +1333,34 @@ def _update_trailing_stop(conn, trade: dict, current_price: float, pnl_pct: floa message, { "current_price": current_price, - "previous_trailing_stop": current_trail, + "previous_trailing_stop": decision.get("previous_trailing_stop"), "trailing_stop": new_trail, - "activate_pnl_pct": activate_pnl_pct, - "distance_pct": distance_pct, - "tier_label": tier_label, - "trailing_mode": profile.get("mode"), - "volatility_pct": profile.get("volatility_pct"), - "base_activate_pnl_pct": profile.get("base_activate_pnl_pct"), - "base_distance_pct": profile.get("base_distance_pct"), - "min_lock_profit_pct": cfg.get("min_lock_profit_pct"), + "activate_pnl_pct": decision.get("activate_pnl_pct"), + "distance_pct": decision.get("distance_pct"), + "tier_label": decision.get("tier_label"), + "trailing_mode": decision.get("trailing_mode"), + "volatility_pct": decision.get("volatility_pct"), + "base_activate_pnl_pct": decision.get("base_activate_pnl_pct"), + "base_distance_pct": decision.get("base_distance_pct"), + "min_lock_profit_pct": decision.get("min_lock_profit_pct"), "notification_throttled": False, + "trailing_decision": decision, }, event_time, ) _push_event_card(event_type, trade, {"trailing_stop": new_trail, "pnl_pct": pnl_pct}, event_time) return new_trail, { - "activated": activated, - "moved": moved, + "activated": bool(decision.get("activated")), + "moved": bool(decision.get("moved")), "trailing_stop": new_trail, - "previous_trailing_stop": current_trail, - "distance_pct": distance_pct, - "activate_pnl_pct": activate_pnl_pct, - "tier_label": tier_label, - "trailing_mode": profile.get("mode"), - "volatility_pct": profile.get("volatility_pct"), + "previous_trailing_stop": decision.get("previous_trailing_stop"), + "distance_pct": decision.get("distance_pct"), + "activate_pnl_pct": decision.get("activate_pnl_pct"), + "tier_label": decision.get("tier_label"), + "trailing_mode": decision.get("trailing_mode"), + "volatility_pct": decision.get("volatility_pct"), "notification_emitted": should_emit, + "trailing_decision": decision, } @@ -1461,6 +1389,12 @@ def _update_open_trade(conn, trade: dict, current_price: float, event_time: str) return _close_trade(conn, trade, current_price, reason, event_time) trailing_stop, trailing_result = _update_trailing_stop(conn, trade, current_price, pnl_pct, event_time or _now()) + guarded_trade = {**trade, "max_price": new_max, "min_price": new_min, "trailing_stop": trailing_stop} + guard_result = _apply_position_health_guard(conn, guarded_trade, current_price, pnl_pct, event_time or _now()) + if guard_result.get("closed"): + return guard_result + if guard_result.get("tightened"): + trailing_stop = max(trailing_stop, _safe_float(guard_result.get("trailing_stop"))) conn.execute( """ @@ -1475,7 +1409,7 @@ def _update_open_trade(conn, trade: dict, current_price: float, event_time: str) """, (current_price, new_max, new_min, trailing_stop, pnl_pct, event_time or _now(), trade["id"]), ) - return {"updated": True, "trade_id": trade["id"], "pnl_pct": pnl_pct, **trailing_result} + return {"updated": True, "trade_id": trade["id"], "pnl_pct": pnl_pct, **trailing_result, **guard_result} def sync_recommendation(rec: dict, current_price: float, event_time: str = "") -> dict: diff --git a/tests/test_order_lifecycle.py b/tests/test_order_lifecycle.py new file mode 100644 index 0000000..baa372b --- /dev/null +++ b/tests/test_order_lifecycle.py @@ -0,0 +1,55 @@ +import pytest + +from app.core.order_lifecycle import ( + evaluate_limit_order, + order_distance_pct, + order_expires_at, + order_rr, + order_touched, + order_too_far, +) + + +def test_limit_order_touched_for_long_and_short(): + assert order_touched({"side": "long", "target_price": 95}, 94.9) is True + assert order_touched({"side": "long", "target_price": 95}, 96) is False + assert order_touched({"side": "short", "target_price": 105}, 105.1) is True + assert order_touched({"side": "short", "target_price": 105}, 104.9) is False + + +def test_limit_order_cancel_when_expired(): + decision = evaluate_limit_order( + order={"id": 1, "side": "long", "target_price": 95, "expires_at": "2026-05-16T10:00:00"}, + current_price=100, + event_time="2026-05-16T10:01:00", + config={"order_cancel_far_from_entry_pct": 12}, + ).as_dict() + + assert decision["action"] == "cancel" + assert decision["reason"] == "expired" + + +def test_limit_order_cancel_when_price_moves_too_far(): + order = {"id": 1, "side": "long", "target_price": 95, "expires_at": "2026-05-17T10:00:00"} + + assert order_too_far(order, 107, threshold_pct=12) is True + decision = evaluate_limit_order( + order=order, + current_price=107, + event_time="2026-05-16T10:01:00", + config={"order_cancel_far_from_entry_pct": 12}, + ).as_dict() + + assert decision["action"] == "cancel" + assert decision["reason"] == "too_far_from_entry" + + +def test_order_rr_and_distance_are_side_aware(): + assert order_rr("long", 95, 90, 105) == pytest.approx(2.0) + assert order_rr("short", 105, 110, 95) == pytest.approx(2.0) + assert order_distance_pct("long", 100, 95) == pytest.approx(5.2631578947) + assert order_distance_pct("short", 100, 105) == pytest.approx(5.0) + + +def test_order_expires_at_uses_minimum_quarter_hour(): + assert order_expires_at("2026-05-16T10:00:00", expire_hours=0.1) == "2026-05-16T10:15:00" diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py index 0d99e70..9268f2a 100644 --- a/tests/test_paper_trading.py +++ b/tests/test_paper_trading.py @@ -1160,6 +1160,86 @@ def test_paper_trading_trailing_stop_never_moves_down(monkeypatch, buy_now_rec): assert pullback.get("moved") is False +def test_position_guard_tightens_when_trade_does_not_launch(monkeypatch, buy_now_rec): + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "0") + monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_SOFT_HOURS", "6") + monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_SOFT_MIN_MAX_PNL_PCT", "1.5") + monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_HARD_HOURS", "24") + monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_TIGHTEN_LOCK_PROFIT_PCT", "0.15") + + sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00") + result = sync_recommendation(buy_now_rec, 100.4, event_time="2026-05-16T16:01:00") + + assert result["updated"] is True + assert result["tightened"] is True + assert result["trailing_stop"] == pytest.approx(100.15) + assert result["position_health"]["reason"] == "position_timeout_soft" + trade = list_paper_trades(status="open")["items"][0] + assert trade["trailing_stop"] == pytest.approx(100.15) + events = list_paper_trade_events(symbol="PAPER/USDT", limit=20)["items"] + assert "position_guard_tighten" in [e["event_type"] for e in events] + + +def test_position_guard_closes_stale_unlaunched_trade(monkeypatch, buy_now_rec): + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "0") + monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_SOFT_HOURS", "0") + monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_HARD_HOURS", "1") + monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_HARD_MIN_MAX_PNL_PCT", "2.5") + + sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00") + result = sync_recommendation(buy_now_rec, 100.4, event_time="2026-05-16T11:10:00") + + assert result["closed"] is True + assert result["exit_reason"] == "position_timeout_weak" + trade = list_paper_trades()["items"][0] + assert trade["status"] == "closed" + assert trade["exit_reason"] == "position_timeout_weak" + + +def test_position_guard_closes_profit_giveback_before_trailing(monkeypatch, buy_now_rec): + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "0") + monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_SOFT_HOURS", "0") + monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_HARD_HOURS", "0") + monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_GIVEBACK_MIN_MAX_PNL_PCT", "2") + monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_GIVEBACK_EXIT_PCT", "70") + monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_GIVEBACK_EXIT_CURRENT_PNL_PCT", "0.6") + + sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00") + sync_recommendation(buy_now_rec, 102.5, event_time="2026-05-16T10:20:00") + result = sync_recommendation(buy_now_rec, 100.5, event_time="2026-05-16T10:40:00") + + assert result["closed"] is True + assert result["exit_reason"] == "profit_giveback_before_trailing" + assert result["pnl_pct"] == pytest.approx(0.5) + + +def test_position_guard_closes_unprotected_trade_when_market_turns_critical(monkeypatch, buy_now_rec): + monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "0") + monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MIN_AGE_HOURS", "0.5") + monkeypatch.setenv("ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MAX_PNL_PCT", "1") + monkeypatch.setattr( + "app.db.paper_trading.evaluate_global_risk", + lambda **kwargs: { + "enabled": True, + "allow_new_entries": True, + "risk_level": "critical", + "decision": "allow_reduced_size", + "reasons": ["测试环境进入 critical"], + }, + ) + + sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00") + result = sync_recommendation(buy_now_rec, 100.5, event_time="2026-05-16T10:40:00") + + assert result["closed"] is True + assert result["exit_reason"] == "market_risk_unprotected" + + def test_paper_trading_events_capture_open_close_and_trailing(monkeypatch, buy_now_rec): monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1") monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MODE", "fixed") diff --git a/tests/test_trailing_stop.py b/tests/test_trailing_stop.py new file mode 100644 index 0000000..28d4414 --- /dev/null +++ b/tests/test_trailing_stop.py @@ -0,0 +1,65 @@ +import pytest + +from app.core.trailing_stop import evaluate_trailing_stop, trailing_profile + + +def test_fixed_trailing_stop_activates_with_profit_floor(): + decision = evaluate_trailing_stop( + position={"entry_price": 100, "max_price": 100, "min_price": 100, "trailing_stop": 0}, + current_price=104, + pnl_pct=4, + config={ + "trailing_stop_enabled": True, + "trailing_mode": "fixed", + "trailing_activate_pnl_pct": 3, + "trailing_min_lock_profit_pct": 0.5, + "trailing_distance_pct": 1.5, + }, + ).as_dict() + + assert decision["action"] == "activate" + assert decision["event_type"] == "trailing_activate" + assert decision["activated"] is True + assert decision["trailing_mode"] == "fixed" + assert decision["trailing_stop"] == pytest.approx(102.44) + + +def test_trailing_stop_never_moves_down(): + decision = evaluate_trailing_stop( + position={"entry_price": 100, "max_price": 106, "min_price": 100, "trailing_stop": 104}, + current_price=104.5, + pnl_pct=4.5, + config={ + "trailing_stop_enabled": True, + "trailing_mode": "fixed", + "trailing_activate_pnl_pct": 3, + "trailing_min_lock_profit_pct": 0.5, + "trailing_distance_pct": 1.5, + }, + ).as_dict() + + assert decision["action"] == "hold" + assert decision["reason"] == "unchanged" + assert decision["trailing_stop"] == pytest.approx(104) + + +def test_volatility_profile_widens_activation_and_distance(): + profile = trailing_profile( + {"entry_price": 100, "max_price": 100, "min_price": 98}, + current_price=110, + pnl_pct=10, + config={ + "trailing_mode": "volatility", + "trailing_activate_pnl_pct": 3, + "trailing_distance_pct": 1.5, + "trailing_volatility_activation_mult": 0.6, + "trailing_volatility_distance_mult": 0.7, + "trailing_volatility_max_activation_pct": 8, + "trailing_volatility_max_distance_pct": 8, + }, + ) + + assert profile["trailing_mode"] == "volatility" + assert profile["volatility_pct"] == pytest.approx(12) + assert profile["activate_pnl_pct"] == pytest.approx(7.2) + assert profile["distance_pct"] == pytest.approx(8.0)