alphax/app/db/paper_trading.py
2026-05-21 16:44:48 +08:00

1376 lines
55 KiB
Python

"""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.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 _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)),
"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 _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 _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)
leverage = default_leverage(cfg)
margin = default_margin_usdt(cfg)
qty = round(notional / entry_price, 12) if entry_price > 0 else 0
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"))
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 "",
},
now,
)
result = {
"opened": True,
"trade_id": trade_id,
"entry_price": entry_price,
"qty": qty,
"notional_usdt": notional,
"margin_usdt": margin,
"leverage": leverage,
}
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) -> 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)
effective_rr = rr if rr > 0 else calc_rr
min_rr = max(0.0, _safe_float(cfg.get("order_min_rr"), 1.2))
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")
if risk_reward_ok is False:
reasons.append("risk_reward_rejected")
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 > 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),
"max_distance_to_entry_pct": max_distance,
"min_rr": min_rr,
"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
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)
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,
}
_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") 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 "上移"
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": cfg.get("activate_pnl_pct"),
"distance_pct": distance_pct,
"tier_label": tier_label,
"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,
"tier_label": tier_label,
"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":
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"}
result = _open_trade(conn, rec, current_price, event_time, config=cfg)
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 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 _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,
}