diff --git a/backend_api_python/app/openapi/register.py b/backend_api_python/app/openapi/register.py index 50c99f094..9cf58b824 100644 --- a/backend_api_python/app/openapi/register.py +++ b/backend_api_python/app/openapi/register.py @@ -39,6 +39,7 @@ ("/api/fast-analysis", "FastAnalysis"), ("/api/billing", "Billing"), ("/api/quick-trade", "QuickTrade"), + ("/api/v2", "ErrorReports"), ] @@ -80,6 +81,7 @@ def register_human_blueprints(api: Api) -> None: from app.routes.fast_analysis import fast_analysis_blp from app.routes.billing import billing_blp from app.routes.quick_trade import quick_trade_blp + from app.routes.error_reports import error_reports_blp registrations: list[tuple] = [ (health_blp, ""), @@ -107,6 +109,7 @@ def register_human_blueprints(api: Api) -> None: (fast_analysis_blp, "/api/fast-analysis"), (billing_blp, "/api/billing"), (quick_trade_blp, "/api/quick-trade"), + (error_reports_blp, "/api/v2"), ] for blp, prefix in registrations: diff --git a/backend_api_python/app/openapi/tags.py b/backend_api_python/app/openapi/tags.py index 85d05f154..77afb4c8b 100644 --- a/backend_api_python/app/openapi/tags.py +++ b/backend_api_python/app/openapi/tags.py @@ -23,6 +23,7 @@ QUICK_TRADE = "QuickTrade" IBKR = "IBKR" ALPACA = "Alpaca" +ERROR_REPORTS = "ErrorReports" ALL_TAGS = [ {"name": HEALTH, "description": "Liveness and API metadata (Public)"}, @@ -48,4 +49,5 @@ {"name": QUICK_TRADE, "description": "Manual quick trade (Internal)"}, {"name": IBKR, "description": "Interactive Brokers adapter (Internal)"}, {"name": ALPACA, "description": "Alpaca adapter (Internal)"}, + {"name": ERROR_REPORTS, "description": "Frontend error monitoring (Public)"}, ] diff --git a/backend_api_python/app/routes/error_reports.py b/backend_api_python/app/routes/error_reports.py new file mode 100644 index 000000000..1c968bcdb --- /dev/null +++ b/backend_api_python/app/routes/error_reports.py @@ -0,0 +1,97 @@ +""" +Error reports API — receives batched frontend error events at /api/v2/errors. + +This endpoint is mounted at /api/v2/errors (no auth — errors can happen before +login; we rely on rate limiting + payload size cap for abuse protection). +Events are stored for observability; critical trade/strategy crashes can be +wired to alerting downstream. +""" +from __future__ import annotations + +import json +import time +from datetime import datetime, timezone + +from flask import g, jsonify, request +from app.openapi.blueprint import HumanBlueprint as Blueprint +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +error_reports_blp = Blueprint( + "error_reports", + __name__, + description="Frontend error monitoring ingestion", +) + +MAX_EVENTS_PER_BATCH = 50 +MAX_EVENT_TEXT_LEN = 4000 + +_seen_count = 0 +_dropped_count = 0 + + +@error_reports_blp.route("/v2/errors", methods=["POST"]) +def report_errors(): + """Ingest a batch of frontend error events. + + Body: { events: [ { type, message, stack, context, severity, ... } ] } + Returns { code: 1, msg: "success", data: { received: N } } + """ + global _seen_count, _dropped_count + try: + payload = request.get_json(silent=True) or {} + except Exception: + return jsonify({"code": 0, "msg": "invalid json", "data": None}), 400 + + events = payload.get("events") + if not isinstance(events, list) or len(events) == 0: + return jsonify({"code": 0, "msg": "events array required", "data": None}), 400 + + # Cap batch size to prevent abuse. + if len(events) > MAX_EVENTS_PER_BATCH: + events = events[:MAX_EVENTS_PER_BATCH] + + received = 0 + for ev in events: + if not isinstance(ev, dict): + continue + ev_type = str(ev.get("type", "unknown"))[:100] + severity = str(ev.get("severity", "info"))[:20] + # Truncate long fields to avoid log bloat. + message = str(ev.get("message", ""))[:MAX_EVENT_TEXT_LEN] + stack = str(ev.get("stack") or "")[:MAX_EVENT_TEXT_LEN] + context = ev.get("context") or {} + + # Log with structured fields; downstream can wire to Sentry/DB. + logger.warning( + "frontend_error type=%s severity=%s msg=%s ctx=%s", + ev_type, + severity, + message[:200], + json.dumps(context, default=str, ensure_ascii=False)[:500] + if isinstance(context, dict) + else str(context)[:500], + ) + received += 1 + + _seen_count += received + return jsonify({ + "code": 1, + "msg": "success", + "data": {"received": received, "total_seen": _seen_count}, + }) + + +@error_reports_blp.route("/v2/errors/stats", methods=["GET"]) +def error_stats(): + """Lightweight stats endpoint for ops dashboards (no auth for now).""" + return jsonify({ + "code": 1, + "msg": "success", + "data": { + "total_seen": _seen_count, + "total_dropped": _dropped_count, + "timestamp": datetime.now(timezone.utc).isoformat(), + }, + }) diff --git a/backend_api_python/app/services/risk_guard.py b/backend_api_python/app/services/risk_guard.py new file mode 100644 index 000000000..6bd325efa --- /dev/null +++ b/backend_api_python/app/services/risk_guard.py @@ -0,0 +1,182 @@ +"""QuantDinger 实盘风控守卫 (Risk Guard) — 实盘下单最后一道闸门。 + +集成点:StrategyV2OrderGateway.submit() 在 _validate() 之后、持久化之前调用。 +规则: + 1. 账户回撤熔断:连续亏损达到阈值,停所有策略。 + 2. 单标的仓位上限:单个标的市值 / 净值 <= ratio。 + 3. 马丁死锁:马丁策略累计加仓次数达到上限,强制平仓。 + 4. 单笔金额上限:单笔下单金额 <= 绝对上限且 <= 账户净值比例。 + 5. 下单频率限制:滑动窗口内下单次数 <= max_orders_per_window。 +配置:全部通过环境变量 RISK_GUARD_* 覆盖,默认值见 RiskConfig。 +""" + +from __future__ import annotations + +import os +import time +import threading +from collections import deque +from dataclasses import dataclass, field +from typing import Deque, List, Tuple + +from app.utils.logger import get_logger + +logger = get_logger(__name__) + + +def _env_decimal(name: str, default: float) -> float: + try: + return float(os.getenv(name, str(default))) + except (TypeError, ValueError): + return default + + +def _env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, str(default))) + except (TypeError, ValueError): + return default + + +@dataclass +class RiskConfig: + max_drawdown_pct: float = _env_decimal("RISK_GUARD_MAX_DRAWDOWN_PCT", 0.20) + max_position_ratio: float = _env_decimal("RISK_GUARD_MAX_POSITION_RATIO", 0.30) + martingale_max_layers: int = _env_int("RISK_GUARD_MARTINGALE_MAX_LAYERS", 6) + martingale_max_leverage: float = _env_decimal("RISK_GUARD_MARTINGALE_MAX_LEVERAGE", 4.0) + max_order_notional: float = _env_decimal("RISK_GUARD_MAX_ORDER_NOTIONAL", 100000.0) + max_order_ratio: float = _env_decimal("RISK_GUARD_MAX_ORDER_RATIO", 0.10) + max_orders_per_window: int = _env_int("RISK_GUARD_MAX_ORDERS_PER_WINDOW", 20) + order_window_seconds: int = _env_int("RISK_GUARD_ORDER_WINDOW_SECONDS", 60) + cooldown_seconds: int = _env_int("RISK_GUARD_COOLDOWN_SECONDS", 300) + + +@dataclass +class AccountState: + net_value: float + peak_net_value: float + positions: dict = field(default_factory=dict) + drawdown_halted: bool = False + halt_until: float = 0.0 + + +@dataclass +class RiskOrder: + symbol: str + side: str + notional: float + strategy_type: str = "signal" + martingale_layers: int = 0 + martingale_leverage: float = 1.0 + + +class RiskGuard: + """Thread-safe risk guard for live order pre-checks.""" + + _instance = None + _instance_lock = threading.Lock() + + @classmethod + def shared(cls, cfg: RiskConfig | None = None) -> "RiskGuard": + with cls._instance_lock: + if cls._instance is None: + cls._instance = cls(cfg or RiskConfig()) + return cls._instance + + def __init__(self, cfg: RiskConfig | None = None) -> None: + self.cfg = cfg or RiskConfig() + self._order_times: Deque[float] = deque() + self._lock = threading.Lock() + + def _drawdown_ratio(self, acct: AccountState) -> float: + if acct.peak_net_value <= 0: + return 0.0 + return (acct.peak_net_value - acct.net_value) / acct.peak_net_value + + def _check_drawdown(self, acct: AccountState) -> Tuple[bool, List[str]]: + now = time.time() + if acct.drawdown_halted and now < acct.halt_until: + return False, [f"drawdown_halted: cooldown until {int(acct.halt_until)}"] + if self._drawdown_ratio(acct) >= self.cfg.max_drawdown_pct: + acct.drawdown_halted = True + acct.halt_until = now + self.cfg.cooldown_seconds + logger.warning( + "RiskGuard drawdown halt: %.2f%% >= %.2f%%", + self._drawdown_ratio(acct) * 100, + self.cfg.max_drawdown_pct * 100, + ) + return False, [ + f"drawdown_halted: drawdown {self._drawdown_ratio(acct):.2%} >= " + f"{self.cfg.max_drawdown_pct:.2%}" + ] + return True, [] + + def _check_position_ratio(self, order: RiskOrder, acct: AccountState) -> Tuple[bool, List[str]]: + current = float(acct.positions.get(order.symbol, 0)) + after = current + (order.notional if order.side == "buy" else -order.notional) + after = max(after, 0.0) + ratio = after / acct.net_value if acct.net_value > 0 else 1.0 + if ratio > self.cfg.max_position_ratio: + return False, [ + f"position_ratio: {order.symbol} {ratio:.2%} > " + f"{self.cfg.max_position_ratio:.2%}" + ] + return True, [] + + def _check_martingale(self, order: RiskOrder) -> Tuple[bool, List[str]]: + if order.strategy_type != "martingale": + return True, [] + reasons: List[str] = [] + if order.martingale_layers >= self.cfg.martingale_max_layers: + reasons.append( + f"martingale_layers: {order.martingale_layers} >= " + f"{self.cfg.martingale_max_layers}" + ) + if order.martingale_leverage > self.cfg.martingale_max_leverage: + reasons.append( + f"martingale_leverage: {order.martingale_leverage}x > " + f"{self.cfg.martingale_max_leverage}x" + ) + return (False, reasons) if reasons else (True, []) + + def _check_order_size(self, order: RiskOrder, acct: AccountState) -> Tuple[bool, List[str]]: + reasons: List[str] = [] + if order.notional > self.cfg.max_order_notional: + reasons.append(f"order_notional: {order.notional} > {self.cfg.max_order_notional}") + ratio = order.notional / acct.net_value if acct.net_value > 0 else 1.0 + if ratio > self.cfg.max_order_ratio: + reasons.append(f"order_ratio: {ratio:.2%} > {self.cfg.max_order_ratio:.2%}") + return (False, reasons) if reasons else (True, []) + + def _check_rate(self) -> Tuple[bool, List[str]]: + now = time.time() + while self._order_times and self._order_times[0] < now - self.cfg.order_window_seconds: + self._order_times.popleft() + if len(self._order_times) >= self.cfg.max_orders_per_window: + return False, [ + f"rate_limit: {len(self._order_times)} orders/{self.cfg.order_window_seconds}s >= " + f"{self.cfg.max_orders_per_window}" + ] + return True, [] + + def check(self, order: RiskOrder, acct: AccountState) -> Tuple[bool, List[str]]: + with self._lock: + reasons: List[str] = [] + for fn, args in ( + (self._check_drawdown, (acct,)), + (self._check_rate, ()), + (self._check_order_size, (order, acct)), + (self._check_position_ratio, (order, acct)), + (self._check_martingale, (order,)), + ): + ok, r = fn(*args) + if not ok: + reasons += r + allowed = not reasons + if allowed: + self._order_times.append(time.time()) + else: + logger.warning( + "RiskGuard blocked %s %s: %s", order.symbol, order.side, reasons + ) + return allowed, reasons diff --git a/backend_api_python/app/services/settings/branding.py b/backend_api_python/app/services/settings/branding.py index 473c7c0af..4f2536908 100644 --- a/backend_api_python/app/services/settings/branding.py +++ b/backend_api_python/app/services/settings/branding.py @@ -18,6 +18,20 @@ "social_discord": "https://discord.com/invite/tyx5B6TChr", "social_telegram": "https://t.me/quantdinger", "social_youtube": "https://youtube.com/@quantdinger", + "legal_user_agreement_url": "/legal/user-agreement", + "legal_user_agreement_text": ( + "QuantDinger 用户协议(摘要)。本平台为量化研究、回测与自动化交易提供基础设施," + "所有 AI 产出仅供研究参考,不构成任何投资建议。用户使用本平台进行实盘交易的风险" + "由用户自行承担。对接第三方券商/交易所前,用户须确认已阅读该平台的服务条款与" + "风险规则。严禁使用本平台从事任何违法违规交易活动。" + ), + "legal_privacy_policy_url": "/legal/privacy-policy", + "legal_privacy_policy_text": ( + "隐私政策(摘要)。QuantDinger 仅收集为提供服务所必需的最少数据,包括登录凭证、" + "券商 API 授权信息(加密存储)、会话与操作日志。除法律强制要求外,我们不会向" + "第三方出售用户数据。用户可随时申请导出或删除其数据。券商 API Key/Secret " + "采用 AES-256 加密存储,前端仅展示掩码。" + ), } @@ -50,10 +64,22 @@ def build_brand_config(app_version: str) -> Dict[str, object]: }, "social_accounts": social_accounts, "legal": { - "user_agreement_url": brand_env("BRAND_LEGAL_USER_AGREEMENT_URL"), - "user_agreement_text": brand_env("BRAND_LEGAL_USER_AGREEMENT_TEXT"), - "privacy_policy_url": brand_env("BRAND_LEGAL_PRIVACY_POLICY_URL"), - "privacy_policy_text": brand_env("BRAND_LEGAL_PRIVACY_POLICY_TEXT"), + "user_agreement_url": brand_env( + "BRAND_LEGAL_USER_AGREEMENT_URL", + "legal_user_agreement_url", + ), + "user_agreement_text": brand_env( + "BRAND_LEGAL_USER_AGREEMENT_TEXT", + "legal_user_agreement_text", + ), + "privacy_policy_url": brand_env( + "BRAND_LEGAL_PRIVACY_POLICY_URL", + "legal_privacy_policy_url", + ), + "privacy_policy_text": brand_env( + "BRAND_LEGAL_PRIVACY_POLICY_TEXT", + "legal_privacy_policy_text", + ), }, "mobile_app": { "latest_version": brand_env("MOBILE_APP_LATEST_VERSION"), diff --git a/backend_api_python/app/services/strategy_v2/live_execution.py b/backend_api_python/app/services/strategy_v2/live_execution.py index 01470d3ea..94173f8fd 100644 --- a/backend_api_python/app/services/strategy_v2/live_execution.py +++ b/backend_api_python/app/services/strategy_v2/live_execution.py @@ -95,6 +95,7 @@ def has_inflight(self, request: LiveOrderRequest) -> bool: def submit(self, request: LiveOrderRequest) -> int | None: request = self._validate(request) + self._check_risk_guard(request) service = OrderIntentService( strategy_id=request.strategy_id, strategy_run_id=request.strategy_run_id, @@ -250,3 +251,70 @@ def _validate(request: LiveOrderRequest) -> LiveOrderRequest: if request.execution_algo == "limit" and request.limit_price <= 0: raise ValueError("strategyV2.limitPriceRequired") return request + + @staticmethod + def _check_risk_guard(request: LiveOrderRequest) -> None: + """Run the platform-level risk guard before persisting the order. + + Fetches a best-effort account snapshot and checks drawdown, position + ratio, martingale lock, order size, and rate limits. If any rule + blocks the order, raises ValueError with the blocking reasons so the + caller can report back to the frontend / AI strategy review. + """ + from app.services.risk_guard import RiskGuard, RiskOrder, AccountState + + # Best-effort account snapshot; if unavailable, skip guard (fail-open + # rather than block trading on observability gaps). + try: + from app.services.live_trading.account_snapshot import ( + fetch_account_snapshot, + ) + from app.utils.auth import get_current_user_id + + user_id = request.user_id or get_current_user_id() + snapshot = fetch_account_snapshot( + user_id=user_id, + credential_id=0, # TODO: resolve from trading_config + ) or {} + net_value = float( + snapshot.get("totalWalletBalance") + or snapshot.get("net_value") + or 0 + ) + peak = max(net_value, float(snapshot.get("peak") or net_value)) + positions = { + str(p.get("symbol")): float(p.get("notional") or p.get("positionValue") or 0) + for p in (snapshot.get("positions") or []) + if p.get("symbol") + } + acct = AccountState( + net_value=net_value, peak_net_value=peak, positions=positions + ) + except Exception: + # Observability gap — don't block trading, but log it. + from app.utils.logger import get_logger + + get_logger(__name__).debug( + "risk_guard: account snapshot unavailable, skipping guard" + ) + return + + notional = float(request.quantity) * float(request.reference_price or 0) + # Infer strategy type / martingale state from the LiveOrderRequest. + strategy_type = "martingale" if "add_" in request.action else "signal" + martingale_layers = 0 # TODO: wire from runtime state + martingale_leverage = float(request.leverage or 1.0) + + order = RiskOrder( + symbol=request.symbol, + side="buy" if "long" in request.action else "sell", + notional=notional, + strategy_type=strategy_type, + martingale_layers=martingale_layers, + martingale_leverage=martingale_leverage, + ) + + guard = RiskGuard.shared() + allowed, reasons = guard.check(order, acct) + if not allowed: + raise ValueError("strategyV2.riskGuardBlocked: " + "; ".join(reasons)) diff --git a/backend_api_python/env.example b/backend_api_python/env.example index 18ea79594..3dd32fcce 100644 --- a/backend_api_python/env.example +++ b/backend_api_python/env.example @@ -37,10 +37,10 @@ BRAND_SOCIAL_YOUTUBE=https://youtube.com/@quantdinger # Legal: external URL takes priority; if both URL and inline text are empty, # the frontend falls back to the built-in i18n copy. -BRAND_LEGAL_USER_AGREEMENT_URL= -BRAND_LEGAL_USER_AGREEMENT_TEXT= -BRAND_LEGAL_PRIVACY_POLICY_URL= -BRAND_LEGAL_PRIVACY_POLICY_TEXT= +BRAND_LEGAL_USER_AGREEMENT_URL=/legal/user-agreement +BRAND_LEGAL_USER_AGREEMENT_TEXT=QuantDinger 用户协议(摘要)。本平台为量化研究、回测与自动化交易提供基础设施,所有 AI 产出仅供研究参考,不构成任何投资建议。用户使用本平台进行实盘交易的风险由用户自行承担。对接第三方券商/交易所前,用户须确认已阅读该平台的服务条款与风险规则。严禁使用本平台从事任何违法违规交易活动。 +BRAND_LEGAL_PRIVACY_POLICY_URL=/legal/privacy-policy +BRAND_LEGAL_PRIVACY_POLICY_TEXT=隐私政策(摘要)。QuantDinger 仅收集为提供服务所必需的最少数据,包括登录凭证、券商 API 授权信息(加密存储)、会话与操作日志。除法律强制要求外,我们不会向第三方出售用户数据。用户可随时申请导出或删除其数据。券商 API Key/Secret 采用 AES-256 加密存储,前端仅展示掩码。 # ========================= # Auth (required)