This commit is contained in:
aaron 2026-05-31 14:10:44 +08:00
parent 9d3201080f
commit 3787a845a2
10 changed files with 902 additions and 178 deletions

View File

@ -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

View File

@ -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 的工作方式建议

View File

@ -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)),

133
app/core/order_lifecycle.py Normal file
View File

@ -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",
]

195
app/core/position_health.py Normal file
View File

@ -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"]

195
app/core/trailing_stop.py Normal file
View File

@ -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",
]

View File

@ -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:

View File

@ -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"

View File

@ -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")

View File

@ -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)