diff --git a/app/config/system_config.py b/app/config/system_config.py index 5953a5f..c63e698 100644 --- a/app/config/system_config.py +++ b/app/config/system_config.py @@ -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(): diff --git a/app/core/opportunity_lifecycle.py b/app/core/opportunity_lifecycle.py index b184b33..a44e2e5 100644 --- a/app/core/opportunity_lifecycle.py +++ b/app/core/opportunity_lifecycle.py @@ -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 = "观察" diff --git a/app/db/altcoin_db.py b/app/db/altcoin_db.py index 22b9021..f789653 100644 --- a/app/db/altcoin_db.py +++ b/app/db/altcoin_db.py @@ -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 diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index b0a2a7c..2f2c89b 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -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), + } diff --git a/app/services/altcoin_confirm.py b/app/services/altcoin_confirm.py index 2326a8a..b7564c3 100644 --- a/app/services/altcoin_confirm.py +++ b/app/services/altcoin_confirm.py @@ -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", ""), } diff --git a/app/services/price_tracker.py b/app/services/price_tracker.py index 577b084..6942289 100644 --- a/app/services/price_tracker.py +++ b/app/services/price_tracker.py @@ -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, diff --git a/app/web/routes_paper_trading.py b/app/web/routes_paper_trading.py index a5b2657..78cbc08 100644 --- a/app/web/routes_paper_trading.py +++ b/app/web/routes_paper_trading.py @@ -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) diff --git a/static/app.html b/static/app.html index 6cb828a..f53fb78 100644 --- a/static/app.html +++ b/static/app.html @@ -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 = '