diff --git a/app/config/system_config.py b/app/config/system_config.py index b8bc304..6d34486 100644 --- a/app/config/system_config.py +++ b/app/config/system_config.py @@ -108,6 +108,8 @@ def default_paper_trading_config(): "trailing_activate_pnl_pct": _env_float("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", 3.0), "trailing_min_lock_profit_pct": _env_float("ALPHAX_PAPER_TRAILING_MIN_LOCK_PROFIT_PCT", 0.5), "trailing_distance_pct": _env_float("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", 1.5), + "trailing_move_push_min_interval_seconds": _env_int("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_INTERVAL_SECONDS", 300), + "trailing_move_push_min_step_pct": _env_float("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_STEP_PCT", 2.0), "trailing_tiers": [ {"min_pnl_pct": 8.0, "distance_pct": 1.0, "label": "紧贴"}, {"min_pnl_pct": 5.0, "distance_pct": 1.2, "label": "锁利"}, diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index 82385e5..be78b1f 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -71,6 +71,8 @@ def _trailing_config() -> dict: "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 [], } @@ -87,6 +89,43 @@ def _trailing_distance_pct(pnl_pct: float, cfg: dict) -> tuple[float, str]: 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(): @@ -212,6 +251,10 @@ 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 = [] @@ -237,6 +280,13 @@ def _push_paper_card(event_type: str, symbol: str, title: str, template: str, fi pass +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", "") @@ -244,14 +294,14 @@ def _push_event_card(event_type: str, trade: dict, result: dict, event_time: str _push_paper_card( event_type, symbol, - f"模拟交易开仓 - {short_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 @@ -259,7 +309,7 @@ def _push_event_card(event_type: str, trade: dict, result: dict, event_time: str _push_paper_card( event_type, symbol, - f"模拟交易平仓 - {short_symbol}", + f"交易平仓 - {short_symbol}", "red" if _safe_float(result.get("pnl_usdt")) < 0 else "green", [ ("退出价", _fmt_price(result.get("exit_price"))), @@ -267,7 +317,7 @@ def _push_event_card(event_type: str, trade: dict, result: dict, event_time: str ("收益额", f"{_safe_float(result.get('pnl_usdt')):.2f} USDT"), ("原因", result.get("exit_reason") or "--"), ], - "收益只来自 paper_trades 模拟账本。", + "收益以交易账本记录为准。", event_time, ) return @@ -275,14 +325,14 @@ def _push_event_card(event_type: str, trade: dict, result: dict, event_time: str _push_paper_card( event_type, symbol, - f"模拟交易移动止盈{'启动' if event_type == 'trailing_activate' else '上移'} - {short_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, ) @@ -295,7 +345,7 @@ def _push_order_created_card(order: dict, event_time: str = "") -> None: _push_paper_card( "paper_order_create", symbol, - f"模拟挂单创建 - {symbol.replace('/USDT', '')}", + f"挂单创建 - {symbol.replace('/USDT', '')}", "wathet", [ ("目标价", _fmt_price(target)), @@ -303,7 +353,7 @@ def _push_order_created_card(order: dict, event_time: str = "") -> None: ("距目标", _fmt_pct(distance)), ("有效期", order.get("expires_at") or "--"), ], - "等回踩机会已进入模拟挂单,触价后才会进入模拟持仓。", + "等回踩机会已进入挂单,触价后进入持仓。", event_time, ) @@ -313,7 +363,7 @@ def _push_order_filled_card(order: dict, result: dict, event_time: str = "") -> _push_paper_card( "paper_order_fill", symbol, - f"模拟挂单成交并开仓 - {symbol.replace('/USDT', '')}", + f"挂单成交并开仓 - {symbol.replace('/USDT', '')}", "green", [ ("挂单价", _fmt_price(order.get("target_price"))), @@ -321,7 +371,7 @@ def _push_order_filled_card(order: dict, result: dict, event_time: str = "") -> ("名义仓位", f"{_safe_float(result.get('notional_usdt')):.2f} USDT"), ("来源", order.get("source_status") or "wait_pullback"), ], - "价格触达理想入场位,模拟挂单已转为 paper trade。", + "价格触达理想入场位,挂单已转为持仓。", event_time, ) @@ -386,7 +436,7 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config: "open", entry_price, 0.0, - "模拟交易开仓:仅用于策略收益验证,不代表真实成交", + "交易开仓:策略信号已进入持仓跟踪", { "notional_usdt": notional, "margin_usdt": margin, @@ -712,7 +762,7 @@ def _close_trade(conn, trade: dict, current_price: float, reason: str, event_tim "close", exit_price, pnl_pct, - f"模拟交易平仓:{reason}", + f"交易平仓:{reason}", {"realized_pnl_usdt": pnl_usdt, "fee_usdt": total_fee}, now, ) @@ -746,28 +796,31 @@ def _update_trailing_stop(conn, trade: dict, current_price: float, pnl_pct: floa event_type = "trailing_activate" if activated else "trailing_move" action_text = "激活" if activated else "上移" - message = f"模拟交易移动止盈{action_text}:保护价 {new_trail:.8g}" - _record_event( - conn, - trade["id"], - trade["recommendation_id"], - trade["symbol"], - event_type, - new_trail, - pnl_pct, - message, - { - "current_price": current_price, - "previous_trailing_stop": current_trail, - "trailing_stop": new_trail, - "activate_pnl_pct": cfg.get("activate_pnl_pct"), - "distance_pct": distance_pct, - "tier_label": tier_label, - "min_lock_profit_pct": cfg.get("min_lock_profit_pct"), - }, - event_time, - ) - _push_event_card(event_type, trade, {"trailing_stop": new_trail}, event_time) + 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, @@ -775,6 +828,7 @@ def _update_trailing_stop(conn, trade: dict, current_price: float, pnl_pct: floa "previous_trailing_stop": current_trail, "distance_pct": distance_pct, "tier_label": tier_label, + "notification_emitted": should_emit, } @@ -1066,3 +1120,88 @@ def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "", "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 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")) + 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("账户收益率", _fmt_pct(return_pct))]}, + ], + }, + { + "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, + "summary": summary, + "sent_at": _now(), + "push_result": push_result, + } diff --git a/app/web/routes_paper_trading.py b/app/web/routes_paper_trading.py index 72b9860..c4dbdc3 100644 --- a/app/web/routes_paper_trading.py +++ b/app/web/routes_paper_trading.py @@ -1,6 +1,12 @@ from fastapi import APIRouter, Cookie -from app.db.paper_trading import get_paper_trading_summary, list_paper_orders, list_paper_trade_events, list_paper_trades +from app.db.paper_trading import ( + get_paper_trading_summary, + list_paper_orders, + list_paper_trade_events, + list_paper_trades, + send_paper_trading_report, +) from app.web.shared import require_admin @@ -45,3 +51,9 @@ async def api_paper_trading_events( ): require_admin(altcoin_session) return list_paper_trade_events(limit=limit, offset=offset, symbol=symbol, event_type=event_type) + + +@router.post("/api/paper-trading/report") +async def api_paper_trading_report(days: int = 30, altcoin_session: str = Cookie(default="")): + require_admin(altcoin_session) + return send_paper_trading_report(days=days) diff --git a/static/paper_trading.html b/static/paper_trading.html index 5d834cd..9bb16cd 100644 --- a/static/paper_trading.html +++ b/static/paper_trading.html @@ -13,10 +13,12 @@
这里展示的是 paper trading 账本:只有系统把可买信号模拟成交后,才会进入收益统计。推荐历史和观察池不会直接产生收益率。