update
This commit is contained in:
parent
9d3201080f
commit
3787a845a2
15
.env.example
15
.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
|
||||
|
||||
28
AGENTS.md
28
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 的工作方式建议
|
||||
|
||||
|
||||
@ -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
133
app/core/order_lifecycle.py
Normal 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
195
app/core/position_health.py
Normal 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
195
app/core/trailing_stop.py
Normal 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",
|
||||
]
|
||||
@ -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:
|
||||
|
||||
55
tests/test_order_lifecycle.py
Normal file
55
tests/test_order_lifecycle.py
Normal 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"
|
||||
@ -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")
|
||||
|
||||
65
tests/test_trailing_stop.py
Normal file
65
tests/test_trailing_stop.py
Normal 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)
|
||||
Loading…
Reference in New Issue
Block a user