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`。
|
||||
- `app/core/global_risk.py`
|
||||
- paper trading 全局风控门禁。单币机会进入开仓或挂单成交前,需要先检查市场环境和账户风险;critical 禁止新开仓,high 只允许高质量机会。
|
||||
- `app/core/trade_math.py`
|
||||
- 多空通用交易数学中心。负责 side 归一、开仓/平仓滑点价、PnL、止损/止盈/移动止盈触发、保护价收紧比较。paper/live 不应各自实现一套多空收益和触发判断。
|
||||
- `app/core/order_lifecycle.py`
|
||||
- 挂单生命周期决策中心。负责限价单是否触价、是否过期、是否远离入场、RR 与入场距离计算;不写 DB、不取消订单、不调用交易所。paper/live 适配层只能消费它的 `OrderLifecycleDecision`。
|
||||
- `app/core/trailing_stop.py`
|
||||
@ -187,7 +189,8 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
|
||||
- `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`。
|
||||
- 交易执行层已支持 `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`。
|
||||
|
||||
## 5. 数据与状态中心
|
||||
|
||||
@ -31,6 +31,9 @@ VALID_FACTOR_ROLES = {
|
||||
DEFAULT_FACTOR_ROLES: dict[str, str] = {
|
||||
"box_breakout_pullback_4h": 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,
|
||||
"volume_consecutive_1h": CONFIRMATION,
|
||||
"volume_divergence_1h": RISK,
|
||||
|
||||
@ -24,6 +24,9 @@ SIGNAL_CODE_LABELS = {
|
||||
"compression_surge_4h": "4H压缩放量",
|
||||
"box_breakout_pullback_1h": "1H箱体突破回踩",
|
||||
"box_breakout_pullback_4h": "4H箱体突破回踩",
|
||||
"breakdown_retest_1h_short": "1H破位反抽做空",
|
||||
"retest_reject_15m_short": "15min反抽失败",
|
||||
"market_risk_off_short": "弱势环境空头确认",
|
||||
"ignition_1h_current": "1H当前起爆点",
|
||||
"ignition_4h_current": "4H当前起爆点",
|
||||
"ignition_d1_current": "日线当前起爆点",
|
||||
@ -67,6 +70,9 @@ _PATTERNS = [
|
||||
("static_accum_4h", ("静K蓄力", "静K旁路")),
|
||||
("box_breakout_pullback_1h", ("1H", "箱体", "突破", "回踩")),
|
||||
("box_breakout_pullback_4h", ("4H", "箱体", "突破", "回踩")),
|
||||
("breakdown_retest_1h_short", ("1H", "破位", "反抽", "做空")),
|
||||
("retest_reject_15m_short", ("15min", "反抽失败")),
|
||||
("market_risk_off_short", ("risk_off", "空头")),
|
||||
("higher_lows_4h", ("底部抬高",)),
|
||||
("compression_surge_4h", ("压缩放量",)),
|
||||
("ignition_stale", ("历史起爆点", "起爆点已过期", "旧起爆")),
|
||||
|
||||
@ -11,6 +11,7 @@ BOX_RETEST_4H_STRATEGY = "box_retest_4h_v1"
|
||||
VOLUME_IGNITION_1H_STRATEGY = "volume_ignition_1h_v1"
|
||||
COMPRESSION_BREAKOUT_4H_STRATEGY = "compression_breakout_4h_v1"
|
||||
INTRADAY_MOMENTUM_15M_STRATEGY = "intraday_momentum_15m_v1"
|
||||
BREAKDOWN_RETEST_SHORT_1H_STRATEGY = "breakdown_retest_short_1h_v1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -139,6 +140,28 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = {
|
||||
"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 app.core.trade_math import normalize_side, tighter_stop
|
||||
|
||||
|
||||
def _safe_float(value, default: float = 0.0) -> float:
|
||||
try:
|
||||
@ -158,12 +160,20 @@ def evaluate_trailing_stop(
|
||||
detail={"reason": "below_activation"},
|
||||
)
|
||||
|
||||
side = normalize_side(position.get("side"))
|
||||
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)
|
||||
if side == "short":
|
||||
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, 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
|
||||
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:
|
||||
return TrailingStopDecision(
|
||||
action="hold",
|
||||
|
||||
@ -16,6 +16,17 @@ from app.core.order_lifecycle import (
|
||||
)
|
||||
from app.core.position_health import evaluate_position_health
|
||||
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.db.schema import get_conn
|
||||
from app.db.system_logs import record_system_error
|
||||
@ -317,26 +328,20 @@ def _parse_time(value: str):
|
||||
return None
|
||||
|
||||
|
||||
def _open_price(current_price: float, config: dict | None = None) -> float:
|
||||
return round(current_price * (1 + default_slippage_pct(config) / 100), 12)
|
||||
def _open_price(current_price: float, config: dict | None = None, side: str = "long") -> float:
|
||||
return side_open_price(side, current_price, default_slippage_pct(config))
|
||||
|
||||
|
||||
def _close_price(current_price: float, config: dict | None = None) -> float:
|
||||
return round(current_price * (1 - default_slippage_pct(config) / 100), 12)
|
||||
def _close_price(current_price: float, config: dict | None = None, side: str = "long") -> float:
|
||||
return side_close_price(side, current_price, default_slippage_pct(config))
|
||||
|
||||
|
||||
def _trade_pnl_pct(entry_price: float, current_price: float) -> float:
|
||||
if entry_price <= 0 or current_price <= 0:
|
||||
return 0.0
|
||||
return round((current_price / entry_price - 1) * 100, 4)
|
||||
def _trade_pnl_pct(entry_price: float, current_price: float, side: str = "long") -> float:
|
||||
return side_pnl_pct(side, entry_price, current_price)
|
||||
|
||||
|
||||
def _stop_loss_distance_pct(side: str, entry_price: float, stop_loss: float) -> float:
|
||||
if entry_price <= 0 or stop_loss <= 0:
|
||||
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)
|
||||
return side_stop_loss_distance_pct(side, entry_price, stop_loss)
|
||||
|
||||
|
||||
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"))
|
||||
symbol = str(rec.get("symbol") or "").strip().upper()
|
||||
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)
|
||||
side = str(plan.get("side") or rec.get("side") or "long").strip().lower() or "long"
|
||||
leverage = default_leverage(cfg)
|
||||
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"))
|
||||
@ -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,
|
||||
source_status, source_action, strategy_version, strategy_code, strategy_signal_id,
|
||||
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
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
rec_id,
|
||||
symbol,
|
||||
side,
|
||||
now,
|
||||
entry_price,
|
||||
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}
|
||||
_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"))
|
||||
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()
|
||||
if trade:
|
||||
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:
|
||||
entry_price = _safe_float(trade.get("entry_price"))
|
||||
exit_price = _close_price(current_price)
|
||||
pnl_pct = _trade_pnl_pct(entry_price, exit_price)
|
||||
side = normalize_side(trade.get("side"))
|
||||
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"))
|
||||
open_fee = _safe_float(trade.get("fee_usdt"))
|
||||
close_fee = round(notional * default_fee_rate(), 8)
|
||||
@ -1284,7 +1292,9 @@ def _apply_position_health_guard(
|
||||
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:
|
||||
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(
|
||||
conn,
|
||||
trade["id"],
|
||||
@ -1297,7 +1307,7 @@ def _apply_position_health_guard(
|
||||
{"position_health": decision},
|
||||
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}
|
||||
|
||||
|
||||
@ -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:
|
||||
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_min = _safe_float(trade.get("min_price")) or entry_price
|
||||
new_max = max(old_max, 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"))
|
||||
trailing_stop = _safe_float(trade.get("trailing_stop"))
|
||||
tp2 = _safe_float(trade.get("tp2"))
|
||||
tp1 = _safe_float(trade.get("tp1"))
|
||||
reason = ""
|
||||
if stop_loss > 0 and current_price <= stop_loss:
|
||||
if should_stop_loss(side, current_price, 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"
|
||||
elif tp2 > 0 and current_price >= tp2:
|
||||
elif should_take_profit(side, current_price, tp2):
|
||||
reason = "tp2"
|
||||
elif tp1 > 0 and current_price >= tp1:
|
||||
elif should_take_profit(side, current_price, tp1):
|
||||
reason = "tp1"
|
||||
|
||||
if reason:
|
||||
@ -1394,7 +1405,7 @@ def _update_open_trade(conn, trade: dict, current_price: float, event_time: str)
|
||||
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")))
|
||||
trailing_stop = tighter_stop(side, trailing_stop, _safe_float(guard_result.get("trailing_stop")))
|
||||
|
||||
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))
|
||||
offset = max(0, _safe_int(offset, 0))
|
||||
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:
|
||||
clauses.append("strategy_code=%s")
|
||||
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 ""
|
||||
conn = get_conn()
|
||||
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))
|
||||
offset = max(0, _safe_int(offset, 0))
|
||||
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:
|
||||
clauses.append("strategy_code=%s")
|
||||
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 ""
|
||||
conn = get_conn()
|
||||
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))
|
||||
offset = max(0, _safe_int(offset, 0))
|
||||
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:
|
||||
where.append("COALESCE(NULLIF(e.strategy_code, ''), t.strategy_code)=%s")
|
||||
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 ""
|
||||
conn = get_conn()
|
||||
try:
|
||||
@ -1930,6 +1953,7 @@ def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "",
|
||||
t.margin_usdt,
|
||||
t.leverage,
|
||||
t.exit_reason,
|
||||
t.side AS trade_side,
|
||||
t.opened_at,
|
||||
t.closed_at,
|
||||
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_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["side"] = normalize_side(item.get("trade_side"))
|
||||
items.append(item)
|
||||
return {
|
||||
"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
|
||||
|
||||
|
||||
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:
|
||||
"""Turn strategy metrics into an explicit lifecycle recommendation.
|
||||
|
||||
@ -145,6 +154,8 @@ def get_strategy_evaluation(days: int = 30) -> dict:
|
||||
"description": definition.description,
|
||||
"mode": definition.mode,
|
||||
"status": definition.status,
|
||||
"direction": _strategy_direction(definition),
|
||||
"direction_label": _direction_label(_strategy_direction(definition)),
|
||||
"signal_count": 0,
|
||||
"candidate_signal_count": 0,
|
||||
"observe_signal_count": 0,
|
||||
@ -158,6 +169,8 @@ def get_strategy_evaluation(days: int = 30) -> dict:
|
||||
"filled_order_count": 0,
|
||||
"canceled_order_count": 0,
|
||||
"trade_count": 0,
|
||||
"long_trade_count": 0,
|
||||
"short_trade_count": 0,
|
||||
"open_trade_count": 0,
|
||||
"closed_trade_count": 0,
|
||||
"win_count": 0,
|
||||
@ -172,12 +185,15 @@ def get_strategy_evaluation(days: int = 30) -> dict:
|
||||
normalized = normalize_strategy_code(code)
|
||||
if normalized not in metrics:
|
||||
definition = strategy_definition(normalized)
|
||||
direction = _strategy_direction(definition)
|
||||
metrics[normalized] = {
|
||||
"strategy_code": normalized,
|
||||
"strategy_name": definition.strategy_name,
|
||||
"description": definition.description,
|
||||
"mode": definition.mode,
|
||||
"status": definition.status,
|
||||
"direction": direction,
|
||||
"direction_label": _direction_label(direction),
|
||||
"signal_count": 0,
|
||||
"candidate_signal_count": 0,
|
||||
"observe_signal_count": 0,
|
||||
@ -191,6 +207,8 @@ def get_strategy_evaluation(days: int = 30) -> dict:
|
||||
"filled_order_count": 0,
|
||||
"canceled_order_count": 0,
|
||||
"trade_count": 0,
|
||||
"long_trade_count": 0,
|
||||
"short_trade_count": 0,
|
||||
"open_trade_count": 0,
|
||||
"closed_trade_count": 0,
|
||||
"win_count": 0,
|
||||
@ -260,7 +278,7 @@ def get_strategy_evaluation(days: int = 30) -> dict:
|
||||
|
||||
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
|
||||
WHERE opened_at >= %s
|
||||
""",
|
||||
@ -268,7 +286,12 @@ def get_strategy_evaluation(days: int = 30) -> dict:
|
||||
).fetchall():
|
||||
b = bucket(row.get("strategy_code"))
|
||||
status = row.get("status") or ""
|
||||
side = str(row.get("side") or "long").strip().lower()
|
||||
b["trade_count"] += 1
|
||||
if side == "short":
|
||||
b["short_trade_count"] += 1
|
||||
else:
|
||||
b["long_trade_count"] += 1
|
||||
if status == "open":
|
||||
b["open_trade_count"] += 1
|
||||
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,
|
||||
status: str = "",
|
||||
strategy_code: str = "",
|
||||
side: str = "",
|
||||
altcoin_session: str = Cookie(default=""),
|
||||
):
|
||||
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")
|
||||
@ -54,10 +55,11 @@ async def api_paper_trading_orders(
|
||||
offset: int = 0,
|
||||
status: str = "",
|
||||
strategy_code: str = "",
|
||||
side: str = "",
|
||||
altcoin_session: str = Cookie(default=""),
|
||||
):
|
||||
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")
|
||||
@ -67,10 +69,11 @@ async def api_paper_trading_events(
|
||||
symbol: str = "",
|
||||
event_type: str = "",
|
||||
strategy_code: str = "",
|
||||
side: str = "",
|
||||
altcoin_session: str = Cookie(default=""),
|
||||
):
|
||||
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")
|
||||
|
||||
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_4H_STRATEGY,
|
||||
COMPRESSION_BREAKOUT_4H_STRATEGY,
|
||||
BREAKDOWN_RETEST_SHORT_1H_STRATEGY,
|
||||
INTRADAY_MOMENTUM_15M_STRATEGY,
|
||||
MAIN_COMPOSITE_STRATEGY,
|
||||
VOLUME_IGNITION_1H_STRATEGY,
|
||||
@ -19,6 +20,7 @@ from app.strategies.altcoin_breakout import (
|
||||
build_volume_ignition_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():
|
||||
@ -47,6 +49,7 @@ def test_default_main_composite_strategy_signal_is_stable():
|
||||
assert strategy_label(VOLUME_IGNITION_1H_STRATEGY) == "1H放量突破启动"
|
||||
assert strategy_label(COMPRESSION_BREAKOUT_4H_STRATEGY) == "4H压缩蓄力突破"
|
||||
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():
|
||||
@ -108,6 +111,31 @@ def test_intraday_momentum_strategy_requires_current_trigger():
|
||||
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():
|
||||
strong = evaluate_strategy_decision({
|
||||
"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)
|
||||
|
||||
|
||||
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):
|
||||
pushed = []
|
||||
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)
|
||||
|
||||
|
||||
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():
|
||||
profile = trailing_profile(
|
||||
{"entry_price": 100, "max_price": 100, "min_price": 98},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user