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+' 页'} 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