1
This commit is contained in:
parent
fc11c73413
commit
fe5c8a7c3f
@ -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": "锁利"},
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -13,10 +13,12 @@
|
||||
<p>这里展示的是 paper trading 账本:只有系统把可买信号模拟成交后,才会进入收益统计。推荐历史和观察池不会直接产生收益率。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" id="sendReportBtn" onclick="sendReport()">发送模拟交易报告</button>
|
||||
<button class="btn" onclick="loadAll()">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note" id="paperNote">模拟交易只统计已经进入 paper trading 的信号。页面用账户余额、持仓价值、累计杠杆和实际盈亏展示策略表现,不再把观察池或推荐归档当作收益。</div>
|
||||
<div class="note" id="reportNote" style="display:none"></div>
|
||||
<div class="kpis" id="kpis"><div class="kpi"><span>状态</span><b>加载中</b></div></div>
|
||||
<div class="tabs" role="tablist" aria-label="模拟交易视图切换">
|
||||
<button class="tab-btn active" id="tab-open" type="button" onclick="setTradeTab('open')" role="tab" aria-selected="true">持仓中</button>
|
||||
@ -99,7 +101,9 @@ function sideText(v){return String(v||'long').toLowerCase()==='short'?'空':'多
|
||||
function sideBadge(v){var s=String(v||'long').toLowerCase();return '<span class="badge '+(s==='short'?'side-short':'side-long')+'">'+sideText(s)+'</span>'}
|
||||
function setTradeTab(tab){['open','orders','completed','events'].forEach(function(k){var on=k===tab;$('tab-'+k).classList.toggle('active',on);$('tab-'+k).setAttribute('aria-selected',on?'true':'false');$('panel-'+k).classList.toggle('active',on)})}
|
||||
async function api(url){var r=await fetch(url);var d=await r.json().catch(function(){return{}});if(!r.ok)throw new Error(d.detail||d.error||'请求失败');return d}
|
||||
async function postApi(url){var r=await fetch(url,{method:'POST'});var d=await r.json().catch(function(){return{}});if(!r.ok)throw new Error(d.detail||d.error||'请求失败');return d}
|
||||
async function loadAll(){await Promise.all([loadSummary(),loadOrders(),loadOpenTrades(openOffset),loadCompleted(),loadEvents(eventOffset)])}
|
||||
async function sendReport(){var btn=$('sendReportBtn'),note=$('reportNote');btn.disabled=true;btn.textContent='发送中...';note.style.display='block';note.textContent='正在汇总当前交易数据并发送飞书报告...';try{var d=await postApi('/api/paper-trading/report?days=30');note.textContent=d.ok?'报告已发送到飞书。':'报告生成完成,但飞书发送未成功:'+String(d.push_result||'未知原因');await loadSummary()}catch(e){note.textContent='发送失败:'+e.message}finally{btn.disabled=false;btn.textContent='发送模拟交易报告'}}
|
||||
async function loadSummary(){try{var d=await api('/api/paper-trading/summary?days=30');var totalPnl=Number(d.total_pnl_usdt||0),realized=Number(d.realized_pnl_usdt||0),unrealized=Number(d.open_unrealized_pnl_usdt||0),ret=Number(d.account_total_return_pct||0);$('paperNote').textContent='当前账户余额 = 初始本金 + 已实现收益 + 持仓浮动收益。累计杠杆按当前持仓名义价值 ÷ 当前账户余额计算,用来衡量账户整体暴露。';$('kpis').innerHTML=[
|
||||
card('当前账户余额',fmt(d.current_balance_usdt||d.account_equity_usdt||0,2)+'U',totalPnl>=0?'green':'red','初始本金 '+fmt(d.initial_equity_usdt||d.account_equity_usdt||0,0)+'U'),
|
||||
card('当前持仓价值',fmt(d.open_position_value_usdt||0,0)+'U','blue',(d.open_count||0)+' 个持仓中'),
|
||||
|
||||
@ -3,7 +3,42 @@ import json
|
||||
import pytest
|
||||
|
||||
from app.db import altcoin_db
|
||||
from app.db.paper_trading import get_paper_trading_summary, list_paper_orders, list_paper_trade_events, list_paper_trades, sync_recommendation
|
||||
from app.db.paper_trading import (
|
||||
get_paper_trading_summary,
|
||||
list_paper_orders,
|
||||
list_paper_trade_events,
|
||||
list_paper_trades,
|
||||
send_paper_trading_report,
|
||||
sync_recommendation,
|
||||
)
|
||||
|
||||
|
||||
def _visible_card_text(card: dict) -> str:
|
||||
texts = []
|
||||
|
||||
def walk(value):
|
||||
if isinstance(value, dict):
|
||||
content = value.get("content")
|
||||
if isinstance(content, str):
|
||||
texts.append(content)
|
||||
for child in value.values():
|
||||
walk(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
walk(child)
|
||||
|
||||
walk(card.get("header"))
|
||||
walk(card.get("elements"))
|
||||
return "\n".join(texts)
|
||||
|
||||
|
||||
def _assert_no_paper_trading_copy(card: dict) -> None:
|
||||
text = _visible_card_text(card).lower()
|
||||
assert "模拟" not in text
|
||||
assert "paper trading" not in text
|
||||
assert "paper-trading" not in text
|
||||
assert "paper trade" not in text
|
||||
assert "paper_trades" not in text
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -183,7 +218,8 @@ def test_wait_pullback_paper_order_pushes_created_card(monkeypatch):
|
||||
assert pushed
|
||||
card = pushed[0]
|
||||
assert card["metadata"]["event_type"] == "paper_order_create"
|
||||
assert "模拟挂单创建" in card["header"]["title"]["content"]
|
||||
assert "挂单创建" in card["header"]["title"]["content"]
|
||||
_assert_no_paper_trading_copy(card)
|
||||
assert card["elements"][0]["tag"] == "column_set"
|
||||
|
||||
|
||||
@ -330,7 +366,8 @@ def test_wait_pullback_paper_order_fill_pushes_single_combined_card(monkeypatch)
|
||||
|
||||
event_types = [card["metadata"]["event_type"] for card in pushed]
|
||||
assert event_types == ["paper_order_create", "paper_order_fill"]
|
||||
assert "模拟挂单成交并开仓" in pushed[1]["header"]["title"]["content"]
|
||||
assert "挂单成交并开仓" in pushed[1]["header"]["title"]["content"]
|
||||
_assert_no_paper_trading_copy(pushed[1])
|
||||
assert "open" not in event_types
|
||||
|
||||
|
||||
@ -343,10 +380,64 @@ def test_paper_trade_open_push_card_is_structured(monkeypatch, buy_now_rec):
|
||||
assert pushed
|
||||
card = pushed[0]
|
||||
assert card["metadata"]["event_type"] == "open"
|
||||
assert "模拟交易开仓" in card["header"]["title"]["content"]
|
||||
assert "交易开仓" in card["header"]["title"]["content"]
|
||||
_assert_no_paper_trading_copy(card)
|
||||
assert card["elements"][0]["tag"] == "column_set"
|
||||
|
||||
|
||||
def test_trailing_move_push_is_throttled_but_stop_still_updates(monkeypatch, buy_now_rec):
|
||||
pushed = []
|
||||
rec = dict(buy_now_rec)
|
||||
rec["tp1"] = 200
|
||||
rec["tp2"] = 220
|
||||
rec["entry_plan"] = {
|
||||
"entry_action": "可即刻买入",
|
||||
"entry_price": 100,
|
||||
"stop_loss": 95,
|
||||
"tp1": 200,
|
||||
"tp2": 220,
|
||||
"entry_trigger_confirmed": True,
|
||||
"risk_reward_ok": True,
|
||||
}
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_STOP_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_ACTIVATE_PNL_PCT", "3")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_INTERVAL_SECONDS", "300")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_STEP_PCT", "2")
|
||||
monkeypatch.setattr("app.db.paper_trading.push_card", lambda card: pushed.append(card) or (True, {"StatusCode": 0}))
|
||||
|
||||
sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
||||
activated = sync_recommendation(rec, 105, event_time="2026-05-16T10:01:00")
|
||||
small_move = sync_recommendation(rec, 105.5, event_time="2026-05-16T10:01:05")
|
||||
large_move = sync_recommendation(rec, 108, event_time="2026-05-16T10:01:10")
|
||||
|
||||
assert small_move["moved"] is True
|
||||
assert small_move["trailing_stop"] > activated["trailing_stop"]
|
||||
assert small_move["notification_emitted"] is False
|
||||
assert large_move["notification_emitted"] is True
|
||||
assert [card["metadata"]["event_type"] for card in pushed] == ["open", "trailing_activate", "trailing_move"]
|
||||
_assert_no_paper_trading_copy(pushed[-1])
|
||||
|
||||
|
||||
def test_send_paper_trading_report_pushes_performance_summary(monkeypatch, buy_now_rec):
|
||||
pushed = []
|
||||
monkeypatch.setattr("app.db.paper_trading.push_card", lambda card: pushed.append(card) or (True, {"StatusCode": 0}))
|
||||
|
||||
sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
|
||||
sync_recommendation(buy_now_rec, 106, event_time="2026-05-16T10:05:00")
|
||||
result = send_paper_trading_report(days=30)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert pushed[-1]["metadata"]["event_type"] == "trade_report"
|
||||
text = _visible_card_text(pushed[-1])
|
||||
assert "交易报告" in text
|
||||
assert "初始资金" in text
|
||||
assert "当前资金" in text
|
||||
assert "账户收益率" in text
|
||||
assert "成功率" in text
|
||||
assert "战绩概览" in text
|
||||
_assert_no_paper_trading_copy(pushed[-1])
|
||||
|
||||
|
||||
def test_summary_counts_pending_paper_orders(monkeypatch):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||
altcoin_db.init_db()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user