From a29298284560b9483d6afbf4ad719b7a91fa59d0 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Sat, 16 May 2026 21:47:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=A8=A1=E6=8B=9F=E4=BA=A4?= =?UTF-8?q?=E6=98=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/cli.py | 7 +- app/db/altcoin_db.py | 16 +- app/db/analytics.py | 149 ++++-- app/db/migrations/0004_paper_trading.sql | 50 ++ .../0005_paper_trading_leverage.sql | 15 + .../0006_paper_trading_margin_model.sql | 14 + app/db/paper_trading.py | 448 ++++++++++++++++++ app/db/scheduler_db.py | 10 + app/services/paper_trader.py | 83 ++++ app/services/price_tracker.py | 24 +- app/web/routes_pages.py | 7 + app/web/routes_paper_trading.py | 24 + app/web/routes_recommendations.py | 10 +- app/web/web_server.py | 2 + rules.yaml | 8 +- static/app.html | 115 +++-- static/base.html | 14 +- static/iteration.html | 8 +- static/paper_trading.html | 74 +++ static/sentiment.html | 56 --- static/subscription.html | 54 --- tests/conftest.py | 2 + tests/test_base_shell_ownership.py | 32 ++ tests/test_paper_trading.py | 144 ++++++ tests/test_price_tracker_watch_only_guard.py | 50 ++ tests/test_recommendation_archive_filter.py | 112 +++++ tests/test_tracker_terminal_action_guard.py | 26 + 27 files changed, 1354 insertions(+), 200 deletions(-) create mode 100644 app/db/migrations/0004_paper_trading.sql create mode 100644 app/db/migrations/0005_paper_trading_leverage.sql create mode 100644 app/db/migrations/0006_paper_trading_margin_model.sql create mode 100644 app/db/paper_trading.py create mode 100644 app/services/paper_trader.py create mode 100644 app/web/routes_paper_trading.py create mode 100644 static/paper_trading.html create mode 100644 tests/test_base_shell_ownership.py create mode 100644 tests/test_paper_trading.py create mode 100644 tests/test_price_tracker_watch_only_guard.py create mode 100644 tests/test_recommendation_archive_filter.py diff --git a/app/cli.py b/app/cli.py index 33f744a..26e879c 100644 --- a/app/cli.py +++ b/app/cli.py @@ -3,7 +3,7 @@ import argparse import sys -from app.services import altcoin_confirm, altcoin_screener, event_driven_screener, onchain_monitor, price_tracker, review_engine, sentiment_monitor +from app.services import altcoin_confirm, altcoin_screener, event_driven_screener, onchain_monitor, paper_trader, price_tracker, review_engine, sentiment_monitor def build_parser(): @@ -18,6 +18,9 @@ def build_parser(): tracker = subparsers.add_parser("tracker", help="运行价格跟踪") + paper = subparsers.add_parser("paper-trader", help="运行模拟交易账本同步") + paper.add_argument("--limit", type=int, default=100, help="本轮最多处理的可执行推荐数量") + review = subparsers.add_parser("review", help="运行复盘") review.add_argument("--compact", action="store_true", help="输出紧凑 JSON") review.add_argument("--no-push", action="store_true", help="只运行复盘,不发飞书") @@ -50,6 +53,8 @@ def main(): return altcoin_confirm.main(compact=args.compact) if args.command == "tracker": return price_tracker.main() + if args.command == "paper-trader": + return paper_trader.main(limit=args.limit) if args.command == "review": return review_engine.run_review(push_enabled=not args.no_push, compact=args.compact) if args.command == "event": diff --git a/app/db/altcoin_db.py b/app/db/altcoin_db.py index 2f6b4b2..aa02017 100644 --- a/app/db/altcoin_db.py +++ b/app/db/altcoin_db.py @@ -277,7 +277,11 @@ def update_recommendation_tracking(rec_id, current_price): 这样 TP1 后继续推高的收益会继续计入 current/max_pnl。 """ conn = get_conn() - row = conn.execute("SELECT entry_price, max_price, min_price, symbol FROM recommendation WHERE id=%s", (rec_id,)).fetchone() + row = conn.execute(""" + SELECT entry_price, max_price, min_price, symbol, status, action_status, + execution_status, display_bucket, entry_triggered + FROM recommendation WHERE id=%s + """, (rec_id,)).fetchone() if not row: conn.close() return @@ -292,20 +296,26 @@ def update_recommendation_tracking(rec_id, current_price): max_pnl_pct = round((new_max / entry_price - 1) * 100, 2) max_drawdown_pct = round((new_min / entry_price - 1) * 100, 2) + is_executed = ( + int(row["entry_triggered"] or 0) == 1 + or row["display_bucket"] == "position" + or row["execution_status"] in ("holding", "completed") + or is_executed_lifecycle(row["status"], row["action_status"], row["execution_status"]) + ) status = "active" tp1_reached = False rec = conn.execute("SELECT stop_loss, tp1, tp2, status, hit_tp1_time FROM recommendation WHERE id=%s", (rec_id,)).fetchone() if rec and rec["status"] == "active": if rec["tp2"] and current_price >= rec["tp2"]: status = "hit_tp2" - elif rec["stop_loss"] and current_price <= rec["stop_loss"]: - status = "stopped_out" elif rec["tp1"] and current_price >= rec["tp1"]: status = "hit_tp1" tp1_reached = True elif rec["tp1"] == 0 and pnl_pct >= 15: status = "hit_tp1" tp1_reached = True + elif is_executed and rec["stop_loss"] and current_price <= rec["stop_loss"]: + status = "stopped_out" now = datetime.now().isoformat() if status != "active": diff --git a/app/db/analytics.py b/app/db/analytics.py index eaa05a4..57a002d 100644 --- a/app/db/analytics.py +++ b/app/db/analytics.py @@ -186,7 +186,59 @@ def get_observation_candidates(limit=50): } -def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, with_meta=False): +def _archive_filter_where(archive_filter): + archive_filter = str(archive_filter or "").strip() + if archive_filter == "executed": + return " AND EXISTS (SELECT 1 FROM paper_trades ptf WHERE ptf.recommendation_id = recommendation.id)" + if archive_filter == "invalid": + return """ + AND NOT EXISTS (SELECT 1 FROM paper_trades ptf WHERE ptf.recommendation_id = recommendation.id) + AND ( + status IN ('expired','invalid','archived','stopped_out') + OR COALESCE(execution_status, '') = 'invalid' + ) + """ + return "" + + +def _attach_paper_trade(item): + paper_id = item.get("paper_trade_id") + if not paper_id: + item["paper_trade"] = None + item["paper_trade_executed"] = False + item["paper_trade_status"] = "" + return item + + paper = { + "id": paper_id, + "recommendation_id": item.get("paper_recommendation_id") or item.get("id"), + "symbol": item.get("paper_symbol") or item.get("symbol"), + "status": item.get("paper_status") or "", + "opened_at": item.get("paper_opened_at") or "", + "closed_at": item.get("paper_closed_at") or "", + "entry_price": item.get("paper_entry_price") or 0, + "exit_price": item.get("paper_exit_price") or 0, + "current_price": item.get("paper_current_price") or 0, + "stop_loss": item.get("paper_stop_loss") or 0, + "tp1": item.get("paper_tp1") or 0, + "tp2": item.get("paper_tp2") or 0, + "trailing_stop": item.get("paper_trailing_stop") or 0, + "max_price": item.get("paper_max_price") or 0, + "min_price": item.get("paper_min_price") or 0, + "pnl_pct": item.get("paper_pnl_pct") or 0, + "realized_pnl_pct": item.get("paper_realized_pnl_pct") or 0, + "realized_pnl_usdt": item.get("paper_realized_pnl_usdt") or 0, + "exit_reason": item.get("paper_exit_reason") or "", + "updated_at": item.get("paper_updated_at") or "", + } + item["paper_trade"] = paper + item["paper_trade_executed"] = True + item["paper_trade_status"] = paper["status"] + item["paper_trade_closed"] = paper["status"] == "closed" + return item + + +def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, with_meta=False, archive_filter=""): """获取推荐列表。""" conn = get_conn() version = str(version or "").strip() @@ -199,18 +251,15 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, except Exception: offset = 0 - result_where = EXECUTED_TRADE_WHERE + archive_where = "(status != 'active' OR COALESCE(display_bucket, '') = 'history' OR COALESCE(execution_status, '') IN ('invalid','completed'))" + archive_filter_where = _archive_filter_where(archive_filter) + filtered_archive_where = archive_where + archive_filter_where version_where = " AND strategy_version=%s" if version else "" params = [version] if version else [] total = None summary = None version_counts = [] - realized_pnl_case = ( - f"CASE WHEN {FAILURE_CASE} THEN COALESCE(pnl_pct,0) " - f"WHEN {SUCCESS_CASE} THEN COALESCE(NULLIF(max_pnl_pct,0), pnl_pct, 0) " - "ELSE 0 END" - ) if decision_only: if with_meta: @@ -220,7 +269,7 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, SELECT symbol FROM recommendation WHERE """ - + result_where + + filtered_archive_where + version_where + """ GROUP BY symbol @@ -233,41 +282,57 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, """ SELECT COUNT(*) AS total, - SUM(CASE WHEN """ - + SUCCESS_CASE - + """ THEN 1 ELSE 0 END) AS success_count, - SUM(CASE WHEN """ - + FAILURE_CASE - + """ THEN 1 ELSE 0 END) AS failure_count, - SUM(""" - + realized_pnl_case - + """) AS total_pnl, - MAX(""" - + realized_pnl_case - + """) AS best_pnl, - AVG(CASE WHEN """ - + FAILURE_CASE - + """ THEN COALESCE(pnl_pct,0) END) AS avg_failure_pnl + SUM(CASE WHEN paper_trade_id IS NOT NULL THEN 1 ELSE 0 END) AS executed_count, + SUM(CASE WHEN paper_trade_closed = TRUE THEN 1 ELSE 0 END) AS completed_count, + SUM(CASE WHEN paper_trade_id IS NULL AND (status IN ('expired','invalid','archived','stopped_out') OR COALESCE(execution_status,'')='invalid') THEN 1 ELSE 0 END) AS invalid_count, + SUM(CASE WHEN paper_trade_id IS NULL THEN 1 ELSE 0 END) AS not_executed_count, + SUM(CASE WHEN status IN ('hit_tp1','hit_tp2') THEN 1 ELSE 0 END) AS legacy_success_count, + SUM(CASE WHEN status='stopped_out' THEN 1 ELSE 0 END) AS legacy_failure_count, + SUM(CASE + WHEN status='stopped_out' THEN COALESCE(pnl_pct,0) + WHEN status IN ('hit_tp1','hit_tp2') THEN COALESCE(NULLIF(max_pnl_pct,0), pnl_pct, 0) + ELSE 0 + END) AS legacy_total_pnl, + MAX(CASE + WHEN status='stopped_out' THEN COALESCE(pnl_pct,0) + WHEN status IN ('hit_tp1','hit_tp2') THEN COALESCE(NULLIF(max_pnl_pct,0), pnl_pct, 0) + ELSE 0 + END) AS legacy_best_pnl, + AVG(CASE WHEN status='stopped_out' THEN COALESCE(pnl_pct,0) END) AS legacy_avg_failure_pnl FROM ( - SELECT r.* + SELECT r.*, + pt.id AS paper_trade_id, + pt.status AS paper_trade_status, + CASE WHEN pt.status='closed' THEN TRUE ELSE FALSE END AS paper_trade_closed FROM recommendation r JOIN ( SELECT symbol, MAX(id) AS max_id FROM recommendation WHERE """ - + result_where + + archive_where + version_where + """ GROUP BY symbol ) latest ON latest.max_id = r.id - ) + LEFT JOIN paper_trades pt ON pt.recommendation_id = r.id + ) x """, tuple(params), ).fetchone() summary = dict(summary_row) if summary_row else {} - for key in ("total", "success_count", "failure_count", "total_pnl", "best_pnl", "avg_failure_pnl"): + for key in ( + "total", "executed_count", "completed_count", "invalid_count", "not_executed_count", + "legacy_success_count", "legacy_failure_count", "legacy_total_pnl", "legacy_best_pnl", "legacy_avg_failure_pnl", + ): if summary.get(key) is None: summary[key] = 0 + # Backward-compatible placeholders. The recommendation archive no + # longer treats signal history as trade PnL; paper_trades owns PnL. + summary["success_count"] = summary.get("legacy_success_count", 0) + summary["failure_count"] = summary.get("legacy_failure_count", 0) + summary["total_pnl"] = summary.get("legacy_total_pnl", 0) + summary["best_pnl"] = summary.get("legacy_best_pnl", 0) + summary["avg_failure_pnl"] = summary.get("legacy_avg_failure_pnl", 0) vc_rows = conn.execute( """ @@ -277,7 +342,7 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, SELECT symbol, MAX(id) AS max_id FROM recommendation WHERE """ - + result_where + + archive_where + """ GROUP BY symbol ) latest ON latest.max_id = r.id @@ -289,16 +354,37 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, rows = conn.execute( """ - SELECT r.*, + SELECT r.*, lpc.price AS latest_cache_price, - lpc.updated_at AS latest_cache_updated_at + lpc.updated_at AS latest_cache_updated_at, + pt.id AS paper_trade_id, + pt.recommendation_id AS paper_recommendation_id, + pt.symbol AS paper_symbol, + pt.status AS paper_status, + pt.opened_at AS paper_opened_at, + pt.closed_at AS paper_closed_at, + pt.entry_price AS paper_entry_price, + pt.exit_price AS paper_exit_price, + pt.current_price AS paper_current_price, + pt.stop_loss AS paper_stop_loss, + pt.tp1 AS paper_tp1, + pt.tp2 AS paper_tp2, + pt.trailing_stop AS paper_trailing_stop, + pt.max_price AS paper_max_price, + pt.min_price AS paper_min_price, + pt.pnl_pct AS paper_pnl_pct, + pt.realized_pnl_pct AS paper_realized_pnl_pct, + pt.realized_pnl_usdt AS paper_realized_pnl_usdt, + pt.exit_reason AS paper_exit_reason, + pt.updated_at AS paper_updated_at FROM recommendation r LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol + LEFT JOIN paper_trades pt ON pt.recommendation_id = r.id JOIN ( SELECT symbol, MAX(id) AS max_id FROM recommendation WHERE """ - + result_where + + filtered_archive_where + version_where + """ GROUP BY symbol @@ -330,6 +416,7 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, item["recommendation_result"] = rec_result item["recommendation_result_label"] = rec_result_label _derive_execution_fields(item) + _attach_paper_trade(item) result.append(item) if not with_meta: diff --git a/app/db/migrations/0004_paper_trading.sql b/app/db/migrations/0004_paper_trading.sql new file mode 100644 index 0000000..a187c8d --- /dev/null +++ b/app/db/migrations/0004_paper_trading.sql @@ -0,0 +1,50 @@ +CREATE TABLE IF NOT EXISTS paper_trades ( + id BIGSERIAL PRIMARY KEY, + recommendation_id BIGINT NOT NULL UNIQUE, + symbol TEXT NOT NULL, + side TEXT NOT NULL DEFAULT 'long', + status TEXT NOT NULL DEFAULT 'open', + opened_at TEXT NOT NULL, + closed_at TEXT DEFAULT '', + entry_price DOUBLE PRECISION NOT NULL, + exit_price DOUBLE PRECISION DEFAULT 0, + qty DOUBLE PRECISION NOT NULL, + notional_usdt DOUBLE PRECISION NOT NULL, + stop_loss DOUBLE PRECISION DEFAULT 0, + tp1 DOUBLE PRECISION DEFAULT 0, + tp2 DOUBLE PRECISION DEFAULT 0, + trailing_stop DOUBLE PRECISION DEFAULT 0, + max_price DOUBLE PRECISION DEFAULT 0, + min_price DOUBLE PRECISION DEFAULT 0, + current_price DOUBLE PRECISION DEFAULT 0, + pnl_pct DOUBLE PRECISION DEFAULT 0, + realized_pnl_pct DOUBLE PRECISION DEFAULT 0, + realized_pnl_usdt DOUBLE PRECISION DEFAULT 0, + fee_usdt DOUBLE PRECISION DEFAULT 0, + exit_reason TEXT DEFAULT '', + source_status TEXT DEFAULT '', + source_action TEXT DEFAULT '', + strategy_version TEXT DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_paper_trades_status_updated ON paper_trades(status, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_paper_trades_symbol_opened ON paper_trades(symbol, opened_at DESC); +CREATE INDEX IF NOT EXISTS idx_paper_trades_recommendation ON paper_trades(recommendation_id); + +CREATE TABLE IF NOT EXISTS paper_trade_events ( + id BIGSERIAL PRIMARY KEY, + trade_id BIGINT NOT NULL, + recommendation_id BIGINT NOT NULL, + symbol TEXT NOT NULL, + event_type TEXT NOT NULL, + event_time TEXT NOT NULL, + price DOUBLE PRECISION DEFAULT 0, + pnl_pct DOUBLE PRECISION DEFAULT 0, + message TEXT DEFAULT '', + detail_json TEXT DEFAULT '{}' +); + +CREATE INDEX IF NOT EXISTS idx_paper_trade_events_trade_time ON paper_trade_events(trade_id, event_time DESC); +CREATE INDEX IF NOT EXISTS idx_paper_trade_events_symbol_time ON paper_trade_events(symbol, event_time DESC); diff --git a/app/db/migrations/0005_paper_trading_leverage.sql b/app/db/migrations/0005_paper_trading_leverage.sql new file mode 100644 index 0000000..9fc7af0 --- /dev/null +++ b/app/db/migrations/0005_paper_trading_leverage.sql @@ -0,0 +1,15 @@ +ALTER TABLE paper_trades + ADD COLUMN IF NOT EXISTS margin_usdt DOUBLE PRECISION DEFAULT 0; + +ALTER TABLE paper_trades + ADD COLUMN IF NOT EXISTS leverage DOUBLE PRECISION DEFAULT 5; + +UPDATE paper_trades +SET leverage = 5 +WHERE leverage IS NULL OR leverage <= 0; + +UPDATE paper_trades +SET margin_usdt = CASE + WHEN margin_usdt IS NULL OR margin_usdt <= 0 THEN COALESCE(notional_usdt, 0) / NULLIF(leverage, 0) + ELSE margin_usdt +END; diff --git a/app/db/migrations/0006_paper_trading_margin_model.sql b/app/db/migrations/0006_paper_trading_margin_model.sql new file mode 100644 index 0000000..b0f4db0 --- /dev/null +++ b/app/db/migrations/0006_paper_trading_margin_model.sql @@ -0,0 +1,14 @@ +UPDATE paper_trades +SET leverage = 5 +WHERE leverage IS NULL OR leverage <= 1; + +UPDATE paper_trades +SET margin_usdt = COALESCE(notional_usdt, 0) / NULLIF(leverage, 0) +WHERE notional_usdt IS NOT NULL + AND leverage IS NOT NULL + AND leverage > 0 + AND ( + margin_usdt IS NULL + OR margin_usdt <= 0 + OR margin_usdt >= notional_usdt + ); diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py new file mode 100644 index 0000000..ea20790 --- /dev/null +++ b/app/db/paper_trading.py @@ -0,0 +1,448 @@ +"""Paper trading ledger for separating signal quality from trade PnL.""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timedelta + +from app.db.schema import get_conn + + +def _now() -> str: + return datetime.now().isoformat() + + +def _safe_float(value, default: float = 0.0) -> float: + try: + if value is None or value == "": + return default + return float(value) + except Exception: + return default + + +def _safe_int(value, default: int = 0) -> int: + try: + return int(value or 0) + except Exception: + return default + + +def paper_trading_enabled() -> bool: + return os.getenv("ALPHAX_PAPER_TRADING_ENABLED", "1").strip().lower() not in {"0", "false", "no", "off"} + + +def default_account_equity_usdt() -> float: + return max(1.0, _safe_float(os.getenv("ALPHAX_PAPER_ACCOUNT_EQUITY_USDT", "20000"), 20000.0)) + + +def default_leverage() -> float: + return max(1.0, _safe_float(os.getenv("ALPHAX_PAPER_TRADE_LEVERAGE", "5"), 5.0)) + + +def default_notional_usdt() -> float: + return max(1.0, _safe_float(os.getenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "5000"), 5000.0)) + + +def default_margin_usdt() -> float: + return round(default_notional_usdt() / default_leverage(), 8) + + +def default_fee_rate() -> float: + return max(0.0, _safe_float(os.getenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0.001"), 0.001)) + + +def default_slippage_pct() -> float: + return max(0.0, _safe_float(os.getenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0.05"), 0.05)) + + +def _loads_json(value, fallback=None): + try: + if isinstance(value, str) and value.strip(): + return json.loads(value) + if value: + return value + except Exception: + pass + return fallback if fallback is not None else {} + + +def _entry_plan(rec: dict) -> dict: + plan = rec.get("entry_plan") + if isinstance(plan, dict): + return plan + return _loads_json(rec.get("entry_plan_json"), {}) + + +def _open_price(current_price: float) -> float: + return round(current_price * (1 + default_slippage_pct() / 100), 12) + + +def _close_price(current_price: float) -> float: + return round(current_price * (1 - default_slippage_pct() / 100), 12) + + +def _trade_pnl_pct(entry_price: float, current_price: float) -> float: + if entry_price <= 0 or current_price <= 0: + return 0.0 + return round((current_price / entry_price - 1) * 100, 4) + + +def _account_return_pct(pnl_usdt: float, account_equity: float | None = None) -> float: + equity = max(1.0, _safe_float(account_equity, default_account_equity_usdt())) + return round(_safe_float(pnl_usdt) / equity * 100, 4) + + +def _margin_roi_pct(pnl_usdt: float, margin_usdt: float) -> float: + margin = max(1.0, _safe_float(margin_usdt, default_margin_usdt())) + return round(_safe_float(pnl_usdt) / margin * 100, 4) + + +def _trade_margin(trade: dict) -> float: + margin = _safe_float(trade.get("margin_usdt")) + if margin > 0: + return margin + leverage = max(1.0, _safe_float(trade.get("leverage"), default_leverage())) + return round(_safe_float(trade.get("notional_usdt")) / leverage, 8) + + +def _decorate_trade(trade: dict) -> dict: + item = dict(trade) + notional = _safe_float(item.get("notional_usdt"), default_notional_usdt()) + leverage = max(1.0, _safe_float(item.get("leverage"), default_leverage())) + margin = _trade_margin({"margin_usdt": item.get("margin_usdt"), "notional_usdt": notional, "leverage": leverage}) + unrealized = round(notional * _safe_float(item.get("pnl_pct")) / 100, 8) + realized = _safe_float(item.get("realized_pnl_usdt")) + effective_pnl = realized if item.get("status") == "closed" else unrealized + item["notional_usdt"] = notional + item["leverage"] = leverage + item["margin_usdt"] = margin + item["unrealized_pnl_usdt"] = unrealized + item["margin_roi_pct"] = _margin_roi_pct(effective_pnl, margin) + item["account_return_pct"] = _account_return_pct(effective_pnl) + item["account_equity_usdt"] = default_account_equity_usdt() + return item + + +def _record_event(conn, trade_id: int, rec_id: int, symbol: str, event_type: str, price: float, pnl_pct: float, message: str, detail=None, event_time: str = ""): + conn.execute( + """ + INSERT INTO paper_trade_events ( + trade_id, recommendation_id, symbol, event_type, event_time, + price, pnl_pct, message, detail_json + ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) + """, + ( + trade_id, + rec_id, + symbol, + event_type, + event_time or _now(), + price, + pnl_pct, + message, + json.dumps(detail or {}, ensure_ascii=False, default=str), + ), + ) + + +def _open_trade(conn, rec: dict, current_price: float, event_time: str) -> dict: + rec_id = _safe_int(rec.get("id")) + symbol = str(rec.get("symbol") or "").strip().upper() + plan = _entry_plan(rec) + entry_price = _open_price(current_price) + notional = default_notional_usdt() + leverage = default_leverage() + margin = default_margin_usdt() + 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(), 8) + now = event_time or _now() + row = conn.execute( + """ + INSERT INTO paper_trades ( + recommendation_id, symbol, side, status, opened_at, + entry_price, qty, notional_usdt, margin_usdt, leverage, stop_loss, tp1, tp2, + max_price, min_price, current_price, pnl_pct, fee_usdt, + source_status, source_action, strategy_version, created_at, updated_at + ) VALUES (%s,%s,'long','open',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,%s,%s,%s,%s,%s,%s) + ON CONFLICT(recommendation_id) DO NOTHING + RETURNING id + """, + ( + rec_id, + symbol, + now, + entry_price, + qty, + notional, + margin, + leverage, + stop_loss, + tp1, + tp2, + entry_price, + entry_price, + entry_price, + fee, + rec.get("execution_status") or "", + rec.get("action_status") or "", + rec.get("strategy_version") or "", + now, + now, + ), + ).fetchone() + if not row: + return {"opened": False, "reason": "already_exists"} + trade_id = row["id"] + _record_event( + conn, + trade_id, + rec_id, + symbol, + "open", + entry_price, + 0.0, + "模拟交易开仓:仅用于策略收益验证,不代表真实成交", + { + "notional_usdt": notional, + "margin_usdt": margin, + "leverage": leverage, + "qty": qty, + "fee_usdt": fee, + "slippage_pct": default_slippage_pct(), + "source_status": rec.get("execution_status") or "", + "source_action": rec.get("action_status") or "", + }, + now, + ) + return { + "opened": True, + "trade_id": trade_id, + "entry_price": entry_price, + "qty": qty, + "notional_usdt": notional, + "margin_usdt": margin, + "leverage": leverage, + } + + +def _close_trade(conn, trade: dict, current_price: float, reason: str, event_time: str) -> dict: + entry_price = _safe_float(trade.get("entry_price")) + exit_price = _close_price(current_price) + pnl_pct = _trade_pnl_pct(entry_price, exit_price) + notional = _safe_float(trade.get("notional_usdt")) + open_fee = _safe_float(trade.get("fee_usdt")) + close_fee = round(notional * default_fee_rate(), 8) + total_fee = round(open_fee + close_fee, 8) + pnl_usdt = round(notional * pnl_pct / 100 - total_fee, 8) + now = event_time or _now() + conn.execute( + """ + UPDATE paper_trades + SET status='closed', + closed_at=%s, + exit_price=%s, + current_price=%s, + pnl_pct=%s, + realized_pnl_pct=%s, + realized_pnl_usdt=%s, + fee_usdt=%s, + exit_reason=%s, + updated_at=%s + WHERE id=%s AND status='open' + """, + ( + now, + exit_price, + exit_price, + pnl_pct, + pnl_pct, + pnl_usdt, + total_fee, + reason, + now, + trade["id"], + ), + ) + _record_event( + conn, + trade["id"], + trade["recommendation_id"], + trade["symbol"], + "close", + exit_price, + pnl_pct, + f"模拟交易平仓:{reason}", + {"realized_pnl_usdt": pnl_usdt, "fee_usdt": total_fee}, + now, + ) + return {"closed": True, "trade_id": trade["id"], "exit_reason": reason, "pnl_pct": pnl_pct, "pnl_usdt": pnl_usdt} + + +def _update_open_trade(conn, trade: dict, current_price: float, event_time: str) -> dict: + entry_price = _safe_float(trade.get("entry_price")) + old_max = _safe_float(trade.get("max_price")) or entry_price + old_min = _safe_float(trade.get("min_price")) or entry_price + new_max = max(old_max, current_price) + new_min = min(old_min, current_price) + pnl_pct = _trade_pnl_pct(entry_price, current_price) + stop_loss = _safe_float(trade.get("stop_loss")) + tp2 = _safe_float(trade.get("tp2")) + tp1 = _safe_float(trade.get("tp1")) + reason = "" + if stop_loss > 0 and current_price <= stop_loss: + reason = "stop_loss" + elif tp2 > 0 and current_price >= tp2: + reason = "tp2" + elif tp1 > 0 and current_price >= tp1: + reason = "tp1" + + if reason: + return _close_trade(conn, trade, current_price, reason, event_time) + + conn.execute( + """ + UPDATE paper_trades + SET current_price=%s, + max_price=%s, + min_price=%s, + pnl_pct=%s, + updated_at=%s + WHERE id=%s AND status='open' + """, + (current_price, new_max, new_min, pnl_pct, event_time or _now(), trade["id"]), + ) + return {"updated": True, "trade_id": trade["id"], "pnl_pct": pnl_pct} + + +def sync_recommendation(rec: dict, current_price: float, event_time: str = "") -> dict: + """Open/update paper trade for one recommendation. + + This is intentionally independent from recommendation PnL fields. A + recommendation can be a signal; only this ledger represents simulated + execution. + """ + if not paper_trading_enabled(): + return {"enabled": False, "skipped": True, "reason": "disabled"} + rec_id = _safe_int(rec.get("id")) + symbol = str(rec.get("symbol") or "").strip().upper() + current_price = _safe_float(current_price) + if rec_id <= 0 or not symbol or current_price <= 0: + return {"enabled": True, "skipped": True, "reason": "invalid_input"} + execution_status = str(rec.get("execution_status") or "").strip() + action_status = str(rec.get("action_status") or "").strip() + event_time = event_time or _now() + + conn = get_conn() + try: + trade = conn.execute("SELECT * FROM paper_trades WHERE recommendation_id=%s", (rec_id,)).fetchone() + if trade: + trade = dict(trade) + if trade.get("status") == "open": + result = _update_open_trade(conn, trade, current_price, event_time) + conn.commit() + return result + conn.close() + return {"skipped": True, "reason": "already_closed", "trade_id": trade.get("id")} + + if execution_status != "buy_now" and action_status != "可即刻买入": + conn.close() + return {"skipped": True, "reason": "not_buy_now"} + result = _open_trade(conn, rec, current_price, event_time) + conn.commit() + return result + except Exception: + conn.rollback() + raise + finally: + try: + conn.close() + except Exception: + pass + + +def get_paper_trading_summary(days: int = 30) -> dict: + days = max(1, min(_safe_int(days, 30), 365)) + cutoff = (datetime.now() - timedelta(days=days)).isoformat() + conn = get_conn() + try: + rows = conn.execute( + """ + SELECT * FROM paper_trades + WHERE opened_at >= %s + ORDER BY opened_at DESC, id DESC + """, + (cutoff,), + ).fetchall() + finally: + conn.close() + items = [_decorate_trade(dict(r)) for r in rows] + open_items = [x for x in items if x.get("status") == "open"] + closed_items = [x for x in items if x.get("status") == "closed"] + wins = [x for x in closed_items if _safe_float(x.get("realized_pnl_pct")) > 0] + losses = [x for x in closed_items if _safe_float(x.get("realized_pnl_pct")) <= 0] + total_realized = round(sum(_safe_float(x.get("realized_pnl_usdt")) for x in closed_items), 4) + avg_realized_pct = round(sum(_safe_float(x.get("realized_pnl_pct")) for x in closed_items) / len(closed_items), 4) if closed_items else 0 + open_unrealized = round(sum(_safe_float(x.get("unrealized_pnl_usdt")) for x in open_items), 4) + total_pnl = round(total_realized + open_unrealized, 4) + allocated_margin = round(sum(_safe_float(x.get("margin_usdt")) for x in open_items), 4) + return { + "days": days, + "total": len(items), + "open_count": len(open_items), + "closed_count": len(closed_items), + "win_count": len(wins), + "loss_count": len(losses), + "win_rate": round(len(wins) / len(closed_items) * 100, 2) if closed_items else 0, + "realized_pnl_usdt": total_realized, + "avg_realized_pnl_pct": avg_realized_pct, + "open_unrealized_pnl_usdt": open_unrealized, + "total_pnl_usdt": total_pnl, + "account_equity_usdt": default_account_equity_usdt(), + "account_realized_return_pct": _account_return_pct(total_realized), + "account_unrealized_return_pct": _account_return_pct(open_unrealized), + "account_total_return_pct": _account_return_pct(total_pnl), + "allocated_margin_usdt": allocated_margin, + "available_equity_usdt": round(default_account_equity_usdt() - allocated_margin, 4), + "margin_usdt": default_margin_usdt(), + "leverage": default_leverage(), + "notional_usdt": default_notional_usdt(), + "fee_rate": default_fee_rate(), + "slippage_pct": default_slippage_pct(), + } + + +def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "") -> dict: + limit = max(1, min(_safe_int(limit, 50), 200)) + offset = max(0, _safe_int(offset, 0)) + status = str(status or "").strip() + where = "" + params = [] + if status in {"open", "closed"}: + where = "WHERE status=%s" + params.append(status) + conn = get_conn() + try: + total = conn.execute(f"SELECT COUNT(*) FROM paper_trades {where}", tuple(params)).fetchone()[0] + rows = conn.execute( + f""" + SELECT * FROM paper_trades + {where} + ORDER BY opened_at DESC, id DESC + LIMIT %s OFFSET %s + """, + tuple(params + [limit, offset]), + ).fetchall() + finally: + conn.close() + return { + "items": [_decorate_trade(dict(r)) for r in rows], + "total": int(total or 0), + "limit": limit, + "offset": offset, + "has_more": offset + len(rows) < int(total or 0), + } diff --git a/app/db/scheduler_db.py b/app/db/scheduler_db.py index 81f94d8..a8cb1a5 100644 --- a/app/db/scheduler_db.py +++ b/app/db/scheduler_db.py @@ -38,6 +38,16 @@ DEFAULT_JOBS = [ "description": "推荐价格跟踪", "sort_order": 20, }, + { + "job_name": "paper-trader", + "command": "paper-trader", + "args": [], + "every_seconds": 180, + "initial_delay": 30, + "lock_group": "paper_trading_write", + "description": "模拟交易账本同步", + "sort_order": 25, + }, { "job_name": "confirm", "command": "confirm", diff --git a/app/services/paper_trader.py b/app/services/paper_trader.py new file mode 100644 index 0000000..78cda96 --- /dev/null +++ b/app/services/paper_trader.py @@ -0,0 +1,83 @@ +"""Paper trading job entrypoint.""" + +from __future__ import annotations + +import json +from datetime import datetime + +import ccxt + +from app.db.altcoin_db import init_db, log_cron_run, update_latest_price_cache +from app.db.paper_trading import get_paper_trading_summary, sync_recommendation +from app.db.recommendation_queries import get_active_recommendations_deduped + + +exchange = ccxt.binance({"enableRateLimit": True}) + + +def run_once(limit: int = 100) -> dict: + init_db() + recs = get_active_recommendations_deduped(actionable_only=True, limit=limit, with_meta=False) + results = [] + failed = [] + for rec in recs: + symbol = rec.get("symbol") + try: + ticker = exchange.fetch_ticker(symbol) + current_price = float(ticker["last"] or 0) + event_time = datetime.now().isoformat() + update_latest_price_cache(symbol, current_price, updated_at=event_time, source="paper_trader") + result = sync_recommendation(rec, current_price, event_time=event_time) + result.update({"symbol": symbol, "rec_id": rec.get("id"), "current_price": current_price}) + results.append(result) + except Exception as exc: + failed.append({"symbol": symbol, "error": str(exc)}) + output = { + "status": "completed", + "processed_count": len(results), + "failed_count": len(failed), + "failed": failed, + "results": results, + "summary": get_paper_trading_summary(days=30), + "run_time": datetime.now().isoformat(), + } + print(json.dumps(output, ensure_ascii=False, indent=2, default=str)) + return output + + +def main(limit: int = 100): + started_at = datetime.now() + try: + output = run_once(limit=limit) + except Exception as exc: + finished_at = datetime.now() + log_cron_run( + job_name="模拟交易", + script_name="paper_trader.py", + run_status="error", + result_status="exception", + started_at=started_at.isoformat(), + finished_at=finished_at.isoformat(), + duration_ms=int((finished_at - started_at).total_seconds() * 1000), + summary={}, + error_message=str(exc), + ) + raise + finished_at = datetime.now() + log_cron_run( + job_name="模拟交易", + script_name="paper_trader.py", + run_status="success", + result_status=output.get("status", "completed"), + started_at=started_at.isoformat(), + finished_at=finished_at.isoformat(), + duration_ms=int((finished_at - started_at).total_seconds() * 1000), + summary={ + "processed_count": output.get("processed_count", 0), + "failed_count": output.get("failed_count", 0), + "open_count": output.get("summary", {}).get("open_count", 0), + "closed_count": output.get("summary", {}).get("closed_count", 0), + }, + error_message="", + ) + return output diff --git a/app/services/price_tracker.py b/app/services/price_tracker.py index c4fc4d7..687ea84 100644 --- a/app/services/price_tracker.py +++ b/app/services/price_tracker.py @@ -30,6 +30,7 @@ from app.db.altcoin_db import ( apply_recommendation_state_transition, log_cron_run, update_latest_price_cache, ) +from app.db.paper_trading import sync_recommendation as sync_paper_trade from app.core.pa_engine import ( calc_atr, full_pa_analysis, detect_trend_exhaustion, analyze_entry_point, @@ -351,7 +352,7 @@ def analyze_tracking_signals(symbol, rec, current_price): def track_prices(): """拉取所有active推荐币的实时价格,更新盈亏 + 动态跟踪信号""" - recs = get_active_recommendations() + recs = get_active_recommendations(actionable_only=True) if not recs: output = { "status": "no_active", @@ -367,6 +368,21 @@ def track_prices(): for rec in recs: symbol = rec["symbol"] try: + if not rec.get("entry_triggered") and rec.get("display_bucket") != "position" and rec.get("execution_status") not in ("holding", "completed"): + results.append({ + "symbol": symbol, + "rec_id": rec["id"], + "entry_price": rec["entry_price"], + "current_price": None, + "pnl_pct": None, + "status": "skipped_watch_only", + "action_status": rec.get("action_status"), + "sell_signals": [], + "buy_signals": [], + "exhaustion_severity": "low", + }) + print(f" {symbol}: 观察池样本跳过跟踪与止盈判断") + continue ticker = exchange.fetch_ticker(symbol) current_price = ticker["last"] @@ -410,6 +426,11 @@ def track_prices(): signals=tracking_signals.get("sell_signals", []) + tracking_signals.get("buy_signals", []), ) final_action = state_decision.get("action_status", requested_action) + paper_result = sync_paper_trade( + {**rec, **state_decision, "id": rec["id"], "symbol": symbol}, + current_price, + event_time=datetime.now().isoformat(), + ) push_trade_action_update(symbol, rec["id"], state_decision, final_action, push_type="entry") if tracking_signals.get("trailing_stop_activated"): activation_decision = dict(state_decision) @@ -434,6 +455,7 @@ def track_prices(): "sell_signals": tracking_signals["sell_signals"], "buy_signals": tracking_signals["buy_signals"], "exhaustion_severity": tracking_signals.get("exhaustion", {}).get("severity", "low"), + "paper_trade": paper_result, }) print(f" {symbol}: 入场${rec['entry_price']} → 现在${current_price} " f"盈亏{tracking_signals['pnl_pct']}% 状态={track_result['status']} " diff --git a/app/web/routes_pages.py b/app/web/routes_pages.py index e481761..36396dd 100644 --- a/app/web/routes_pages.py +++ b/app/web/routes_pages.py @@ -78,6 +78,13 @@ def build_router(templates, repo_root: Path, stock_report_template: str): return HTMLResponse(content=f"

需要管理员权限

{exc.detail}

返回看板", status_code=exc.status_code) return render_page("system_logs.html", request, active_nav="system_logs") + @router.get("/paper-trading", response_class=HTMLResponse) + async def paper_trading_page(request: Request): + user, redirect = require_page_user(request) + if redirect: + return redirect + return render_page("paper_trading.html", request, active_nav="paper_trading") + @router.get("/strategy", response_class=HTMLResponse) async def strategy_page(request: Request): user, redirect = require_page_user(request) diff --git a/app/web/routes_paper_trading.py b/app/web/routes_paper_trading.py new file mode 100644 index 0000000..2203ff7 --- /dev/null +++ b/app/web/routes_paper_trading.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, Cookie + +from app.db.paper_trading import get_paper_trading_summary, list_paper_trades +from app.web.shared import require_api_user_with_subscription + + +router = APIRouter() + + +@router.get("/api/paper-trading/summary") +async def api_paper_trading_summary(days: int = 30, altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + return get_paper_trading_summary(days=days) + + +@router.get("/api/paper-trading/trades") +async def api_paper_trading_trades( + limit: int = 50, + offset: int = 0, + status: str = "", + altcoin_session: str = Cookie(default=""), +): + require_api_user_with_subscription(altcoin_session) + return list_paper_trades(limit=limit, offset=offset, status=status) diff --git a/app/web/routes_recommendations.py b/app/web/routes_recommendations.py index 4fcd901..4deaef5 100644 --- a/app/web/routes_recommendations.py +++ b/app/web/routes_recommendations.py @@ -72,12 +72,20 @@ async def api_recommendations( offset: int = 0, decision_only: bool = False, version: str = "", + archive_filter: str = "", paged: bool = False, compact: bool = False, altcoin_session: str = Cookie(default=""), ): require_api_user_with_subscription(altcoin_session) - return get_all_recommendations(limit, decision_only=decision_only, version=version, offset=offset, with_meta=(paged or compact)) + return get_all_recommendations( + limit, + decision_only=decision_only, + version=version, + offset=offset, + with_meta=(paged or compact), + archive_filter=archive_filter, + ) @router.get("/api/recommendations/active") diff --git a/app/web/web_server.py b/app/web/web_server.py index d2f01b3..ec14ace 100644 --- a/app/web/web_server.py +++ b/app/web/web_server.py @@ -19,6 +19,7 @@ from app.web.routes_auth import router as auth_router from app.web.routes_content import build_router as build_content_router from app.web.routes_market import router as market_router from app.web.routes_onchain import router as onchain_router +from app.web.routes_paper_trading import router as paper_trading_router from app.web.routes_pages import build_router as build_pages_router from app.web.routes_recommendations import router as recommendations_router from app.web.routes_strategy import router as strategy_router @@ -46,6 +47,7 @@ app.include_router(auth_router) app.include_router(recommendations_router) app.include_router(strategy_router) app.include_router(onchain_router) +app.include_router(paper_trading_router) app.include_router(market_router) app.include_router(build_admin_router(templates)) app.include_router(build_content_router(REPO_ROOT)) diff --git a/rules.yaml b/rules.yaml index 2ae8fd1..d904966 100644 --- a/rules.yaml +++ b/rules.yaml @@ -407,11 +407,11 @@ event_driven: note: Solana meme主题扩散 meta: version: 1 - last_review: '2026-05-16T15:25:43.236681' - last_reverse_analysis: '2026-05-16T15:27:11.686080' - total_reviews: 53 + last_review: '2026-05-16T21:27:46.729074' + last_reverse_analysis: '2026-05-16T21:28:18.838591' + total_reviews: 59 total_rules_learned: 37 - iteration_count: 58 + iteration_count: 64 strategy_version: v1.7.11 strategy_revision_started_at: '2026-05-09T01:20:00' strategy_revision_note: 'v1.7.11: 触发时效治理,旧形态只作背景,消息触发显式标记' diff --git a/static/app.html b/static/app.html index 7ac31fe..3da4d4b 100644 --- a/static/app.html +++ b/static/app.html @@ -19,6 +19,10 @@ .version-select:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(66,98,255,.10); } .history-version-bar { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin: -6px 0 16px; padding: 10px 14px; border: 1px solid var(--hairline-soft); border-radius: var(--radius-xl); background: var(--canvas); } .history-version-bar label { font-size: 12px; color: var(--stone); font-weight: 600; line-height: 1.4; white-space: nowrap; } +.history-filter-bar { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; margin: 0 0 14px; } +.history-filter-btn { border: 1px solid var(--hairline); background: var(--canvas); color: var(--steel); padding: 7px 12px; border-radius: var(--radius-full); font-size: 12px; font-weight: 700; line-height: 1.3; cursor: pointer; transition: .15s; white-space: nowrap; } +.history-filter-btn:hover { color: var(--ink); border-color: var(--hairline-strong); } +.history-filter-btn.active { background: var(--primary); color: var(--on-primary); border-color: var(--primary); box-shadow: 0 4px 12px rgba(5,0,56,.10); } /* ===== DASHBOARD OVERVIEW ===== */ .dashboard-overview { display: flex; flex-direction: column; gap: 14px; margin-bottom: 18px; } @@ -115,6 +119,8 @@ .h-pnl-row .price.h-entry-price { color: var(--blue); } .h-pnl-row .price.h-exit-price.win { color: var(--green); } .h-pnl-row .price.h-exit-price.loss { color: var(--red); } +.price.muted { color: var(--stone); } +.h-arrow.neutral { color: var(--stone); } .h-arrow.win { color: var(--green); } .h-arrow.loss { color: var(--red); } .h-duration { color: var(--blue); background: rgba(66,98,255,.06); padding: 3px 8px; border-radius: var(--radius-full); font-weight: 700; } @@ -267,7 +273,7 @@
- +
@@ -286,6 +292,11 @@ +
+ + + +
加载中…
@@ -309,6 +320,7 @@ var historyOffset = 0; var historyLimit = 24; var historyHasMore = false; var historyLoading = false; +var historyArchiveFilter = ''; // ====== VERSIONS ====== async function loadVersions() { try { @@ -347,6 +359,14 @@ async function onVersionChange() { else await loadContent(true); } +function setHistoryFilter(filter) { + historyArchiveFilter = filter || ''; + document.querySelectorAll('#historyFilterBar .history-filter-btn').forEach(function(btn){ + btn.classList.toggle('active', (btn.dataset.filter || '') === historyArchiveFilter); + }); + loadHistoryRecommendations(true); +} + // ====== LOAD ====== async function switchTab(tab) { curTab = tab; @@ -854,18 +874,30 @@ function renderKlineChart(symbol, candles, entryPrice, stopLoss, tp1, recTime, t // ====== HISTORY ====== function historyOutcome(r) { var status = (r && r.status) || ''; - var pnl = Number((r && r.pnl_pct) || 0); - var maxPnl = Number((r && r.max_pnl_pct) || 0); - var maxDd = Number((r && r.max_drawdown_pct) || 0); - var hitFailure = status === 'stopped_out' || pnl <= -3 || maxDd <= -5; - if (hitFailure) { - return { resolved: true, type: 'failure', pnl: pnl, label: '风险边界' }; + var execution = (r && r.execution_status) || ''; + var bucket = (r && r.display_bucket) || ''; + var triggered = !!(r && Number(r.entry_triggered || 0)); + var paper = r && r.paper_trade ? r.paper_trade : null; + if (paper && paper.status === 'closed') { + var exitReason = String(paper.exit_reason || '').toLowerCase(); + if (exitReason === 'stop_loss' || exitReason === 'sl' || exitReason === 'stopped_out') { + return { resolved: true, type: 'executed_failed', label: '模拟交易止损', detail: '已进入模拟交易并触发止损' }; + } + return { resolved: true, type: 'executed_success', label: '模拟交易兑现', detail: '已进入模拟交易并完成退出' }; } - var hitSuccess = status === 'hit_tp1' || status === 'hit_tp2' || maxPnl >= 5; - if (hitSuccess) { - return { resolved: true, type: 'success', pnl: maxPnl || pnl, label: '阶段兑现' }; + if (paper && paper.status === 'open') { + return { resolved: true, type: 'executed_open', label: '模拟交易持有', detail: '已进入模拟交易,仍在持仓中' }; } - return { resolved: false, type: 'pending', pnl: pnl, label: '跟踪中' }; + if (status === 'hit_tp1' || status === 'hit_tp2' || execution === 'completed') { + return { resolved: true, type: 'executed_success', label: '执行后兑现', detail: '已进入模拟/持仓口径验证' }; + } + if (status === 'stopped_out') { + return { resolved: true, type: 'executed_failed', label: '执行后止损', detail: '执行样本触发风险边界' }; + } + if (status === 'expired' || status === 'invalid' || status === 'archived' || execution === 'invalid' || bucket === 'history') { + return { resolved: true, type: triggered ? 'executed_invalid' : 'not_executed', label: triggered ? '执行后失效' : '未执行失效', detail: triggered ? '曾进入执行态,后续失效' : '推荐/观察后未形成真实交易' }; + } + return { resolved: false, type: 'pending', label: '仍在跟踪', detail: '尚未归档' }; } function isResolvedHistory(r) { return historyOutcome(r).resolved; @@ -881,23 +913,22 @@ async function loadHistoryRecommendations(reset) { try { var offset = reset ? 0 : historyOffset; var pageSize = historyLimit; - var url = API+'/api/recommendations?limit='+pageSize+'&offset='+offset+'&decision_only=true&compact=true'; + var url = API+'/api/recommendations?limit='+pageSize+'&offset='+offset+'&decision_only=true&compact=true&archive_filter='+encodeURIComponent(historyArchiveFilter || ''); if (currentVersion) url += '&version=' + encodeURIComponent(currentVersion); var resp = await fetch(url); var page = await resp.json(); var summary = page.summary || {}; var totalCount = Number(page.total || summary.total || 0); - var successCount = Number(summary.success_count || 0); - var failureCount = Number(summary.failure_count || 0); - var totalPnl = Number(summary.total_pnl || 0); - var bestPnl = Number(summary.best_pnl || 0); - var avgSl = Number(summary.avg_failure_pnl || 0); + var executedCount = Number(summary.executed_count || 0); + var completedCount = Number(summary.completed_count || 0); + var invalidCount = Number(summary.invalid_count || 0); + var notExecutedCount = Number(summary.not_executed_count || 0); $('histCount').textContent = totalCount ? ' ' + totalCount : ''; $('historyStats').innerHTML = - '
'+successCount+'
已兑现样本
全部历史
'+ - '
'+(totalPnl>=0?'+':'')+totalPnl.toFixed(1)+'%
累计表现
全部历史
'+ - '
+'+bestPnl.toFixed(1)+'%
最大单笔表现
全部历史
'+ - '
'+avgSl.toFixed(1)+'%
风险边界失效
'+failureCount+'次 · 平均
'; + '
'+totalCount+'
归档信号
推荐/观察历史
'+ + '
'+executedCount+'
进入执行
收益见模拟交易
'+ + '
'+notExecutedCount+'
未执行归档
观察/等回踩失效
'+ + '
'+invalidCount+'
信号失效
含过期/风控失效
'; var items = Array.isArray(page.items) ? page.items : []; var completed = items.filter(isResolvedHistory); if (reset) { @@ -908,25 +939,27 @@ async function loadHistoryRecommendations(reset) { historyOffset += completed.length; } historyHasMore = !!page.has_more; - if(!historyItems.length){ $('historyCards').innerHTML='

暂无已完成交易记录
机会完成兑现或风险边界失效后会出现在这里

'; return; } + if(!historyItems.length){ $('historyCards').innerHTML='

暂无归档推荐
推荐过期、失效或完成后会出现在这里

'; return; } var cardsHtml = historyItems.map(function(r,idx) { - var base = (r.symbol||'').replace('/USDT',''), outcome = historyOutcome(r), pnl = outcome.pnl, win = pnl>0; - var pnlSign = pnl>0?'+':'', pnlCls = win?'pos':pnl<0?'neg':'zero'; - var exitP = (r.status==='stopped_out'||Number(r.pnl_pct||0)<0) ? (r.current_price||r.stop_loss||0) : (r.tp1||r.current_price||0); - var entryP = r.entry_price||0; + var base = (r.symbol||'').replace('/USDT',''), outcome = historyOutcome(r); + var paper = r.paper_trade || null; + var hasPaper = !!(paper && paper.id); + var exitP = hasPaper ? Number(paper.exit_price || 0) : 0; + var entryP = hasPaper ? Number(paper.entry_price || 0) : 0; function fmtN(n) { return fmtPrice(n, priceDecimals(r.current_price || entryP || exitP || n)); } - var statusLabel = outcome.type === 'failure' ? '风险边界失效' : '阶段兑现'; - var isWin = Number(pnl || 0) > 0; - var resultCls = isWin ? 'win' : 'loss'; - var maxPnl = Number(r.max_pnl_pct || pnl || 0); - var maxPnlSign = maxPnl > 0 ? '+' : ''; - var maxPnlCls = maxPnl > 0 ? 'win' : (maxPnl < 0 ? 'loss' : ''); + var resultCls = (outcome.type === 'executed_success') ? 'win' : ((outcome.type === 'executed_failed') ? 'loss' : 'neutral'); + var statusColorCls = resultCls === 'win' ? 'pos' : (resultCls === 'loss' ? 'neg' : 'zero'); + var afterMove = hasPaper && entryP && exitP ? ((exitP / entryP - 1) * 100) : 0; + var afterMoveSign = afterMove > 0 ? '+' : ''; + var maxPnl = Number(r.max_pnl_pct || 0); var maxDd = Number(r.max_drawdown_pct || 0); - var exitMode = outcome.type === 'failure' ? '风险边界' : ((r.action_status==='跟踪止盈') ? '跟踪止盈' : '阶段兑现'); - var isRiskExit = outcome.type === 'failure'; - var hEntryTime = r.rec_time||'', hTpTime = (!isRiskExit && (r.status==='hit_tp1'||r.status==='hit_tp2'||Number(r.max_pnl_pct||0)>=5))?(r.hit_tp1_time||r.last_track_time||''):''; - var hSlTime = isRiskExit ? (r.stopped_out_time||r.last_track_time||r.expired_time||'') : ''; - var hEntryPrice = r.entry_price||0, hSl = isRiskExit ? exitP : (r.stop_loss||0), hTp = isRiskExit ? 0 : (r.tp1||0), hid = 'hkline'+idx; + var exitMode = outcome.label; + var isPaperClosed = hasPaper && paper.status === 'closed'; + var isPaperStop = isPaperClosed && /stop|sl|stopped/i.test(String(paper.exit_reason || '')); + var hEntryTime = hasPaper ? (paper.opened_at||r.rec_time||'') : ''; + var hTpTime = isPaperClosed && !isPaperStop ? (paper.closed_at||'') : ''; + var hSlTime = isPaperClosed && isPaperStop ? (paper.closed_at||'') : ''; + var hEntryPrice = hasPaper ? entryP : 0, hSl = isPaperClosed ? (isPaperStop ? exitP : Number(paper.stop_loss || 0)) : 0, hTp = isPaperClosed ? (isPaperStop ? 0 : (Number(paper.tp1 || 0) || exitP)) : 0, hid = 'hkline'+idx; var score = r.rec_score||0; function scoreTier(s) { if(s>=80) return{label:'强势异动',cls:'tier-strong'}; if(s>=65) return{label:'值得关注',cls:'tier-good'}; @@ -938,10 +971,12 @@ async function loadHistoryRecommendations(reset) { var sigs = Array.isArray(r.signals)?r.signals:[]; var sigHtml = sigs.slice(0,4).map(function(s){ return ''+cleanDisplayText(s).replace(/^(\\d+H|\\d+m|日线|周线)\\s*/,'').slice(0,12)+''; }).join(''); var duration = daysBetween(r.rec_time, r.last_track_time||r.hit_tp1_time||r.stopped_out_time); + var execText = hasPaper ? (paper.status === 'closed' ? '已模拟交易完成' : '模拟交易持有中') : (Number(r.entry_triggered || 0) ? '已触发执行' : '未执行'); + var signalStateText = hasPaper ? '已执行归档' : (historyArchiveFilter === 'invalid' ? '失效归档' : '未执行归档'); return '
'+ - '
'+base.slice(0,2).toUpperCase()+'
'+base+'
'+pnlSign+pnl.toFixed(1)+'%
'+ - '
$'+fmtN(entryP)+'$'+fmtN(exitP)+'评分 '+score+' · '+st.label+''+duration+'
'+ - '
最大表现'+maxPnlSign+maxPnl.toFixed(1)+'%
最大回撤'+maxDd.toFixed(1)+'%
退出方式'+exitMode+'
'+ + '
'+base.slice(0,2).toUpperCase()+'
'+base+'
'+outcome.label+'
'+ + '
'+(hasPaper ? '$'+fmtN(entryP)+''+(paper.status === 'closed' ? '$'+fmtN(exitP) : '持有中')+'' : '未执行失效/归档')+'评分 '+score+' · '+st.label+''+duration+'
'+ + '
归档状态'+signalStateText+'
执行状态'+execText+'
归档原因'+exitMode+'
'+ '
'+ (sigHtml?'
'+sigHtml+'
':'')+ '
'; diff --git a/static/base.html b/static/base.html index 349d854..52a8b9e 100644 --- a/static/base.html +++ b/static/base.html @@ -158,6 +158,7 @@ a { color: inherit; text-decoration: none; } + @@ -175,6 +176,7 @@ a { color: inherit; text-decoration: none; } 市场总览 舆情 链上异动 + 模拟交易 订阅 推荐 @@ -252,7 +254,15 @@ async function loadUser() { function toggleUserMenu() { $('userDropdown').classList.toggle('show'); } document.addEventListener('click', function(e) { if (!e.target.closest('.sidebar-user') && !e.target.closest('.user-dropdown')) $('userDropdown').classList.remove('show'); }); -function showChangePwd() { $('userDropdown').classList.remove('show'); $('pwdModal').classList.add('show'); } +function showChangePwd() { + $('userDropdown').classList.remove('show'); + $('pwdModal').classList.add('show'); + $('oldPwd').value = ''; + $('newPwd').value = ''; + $('cfmPwd').value = ''; + $('pwdMsg').textContent = ''; + $('pwdMsg').className = 'modal-msg'; +} function closePwdModal() { $('pwdModal').classList.remove('show'); $('pwdMsg').textContent=''; $('pwdMsg').className='modal-msg'; } async function changePwd() { @@ -268,7 +278,7 @@ async function changePwd() { async function doLogout() { try{ await fetch(API+'/api/auth/logout',{method:'POST'}); }catch(e){} - window.location.href='/auth'; + window.location.href='/'; } // Mobile sidebar diff --git a/static/iteration.html b/static/iteration.html index 8819dcd..fe41801 100644 --- a/static/iteration.html +++ b/static/iteration.html @@ -127,17 +127,11 @@ h2 { font-size:26px; font-weight:900; margin:0 0 8px; color:var(--ink); } {% block extra_script %} {% endblock %} diff --git a/static/paper_trading.html b/static/paper_trading.html new file mode 100644 index 0000000..25c255c --- /dev/null +++ b/static/paper_trading.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block title %}AlphaX Agent | Crypto — 模拟交易{% endblock %} +{% block extra_head_css %} + +{% endblock %} +{% block content %} +
+
+
+

模拟交易

+

这里展示的是 paper trading 账本:只有系统把可买信号模拟成交后,才会进入收益统计。推荐历史和观察池不会直接产生收益率。

+
+
+ + +
+
+
模拟成交默认使用 20,000U 本金、每笔 5,000U 名义仓位、5x 杠杆、1,000U 保证金,用来验证策略真实交易口径。它不代表真实账户持仓,也不会反写推荐收益。
+
状态加载中
+
+
交易账本
--
+
+ + + +
币种状态仓位开仓止盈 / 止损最新 / 平仓价格收益账户收益退出原因来源
加载中...
+
+ +
+
+{% endblock %} +{% block extra_script %} + +{% endblock %} diff --git a/static/sentiment.html b/static/sentiment.html index e997f11..67869ca 100644 --- a/static/sentiment.html +++ b/static/sentiment.html @@ -153,61 +153,6 @@ {% endblock %} {% block extra_script %} {% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py index 6ce83fd..64d212c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -74,6 +74,8 @@ _ID_TABLES = { "screening_log", "recommendation", "price_tracking", + "paper_trades", + "paper_trade_events", "cron_run_log", "review_log", "missed_explosions", diff --git a/tests/test_base_shell_ownership.py b/tests/test_base_shell_ownership.py new file mode 100644 index 0000000..f8248d5 --- /dev/null +++ b/tests/test_base_shell_ownership.py @@ -0,0 +1,32 @@ +from pathlib import Path +import re + + +ROOT = Path(__file__).resolve().parents[1] +STATIC_DIR = ROOT / "static" + + +def test_base_template_owns_sidebar_and_user_shell(): + forbidden_patterns = [ + (r'id=["\']sidebar["\']', "sidebar element"), + (r'class=["\']sidebar["\']', "sidebar class"), + (r"\bsidebar-user\b", "sidebar user trigger"), + (r"\buserDropdown\b", "user dropdown"), + (r"\buserInitial\b", "user initial"), + (r"\buserEmailShort\b", "user email"), + (r"\bddEmail\b", "dropdown email"), + (r"\bfunction\s+toggleUserMenu\s*\(", "toggleUserMenu"), + (r"\basync\s+function\s+loadUser\s*\(", "loadUser"), + (r"\bfunction\s+showChangePwd\s*\(", "showChangePwd"), + (r"\bfunction\s+closePwdModal\s*\(", "closePwdModal"), + (r"\basync\s+function\s+changePwd\s*\(", "changePwd"), + (r"\basync\s+function\s+doLogout\s*\(", "doLogout"), + ] + for path in STATIC_DIR.glob("*.html"): + if path.name in {"base.html", "index.html"}: + continue + text = path.read_text(encoding="utf-8") + if "extends \"base.html\"" not in text and "extends 'base.html'" not in text: + continue + for pattern, label in forbidden_patterns: + assert not re.search(pattern, text), f"{path.name} should inherit shell behavior from base.html, found {label}" diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py new file mode 100644 index 0000000..f8dcedd --- /dev/null +++ b/tests/test_paper_trading.py @@ -0,0 +1,144 @@ +import json + +import pytest + +from app.db import altcoin_db +from app.db.paper_trading import get_paper_trading_summary, list_paper_trades, sync_recommendation + + +@pytest.fixture +def buy_now_rec(monkeypatch): + monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "100") + monkeypatch.setenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0") + monkeypatch.setenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0") + altcoin_db.init_db() + rec_id = altcoin_db.create_recommendation( + symbol="PAPER/USDT", + rec_state="爆发", + rec_score=28, + entry_price=100, + stop_loss=95, + tp1=106, + tp2=112, + signals=["当前15min即刻入场信号"], + entry_plan={ + "entry_action": "可即刻买入", + "entry_price": 100, + "stop_loss": 95, + "tp1": 106, + "tp2": 112, + "risk_reward_ok": True, + "rr1": 1.2, + "entry_trigger_confirmed": True, + }, + ) + rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False) + return next(r for r in rows if r["id"] == rec_id) + + +def test_buy_now_opens_paper_trade_once(buy_now_rec): + first = sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00") + second = sync_recommendation(buy_now_rec, 101, event_time="2026-05-16T10:01:00") + + assert first["opened"] is True + assert second["updated"] is True + + trades = list_paper_trades()["items"] + assert len(trades) == 1 + assert trades[0]["symbol"] == "PAPER/USDT" + assert trades[0]["status"] == "open" + assert trades[0]["pnl_pct"] == pytest.approx(1.0) + + +def test_default_paper_trade_uses_5000u_notional_5x_and_1000u_margin(monkeypatch): + monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") + monkeypatch.delenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", raising=False) + monkeypatch.delenv("ALPHAX_PAPER_TRADE_MARGIN_USDT", raising=False) + monkeypatch.delenv("ALPHAX_PAPER_TRADE_LEVERAGE", raising=False) + monkeypatch.delenv("ALPHAX_PAPER_ACCOUNT_EQUITY_USDT", raising=False) + monkeypatch.setenv("ALPHAX_PAPER_TRADE_FEE_RATE", "0") + monkeypatch.setenv("ALPHAX_PAPER_TRADE_SLIPPAGE_PCT", "0") + altcoin_db.init_db() + rec_id = altcoin_db.create_recommendation( + symbol="DEFAULT/USDT", + rec_state="爆发", + rec_score=28, + entry_price=100, + stop_loss=95, + tp1=106, + tp2=112, + signals=["当前15min即刻入场信号"], + entry_plan={"entry_action": "可即刻买入", "entry_trigger_confirmed": True, "risk_reward_ok": True}, + ) + rec = next(r for r in altcoin_db.get_active_recommendations_deduped(actionable_only=False) if r["id"] == rec_id) + + sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00") + trade = list_paper_trades()["items"][0] + summary = get_paper_trading_summary(days=30) + + assert trade["notional_usdt"] == pytest.approx(5000.0) + assert trade["leverage"] == pytest.approx(5.0) + assert trade["margin_usdt"] == pytest.approx(1000.0) + assert summary["account_equity_usdt"] == pytest.approx(20000.0) + assert summary["notional_usdt"] == pytest.approx(5000.0) + assert summary["margin_usdt"] == pytest.approx(1000.0) + + +def test_paper_margin_is_derived_from_notional_and_leverage(monkeypatch): + monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "5000") + monkeypatch.setenv("ALPHAX_PAPER_TRADE_LEVERAGE", "5") + monkeypatch.setenv("ALPHAX_PAPER_TRADE_MARGIN_USDT", "5000") + + summary = get_paper_trading_summary(days=30) + + assert summary["notional_usdt"] == pytest.approx(5000.0) + assert summary["leverage"] == pytest.approx(5.0) + assert summary["margin_usdt"] == pytest.approx(1000.0) + + +def test_observation_does_not_open_paper_trade(monkeypatch): + monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") + altcoin_db.init_db() + rec_id = altcoin_db.create_recommendation( + symbol="OBS/USDT", + rec_state="加速", + rec_score=18, + entry_price=100, + stop_loss=95, + tp1=106, + signals=["历史放量"], + entry_plan={"entry_action": "观察", "entry_price": 100, "stop_loss": 95, "tp1": 106}, + ) + rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False) + rec = next(r for r in rows if r["id"] == rec_id) + + result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00") + + assert result["skipped"] is True + assert result["reason"] == "not_buy_now" + assert list_paper_trades()["total"] == 0 + + +def test_open_paper_trade_closes_on_tp1_and_summary_counts_win(buy_now_rec): + sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00") + result = sync_recommendation(buy_now_rec, 106, event_time="2026-05-16T10:05:00") + + assert result["closed"] is True + assert result["exit_reason"] == "tp1" + assert result["pnl_pct"] == pytest.approx(6.0) + + summary = get_paper_trading_summary(days=30) + assert summary["closed_count"] == 1 + assert summary["win_count"] == 1 + assert summary["win_rate"] == pytest.approx(100.0) + + +def test_disabled_paper_trading_skips_without_writing(monkeypatch, buy_now_rec): + monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "0") + + result = sync_recommendation(buy_now_rec, 100, event_time="2026-05-16T10:00:00") + + assert result["skipped"] is True + assert result["reason"] == "disabled" + assert list_paper_trades()["total"] == 0 diff --git a/tests/test_price_tracker_watch_only_guard.py b/tests/test_price_tracker_watch_only_guard.py new file mode 100644 index 0000000..ed017cd --- /dev/null +++ b/tests/test_price_tracker_watch_only_guard.py @@ -0,0 +1,50 @@ +import os +import sys + +PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if PROJECT_DIR not in sys.path: + sys.path.insert(0, PROJECT_DIR) + +from app.services import price_tracker + + +def test_watch_only_recommendation_is_skipped_before_take_profit_push(monkeypatch): + rec = { + "id": 1, + "symbol": "QNT/USDT", + "entry_price": 100.0, + "stop_loss": 95.0, + "tp1": 110.0, + "tp2": 120.0, + "entry_plan": {"trailing_stop_level": 0.0}, + "entry_triggered": 0, + "display_bucket": "watch_pool", + "execution_status": "observe", + "action_status": "观察", + } + + monkeypatch.setattr(price_tracker, "get_active_recommendations", lambda actionable_only=True: [rec]) + monkeypatch.setattr(price_tracker.exchange, "fetch_ticker", lambda symbol: {"last": 102.0}) + monkeypatch.setattr(price_tracker, "update_latest_price_cache", lambda *args, **kwargs: None) + monkeypatch.setattr(price_tracker, "update_recommendation_tracking", lambda rec_id, current_price: {"status": "active"}) + monkeypatch.setattr(price_tracker, "analyze_tracking_signals", lambda symbol, rec, current_price: { + "action_status": "持有", + "sell_signals": [], + "buy_signals": [], + "exhaustion": {"severity": "low"}, + "pnl_pct": 0.0, + "trailing_stop_level": 0.0, + "trailing_stop_activated": False, + "trailing_stop_moved": False, + }) + monkeypatch.setattr(price_tracker, "apply_recommendation_state_transition", lambda *args, **kwargs: {"action_status": "观察", "push_required": False}) + pushed = [] + monkeypatch.setattr(price_tracker, "push_trade_action_update", lambda *args, **kwargs: pushed.append((args, kwargs)) or True) + monkeypatch.setattr(price_tracker, "expire_old_recommendations", lambda: None) + monkeypatch.setattr(price_tracker, "get_stats", lambda: {"active_count": 1}) + + output = price_tracker.track_prices() + + assert output["tracked_count"] == 0 + assert output["results"][0]["status"] == "skipped_watch_only" + assert pushed == [] diff --git a/tests/test_recommendation_archive_filter.py b/tests/test_recommendation_archive_filter.py new file mode 100644 index 0000000..cabd530 --- /dev/null +++ b/tests/test_recommendation_archive_filter.py @@ -0,0 +1,112 @@ +import os +import sys + +import pytest +from fastapi.testclient import TestClient + +PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if PROJECT_DIR not in sys.path: + sys.path.insert(0, PROJECT_DIR) + +from app.db import altcoin_db +from app.web import web_server +from test_actionable_active_recommendations import _insert_recommendation + + +@pytest.fixture +def temp_db(monkeypatch, tmp_path): + db_path = tmp_path / "altcoin_monitor.db" + monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path)) + monkeypatch.setattr(web_server, "get_all_recommendations", altcoin_db.get_all_recommendations) + monkeypatch.setattr(web_server, "get_stats", altcoin_db.get_stats) + altcoin_db.init_db() + return db_path + + +def test_archive_filter_separates_executed_and_invalid(temp_db): + _insert_recommendation( + temp_db, + symbol="EXEC/USDT", + action_status="止盈1", + status="hit_tp1", + execution_status="completed", + display_bucket="history", + entry_plan_json='{"entry_action": "可即刻买入"}', + ) + _insert_recommendation( + temp_db, + symbol="EXPIRE/USDT", + action_status="衰减", + status="expired", + execution_status="invalid", + display_bucket="history", + entry_plan_json='{"entry_action": "继续观察"}', + ) + + conn = altcoin_db.sqlite3.connect(str(temp_db)) + exec_id = conn.execute("SELECT id FROM recommendation WHERE symbol=%s", ("EXEC/USDT",)).fetchone()[0] + conn.execute( + """ + INSERT INTO paper_trades ( + recommendation_id, symbol, status, opened_at, closed_at, + entry_price, exit_price, qty, notional_usdt, + stop_loss, tp1, tp2, current_price, pnl_pct, + realized_pnl_pct, realized_pnl_usdt, exit_reason, + created_at, updated_at + ) VALUES (%s, %s, 'closed', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + exec_id, + "EXEC/USDT", + "2026-05-16T10:00:00", + "2026-05-16T10:10:00", + 100.0, + 106.0, + 1.0, + 100.0, + 95.0, + 106.0, + 112.0, + 106.0, + 6.0, + 6.0, + 0.0, + "tp1", + "2026-05-16T10:00:00", + "2026-05-16T10:10:00", + ), + ) + conn.commit() + conn.close() + + client = TestClient(web_server.app) + executed_page = client.get("/api/recommendations?decision_only=true&compact=true&archive_filter=executed").json() + symbols = {row["symbol"] for row in executed_page["items"]} + assert symbols == {"EXEC/USDT"} + executed_item = executed_page["items"][0] + assert executed_item["paper_trade"]["entry_price"] == pytest.approx(100.0) + assert executed_item["paper_trade_executed"] is True + + invalid_page = client.get("/api/recommendations?decision_only=true&compact=true&archive_filter=invalid").json() + invalid_symbols = {row["symbol"] for row in invalid_page["items"]} + assert "EXPIRE/USDT" in invalid_symbols + for row in invalid_page["items"]: + assert not row.get("paper_trade") + + +def test_unexecuted_archive_items_do_not_have_paper_trade_payload(temp_db): + _insert_recommendation( + temp_db, + symbol="OBS/USDT", + action_status="等回踩", + status="expired", + execution_status="invalid", + display_bucket="history", + entry_plan_json='{"entry_action": "等回踩", "entry_price": 90}', + ) + + client = TestClient(web_server.app) + page = client.get("/api/recommendations?decision_only=true&compact=true&archive_filter=invalid").json() + item = next(row for row in page["items"] if row["symbol"] == "OBS/USDT") + assert item["paper_trade"] is None + assert item["paper_trade_executed"] is False diff --git a/tests/test_tracker_terminal_action_guard.py b/tests/test_tracker_terminal_action_guard.py index bc3a5b0..51f169e 100644 --- a/tests/test_tracker_terminal_action_guard.py +++ b/tests/test_tracker_terminal_action_guard.py @@ -47,3 +47,29 @@ def test_ws_tracker_does_not_emit_entry_signal_for_closed_recommendation(): "action_status": "止盈1", } assert price_tracker_ws.check_triggers("NOT/USDT", rec, 0.000628) is None + + +def test_watch_pool_tracking_does_not_mark_stopped_out(monkeypatch, tmp_path): + db_path = tmp_path / "altcoin_monitor.db" + monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path)) + altcoin_db.init_db() + + rec_id = altcoin_db.create_recommendation( + symbol="STORJ/USDT", + rec_state="爆发", + rec_score=12, + entry_price=0.1449, + stop_loss=0.12042, + tp1=0.177279, + tp2=0.206264, + entry_plan={"entry_action": "观察", "entry_price": 0.1338, "stop_loss": 0.12042, "tp1": 0.177279}, + ) + + altcoin_db.update_recommendation_tracking(rec_id, 0.119) + + rows = altcoin_db.get_all_recommendations(limit=10) + rec = next(r for r in rows if r["id"] == rec_id) + assert rec["status"] == "active" + assert rec["execution_status"] == "observe" + assert rec["entry_triggered"] == 0 + assert rec["recommendation_result"] == "pending"