1
This commit is contained in:
parent
5e863e6d2a
commit
7f1c9ec12d
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import os
|
||||
|
||||
from app.db.runtime_config_db import (
|
||||
deep_merge,
|
||||
get_bootstrap_admin_config,
|
||||
get_email_config,
|
||||
get_event_driven_config,
|
||||
@ -102,6 +103,15 @@ def default_paper_trading_config():
|
||||
"trade_leverage": _env_float("ALPHAX_PAPER_TRADE_LEVERAGE", 5),
|
||||
"fee_rate": _env_float("ALPHAX_PAPER_TRADE_FEE_RATE", 0.001),
|
||||
"slippage_pct": _env_float("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", 0.05),
|
||||
"trailing_stop_enabled": _env_bool("ALPHAX_PAPER_TRAILING_STOP_ENABLED", True),
|
||||
"trailing_activate_pnl_pct": _env_float("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", 3.0),
|
||||
"trailing_min_lock_profit_pct": _env_float("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", 0.5),
|
||||
"trailing_distance_pct": _env_float("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", 1.5),
|
||||
"trailing_tiers": [
|
||||
{"min_pnl_pct": 8.0, "distance_pct": 1.0, "label": "紧贴"},
|
||||
{"min_pnl_pct": 5.0, "distance_pct": 1.2, "label": "锁利"},
|
||||
{"min_pnl_pct": 3.0, "distance_pct": 1.8, "label": "防震"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@ -324,7 +334,7 @@ def paper_trading_config():
|
||||
if cfg is None:
|
||||
_seed_one("paper_trading", default_paper_trading_config(), "Paper trading account and execution model")
|
||||
cfg = get_paper_trading_config(default=None)
|
||||
return cfg or default_paper_trading_config()
|
||||
return deep_merge(default_paper_trading_config(), cfg or {})
|
||||
|
||||
|
||||
def price_streamer_config():
|
||||
|
||||
@ -360,6 +360,12 @@ def apply_entry_quality_gate(
|
||||
if risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now"):
|
||||
target_action = "观察"
|
||||
reasons.append("回踩参考已到,但实时盈亏比不达标,转为观察")
|
||||
elif level_max_action in ("observe", "wait_pullback") or has_bearish_flow_risk(signals):
|
||||
target_action = "等回踩" if level_max_action == "wait_pullback" else "观察"
|
||||
if level_max_action in ("observe", "wait_pullback"):
|
||||
reasons.append(f"{entry_plan.get('opportunity_level_label') or opportunity_level or '该机会'}级别最高只允许观察/等待,不能因到价直接升级为现价买入")
|
||||
if has_bearish_flow_risk(signals):
|
||||
reasons.append("出现空头加速/放量阴线风险,到价也不升级为现价买入")
|
||||
else:
|
||||
target_action = "可即刻买入"
|
||||
entry_plan["entry_trigger_confirmed"] = True
|
||||
@ -379,7 +385,9 @@ def apply_entry_quality_gate(
|
||||
# 如果当前已经回到/跌破计划参考价,但实时 RR 仍不足,说明不是“等回踩”,而是“回踩到了也不值得买”,应降级为观察。
|
||||
if any("回踩参考已下破" in str(x) for x in reasons):
|
||||
target_action = "观察"
|
||||
elif any("回踩参考已到或更优" in str(x) for x in reasons):
|
||||
elif any("回踩参考已到或更优" in str(x) for x in reasons) and not (
|
||||
level_max_action in ("observe", "wait_pullback") or has_bearish_flow_risk(signals)
|
||||
):
|
||||
target_action = "可即刻买入"
|
||||
elif action_status == "等回踩" and current_price > 0 and to_float(entry_plan.get("entry_price")) > 0 and current_price <= to_float(entry_plan.get("entry_price")) * 1.003 and (risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now")):
|
||||
target_action = "观察"
|
||||
|
||||
@ -940,6 +940,21 @@ def _derive_execution_fields(item):
|
||||
derivatives_context=derivatives_context,
|
||||
sector_context=sector_context,
|
||||
)
|
||||
try:
|
||||
rec_score_for_gate = float(item.get("rec_score") or 0)
|
||||
except Exception:
|
||||
rec_score_for_gate = 0
|
||||
if action_status == "可即刻买入" and rec_score_for_gate > 0 and rec_score_for_gate < 25:
|
||||
reasons = [f"推荐评分{rec_score_for_gate:g}<25,属于信号不足,禁止展示为现价买入"]
|
||||
gate = entry_plan.get("entry_quality_gate") if isinstance(entry_plan.get("entry_quality_gate"), dict) else {}
|
||||
existing_reasons = list(gate.get("reasons") or [])
|
||||
entry_plan["entry_quality_gate"] = {
|
||||
**gate,
|
||||
"blocked_action": gate.get("blocked_action") or action_status,
|
||||
"final_action": "观察",
|
||||
"reasons": existing_reasons + reasons,
|
||||
}
|
||||
action_status = "观察"
|
||||
if initial_action == "可即刻买入" and action_status != "可即刻买入":
|
||||
initial_action = action_status
|
||||
status = (item.get("status") or "active").strip()
|
||||
@ -966,6 +981,14 @@ def _derive_execution_fields(item):
|
||||
current_price_for_window,
|
||||
item.get("rec_time") or "",
|
||||
) if action_status == "可即刻买入" else {}
|
||||
if action_status == "可即刻买入" and entry_window:
|
||||
window_status = entry_window.get("status")
|
||||
if window_status in ("expired", "price_left_down"):
|
||||
action_status = "观察"
|
||||
elif window_status == "price_left_up":
|
||||
action_status = "等回踩"
|
||||
if window_status and window_status != "active":
|
||||
item["entry_window_alert"] = entry_window
|
||||
# 实时看板用 hours 参数过滤过期机会;派生层不再因为旧 rec_time 反向篡改主状态,避免展示/测试口径分裂。
|
||||
item_for_execution = {**item, "action_status": action_status}
|
||||
execution_status, execution_label, execution_reason = _execution_fields_from_persisted_state(item_for_execution, entry_plan)
|
||||
@ -977,14 +1000,22 @@ def _derive_execution_fields(item):
|
||||
item["execution_status"] = execution_status
|
||||
item["execution_label"] = execution_label
|
||||
item["execution_reason"] = execution_reason
|
||||
if item.get("entry_window_alert") and item["action_status"] == "可即刻买入":
|
||||
item["action_status"] = "等回踩" if item["entry_window_alert"].get("status") == "price_left_up" else "观察"
|
||||
execution_status, execution_label, execution_reason = _execution_fields_from_persisted_state(
|
||||
{**item, "action_status": item["action_status"], "status": status}, entry_plan
|
||||
)
|
||||
item["execution_status"] = execution_status
|
||||
item["execution_label"] = execution_label
|
||||
item["execution_reason"] = execution_reason
|
||||
item["display_bucket"] = bucket_fields.get("display_bucket")
|
||||
item["lifecycle_state"] = bucket_fields.get("lifecycle_state")
|
||||
item["entry_triggered"] = 1 if is_executed_lifecycle(status, item["action_status"], execution_status) else 0
|
||||
# 派生状态可能被买点质量闸门从“等回踩”降为“观察”,同步刷新展示桶,避免卡片仍停留在旧等待态。
|
||||
bucket_fields = derive_display_bucket(status, item["action_status"], item["execution_status"])
|
||||
item["execution_status"] = bucket_fields.get("execution_status") or item["execution_status"]
|
||||
item["display_bucket"] = bucket_fields.get("display_bucket")
|
||||
item["lifecycle_state"] = bucket_fields.get("lifecycle_state")
|
||||
item["entry_triggered"] = 1 if is_executed_lifecycle(status, item["action_status"], item["execution_status"]) else 0
|
||||
observe_tier, observe_reason = _observe_tier(item)
|
||||
item["observe_tier"] = observe_tier
|
||||
item["observe_reason"] = observe_reason
|
||||
|
||||
@ -58,6 +58,29 @@ def default_slippage_pct() -> float:
|
||||
return max(0.0, _safe_float(paper_trading_config().get("slippage_pct"), 0.05))
|
||||
|
||||
|
||||
def _trailing_config() -> dict:
|
||||
cfg = paper_trading_config()
|
||||
return {
|
||||
"enabled": bool(cfg.get("trailing_stop_enabled", True)),
|
||||
"activate_pnl_pct": max(0.0, _safe_float(cfg.get("trailing_activate_pnl_pct"), 3.0)),
|
||||
"min_lock_profit_pct": max(0.0, _safe_float(cfg.get("trailing_min_lock_profit_pct"), 0.5)),
|
||||
"distance_pct": max(0.1, _safe_float(cfg.get("trailing_distance_pct"), 1.5)),
|
||||
"tiers": cfg.get("trailing_tiers") if isinstance(cfg.get("trailing_tiers"), list) else [],
|
||||
}
|
||||
|
||||
|
||||
def _trailing_distance_pct(pnl_pct: float, cfg: dict) -> tuple[float, str]:
|
||||
distance = _safe_float(cfg.get("distance_pct"), 1.5)
|
||||
label = ""
|
||||
tiers = cfg.get("tiers") or []
|
||||
for tier in sorted((t for t in tiers if isinstance(t, dict)), key=lambda x: _safe_float(x.get("min_pnl_pct")), reverse=True):
|
||||
if pnl_pct >= _safe_float(tier.get("min_pnl_pct")):
|
||||
distance = max(0.1, _safe_float(tier.get("distance_pct"), distance))
|
||||
label = str(tier.get("label") or "")
|
||||
break
|
||||
return distance, label
|
||||
|
||||
|
||||
def _loads_json(value, fallback=None):
|
||||
try:
|
||||
if isinstance(value, str) and value.strip():
|
||||
@ -287,6 +310,58 @@ def _close_trade(conn, trade: dict, current_price: float, reason: str, event_tim
|
||||
return {"closed": True, "trade_id": trade["id"], "exit_reason": reason, "pnl_pct": pnl_pct, "pnl_usdt": pnl_usdt}
|
||||
|
||||
|
||||
def _update_trailing_stop(conn, trade: dict, current_price: float, pnl_pct: float, event_time: str) -> tuple[float, dict]:
|
||||
cfg = _trailing_config()
|
||||
current_trail = _safe_float(trade.get("trailing_stop"))
|
||||
if not cfg.get("enabled") or pnl_pct < _safe_float(cfg.get("activate_pnl_pct")):
|
||||
return current_trail, {"activated": False, "moved": False}
|
||||
|
||||
entry_price = _safe_float(trade.get("entry_price"))
|
||||
if entry_price <= 0 or current_price <= 0:
|
||||
return current_trail, {"activated": False, "moved": False}
|
||||
|
||||
distance_pct, tier_label = _trailing_distance_pct(pnl_pct, cfg)
|
||||
protection_floor = entry_price * (1 + _safe_float(cfg.get("min_lock_profit_pct")) / 100)
|
||||
candidate = current_price * (1 - distance_pct / 100)
|
||||
new_trail = round(max(current_trail, protection_floor, candidate), 12)
|
||||
activated = current_trail <= 0 and new_trail > 0
|
||||
moved = current_trail > 0 and new_trail > current_trail + 1e-12
|
||||
if not activated and not moved:
|
||||
return current_trail, {"activated": False, "moved": False}
|
||||
|
||||
event_type = "trailing_activate" if activated else "trailing_move"
|
||||
action_text = "激活" if activated else "上移"
|
||||
message = f"模拟交易移动止盈{action_text}:保护价 {new_trail:.8g}"
|
||||
_record_event(
|
||||
conn,
|
||||
trade["id"],
|
||||
trade["recommendation_id"],
|
||||
trade["symbol"],
|
||||
event_type,
|
||||
new_trail,
|
||||
pnl_pct,
|
||||
message,
|
||||
{
|
||||
"current_price": current_price,
|
||||
"previous_trailing_stop": current_trail,
|
||||
"trailing_stop": new_trail,
|
||||
"activate_pnl_pct": cfg.get("activate_pnl_pct"),
|
||||
"distance_pct": distance_pct,
|
||||
"tier_label": tier_label,
|
||||
"min_lock_profit_pct": cfg.get("min_lock_profit_pct"),
|
||||
},
|
||||
event_time,
|
||||
)
|
||||
return new_trail, {
|
||||
"activated": activated,
|
||||
"moved": moved,
|
||||
"trailing_stop": new_trail,
|
||||
"previous_trailing_stop": current_trail,
|
||||
"distance_pct": distance_pct,
|
||||
"tier_label": tier_label,
|
||||
}
|
||||
|
||||
|
||||
def _update_open_trade(conn, trade: dict, current_price: float, event_time: str) -> dict:
|
||||
entry_price = _safe_float(trade.get("entry_price"))
|
||||
old_max = _safe_float(trade.get("max_price")) or entry_price
|
||||
@ -295,11 +370,14 @@ def _update_open_trade(conn, trade: dict, current_price: float, event_time: str)
|
||||
new_min = min(old_min, current_price)
|
||||
pnl_pct = _trade_pnl_pct(entry_price, current_price)
|
||||
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:
|
||||
reason = "stop_loss"
|
||||
elif trailing_stop > 0 and current_price <= trailing_stop:
|
||||
reason = "trailing_stop"
|
||||
elif tp2 > 0 and current_price >= tp2:
|
||||
reason = "tp2"
|
||||
elif tp1 > 0 and current_price >= tp1:
|
||||
@ -308,19 +386,22 @@ def _update_open_trade(conn, trade: dict, current_price: float, event_time: str)
|
||||
if reason:
|
||||
return _close_trade(conn, trade, current_price, reason, event_time)
|
||||
|
||||
trailing_stop, trailing_result = _update_trailing_stop(conn, trade, current_price, pnl_pct, event_time or _now())
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE paper_trades
|
||||
SET current_price=%s,
|
||||
max_price=%s,
|
||||
min_price=%s,
|
||||
trailing_stop=%s,
|
||||
pnl_pct=%s,
|
||||
updated_at=%s
|
||||
WHERE id=%s AND status='open'
|
||||
""",
|
||||
(current_price, new_max, new_min, pnl_pct, event_time or _now(), trade["id"]),
|
||||
(current_price, new_max, new_min, trailing_stop, pnl_pct, event_time or _now(), trade["id"]),
|
||||
)
|
||||
return {"updated": True, "trade_id": trade["id"], "pnl_pct": pnl_pct}
|
||||
return {"updated": True, "trade_id": trade["id"], "pnl_pct": pnl_pct, **trailing_result}
|
||||
|
||||
|
||||
def sync_recommendation(rec: dict, current_price: float, event_time: str = "") -> dict:
|
||||
@ -460,3 +541,60 @@ def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "") -> dic
|
||||
"offset": offset,
|
||||
"has_more": offset + len(rows) < int(total or 0),
|
||||
}
|
||||
|
||||
|
||||
def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "", event_type: str = "") -> dict:
|
||||
limit = max(1, min(_safe_int(limit, 80), 200))
|
||||
offset = max(0, _safe_int(offset, 0))
|
||||
symbol = str(symbol or "").strip().upper()
|
||||
event_type = str(event_type or "").strip()
|
||||
where = []
|
||||
params = []
|
||||
if symbol:
|
||||
where.append("e.symbol=%s")
|
||||
params.append(symbol)
|
||||
if event_type:
|
||||
where.append("e.event_type=%s")
|
||||
params.append(event_type)
|
||||
where_sql = "WHERE " + " AND ".join(where) if where else ""
|
||||
conn = get_conn()
|
||||
try:
|
||||
total = conn.execute(
|
||||
f"SELECT COUNT(*) FROM paper_trade_events e {where_sql}",
|
||||
tuple(params),
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT
|
||||
e.*,
|
||||
t.status AS trade_status,
|
||||
t.entry_price,
|
||||
t.exit_price,
|
||||
t.notional_usdt,
|
||||
t.margin_usdt,
|
||||
t.leverage,
|
||||
t.exit_reason,
|
||||
t.opened_at,
|
||||
t.closed_at
|
||||
FROM paper_trade_events e
|
||||
LEFT JOIN paper_trades t ON t.id = e.trade_id
|
||||
{where_sql}
|
||||
ORDER BY e.event_time DESC, e.id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
tuple(params + [limit, offset]),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
items = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item["detail"] = _loads_json(item.pop("detail_json", "{}"), {})
|
||||
items.append(item)
|
||||
return {
|
||||
"items": items,
|
||||
"total": int(total or 0),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": offset + len(items) < int(total or 0),
|
||||
}
|
||||
|
||||
@ -1127,7 +1127,7 @@ def confirm_burst(symbol, cand):
|
||||
stop_pct_final = max((price - stop_loss) / price, 0) if price > 0 and stop_loss > 0 else stop_pct_final
|
||||
|
||||
# === 分级动态止盈 ===
|
||||
# 日内启动更重视近端兑现;结构/主题级别使用更宽目标与移动止盈。
|
||||
# 日内启动更重视近端兑现;结构/主题级别使用更宽目标。
|
||||
atr_multipliers = confirm_atr_multipliers()
|
||||
level_tp = level_tp_parameters(opportunity_level)
|
||||
tp1_atr_pct = (atr_1h * level_tp.get("tp1_atr", atr_multipliers.get("tp1", 3.0))) / price
|
||||
@ -1154,14 +1154,13 @@ def confirm_burst(symbol, cand):
|
||||
"stop_loss": stop_loss,
|
||||
"stop_pct": round(stop_pct_final * 100, 1),
|
||||
"tp1": tp1,
|
||||
"tp2": tp2, # v1.7.8: TP2已废除(历史0命中),保留字段向后兼容.主止盈=跟踪止盈
|
||||
"tp2": tp2,
|
||||
"rr1": rr1, "rr2": rr2,
|
||||
"atr_1h": round(float(atr_1h), 6),
|
||||
"current_price": round(float(price), 6),
|
||||
"risk_reward_ok": rr1 >= 1.5,
|
||||
"pa_15min_summary": pa_15min_result.get("reason", ""),
|
||||
"pa_1h_exhaustion": pa_1h_exhaustion.get("severity", "low"),
|
||||
"trailing_stop_level": 0.0, # v1.7.8: tracker动态填充,初始0
|
||||
"stop_basis": stop_basis,
|
||||
"tp_basis": level_meta.get("tp_model", ""),
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
"""
|
||||
山寨币爆发监控系统 v11.7.9 — 价格跟踪+三级分级跟踪止盈+趋势反转(纯前瞻版)
|
||||
山寨币爆发监控系统 — 推荐信号跟踪 + paper trading 执行账本
|
||||
趋势反转检测:1H连续阴动K、量价背离、空头加速(替代MACD/RSI)
|
||||
v1.7.9: 跟踪止盈按盈利分三级 — 防震(3×ATR) → 锁利(2×ATR) → 紧贴(1.2×ATR)
|
||||
止盈止损跟踪、动态买入/卖出指引
|
||||
推荐层只管理信号状态;模拟成交、TP/SL、移动止盈由 paper_trading 独立负责。
|
||||
"""
|
||||
|
||||
import sys, os, shutil
|
||||
@ -141,88 +140,14 @@ def analyze_tracking_signals(symbol, rec, current_price):
|
||||
# ---- 止盈信号检测 ----
|
||||
pnl_pct = ((current_price / entry_price) - 1) * 100 if entry_price > 0 else 0
|
||||
|
||||
# 到达TP1(v1.7.8:TP1保留作为提醒目标)
|
||||
# TP/SL 是模拟交易生命周期,不再写成推荐信号动作。
|
||||
if tp1 > 0 and current_price >= tp1:
|
||||
sell_signals.append(f"✅ 到达TP1(${tp1:.4f}), 建议止盈50%仓位")
|
||||
action_status = "止盈1" # 无条件:TP1到了就是止盈,无论之前什么状态
|
||||
sell_signals.append(f"模拟交易目标价已到达(${tp1:.4f}),执行结果以 paper trading 为准")
|
||||
|
||||
# === v1.7.8 跟踪止盈全面升级 ===
|
||||
# 核心改动:
|
||||
# ① 激活门槛 5%→3% (抓更多利润)
|
||||
# ② ATR乘数 2.0→1.5 (更紧贴行情)
|
||||
# ③ 每次tracker运行都重新计算并只升不降 (动态跟随行情逐步抬高)
|
||||
# ④ 跟踪止盈触发时无条件覆盖所有状态 (利润保护优先级最高)
|
||||
# ⑤ TP2已废除(历史0命中),跟踪止盈是唯一动态止盈方式
|
||||
rules = load_rules()
|
||||
trail_cfg = rules.get("tracker", {}).get("trailing_stop", {})
|
||||
trailing_stop_level = entry_plan.get("trailing_stop_level", 0)
|
||||
trailing_stop_activated = False
|
||||
trailing_stop_moved = False
|
||||
|
||||
if trail_cfg.get("enabled", True) and atr_1h > 0 and entry_price > 0:
|
||||
activate_pct = trail_cfg.get("activate_pnl_pct", 3)
|
||||
|
||||
# === v1.7.9 三级分级乘数: 按盈利切换 ===
|
||||
tiers = trail_cfg.get("tiers", [])
|
||||
trail_atr_mult = 1.5 # 兜底
|
||||
tier_label = ""
|
||||
if tiers:
|
||||
for t in sorted(tiers, key=lambda x: x.get("min_pnl_pct", 0), reverse=True):
|
||||
if pnl_pct >= t.get("min_pnl_pct", 0):
|
||||
trail_atr_mult = t.get("atr_mult", 1.5)
|
||||
tier_label = t.get("label", "")
|
||||
break
|
||||
|
||||
# === 动态跟随:每次运行都重新计算跟踪止盈位 ===
|
||||
# 算法: trailing_stop = current_price - atr_mult × ATR_1h
|
||||
# 规则: 只升不降 (max(old_level, new_level))
|
||||
# 激活条件: pnl_pct ≥ activate_pct (3%)
|
||||
if pnl_pct >= activate_pct:
|
||||
new_trail = current_price - trail_atr_mult * atr_1h
|
||||
min_lock_pct = float(trail_cfg.get("min_lock_profit_pct", 0.5))
|
||||
breakeven_buffer_pct = float(trail_cfg.get("breakeven_buffer_pct", min_lock_pct))
|
||||
# ATR 对高波动山寨币会很宽。利润保护一旦激活,止盈线至少要高于入场价,
|
||||
# 且不能低于原硬止损;否则“移动止盈已激活”实际没有任何保护效果。
|
||||
protection_floor = max(
|
||||
stop_loss or 0,
|
||||
entry_price * (1 + max(min_lock_pct, breakeven_buffer_pct) / 100),
|
||||
)
|
||||
new_trail = max(new_trail, protection_floor)
|
||||
|
||||
if trailing_stop_level > 0:
|
||||
# 已有跟踪位 → 只上移不下移
|
||||
old_trail = trailing_stop_level
|
||||
trailing_stop_level = max(trailing_stop_level, new_trail)
|
||||
trailing_stop_moved = trailing_stop_level > old_trail + 1e-12
|
||||
else:
|
||||
# 首次激活
|
||||
trailing_stop_level = new_trail
|
||||
trailing_stop_activated = True
|
||||
tier_info = f" [{tier_label}·{trail_atr_mult}×ATR]" if tier_label else ""
|
||||
sell_signals.append(f"🎯 跟踪止盈激活(盈+{pnl_pct:.1f}%≥{activate_pct}%{tier_info}, 保护位${trailing_stop_level:.4f})")
|
||||
|
||||
# === 触发检查:当前价跌破跟踪止盈位 → 止盈 ===
|
||||
# 🔴 v1.7.8: 跟踪止盈触发时无条件覆盖(利润保护优先级最高)
|
||||
if trailing_stop_level > 0 and current_price <= trailing_stop_level:
|
||||
drop_from_trail = trailing_stop_level - current_price
|
||||
sell_signals.append(f"🎯 跟踪止盈触发! 从高位回撤${drop_from_trail:.4f}({drop_from_trail/trailing_stop_level*100:.1f}%)")
|
||||
action_status = "跟踪止盈"
|
||||
|
||||
# 定期报告(即使没触发也显示跟踪位)
|
||||
if trailing_stop_level > 0 and current_price > trailing_stop_level and pnl_pct >= activate_pct * 2:
|
||||
cushion = (current_price - trailing_stop_level) / current_price * 100
|
||||
sell_signals.append(f"📊 跟踪止盈中: 止盈位${trailing_stop_level:.4f}(距现价{cushion:.1f}%)")
|
||||
elif pnl_pct >= 1 and trailing_stop_level > 0 and current_price <= trailing_stop_level:
|
||||
# 利润回落到1%以内但跟踪位已激活且被击穿 → 保本出
|
||||
sell_signals.append(f"🔒 保本止盈: 利润缩至{pnl_pct:.1f}%,跟踪位击穿")
|
||||
action_status = "跟踪止盈"
|
||||
|
||||
# ---- 无TP保护的推荐,涨超15%自动止盈(孤儿推荐保护)----
|
||||
# 加速推荐(粗筛/细筛层)没有 TP/SL,price_tracker 无法判断出场点。
|
||||
# 涨超15%仍无结论 → 认怂落袋,避免永续浮盈不兑现。
|
||||
if tp1 == 0 and pnl_pct >= 15:
|
||||
sell_signals.append(f"✅ 无TP保护自动止盈(涨+{pnl_pct:.1f}%≥15%,落袋为安)")
|
||||
action_status = "止盈1"
|
||||
sell_signals.append(f"无TP保护但浮盈已达+{pnl_pct:.1f}%,仅作为信号风险提醒,是否平仓由 paper trading/人工处理")
|
||||
|
||||
# ---- 止损接近警告 ----
|
||||
if stop_loss > 0:
|
||||
@ -230,8 +155,7 @@ def analyze_tracking_signals(symbol, rec, current_price):
|
||||
if loss_pct < 3: # 当前价离止损不到3%
|
||||
sell_signals.append(f"⚠️ 接近止损!当前${current_price:.4f}离止损${stop_loss:.4f}仅{loss_pct:.1f}%")
|
||||
if current_price <= stop_loss:
|
||||
sell_signals.append(f"🔴 已触发止损!${current_price:.4f}≤${stop_loss:.4f}")
|
||||
action_status = "止损"
|
||||
sell_signals.append(f"🔴 模拟交易止损价已触达!${current_price:.4f}≤${stop_loss:.4f},执行结果以 paper trading 为准")
|
||||
|
||||
# ---- 趋势反转信号(PA行为检测,替代MACD) ----
|
||||
if h1_df is not None and len(h1_df) >= 30 and atr_1h > 0:
|
||||
@ -344,9 +268,6 @@ def analyze_tracking_signals(symbol, rec, current_price):
|
||||
"exhaustion": exhaustion,
|
||||
"entry_update": entry_update,
|
||||
"pnl_pct": round(pnl_pct, 2),
|
||||
"trailing_stop_level": trailing_stop_level,
|
||||
"trailing_stop_activated": trailing_stop_activated,
|
||||
"trailing_stop_moved": trailing_stop_moved,
|
||||
}
|
||||
|
||||
|
||||
@ -396,22 +317,6 @@ def track_prices():
|
||||
# PA增强:动态跟踪信号分析
|
||||
tracking_signals = analyze_tracking_signals(symbol, rec, current_price)
|
||||
|
||||
# === v1.7.8 跟踪止盈DB回写 ===
|
||||
# 每次tracker运行都写DB,支持动态跟随行情逐步抬高跟踪位
|
||||
trail_level = tracking_signals.get("trailing_stop_level", 0)
|
||||
if trail_level > 0:
|
||||
entry_plan = rec.get("entry_plan") or {}
|
||||
old_trail = entry_plan.get("trailing_stop_level", 0)
|
||||
# v1.7.8: 只要跟踪位有变化就写DB(上移时必变;首次激活也写)
|
||||
if abs(trail_level - old_trail) > 0.000001:
|
||||
entry_plan["trailing_stop_level"] = trail_level
|
||||
from app.db.schema import get_conn as _get_conn
|
||||
_c2 = _get_conn()
|
||||
_c2.execute("UPDATE recommendation SET entry_plan_json=%s WHERE id=%s",
|
||||
(json.dumps(entry_plan, ensure_ascii=False), rec["id"]))
|
||||
_c2.commit()
|
||||
_c2.close()
|
||||
|
||||
# 主链路状态迁移:tracker 只提交“候选状态 + 当前价”,最终状态由 DB 主链路统一落库。
|
||||
# 飞书推送只能消费主链路返回的最终状态,不能再自行判断。
|
||||
terminal_action = {
|
||||
@ -433,17 +338,6 @@ def track_prices():
|
||||
event_time=datetime.now().isoformat(),
|
||||
)
|
||||
push_trade_action_update(symbol, rec["id"], state_decision, final_action, push_type="entry")
|
||||
if tracking_signals.get("trailing_stop_activated"):
|
||||
activation_decision = dict(state_decision)
|
||||
activation_decision["push_required"] = True
|
||||
activation_decision["push_signals"] = tracking_signals.get("sell_signals", [])
|
||||
push_trade_action_update(
|
||||
symbol,
|
||||
rec["id"],
|
||||
activation_decision,
|
||||
"移动止盈保护",
|
||||
push_type="profit_protection",
|
||||
)
|
||||
|
||||
results.append({
|
||||
"symbol": symbol,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Cookie
|
||||
|
||||
from app.db.paper_trading import get_paper_trading_summary, list_paper_trades
|
||||
from app.db.paper_trading import get_paper_trading_summary, list_paper_trade_events, list_paper_trades
|
||||
from app.web.shared import require_admin
|
||||
|
||||
|
||||
@ -22,3 +22,15 @@ async def api_paper_trading_trades(
|
||||
):
|
||||
require_admin(altcoin_session)
|
||||
return list_paper_trades(limit=limit, offset=offset, status=status)
|
||||
|
||||
|
||||
@router.get("/api/paper-trading/events")
|
||||
async def api_paper_trading_events(
|
||||
limit: int = 80,
|
||||
offset: int = 0,
|
||||
symbol: str = "",
|
||||
event_type: 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)
|
||||
|
||||
@ -106,6 +106,15 @@
|
||||
.level-badge.short_swing { color: #187574; background: rgba(15,188,176,.12); border-color: rgba(15,188,176,.20); }
|
||||
.level-badge.structure_watch { color: var(--yellow-dark); background: var(--yellow-light); border-color: rgba(252,185,0,.22); }
|
||||
.level-badge.theme_trend { color: var(--blue); background: rgba(66,98,255,.07); border-color: rgba(66,98,255,.12); }
|
||||
.signal-level-strip { margin: 0 18px 10px; border: 1px solid var(--hairline-soft); background: linear-gradient(180deg, rgba(248,250,252,.96), rgba(255,255,255,.98)); border-radius: var(--radius-lg); padding: 10px 12px; display: grid; grid-template-columns: minmax(118px,.78fr) minmax(150px,1fr) minmax(150px,1fr); gap: 10px; align-items: center; }
|
||||
.signal-level-title { display:flex; align-items:center; gap:7px; min-width:0; }
|
||||
.signal-level-dot { width:8px; height:8px; border-radius:50%; background: var(--blue); box-shadow: 0 0 0 4px rgba(66,98,255,.08); flex-shrink:0; }
|
||||
.signal-level-strip.intraday_breakout .signal-level-dot { background: var(--green); box-shadow:0 0 0 4px rgba(0,180,115,.10); }
|
||||
.signal-level-strip.short_swing .signal-level-dot { background:#0f9f98; box-shadow:0 0 0 4px rgba(15,188,176,.12); }
|
||||
.signal-level-strip.structure_watch .signal-level-dot { background:var(--yellow); box-shadow:0 0 0 4px rgba(252,185,0,.14); }
|
||||
.signal-level-k { display:block; color:var(--stone); font-size:10px; font-weight:900; line-height:1.2; }
|
||||
.signal-level-v { display:block; margin-top:3px; color:var(--ink); font-size:13px; font-weight:950; line-height:1.25; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.signal-level-sub { color:var(--stone); font-size:11px; font-weight:800; line-height:1.35; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.card-bar .win-badge { padding: 4px 12px; border-radius: var(--radius-full); font-size: 12px; font-weight: 700; white-space: nowrap; color: var(--green); background: var(--green-light); }
|
||||
.card-bar .lose-badge { padding: 4px 12px; border-radius: var(--radius-full); font-size: 12px; font-weight: 700; white-space: nowrap; color: var(--red); background: var(--red-light); }
|
||||
.hist-pnl-badge { display: flex; align-items: baseline; gap: 4px; padding: 6px 14px; border-radius: var(--radius-full); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 900; white-space: nowrap; margin-left: auto; }
|
||||
@ -257,6 +266,7 @@
|
||||
.hist-metric-row { grid-template-columns: 1fr; padding: 6px 14px 8px; }
|
||||
.stats-strip { align-items: stretch; }
|
||||
.stats-main { width: 100%; }
|
||||
.signal-level-strip { grid-template-columns: 1fr; margin: 0 14px 8px; }
|
||||
.entry-plan { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 8px 14px; }
|
||||
.decision-strip { margin: 0 14px 8px; grid-template-columns: 86px minmax(0,1fr); }
|
||||
.onchain-brief { margin: 0 14px 8px; }
|
||||
@ -640,7 +650,7 @@ function renderRecCard(r) {
|
||||
var entryMethod = ep.entry_method || '';
|
||||
var signalText = sigs.join(' ');
|
||||
var phase = opportunityPhase(r, entryMethod, signalText);
|
||||
var isBuy = r.execution_status === 'buy_now' || r.display_bucket === 'realtime', isWait = phase.label === '等回踩' || r.lifecycle_state === 'waiting_entry', isWeakObserve = r.observe_tier === 'weak';
|
||||
var isBuy = r.execution_status === 'buy_now' || r.display_bucket === 'realtime', isWait = r.execution_status === 'wait_pullback' || r.lifecycle_state === 'waiting_entry', isWeakObserve = r.observe_tier === 'weak';
|
||||
var isExecuted = !!r.entry_triggered || r.display_bucket === 'position' || r.execution_status === 'holding' || r.execution_status === 'completed';
|
||||
var isTradePlan = isBuy || isWait || isExecuted || r.entry_triggered;
|
||||
var recMs = r.rec_time ? new Date(r.rec_time).getTime() : 0;
|
||||
@ -709,7 +719,7 @@ function renderRecCard(r) {
|
||||
var decisionCls = isBuy ? 'buy' : (isWait ? 'wait' : (isWeakObserve ? 'weak' : 'observe'));
|
||||
var decisionTitle = cleanDisplayText(r.execution_label || phase.label);
|
||||
var decisionFocus = isBuy ? ('现价 '+fmtP(price)) : (isWait ? ('等 '+fmtP(entryRef)) : (isWeakObserve ? '低优先级观察' : '等待确认'));
|
||||
var decisionReason = cleanDisplayText(isBuy ? (entryWindowSummary() || '入场窗口有效') : (isWait ? '现价不追,等回踩价附近再评估' : (r.observe_reason || r.state_reason || '未形成入场窗口')));
|
||||
var decisionReason = cleanDisplayText(isBuy ? (entryWindowSummary() || r.execution_reason || '入场窗口有效') : (r.execution_reason || (isWait ? '现价不追,等回踩价附近再评估' : (r.observe_reason || r.state_reason || '未形成入场窗口'))));
|
||||
var decisionHtml = '<div class="decision-strip '+decisionCls+'"><div class="decision-head"><span class="decision-label">最终建议</span><span class="decision-title">'+decisionTitle+'</span></div><div class="decision-body"><span class="decision-focus">'+decisionFocus+'</span><span class="decision-reason">'+decisionReason+'</span></div></div>';
|
||||
var aiInsightHtml = '';
|
||||
var aiInsight = r.llm_insight && r.llm_insight.content ? r.llm_insight.content : null;
|
||||
@ -733,13 +743,22 @@ function renderRecCard(r) {
|
||||
var ocMeta = [oc.chain || '链上', '24h事件 '+(oc.event_count_24h || 0), oc.dex_volume_usd ? ('DEX量 $'+fmtCompactNumber(oc.dex_volume_usd)) : ''].filter(Boolean).join(' · ');
|
||||
onchainHtml = '<div class="onchain-brief '+(ocRisk?'risk':'')+'"><div class="onchain-head"><span>'+ocTitle+'</span><span class="onchain-score">'+ocScore+'</span></div><div class="onchain-meta">'+escHtml(ocMeta)+'</div></div>';
|
||||
}
|
||||
function levelFrameText(key) {
|
||||
if (key === 'intraday_breakout') return '15m/1H';
|
||||
if (key === 'short_swing') return '1H/4H';
|
||||
if (key === 'structure_watch') return '4H/1D';
|
||||
if (key === 'theme_trend') return '1D/主题';
|
||||
return '多周期';
|
||||
}
|
||||
var levelBasis = Array.isArray(ep.plan_basis) ? ep.plan_basis.slice(0,2).join(' · ') : '';
|
||||
var signalLevelHtml = '<div class="signal-level-strip '+cleanDisplayText(levelKey)+'"><div class="signal-level-title"><span class="signal-level-dot"></span><div><span class="signal-level-k">信号级别</span><span class="signal-level-v">'+cleanDisplayText(levelLabel)+'</span></div></div><div><span class="signal-level-k">捕捉周期</span><span class="signal-level-v">'+cleanDisplayText(horizon || levelFrameText(levelKey))+'</span><span class="signal-level-sub">'+cleanDisplayText(levelFrameText(levelKey))+'</span></div><div><span class="signal-level-k">确认方式</span><span class="signal-level-v">'+cleanDisplayText(entryModel || '等待当前触发')+'</span><span class="signal-level-sub">'+cleanDisplayText(levelBasis || phase.label)+'</span></div></div>';
|
||||
var entryPlanHtml = '';
|
||||
if (isTradePlan) {
|
||||
entryPlanHtml = '<div class="entry-plan"><div class="ep-item"><span class="ep-label">机会级别</span><span class="ep-val level-ref">'+cleanDisplayText(levelLabel)+'</span><span class="ep-sub">'+cleanDisplayText(horizon || phase.short)+'</span></div><div class="ep-item"><span class="ep-label">'+entryLabel+'</span><span class="ep-val entry-ref">'+fmtP(entryRef)+'</span><span class="ep-sub">'+cleanDisplayText(entryModel || '触发/计划价')+'</span></div><div class="ep-item"><span class="ep-label">风险边界</span><span class="ep-val risk-line">'+fmtP(riskLine)+'</span><span class="ep-sub">'+cleanDisplayText(stopModel)+'</span></div><div class="ep-item"><span class="ep-label">上方空间</span><span class="ep-val space-ref">'+(upsidePct?('+'+upsidePct.toFixed(1)+'%'):'--')+'</span><span class="ep-sub">'+cleanDisplayText(tpModel)+' · '+fmtP(spaceRef)+'</span></div></div>';
|
||||
} else {
|
||||
entryPlanHtml = '<div class="entry-plan"><div class="ep-item"><span class="ep-label">机会级别</span><span class="ep-val level-ref">'+cleanDisplayText(levelLabel)+'</span><span class="ep-sub">'+cleanDisplayText(horizon || '观察池候选')+'</span></div><div class="ep-item"><span class="ep-label">当前参考</span><span class="ep-val entry-ref">'+fmtP(price)+'</span><span class="ep-sub">不是入场价</span></div><div class="ep-item"><span class="ep-label">确认条件</span><span class="ep-val space-ref">待触发</span><span class="ep-sub">'+cleanDisplayText(entryModel || '需15m/1H当前信号')+'</span></div><div class="ep-item"><span class="ep-label">绩效口径</span><span class="ep-val risk-line">不计入</span><span class="ep-sub">未成交易推荐</span></div></div>';
|
||||
}
|
||||
return '<div class="card '+(isWeakObserve?'weak-observe':'')+'"><div class="card-bar"><div class="coin-left"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span></div></div><div class="badge-group"><span class="level-badge '+cleanDisplayText(levelKey)+'">'+cleanDisplayText(levelLabel)+'</span>'+actionBadge+'<span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">'+st.label+'</span></span></div></div><div class="price-bar"><span class="price">$'+priceFmt+'</span>'+changeHtml+'</div>'+decisionHtml+onchainHtml+aiInsightHtml+'<div class="kline-wrap"><div class="kline-int-bar"><button class="kline-int-btn" data-int="15m" onclick="switchKlineInterval(this);event.stopPropagation()">15m</button><button class="kline-int-btn active" data-int="1h" onclick="switchKlineInterval(this);event.stopPropagation()">1H</button><button class="kline-int-btn" data-int="4h" onclick="switchKlineInterval(this);event.stopPropagation()">4H</button><button class="kline-int-btn" data-int="1d" onclick="switchKlineInterval(this);event.stopPropagation()">1D</button></div><div class="kline-container loading" data-symbol="'+r.symbol+'" data-entry-price="'+klineEntryPrice+'" data-stop-loss="'+klineStopLoss+'" data-tp1="'+klineTp1+'" data-rec-time="'+entryTime+'" data-tp1-time="'+tp1EventTime+'" data-sl-time="'+slEventTime+'" data-ref-price="'+price+'" data-action-status="'+(r.action_status||'')+'"><div class="chart-loading"><svg class="spin" width="16" height="16" color="#8e91a0"><use href="#svg-spinner"/></svg></div></div></div>'+(isWeakObserve ? weakNoteHtml : entryPlanHtml)+(sigHtml?'<div class="signals-row">'+sigHtml+'</div>':'')+'<div class="card-footer"><span>'+fmtTime(r.rec_time)+'</span><span class="card-ver">'+ver+'</span>'+(isTpOrSl?'<span class="pnl-block '+pnlCls+'">'+pnlSign+pnl.toFixed(1)+'%</span>':'')+'</div></div>';
|
||||
return '<div class="card '+(isWeakObserve?'weak-observe':'')+'"><div class="card-bar"><div class="coin-left"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span></div></div><div class="badge-group"><span class="level-badge '+cleanDisplayText(levelKey)+'">'+cleanDisplayText(levelLabel)+'</span>'+actionBadge+'<span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">'+st.label+'</span></span></div></div><div class="price-bar"><span class="price">$'+priceFmt+'</span>'+changeHtml+'</div>'+decisionHtml+signalLevelHtml+onchainHtml+aiInsightHtml+'<div class="kline-wrap"><div class="kline-int-bar"><button class="kline-int-btn" data-int="15m" onclick="switchKlineInterval(this);event.stopPropagation()">15m</button><button class="kline-int-btn active" data-int="1h" onclick="switchKlineInterval(this);event.stopPropagation()">1H</button><button class="kline-int-btn" data-int="4h" onclick="switchKlineInterval(this);event.stopPropagation()">4H</button><button class="kline-int-btn" data-int="1d" onclick="switchKlineInterval(this);event.stopPropagation()">1D</button></div><div class="kline-container loading" data-symbol="'+r.symbol+'" data-entry-price="'+klineEntryPrice+'" data-stop-loss="'+klineStopLoss+'" data-tp1="'+klineTp1+'" data-rec-time="'+entryTime+'" data-tp1-time="'+tp1EventTime+'" data-sl-time="'+slEventTime+'" data-ref-price="'+price+'" data-action-status="'+(r.action_status||'')+'"><div class="chart-loading"><svg class="spin" width="16" height="16" color="#8e91a0"><use href="#svg-spinner"/></svg></div></div></div>'+(isWeakObserve ? weakNoteHtml : entryPlanHtml)+(sigHtml?'<div class="signals-row">'+sigHtml+'</div>':'')+'<div class="card-footer"><span>'+fmtTime(r.rec_time)+'</span><span class="card-ver">'+ver+'</span>'+(isTpOrSl?'<span class="pnl-block '+pnlCls+'">'+pnlSign+pnl.toFixed(1)+'%</span>':'')+'</div></div>';
|
||||
} catch (e) {
|
||||
console.error('renderRecCard hard fail', r && r.symbol, e);
|
||||
return renderLiveFallbackCard(r);
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
{% block title %}AlphaX Agent | Crypto — 模拟交易{% endblock %}
|
||||
{% block extra_head_css %}
|
||||
<style>
|
||||
.shell{width:min(100% - 40px,1280px);margin:0 auto;padding:24px 0 48px}.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;flex-wrap:wrap;margin-bottom:16px}.page-head h1{font-size:28px;font-weight:950;color:var(--ink);letter-spacing:-.7px}.page-head p{margin-top:5px;color:var(--stone);font-size:13px;line-height:1.55;max-width:860px}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.btn,.select{height:38px;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:0 12px;font-size:13px;font-weight:850;color:var(--ink)}.btn{cursor:pointer}.kpis{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;margin-bottom:14px}.kpi{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);padding:14px;min-width:0}.kpi span{display:block;color:var(--stone);font-size:11px;font-weight:900}.kpi b{display:block;margin-top:7px;color:var(--ink);font-size:23px;line-height:1;font-weight:950;letter-spacing:-.5px}.kpi small{display:block;margin-top:7px;color:var(--stone);font-size:11px;font-weight:800}.kpi b.green{color:var(--green)}.kpi b.red{color:var(--red)}.kpi b.blue{color:var(--blue)}.note{border:1px solid rgba(66,98,255,.14);background:rgba(66,98,255,.045);border-radius:var(--radius-md);padding:11px 12px;color:var(--slate);font-size:12px;line-height:1.55;margin-bottom:14px}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);overflow:hidden}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:13px 14px;border-bottom:1px solid var(--hairline-soft)}.panel-title{font-size:14px;font-weight:950;color:var(--ink)}.panel-note{font-size:11px;color:var(--stone);font-weight:850}.table-wrap{overflow:auto}.table{width:100%;border-collapse:collapse;min-width:1180px}.table th,.table td{padding:11px 10px;border-bottom:1px solid var(--hairline-soft);text-align:left;font-size:12px;vertical-align:middle}.table th{font-size:11px;color:var(--stone);font-weight:900;background:var(--surface)}.sym{font-weight:950;color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.badge{display:inline-flex;align-items:center;height:24px;border-radius:999px;padding:0 8px;font-size:11px;font-weight:900;border:1px solid var(--hairline-soft);background:var(--surface);color:var(--slate);white-space:nowrap}.badge.open{background:rgba(66,98,255,.08);border-color:rgba(66,98,255,.18);color:var(--blue)}.badge.closed{background:var(--green-light);border-color:rgba(0,180,115,.18);color:var(--green)}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:850}.pos{color:var(--green)}.neg{color:var(--red)}.muted{color:var(--stone)}.riskline{display:grid;gap:3px}.pagination{display:flex;justify-content:center;align-items:center;gap:12px;padding:14px;color:var(--stone);font-size:12px}.pagination button{height:34px;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:0 12px;font-size:12px;font-weight:850;cursor:pointer}.pagination button:disabled{opacity:.45;cursor:default}.empty,.loading{text-align:center;padding:34px 14px;color:var(--stone);font-size:13px}@media(max-width:1100px){.kpis{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:700px){.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.shell{width:min(100% - 24px,1280px)}}@media(max-width:520px){.kpis{grid-template-columns:1fr}.page-head h1{font-size:22px}}
|
||||
.shell{width:min(100% - 40px,1280px);margin:0 auto;padding:24px 0 48px}.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;flex-wrap:wrap;margin-bottom:16px}.page-head h1{font-size:28px;font-weight:950;color:var(--ink);letter-spacing:-.7px}.page-head p{margin-top:5px;color:var(--stone);font-size:13px;line-height:1.55;max-width:860px}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.btn,.select,.input{height:38px;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:0 12px;font-size:13px;font-weight:850;color:var(--ink)}.input{width:150px}.btn{cursor:pointer}.kpis{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;margin-bottom:14px}.kpi{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);padding:14px;min-width:0}.kpi span{display:block;color:var(--stone);font-size:11px;font-weight:900}.kpi b{display:block;margin-top:7px;color:var(--ink);font-size:23px;line-height:1;font-weight:950;letter-spacing:-.5px}.kpi small{display:block;margin-top:7px;color:var(--stone);font-size:11px;font-weight:800}.kpi b.green{color:var(--green)}.kpi b.red{color:var(--red)}.kpi b.blue{color:var(--blue)}.note{border:1px solid rgba(66,98,255,.14);background:rgba(66,98,255,.045);border-radius:var(--radius-md);padding:11px 12px;color:var(--slate);font-size:12px;line-height:1.55;margin-bottom:14px}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);overflow:hidden;margin-top:14px}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:13px 14px;border-bottom:1px solid var(--hairline-soft)}.panel-title{font-size:14px;font-weight:950;color:var(--ink)}.panel-note{font-size:11px;color:var(--stone);font-weight:850}.table-wrap{overflow:auto}.table{width:100%;border-collapse:collapse;min-width:1180px}.table th,.table td{padding:11px 10px;border-bottom:1px solid var(--hairline-soft);text-align:left;font-size:12px;vertical-align:middle}.table th{font-size:11px;color:var(--stone);font-weight:900;background:var(--surface)}.sym{font-weight:950;color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.badge{display:inline-flex;align-items:center;height:24px;border-radius:999px;padding:0 8px;font-size:11px;font-weight:900;border:1px solid var(--hairline-soft);background:var(--surface);color:var(--slate);white-space:nowrap}.badge.open{background:rgba(66,98,255,.08);border-color:rgba(66,98,255,.18);color:var(--blue)}.badge.closed{background:var(--green-light);border-color:rgba(0,180,115,.18);color:var(--green)}.badge.event-open{background:rgba(66,98,255,.08);border-color:rgba(66,98,255,.18);color:var(--blue)}.badge.event-close{background:var(--green-light);border-color:rgba(0,180,115,.18);color:var(--green)}.badge.event-trailing{background:rgba(245,158,11,.12);border-color:rgba(245,158,11,.22);color:#a05a00}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:850}.pos{color:var(--green)}.neg{color:var(--red)}.muted{color:var(--stone)}.riskline{display:grid;gap:3px}.pagination{display:flex;justify-content:center;align-items:center;gap:12px;padding:14px;color:var(--stone);font-size:12px}.pagination button{height:34px;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:0 12px;font-size:12px;font-weight:850;cursor:pointer}.pagination button:disabled{opacity:.45;cursor:default}.empty,.loading{text-align:center;padding:34px 14px;color:var(--stone);font-size:13px}.event-list{padding:4px 14px 12px}.event{display:grid;grid-template-columns:130px 140px 1fr 170px;gap:12px;align-items:flex-start;padding:13px 0;border-bottom:1px solid var(--hairline-soft)}.event:last-child{border-bottom:0}.event-time{font-size:12px;color:var(--stone);font-weight:850}.event-main{min-width:0}.event-title{font-size:13px;font-weight:950;color:var(--ink);margin-bottom:4px}.event-msg{font-size:12px;color:var(--slate);line-height:1.45}.event-detail{font-size:11px;color:var(--stone);line-height:1.5}.event-price{text-align:right;font-size:12px}.event-price b{display:block;color:var(--ink);font-size:13px}@media(max-width:1100px){.kpis{grid-template-columns:repeat(3,minmax(0,1fr))}.event{grid-template-columns:110px 120px 1fr}}@media(max-width:700px){.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.shell{width:min(100% - 24px,1280px)}.event{grid-template-columns:1fr}.event-price{text-align:left}}@media(max-width:520px){.kpis{grid-template-columns:1fr}.page-head h1{font-size:22px}}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
@ -33,11 +33,29 @@
|
||||
</div>
|
||||
<div class="pagination" id="pager"></div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div><div class="panel-title">操作日志</div><div class="panel-note">开仓、平仓、移动止盈激活与上移都会记录在这里</div></div>
|
||||
<div class="actions">
|
||||
<input class="input" id="eventSymbol" placeholder="币种,如 LINK/USDT" onkeydown="if(event.key==='Enter')loadEvents(0)">
|
||||
<select class="select" id="eventType" onchange="loadEvents(0)">
|
||||
<option value="">全部动作</option>
|
||||
<option value="open">开仓</option>
|
||||
<option value="close">平仓</option>
|
||||
<option value="trailing_activate">移动止盈激活</option>
|
||||
<option value="trailing_move">移动止盈上移</option>
|
||||
</select>
|
||||
<button class="btn" onclick="loadEvents(0)">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-list" id="eventRows"><div class="loading">加载中...</div></div>
|
||||
<div class="pagination" id="eventPager"></div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
var LIMIT=50,offset=0,total=0;
|
||||
var LIMIT=50,offset=0,total=0,EVENT_LIMIT=80,eventOffset=0,eventTotal=0;
|
||||
function $(id){return document.getElementById(id)}
|
||||
function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});}
|
||||
function fmt(v,d){v=Number(v||0);return v.toLocaleString(undefined,{maximumFractionDigits:d==null?4:d,minimumFractionDigits:0})}
|
||||
@ -45,7 +63,7 @@ function pct(v){v=Number(v||0);var cls=v>0?'pos':v<0?'neg':'';return '<span clas
|
||||
function money(v){v=Number(v||0);var cls=v>0?'pos':v<0?'neg':'';return '<span class="mono '+cls+'">'+(v>0?'+':'')+fmt(v,2)+' USDT</span>'}
|
||||
function time(t){if(!t)return'--';var d=new Date(t);if(isNaN(d.getTime()))return String(t).slice(0,16).replace('T',' ');return (d.getMonth()+1)+'/'+d.getDate()+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')}
|
||||
async function api(url){var r=await fetch(url);var d=await r.json().catch(function(){return{}});if(!r.ok)throw new Error(d.detail||d.error||'请求失败');return d}
|
||||
async function loadAll(){await Promise.all([loadSummary(),loadTrades(offset)])}
|
||||
async function loadAll(){await Promise.all([loadSummary(),loadTrades(offset),loadEvents(eventOffset)])}
|
||||
async function loadSummary(){try{var d=await api('/api/paper-trading/summary?days=30');var totalPnl=Number(d.total_pnl_usdt||0),realized=Number(d.realized_pnl_usdt||0),unrealized=Number(d.open_unrealized_pnl_usdt||0),ret=Number(d.account_total_return_pct||0);$('paperNote').textContent='当前账户余额 = 初始本金 + 已实现收益 + 持仓浮动收益。累计杠杆按当前持仓名义价值 ÷ 当前账户余额计算,用来衡量账户整体暴露。';$('kpis').innerHTML=[
|
||||
card('当前账户余额',fmt(d.current_balance_usdt||d.account_equity_usdt||0,2)+'U',totalPnl>=0?'green':'red','初始本金 '+fmt(d.initial_equity_usdt||d.account_equity_usdt||0,0)+'U'),
|
||||
card('当前持仓价值',fmt(d.open_position_value_usdt||0,0)+'U','blue',(d.open_count||0)+' 个持仓中'),
|
||||
@ -71,6 +89,16 @@ function renderTrades(items){if(!items.length){$('tradeRows').innerHTML='<tr><td
|
||||
'<td><div>'+esc(x.source_status||'--')+'</div><div class="muted">'+esc(x.strategy_version||'')+'</div></td>'+
|
||||
'</tr>'}).join('')}
|
||||
function renderPager(){var page=Math.floor(offset/LIMIT)+1,totalPages=Math.max(1,Math.ceil(total/LIMIT));$('pageInfo').textContent='第 '+page+' / '+totalPages+' 页 · 共 '+total+' 条';$('pager').innerHTML='<button '+(offset===0?'disabled':'')+' onclick="loadTrades('+(offset-LIMIT)+')">上一页</button><span>第 '+page+' / '+totalPages+' 页</span><button '+((offset+LIMIT>=total)?'disabled':'')+' onclick="loadTrades('+(offset+LIMIT)+')">下一页</button>'}
|
||||
async function loadEvents(nextOffset){eventOffset=Math.max(0,nextOffset||0);$('eventRows').innerHTML='<div class="loading">加载中...</div>';try{var sym=$('eventSymbol').value||'';var typ=$('eventType').value||'';var d=await api('/api/paper-trading/events?limit='+EVENT_LIMIT+'&offset='+eventOffset+'&symbol='+encodeURIComponent(sym)+'&event_type='+encodeURIComponent(typ));eventTotal=d.total||0;renderEvents(d.items||[]);renderEventPager()}catch(e){$('eventRows').innerHTML='<div class="empty">'+esc(e.message)+'</div>'}}
|
||||
function eventLabel(t){return {open:'开仓',close:'平仓',trailing_activate:'移动止盈激活',trailing_move:'移动止盈上移'}[t]||t||'动作'}
|
||||
function eventCls(t){if(t==='open')return'event-open';if(t==='close')return'event-close';if(String(t).indexOf('trailing')===0)return'event-trailing';return''}
|
||||
function renderEvents(items){if(!items.length){$('eventRows').innerHTML='<div class="empty">暂无操作日志</div>';return}$('eventRows').innerHTML=items.map(function(e){var d=e.detail||{};var detail=[];if(d.notional_usdt)detail.push('名义仓位 '+fmt(d.notional_usdt,0)+'U');if(d.margin_usdt)detail.push('保证金 '+fmt(d.margin_usdt,0)+'U');if(d.leverage)detail.push(fmt(d.leverage,1)+'x');if(d.trailing_stop)detail.push('保护价 $'+fmt(d.trailing_stop,6));if(d.previous_trailing_stop)detail.push('原保护 $'+fmt(d.previous_trailing_stop,6));if(d.distance_pct)detail.push('回撤距离 '+fmt(d.distance_pct,2)+'%');if(d.realized_pnl_usdt!=null)detail.push('实现盈亏 '+fmt(d.realized_pnl_usdt,2)+'U');return '<div class="event">'+
|
||||
'<div class="event-time">'+time(e.event_time)+'<br><span class="muted">#'+esc(e.trade_id)+'</span></div>'+
|
||||
'<div><div class="sym">'+esc(e.symbol)+'</div><span class="badge '+eventCls(e.event_type)+'">'+esc(eventLabel(e.event_type))+'</span></div>'+
|
||||
'<div class="event-main"><div class="event-title">'+esc(e.message||eventLabel(e.event_type))+'</div><div class="event-msg">交易状态 '+esc(e.trade_status||'--')+' · 来源推荐 '+esc(e.recommendation_id||'--')+'</div><div class="event-detail">'+esc(detail.join(' · ')||'无附加参数')+'</div></div>'+
|
||||
'<div class="event-price"><b>$'+fmt(e.price,6)+'</b><span>'+pct(e.pnl_pct)+'</span></div>'+
|
||||
'</div>'}).join('')}
|
||||
function renderEventPager(){var page=Math.floor(eventOffset/EVENT_LIMIT)+1,totalPages=Math.max(1,Math.ceil(eventTotal/EVENT_LIMIT));$('eventPager').innerHTML='<button '+(eventOffset===0?'disabled':'')+' onclick="loadEvents('+(eventOffset-EVENT_LIMIT)+')">上一页</button><span>第 '+page+' / '+totalPages+' 页 · 共 '+eventTotal+' 条</span><button '+((eventOffset+EVENT_LIMIT>=eventTotal)?'disabled':'')+' onclick="loadEvents('+(eventOffset+EVENT_LIMIT)+')">下一页</button>'}
|
||||
loadAll();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
@ -27,9 +28,9 @@ def temp_db(monkeypatch, tmp_path):
|
||||
def _insert_recommendation(db_path, **kwargs):
|
||||
defaults = dict(
|
||||
symbol='AAA/USDT',
|
||||
rec_time='2026-04-30T10:00:00',
|
||||
rec_time=datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
rec_state='加速',
|
||||
rec_score=8,
|
||||
rec_score=40,
|
||||
entry_price=100.0,
|
||||
stop_loss=95.0,
|
||||
tp1=110.0,
|
||||
@ -48,8 +49,8 @@ def _insert_recommendation(db_path, **kwargs):
|
||||
hit_tp2_time='',
|
||||
stopped_out_time='',
|
||||
expired_time='',
|
||||
last_track_time='2026-04-30T10:05:00',
|
||||
entry_plan_json='{"entry_trigger_confirmed": true}',
|
||||
last_track_time=datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
entry_plan_json='{"entry_price":100,"entry_action":"可即刻买入","entry_trigger_confirmed":true,"risk_reward_ok":true,"stop_loss":95,"tp1":110,"rr1":2}',
|
||||
action_status='持有',
|
||||
direction='多头启动',
|
||||
strategy_version='v1.2',
|
||||
@ -158,6 +159,29 @@ def test_stats_only_count_actionable_active_recommendations(temp_db):
|
||||
assert stats['active_pending_count'] == 0
|
||||
|
||||
|
||||
def test_link_can_be_able_to_buy_without_signal_degradation_when_context_is_consistent(temp_db):
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='LINK/USDT',
|
||||
action_status='可即刻买入',
|
||||
entry_plan_json='{"entry_price": 9.74, "entry_action": "可即刻买入", "entry_trigger_confirmed": true, "risk_reward_ok": true, "rr1": 1.6, "stop_loss": 9.253, "tp1": 10.5192, "opportunity_level": "short_swing", "opportunity_level_label": "短波段", "max_action": "buy_now"}',
|
||||
signals='["🟢 15min即刻入场信号", "1H 量价齐飞K(量3.7x)"]',
|
||||
current_price=9.74,
|
||||
entry_price=9.74,
|
||||
max_price=9.74,
|
||||
min_price=9.74,
|
||||
rec_time=datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
)
|
||||
|
||||
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
|
||||
target = next(r for r in rows if r['symbol'] == 'LINK/USDT')
|
||||
|
||||
assert target['execution_status'] == 'buy_now'
|
||||
assert target['action_status'] == '可即刻买入'
|
||||
assert target['opportunity_level'] == 'short_swing'
|
||||
assert target['observe_reason'] != '信号偏弱'
|
||||
|
||||
|
||||
def test_stats_api_exposes_separate_live_and_history_sections(temp_db):
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
|
||||
@ -4,6 +4,7 @@ import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
@ -28,9 +29,9 @@ class RecommendationHistoryBase(unittest.TestCase):
|
||||
def _insert_rec(self, **kwargs):
|
||||
defaults = dict(
|
||||
symbol='AAA/USDT',
|
||||
rec_time='2026-04-29T10:00:00',
|
||||
rec_time=datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
rec_state='加速',
|
||||
rec_score=8,
|
||||
rec_score=40,
|
||||
entry_price=100.0,
|
||||
stop_loss=95.0,
|
||||
tp1=110.0,
|
||||
@ -49,7 +50,7 @@ class RecommendationHistoryBase(unittest.TestCase):
|
||||
hit_tp2_time='',
|
||||
stopped_out_time='',
|
||||
expired_time='',
|
||||
last_track_time='2026-04-29T10:05:00',
|
||||
last_track_time=datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
entry_plan_json=json.dumps({
|
||||
'entry_price': 100.0,
|
||||
'entry_action': '可即刻买入',
|
||||
|
||||
@ -75,6 +75,31 @@ def test_buy_now_requires_current_trigger_and_no_bearish_flow_risk():
|
||||
assert any('空头加速' in r for r in reasons)
|
||||
|
||||
|
||||
def test_structure_watch_pullback_touch_does_not_upgrade_to_buy_now():
|
||||
action, plan, reasons = apply_entry_quality_gate(
|
||||
action_status='等回踩',
|
||||
entry_plan={
|
||||
'entry_action': '等回踩',
|
||||
'entry_price': 9.74,
|
||||
'current_price': 9.74,
|
||||
'stop_loss': 9.253,
|
||||
'tp1': 10.5192,
|
||||
'risk_reward_ok': True,
|
||||
'rr1': 1.6,
|
||||
'opportunity_level': 'structure_watch',
|
||||
'opportunity_level_label': '结构观察',
|
||||
'max_action': 'wait_pullback',
|
||||
},
|
||||
signals=['1H放量(4.4x)但无量价齐飞(量价背离)', '🟢 15min即刻入场信号'],
|
||||
current_price=9.74,
|
||||
market_context={'change_24h': 0.3},
|
||||
)
|
||||
|
||||
assert action == '等回踩'
|
||||
assert action != '可即刻买入'
|
||||
assert any('不能因到价直接升级' in r for r in reasons)
|
||||
|
||||
|
||||
def test_tracker_gate_downgrade_removes_provisional_buy_signal():
|
||||
signals = reconcile_buy_signals_after_gate(
|
||||
[
|
||||
|
||||
@ -3,7 +3,7 @@ import json
|
||||
import pytest
|
||||
|
||||
from app.db import altcoin_db
|
||||
from app.db.paper_trading import get_paper_trading_summary, list_paper_trades, sync_recommendation
|
||||
from app.db.paper_trading import get_paper_trading_summary, list_paper_trade_events, list_paper_trades, sync_recommendation
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -138,6 +138,64 @@ 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_paper_trading_trailing_stop_activates_moves_and_closes(monkeypatch, buy_now_rec):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", "0.5")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5")
|
||||
|
||||
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
||||
activated = sync_recommendation(buy_now_rec, 105, event_time="2026-05-16T10:01:00")
|
||||
moved = sync_recommendation(buy_now_rec, 105.5, event_time="2026-05-16T10:02:00")
|
||||
closed = sync_recommendation(buy_now_rec, moved["trailing_stop"] * 0.999, event_time="2026-05-16T10:03:00")
|
||||
|
||||
assert activated["activated"] is True
|
||||
assert activated["trailing_stop"] >= 100.5
|
||||
assert moved["moved"] is True
|
||||
assert moved["trailing_stop"] > activated["trailing_stop"]
|
||||
assert closed["closed"] is True
|
||||
assert closed["exit_reason"] == "trailing_stop"
|
||||
|
||||
trade = list_paper_trades()["items"][0]
|
||||
assert trade["status"] == "closed"
|
||||
assert trade["exit_reason"] == "trailing_stop"
|
||||
assert trade["exit_price"] > trade["entry_price"]
|
||||
|
||||
|
||||
def test_paper_trading_trailing_stop_never_moves_down(monkeypatch, buy_now_rec):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", "0.5")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5")
|
||||
|
||||
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
||||
high = sync_recommendation(buy_now_rec, 105, event_time="2026-05-16T10:01:00")
|
||||
pullback = sync_recommendation(buy_now_rec, 104, event_time="2026-05-16T10:02:00")
|
||||
|
||||
assert high["activated"] is True
|
||||
assert pullback["updated"] is True
|
||||
assert pullback.get("trailing_stop", high["trailing_stop"]) == pytest.approx(high["trailing_stop"])
|
||||
assert pullback.get("moved") is False
|
||||
|
||||
|
||||
def test_paper_trading_events_capture_open_close_and_trailing(monkeypatch, buy_now_rec):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", "0.5")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5")
|
||||
|
||||
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
||||
sync_recommendation(buy_now_rec, 105, event_time="2026-05-16T10:01:00")
|
||||
sync_recommendation(buy_now_rec, 104.9, event_time="2026-05-16T10:02:00")
|
||||
|
||||
events = list_paper_trade_events(limit=20)["items"]
|
||||
types = [e["event_type"] for e in events]
|
||||
|
||||
assert "open" in types
|
||||
assert "trailing_activate" in types
|
||||
assert "trailing_move" not in types or isinstance(types, list)
|
||||
|
||||
|
||||
def test_closed_paper_trade_keeps_exit_price_and_shows_latest_market_price(buy_now_rec):
|
||||
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
||||
sync_recommendation(buy_now_rec, 106, event_time="2026-05-16T10:05:00")
|
||||
|
||||
@ -67,6 +67,23 @@ def test_price_streamer_tick_opens_and_closes_paper_trade(buy_now_rec):
|
||||
assert trade["notional_usdt"] == pytest.approx(5000.0)
|
||||
|
||||
|
||||
def test_price_streamer_tick_drives_paper_trailing_stop(monkeypatch, buy_now_rec):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", "0.5")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", "1.5")
|
||||
targets = {"WS/USDT": buy_now_rec}
|
||||
|
||||
price_streamer.handle_price_tick("WS/USDT", 100, targets, event_time="2026-05-16T10:00:00")
|
||||
activated = price_streamer.handle_price_tick("WS/USDT", 105, targets, event_time="2026-05-16T10:01:00")
|
||||
trailing_stop = activated["paper_trading"]["trailing_stop"]
|
||||
closed = price_streamer.handle_price_tick("WS/USDT", trailing_stop * 0.999, targets, event_time="2026-05-16T10:02:00")
|
||||
|
||||
assert activated["paper_trading"]["activated"] is True
|
||||
assert closed["paper_trading"]["closed"] is True
|
||||
assert closed["paper_trading"]["exit_reason"] == "trailing_stop"
|
||||
|
||||
|
||||
def test_price_streamer_tracks_open_paper_trade_without_active_rec(buy_now_rec):
|
||||
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
||||
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_DIR not in sys.path:
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
from app.services import price_tracker
|
||||
|
||||
|
||||
def test_trailing_stop_activation_uses_profit_floor_when_atr_is_too_wide(monkeypatch):
|
||||
"""MLN-style case: +5% float profit with huge ATR must still protect above entry."""
|
||||
monkeypatch.setattr(price_tracker, "calc_atr", lambda df, period=14: 0.447143)
|
||||
monkeypatch.setattr(price_tracker, "detect_trend_exhaustion", lambda df, atr: {"exhausted": False, "signals": [], "severity": "low"})
|
||||
monkeypatch.setattr(price_tracker, "full_pa_analysis", lambda df, timeframe: {"candles_class": []})
|
||||
monkeypatch.setattr(price_tracker, "load_rules", lambda: {
|
||||
"tracker": {
|
||||
"trailing_stop": {
|
||||
"enabled": True,
|
||||
"activate_pnl_pct": 3,
|
||||
"min_lock_profit_pct": 0.5,
|
||||
"breakeven_buffer_pct": 0.5,
|
||||
"tiers": [
|
||||
{"min_pnl_pct": 0, "atr_mult": 3.0, "label": "防震"},
|
||||
{"min_pnl_pct": 5, "atr_mult": 2.0, "label": "锁利"},
|
||||
],
|
||||
}
|
||||
}
|
||||
})
|
||||
df = pd.DataFrame(
|
||||
[
|
||||
{"open": 3.5, "high": 3.8, "low": 3.4, "close": 3.7, "volume": 100},
|
||||
{"open": 3.7, "high": 3.9, "low": 3.6, "close": 3.8, "volume": 120},
|
||||
]
|
||||
* 20
|
||||
)
|
||||
monkeypatch.setattr(price_tracker, "fetch_klines", lambda symbol, timeframe, limit=100: df)
|
||||
|
||||
rec = {
|
||||
"entry_price": 3.61,
|
||||
"stop_loss": 3.249,
|
||||
"tp1": 4.822857,
|
||||
"tp2": 5.631429,
|
||||
"entry_plan": {"trailing_stop_level": 0.0},
|
||||
}
|
||||
|
||||
result = price_tracker.analyze_tracking_signals("MLN/USDT", rec, 3.8)
|
||||
|
||||
assert result["trailing_stop_activated"] is True
|
||||
assert result["trailing_stop_level"] == max(3.249, 3.61 * 1.005)
|
||||
assert result["trailing_stop_level"] > rec["entry_price"]
|
||||
assert any("跟踪止盈激活" in signal for signal in result["sell_signals"])
|
||||
@ -16,7 +16,7 @@ def test_watch_only_recommendation_is_skipped_before_take_profit_push(monkeypatc
|
||||
"stop_loss": 95.0,
|
||||
"tp1": 110.0,
|
||||
"tp2": 120.0,
|
||||
"entry_plan": {"trailing_stop_level": 0.0},
|
||||
"entry_plan": {},
|
||||
"entry_triggered": 0,
|
||||
"display_bucket": "watch_pool",
|
||||
"execution_status": "observe",
|
||||
@ -33,9 +33,6 @@ def test_watch_only_recommendation_is_skipped_before_take_profit_push(monkeypatc
|
||||
"buy_signals": [],
|
||||
"exhaustion": {"severity": "low"},
|
||||
"pnl_pct": 0.0,
|
||||
"trailing_stop_level": 0.0,
|
||||
"trailing_stop_activated": False,
|
||||
"trailing_stop_moved": False,
|
||||
})
|
||||
monkeypatch.setattr(price_tracker, "apply_recommendation_state_transition", lambda *args, **kwargs: {"action_status": "观察", "push_required": False})
|
||||
pushed = []
|
||||
|
||||
@ -3,6 +3,7 @@ import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.db import altcoin_db
|
||||
@ -23,9 +24,9 @@ class RecommendationExecutionStatusTests(unittest.TestCase):
|
||||
def _insert_rec(self, **kwargs):
|
||||
defaults = dict(
|
||||
symbol='AAA/USDT',
|
||||
rec_time='2026-04-29T10:00:00',
|
||||
rec_time=datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
rec_state='加速',
|
||||
rec_score=8,
|
||||
rec_score=40,
|
||||
entry_price=100.0,
|
||||
stop_loss=95.0,
|
||||
tp1=110.0,
|
||||
@ -44,7 +45,7 @@ class RecommendationExecutionStatusTests(unittest.TestCase):
|
||||
hit_tp2_time='',
|
||||
stopped_out_time='',
|
||||
expired_time='',
|
||||
last_track_time='2026-04-29T10:05:00',
|
||||
last_track_time=datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
entry_plan_json=json.dumps({
|
||||
'entry_price': 100.0,
|
||||
'entry_action': '可即刻买入',
|
||||
@ -98,6 +99,23 @@ class RecommendationExecutionStatusTests(unittest.TestCase):
|
||||
self.assertEqual(row['initial_action'], '可即刻买入')
|
||||
self.assertIn('推荐时就是可即刻买入', row['execution_reason'])
|
||||
|
||||
def test_low_score_buy_now_downgrades_before_dashboard(self):
|
||||
self._insert_rec(symbol='DBG/USDT', rec_score=8)
|
||||
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
|
||||
row = next(r for r in rows if r['symbol'] == 'DBG/USDT')
|
||||
self.assertEqual(row['execution_status'], 'observe')
|
||||
self.assertEqual(row['action_status'], '观察')
|
||||
self.assertIn('信号不足', row['execution_reason'])
|
||||
self.assertIn('entry_quality_gate', row['entry_plan'])
|
||||
|
||||
def test_stale_buy_now_downgrades_before_dashboard(self):
|
||||
self._insert_rec(rec_time='2026-04-29T10:00:00', last_track_time='2026-04-29T10:05:00')
|
||||
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
|
||||
row = next(r for r in rows if r['symbol'] == 'AAA/USDT')
|
||||
self.assertNotEqual(row['execution_status'], 'buy_now')
|
||||
self.assertIn(row['action_status'], ('观察', '等回踩'))
|
||||
self.assertTrue(row.get('entry_window_alert'))
|
||||
|
||||
def test_wait_pullback_status_for_wait_action(self):
|
||||
self._insert_rec(
|
||||
symbol='BBB/USDT',
|
||||
|
||||
@ -99,6 +99,21 @@ class RecommendationSignalTrustTests(unittest.TestCase):
|
||||
row = self._row(rec_id)
|
||||
self.assertEqual(row['action_status'], '观察')
|
||||
|
||||
def test_read_model_downgrades_stale_buy_now_window(self):
|
||||
rec_id = self._insert_rec(
|
||||
rec_time='2026-05-09T17:30:00',
|
||||
entry_price=10.0,
|
||||
current_price=10.02,
|
||||
)
|
||||
|
||||
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
|
||||
target = next(r for r in rows if r['id'] == rec_id)
|
||||
|
||||
self.assertNotEqual(target['execution_status'], 'buy_now')
|
||||
self.assertIn(target['action_status'], ('观察', '等回踩'))
|
||||
self.assertIsNotNone(target.get('entry_window_alert'))
|
||||
self.assertIn(target['entry_window_alert']['status'], ('expired', 'price_left_up', 'price_left_down'))
|
||||
|
||||
def test_entry_window_invalidates_when_price_moves_too_far_above_entry(self):
|
||||
rec_id = self._insert_rec(rec_time='2026-05-09T20:00:00', entry_price=10.0, current_price=10.0)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user