"""Paper trading ledger for separating signal quality from trade PnL.""" from __future__ import annotations import json import os from datetime import datetime, timedelta from app.config.system_config import paper_trading_config from app.core.global_risk import evaluate_global_risk from app.db.schema import get_conn from app.db.system_logs import record_system_error from app.integrations.feishu_push import push_card def _now() -> str: return datetime.now().isoformat() def _safe_float(value, default: float = 0.0) -> float: try: if value is None or value == "": return default return float(value) except Exception: return default def _safe_int(value, default: int = 0) -> int: try: return int(value or 0) except Exception: return default def paper_trading_enabled() -> bool: return bool(paper_trading_config().get("enabled", True)) def _paper_cfg(config: dict | None = None) -> dict: return config if isinstance(config, dict) else paper_trading_config() def default_account_equity_usdt(config: dict | None = None) -> float: return max(1.0, _safe_float(_paper_cfg(config).get("account_equity_usdt"), 20000.0)) def default_leverage(config: dict | None = None) -> float: return max(1.0, _safe_float(_paper_cfg(config).get("trade_leverage"), 5.0)) def default_notional_usdt(config: dict | None = None) -> float: return max(1.0, _safe_float(_paper_cfg(config).get("trade_notional_usdt"), 5000.0)) def default_margin_usdt(config: dict | None = None) -> float: cfg = _paper_cfg(config) return round(default_notional_usdt(cfg) / default_leverage(cfg), 8) def default_fee_rate(config: dict | None = None) -> float: return max(0.0, _safe_float(_paper_cfg(config).get("fee_rate"), 0.001)) def default_slippage_pct(config: dict | None = None) -> float: return max(0.0, _safe_float(_paper_cfg(config).get("slippage_pct"), 0.05)) def max_cumulative_leverage(config: dict | None = None) -> float: return max(0.0, _safe_float(_paper_cfg(config).get("max_cumulative_leverage"), 5.0)) def _cumulative_leverage_check(conn, additional_notional: float, config: dict | None = None, exclude_rec_id: int = 0) -> tuple[bool, dict]: cfg = _paper_cfg(config) equity = default_account_equity_usdt(cfg) cap = max_cumulative_leverage(cfg) if cap <= 0: return True, {"max_cumulative_leverage": cap, "disabled": True} exclude_rec_id = _safe_int(exclude_rec_id) open_params = [] pending_params = [] open_where = "status='open'" pending_where = "status='pending'" if exclude_rec_id > 0: open_where += " AND recommendation_id<>%s" pending_where += " AND recommendation_id<>%s" open_params.append(exclude_rec_id) pending_params.append(exclude_rec_id) open_notional = _safe_float(conn.execute(f"SELECT COALESCE(SUM(notional_usdt),0) FROM paper_trades WHERE {open_where}", tuple(open_params)).fetchone()[0]) pending_notional = _safe_float(conn.execute(f"SELECT COALESCE(SUM(notional_usdt),0) FROM paper_orders WHERE {pending_where}", tuple(pending_params)).fetchone()[0]) add = max(0.0, _safe_float(additional_notional)) projected_notional = open_notional + pending_notional + add projected_leverage = projected_notional / equity if equity > 0 else 0 detail = { "account_equity_usdt": equity, "open_notional_usdt": round(open_notional, 8), "pending_notional_usdt": round(pending_notional, 8), "additional_notional_usdt": round(add, 8), "projected_notional_usdt": round(projected_notional, 8), "projected_cumulative_leverage": round(projected_leverage, 6), "max_cumulative_leverage": cap, } return projected_leverage <= cap + 1e-12, detail def _portfolio_drawdown_check(conn, additional_notional: float, config: dict | None = None) -> tuple[bool, dict]: cfg = _paper_cfg(config) cap = max(0.0, _safe_float(cfg.get("max_account_drawdown_pause_pct"), 0)) if cap <= 0: return True, {"disabled": True, "max_account_drawdown_pause_pct": cap} equity = default_account_equity_usdt(cfg) open_rows = conn.execute("SELECT notional_usdt, pnl_pct FROM paper_trades WHERE status='open'").fetchall() unrealized = sum(_safe_float(r["notional_usdt"]) * _safe_float(r["pnl_pct"]) / 100 for r in open_rows) drawdown_pct = abs(min(0.0, unrealized)) / equity * 100 if equity > 0 else 0 detail = { "account_equity_usdt": equity, "open_unrealized_pnl_usdt": round(unrealized, 8), "open_drawdown_pct": round(drawdown_pct, 6), "max_account_drawdown_pause_pct": cap, "additional_notional_usdt": round(max(0.0, _safe_float(additional_notional)), 8), } return drawdown_pct <= cap + 1e-12, detail def _weak_entries_check(conn, event_time: str, config: dict | None = None) -> tuple[bool, dict]: cfg = _paper_cfg(config) limit = max(0, _safe_int(cfg.get("pause_after_weak_entries"), 0)) if limit <= 0: return True, {"disabled": True, "pause_after_weak_entries": limit} window_hours = max(0.1, _safe_float(cfg.get("weak_entry_window_hours"), 6.0)) threshold = max(0.0, _safe_float(cfg.get("weak_entry_min_max_pnl_pct"), 1.0)) now = _parse_time(event_time or _now()) or datetime.now() since = (now - timedelta(hours=window_hours)).isoformat() rows = conn.execute( """ SELECT symbol, opened_at, entry_price, max_price FROM paper_trades WHERE opened_at >= %s ORDER BY opened_at DESC, id DESC LIMIT %s """, (since, limit), ).fetchall() samples = [] for row in rows: entry = _safe_float(row["entry_price"]) max_pnl = (max(_safe_float(row["max_price"]), entry) / entry - 1) * 100 if entry > 0 else 0 samples.append({"symbol": row["symbol"], "opened_at": row["opened_at"], "max_pnl_pct": round(max_pnl, 6)}) enough = len(samples) >= limit all_weak = enough and all(x["max_pnl_pct"] < threshold for x in samples) detail = { "pause_after_weak_entries": limit, "weak_entry_window_hours": window_hours, "weak_entry_min_max_pnl_pct": threshold, "samples": samples, } return not all_weak, detail def _portfolio_entry_pause_check(conn, additional_notional: float, event_time: str, config: dict | None = None) -> tuple[bool, str, dict]: drawdown_ok, drawdown = _portfolio_drawdown_check(conn, additional_notional, config) if not drawdown_ok: return False, "portfolio_drawdown_pause", {"drawdown": drawdown} weak_ok, weak = _weak_entries_check(conn, event_time, config) if not weak_ok: return False, "weak_entries_pause", {"weak_entries": weak} return True, "", {"drawdown": drawdown, "weak_entries": weak} def _global_risk_entry_check(conn, rec: dict, additional_notional: float, config: dict | None = None) -> tuple[bool, dict]: detail = evaluate_global_risk( conn=conn, config=_paper_cfg(config), rec=rec, additional_notional=additional_notional, ) return bool(detail.get("allow_new_entries", True)), detail def _trailing_config() -> dict: cfg = paper_trading_config() return { "enabled": bool(cfg.get("trailing_stop_enabled", True)), "mode": str(cfg.get("trailing_mode") or "volatility").strip().lower(), "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)), "vol_min_activation_pct": max(0.0, _safe_float(cfg.get("trailing_volatility_min_activation_pct"), 2.5)), "vol_max_activation_pct": max(0.1, _safe_float(cfg.get("trailing_volatility_max_activation_pct"), 8.0)), "vol_activation_mult": max(0.0, _safe_float(cfg.get("trailing_volatility_activation_mult"), 0.6)), "vol_min_distance_pct": max(0.1, _safe_float(cfg.get("trailing_volatility_min_distance_pct"), 1.2)), "vol_max_distance_pct": max(0.1, _safe_float(cfg.get("trailing_volatility_max_distance_pct"), 8.0)), "vol_distance_mult": max(0.0, _safe_float(cfg.get("trailing_volatility_distance_mult"), 0.7)), "move_push_min_interval_seconds": max(0, _safe_int(cfg.get("trailing_move_push_min_interval_seconds"), 300)), "move_push_min_step_pct": max(0.0, _safe_float(cfg.get("trailing_move_push_min_step_pct"), 2.0)), "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 _clamp(value: float, min_value: float, max_value: float) -> float: low = min(min_value, max_value) high = max(min_value, max_value) return max(low, min(high, value)) def _trade_observed_volatility_pct(trade: dict, current_price: float) -> float: entry = _safe_float(trade.get("entry_price")) if entry <= 0 or current_price <= 0: return 0.0 high = max(_safe_float(trade.get("max_price")) or entry, current_price, entry) low = min(_safe_float(trade.get("min_price")) or entry, current_price, entry) return round(max(0.0, (high - low) / entry * 100), 6) def _dynamic_trailing_profile(trade: dict, current_price: float, pnl_pct: float, cfg: dict) -> dict: base_activate = _safe_float(cfg.get("activate_pnl_pct"), 3.0) base_distance, tier_label = _trailing_distance_pct(pnl_pct, cfg) volatility_pct = _trade_observed_volatility_pct(trade, current_price) if str(cfg.get("mode") or "volatility").lower() != "volatility": return { "mode": "fixed", "volatility_pct": volatility_pct, "activate_pnl_pct": base_activate, "distance_pct": base_distance, "tier_label": tier_label, } dynamic_activate = max(base_activate, volatility_pct * _safe_float(cfg.get("vol_activation_mult"), 0.6)) dynamic_activate = _clamp( dynamic_activate, _safe_float(cfg.get("vol_min_activation_pct"), 2.5), _safe_float(cfg.get("vol_max_activation_pct"), 8.0), ) dynamic_distance = max(base_distance, volatility_pct * _safe_float(cfg.get("vol_distance_mult"), 0.7)) dynamic_distance = _clamp( dynamic_distance, _safe_float(cfg.get("vol_min_distance_pct"), 1.2), _safe_float(cfg.get("vol_max_distance_pct"), 8.0), ) label = tier_label or "波动率" return { "mode": "volatility", "volatility_pct": volatility_pct, "activate_pnl_pct": round(dynamic_activate, 6), "distance_pct": round(dynamic_distance, 6), "tier_label": label, "base_activate_pnl_pct": base_activate, "base_distance_pct": base_distance, } def _parse_time(value: str) -> datetime | None: if not value: return None try: return datetime.fromisoformat(str(value).replace("Z", "+00:00")) except Exception: return None def _should_emit_trailing_move(conn, trade: dict, new_trail: float, event_time: str, cfg: dict) -> bool: last = conn.execute( """ SELECT event_time, price FROM paper_trade_events WHERE trade_id=%s AND event_type IN ('trailing_activate','trailing_move') ORDER BY event_time DESC, id DESC LIMIT 1 """, (trade["id"],), ).fetchone() if not last: return True min_interval = _safe_int(cfg.get("move_push_min_interval_seconds"), 300) if min_interval > 0: current_ts = _parse_time(event_time or _now()) last_ts = _parse_time(last["event_time"]) if current_ts and last_ts and (current_ts - last_ts).total_seconds() >= min_interval: return True last_trail = _safe_float(last["price"]) min_step_pct = _safe_float(cfg.get("move_push_min_step_pct"), 2.0) if min_step_pct <= 0 or last_trail <= 0: return True return (new_trail / last_trail - 1) * 100 >= min_step_pct def _loads_json(value, fallback=None): try: if isinstance(value, str) and value.strip(): return json.loads(value) if value: return value except Exception: pass return fallback if fallback is not None else {} def _entry_plan(rec: dict) -> dict: plan = rec.get("entry_plan") if isinstance(plan, dict): return plan return _loads_json(rec.get("entry_plan_json"), {}) def _parse_time(value: str): try: return datetime.fromisoformat(str(value or "")) except Exception: return None def _open_price(current_price: float, config: dict | None = None) -> float: return round(current_price * (1 + default_slippage_pct(config) / 100), 12) def _close_price(current_price: float, config: dict | None = None) -> float: return round(current_price * (1 - default_slippage_pct(config) / 100), 12) def _trade_pnl_pct(entry_price: float, current_price: float) -> float: if entry_price <= 0 or current_price <= 0: return 0.0 return round((current_price / entry_price - 1) * 100, 4) def _stop_loss_distance_pct(side: str, entry_price: float, stop_loss: float) -> float: if entry_price <= 0 or stop_loss <= 0: return 0.0 if str(side or "long").lower() == "short": return max(0.0, (stop_loss / entry_price - 1) * 100) return max(0.0, (1 - stop_loss / entry_price) * 100) def _stop_loss_leverage_risk_pct(side: str, entry_price: float, stop_loss: float, leverage: float) -> float: return round(_stop_loss_distance_pct(side, entry_price, stop_loss) * max(1.0, _safe_float(leverage, 1.0)), 6) def _trade_rr(side: str, entry_price: float, stop_loss: float, tp1: float) -> float: return _paper_order_rr(side, entry_price, stop_loss, tp1) def _account_return_pct(pnl_usdt: float, account_equity: float | None = None, config: dict | None = None) -> float: equity = max(1.0, _safe_float(account_equity, default_account_equity_usdt(config))) return round(_safe_float(pnl_usdt) / equity * 100, 4) def _margin_roi_pct(pnl_usdt: float, margin_usdt: float, config: dict | None = None) -> float: margin = max(1.0, _safe_float(margin_usdt, default_margin_usdt(config))) return round(_safe_float(pnl_usdt) / margin * 100, 4) def _trade_margin(trade: dict, config: dict | None = None) -> float: margin = _safe_float(trade.get("margin_usdt")) if margin > 0: return margin leverage = max(1.0, _safe_float(trade.get("leverage"), default_leverage(config))) return round(_safe_float(trade.get("notional_usdt")) / leverage, 8) def _decorate_trade(trade: dict, config: dict | None = None) -> dict: cfg = _paper_cfg(config) item = dict(trade) notional = _safe_float(item.get("notional_usdt"), default_notional_usdt(cfg)) leverage = max(1.0, _safe_float(item.get("leverage"), default_leverage(cfg))) margin = _trade_margin({"margin_usdt": item.get("margin_usdt"), "notional_usdt": notional, "leverage": leverage}, cfg) unrealized = round(notional * _safe_float(item.get("pnl_pct")) / 100, 8) realized = _safe_float(item.get("realized_pnl_usdt")) effective_pnl = realized if item.get("status") == "closed" else unrealized item["notional_usdt"] = notional item["leverage"] = leverage item["margin_usdt"] = margin item["unrealized_pnl_usdt"] = unrealized item["margin_roi_pct"] = _margin_roi_pct(effective_pnl, margin, cfg) item["account_return_pct"] = _account_return_pct(effective_pnl, config=cfg) item["account_equity_usdt"] = default_account_equity_usdt(cfg) latest_market = _safe_float(item.get("latest_market_price")) item["latest_price"] = latest_market if latest_market > 0 else _safe_float(item.get("current_price")) item["latest_price_updated_at"] = item.get("latest_market_price_updated_at") or item.get("updated_at") or "" return item def _record_event(conn, trade_id: int, rec_id: int, symbol: str, event_type: str, price: float, pnl_pct: float, message: str, detail=None, event_time: str = ""): conn.execute( """ INSERT INTO paper_trade_events ( trade_id, recommendation_id, symbol, event_type, event_time, price, pnl_pct, message, detail_json ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) """, ( trade_id, rec_id, symbol, event_type, event_time or _now(), price, pnl_pct, message, json.dumps(detail or {}, ensure_ascii=False, default=str), ), ) def _fmt_price(value) -> str: price = _safe_float(value) if price <= 0: return "--" return f"${price:.8g}" def _fmt_pct(value) -> str: pct = _safe_float(value) sign = "+" if pct > 0 else "" return f"{sign}{pct:.2f}%" def _card_field(label: str, value) -> dict: return { "tag": "div", "text": {"tag": "lark_md", "content": f"**{label}**\n{value}"}, } def _card_note(content: str) -> dict: return {"tag": "note", "elements": [{"tag": "plain_text", "content": content}]} def _card_md(content: str) -> dict: return {"tag": "div", "text": {"tag": "lark_md", "content": content}} def _push_paper_card(event_type: str, symbol: str, title: str, template: str, fields: list[tuple[str, str]], note: str = "", event_time: str = "") -> None: try: elements = [] if fields: elements.append({"tag": "column_set", "flex_mode": "none", "background_style": "default", "columns": [ {"tag": "column", "width": "weighted", "weight": 1, "elements": [_card_field(label, value)]} for label, value in fields ]}) if note: elements.append(_card_note(note)) if event_time: elements.append(_card_note(f"时间: {event_time}")) ok, result = push_card({ "metadata": {"source": "paper_trading", "event_type": event_type, "symbol": symbol}, "config": {"wide_screen_mode": True}, "header": { "template": template, "title": {"tag": "plain_text", "content": title}, }, "elements": elements, }) if not ok: record_system_error( source="paper_trading", level="warning", error_type="FeishuPushFailed", message=f"Feishu push failed for {event_type} {symbol}: {str(result)[:500]}", status_code=0, context={"event_type": event_type, "symbol": symbol, "push_result": result}, fingerprint=f"paper_trading_feishu_push_failed:{event_type}:{symbol}", ) except Exception as exc: record_system_error( source="paper_trading", level="warning", error_type=exc.__class__.__name__, message=f"Feishu push exception for {event_type} {symbol}: {str(exc)[:500]}", status_code=0, context={"event_type": event_type, "symbol": symbol}, fingerprint=f"paper_trading_feishu_push_exception:{event_type}:{symbol}", ) def _push_custom_paper_card(card: dict) -> tuple[bool, object]: try: return push_card(card) except Exception as exc: return False, str(exc) def _push_event_card(event_type: str, trade: dict, result: dict, event_time: str = "") -> None: symbol = str(trade.get("symbol") or "") short_symbol = symbol.replace("/USDT", "") if event_type == "open": _push_paper_card( event_type, symbol, f"交易开仓 - {short_symbol}", "blue", [ ("成交价", _fmt_price(result.get("entry_price"))), ("名义仓位", f"{_safe_float(result.get('notional_usdt')):.2f} USDT"), ("杠杆/保证金", f"{_safe_float(result.get('leverage')):.1f}x / {_safe_float(result.get('margin_usdt')):.2f} USDT"), ], "策略信号已进入持仓跟踪。", event_time, ) return if event_type == "close": exit_reason = str(result.get("exit_reason") or "--") title_prefix = "移动止盈成交平仓" if exit_reason == "trailing_stop" else "交易平仓" _push_paper_card( event_type, symbol, f"{title_prefix} - {short_symbol}", "red" if _safe_float(result.get("pnl_usdt")) < 0 else "green", [ ("退出价", _fmt_price(result.get("exit_price"))), ("收益率", _fmt_pct(result.get("pnl_pct"))), ("收益额", f"{_safe_float(result.get('pnl_usdt')):.2f} USDT"), ("原因", exit_reason), ], "收益以交易账本记录为准。", event_time, ) return if event_type.startswith("trailing"): _push_paper_card( event_type, symbol, f"移动止盈{'启动' if event_type == 'trailing_activate' else '上移'} - {short_symbol}", "yellow", [ ("保护价", _fmt_price(result.get("trailing_stop"))), ("当前收益", _fmt_pct(result.get("pnl_pct"))), ("动作", "启动保护" if event_type == "trailing_activate" else "上移保护价"), ], "移动止盈用于锁定浮盈。", event_time, ) def _push_order_created_card(order: dict, event_time: str = "") -> None: symbol = str(order.get("symbol") or "") target = _safe_float(order.get("target_price")) current = _safe_float(order.get("current_price_at_create")) distance = round((current / target - 1) * 100, 2) if target and current else 0 _push_paper_card( "paper_order_create", symbol, f"挂单创建 - {symbol.replace('/USDT', '')}", "wathet", [ ("目标价", _fmt_price(target)), ("当前价", _fmt_price(current)), ("距目标", _fmt_pct(distance)), ("有效期", order.get("expires_at") or "--"), ], "等回踩机会已进入挂单,触价后进入持仓。", event_time, ) def _push_order_filled_card(order: dict, result: dict, event_time: str = "") -> None: symbol = str(order.get("symbol") or "") _push_paper_card( "paper_order_fill", symbol, f"挂单成交并开仓 - {symbol.replace('/USDT', '')}", "green", [ ("挂单价", _fmt_price(order.get("target_price"))), ("成交价", _fmt_price(result.get("entry_price") or order.get("fill_price"))), ("名义仓位", f"{_safe_float(result.get('notional_usdt')):.2f} USDT"), ("来源", order.get("source_status") or "wait_pullback"), ], "价格触达理想入场位,挂单已转为持仓。", event_time, ) def _open_trade(conn, rec: dict, current_price: float, event_time: str, config: dict | None = None, push_open_card: bool = True) -> dict: cfg = _paper_cfg(config) rec_id = _safe_int(rec.get("id")) symbol = str(rec.get("symbol") or "").strip().upper() plan = _entry_plan(rec) entry_price = _open_price(current_price, cfg) notional = default_notional_usdt(cfg) side = str(plan.get("side") or rec.get("side") or "long").strip().lower() or "long" leverage = default_leverage(cfg) stop_loss = _safe_float(rec.get("stop_loss") or plan.get("stop_loss")) tp1 = _safe_float(rec.get("tp1") or plan.get("tp1") or plan.get("take_profit_1")) rec_score = _safe_float(rec.get("rec_score") or rec.get("score")) if rec_score <= 0 and rec_id > 0: row = conn.execute("SELECT rec_score FROM recommendation WHERE id=%s", (rec_id,)).fetchone() rec_score = _safe_float(row["rec_score"] if row else 0) if bool(cfg.get("entry_gate_enabled", True)): rr = _safe_float(plan.get("rr1") or plan.get("rr1_live")) or _trade_rr(side, entry_price, stop_loss, tp1) min_rr = max(0.0, _safe_float(cfg.get("entry_min_rr"), 0)) min_score = max(0.0, _safe_float(cfg.get("entry_min_rec_score"), 0)) sl_risk = _stop_loss_leverage_risk_pct(side, entry_price, stop_loss, leverage) max_sl_risk = max(0.0, _safe_float(cfg.get("max_stop_loss_leverage_risk_pct"), 0)) entry_reasons = [] if rec_score < min_score: entry_reasons.append("rec_score_below_min") if rr <= 0: entry_reasons.append("missing_rr") elif rr < min_rr: entry_reasons.append("rr_below_min") if max_sl_risk > 0 and sl_risk > max_sl_risk: entry_reasons.append("stop_loss_leverage_risk_exceeded") if entry_reasons: return { "opened": False, "skipped": True, "reason": "entry_gate_rejected", "gate_reasons": entry_reasons, "gate_detail": { "rec_score": rec_score, "min_rec_score": min_score, "rr1": round(rr, 4) if rr > 0 else 0, "min_rr": min_rr, "stop_loss_leverage_risk_pct": sl_risk, "max_stop_loss_leverage_risk_pct": max_sl_risk, "entry_price": entry_price, "stop_loss": stop_loss, "tp1": tp1, "leverage": leverage, }, } pause_ok, pause_reason, pause_detail = _portfolio_entry_pause_check(conn, notional, event_time, cfg) if not pause_ok: return {"opened": False, "skipped": True, "reason": pause_reason, "risk_detail": pause_detail} global_ok, global_detail = _global_risk_entry_check(conn, rec, notional, cfg) if not global_ok: return { "opened": False, "skipped": True, "reason": "global_risk_rejected", "risk_detail": global_detail, } leverage_ok, leverage_detail = _cumulative_leverage_check(conn, notional, cfg, exclude_rec_id=rec_id) if not leverage_ok: return { "opened": False, "skipped": True, "reason": "cumulative_leverage_exceeded", "risk_detail": leverage_detail, } margin = default_margin_usdt(cfg) qty = round(notional / entry_price, 12) if entry_price > 0 else 0 tp2 = _safe_float(rec.get("tp2") or plan.get("tp2") or plan.get("take_profit_2")) fee = round(notional * default_fee_rate(cfg), 8) now = event_time or _now() row = conn.execute( """ INSERT INTO paper_trades ( recommendation_id, symbol, side, status, opened_at, entry_price, qty, notional_usdt, margin_usdt, leverage, stop_loss, tp1, tp2, max_price, min_price, current_price, pnl_pct, fee_usdt, source_status, source_action, strategy_version, created_at, updated_at ) VALUES (%s,%s,'long','open',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,%s,%s,%s,%s,%s,%s) ON CONFLICT(recommendation_id) DO NOTHING RETURNING id """, ( rec_id, symbol, now, entry_price, qty, notional, margin, leverage, stop_loss, tp1, tp2, entry_price, entry_price, entry_price, fee, rec.get("execution_status") or "", rec.get("action_status") or "", rec.get("strategy_version") or "", now, now, ), ).fetchone() if not row: return {"opened": False, "reason": "already_exists"} trade_id = row["id"] _record_event( conn, trade_id, rec_id, symbol, "open", entry_price, 0.0, "交易开仓:策略信号已进入持仓跟踪", { "notional_usdt": notional, "margin_usdt": margin, "leverage": leverage, "qty": qty, "fee_usdt": fee, "slippage_pct": default_slippage_pct(cfg), "source_status": rec.get("execution_status") or "", "source_action": rec.get("action_status") or "", "market_regime": global_detail.get("market_regime") or _entry_plan(rec).get("market_regime") or {}, "global_risk": global_detail, "score_components": _entry_plan(rec).get("score_components") or {}, "decision_log": _entry_plan(rec).get("decision_log") or {}, }, now, ) result = { "opened": True, "trade_id": trade_id, "entry_price": entry_price, "qty": qty, "notional_usdt": notional, "margin_usdt": margin, "leverage": leverage, "global_risk": global_detail, "market_regime": global_detail.get("market_regime") or _entry_plan(rec).get("market_regime") or {}, } if push_open_card: _push_event_card("open", {"symbol": symbol}, result, now) return result def _is_wait_pullback(rec: dict) -> bool: plan = _entry_plan(rec) execution_status = str(rec.get("execution_status") or "").strip() action_status = str(rec.get("action_status") or "").strip() entry_action = str(plan.get("entry_action") or "").strip() return execution_status == "wait_pullback" or action_status == "等回踩" or entry_action == "等回踩" def _paper_order_target_price(rec: dict) -> float: plan = _entry_plan(rec) return _safe_float( plan.get("limit_price") or plan.get("order_price") or plan.get("entry_price") or rec.get("entry_price") ) def _paper_order_expires_at(event_time: str, config: dict | None = None) -> str: cfg = _paper_cfg(config) hours = max(0.25, _safe_float(cfg.get("order_expire_hours"), 24.0)) base = _parse_time(event_time) or datetime.now() return (base + timedelta(hours=hours)).isoformat() def _paper_order_touched(order: dict, current_price: float) -> bool: side = str(order.get("side") or "long").lower() target = _safe_float(order.get("target_price")) if target <= 0 or current_price <= 0: return False if side == "short": return current_price >= target return current_price <= target def _paper_order_too_far(order: dict, current_price: float, config: dict | None = None) -> bool: cfg = _paper_cfg(config) threshold_pct = max(0.0, _safe_float(cfg.get("order_cancel_far_from_entry_pct"), 12.0)) if threshold_pct <= 0: return False side = str(order.get("side") or "long").lower() target = _safe_float(order.get("target_price")) if target <= 0 or current_price <= 0: return False if side == "short": return current_price < target * (1 - threshold_pct / 100) return current_price > target * (1 + threshold_pct / 100) def _paper_order_rr(side: str, target: float, stop_loss: float, tp1: float) -> float: if side == "short": risk = stop_loss - target reward = target - tp1 else: risk = target - stop_loss reward = tp1 - target if risk <= 0 or reward <= 0: return 0.0 return reward / risk def _paper_order_distance_pct(side: str, current_price: float, target: float) -> float: if target <= 0 or current_price <= 0: return 999.0 if side == "short": return max(0.0, (target / current_price - 1) * 100) return max(0.0, (current_price / target - 1) * 100) def _paper_order_gate(rec: dict, current_price: float, config: dict | None = None, conn=None) -> tuple[bool, list[str], dict]: cfg = _paper_cfg(config) if not bool(cfg.get("order_gate_enabled", True)): return True, [], {"gate_enabled": False} plan = _entry_plan(rec) side = str(plan.get("side") or rec.get("side") or "long").strip().lower() or "long" target = _paper_order_target_price(rec) stop_loss = _safe_float(rec.get("stop_loss") or plan.get("stop_loss")) tp1 = _safe_float(rec.get("tp1") or plan.get("tp1") or plan.get("take_profit_1")) rr = _safe_float(plan.get("rr1") or plan.get("rr1_live")) calc_rr = _paper_order_rr(side, target, stop_loss, tp1) # Wait-pullback orders must be judged at the intended limit price, not at # the stale confirmation price. A buy-now RR can be invalid while the # pullback target is perfectly tradeable. effective_rr = max(rr, calc_rr) min_rr = max(0.0, _safe_float(cfg.get("order_min_rr"), 1.2)) min_rec_score = max(0.0, _safe_float(cfg.get("order_min_rec_score"), 20.0)) min_distance = max(0.0, _safe_float(cfg.get("order_min_distance_to_entry_pct"), 0.0)) rec_score = _safe_float(rec.get("rec_score") or rec.get("score")) if rec_score <= 0 and conn is not None and _safe_int(rec.get("id")) > 0: row = conn.execute("SELECT rec_score FROM recommendation WHERE id=%s", (_safe_int(rec.get("id")),)).fetchone() rec_score = _safe_float(row["rec_score"] if row else 0) leverage_ok = True leverage_detail = {} if conn is not None: leverage_ok, leverage_detail = _cumulative_leverage_check( conn, default_notional_usdt(cfg), cfg, exclude_rec_id=_safe_int(rec.get("id")), ) distance_pct = _paper_order_distance_pct(side, current_price, target) max_distance = max(0.0, _safe_float(cfg.get("order_max_distance_to_entry_pct"), 8.0)) opportunity_level = str(plan.get("opportunity_level") or rec.get("opportunity_level") or "").strip() level_max_action = str(plan.get("max_action") or "").strip() risk_reward_ok = plan.get("risk_reward_ok") trigger_ok = plan.get("entry_trigger_confirmed") is True or _safe_int(rec.get("entry_triggered")) == 1 reasons = [] if target <= 0: reasons.append("missing_target_price") if stop_loss <= 0: reasons.append("missing_stop_loss") if tp1 <= 0: reasons.append("missing_tp1") if target > 0 and stop_loss > 0 and tp1 > 0 and calc_rr <= 0: reasons.append("invalid_risk_geometry") target_rr_confirms = calc_rr + 1e-9 >= min_rr if risk_reward_ok is False and not target_rr_confirms: reasons.append("risk_reward_rejected") if bool(cfg.get("order_require_risk_reward_ok", True)) and risk_reward_ok is not True and not ( risk_reward_ok is False and target_rr_confirms ): reasons.append("risk_reward_not_confirmed") if rec_score < min_rec_score: reasons.append("rec_score_below_min") if not leverage_ok: reasons.append("cumulative_leverage_exceeded") if effective_rr > 0 and effective_rr < min_rr: reasons.append("rr_below_min") if effective_rr <= 0: reasons.append("missing_rr") if distance_pct < min_distance: reasons.append("too_close_to_entry") if distance_pct > max_distance: reasons.append("too_far_from_entry") if opportunity_level in {"momentum_watch", "theme_trend"} or level_max_action == "observe": reasons.append("observe_only_opportunity") if bool(cfg.get("order_require_current_trigger", False)) and not trigger_ok: reasons.append("missing_current_trigger") return not reasons, reasons, { "target_price": target, "stop_loss": stop_loss, "tp1": tp1, "rr1": round(effective_rr, 4) if effective_rr > 0 else 0, "calc_rr1": round(calc_rr, 4) if calc_rr > 0 else 0, "distance_to_entry_pct": round(distance_pct, 4), "min_distance_to_entry_pct": min_distance, "max_distance_to_entry_pct": max_distance, "min_rr": min_rr, "rec_score": rec_score, "min_rec_score": min_rec_score, "leverage": leverage_detail, "opportunity_level": opportunity_level, "entry_trigger_confirmed": trigger_ok, } def _cancel_paper_order(conn, order: dict, reason: str, event_time: str) -> dict: conn.execute( """ UPDATE paper_orders SET status='canceled', cancel_reason=%s, canceled_at=%s, updated_at=%s WHERE id=%s AND status='pending' """, (reason, event_time, event_time, order["id"]), ) return {"skipped": True, "reason": f"paper_order_{reason}", "paper_order_id": order["id"]} def _order_recommendation_cancel_reason(conn, rec: dict, order: dict) -> str: rec_id = _safe_int(rec.get("id") or order.get("recommendation_id")) if rec_id <= 0: return "" row = conn.execute( """ SELECT status, execution_status, lifecycle_state, display_bucket FROM recommendation WHERE id=%s """, (rec_id,), ).fetchone() if not row: return "recommendation_missing" row = dict(row) status = str(row.get("status") or "").strip().lower() execution_status = str(row.get("execution_status") or "").strip().lower() lifecycle_state = str(row.get("lifecycle_state") or "").strip().lower() display_bucket = str(row.get("display_bucket") or "").strip().lower() if status in {"expired", "invalid", "archived", "stopped_out", "closed"}: return "recommendation_invalid" if execution_status in {"invalid", "expired", "archived", "stopped_out"}: return "recommendation_invalid" if lifecycle_state in {"invalid", "expired", "archived", "stopped_out"}: return "recommendation_invalid" if display_bucket in {"history", "archive", "archived"}: return "recommendation_invalid" return "" def _order_payload_from_rec(rec: dict, current_price: float, event_time: str, config: dict | None = None) -> dict: cfg = _paper_cfg(config) plan = _entry_plan(rec) return { "recommendation_id": _safe_int(rec.get("id")), "symbol": str(rec.get("symbol") or "").strip().upper(), "side": str(plan.get("side") or rec.get("side") or "long").strip().lower() or "long", "order_type": "limit", "status": "pending", "source_status": str(rec.get("execution_status") or ""), "source_action": str(rec.get("action_status") or plan.get("entry_action") or ""), "target_price": _paper_order_target_price(rec), "current_price_at_create": current_price, "notional_usdt": default_notional_usdt(cfg), "stop_loss": _safe_float(rec.get("stop_loss") or plan.get("stop_loss")), "tp1": _safe_float(rec.get("tp1") or plan.get("tp1") or plan.get("take_profit_1")), "tp2": _safe_float(rec.get("tp2") or plan.get("tp2") or plan.get("take_profit_2")), "strategy_version": str(rec.get("strategy_version") or ""), "entry_plan_snapshot_json": json.dumps(plan, ensure_ascii=False, default=str), "created_at": event_time, "updated_at": event_time, "expires_at": _paper_order_expires_at(event_time, cfg), } def _fill_paper_order(conn, order: dict, rec: dict, current_price: float, event_time: str, config: dict | None = None) -> dict: fill_price = _safe_float(order.get("target_price")) or current_price cfg = _paper_cfg(config) side = str(order.get("side") or "long").lower() leverage = default_leverage(cfg) stop_loss = _safe_float(order.get("stop_loss") or rec.get("stop_loss") or _entry_plan(rec).get("stop_loss")) sl_risk = _stop_loss_leverage_risk_pct(side, fill_price, stop_loss, leverage) max_sl_risk = max(0.0, _safe_float(cfg.get("max_stop_loss_leverage_risk_pct"), 0)) if max_sl_risk > 0 and sl_risk > max_sl_risk: return _cancel_paper_order(conn, order, "stop_loss_leverage_risk_exceeded", event_time) pause_ok, pause_reason, pause_detail = _portfolio_entry_pause_check(conn, default_notional_usdt(cfg), event_time, cfg) if not pause_ok: return _cancel_paper_order(conn, order, pause_reason, event_time) global_ok, global_detail = _global_risk_entry_check(conn, rec, default_notional_usdt(cfg), cfg) if not global_ok: result = _cancel_paper_order(conn, order, "global_risk_rejected", event_time) result["risk_detail"] = global_detail return result trade_rec = dict(rec) plan = _entry_plan(trade_rec) plan.setdefault("entry_price", fill_price) trade_rec["entry_plan"] = plan trade_rec["entry_price"] = fill_price result = _open_trade(conn, trade_rec, fill_price, event_time, config=config, push_open_card=False) if result.get("opened"): order = {**order, "fill_price": fill_price} conn.execute( """ UPDATE paper_orders SET status='filled', fill_price=%s, filled_at=%s, updated_at=%s WHERE id=%s """, (fill_price, event_time, event_time, order["id"]), ) result["paper_order"] = {"filled": True, "order_id": order["id"], "fill_price": fill_price} _push_order_filled_card(order, result, event_time) stop_loss = _safe_float(rec.get("stop_loss") or _entry_plan(rec).get("stop_loss") or order.get("stop_loss")) if stop_loss > 0 and current_price <= stop_loss: trade = conn.execute("SELECT * FROM paper_trades WHERE id=%s", (result["trade_id"],)).fetchone() if trade: close_result = _close_trade(conn, dict(trade), current_price, "stop_loss_same_tick", event_time) result.update({ "closed": True, "exit_reason": close_result.get("exit_reason"), "pnl_pct": close_result.get("pnl_pct"), "pnl_usdt": close_result.get("pnl_usdt"), "same_tick_stop_loss": True, }) return result if result.get("reason") == "already_exists": conn.execute( """ UPDATE paper_orders SET status='filled', fill_price=%s, filled_at=%s, updated_at=%s WHERE id=%s """, (fill_price, event_time, event_time, order["id"]), ) result["paper_order"] = {"filled": False, "order_id": order["id"], "fill_price": fill_price} return result def _sync_wait_pullback_order(conn, rec: dict, current_price: float, event_time: str, config: dict | None = None) -> dict: cfg = _paper_cfg(config) rec_id = _safe_int(rec.get("id")) order = conn.execute("SELECT * FROM paper_orders WHERE recommendation_id=%s", (rec_id,)).fetchone() if order: order = dict(order) if order.get("status") != "pending": return {"skipped": True, "reason": f"paper_order_{order.get('status')}", "paper_order_id": order.get("id")} cancel_reason = _order_recommendation_cancel_reason(conn, rec, order) if cancel_reason: return _cancel_paper_order(conn, order, cancel_reason, event_time) if _paper_order_touched(order, current_price): return _fill_paper_order(conn, order, rec, current_price, event_time, cfg) expires_at = _parse_time(order.get("expires_at")) now = _parse_time(event_time) or datetime.now() if expires_at and now > expires_at: conn.execute( """ UPDATE paper_orders SET status='expired', cancel_reason='expired', canceled_at=%s, updated_at=%s WHERE id=%s """, (event_time, event_time, order["id"]), ) return {"skipped": True, "reason": "paper_order_expired", "paper_order_id": order["id"]} if _paper_order_too_far(order, current_price, cfg): return _cancel_paper_order(conn, order, "too_far_from_entry", event_time) conn.execute("UPDATE paper_orders SET updated_at=%s WHERE id=%s", (event_time, order["id"])) return { "skipped": True, "reason": "paper_order_pending", "paper_order_id": order["id"], "target_price": order.get("target_price"), "current_price": current_price, } gate_ok, gate_reasons, gate_detail = _paper_order_gate(rec, current_price, cfg, conn=conn) if not gate_ok: return { "skipped": True, "reason": "paper_order_gate_rejected", "gate_reasons": gate_reasons, "gate_detail": gate_detail, "target_price": gate_detail.get("target_price"), "current_price": current_price, } payload = _order_payload_from_rec(rec, current_price, event_time, cfg) if payload["recommendation_id"] <= 0 or not payload["symbol"] or payload["target_price"] <= 0: return {"skipped": True, "reason": "invalid_paper_order"} row = conn.execute( """ INSERT INTO paper_orders ( recommendation_id, symbol, side, order_type, status, source_status, source_action, target_price, current_price_at_create, notional_usdt, stop_loss, tp1, tp2, strategy_version, entry_plan_snapshot_json, created_at, updated_at, expires_at ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT(recommendation_id) DO NOTHING RETURNING id """, ( payload["recommendation_id"], payload["symbol"], payload["side"], payload["order_type"], payload["status"], payload["source_status"], payload["source_action"], payload["target_price"], payload["current_price_at_create"], payload["notional_usdt"], payload["stop_loss"], payload["tp1"], payload["tp2"], payload["strategy_version"], payload["entry_plan_snapshot_json"], payload["created_at"], payload["updated_at"], payload["expires_at"], ), ).fetchone() order_id = row["id"] if row else None order = {"id": order_id, **payload} if _paper_order_touched(order, current_price): return _fill_paper_order(conn, order, rec, current_price, event_time, cfg) cancel_reason = _order_recommendation_cancel_reason(conn, rec, order) if cancel_reason: return _cancel_paper_order(conn, order, cancel_reason, event_time) if _paper_order_too_far(order, current_price, cfg): return _cancel_paper_order(conn, order, "too_far_from_entry", event_time) result = { "skipped": True, "reason": "paper_order_created", "paper_order_id": order_id, "target_price": payload["target_price"], "current_price": current_price, "gate_detail": gate_detail, } _push_order_created_card(order, event_time) return result def _close_trade(conn, trade: dict, current_price: float, reason: str, event_time: str) -> dict: entry_price = _safe_float(trade.get("entry_price")) exit_price = _close_price(current_price) pnl_pct = _trade_pnl_pct(entry_price, exit_price) notional = _safe_float(trade.get("notional_usdt")) open_fee = _safe_float(trade.get("fee_usdt")) close_fee = round(notional * default_fee_rate(), 8) total_fee = round(open_fee + close_fee, 8) pnl_usdt = round(notional * pnl_pct / 100 - total_fee, 8) now = event_time or _now() conn.execute( """ UPDATE paper_trades SET status='closed', closed_at=%s, exit_price=%s, current_price=%s, pnl_pct=%s, realized_pnl_pct=%s, realized_pnl_usdt=%s, fee_usdt=%s, exit_reason=%s, updated_at=%s WHERE id=%s AND status='open' """, ( now, exit_price, exit_price, pnl_pct, pnl_pct, pnl_usdt, total_fee, reason, now, trade["id"], ), ) _record_event( conn, trade["id"], trade["recommendation_id"], trade["symbol"], "close", exit_price, pnl_pct, f"交易平仓:{reason}", {"realized_pnl_usdt": pnl_usdt, "fee_usdt": total_fee}, now, ) _push_event_card( "close", trade, {"exit_price": exit_price, "exit_reason": reason, "pnl_pct": pnl_pct, "pnl_usdt": pnl_usdt}, now, ) 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"): 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} profile = _dynamic_trailing_profile(trade, current_price, pnl_pct, cfg) activate_pnl_pct = _safe_float(profile.get("activate_pnl_pct"), cfg.get("activate_pnl_pct")) if pnl_pct < activate_pnl_pct: return current_trail, { "activated": False, "moved": False, "trailing_mode": profile.get("mode"), "volatility_pct": profile.get("volatility_pct"), "activate_pnl_pct": activate_pnl_pct, } distance_pct = _safe_float(profile.get("distance_pct"), cfg.get("distance_pct")) tier_label = str(profile.get("tier_label") or "") 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 "上移" should_emit = activated or _should_emit_trailing_move(conn, trade, new_trail, event_time, cfg) if should_emit: 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": activate_pnl_pct, "distance_pct": distance_pct, "tier_label": tier_label, "trailing_mode": profile.get("mode"), "volatility_pct": profile.get("volatility_pct"), "base_activate_pnl_pct": profile.get("base_activate_pnl_pct"), "base_distance_pct": profile.get("base_distance_pct"), "min_lock_profit_pct": cfg.get("min_lock_profit_pct"), "notification_throttled": False, }, event_time, ) _push_event_card(event_type, trade, {"trailing_stop": new_trail, "pnl_pct": pnl_pct}, event_time) return new_trail, { "activated": activated, "moved": moved, "trailing_stop": new_trail, "previous_trailing_stop": current_trail, "distance_pct": distance_pct, "activate_pnl_pct": activate_pnl_pct, "tier_label": tier_label, "trailing_mode": profile.get("mode"), "volatility_pct": profile.get("volatility_pct"), "notification_emitted": should_emit, } 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 old_min = _safe_float(trade.get("min_price")) or entry_price new_max = max(old_max, current_price) new_min = min(old_min, current_price) pnl_pct = _trade_pnl_pct(entry_price, current_price) 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: reason = "tp1" 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, trailing_stop, pnl_pct, event_time or _now(), trade["id"]), ) 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: """Open/update paper trade for one recommendation. This is intentionally independent from recommendation PnL fields. A recommendation can be a signal; only this ledger represents simulated execution. """ if not paper_trading_enabled(): return {"enabled": False, "skipped": True, "reason": "disabled"} rec_id = _safe_int(rec.get("id")) symbol = str(rec.get("symbol") or "").strip().upper() current_price = _safe_float(current_price) if rec_id <= 0 or not symbol or current_price <= 0: return {"enabled": True, "skipped": True, "reason": "invalid_input"} execution_status = str(rec.get("execution_status") or "").strip() action_status = str(rec.get("action_status") or "").strip() event_time = event_time or _now() cfg = paper_trading_config() conn = get_conn() try: trade = conn.execute("SELECT * FROM paper_trades WHERE recommendation_id=%s", (rec_id,)).fetchone() if trade: trade = dict(trade) if trade.get("status") == "open": conn.execute( """ UPDATE paper_orders SET status='filled', fill_price=%s, filled_at=COALESCE(NULLIF(filled_at,''), %s), updated_at=%s WHERE recommendation_id=%s AND status='pending' """, ( _safe_float(trade.get("entry_price")), trade.get("opened_at") or event_time, event_time, rec_id, ), ) result = _update_open_trade(conn, trade, current_price, event_time) conn.commit() return result return {"skipped": True, "reason": "already_closed", "trade_id": trade.get("id")} if execution_status != "buy_now" and action_status != "可即刻买入": if _is_wait_pullback(rec): result = _sync_wait_pullback_order(conn, rec, current_price, event_time, cfg) conn.commit() return result return {"skipped": True, "reason": "not_buy_now"} pending_order = conn.execute( "SELECT * FROM paper_orders WHERE recommendation_id=%s AND status='pending'", (rec_id,), ).fetchone() if pending_order: conn.execute( """ UPDATE paper_orders SET status='canceled', cancel_reason='upgraded_to_buy_now', canceled_at=%s, updated_at=%s WHERE recommendation_id=%s AND status='pending' """, (event_time, event_time, rec_id), ) result = _open_trade(conn, rec, current_price, event_time, config=cfg) if pending_order: result["paper_order"] = { "canceled": True, "order_id": pending_order["id"], "cancel_reason": "upgraded_to_buy_now", } conn.commit() return result except Exception: conn.rollback() raise finally: try: conn.close() except Exception: pass def get_paper_trading_summary(days: int = 30) -> dict: days = max(1, min(_safe_int(days, 30), 365)) cutoff = (datetime.now() - timedelta(days=days)).isoformat() conn = get_conn() try: rows = conn.execute( """ SELECT * FROM paper_trades WHERE opened_at >= %s ORDER BY opened_at DESC, id DESC """, (cutoff,), ).fetchall() pending_order_count = conn.execute("SELECT COUNT(*) FROM paper_orders WHERE status='pending'").fetchone()[0] finally: conn.close() cfg = paper_trading_config() items = [_decorate_trade(dict(r), cfg) for r in rows] open_items = [x for x in items if x.get("status") == "open"] closed_items = [x for x in items if x.get("status") == "closed"] wins = [x for x in closed_items if _safe_float(x.get("realized_pnl_pct")) > 0] losses = [x for x in closed_items if _safe_float(x.get("realized_pnl_pct")) <= 0] total_realized = round(sum(_safe_float(x.get("realized_pnl_usdt")) for x in closed_items), 4) avg_realized_pct = round(sum(_safe_float(x.get("realized_pnl_pct")) for x in closed_items) / len(closed_items), 4) if closed_items else 0 open_unrealized = round(sum(_safe_float(x.get("unrealized_pnl_usdt")) for x in open_items), 4) total_pnl = round(total_realized + open_unrealized, 4) allocated_margin = round(sum(_safe_float(x.get("margin_usdt")) for x in open_items), 4) open_position_value = round(sum(_safe_float(x.get("notional_usdt")) for x in open_items), 4) initial_equity = default_account_equity_usdt(cfg) current_balance = round(initial_equity + total_pnl, 4) cumulative_leverage = round(open_position_value / current_balance, 4) if current_balance > 0 else 0 return { "days": days, "total": len(items), "open_count": len(open_items), "closed_count": len(closed_items), "win_count": len(wins), "loss_count": len(losses), "pending_order_count": int(pending_order_count or 0), "win_rate": round(len(wins) / len(closed_items) * 100, 2) if closed_items else 0, "realized_pnl_usdt": total_realized, "avg_realized_pnl_pct": avg_realized_pct, "open_unrealized_pnl_usdt": open_unrealized, "total_pnl_usdt": total_pnl, "initial_equity_usdt": initial_equity, "account_equity_usdt": initial_equity, "current_balance_usdt": current_balance, "account_realized_return_pct": _account_return_pct(total_realized), "account_unrealized_return_pct": _account_return_pct(open_unrealized), "account_total_return_pct": _account_return_pct(total_pnl), "allocated_margin_usdt": allocated_margin, "open_position_value_usdt": open_position_value, "cumulative_leverage": cumulative_leverage, "available_equity_usdt": round(current_balance - allocated_margin, 4), "margin_usdt": default_margin_usdt(cfg), "leverage": default_leverage(cfg), "notional_usdt": default_notional_usdt(cfg), "fee_rate": default_fee_rate(cfg), "slippage_pct": default_slippage_pct(cfg), } def get_paper_trading_performance(days: int = 30) -> dict: days = max(1, min(_safe_int(days, 30), 365)) cfg = paper_trading_config() initial_equity = default_account_equity_usdt(cfg) today = datetime.now().date() start_date = today - timedelta(days=days - 1) conn = get_conn() try: closed_rows = conn.execute( """ SELECT closed_at, realized_pnl_usdt FROM paper_trades WHERE status='closed' AND closed_at IS NOT NULL ORDER BY closed_at ASC, id ASC """ ).fetchall() open_unrealized = conn.execute( """ SELECT COALESCE(SUM(notional_usdt * pnl_pct / 100.0), 0) FROM paper_trades WHERE status='open' """ ).fetchone()[0] finally: conn.close() daily_realized = {} realized_before = 0.0 for row in closed_rows: closed_at = _parse_time(row["closed_at"]) pnl = _safe_float(row["realized_pnl_usdt"]) if not closed_at: continue closed_date = closed_at.date() if closed_date < start_date: realized_before += pnl continue if closed_date > today: continue key = closed_date.isoformat() daily_realized[key] = daily_realized.get(key, 0.0) + pnl points = [] cumulative_realized = realized_before prev_equity = initial_equity + realized_before peak_equity = max(initial_equity, prev_equity) max_drawdown_pct = 0.0 max_drawdown_usdt = 0.0 open_unrealized = _safe_float(open_unrealized) for idx in range(days): day = start_date + timedelta(days=idx) key = day.isoformat() realized = round(daily_realized.get(key, 0.0), 8) cumulative_realized += realized unrealized = open_unrealized if day == today else 0.0 equity = round(initial_equity + cumulative_realized + unrealized, 8) daily_pnl = round(equity - prev_equity, 8) daily_return_pct = round(daily_pnl / prev_equity * 100, 6) if prev_equity > 0 else 0.0 peak_equity = max(peak_equity, equity) drawdown_usdt = round(max(0.0, peak_equity - equity), 8) drawdown_pct = round(drawdown_usdt / peak_equity * 100, 6) if peak_equity > 0 else 0.0 max_drawdown_pct = max(max_drawdown_pct, drawdown_pct) max_drawdown_usdt = max(max_drawdown_usdt, drawdown_usdt) points.append( { "date": key, "equity_usdt": equity, "daily_pnl_usdt": daily_pnl, "daily_return_pct": daily_return_pct, "realized_pnl_usdt": round(cumulative_realized, 8), "unrealized_pnl_usdt": round(unrealized, 8), "return_pct": _account_return_pct(equity - initial_equity, initial_equity, cfg), "drawdown_usdt": drawdown_usdt, "drawdown_pct": drawdown_pct, } ) prev_equity = equity total_pnl = round((points[-1]["equity_usdt"] - initial_equity) if points else 0.0, 8) return { "days": days, "initial_equity_usdt": initial_equity, "current_equity_usdt": points[-1]["equity_usdt"] if points else initial_equity, "total_pnl_usdt": total_pnl, "total_return_pct": _account_return_pct(total_pnl, initial_equity, cfg), "max_drawdown_usdt": round(max_drawdown_usdt, 8), "max_drawdown_pct": round(max_drawdown_pct, 6), "points": points, } def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "") -> dict: limit = max(1, min(_safe_int(limit, 50), 200)) offset = max(0, _safe_int(offset, 0)) status = str(status or "").strip() where = "" params = [] if status in {"open", "closed"}: where = "WHERE status=%s" params.append(status) conn = get_conn() try: total = conn.execute(f"SELECT COUNT(*) FROM paper_trades {where}", tuple(params)).fetchone()[0] rows = conn.execute( f""" SELECT pt.*, lpc.price AS latest_market_price, lpc.updated_at AS latest_market_price_updated_at FROM paper_trades pt LEFT JOIN latest_price_cache lpc ON lpc.symbol = pt.symbol {where} ORDER BY pt.opened_at DESC, pt.id DESC LIMIT %s OFFSET %s """, tuple(params + [limit, offset]), ).fetchall() finally: conn.close() cfg = paper_trading_config() return { "items": [_decorate_trade(dict(r), cfg) for r in rows], "total": int(total or 0), "limit": limit, "offset": offset, "has_more": offset + len(rows) < int(total or 0), } def list_paper_orders(limit: int = 50, offset: int = 0, status: str = "") -> dict: limit = max(1, min(_safe_int(limit, 50), 200)) offset = max(0, _safe_int(offset, 0)) status = str(status or "").strip() where = "" params = [] if status in {"pending", "filled", "canceled", "expired", "rejected"}: where = "WHERE status=%s" params.append(status) conn = get_conn() try: total = conn.execute(f"SELECT COUNT(*) FROM paper_orders {where}", tuple(params)).fetchone()[0] rows = conn.execute( f""" SELECT po.*, lpc.price AS latest_market_price, lpc.updated_at AS latest_market_price_updated_at FROM paper_orders po LEFT JOIN latest_price_cache lpc ON lpc.symbol = po.symbol {where} ORDER BY po.created_at DESC, po.id DESC LIMIT %s OFFSET %s """, tuple(params + [limit, offset]), ).fetchall() finally: conn.close() items = [] for row in rows: item = dict(row) item["entry_plan_snapshot"] = _loads_json(item.pop("entry_plan_snapshot_json", "{}"), {}) latest_market = _safe_float(item.get("latest_market_price")) item["latest_price"] = latest_market if latest_market > 0 else _safe_float(item.get("current_price_at_create")) item["latest_price_updated_at"] = item.get("latest_market_price_updated_at") or item.get("updated_at") or "" target = _safe_float(item.get("target_price")) latest = _safe_float(item.get("latest_price")) item["distance_to_target_pct"] = round((latest / target - 1) * 100, 4) if target and latest else 0 items.append(item) return { "items": items, "total": int(total or 0), "limit": limit, "offset": offset, "has_more": offset + len(items) < 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), } def delete_paper_trade(trade_id: int) -> dict: trade_id = _safe_int(trade_id) if trade_id <= 0: return {"deleted": False, "reason": "invalid_trade_id"} conn = get_conn() try: trade = conn.execute("SELECT id, recommendation_id, symbol, status FROM paper_trades WHERE id=%s", (trade_id,)).fetchone() if not trade: return {"deleted": False, "reason": "not_found", "trade_id": trade_id} event_count = conn.execute("SELECT COUNT(*) FROM paper_trade_events WHERE trade_id=%s", (trade_id,)).fetchone()[0] conn.execute("DELETE FROM paper_trade_events WHERE trade_id=%s", (trade_id,)) conn.execute("DELETE FROM paper_trades WHERE id=%s", (trade_id,)) conn.commit() return { "deleted": True, "trade_id": trade_id, "symbol": trade["symbol"], "status": trade["status"], "deleted_events": int(event_count or 0), } finally: conn.close() def delete_paper_order(order_id: int) -> dict: order_id = _safe_int(order_id) if order_id <= 0: return {"deleted": False, "reason": "invalid_order_id"} conn = get_conn() try: order = conn.execute("SELECT id, recommendation_id, symbol, status FROM paper_orders WHERE id=%s", (order_id,)).fetchone() if not order: return {"deleted": False, "reason": "not_found", "order_id": order_id} conn.execute("DELETE FROM paper_orders WHERE id=%s", (order_id,)) conn.commit() return { "deleted": True, "order_id": order_id, "symbol": order["symbol"], "status": order["status"], } finally: conn.close() def reset_paper_trading_data(scope: str = "all") -> dict: scope = str(scope or "all").strip().lower() allowed = {"all", "trades", "orders", "events", "open_trades", "closed_trades", "completed"} if scope not in allowed: return {"reset": False, "reason": "invalid_scope", "scope": scope, "allowed_scopes": sorted(allowed)} conn = get_conn() try: deleted = {"trades": 0, "orders": 0, "events": 0} def delete_trades(where_sql: str = "", params: tuple = ()) -> None: rows = conn.execute(f"SELECT id FROM paper_trades {where_sql}", params).fetchall() ids = [int(r["id"]) for r in rows] if not ids: return event_count = conn.execute("SELECT COUNT(*) FROM paper_trade_events WHERE trade_id = ANY(%s)", (ids,)).fetchone()[0] conn.execute("DELETE FROM paper_trade_events WHERE trade_id = ANY(%s)", (ids,)) conn.execute("DELETE FROM paper_trades WHERE id = ANY(%s)", (ids,)) deleted["events"] += int(event_count or 0) deleted["trades"] += len(ids) def delete_orders(where_sql: str = "", params: tuple = ()) -> None: count = conn.execute(f"SELECT COUNT(*) FROM paper_orders {where_sql}", params).fetchone()[0] conn.execute(f"DELETE FROM paper_orders {where_sql}", params) deleted["orders"] += int(count or 0) if scope == "all": deleted["events"] += int(conn.execute("SELECT COUNT(*) FROM paper_trade_events").fetchone()[0] or 0) deleted["trades"] += int(conn.execute("SELECT COUNT(*) FROM paper_trades").fetchone()[0] or 0) deleted["orders"] += int(conn.execute("SELECT COUNT(*) FROM paper_orders").fetchone()[0] or 0) conn.execute("DELETE FROM paper_trade_events") conn.execute("DELETE FROM paper_trades") conn.execute("DELETE FROM paper_orders") elif scope == "events": deleted["events"] += int(conn.execute("SELECT COUNT(*) FROM paper_trade_events").fetchone()[0] or 0) conn.execute("DELETE FROM paper_trade_events") elif scope == "orders": delete_orders() elif scope == "trades": delete_trades() elif scope == "open_trades": delete_trades("WHERE status='open'") elif scope == "closed_trades": delete_trades("WHERE status='closed'") elif scope == "completed": delete_trades("WHERE status='closed'") delete_orders("WHERE status IN ('filled','expired','canceled','rejected')") conn.commit() return {"reset": True, "scope": scope, "deleted": deleted} finally: conn.close() def _report_trade_line(item: dict, closed: bool = False) -> str: symbol = item.get("symbol") or "--" side = "空" if str(item.get("side") or "long").lower() == "short" else "多" pnl = _safe_float(item.get("realized_pnl_pct") if closed else item.get("pnl_pct")) pnl_usdt = _safe_float(item.get("realized_pnl_usdt") if closed else item.get("unrealized_pnl_usdt")) status = item.get("exit_reason") if closed else "持仓中" return f"- **{symbol}** · {side} · {_fmt_pct(pnl)} · {pnl_usdt:+.2f} USDT · {status or '--'}" def _report_order_line(item: dict) -> str: symbol = item.get("symbol") or "--" side = "空" if str(item.get("side") or "long").lower() == "short" else "多" distance = _safe_float(item.get("distance_to_target_pct")) return f"- **{symbol}** · {side} · 目标 {_fmt_price(item.get('target_price'))} · 距目标 {_fmt_pct(distance)}" def _paper_running_days() -> float: conn = get_conn() try: row = conn.execute( """ SELECT MIN(first_at) AS first_at FROM ( SELECT MIN(opened_at) AS first_at FROM paper_trades UNION ALL SELECT MIN(created_at) AS first_at FROM paper_orders ) x WHERE first_at IS NOT NULL AND first_at <> '' """ ).fetchone() finally: conn.close() first_at = _parse_time(row["first_at"] if row else "") if not first_at: return 0.0 now = datetime.now(first_at.tzinfo) if first_at.tzinfo else datetime.now() seconds = max(0.0, (now - first_at).total_seconds()) return max(1.0, seconds / 86400) def _periodized_return_pct(total_return_pct: float, running_days: float, period_days: float) -> float: if running_days <= 0: return 0.0 growth = 1 + total_return_pct / 100 if growth <= 0: return -100.0 return round((growth ** (period_days / running_days) - 1) * 100, 4) def _periodized_return_label(total_return_pct: float, running_days: float, period_days: float) -> str: if running_days < 30: return "样本不足" return _fmt_pct(_periodized_return_pct(total_return_pct, running_days, period_days)) def send_paper_trading_report(days: int = 30) -> dict: days = max(1, min(_safe_int(days, 30), 365)) summary = get_paper_trading_summary(days=days) open_trades = list_paper_trades(limit=5, status="open").get("items", []) closed_trades = list_paper_trades(limit=5, status="closed").get("items", []) pending_orders = list_paper_orders(limit=5, status="pending").get("items", []) total_pnl = _safe_float(summary.get("total_pnl_usdt")) realized = _safe_float(summary.get("realized_pnl_usdt")) unrealized = _safe_float(summary.get("open_unrealized_pnl_usdt")) initial_equity = _safe_float(summary.get("initial_equity_usdt")) current_balance = _safe_float(summary.get("current_balance_usdt")) return_pct = _safe_float(summary.get("account_total_return_pct")) win_rate = _safe_float(summary.get("win_rate")) running_days = _paper_running_days() monthly_return_pct = _periodized_return_pct(return_pct, running_days, 30) if running_days >= 30 else None annualized_return_pct = _periodized_return_pct(return_pct, running_days, 365) if running_days >= 30 else None monthly_return_label = _periodized_return_label(return_pct, running_days, 30) annualized_return_label = _periodized_return_label(return_pct, running_days, 365) template = "green" if total_pnl >= 0 else "red" elements = [ { "tag": "column_set", "flex_mode": "none", "background_style": "default", "columns": [ {"tag": "column", "width": "weighted", "weight": 1, "elements": [_card_field("初始资金", f"{initial_equity:.2f} USDT")]}, {"tag": "column", "width": "weighted", "weight": 1, "elements": [_card_field("当前资金", f"{current_balance:.2f} USDT")]}, {"tag": "column", "width": "weighted", "weight": 1, "elements": [_card_field("运行天数", f"{running_days:.1f} 天")]}, ], }, { "tag": "column_set", "flex_mode": "none", "background_style": "default", "columns": [ {"tag": "column", "width": "weighted", "weight": 1, "elements": [_card_field("账户收益率", _fmt_pct(return_pct))]}, {"tag": "column", "width": "weighted", "weight": 1, "elements": [_card_field("月化收益率", monthly_return_label)]}, {"tag": "column", "width": "weighted", "weight": 1, "elements": [_card_field("年化收益率", annualized_return_label)]}, ], }, { "tag": "column_set", "flex_mode": "none", "background_style": "default", "columns": [ {"tag": "column", "width": "weighted", "weight": 1, "elements": [_card_field("总收益", f"{total_pnl:+.2f} USDT")]}, {"tag": "column", "width": "weighted", "weight": 1, "elements": [_card_field("成功率", f"{_fmt_pct(win_rate)} ({summary.get('win_count', 0)}/{summary.get('closed_count', 0)})")]}, {"tag": "column", "width": "weighted", "weight": 1, "elements": [_card_field("交易数量", f"持仓 {summary.get('open_count', 0)} / 平仓 {summary.get('closed_count', 0)} / 挂单 {summary.get('pending_order_count', 0)}")]}, ], }, _card_md( "**战绩概览**\n" f"- 已实现: {realized:+.2f} USDT\n" f"- 浮动收益: {unrealized:+.2f} USDT\n" f"- 平均平仓收益率: {_fmt_pct(summary.get('avg_realized_pnl_pct'))}\n" f"- 持仓名义价值: {_safe_float(summary.get('open_position_value_usdt')):.2f} USDT\n" f"- 已占用保证金: {_safe_float(summary.get('allocated_margin_usdt')):.2f} USDT\n" f"- 可用资金: {_safe_float(summary.get('available_equity_usdt')):.2f} USDT\n" f"- 累计杠杆: {_safe_float(summary.get('cumulative_leverage')):.2f}x" ), _card_md("**当前持仓**\n" + ("\n".join(_report_trade_line(x) for x in open_trades) if open_trades else "- 暂无持仓")), _card_md("**等待成交**\n" + ("\n".join(_report_order_line(x) for x in pending_orders) if pending_orders else "- 暂无挂单")), _card_md("**最近平仓**\n" + ("\n".join(_report_trade_line(x, closed=True) for x in closed_trades) if closed_trades else "- 暂无平仓记录")), _card_note(f"报告周期: 最近 {days} 天 · 发送时间: {_now()}"), ] ok, push_result = _push_custom_paper_card({ "metadata": {"source": "paper_trading", "event_type": "trade_report", "symbol": "ALL"}, "config": {"wide_screen_mode": True}, "header": { "template": template, "title": {"tag": "plain_text", "content": f"交易报告 - 最近 {days} 天"}, }, "elements": elements, }) return { "ok": bool(ok), "days": days, "running_days": running_days, "monthly_return_pct": monthly_return_pct, "annualized_return_pct": annualized_return_pct, "monthly_return_label": monthly_return_label, "annualized_return_label": annualized_return_label, "summary": summary, "sent_at": _now(), "push_result": push_result, }