update
This commit is contained in:
parent
3787a845a2
commit
447edff0f6
@ -180,6 +180,8 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
|
|||||||
- 市场环境识别中心,第一版基于市场快照、BTC/ETH 涨跌、山寨涨跌广度、强势/大跌数量和 funding 热度识别 `risk_off`、`btc_main_uptrend`、`altcoin_rotation`、`sideways_chop`、`meme_frenzy`、`unknown`。
|
- 市场环境识别中心,第一版基于市场快照、BTC/ETH 涨跌、山寨涨跌广度、强势/大跌数量和 funding 热度识别 `risk_off`、`btc_main_uptrend`、`altcoin_rotation`、`sideways_chop`、`meme_frenzy`、`unknown`。
|
||||||
- `app/core/global_risk.py`
|
- `app/core/global_risk.py`
|
||||||
- paper trading 全局风控门禁。单币机会进入开仓或挂单成交前,需要先检查市场环境和账户风险;critical 禁止新开仓,high 只允许高质量机会。
|
- paper trading 全局风控门禁。单币机会进入开仓或挂单成交前,需要先检查市场环境和账户风险;critical 禁止新开仓,high 只允许高质量机会。
|
||||||
|
- `app/core/trade_math.py`
|
||||||
|
- 多空通用交易数学中心。负责 side 归一、开仓/平仓滑点价、PnL、止损/止盈/移动止盈触发、保护价收紧比较。paper/live 不应各自实现一套多空收益和触发判断。
|
||||||
- `app/core/order_lifecycle.py`
|
- `app/core/order_lifecycle.py`
|
||||||
- 挂单生命周期决策中心。负责限价单是否触价、是否过期、是否远离入场、RR 与入场距离计算;不写 DB、不取消订单、不调用交易所。paper/live 适配层只能消费它的 `OrderLifecycleDecision`。
|
- 挂单生命周期决策中心。负责限价单是否触价、是否过期、是否远离入场、RR 与入场距离计算;不写 DB、不取消订单、不调用交易所。paper/live 适配层只能消费它的 `OrderLifecycleDecision`。
|
||||||
- `app/core/trailing_stop.py`
|
- `app/core/trailing_stop.py`
|
||||||
@ -187,7 +189,8 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
|
|||||||
- `app/core/position_health.py`
|
- `app/core/position_health.py`
|
||||||
- 已开仓仓位的健康检查中心。它不是入场规则,而是持仓后判断“机会是否仍按原计划运行”:超时未启动会收紧保护价或提前退出,浮盈大幅回吐且未触发移动止盈会提前退出,市场进入 critical 时未受保护的微盈利/弱势仓位可提前退出。
|
- 已开仓仓位的健康检查中心。它不是入场规则,而是持仓后判断“机会是否仍按原计划运行”:超时未启动会收紧保护价或提前退出,浮盈大幅回吐且未触发移动止盈会提前退出,市场进入 critical 时未受保护的微盈利/弱势仓位可提前退出。
|
||||||
- 交易执行能力必须按“决策核心 -> 执行适配 -> 账本记录”分层:移动止盈、仓位失效保护、动态杠杆、仓位 sizing、订单触发、账户风控都应有可被 paper trading 和 live trading 复用的核心模块。不要把这些规则直接写死在 `app/db/paper_trading.py`、`routes_live_trading.py` 或页面 JS 中。
|
- 交易执行能力必须按“决策核心 -> 执行适配 -> 账本记录”分层:移动止盈、仓位失效保护、动态杠杆、仓位 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`。
|
- 交易执行层已支持 `side=long/short`:paper trading 开仓、平仓、PnL、TP/SL、移动止盈、持仓健康和挂单触价都按 side 处理。当前已注册第一版独立空头策略 `breakdown_retest_short_1h_v1`,但这不代表发现/确认层已经全量接入空头扫描;新增做空必须继续使用独立 `strategy_code`、触发条件、失效条件和复盘口径,不能把多头策略简单反向。
|
||||||
|
- 多策略基础设施当前内置 `main_composite_v1`、`box_retest_4h_v1`、`box_retest_1h_v1`、`volume_ignition_1h_v1`、`compression_breakout_4h_v1`、`intraday_momentum_15m_v1`、`breakdown_retest_short_1h_v1`。`box_breakout_pullback_4h` / `box_breakout_pullback_1h`、`vp_fly_1h_current`、`short_tf_15m_ignition`、`breakdown_retest_1h_short` 等只是因子,只有和入场确认、风控、失效条件组成完整剧本后,才作为对应策略信号写入 `strategy_signals`。
|
||||||
- 确认层也会应用同一市场风控语义:`risk_level=critical` 且 `position_multiplier=0` 时,强势发现仍可记录为观察,但不能输出 `buy_now` 或新挂单动作;已有活跃可交易推荐会被降级为观察并写入 `market_risk_gate`。
|
- 确认层也会应用同一市场风控语义:`risk_level=critical` 且 `position_multiplier=0` 时,强势发现仍可记录为观察,但不能输出 `buy_now` 或新挂单动作;已有活跃可交易推荐会被降级为观察并写入 `market_risk_gate`。
|
||||||
|
|
||||||
## 5. 数据与状态中心
|
## 5. 数据与状态中心
|
||||||
|
|||||||
@ -31,6 +31,9 @@ VALID_FACTOR_ROLES = {
|
|||||||
DEFAULT_FACTOR_ROLES: dict[str, str] = {
|
DEFAULT_FACTOR_ROLES: dict[str, str] = {
|
||||||
"box_breakout_pullback_4h": TRIGGER,
|
"box_breakout_pullback_4h": TRIGGER,
|
||||||
"box_breakout_pullback_1h": TRIGGER,
|
"box_breakout_pullback_1h": TRIGGER,
|
||||||
|
"breakdown_retest_1h_short": TRIGGER,
|
||||||
|
"retest_reject_15m_short": ENTRY,
|
||||||
|
"market_risk_off_short": CONFIRMATION,
|
||||||
"vp_fly_1h_current": TRIGGER,
|
"vp_fly_1h_current": TRIGGER,
|
||||||
"volume_consecutive_1h": CONFIRMATION,
|
"volume_consecutive_1h": CONFIRMATION,
|
||||||
"volume_divergence_1h": RISK,
|
"volume_divergence_1h": RISK,
|
||||||
|
|||||||
@ -24,6 +24,9 @@ SIGNAL_CODE_LABELS = {
|
|||||||
"compression_surge_4h": "4H压缩放量",
|
"compression_surge_4h": "4H压缩放量",
|
||||||
"box_breakout_pullback_1h": "1H箱体突破回踩",
|
"box_breakout_pullback_1h": "1H箱体突破回踩",
|
||||||
"box_breakout_pullback_4h": "4H箱体突破回踩",
|
"box_breakout_pullback_4h": "4H箱体突破回踩",
|
||||||
|
"breakdown_retest_1h_short": "1H破位反抽做空",
|
||||||
|
"retest_reject_15m_short": "15min反抽失败",
|
||||||
|
"market_risk_off_short": "弱势环境空头确认",
|
||||||
"ignition_1h_current": "1H当前起爆点",
|
"ignition_1h_current": "1H当前起爆点",
|
||||||
"ignition_4h_current": "4H当前起爆点",
|
"ignition_4h_current": "4H当前起爆点",
|
||||||
"ignition_d1_current": "日线当前起爆点",
|
"ignition_d1_current": "日线当前起爆点",
|
||||||
@ -67,6 +70,9 @@ _PATTERNS = [
|
|||||||
("static_accum_4h", ("静K蓄力", "静K旁路")),
|
("static_accum_4h", ("静K蓄力", "静K旁路")),
|
||||||
("box_breakout_pullback_1h", ("1H", "箱体", "突破", "回踩")),
|
("box_breakout_pullback_1h", ("1H", "箱体", "突破", "回踩")),
|
||||||
("box_breakout_pullback_4h", ("4H", "箱体", "突破", "回踩")),
|
("box_breakout_pullback_4h", ("4H", "箱体", "突破", "回踩")),
|
||||||
|
("breakdown_retest_1h_short", ("1H", "破位", "反抽", "做空")),
|
||||||
|
("retest_reject_15m_short", ("15min", "反抽失败")),
|
||||||
|
("market_risk_off_short", ("risk_off", "空头")),
|
||||||
("higher_lows_4h", ("底部抬高",)),
|
("higher_lows_4h", ("底部抬高",)),
|
||||||
("compression_surge_4h", ("压缩放量",)),
|
("compression_surge_4h", ("压缩放量",)),
|
||||||
("ignition_stale", ("历史起爆点", "起爆点已过期", "旧起爆")),
|
("ignition_stale", ("历史起爆点", "起爆点已过期", "旧起爆")),
|
||||||
|
|||||||
@ -11,6 +11,7 @@ BOX_RETEST_4H_STRATEGY = "box_retest_4h_v1"
|
|||||||
VOLUME_IGNITION_1H_STRATEGY = "volume_ignition_1h_v1"
|
VOLUME_IGNITION_1H_STRATEGY = "volume_ignition_1h_v1"
|
||||||
COMPRESSION_BREAKOUT_4H_STRATEGY = "compression_breakout_4h_v1"
|
COMPRESSION_BREAKOUT_4H_STRATEGY = "compression_breakout_4h_v1"
|
||||||
INTRADAY_MOMENTUM_15M_STRATEGY = "intraday_momentum_15m_v1"
|
INTRADAY_MOMENTUM_15M_STRATEGY = "intraday_momentum_15m_v1"
|
||||||
|
BREAKDOWN_RETEST_SHORT_1H_STRATEGY = "breakdown_retest_short_1h_v1"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -139,6 +140,28 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = {
|
|||||||
"dynamic_leverage_min": 3,
|
"dynamic_leverage_min": 3,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
BREAKDOWN_RETEST_SHORT_1H_STRATEGY: StrategyDefinition(
|
||||||
|
strategy_code=BREAKDOWN_RETEST_SHORT_1H_STRATEGY,
|
||||||
|
strategy_name="1H破位反抽做空",
|
||||||
|
description="箱体或关键均线破位后反抽失败的空头策略;只用于独立空头样本,不与多头突破策略共享入场门槛。",
|
||||||
|
mode="paper_only",
|
||||||
|
entry_gate_config={
|
||||||
|
"direction": "short",
|
||||||
|
"min_entry_score_buy_now": 2,
|
||||||
|
"min_entry_score_wait_pullback": 1,
|
||||||
|
"min_rr_buy_now": 1.5,
|
||||||
|
"breakdown_distance_wait_pct": 10,
|
||||||
|
"max_retest_deviation_pct": 8,
|
||||||
|
},
|
||||||
|
paper_config={
|
||||||
|
"entry_min_rr": 1.8,
|
||||||
|
"order_min_rr": 1.8,
|
||||||
|
"order_min_distance_to_entry_pct": 0,
|
||||||
|
"order_require_current_trigger": True,
|
||||||
|
"dynamic_leverage_enabled": True,
|
||||||
|
"dynamic_leverage_min": 2,
|
||||||
|
},
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
112
app/core/trade_math.py
Normal file
112
app/core/trade_math.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
"""Side-aware execution math shared by paper and live trading adapters."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
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 normalize_side(side: str | None) -> str:
|
||||||
|
return "short" if str(side or "").strip().lower() == "short" else "long"
|
||||||
|
|
||||||
|
|
||||||
|
def open_price(side: str, current_price: float, slippage_pct: float = 0.0) -> float:
|
||||||
|
price = safe_float(current_price)
|
||||||
|
slip = max(0.0, safe_float(slippage_pct))
|
||||||
|
if price <= 0:
|
||||||
|
return 0.0
|
||||||
|
if normalize_side(side) == "short":
|
||||||
|
return round(price * (1 - slip / 100), 12)
|
||||||
|
return round(price * (1 + slip / 100), 12)
|
||||||
|
|
||||||
|
|
||||||
|
def close_price(side: str, current_price: float, slippage_pct: float = 0.0) -> float:
|
||||||
|
price = safe_float(current_price)
|
||||||
|
slip = max(0.0, safe_float(slippage_pct))
|
||||||
|
if price <= 0:
|
||||||
|
return 0.0
|
||||||
|
if normalize_side(side) == "short":
|
||||||
|
return round(price * (1 + slip / 100), 12)
|
||||||
|
return round(price * (1 - slip / 100), 12)
|
||||||
|
|
||||||
|
|
||||||
|
def pnl_pct(side: str, entry_price: float, current_price: float) -> float:
|
||||||
|
entry = safe_float(entry_price)
|
||||||
|
price = safe_float(current_price)
|
||||||
|
if entry <= 0 or price <= 0:
|
||||||
|
return 0.0
|
||||||
|
if normalize_side(side) == "short":
|
||||||
|
return round((entry / price - 1) * 100, 4)
|
||||||
|
return round((price / entry - 1) * 100, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_loss_distance_pct(side: str, entry_price: float, stop_loss: float) -> float:
|
||||||
|
entry = safe_float(entry_price)
|
||||||
|
stop = safe_float(stop_loss)
|
||||||
|
if entry <= 0 or stop <= 0:
|
||||||
|
return 0.0
|
||||||
|
if normalize_side(side) == "short":
|
||||||
|
return max(0.0, (stop / entry - 1) * 100)
|
||||||
|
return max(0.0, (1 - stop / entry) * 100)
|
||||||
|
|
||||||
|
|
||||||
|
def should_stop_loss(side: str, current_price: float, stop_loss: float) -> bool:
|
||||||
|
price = safe_float(current_price)
|
||||||
|
stop = safe_float(stop_loss)
|
||||||
|
if price <= 0 or stop <= 0:
|
||||||
|
return False
|
||||||
|
if normalize_side(side) == "short":
|
||||||
|
return price >= stop
|
||||||
|
return price <= stop
|
||||||
|
|
||||||
|
|
||||||
|
def should_take_profit(side: str, current_price: float, target: float) -> bool:
|
||||||
|
price = safe_float(current_price)
|
||||||
|
tp = safe_float(target)
|
||||||
|
if price <= 0 or tp <= 0:
|
||||||
|
return False
|
||||||
|
if normalize_side(side) == "short":
|
||||||
|
return price <= tp
|
||||||
|
return price >= tp
|
||||||
|
|
||||||
|
|
||||||
|
def should_hit_trailing_stop(side: str, current_price: float, trailing_stop: float) -> bool:
|
||||||
|
price = safe_float(current_price)
|
||||||
|
stop = safe_float(trailing_stop)
|
||||||
|
if price <= 0 or stop <= 0:
|
||||||
|
return False
|
||||||
|
if normalize_side(side) == "short":
|
||||||
|
return price >= stop
|
||||||
|
return price <= stop
|
||||||
|
|
||||||
|
|
||||||
|
def tighter_stop(side: str, current_stop: float, candidate_stop: float) -> float:
|
||||||
|
current = safe_float(current_stop)
|
||||||
|
candidate = safe_float(candidate_stop)
|
||||||
|
if candidate <= 0:
|
||||||
|
return current
|
||||||
|
if current <= 0:
|
||||||
|
return candidate
|
||||||
|
if normalize_side(side) == "short":
|
||||||
|
return min(current, candidate)
|
||||||
|
return max(current, candidate)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"close_price",
|
||||||
|
"normalize_side",
|
||||||
|
"open_price",
|
||||||
|
"pnl_pct",
|
||||||
|
"safe_float",
|
||||||
|
"should_hit_trailing_stop",
|
||||||
|
"should_stop_loss",
|
||||||
|
"should_take_profit",
|
||||||
|
"stop_loss_distance_pct",
|
||||||
|
"tighter_stop",
|
||||||
|
]
|
||||||
@ -9,6 +9,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from app.core.trade_math import normalize_side, tighter_stop
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(value, default: float = 0.0) -> float:
|
def _safe_float(value, default: float = 0.0) -> float:
|
||||||
try:
|
try:
|
||||||
@ -158,12 +160,20 @@ def evaluate_trailing_stop(
|
|||||||
detail={"reason": "below_activation"},
|
detail={"reason": "below_activation"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
side = normalize_side(position.get("side"))
|
||||||
distance_pct = _safe_float(profile.get("distance_pct"), cfg.get("distance_pct"))
|
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)
|
if side == "short":
|
||||||
candidate = current_price * (1 - distance_pct / 100)
|
protection_floor = entry_price * (1 - _safe_float(cfg.get("min_lock_profit_pct")) / 100)
|
||||||
new_trail = round(max(current_trail, protection_floor, candidate), 12)
|
candidate = current_price * (1 + distance_pct / 100)
|
||||||
|
new_trail = round(tighter_stop(side, current_trail, min(protection_floor, candidate)), 12)
|
||||||
|
else:
|
||||||
|
protection_floor = entry_price * (1 + _safe_float(cfg.get("min_lock_profit_pct")) / 100)
|
||||||
|
candidate = current_price * (1 - distance_pct / 100)
|
||||||
|
new_trail = round(tighter_stop(side, current_trail, max(protection_floor, candidate)), 12)
|
||||||
activated = current_trail <= 0 and new_trail > 0
|
activated = current_trail <= 0 and new_trail > 0
|
||||||
moved = current_trail > 0 and new_trail > current_trail + 1e-12
|
moved = current_trail > 0 and (
|
||||||
|
new_trail < current_trail - 1e-12 if side == "short" else new_trail > current_trail + 1e-12
|
||||||
|
)
|
||||||
if not activated and not moved:
|
if not activated and not moved:
|
||||||
return TrailingStopDecision(
|
return TrailingStopDecision(
|
||||||
action="hold",
|
action="hold",
|
||||||
|
|||||||
@ -16,6 +16,17 @@ from app.core.order_lifecycle import (
|
|||||||
)
|
)
|
||||||
from app.core.position_health import evaluate_position_health
|
from app.core.position_health import evaluate_position_health
|
||||||
from app.core.trailing_stop import evaluate_trailing_stop, normalize_trailing_config
|
from app.core.trailing_stop import evaluate_trailing_stop, normalize_trailing_config
|
||||||
|
from app.core.trade_math import (
|
||||||
|
close_price as side_close_price,
|
||||||
|
normalize_side,
|
||||||
|
open_price as side_open_price,
|
||||||
|
pnl_pct as side_pnl_pct,
|
||||||
|
should_hit_trailing_stop,
|
||||||
|
should_stop_loss,
|
||||||
|
should_take_profit,
|
||||||
|
stop_loss_distance_pct as side_stop_loss_distance_pct,
|
||||||
|
tighter_stop,
|
||||||
|
)
|
||||||
from app.core.strategy_registry import normalize_strategy_code, strategy_label, strategy_paper_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.schema import get_conn
|
||||||
from app.db.system_logs import record_system_error
|
from app.db.system_logs import record_system_error
|
||||||
@ -317,26 +328,20 @@ def _parse_time(value: str):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _open_price(current_price: float, config: dict | None = None) -> float:
|
def _open_price(current_price: float, config: dict | None = None, side: str = "long") -> float:
|
||||||
return round(current_price * (1 + default_slippage_pct(config) / 100), 12)
|
return side_open_price(side, current_price, default_slippage_pct(config))
|
||||||
|
|
||||||
|
|
||||||
def _close_price(current_price: float, config: dict | None = None) -> float:
|
def _close_price(current_price: float, config: dict | None = None, side: str = "long") -> float:
|
||||||
return round(current_price * (1 - default_slippage_pct(config) / 100), 12)
|
return side_close_price(side, current_price, default_slippage_pct(config))
|
||||||
|
|
||||||
|
|
||||||
def _trade_pnl_pct(entry_price: float, current_price: float) -> float:
|
def _trade_pnl_pct(entry_price: float, current_price: float, side: str = "long") -> float:
|
||||||
if entry_price <= 0 or current_price <= 0:
|
return side_pnl_pct(side, entry_price, current_price)
|
||||||
return 0.0
|
|
||||||
return round((current_price / entry_price - 1) * 100, 4)
|
|
||||||
|
|
||||||
|
|
||||||
def _stop_loss_distance_pct(side: str, entry_price: float, stop_loss: float) -> float:
|
def _stop_loss_distance_pct(side: str, entry_price: float, stop_loss: float) -> float:
|
||||||
if entry_price <= 0 or stop_loss <= 0:
|
return side_stop_loss_distance_pct(side, entry_price, stop_loss)
|
||||||
return 0.0
|
|
||||||
if str(side or "long").lower() == "short":
|
|
||||||
return max(0.0, (stop_loss / entry_price - 1) * 100)
|
|
||||||
return max(0.0, (1 - stop_loss / entry_price) * 100)
|
|
||||||
|
|
||||||
|
|
||||||
def _stop_loss_leverage_risk_pct(side: str, entry_price: float, stop_loss: float, leverage: float) -> float:
|
def _stop_loss_leverage_risk_pct(side: str, entry_price: float, stop_loss: float, leverage: float) -> float:
|
||||||
@ -637,9 +642,9 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config:
|
|||||||
rec_id = _safe_int(rec.get("id"))
|
rec_id = _safe_int(rec.get("id"))
|
||||||
symbol = str(rec.get("symbol") or "").strip().upper()
|
symbol = str(rec.get("symbol") or "").strip().upper()
|
||||||
plan = _entry_plan(rec)
|
plan = _entry_plan(rec)
|
||||||
entry_price = _open_price(current_price, cfg)
|
side = normalize_side(plan.get("side") or rec.get("side"))
|
||||||
|
entry_price = _open_price(current_price, cfg, side)
|
||||||
notional = default_notional_usdt(cfg)
|
notional = default_notional_usdt(cfg)
|
||||||
side = str(plan.get("side") or rec.get("side") or "long").strip().lower() or "long"
|
|
||||||
leverage = default_leverage(cfg)
|
leverage = default_leverage(cfg)
|
||||||
stop_loss = _safe_float(plan.get("stop_loss") or rec.get("stop_loss"))
|
stop_loss = _safe_float(plan.get("stop_loss") or rec.get("stop_loss"))
|
||||||
tp1 = _safe_float(plan.get("tp1") or plan.get("take_profit_1") or rec.get("tp1"))
|
tp1 = _safe_float(plan.get("tp1") or plan.get("take_profit_1") or rec.get("tp1"))
|
||||||
@ -735,13 +740,14 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config:
|
|||||||
max_price, min_price, current_price, pnl_pct, fee_usdt,
|
max_price, min_price, current_price, pnl_pct, fee_usdt,
|
||||||
source_status, source_action, strategy_version, strategy_code, strategy_signal_id,
|
source_status, source_action, strategy_version, strategy_code, strategy_signal_id,
|
||||||
strategy_snapshot_json, factor_roles_json, created_at, updated_at
|
strategy_snapshot_json, factor_roles_json, created_at, updated_at
|
||||||
) VALUES (%s,%s,'long','open',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
) VALUES (%s,%s,%s,'open',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
ON CONFLICT(recommendation_id) DO NOTHING
|
ON CONFLICT(recommendation_id) DO NOTHING
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
rec_id,
|
rec_id,
|
||||||
symbol,
|
symbol,
|
||||||
|
side,
|
||||||
now,
|
now,
|
||||||
entry_price,
|
entry_price,
|
||||||
qty,
|
qty,
|
||||||
@ -1062,7 +1068,8 @@ def _fill_paper_order(conn, order: dict, rec: dict, current_price: float, event_
|
|||||||
result["paper_order"] = {"filled": True, "order_id": order["id"], "fill_price": fill_price}
|
result["paper_order"] = {"filled": True, "order_id": order["id"], "fill_price": fill_price}
|
||||||
_push_order_filled_card(order, result, event_time)
|
_push_order_filled_card(order, result, event_time)
|
||||||
stop_loss = _safe_float(rec.get("stop_loss") or _entry_plan(rec).get("stop_loss") or order.get("stop_loss"))
|
stop_loss = _safe_float(rec.get("stop_loss") or _entry_plan(rec).get("stop_loss") or order.get("stop_loss"))
|
||||||
if stop_loss > 0 and current_price <= stop_loss:
|
side = normalize_side(order.get("side") or _entry_plan(rec).get("side") or rec.get("side"))
|
||||||
|
if should_stop_loss(side, current_price, stop_loss):
|
||||||
trade = conn.execute("SELECT * FROM paper_trades WHERE id=%s", (result["trade_id"],)).fetchone()
|
trade = conn.execute("SELECT * FROM paper_trades WHERE id=%s", (result["trade_id"],)).fetchone()
|
||||||
if trade:
|
if trade:
|
||||||
close_result = _close_trade(conn, dict(trade), current_price, "stop_loss_same_tick", event_time)
|
close_result = _close_trade(conn, dict(trade), current_price, "stop_loss_same_tick", event_time)
|
||||||
@ -1199,8 +1206,9 @@ def _sync_wait_pullback_order(conn, rec: dict, current_price: float, event_time:
|
|||||||
|
|
||||||
def _close_trade(conn, trade: dict, current_price: float, reason: str, event_time: str, detail: dict | None = None) -> 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"))
|
entry_price = _safe_float(trade.get("entry_price"))
|
||||||
exit_price = _close_price(current_price)
|
side = normalize_side(trade.get("side"))
|
||||||
pnl_pct = _trade_pnl_pct(entry_price, exit_price)
|
exit_price = _close_price(current_price, side=side)
|
||||||
|
pnl_pct = _trade_pnl_pct(entry_price, exit_price, side)
|
||||||
notional = _safe_float(trade.get("notional_usdt"))
|
notional = _safe_float(trade.get("notional_usdt"))
|
||||||
open_fee = _safe_float(trade.get("fee_usdt"))
|
open_fee = _safe_float(trade.get("fee_usdt"))
|
||||||
close_fee = round(notional * default_fee_rate(), 8)
|
close_fee = round(notional * default_fee_rate(), 8)
|
||||||
@ -1284,7 +1292,9 @@ def _apply_position_health_guard(
|
|||||||
if action == "tighten_stop":
|
if action == "tighten_stop":
|
||||||
guard_stop = _safe_float(decision.get("guard_stop"))
|
guard_stop = _safe_float(decision.get("guard_stop"))
|
||||||
current_trail = _safe_float(trade.get("trailing_stop"))
|
current_trail = _safe_float(trade.get("trailing_stop"))
|
||||||
if guard_stop > 0 and guard_stop > current_trail + 1e-12:
|
side = normalize_side(trade.get("side"))
|
||||||
|
next_stop = tighter_stop(side, current_trail, guard_stop)
|
||||||
|
if guard_stop > 0 and abs(next_stop - current_trail) > 1e-12:
|
||||||
_record_event(
|
_record_event(
|
||||||
conn,
|
conn,
|
||||||
trade["id"],
|
trade["id"],
|
||||||
@ -1297,7 +1307,7 @@ def _apply_position_health_guard(
|
|||||||
{"position_health": decision},
|
{"position_health": decision},
|
||||||
event_time,
|
event_time,
|
||||||
)
|
)
|
||||||
return {"updated": True, "tightened": True, "trailing_stop": guard_stop, "position_health": decision}
|
return {"updated": True, "tightened": True, "trailing_stop": next_stop, "position_health": decision}
|
||||||
return {"updated": True, "tightened": False, "position_health": decision}
|
return {"updated": True, "tightened": False, "position_health": decision}
|
||||||
|
|
||||||
|
|
||||||
@ -1366,23 +1376,24 @@ def _update_trailing_stop(conn, trade: dict, current_price: float, pnl_pct: floa
|
|||||||
|
|
||||||
def _update_open_trade(conn, trade: dict, current_price: float, event_time: str) -> dict:
|
def _update_open_trade(conn, trade: dict, current_price: float, event_time: str) -> dict:
|
||||||
entry_price = _safe_float(trade.get("entry_price"))
|
entry_price = _safe_float(trade.get("entry_price"))
|
||||||
|
side = normalize_side(trade.get("side"))
|
||||||
old_max = _safe_float(trade.get("max_price")) or entry_price
|
old_max = _safe_float(trade.get("max_price")) or entry_price
|
||||||
old_min = _safe_float(trade.get("min_price")) or entry_price
|
old_min = _safe_float(trade.get("min_price")) or entry_price
|
||||||
new_max = max(old_max, current_price)
|
new_max = max(old_max, current_price)
|
||||||
new_min = min(old_min, current_price)
|
new_min = min(old_min, current_price)
|
||||||
pnl_pct = _trade_pnl_pct(entry_price, current_price)
|
pnl_pct = _trade_pnl_pct(entry_price, current_price, side)
|
||||||
stop_loss = _safe_float(trade.get("stop_loss"))
|
stop_loss = _safe_float(trade.get("stop_loss"))
|
||||||
trailing_stop = _safe_float(trade.get("trailing_stop"))
|
trailing_stop = _safe_float(trade.get("trailing_stop"))
|
||||||
tp2 = _safe_float(trade.get("tp2"))
|
tp2 = _safe_float(trade.get("tp2"))
|
||||||
tp1 = _safe_float(trade.get("tp1"))
|
tp1 = _safe_float(trade.get("tp1"))
|
||||||
reason = ""
|
reason = ""
|
||||||
if stop_loss > 0 and current_price <= stop_loss:
|
if should_stop_loss(side, current_price, stop_loss):
|
||||||
reason = "stop_loss"
|
reason = "stop_loss"
|
||||||
elif trailing_stop > 0 and current_price <= trailing_stop:
|
elif should_hit_trailing_stop(side, current_price, trailing_stop):
|
||||||
reason = "trailing_stop"
|
reason = "trailing_stop"
|
||||||
elif tp2 > 0 and current_price >= tp2:
|
elif should_take_profit(side, current_price, tp2):
|
||||||
reason = "tp2"
|
reason = "tp2"
|
||||||
elif tp1 > 0 and current_price >= tp1:
|
elif should_take_profit(side, current_price, tp1):
|
||||||
reason = "tp1"
|
reason = "tp1"
|
||||||
|
|
||||||
if reason:
|
if reason:
|
||||||
@ -1394,7 +1405,7 @@ def _update_open_trade(conn, trade: dict, current_price: float, event_time: str)
|
|||||||
if guard_result.get("closed"):
|
if guard_result.get("closed"):
|
||||||
return guard_result
|
return guard_result
|
||||||
if guard_result.get("tightened"):
|
if guard_result.get("tightened"):
|
||||||
trailing_stop = max(trailing_stop, _safe_float(guard_result.get("trailing_stop")))
|
trailing_stop = tighter_stop(side, trailing_stop, _safe_float(guard_result.get("trailing_stop")))
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
@ -1797,7 +1808,7 @@ def get_paper_trading_performance(days: int = 30) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "", strategy_code: str = "") -> dict:
|
def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "", strategy_code: str = "", side: str = "") -> dict:
|
||||||
limit = max(1, min(_safe_int(limit, 50), 200))
|
limit = max(1, min(_safe_int(limit, 50), 200))
|
||||||
offset = max(0, _safe_int(offset, 0))
|
offset = max(0, _safe_int(offset, 0))
|
||||||
status = str(status or "").strip()
|
status = str(status or "").strip()
|
||||||
@ -1811,6 +1822,10 @@ def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "", strate
|
|||||||
if strategy_code:
|
if strategy_code:
|
||||||
clauses.append("strategy_code=%s")
|
clauses.append("strategy_code=%s")
|
||||||
params.append(normalize_strategy_code(strategy_code))
|
params.append(normalize_strategy_code(strategy_code))
|
||||||
|
side = str(side or "").strip().lower()
|
||||||
|
if side in {"long", "short"}:
|
||||||
|
clauses.append("LOWER(COALESCE(side, 'long'))=%s")
|
||||||
|
params.append(side)
|
||||||
where = "WHERE " + " AND ".join(clauses) if clauses else ""
|
where = "WHERE " + " AND ".join(clauses) if clauses else ""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
@ -1838,7 +1853,7 @@ def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "", strate
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_paper_orders(limit: int = 50, offset: int = 0, status: str = "", strategy_code: str = "") -> dict:
|
def list_paper_orders(limit: int = 50, offset: int = 0, status: str = "", strategy_code: str = "", side: str = "") -> dict:
|
||||||
limit = max(1, min(_safe_int(limit, 50), 200))
|
limit = max(1, min(_safe_int(limit, 50), 200))
|
||||||
offset = max(0, _safe_int(offset, 0))
|
offset = max(0, _safe_int(offset, 0))
|
||||||
status = str(status or "").strip()
|
status = str(status or "").strip()
|
||||||
@ -1852,6 +1867,10 @@ def list_paper_orders(limit: int = 50, offset: int = 0, status: str = "", strate
|
|||||||
if strategy_code:
|
if strategy_code:
|
||||||
clauses.append("strategy_code=%s")
|
clauses.append("strategy_code=%s")
|
||||||
params.append(normalize_strategy_code(strategy_code))
|
params.append(normalize_strategy_code(strategy_code))
|
||||||
|
side = str(side or "").strip().lower()
|
||||||
|
if side in {"long", "short"}:
|
||||||
|
clauses.append("LOWER(COALESCE(side, 'long'))=%s")
|
||||||
|
params.append(side)
|
||||||
where = "WHERE " + " AND ".join(clauses) if clauses else ""
|
where = "WHERE " + " AND ".join(clauses) if clauses else ""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
@ -1890,7 +1909,7 @@ def list_paper_orders(limit: int = 50, offset: int = 0, status: str = "", strate
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "", event_type: str = "", strategy_code: str = "") -> dict:
|
def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "", event_type: str = "", strategy_code: str = "", side: str = "") -> dict:
|
||||||
limit = max(1, min(_safe_int(limit, 80), 200))
|
limit = max(1, min(_safe_int(limit, 80), 200))
|
||||||
offset = max(0, _safe_int(offset, 0))
|
offset = max(0, _safe_int(offset, 0))
|
||||||
symbol = str(symbol or "").strip().upper()
|
symbol = str(symbol or "").strip().upper()
|
||||||
@ -1907,6 +1926,10 @@ def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "",
|
|||||||
if strategy_code:
|
if strategy_code:
|
||||||
where.append("COALESCE(NULLIF(e.strategy_code, ''), t.strategy_code)=%s")
|
where.append("COALESCE(NULLIF(e.strategy_code, ''), t.strategy_code)=%s")
|
||||||
params.append(normalize_strategy_code(strategy_code))
|
params.append(normalize_strategy_code(strategy_code))
|
||||||
|
side = str(side or "").strip().lower()
|
||||||
|
if side in {"long", "short"}:
|
||||||
|
where.append("LOWER(COALESCE(t.side, 'long'))=%s")
|
||||||
|
params.append(side)
|
||||||
where_sql = "WHERE " + " AND ".join(where) if where else ""
|
where_sql = "WHERE " + " AND ".join(where) if where else ""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
@ -1930,6 +1953,7 @@ def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "",
|
|||||||
t.margin_usdt,
|
t.margin_usdt,
|
||||||
t.leverage,
|
t.leverage,
|
||||||
t.exit_reason,
|
t.exit_reason,
|
||||||
|
t.side AS trade_side,
|
||||||
t.opened_at,
|
t.opened_at,
|
||||||
t.closed_at,
|
t.closed_at,
|
||||||
t.strategy_code AS trade_strategy_code,
|
t.strategy_code AS trade_strategy_code,
|
||||||
@ -1951,6 +1975,7 @@ def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "",
|
|||||||
item["strategy_code"] = normalize_strategy_code(item.get("strategy_code") or item.get("trade_strategy_code"))
|
item["strategy_code"] = normalize_strategy_code(item.get("strategy_code") or item.get("trade_strategy_code"))
|
||||||
item["strategy_name"] = strategy_label(item["strategy_code"])
|
item["strategy_name"] = strategy_label(item["strategy_code"])
|
||||||
item["strategy_signal_id"] = _safe_int(item.get("strategy_signal_id") or item.get("trade_strategy_signal_id"))
|
item["strategy_signal_id"] = _safe_int(item.get("strategy_signal_id") or item.get("trade_strategy_signal_id"))
|
||||||
|
item["side"] = normalize_side(item.get("trade_side"))
|
||||||
items.append(item)
|
items.append(item)
|
||||||
return {
|
return {
|
||||||
"items": items,
|
"items": items,
|
||||||
|
|||||||
@ -54,6 +54,15 @@ def _pct(part, total):
|
|||||||
return round(float(part or 0) / float(total or 0) * 100, 2) if total else 0.0
|
return round(float(part or 0) / float(total or 0) * 100, 2) if total else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _strategy_direction(definition) -> str:
|
||||||
|
direction = str((definition.entry_gate_config or {}).get("direction") or "long").strip().lower()
|
||||||
|
return "short" if direction == "short" else "long"
|
||||||
|
|
||||||
|
|
||||||
|
def _direction_label(direction: str) -> str:
|
||||||
|
return "空" if str(direction or "").lower() == "short" else "多"
|
||||||
|
|
||||||
|
|
||||||
def evaluate_strategy_decision(metrics: dict) -> dict:
|
def evaluate_strategy_decision(metrics: dict) -> dict:
|
||||||
"""Turn strategy metrics into an explicit lifecycle recommendation.
|
"""Turn strategy metrics into an explicit lifecycle recommendation.
|
||||||
|
|
||||||
@ -145,6 +154,8 @@ def get_strategy_evaluation(days: int = 30) -> dict:
|
|||||||
"description": definition.description,
|
"description": definition.description,
|
||||||
"mode": definition.mode,
|
"mode": definition.mode,
|
||||||
"status": definition.status,
|
"status": definition.status,
|
||||||
|
"direction": _strategy_direction(definition),
|
||||||
|
"direction_label": _direction_label(_strategy_direction(definition)),
|
||||||
"signal_count": 0,
|
"signal_count": 0,
|
||||||
"candidate_signal_count": 0,
|
"candidate_signal_count": 0,
|
||||||
"observe_signal_count": 0,
|
"observe_signal_count": 0,
|
||||||
@ -158,6 +169,8 @@ def get_strategy_evaluation(days: int = 30) -> dict:
|
|||||||
"filled_order_count": 0,
|
"filled_order_count": 0,
|
||||||
"canceled_order_count": 0,
|
"canceled_order_count": 0,
|
||||||
"trade_count": 0,
|
"trade_count": 0,
|
||||||
|
"long_trade_count": 0,
|
||||||
|
"short_trade_count": 0,
|
||||||
"open_trade_count": 0,
|
"open_trade_count": 0,
|
||||||
"closed_trade_count": 0,
|
"closed_trade_count": 0,
|
||||||
"win_count": 0,
|
"win_count": 0,
|
||||||
@ -172,12 +185,15 @@ def get_strategy_evaluation(days: int = 30) -> dict:
|
|||||||
normalized = normalize_strategy_code(code)
|
normalized = normalize_strategy_code(code)
|
||||||
if normalized not in metrics:
|
if normalized not in metrics:
|
||||||
definition = strategy_definition(normalized)
|
definition = strategy_definition(normalized)
|
||||||
|
direction = _strategy_direction(definition)
|
||||||
metrics[normalized] = {
|
metrics[normalized] = {
|
||||||
"strategy_code": normalized,
|
"strategy_code": normalized,
|
||||||
"strategy_name": definition.strategy_name,
|
"strategy_name": definition.strategy_name,
|
||||||
"description": definition.description,
|
"description": definition.description,
|
||||||
"mode": definition.mode,
|
"mode": definition.mode,
|
||||||
"status": definition.status,
|
"status": definition.status,
|
||||||
|
"direction": direction,
|
||||||
|
"direction_label": _direction_label(direction),
|
||||||
"signal_count": 0,
|
"signal_count": 0,
|
||||||
"candidate_signal_count": 0,
|
"candidate_signal_count": 0,
|
||||||
"observe_signal_count": 0,
|
"observe_signal_count": 0,
|
||||||
@ -191,6 +207,8 @@ def get_strategy_evaluation(days: int = 30) -> dict:
|
|||||||
"filled_order_count": 0,
|
"filled_order_count": 0,
|
||||||
"canceled_order_count": 0,
|
"canceled_order_count": 0,
|
||||||
"trade_count": 0,
|
"trade_count": 0,
|
||||||
|
"long_trade_count": 0,
|
||||||
|
"short_trade_count": 0,
|
||||||
"open_trade_count": 0,
|
"open_trade_count": 0,
|
||||||
"closed_trade_count": 0,
|
"closed_trade_count": 0,
|
||||||
"win_count": 0,
|
"win_count": 0,
|
||||||
@ -260,7 +278,7 @@ def get_strategy_evaluation(days: int = 30) -> dict:
|
|||||||
|
|
||||||
for row in conn.execute(
|
for row in conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT strategy_code, status, realized_pnl_pct, realized_pnl_usdt, pnl_pct
|
SELECT strategy_code, side, status, realized_pnl_pct, realized_pnl_usdt, pnl_pct
|
||||||
FROM paper_trades
|
FROM paper_trades
|
||||||
WHERE opened_at >= %s
|
WHERE opened_at >= %s
|
||||||
""",
|
""",
|
||||||
@ -268,7 +286,12 @@ def get_strategy_evaluation(days: int = 30) -> dict:
|
|||||||
).fetchall():
|
).fetchall():
|
||||||
b = bucket(row.get("strategy_code"))
|
b = bucket(row.get("strategy_code"))
|
||||||
status = row.get("status") or ""
|
status = row.get("status") or ""
|
||||||
|
side = str(row.get("side") or "long").strip().lower()
|
||||||
b["trade_count"] += 1
|
b["trade_count"] += 1
|
||||||
|
if side == "short":
|
||||||
|
b["short_trade_count"] += 1
|
||||||
|
else:
|
||||||
|
b["long_trade_count"] += 1
|
||||||
if status == "open":
|
if status == "open":
|
||||||
b["open_trade_count"] += 1
|
b["open_trade_count"] += 1
|
||||||
if status == "closed":
|
if status == "closed":
|
||||||
|
|||||||
113
app/strategies/short_breakdown.py
Normal file
113
app/strategies/short_breakdown.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
"""Independent short strategy builders.
|
||||||
|
|
||||||
|
Short setups are not inverted long setups. They need their own trigger,
|
||||||
|
confirmation, invalidation and review lineage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.factor_roles import CONFIRMATION, ENTRY, RISK, TRIGGER
|
||||||
|
from app.core.strategy_contract import StrategySignal, current_strategy_version
|
||||||
|
from app.core.strategy_registry import BREAKDOWN_RETEST_SHORT_1H_STRATEGY
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(value, default=0.0) -> float:
|
||||||
|
try:
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
return float(value)
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def build_breakdown_retest_short_1h_signal(
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
current_price: float,
|
||||||
|
detection: dict,
|
||||||
|
entry_plan: dict | None = None,
|
||||||
|
market_regime: dict | None = None,
|
||||||
|
decision_log: dict | None = None,
|
||||||
|
) -> StrategySignal | None:
|
||||||
|
"""Build a short signal for breakdown -> failed retest setups.
|
||||||
|
|
||||||
|
Expected detection fields are intentionally simple so scanner/confirm
|
||||||
|
implementations can evolve without changing the strategy contract:
|
||||||
|
detected, breakdown_level, retest_zone, stop_level, target_1, quality,
|
||||||
|
retest_rejected, score.
|
||||||
|
"""
|
||||||
|
if not (detection or {}).get("detected"):
|
||||||
|
return None
|
||||||
|
entry_plan = dict(entry_plan or {})
|
||||||
|
market_regime = market_regime or {}
|
||||||
|
quality = str(detection.get("quality") or "")
|
||||||
|
retest_rejected = bool(detection.get("retest_rejected"))
|
||||||
|
current_price = _safe_float(current_price)
|
||||||
|
retest_zone = _safe_float(detection.get("retest_zone") or detection.get("breakdown_level"))
|
||||||
|
distance_pct = abs(current_price / retest_zone - 1) * 100 if current_price > 0 and retest_zone > 0 else 0.0
|
||||||
|
risk_level = str(market_regime.get("risk_level") or "medium").lower()
|
||||||
|
reasons = []
|
||||||
|
status = "candidate"
|
||||||
|
if quality not in {"良好", "优质"}:
|
||||||
|
status = "observe"
|
||||||
|
reasons.append(f"反抽质量 {quality or '未知'},不直接做空")
|
||||||
|
if not retest_rejected:
|
||||||
|
status = "observe"
|
||||||
|
reasons.append("尚未确认反抽失败")
|
||||||
|
if risk_level in {"low", "medium"} and not bool(detection.get("relative_weakness")):
|
||||||
|
status = "observe"
|
||||||
|
reasons.append("市场并非明显弱势,且缺少相对弱势确认")
|
||||||
|
if distance_pct > 8:
|
||||||
|
status = "observe"
|
||||||
|
reasons.append(f"当前价离反抽区 {distance_pct:.1f}%,不追空")
|
||||||
|
|
||||||
|
entry_plan.setdefault("side", "short")
|
||||||
|
entry_plan.setdefault("entry_action", "可即刻买入" if status == "candidate" else "观察")
|
||||||
|
entry_plan.setdefault("entry_price", current_price)
|
||||||
|
entry_plan.setdefault("stop_loss", detection.get("stop_level"))
|
||||||
|
entry_plan.setdefault("tp1", detection.get("target_1"))
|
||||||
|
entry_plan.setdefault("risk_reward_ok", True)
|
||||||
|
|
||||||
|
score = _safe_float(detection.get("score"))
|
||||||
|
confidence = min(100.0, max(0.0, score * 8 + (12 if retest_rejected else 0)))
|
||||||
|
return StrategySignal(
|
||||||
|
strategy_code=BREAKDOWN_RETEST_SHORT_1H_STRATEGY,
|
||||||
|
strategy_version=current_strategy_version(),
|
||||||
|
symbol=symbol,
|
||||||
|
direction="short",
|
||||||
|
status=status,
|
||||||
|
confidence=confidence,
|
||||||
|
score=score,
|
||||||
|
trigger={
|
||||||
|
"factor_code": "breakdown_retest_1h_short",
|
||||||
|
"factor_label": "1H破位反抽做空",
|
||||||
|
"breakdown_level": detection.get("breakdown_level"),
|
||||||
|
"retest_zone": retest_zone,
|
||||||
|
"stop_level": detection.get("stop_level"),
|
||||||
|
"target_1": detection.get("target_1"),
|
||||||
|
"quality": quality,
|
||||||
|
"retest_rejected": retest_rejected,
|
||||||
|
"distance_to_retest_zone_pct": round(distance_pct, 4),
|
||||||
|
"risk_level": risk_level,
|
||||||
|
},
|
||||||
|
factor_roles={
|
||||||
|
"breakdown_retest_1h_short": TRIGGER,
|
||||||
|
"retest_reject_15m_short": ENTRY,
|
||||||
|
"market_risk_off_short": CONFIRMATION,
|
||||||
|
"false_breakout": RISK,
|
||||||
|
"funding_extreme": RISK,
|
||||||
|
},
|
||||||
|
entry_plan=entry_plan,
|
||||||
|
risk_plan={
|
||||||
|
"invalid_if": ["重新站回破位区", "反抽放量突破", "BTC/ETH快速转强", "RR不足"],
|
||||||
|
"risk_reasons": reasons,
|
||||||
|
},
|
||||||
|
decision_log=decision_log or {
|
||||||
|
"module": BREAKDOWN_RETEST_SHORT_1H_STRATEGY,
|
||||||
|
"decision": status,
|
||||||
|
"reasons": reasons,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["build_breakdown_retest_short_1h_signal"]
|
||||||
@ -42,10 +42,11 @@ async def api_paper_trading_trades(
|
|||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
status: str = "",
|
status: str = "",
|
||||||
strategy_code: str = "",
|
strategy_code: str = "",
|
||||||
|
side: str = "",
|
||||||
altcoin_session: str = Cookie(default=""),
|
altcoin_session: str = Cookie(default=""),
|
||||||
):
|
):
|
||||||
require_admin(altcoin_session)
|
require_admin(altcoin_session)
|
||||||
return list_paper_trades(limit=limit, offset=offset, status=status, strategy_code=strategy_code)
|
return list_paper_trades(limit=limit, offset=offset, status=status, strategy_code=strategy_code, side=side)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/paper-trading/orders")
|
@router.get("/api/paper-trading/orders")
|
||||||
@ -54,10 +55,11 @@ async def api_paper_trading_orders(
|
|||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
status: str = "",
|
status: str = "",
|
||||||
strategy_code: str = "",
|
strategy_code: str = "",
|
||||||
|
side: str = "",
|
||||||
altcoin_session: str = Cookie(default=""),
|
altcoin_session: str = Cookie(default=""),
|
||||||
):
|
):
|
||||||
require_admin(altcoin_session)
|
require_admin(altcoin_session)
|
||||||
return list_paper_orders(limit=limit, offset=offset, status=status, strategy_code=strategy_code)
|
return list_paper_orders(limit=limit, offset=offset, status=status, strategy_code=strategy_code, side=side)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/paper-trading/events")
|
@router.get("/api/paper-trading/events")
|
||||||
@ -67,10 +69,11 @@ async def api_paper_trading_events(
|
|||||||
symbol: str = "",
|
symbol: str = "",
|
||||||
event_type: str = "",
|
event_type: str = "",
|
||||||
strategy_code: str = "",
|
strategy_code: str = "",
|
||||||
|
side: str = "",
|
||||||
altcoin_session: str = Cookie(default=""),
|
altcoin_session: str = Cookie(default=""),
|
||||||
):
|
):
|
||||||
require_admin(altcoin_session)
|
require_admin(altcoin_session)
|
||||||
return list_paper_trade_events(limit=limit, offset=offset, symbol=symbol, event_type=event_type, strategy_code=strategy_code)
|
return list_paper_trade_events(limit=limit, offset=offset, symbol=symbol, event_type=event_type, strategy_code=strategy_code, side=side)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/paper-trading/report")
|
@router.post("/api/paper-trading/report")
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -4,6 +4,7 @@ from app.core.strategy_registry import (
|
|||||||
BOX_RETEST_1H_STRATEGY,
|
BOX_RETEST_1H_STRATEGY,
|
||||||
BOX_RETEST_4H_STRATEGY,
|
BOX_RETEST_4H_STRATEGY,
|
||||||
COMPRESSION_BREAKOUT_4H_STRATEGY,
|
COMPRESSION_BREAKOUT_4H_STRATEGY,
|
||||||
|
BREAKDOWN_RETEST_SHORT_1H_STRATEGY,
|
||||||
INTRADAY_MOMENTUM_15M_STRATEGY,
|
INTRADAY_MOMENTUM_15M_STRATEGY,
|
||||||
MAIN_COMPOSITE_STRATEGY,
|
MAIN_COMPOSITE_STRATEGY,
|
||||||
VOLUME_IGNITION_1H_STRATEGY,
|
VOLUME_IGNITION_1H_STRATEGY,
|
||||||
@ -19,6 +20,7 @@ from app.strategies.altcoin_breakout import (
|
|||||||
build_volume_ignition_1h_signal,
|
build_volume_ignition_1h_signal,
|
||||||
)
|
)
|
||||||
from app.strategies.box_retest_4h import build_box_retest_1h_signal
|
from app.strategies.box_retest_4h import build_box_retest_1h_signal
|
||||||
|
from app.strategies.short_breakdown import build_breakdown_retest_short_1h_signal
|
||||||
|
|
||||||
|
|
||||||
def test_factor_roles_never_promote_unknown_to_trigger():
|
def test_factor_roles_never_promote_unknown_to_trigger():
|
||||||
@ -47,6 +49,7 @@ def test_default_main_composite_strategy_signal_is_stable():
|
|||||||
assert strategy_label(VOLUME_IGNITION_1H_STRATEGY) == "1H放量突破启动"
|
assert strategy_label(VOLUME_IGNITION_1H_STRATEGY) == "1H放量突破启动"
|
||||||
assert strategy_label(COMPRESSION_BREAKOUT_4H_STRATEGY) == "4H压缩蓄力突破"
|
assert strategy_label(COMPRESSION_BREAKOUT_4H_STRATEGY) == "4H压缩蓄力突破"
|
||||||
assert strategy_label(INTRADAY_MOMENTUM_15M_STRATEGY) == "15m日内动量延续"
|
assert strategy_label(INTRADAY_MOMENTUM_15M_STRATEGY) == "15m日内动量延续"
|
||||||
|
assert strategy_label(BREAKDOWN_RETEST_SHORT_1H_STRATEGY) == "1H破位反抽做空"
|
||||||
|
|
||||||
|
|
||||||
def test_volume_ignition_strategy_builds_independent_signal():
|
def test_volume_ignition_strategy_builds_independent_signal():
|
||||||
@ -108,6 +111,31 @@ def test_intraday_momentum_strategy_requires_current_trigger():
|
|||||||
assert fresh["status"] == "candidate"
|
assert fresh["status"] == "candidate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_breakdown_retest_short_strategy_is_independent_short_signal():
|
||||||
|
signal = build_breakdown_retest_short_1h_signal(
|
||||||
|
symbol="WEAK/USDT",
|
||||||
|
current_price=0.98,
|
||||||
|
detection={
|
||||||
|
"detected": True,
|
||||||
|
"score": 8,
|
||||||
|
"breakdown_level": 1.0,
|
||||||
|
"retest_zone": 1.0,
|
||||||
|
"stop_level": 1.04,
|
||||||
|
"target_1": 0.9,
|
||||||
|
"quality": "优质",
|
||||||
|
"retest_rejected": True,
|
||||||
|
"relative_weakness": True,
|
||||||
|
},
|
||||||
|
market_regime={"risk_level": "high", "regime": "risk_off"},
|
||||||
|
).to_json_dict()
|
||||||
|
|
||||||
|
assert signal["strategy_code"] == BREAKDOWN_RETEST_SHORT_1H_STRATEGY
|
||||||
|
assert signal["direction"] == "short"
|
||||||
|
assert signal["entry_plan"]["side"] == "short"
|
||||||
|
assert signal["status"] == "candidate"
|
||||||
|
assert signal["factor_roles"]["breakdown_retest_1h_short"] == "trigger"
|
||||||
|
|
||||||
|
|
||||||
def test_strategy_evaluation_recommends_promote_or_pause():
|
def test_strategy_evaluation_recommends_promote_or_pause():
|
||||||
strong = evaluate_strategy_decision({
|
strong = evaluate_strategy_decision({
|
||||||
"signal_count": 24,
|
"signal_count": 24,
|
||||||
|
|||||||
@ -1049,6 +1049,62 @@ def test_open_paper_trade_closes_on_tp1_and_summary_counts_win(buy_now_rec):
|
|||||||
assert summary["win_rate"] == pytest.approx(100.0)
|
assert summary["win_rate"] == pytest.approx(100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_paper_trade_opens_and_closes_on_take_profit(monkeypatch, buy_now_rec):
|
||||||
|
rec = dict(buy_now_rec)
|
||||||
|
rec["side"] = "short"
|
||||||
|
rec["stop_loss"] = 105
|
||||||
|
rec["tp1"] = 94
|
||||||
|
rec["tp2"] = 90
|
||||||
|
rec["entry_plan"] = {
|
||||||
|
"side": "short",
|
||||||
|
"entry_action": "可即刻买入",
|
||||||
|
"entry_price": 100,
|
||||||
|
"stop_loss": 105,
|
||||||
|
"tp1": 94,
|
||||||
|
"tp2": 90,
|
||||||
|
"risk_reward_ok": True,
|
||||||
|
"rr1": 1.2,
|
||||||
|
"entry_trigger_confirmed": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
opened = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
||||||
|
closed = sync_recommendation(rec, 94, event_time="2026-05-16T10:10:00")
|
||||||
|
|
||||||
|
assert opened["opened"] is True
|
||||||
|
assert closed["closed"] is True
|
||||||
|
assert closed["exit_reason"] == "tp1"
|
||||||
|
assert closed["pnl_pct"] > 0
|
||||||
|
trade = list_paper_trades()["items"][0]
|
||||||
|
assert trade["side"] == "short"
|
||||||
|
assert trade["status"] == "closed"
|
||||||
|
assert list_paper_trades(side="short")["total"] == 1
|
||||||
|
assert list_paper_trades(side="long")["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_paper_trade_closes_on_stop_loss(monkeypatch, buy_now_rec):
|
||||||
|
rec = dict(buy_now_rec)
|
||||||
|
rec["side"] = "short"
|
||||||
|
rec["stop_loss"] = 105
|
||||||
|
rec["tp1"] = 94
|
||||||
|
rec["entry_plan"] = {
|
||||||
|
"side": "short",
|
||||||
|
"entry_action": "可即刻买入",
|
||||||
|
"entry_price": 100,
|
||||||
|
"stop_loss": 105,
|
||||||
|
"tp1": 94,
|
||||||
|
"risk_reward_ok": True,
|
||||||
|
"rr1": 1.2,
|
||||||
|
"entry_trigger_confirmed": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
||||||
|
result = sync_recommendation(rec, 106, event_time="2026-05-16T10:10:00")
|
||||||
|
|
||||||
|
assert result["closed"] is True
|
||||||
|
assert result["exit_reason"] == "stop_loss"
|
||||||
|
assert result["pnl_pct"] < 0
|
||||||
|
|
||||||
|
|
||||||
def test_paper_trading_trailing_stop_activates_moves_and_closes(monkeypatch, buy_now_rec):
|
def test_paper_trading_trailing_stop_activates_moves_and_closes(monkeypatch, buy_now_rec):
|
||||||
pushed = []
|
pushed = []
|
||||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
||||||
|
|||||||
30
tests/test_trade_math.py
Normal file
30
tests/test_trade_math.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.trade_math import (
|
||||||
|
close_price,
|
||||||
|
open_price,
|
||||||
|
pnl_pct,
|
||||||
|
should_hit_trailing_stop,
|
||||||
|
should_stop_loss,
|
||||||
|
should_take_profit,
|
||||||
|
tighter_stop,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trade_math_is_side_aware_for_short():
|
||||||
|
assert open_price("short", 100, 0.1) == pytest.approx(99.9)
|
||||||
|
assert close_price("short", 90, 0.1) == pytest.approx(90.09)
|
||||||
|
assert pnl_pct("short", 100, 90) == pytest.approx(11.1111)
|
||||||
|
assert should_stop_loss("short", 106, 105) is True
|
||||||
|
assert should_take_profit("short", 94, 95) is True
|
||||||
|
assert should_hit_trailing_stop("short", 98, 97.5) is True
|
||||||
|
assert tighter_stop("short", 99, 97.5) == pytest.approx(97.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trade_math_keeps_long_behavior():
|
||||||
|
assert open_price("long", 100, 0.1) == pytest.approx(100.1)
|
||||||
|
assert close_price("long", 110, 0.1) == pytest.approx(109.89)
|
||||||
|
assert pnl_pct("long", 100, 110) == pytest.approx(10)
|
||||||
|
assert should_stop_loss("long", 94, 95) is True
|
||||||
|
assert should_take_profit("long", 106, 105) is True
|
||||||
|
assert tighter_stop("long", 99, 101) == pytest.approx(101)
|
||||||
@ -43,6 +43,38 @@ def test_trailing_stop_never_moves_down():
|
|||||||
assert decision["trailing_stop"] == pytest.approx(104)
|
assert decision["trailing_stop"] == pytest.approx(104)
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_trailing_stop_activates_and_moves_down():
|
||||||
|
activated = evaluate_trailing_stop(
|
||||||
|
position={"side": "short", "entry_price": 100, "max_price": 100, "min_price": 100, "trailing_stop": 0},
|
||||||
|
current_price=94,
|
||||||
|
pnl_pct=6,
|
||||||
|
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()
|
||||||
|
moved = evaluate_trailing_stop(
|
||||||
|
position={"side": "short", "entry_price": 100, "max_price": 100, "min_price": 94, "trailing_stop": activated["trailing_stop"]},
|
||||||
|
current_price=92,
|
||||||
|
pnl_pct=8,
|
||||||
|
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 activated["action"] == "activate"
|
||||||
|
assert activated["trailing_stop"] == pytest.approx(95.41)
|
||||||
|
assert moved["action"] == "move"
|
||||||
|
assert moved["trailing_stop"] < activated["trailing_stop"]
|
||||||
|
|
||||||
|
|
||||||
def test_volatility_profile_widens_activation_and_distance():
|
def test_volatility_profile_widens_activation_and_distance():
|
||||||
profile = trailing_profile(
|
profile = trailing_profile(
|
||||||
{"entry_price": 100, "max_price": 100, "min_price": 98},
|
{"entry_price": 100, "max_price": 100, "min_price": 98},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user