This commit is contained in:
aaron 2026-05-31 19:00:46 +08:00
parent 3787a845a2
commit 447edff0f6
15 changed files with 557 additions and 49 deletions

View File

@ -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. 数据与状态中心

View File

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

View File

@ -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", ("历史起爆点", "起爆点已过期", "旧起爆")),

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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