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 @@暂无已完成交易记录
机会完成兑现或风险边界失效后会出现在这里
暂无归档推荐
推荐过期、失效或完成后会出现在这里
这里展示的是 paper trading 账本:只有系统把可买信号模拟成交后,才会进入收益统计。推荐历史和观察池不会直接产生收益率。
+| 币种 | 状态 | 仓位 | 开仓 | 止盈 / 止损 | 最新 / 平仓 | 价格收益 | 账户收益 | 退出原因 | 来源 |
|---|---|---|---|---|---|---|---|---|---|
| 加载中... | |||||||||