Skip to content

Commit 10a1244

Browse files
HenryHenry
authored andcommitted
fix security, backtests, and live trading
1 parent 769f07b commit 10a1244

42 files changed

Lines changed: 1882 additions & 161 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend_api_python/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ cp env.example .env
6868
At minimum, replace these values before a shared or production deployment:
6969

7070
```dotenv
71-
SECRET_KEY=<independent-random-value-at-least-32-bytes>
71+
SECRET_KEY=<independent-random-value-at-least-10-bytes-32-plus-recommended>
7272
CREDENTIAL_ENCRYPTION_KEY=<independent-random-value-at-least-32-bytes>
7373
ADMIN_USER=<initial-admin-name>
7474
ADMIN_PASSWORD=<strong-initial-password>

backend_api_python/app/commands/worker_health.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ def main() -> None:
1818
credential_key = str(os.getenv("CREDENTIAL_ENCRYPTION_KEY") or "").strip()
1919
session_key = str(os.getenv("SECRET_KEY") or "").strip()
2020
if args.role in {"trading", "scheduler"} and not credential_key:
21-
if not session_key or session_key == "quantdinger-secret-key-change-me":
21+
if (
22+
len(session_key.encode("utf-8")) < 10
23+
or session_key == "quantdinger-secret-key-change-me"
24+
):
2225
sys.exit(1)
2326

2427
with get_db_connection() as db:

backend_api_python/app/config/settings.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
"""Application settings."""
22
import os
33

4+
5+
_INSECURE_SECRET_KEYS = {
6+
"quantdinger-secret-key-change-me",
7+
}
8+
# Compatibility floor for installations upgraded from releases that used
9+
# shorter session keys. New deployments should still generate 32 random bytes.
10+
_MIN_SECRET_KEY_BYTES = 10
11+
12+
413
class MetaConfig(type):
514

615
@property
@@ -25,7 +34,17 @@ def VERSION(cls):
2534

2635
@property
2736
def SECRET_KEY(cls):
28-
return os.getenv('SECRET_KEY', 'quantdinger-secret-key-change-me')
37+
secret = (os.getenv('SECRET_KEY') or '').strip()
38+
if not secret or secret in _INSECURE_SECRET_KEYS:
39+
raise RuntimeError(
40+
'SECRET_KEY must be set to a unique random value. Generate one with: '
41+
'python -c "import secrets; print(secrets.token_hex(32))"'
42+
)
43+
if len(secret.encode('utf-8')) < _MIN_SECRET_KEY_BYTES:
44+
raise RuntimeError(
45+
f'SECRET_KEY must contain at least {_MIN_SECRET_KEY_BYTES} bytes'
46+
)
47+
return secret
2948

3049
@property
3150
def ADMIN_USER(cls):

backend_api_python/app/routes/backtest_center.py

Lines changed: 120 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import math
99
import random
1010
from typing import Any
11+
from uuid import uuid4
1112

1213
from flask import g, jsonify, request
1314

@@ -19,6 +20,7 @@
1920
parse_rate,
2021
)
2122
from app.services.backtest_limits import BacktestRangeLimitError
23+
from app.services.billing_service import get_billing_service
2224
from app.services.script_source import get_script_source_service
2325
from app.services.strategy_v2 import (
2426
FactorResearchRepository,
@@ -80,7 +82,7 @@ def _source(payload: dict[str, Any], user_id: int) -> tuple[str, int | None, int
8082
return code, source_id, strategy_id, strategy_name
8183

8284

83-
def _run(payload: dict[str, Any], user_id: int, *, persist: bool) -> tuple[int | None, dict[str, Any]]:
85+
def _prepare_run(payload: dict[str, Any], user_id: int) -> dict[str, Any]:
8486
code, source_id, strategy_id, strategy_name = _source(payload, user_id)
8587
start_raw = str(payload.get("startDate") or "").strip()
8688
end_raw = str(payload.get("endDate") or "").strip()
@@ -89,36 +91,131 @@ def _run(payload: dict[str, Any], user_id: int, *, persist: bool) -> tuple[int |
8991
start_date = datetime.strptime(start_raw, "%Y-%m-%d")
9092
end_date = datetime.strptime(end_raw, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
9193
leverage_enabled = bool(payload.get("leverageEnabled", False))
92-
return get_strategy_backtest_service().run(
94+
return {
95+
"user_id": user_id,
96+
"code": code,
97+
"start_date": start_date,
98+
"end_date": end_date,
99+
"initial_capital": float(payload.get("initialCapital") or 10_000),
100+
"leverage_enabled": leverage_enabled,
101+
"leverage": float(payload.get("leverage") or 1),
102+
"commission": parse_rate(payload.get("commission"), default=default_commission_if_missing(None)),
103+
"slippage": parse_rate(payload.get("slippage"), default=default_slippage_if_missing(None)),
104+
"params": dict(payload.get("params") or {}),
105+
"strategy_id": strategy_id,
106+
"source_id": source_id,
107+
"strategy_name": strategy_name,
108+
}
109+
110+
111+
def _run_prepared(prepared: dict[str, Any], *, persist: bool) -> tuple[int | None, dict[str, Any]]:
112+
return get_strategy_backtest_service().run(**prepared, persist=persist)
113+
114+
115+
def _run(payload: dict[str, Any], user_id: int, *, persist: bool) -> tuple[int | None, dict[str, Any]]:
116+
return _run_prepared(_prepare_run(payload, user_id), persist=persist)
117+
118+
119+
def _consume_backtest_credits(user_id: int) -> tuple[Any, dict[str, Any]]:
120+
"""Charge one backtest run and return a response-safe billing snapshot."""
121+
billing = get_billing_service()
122+
enabled = bool(billing.is_billing_enabled())
123+
cost = max(0, int(billing.get_feature_cost("backtest") or 0))
124+
reference_id = f"backtest:{uuid4().hex}"
125+
charge = {
126+
"enabled": enabled,
127+
"cost": cost,
128+
"charged": 0,
129+
"remaining": float(billing.get_user_credits(user_id)),
130+
"referenceId": reference_id,
131+
}
132+
if not enabled or cost <= 0:
133+
return billing, charge
134+
135+
success, message = billing.check_and_consume(
136+
user_id=user_id,
137+
feature="backtest",
138+
reference_id=reference_id,
139+
)
140+
if not success:
141+
current = float(billing.get_user_credits(user_id))
142+
if str(message).startswith("insufficient_credits:"):
143+
return billing, {
144+
**charge,
145+
"error": "insufficient_credits",
146+
"current": current,
147+
"required": cost,
148+
"shortage": max(0, cost - current),
149+
}
150+
return billing, {**charge, "error": "billing_error", "message": str(message)}
151+
152+
charge["charged"] = cost
153+
charge["remaining"] = float(billing.get_user_credits(user_id))
154+
return billing, charge
155+
156+
157+
def _refund_backtest_credits(billing: Any, user_id: int, charge: dict[str, Any]) -> None:
158+
cost = int(charge.get("charged") or 0)
159+
if not billing or cost <= 0:
160+
return
161+
refunded, message = billing.add_credits(
93162
user_id=user_id,
94-
code=code,
95-
start_date=start_date,
96-
end_date=end_date,
97-
initial_capital=float(payload.get("initialCapital") or 10_000),
98-
leverage_enabled=leverage_enabled,
99-
leverage=float(payload.get("leverage") or 1),
100-
commission=parse_rate(payload.get("commission"), default=default_commission_if_missing(None)),
101-
slippage=parse_rate(payload.get("slippage"), default=default_slippage_if_missing(None)),
102-
params=dict(payload.get("params") or {}),
103-
persist=persist,
104-
strategy_id=strategy_id,
105-
source_id=source_id,
106-
strategy_name=strategy_name,
163+
amount=cost,
164+
action="refund",
165+
remark="Automatic refund: backtest execution failed",
166+
reference_id=str(charge.get("referenceId") or ""),
107167
)
168+
if not refunded:
169+
logger.error("Backtest credit refund failed for user %s: %s", user_id, message)
108170

109171

110172
@backtest_center_blp.route("/run", methods=["POST"])
111173
@login_required
112174
def run_strategy_backtest():
175+
billing = None
176+
charge: dict[str, Any] = {}
177+
user_id = int(g.user_id)
113178
try:
114179
payload = request.get_json(silent=True) or {}
115-
run_id, result = _run(payload, int(g.user_id), persist=bool(payload.get("persist", True)))
116-
return jsonify({"code": 1, "msg": "success", "data": {**result, "runId": run_id}})
180+
prepared = _prepare_run(payload, user_id)
181+
billing, charge = _consume_backtest_credits(user_id)
182+
if charge.get("error") == "insufficient_credits":
183+
return jsonify({
184+
"code": 0,
185+
"msg": "insufficient_credits",
186+
"data": {
187+
"error_type": "INSUFFICIENT_CREDITS",
188+
"feature": "backtest",
189+
"current": charge["current"],
190+
"required": charge["required"],
191+
"shortage": charge["shortage"],
192+
},
193+
}), 402
194+
if charge.get("error"):
195+
return jsonify({
196+
"code": 0,
197+
"msg": charge.get("message") or "Failed to deduct credits",
198+
"data": {"error_type": "BILLING_ERROR", "feature": "backtest"},
199+
}), 500
200+
201+
run_id, result = _run_prepared(prepared, persist=bool(payload.get("persist", True)))
202+
billing_data = {
203+
key: charge.get(key)
204+
for key in ("enabled", "cost", "charged", "remaining")
205+
}
206+
return jsonify({
207+
"code": 1,
208+
"msg": "success",
209+
"data": {**result, "runId": run_id, "billing": billing_data},
210+
})
117211
except BacktestRangeLimitError as exc:
212+
_refund_backtest_credits(billing, user_id, charge)
118213
return jsonify({"code": 0, "msg": str(exc), "data": exc.details}), 400
119214
except ValueError as exc:
215+
_refund_backtest_credits(billing, user_id, charge)
120216
return jsonify({"code": 0, "msg": str(exc), "data": None}), 400
121217
except Exception as exc:
218+
_refund_backtest_credits(billing, user_id, charge)
122219
logger.exception("Strategy backtest failed")
123220
return jsonify({"code": 0, "msg": str(exc), "data": None}), 500
124221

@@ -343,9 +440,14 @@ def _candidates(space: dict[str, list[Any]], *, method: str, limit: int) -> list
343440

344441
def _metrics(result: dict[str, Any]) -> dict[str, float]:
345442
raw = result.get("metrics") if isinstance(result.get("metrics"), dict) else result
443+
annual_return = raw.get("annualReturn")
444+
if annual_return is None:
445+
annual_return = raw.get("annualizedReturn")
446+
if annual_return is None:
447+
annual_return = raw.get("annual_return")
346448
return {
347449
"totalReturn": _number(raw.get("totalReturn", raw.get("total_return"))),
348-
"annualReturn": _number(raw.get("annualReturn", raw.get("annual_return"))),
450+
"annualReturn": _number(annual_return),
349451
"maxDrawdown": _number(raw.get("maxDrawdown", raw.get("max_drawdown"))),
350452
"sharpeRatio": _number(raw.get("sharpeRatio", raw.get("sharpe_ratio"))),
351453
"winRate": _number(raw.get("winRate", raw.get("win_rate"))),

backend_api_python/app/routes/settings.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,8 +269,8 @@
269269
'key': 'SECRET_KEY',
270270
'label': 'Secret Key',
271271
'type': 'password',
272-
'default': 'quantdinger-secret-key-change-me',
273-
'description': 'JWT signing secret key. MUST change in production for security'
272+
'default': '',
273+
'description': 'Required JWT signing key (minimum 10 bytes for legacy compatibility; 32+ random bytes recommended)'
274274
},
275275
{
276276
'key': 'ADMIN_USER',
@@ -1605,6 +1605,13 @@
16051605
'default': '30',
16061606
'description': 'How often the background worker re-scans pending/paid orders against on-chain data.'
16071607
},
1608+
{
1609+
'key': 'BILLING_COST_BACKTEST',
1610+
'label': 'Backtest Cost',
1611+
'type': 'number',
1612+
'default': '30',
1613+
'description': 'Credits charged for each strategy backtest run'
1614+
},
16081615
{
16091616
'key': 'BILLING_COST_AI_ANALYSIS',
16101617
'label': 'AI Analysis Cost (per symbol)',

backend_api_python/app/routes/strategy.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
SCRIPT_STRATEGY_SYSTEM_PROMPT,
1818
)
1919
from app.services.strategy import redact_strategy_row
20+
from app.services.strategy_daily_pnl import load_strategy_daily_metrics
2021
from app.services.strategy_runtime.health import load_runtime_health
2122
from app.services.strategy_v2 import compile_strategy_v2
2223
from app.utils.auth import login_required
@@ -51,7 +52,7 @@ def _strategy(strategy_id: int):
5152
return get_strategy_service().get_strategy(int(strategy_id), user_id=int(g.user_id))
5253

5354

54-
def _attach_runtime_health(rows):
55+
def _attach_runtime_health(rows, *, user_id: int | None = None, client_timezone: str = ""):
5556
items = [dict(row) for row in (rows or [])]
5657
statuses = {
5758
int(row.get("id") or 0): str(row.get("status") or "")
@@ -61,14 +62,28 @@ def _attach_runtime_health(rows):
6162
health = load_runtime_health(statuses, strategy_statuses=statuses)
6263
for row in items:
6364
row["runtime_health"] = health.get(int(row.get("id") or 0), {})
65+
if user_id is not None:
66+
metrics = load_strategy_daily_metrics(
67+
items,
68+
user_id=int(user_id),
69+
client_timezone=str(client_timezone or ""),
70+
)
71+
for row in items:
72+
row.update(metrics.get(int(row.get("id") or 0), {}))
6473
return items
6574

6675

6776
@strategy_blp.route("/strategies", methods=["GET"])
6877
@login_required
6978
def list_strategies():
70-
rows = get_strategy_service().list_strategies(user_id=int(g.user_id))
71-
return _ok([redact_strategy_row(row) for row in _attach_runtime_health(rows)])
79+
user_id = int(g.user_id)
80+
rows = get_strategy_service().list_strategies(user_id=user_id)
81+
enriched = _attach_runtime_health(
82+
rows,
83+
user_id=user_id,
84+
client_timezone=request.headers.get("X-App-Timezone", ""),
85+
)
86+
return _ok([redact_strategy_row(row) for row in enriched])
7287

7388

7489
@strategy_blp.route("/strategies/<int:strategy_id>", methods=["GET"])
@@ -77,7 +92,11 @@ def get_strategy(strategy_id: int):
7792
row = _strategy(strategy_id)
7893
if not row:
7994
return _error("strategyV2.strategyNotFound", 404)
80-
return _ok(redact_strategy_row(_attach_runtime_health([row])[0]))
95+
return _ok(redact_strategy_row(_attach_runtime_health(
96+
[row],
97+
user_id=int(g.user_id),
98+
client_timezone=request.headers.get("X-App-Timezone", ""),
99+
)[0]))
81100

82101

83102
@strategy_blp.route("/strategies", methods=["POST"])

backend_api_python/app/services/billing_config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
DEFAULT_BILLING_CONFIG = {
88
"enabled": False,
9+
"cost_backtest": 30,
910
"cost_ai_analysis": 10,
1011
"cost_ai_code_gen": 30,
1112
"cost_ai_indicator_to_strategy": 30,
@@ -16,6 +17,7 @@
1617
}
1718

1819
FEATURE_NAMES = {
20+
"backtest": "Strategy Backtest",
1921
"ai_analysis": "AI Analysis",
2022
"ai_code_gen": "AI Code Generation",
2123
"ai_indicator_to_strategy": "AI Indicator to Strategy",

0 commit comments

Comments
 (0)