"""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.config.system_config import paper_trading_config 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 bool(paper_trading_config().get("enabled", True)) def default_account_equity_usdt() -> float: return max(1.0, _safe_float(paper_trading_config().get("account_equity_usdt"), 20000.0)) def default_leverage() -> float: return max(1.0, _safe_float(paper_trading_config().get("trade_leverage"), 5.0)) def default_notional_usdt() -> float: return max(1.0, _safe_float(paper_trading_config().get("trade_notional_usdt"), 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(paper_trading_config().get("fee_rate"), 0.001)) def default_slippage_pct() -> float: return max(0.0, _safe_float(paper_trading_config().get("slippage_pct"), 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() latest_market = _safe_float(item.get("latest_market_price")) item["latest_price"] = latest_market if latest_market > 0 else _safe_float(item.get("current_price")) item["latest_price_updated_at"] = item.get("latest_market_price_updated_at") or item.get("updated_at") or "" 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) open_position_value = round(sum(_safe_float(x.get("notional_usdt")) for x in open_items), 4) initial_equity = default_account_equity_usdt() current_balance = round(initial_equity + total_pnl, 4) cumulative_leverage = round(open_position_value / current_balance, 4) if current_balance > 0 else 0 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, "initial_equity_usdt": initial_equity, "account_equity_usdt": initial_equity, "current_balance_usdt": current_balance, "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, "open_position_value_usdt": open_position_value, "cumulative_leverage": cumulative_leverage, "available_equity_usdt": round(current_balance - 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 pt.*, lpc.price AS latest_market_price, lpc.updated_at AS latest_market_price_updated_at FROM paper_trades pt LEFT JOIN latest_price_cache lpc ON lpc.symbol = pt.symbol {where} ORDER BY pt.opened_at DESC, pt.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), }