This commit is contained in:
aaron 2026-05-18 00:58:19 +08:00
parent 5e863e6d2a
commit 7f1c9ec12d
18 changed files with 436 additions and 196 deletions

View File

@ -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():

View File

@ -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 = "观察"

View File

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

View File

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

View File

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

View File

@ -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
# 到达TP1v1.7.8TP1保留作为提醒目标
# 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/SLprice_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,

View File

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

View File

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

View File

@ -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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 %}

View File

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

View File

@ -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': '可即刻买入',

View File

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

View File

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

View File

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

View File

@ -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"])

View File

@ -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 = []

View File

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

View File

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