From ea342161fcf8a62862d2818c08cf5e7c7d56c906 Mon Sep 17 00:00:00 2001
From: aaron <>
Date: Sat, 23 May 2026 09:46:12 +0800
Subject: [PATCH] 1
---
.env.example | 13 +-
app/config/system_config.py | 13 +-
app/db/paper_trading.py | 241 +++++++++++++++++++++++++++++-
app/services/live_trading_sync.py | 6 +
app/services/price_streamer.py | 3 +
app/web/routes_paper_trading.py | 32 +++-
static/paper_trading.html | 56 +++++--
tests/test_paper_trading.py | 190 +++++++++++++++++++++++
8 files changed, 530 insertions(+), 24 deletions(-)
diff --git a/.env.example b/.env.example
index 45248bf..600e4bf 100644
--- a/.env.example
+++ b/.env.example
@@ -66,9 +66,18 @@ ALPHAX_ONCHAIN_WHALE_TX_USD=250000
# 策略交易挂单门控。wait_pullback 只是候选,必须通过这些条件才会创建挂单。
ALPHAX_PAPER_ORDER_GATE_ENABLED=1
ALPHAX_PAPER_MAX_CUMULATIVE_LEVERAGE=5
-ALPHAX_PAPER_ORDER_MIN_REC_SCORE=20
-ALPHAX_PAPER_ORDER_MIN_RR=1.2
+ALPHAX_PAPER_ENTRY_GATE_ENABLED=1
+ALPHAX_PAPER_ENTRY_MIN_REC_SCORE=50
+ALPHAX_PAPER_ENTRY_MIN_RR=1.8
+ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT=20
+ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT=3
+ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES=3
+ALPHAX_PAPER_WEAK_ENTRY_WINDOW_HOURS=6
+ALPHAX_PAPER_WEAK_ENTRY_MIN_MAX_PNL_PCT=1
+ALPHAX_PAPER_ORDER_MIN_REC_SCORE=50
+ALPHAX_PAPER_ORDER_MIN_RR=1.8
ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK=1
+ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT=1.5
ALPHAX_PAPER_ORDER_MAX_DISTANCE_TO_ENTRY_PCT=8
ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER=0
ALPHAX_PAPER_ORDER_CANCEL_FAR_FROM_ENTRY_PCT=12
diff --git a/app/config/system_config.py b/app/config/system_config.py
index 95964a5..912afc7 100644
--- a/app/config/system_config.py
+++ b/app/config/system_config.py
@@ -118,9 +118,18 @@ def default_paper_trading_config():
"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),
"order_gate_enabled": _env_bool("ALPHAX_PAPER_ORDER_GATE_ENABLED", True),
- "order_min_rec_score": _env_float("ALPHAX_PAPER_ORDER_MIN_REC_SCORE", 20.0),
- "order_min_rr": _env_float("ALPHAX_PAPER_ORDER_MIN_RR", 1.2),
+ "entry_gate_enabled": _env_bool("ALPHAX_PAPER_ENTRY_GATE_ENABLED", True),
+ "entry_min_rec_score": _env_float("ALPHAX_PAPER_ENTRY_MIN_REC_SCORE", 50.0),
+ "entry_min_rr": _env_float("ALPHAX_PAPER_ENTRY_MIN_RR", 1.8),
+ "max_stop_loss_leverage_risk_pct": _env_float("ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT", 20.0),
+ "max_account_drawdown_pause_pct": _env_float("ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT", 3.0),
+ "pause_after_weak_entries": _env_int("ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES", 3),
+ "weak_entry_window_hours": _env_float("ALPHAX_PAPER_WEAK_ENTRY_WINDOW_HOURS", 6.0),
+ "weak_entry_min_max_pnl_pct": _env_float("ALPHAX_PAPER_WEAK_ENTRY_MIN_MAX_PNL_PCT", 1.0),
+ "order_min_rec_score": _env_float("ALPHAX_PAPER_ORDER_MIN_REC_SCORE", 50.0),
+ "order_min_rr": _env_float("ALPHAX_PAPER_ORDER_MIN_RR", 1.8),
"order_require_risk_reward_ok": _env_bool("ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK", True),
+ "order_min_distance_to_entry_pct": _env_float("ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT", 1.5),
"order_max_distance_to_entry_pct": _env_float("ALPHAX_PAPER_ORDER_MAX_DISTANCE_TO_ENTRY_PCT", 8.0),
"order_require_current_trigger": _env_bool("ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER", False),
"order_cancel_far_from_entry_pct": _env_float("ALPHAX_PAPER_ORDER_CANCEL_FAR_FROM_ENTRY_PCT", 12.0),
diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py
index acf58ff..99ed1aa 100644
--- a/app/db/paper_trading.py
+++ b/app/db/paper_trading.py
@@ -102,6 +102,70 @@ def _cumulative_leverage_check(conn, additional_notional: float, config: dict |
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 _trailing_config() -> dict:
cfg = paper_trading_config()
return {
@@ -262,6 +326,22 @@ def _trade_pnl_pct(entry_price: float, current_price: float) -> float:
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)
@@ -500,6 +580,51 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config:
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}
leverage_ok, leverage_detail = _cumulative_leverage_check(conn, notional, cfg, exclude_rec_id=rec_id)
if not leverage_ok:
return {
@@ -508,11 +633,8 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config:
"reason": "cumulative_leverage_exceeded",
"risk_detail": leverage_detail,
}
- 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()
@@ -672,6 +794,7 @@ def _paper_order_gate(rec: dict, current_price: float, config: dict | None = Non
effective_rr = rr if rr > 0 else 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()
@@ -713,6 +836,8 @@ def _paper_order_gate(rec: dict, current_price: float, config: dict | None = Non
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":
@@ -727,6 +852,7 @@ def _paper_order_gate(rec: dict, current_price: float, config: dict | None = Non
"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,
@@ -806,6 +932,17 @@ def _order_payload_from_rec(rec: dict, current_price: float, event_time: str, co
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)
trade_rec = dict(rec)
plan = _entry_plan(trade_rec)
plan.setdefault("entry_price", fill_price)
@@ -1485,6 +1622,104 @@ def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "",
}
+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 "多"
diff --git a/app/services/live_trading_sync.py b/app/services/live_trading_sync.py
index d8bf939..187adbb 100644
--- a/app/services/live_trading_sync.py
+++ b/app/services/live_trading_sync.py
@@ -15,6 +15,7 @@ from app.db.live_trading import (
update_live_order_intent,
_row,
)
+from app.config.system_config import live_trading_config
from app.db.schema import get_conn, init_db
from app.integrations.binance_live import LiveTradingConfigError, build_binance_client
@@ -206,6 +207,11 @@ def sync_paper_trade_to_live(
client_factory=None,
) -> dict:
init_db()
+ cfg = live_trading_config()
+ if not bool(cfg.get("enabled")):
+ return {"ok": False, "reason": "live_trading_disabled", "items": []}
+ if str(cfg.get("execution_mode") or "exchange_api").strip().lower() != "exchange_api":
+ return {"ok": False, "reason": "live_trading_not_exchange_api", "items": []}
trade = _paper_trade(paper_trade_id)
if not trade:
return {"ok": False, "reason": "paper_trade_not_found", "items": []}
diff --git a/app/services/price_streamer.py b/app/services/price_streamer.py
index 4b954b3..99550be 100644
--- a/app/services/price_streamer.py
+++ b/app/services/price_streamer.py
@@ -19,6 +19,7 @@ from app.db.paper_trading import sync_recommendation
from app.db.recommendation_queries import get_active_recommendations_deduped
from app.db.schema import get_conn
from app.db.system_logs import record_exception, record_system_error
+from app.services.live_trading_sync import sync_paper_trade_to_live
def _now() -> str:
@@ -149,6 +150,8 @@ def handle_price_tick(symbol: str, price: float, targets: dict[str, dict], event
if not rec:
return {"updated_price": True, "paper_trading": {"skipped": True, "reason": "no_target"}}
result = sync_recommendation(rec, price, event_time=event_time)
+ if result.get("trade_id") and (result.get("opened") or result.get("paper_order", {}).get("filled")):
+ result["live_sync"] = sync_paper_trade_to_live(int(result["trade_id"]), execute=True)
return {"updated_price": True, "paper_trading": result}
diff --git a/app/web/routes_paper_trading.py b/app/web/routes_paper_trading.py
index 4c7feec..5b82e5c 100644
--- a/app/web/routes_paper_trading.py
+++ b/app/web/routes_paper_trading.py
@@ -1,11 +1,14 @@
-from fastapi import APIRouter, Cookie
+from fastapi import APIRouter, Cookie, HTTPException
from app.db.paper_trading import (
+ delete_paper_order,
+ delete_paper_trade,
get_paper_trading_performance,
get_paper_trading_summary,
list_paper_orders,
list_paper_trade_events,
list_paper_trades,
+ reset_paper_trading_data,
send_paper_trading_report,
)
from app.web.shared import require_admin
@@ -64,3 +67,30 @@ async def api_paper_trading_events(
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)
+
+
+@router.delete("/api/paper-trading/trades/{trade_id}")
+async def api_delete_paper_trade(trade_id: int, altcoin_session: str = Cookie(default="")):
+ require_admin(altcoin_session)
+ result = delete_paper_trade(trade_id)
+ if not result.get("deleted"):
+ raise HTTPException(status_code=404 if result.get("reason") == "not_found" else 400, detail=result)
+ return result
+
+
+@router.delete("/api/paper-trading/orders/{order_id}")
+async def api_delete_paper_order(order_id: int, altcoin_session: str = Cookie(default="")):
+ require_admin(altcoin_session)
+ result = delete_paper_order(order_id)
+ if not result.get("deleted"):
+ raise HTTPException(status_code=404 if result.get("reason") == "not_found" else 400, detail=result)
+ return result
+
+
+@router.post("/api/paper-trading/reset")
+async def api_reset_paper_trading(scope: str = "all", altcoin_session: str = Cookie(default="")):
+ require_admin(altcoin_session)
+ result = reset_paper_trading_data(scope=scope)
+ if not result.get("reset"):
+ raise HTTPException(status_code=400, detail=result)
+ return result
diff --git a/static/paper_trading.html b/static/paper_trading.html
index eaebde9..b585146 100644
--- a/static/paper_trading.html
+++ b/static/paper_trading.html
@@ -2,7 +2,7 @@
{% block title %}AlphaX Agent — 策略交易{% endblock %}
{% block extra_head_css %}
{% endblock %}
{% block content %}
@@ -19,6 +19,23 @@
策略交易只统计已经进入交易账本的信号。页面用账户余额、持仓价值、累计杠杆和实际盈亏展示策略表现,不再把观察池或推荐归档当作收益。
+
+
+
账本维护
+
用于清理异常测试数据或重置策略交易账本。删除只影响策略交易表,不会删除推荐、筛选和行情数据。
+
+
+
+ 全部账本数据
+ 仅持仓中
+ 仅已平仓
+ 仅挂单
+ 已完成交易和挂单
+ 仅操作日志
+
+ 重置所选数据
+
+
@@ -41,8 +58,8 @@
- 币种 状态 方向 仓位 开仓 止盈 / 止损 / 移动止盈 最新价 平仓价 平仓时间 价格收益 账户收益 退出原因 来源
- 加载中...
+ 币种 状态 方向 仓位 开仓 止盈 / 止损 / 移动止盈 最新价 平仓价 平仓时间 价格收益 账户收益 退出原因 来源 操作
+ 加载中...
@@ -53,8 +70,8 @@
挂单中
等回踩机会通过闸门后进入这里,价格触达后再转成策略持仓
- 币种 状态 方向 目标价 最新价 距离目标 止盈 / 止损 创建时间 过期时间 来源
- 加载中...
+ 币种 状态 方向 目标价 最新价 距离目标 止盈 / 止损 创建时间 过期时间 来源 操作
+ 加载中...
@@ -64,14 +81,14 @@
- 币种 状态 方向 仓位 开仓 止盈 / 止损 / 移动止盈 最新价 平仓价 平仓时间 价格收益 账户收益 退出原因 来源
- 加载中...
+ 币种 状态 方向 仓位 开仓 止盈 / 止损 / 移动止盈 最新价 平仓价 平仓时间 价格收益 账户收益 退出原因 来源 操作
+ 加载中...
- 币种 状态 方向 目标价 最新价 距离目标 止盈 / 止损 创建时间 结束时间 来源
- 加载中...
+ 币种 状态 方向 目标价 最新价 距离目标 止盈 / 止损 创建时间 结束时间 来源 操作
+ 加载中...
@@ -112,8 +129,12 @@ function sideBadge(v){var s=String(v||'long').toLowerCase();return '
=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)+' 个持仓中'),
@@ -130,8 +151,8 @@ function renderPerformance(d){var points=d.points||[];if(!points.length){$('perf
'最大回撤 '+fmt(dd,2)+'% ',
'总收益 '+(pnl>0?'+':'')+fmt(pnl,2)+'U '
].join('');var w=960,h=260,pad=38,top=22,lineH=150,barBase=224;var equities=points.map(function(p){return Number(p.equity_usdt||0)}),pnls=points.map(function(p){return Number(p.daily_pnl_usdt||0)});var minEq=Math.min.apply(null,equities),maxEq=Math.max.apply(null,equities);if(maxEq===minEq){maxEq+=1;minEq-=1}function x(i){return pad+(points.length===1?0:i*(w-pad*2)/(points.length-1))}function y(v){return top+(maxEq-v)*lineH/(maxEq-minEq)}var line=points.map(function(p,i){return x(i).toFixed(1)+','+y(Number(p.equity_usdt||0)).toFixed(1)}).join(' ');var area=pad+','+barBase+' '+line+' '+x(points.length-1).toFixed(1)+','+barBase;var maxAbs=Math.max.apply(null,[1].concat(pnls.map(function(v){return Math.abs(v)})));var barW=Math.max(3,(w-pad*2)/points.length*.55);var bars=points.map(function(p,i){var v=Number(p.daily_pnl_usdt||0),bh=Math.abs(v)/maxAbs*44,bx=x(i)-barW/2,by=v>=0?barBase-bh:barBase;return ''+esc(p.date)+' 每日收益 '+(v>0?'+':'')+fmt(v,2)+'U '}).join('');var first=points[0],last=points[points.length-1];var grid=[top,top+lineH/2,top+lineH].map(function(gy){return ' '}).join('');$('performanceChart').innerHTML=''+grid+' '+bars+''+esc(first.date)+' '+esc(last.date)+' 权益 '+fmt(minEq,0)+'U - '+fmt(maxEq,0)+'U 柱状图:每日实现收益 '}
-async function loadOrders(){$('orderRows').innerHTML='加载中... ';try{var d=await api('/api/paper-trading/orders?limit=50&offset=0&status=pending');renderOrders(d.items||[])}catch(e){$('orderRows').innerHTML=''+esc(e.message)+' '}}
-function renderOrders(items){if(!items.length){$('orderRows').innerHTML='暂无等待触价的策略挂单 ';return}$('orderRows').innerHTML=items.map(function(x){var latest=x.latest_price||x.current_price_at_create||0,dist=Number(x.distance_to_target_pct||0);return ''+
+async function loadOrders(){$('orderRows').innerHTML=' 加载中... ';try{var d=await api('/api/paper-trading/orders?limit=50&offset=0&status=pending');renderOrders(d.items||[])}catch(e){$('orderRows').innerHTML=''+esc(e.message)+' '}}
+function renderOrders(items){if(!items.length){$('orderRows').innerHTML='暂无等待触价的策略挂单 ';return}$('orderRows').innerHTML=items.map(function(x){var latest=x.latest_price||x.current_price_at_create||0,dist=Number(x.distance_to_target_pct||0);return ''+
''+esc(x.symbol)+'
#'+esc(x.id)+' · Rec '+esc(x.recommendation_id)+'
'+
''+esc(x.status==='pending'?'等待成交':x.status)+' '+
''+sideBadge(x.side)+' '+
@@ -142,13 +163,14 @@ function renderOrders(items){if(!items.length){$('orderRows').innerHTML=' '+time(x.created_at)+' '+
''+time(x.expires_at)+' '+
''+esc(x.source_status||'--')+'
'+esc(x.source_action||'')+'
'+
+ '删除 '+
' '}).join('')}
function protectionCell(x){var trail=Number(x.trailing_stop||0);var trailHtml=trail>0?'移动止盈 $'+fmt(trail,6)+' ':'移动止盈未启动 ';return 'TP $'+fmt(x.tp1,6)+' SL $'+fmt(x.stop_loss,6)+' '+trailHtml+'
'}
-async function loadOpenTrades(nextOffset){openOffset=Math.max(0,nextOffset||0);$('openRows').innerHTML='加载中... ';try{var d=await api('/api/paper-trading/trades?limit='+LIMIT+'&offset='+openOffset+'&status=open');openTotal=d.total||0;renderTradeRows('openRows',d.items||[],'暂无持仓中的策略交易');renderOpenPager()}catch(e){$('openRows').innerHTML=''+esc(e.message)+' '}}
+async function loadOpenTrades(nextOffset){openOffset=Math.max(0,nextOffset||0);$('openRows').innerHTML='加载中... ';try{var d=await api('/api/paper-trading/trades?limit='+LIMIT+'&offset='+openOffset+'&status=open');openTotal=d.total||0;renderTradeRows('openRows',d.items||[],'暂无持仓中的策略交易');renderOpenPager()}catch(e){$('openRows').innerHTML=''+esc(e.message)+' '}}
async function loadCompleted(){await Promise.all([loadCompletedTrades(),loadCompletedOrders()])}
-async function loadCompletedTrades(){$('completedTradeRows').innerHTML='加载中... ';try{var d=await api('/api/paper-trading/trades?limit=80&offset=0&status=closed');renderTradeRows('completedTradeRows',d.items||[],'暂无已平仓策略交易')}catch(e){$('completedTradeRows').innerHTML=''+esc(e.message)+' '}}
-async function loadCompletedOrders(){$('completedOrderRows').innerHTML='加载中... ';try{var sets=await Promise.all(['filled','expired','canceled','rejected'].map(function(s){return api('/api/paper-trading/orders?limit=50&offset=0&status='+s)}));var items=[];sets.forEach(function(d){items=items.concat(d.items||[])});items.sort(function(a,b){return String(b.updated_at||b.created_at).localeCompare(String(a.updated_at||a.created_at))});renderCompletedOrders(items)}catch(e){$('completedOrderRows').innerHTML=''+esc(e.message)+' '}}
-function renderTradeRows(targetId,items,emptyText){if(!items.length){$(targetId).innerHTML=''+esc(emptyText||'暂无策略交易')+' ';return}$(targetId).innerHTML=items.map(function(x){var st=x.status==='open'?'持仓中':'已平仓';var latest=x.latest_price||x.current_price||0;var pnlUsdt=x.status==='closed'?x.realized_pnl_usdt:x.unrealized_pnl_usdt;return ''+
+async function loadCompletedTrades(){$('completedTradeRows').innerHTML=' 加载中... ';try{var d=await api('/api/paper-trading/trades?limit=80&offset=0&status=closed');renderTradeRows('completedTradeRows',d.items||[],'暂无已平仓策略交易')}catch(e){$('completedTradeRows').innerHTML=''+esc(e.message)+' '}}
+async function loadCompletedOrders(){$('completedOrderRows').innerHTML='加载中... ';try{var sets=await Promise.all(['filled','expired','canceled','rejected'].map(function(s){return api('/api/paper-trading/orders?limit=50&offset=0&status='+s)}));var items=[];sets.forEach(function(d){items=items.concat(d.items||[])});items.sort(function(a,b){return String(b.updated_at||b.created_at).localeCompare(String(a.updated_at||a.created_at))});renderCompletedOrders(items)}catch(e){$('completedOrderRows').innerHTML=''+esc(e.message)+' '}}
+function renderTradeRows(targetId,items,emptyText){if(!items.length){$(targetId).innerHTML=''+esc(emptyText||'暂无策略交易')+' ';return}$(targetId).innerHTML=items.map(function(x){var st=x.status==='open'?'持仓中':'已平仓';var latest=x.latest_price||x.current_price||0;var pnlUsdt=x.status==='closed'?x.realized_pnl_usdt:x.unrealized_pnl_usdt;return ''+
''+esc(x.symbol)+'
#'+esc(x.id)+' · Rec '+esc(x.recommendation_id)+'
'+
''+st+' '+
''+sideBadge(x.side)+' '+
@@ -162,8 +184,9 @@ function renderTradeRows(targetId,items,emptyText){if(!items.length){$(targetId)
''+money(pnlUsdt)+'
账户 '+(x.account_return_pct>0?'+':'')+fmt(x.account_return_pct,2)+'% · 保证金 '+(x.margin_roi_pct>0?'+':'')+fmt(x.margin_roi_pct,2)+'%
'+
''+esc(x.exit_reason||'--')+' '+
''+esc(x.source_status||'--')+'
'+esc(x.strategy_version||'')+'
'+
+ '删除 '+
' '}).join('')}
-function renderCompletedOrders(items){if(!items.length){$('completedOrderRows').innerHTML='暂无已结束策略挂单 ';return}$('completedOrderRows').innerHTML=items.map(function(x){var latest=x.latest_price||x.current_price_at_create||0,dist=Number(x.distance_to_target_pct||0),ended=x.filled_at||x.canceled_at||x.updated_at;return ''+
+function renderCompletedOrders(items){if(!items.length){$('completedOrderRows').innerHTML=' 暂无已结束策略挂单 ';return}$('completedOrderRows').innerHTML=items.map(function(x){var latest=x.latest_price||x.current_price_at_create||0,dist=Number(x.distance_to_target_pct||0),ended=x.filled_at||x.canceled_at||x.updated_at;return ''+
''+esc(x.symbol)+'
#'+esc(x.id)+' · Rec '+esc(x.recommendation_id)+'
'+
''+esc(orderStatus(x))+' '+
''+sideBadge(x.side)+' '+
@@ -174,6 +197,7 @@ function renderCompletedOrders(items){if(!items.length){$('completedOrderRows').
''+time(x.created_at)+' '+
''+time(ended)+' '+
''+esc(x.cancel_reason||x.source_status||'--')+'
'+esc(x.source_action||'')+'
'+
+ '删除 '+
' '}).join('')}
function orderStatus(x){return {filled:'已成交',expired:'已过期',canceled:'已取消',rejected:'已拒绝'}[x.status]||x.status||'--'}
function renderOpenPager(){var page=Math.floor(openOffset/LIMIT)+1,totalPages=Math.max(1,Math.ceil(openTotal/LIMIT));$('openPageInfo').textContent='第 '+page+' / '+totalPages+' 页 · 共 '+openTotal+' 条';$('openPager').innerHTML='上一页 第 '+page+' / '+totalPages+' 页 =openTotal)?'disabled':'')+' onclick="loadOpenTrades('+(openOffset+LIMIT)+')">下一页 '}
diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py
index 9b33213..693ef33 100644
--- a/tests/test_paper_trading.py
+++ b/tests/test_paper_trading.py
@@ -5,11 +5,14 @@ import pytest
from app.db import altcoin_db
from app.db.paper_trading import (
+ delete_paper_order,
+ delete_paper_trade,
get_paper_trading_performance,
get_paper_trading_summary,
list_paper_orders,
list_paper_trade_events,
list_paper_trades,
+ reset_paper_trading_data,
send_paper_trading_report,
sync_recommendation,
)
@@ -43,6 +46,17 @@ def _assert_no_paper_trading_copy(card: dict) -> None:
assert "paper_trades" not in text
+@pytest.fixture(autouse=True)
+def legacy_paper_trade_thresholds(monkeypatch):
+ monkeypatch.setenv("ALPHAX_PAPER_ENTRY_GATE_ENABLED", "0")
+ monkeypatch.setenv("ALPHAX_PAPER_ORDER_MIN_REC_SCORE", "20")
+ monkeypatch.setenv("ALPHAX_PAPER_ORDER_MIN_RR", "1.2")
+ monkeypatch.setenv("ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT", "0")
+ monkeypatch.setenv("ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT", "0")
+ monkeypatch.setenv("ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT", "0")
+ monkeypatch.setenv("ALPHAX_PAPER_PAUSE_AFTER_WEAK_ENTRIES", "0")
+
+
@pytest.fixture
def buy_now_rec(monkeypatch):
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
@@ -311,6 +325,118 @@ def test_wait_pullback_too_far_from_entry_does_not_create_order(monkeypatch):
assert list_paper_orders()["total"] == 0
+def test_wait_pullback_too_close_to_entry_does_not_create_order(monkeypatch):
+ monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
+ monkeypatch.setenv("ALPHAX_PAPER_ORDER_MIN_DISTANCE_TO_ENTRY_PCT", "1.5")
+ altcoin_db.init_db()
+ rec_id = altcoin_db.create_recommendation(
+ symbol="CLOSEWAIT/USDT",
+ rec_state="蓄力",
+ rec_score=60,
+ entry_price=99,
+ stop_loss=95,
+ tp1=107,
+ signals=["等待回踩"],
+ entry_plan={"entry_action": "等回踩", "entry_price": 99, "stop_loss": 95, "tp1": 107, "risk_reward_ok": True, "rr1": 2.0},
+ )
+ rec = {"id": rec_id, "symbol": "CLOSEWAIT/USDT", "execution_status": "wait_pullback", "action_status": "等回踩", "entry_price": 99, "stop_loss": 95, "tp1": 107, "entry_plan": {"entry_action": "等回踩", "entry_price": 99, "stop_loss": 95, "tp1": 107, "risk_reward_ok": True, "rr1": 2.0}}
+
+ result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
+
+ assert result["reason"] == "paper_order_gate_rejected"
+ assert "too_close_to_entry" in result["gate_reasons"]
+ assert list_paper_orders()["total"] == 0
+
+
+def test_buy_now_rejects_large_leveraged_stop_loss(monkeypatch):
+ monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
+ monkeypatch.setenv("ALPHAX_PAPER_ENTRY_GATE_ENABLED", "1")
+ monkeypatch.setenv("ALPHAX_PAPER_TRADE_LEVERAGE", "5")
+ monkeypatch.setenv("ALPHAX_PAPER_ENTRY_MIN_REC_SCORE", "50")
+ monkeypatch.setenv("ALPHAX_PAPER_ENTRY_MIN_RR", "1.8")
+ monkeypatch.setenv("ALPHAX_PAPER_MAX_STOP_LOSS_LEVERAGE_RISK_PCT", "20")
+ altcoin_db.init_db()
+ rec_id = altcoin_db.create_recommendation(
+ symbol="WIDESTOP/USDT",
+ rec_state="爆发",
+ rec_score=60,
+ entry_price=100,
+ stop_loss=90,
+ tp1=120,
+ signals=["当前15min即刻入场信号"],
+ entry_plan={"entry_action": "可即刻买入", "entry_price": 100, "stop_loss": 90, "tp1": 120, "risk_reward_ok": True, "rr1": 2.0, "entry_trigger_confirmed": True},
+ )
+ rec = next(r for r in altcoin_db.get_active_recommendations_deduped(actionable_only=False) if r["id"] == rec_id)
+
+ result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
+
+ assert result["reason"] == "entry_gate_rejected"
+ assert "stop_loss_leverage_risk_exceeded" in result["gate_reasons"]
+ assert list_paper_trades()["total"] == 0
+
+
+def test_buy_now_pauses_when_portfolio_drawdown_exceeded(monkeypatch, buy_now_rec):
+ monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
+ monkeypatch.setenv("ALPHAX_PAPER_ACCOUNT_EQUITY_USDT", "1000")
+ monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "100")
+ monkeypatch.setenv("ALPHAX_PAPER_MAX_ACCOUNT_DRAWDOWN_PAUSE_PCT", "3")
+ monkeypatch.setenv("ALPHAX_PAPER_ENTRY_GATE_ENABLED", "0")
+ altcoin_db.init_db()
+ with altcoin_db.get_conn() as conn:
+ conn.execute(
+ """
+ INSERT INTO paper_trades (
+ recommendation_id, symbol, side, status, opened_at, entry_price,
+ qty, notional_usdt, stop_loss, tp1, tp2, max_price, min_price,
+ current_price, pnl_pct, source_status, source_action,
+ strategy_version, created_at, updated_at, margin_usdt, leverage
+ ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
+ """,
+ (
+ 9001,
+ "DRAWDOWN/USDT",
+ "long",
+ "open",
+ "2026-05-16T09:00:00",
+ 100,
+ 1,
+ 100,
+ 1,
+ 120,
+ 130,
+ 100,
+ 60,
+ 60,
+ -40,
+ "test",
+ "test",
+ "",
+ "2026-05-16T09:00:00",
+ "2026-05-16T09:01:00",
+ 20,
+ 5,
+ ),
+ )
+ conn.commit()
+ altcoin_db.init_db()
+ rec_id = altcoin_db.create_recommendation(
+ symbol="PAUSE/USDT",
+ rec_state="爆发",
+ rec_score=80,
+ entry_price=100,
+ stop_loss=95,
+ tp1=110,
+ signals=["当前15min即刻入场信号"],
+ entry_plan={"entry_action": "可即刻买入", "entry_trigger_confirmed": True, "risk_reward_ok": True, "rr1": 2.0},
+ )
+ rec = next(r for r in altcoin_db.get_active_recommendations_deduped(actionable_only=False) if r["id"] == rec_id)
+
+ result = sync_recommendation(rec, 100, event_time="2026-05-16T10:02:00")
+
+ assert result["reason"] == "portfolio_drawdown_pause"
+ assert list_paper_trades(status="open")["total"] == 1
+
+
def test_buy_now_rejects_when_cumulative_leverage_exceeded(monkeypatch):
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
monkeypatch.setenv("ALPHAX_PAPER_ACCOUNT_EQUITY_USDT", "100")
@@ -796,3 +922,67 @@ def test_disabled_paper_trading_skips_without_writing(monkeypatch, buy_now_rec):
assert result["skipped"] is True
assert result["reason"] == "disabled"
assert list_paper_trades()["total"] == 0
+
+
+def test_delete_paper_trade_removes_related_events(buy_now_rec):
+ sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
+ trade = list_paper_trades()["items"][0]
+ assert list_paper_trade_events()["total"] == 1
+
+ result = delete_paper_trade(trade["id"])
+
+ assert result["deleted"] is True
+ assert result["deleted_events"] == 1
+ assert list_paper_trades()["total"] == 0
+ assert list_paper_trade_events()["total"] == 0
+
+
+def test_delete_paper_order_removes_order_only(monkeypatch):
+ monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
+ altcoin_db.init_db()
+ rec_id = altcoin_db.create_recommendation(
+ symbol="DELORD/USDT",
+ rec_state="蓄力",
+ rec_score=22,
+ entry_price=95,
+ stop_loss=90,
+ tp1=105,
+ signals=["等待回踩"],
+ entry_plan={"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0},
+ )
+ rec = {"id": rec_id, "symbol": "DELORD/USDT", "execution_status": "wait_pullback", "action_status": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "entry_plan": {"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0}}
+ sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
+ order = list_paper_orders()["items"][0]
+
+ result = delete_paper_order(order["id"])
+
+ assert result["deleted"] is True
+ assert list_paper_orders()["total"] == 0
+
+
+def test_reset_paper_trading_data_all_clears_ledger(monkeypatch, buy_now_rec):
+ monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
+ sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00")
+ altcoin_db.init_db()
+ rec_id = altcoin_db.create_recommendation(
+ symbol="RESETORD/USDT",
+ rec_state="蓄力",
+ rec_score=22,
+ entry_price=95,
+ stop_loss=90,
+ tp1=105,
+ signals=["等待回踩"],
+ entry_plan={"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0},
+ )
+ rec = {"id": rec_id, "symbol": "RESETORD/USDT", "execution_status": "wait_pullback", "action_status": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "entry_plan": {"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0}}
+ sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
+
+ result = reset_paper_trading_data("all")
+
+ assert result["reset"] is True
+ assert result["deleted"]["trades"] == 1
+ assert result["deleted"]["orders"] == 1
+ assert result["deleted"]["events"] == 1
+ assert list_paper_trades()["total"] == 0
+ assert list_paper_orders()["total"] == 0
+ assert list_paper_trade_events()["total"] == 0