Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend_api_python/app/openapi/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
("/api/fast-analysis", "FastAnalysis"),
("/api/billing", "Billing"),
("/api/quick-trade", "QuickTrade"),
("/api/v2", "ErrorReports"),
]


Expand Down Expand Up @@ -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, ""),
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions backend_api_python/app/openapi/tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
QUICK_TRADE = "QuickTrade"
IBKR = "IBKR"
ALPACA = "Alpaca"
ERROR_REPORTS = "ErrorReports"

ALL_TAGS = [
{"name": HEALTH, "description": "Liveness and API metadata (Public)"},
Expand All @@ -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)"},
]
97 changes: 97 additions & 0 deletions backend_api_python/app/routes/error_reports.py
Original file line number Diff line number Diff line change
@@ -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(),
},
})
182 changes: 182 additions & 0 deletions backend_api_python/app/services/risk_guard.py
Original file line number Diff line number Diff line change
@@ -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
34 changes: 30 additions & 4 deletions backend_api_python/app/services/settings/branding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 加密存储,前端仅展示掩码。"
),
}


Expand Down Expand Up @@ -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"),
Expand Down
Loading