Skip to content

Commit 84d56f2

Browse files
authored
Merge pull request #96 from smc1263/security-and-agent-fix
Security: gate broker routes + CORS allow-list; fix agent token crash
2 parents 191edbc + f18d72d commit 84d56f2

9 files changed

Lines changed: 117 additions & 14 deletions

File tree

backend_api_python/app/__init__.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
QuantDinger Python API - Flask application factory.
33
"""
44
import math
5+
import os
56
import logging
67
import traceback
78

@@ -225,9 +226,21 @@ def create_app(config_name='default'):
225226
app.json = SafeJSONProvider(app)
226227

227228
app.config['JSON_AS_ASCII'] = False
228-
229-
CORS(app)
230-
229+
230+
# CORS — pin to specific origins instead of '*'. FRONTEND_URL accepts a
231+
# comma-separated list (e.g. "http://localhost:8888,http://localhost:8000")
232+
# so dev and prod frontends can both be allowed. Default covers the docker
233+
# frontend port (8888) and the Vue dev server port (8000).
234+
_cors_origins = [
235+
o.strip() for o in os.getenv(
236+
"FRONTEND_URL",
237+
"http://localhost:8888,http://localhost:8000",
238+
).split(",")
239+
if o.strip()
240+
]
241+
CORS(app, origins=_cors_origins)
242+
logger.info(f"CORS allowed origins: {_cors_origins}")
243+
231244
setup_logger()
232245

233246
# ib_insync uses asyncio across Flask + worker threads; without this, IBKR

backend_api_python/app/routes/agent_v1/_helpers.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,22 @@
66
from flask import jsonify, request
77

88

9-
def envelope(data: Any, *, message: str = "ok", code: int = 0) -> tuple:
9+
def envelope(data: Any, *, message: str = "ok", code: int = 0, status: int = 200) -> tuple:
1010
"""Standard agent-facing response envelope.
1111
1212
Distinct from the legacy human envelope (`code: 1, msg, data`) so client
1313
code targeting `/api/agent/v1` can rely on a single, stable shape.
14+
15+
The HTTP status defaults to 200; pass ``status=202`` for async-queue
16+
responses. Always returns a (Response, status) tuple so callers must
17+
NOT wrap the result in another tuple — that produces nested tuples
18+
Flask cannot decode.
1419
"""
1520
return jsonify({
1621
"code": code,
1722
"message": message,
1823
"data": data,
19-
}), 200
24+
}), status
2025

2126

2227
def error(code: int, message: str, *, details: Any = None, retriable: bool = False, http: int = 400):

backend_api_python/app/routes/agent_v1/admin.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,16 @@ def issue_token():
142142
paper_only, rate_limit, expires_at,
143143
),
144144
)
145-
row = cur.fetchone()
145+
# NOTE: app.utils.db_postgres' PostgresCursor wrapper silently
146+
# consumes the RETURNING row into its internal `_last_insert_id`
147+
# attribute, so cur.fetchone() here returns None. Re-fetch via
148+
# SELECT on the unique token_hash to recover id + created_at.
146149
db.commit()
150+
cur.execute(
151+
"SELECT id, created_at FROM qd_agent_tokens WHERE token_hash = %s",
152+
(token_hash,),
153+
)
154+
row = cur.fetchone()
147155
cur.close()
148156

149157
return envelope({

backend_api_python/app/routes/agent_v1/backtests.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def create_backtest():
9797
"job_id": existing["job_id"],
9898
"status": existing["status"],
9999
"duplicate": True,
100-
}, message="idempotent replay"), 200
100+
}, message="idempotent replay")
101101

102102
payload = dict(body)
103103
payload["__user_id"] = current_user_id()
@@ -108,4 +108,4 @@ def create_backtest():
108108
request_payload=payload,
109109
runner=_run_backtest,
110110
)
111-
return envelope(job, message="queued"), 202
111+
return envelope(job, message="queued", status=202)

backend_api_python/app/routes/agent_v1/experiments.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def submit_pipeline():
4444
"job_id": existing["job_id"],
4545
"status": existing["status"],
4646
"duplicate": True,
47-
}, message="idempotent replay"), 200
47+
}, message="idempotent replay")
4848

4949
payload = dict(body)
5050
payload["__user_id"] = current_user_id()
@@ -59,7 +59,7 @@ def _run(p):
5959
request_payload=payload,
6060
runner=_run,
6161
)
62-
return envelope(job, message="queued"), 202
62+
return envelope(job, message="queued", status=202)
6363

6464

6565
@agent_v1_bp.route("/experiments/structured-tune", methods=["POST"])
@@ -83,7 +83,7 @@ def _run(p):
8383
request_payload=payload,
8484
runner=_run,
8585
)
86-
return envelope(job, message="queued"), 202
86+
return envelope(job, message="queued", status=202)
8787

8888

8989
@agent_v1_bp.route("/experiments/ai-optimize", methods=["POST"])
@@ -118,4 +118,4 @@ def _run(p, on_progress):
118118
request_payload=payload,
119119
runner=_run,
120120
)
121-
return envelope(job, message="queued"), 202
121+
return envelope(job, message="queued", status=202)

backend_api_python/app/routes/agent_v1/quick_trade.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def place_order():
133133
return envelope({
134134
"duplicate": True,
135135
"previous": existing.get("result"),
136-
}, message="idempotent replay"), 200
136+
}, message="idempotent replay")
137137

138138
# Live trading is hard-gated. Even with paper_only=false on the token, the
139139
# operator must enable AGENT_LIVE_TRADING_ENABLED to actually route to

backend_api_python/app/routes/ibkr.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
from flask import Blueprint, request, jsonify
8+
from app.utils.auth import login_required
89

910
from app.utils.logger import get_logger
1011
from app.services.ibkr_trading import IBKRClient, IBKRConfig
@@ -29,6 +30,7 @@ def _get_client() -> IBKRClient:
2930
# ==================== Connection Management ====================
3031

3132
@ibkr_bp.route('/status', methods=['GET'])
33+
@login_required
3234
def get_status():
3335
"""
3436
Get connection status.
@@ -50,6 +52,7 @@ def get_status():
5052

5153

5254
@ibkr_bp.route('/connect', methods=['POST'])
55+
@login_required
5356
def connect():
5457
"""
5558
Connect to TWS / IB Gateway.
@@ -111,6 +114,7 @@ def connect():
111114

112115

113116
@ibkr_bp.route('/disconnect', methods=['POST'])
117+
@login_required
114118
def disconnect():
115119
"""
116120
Disconnect from IBKR.
@@ -141,6 +145,7 @@ def disconnect():
141145
# ==================== Account Queries ====================
142146

143147
@ibkr_bp.route('/account', methods=['GET'])
148+
@login_required
144149
def get_account():
145150
"""
146151
Get account information.
@@ -168,6 +173,7 @@ def get_account():
168173

169174

170175
@ibkr_bp.route('/positions', methods=['GET'])
176+
@login_required
171177
def get_positions():
172178
"""
173179
Get positions.
@@ -196,6 +202,7 @@ def get_positions():
196202

197203

198204
@ibkr_bp.route('/orders', methods=['GET'])
205+
@login_required
199206
def get_orders():
200207
"""
201208
Get open orders.
@@ -226,6 +233,7 @@ def get_orders():
226233
# ==================== Trading ====================
227234

228235
@ibkr_bp.route('/order', methods=['POST'])
236+
@login_required
229237
def place_order():
230238
"""
231239
Place an order.
@@ -313,6 +321,7 @@ def place_order():
313321

314322

315323
@ibkr_bp.route('/order/<int:order_id>', methods=['DELETE'])
324+
@login_required
316325
def cancel_order(order_id: int):
317326
"""
318327
Cancel an order.
@@ -351,6 +360,7 @@ def cancel_order(order_id: int):
351360
# ==================== Market Data ====================
352361

353362
@ibkr_bp.route('/quote', methods=['GET'])
363+
@login_required
354364
def get_quote():
355365
"""
356366
Get real-time quote.

backend_api_python/app/routes/mt5.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
from flask import Blueprint, request, jsonify
8+
from app.utils.auth import login_required
89

910
from app.utils.logger import get_logger
1011
from app.utils.local_brokers import (
@@ -50,6 +51,7 @@ def _get_client():
5051
# ==================== Connection Management ====================
5152

5253
@mt5_bp.route("/status", methods=["GET"])
54+
@login_required
5355
def get_status():
5456
"""Get MT5 connection status."""
5557
try:
@@ -75,6 +77,7 @@ def get_status():
7577

7678

7779
@mt5_bp.route("/connect", methods=["POST"])
80+
@login_required
7881
def connect():
7982
"""
8083
Connect to MT5 terminal.
@@ -154,6 +157,7 @@ def connect():
154157

155158

156159
@mt5_bp.route("/disconnect", methods=["POST"])
160+
@login_required
157161
def disconnect():
158162
"""Disconnect from MT5 terminal."""
159163
global _client
@@ -177,6 +181,7 @@ def disconnect():
177181
# ==================== Account Queries ====================
178182

179183
@mt5_bp.route("/account", methods=["GET"])
184+
@login_required
180185
def get_account():
181186
"""Get account information."""
182187
try:
@@ -195,6 +200,7 @@ def get_account():
195200

196201

197202
@mt5_bp.route("/positions", methods=["GET"])
203+
@login_required
198204
def get_positions():
199205
"""Get open positions."""
200206
try:
@@ -214,6 +220,7 @@ def get_positions():
214220

215221

216222
@mt5_bp.route("/orders", methods=["GET"])
223+
@login_required
217224
def get_orders():
218225
"""Get pending orders."""
219226
try:
@@ -233,6 +240,7 @@ def get_orders():
233240

234241

235242
@mt5_bp.route("/symbols", methods=["GET"])
243+
@login_required
236244
def get_symbols():
237245
"""Get available symbols."""
238246
try:
@@ -254,6 +262,7 @@ def get_symbols():
254262
# ==================== Trading ====================
255263

256264
@mt5_bp.route("/order", methods=["POST"])
265+
@login_required
257266
def place_order():
258267
"""
259268
Place an order.
@@ -333,6 +342,7 @@ def place_order():
333342

334343

335344
@mt5_bp.route("/close", methods=["POST"])
345+
@login_required
336346
def close_position():
337347
"""
338348
Close a position.
@@ -388,6 +398,7 @@ def close_position():
388398

389399

390400
@mt5_bp.route("/order/<int:ticket>", methods=["DELETE"])
401+
@login_required
391402
def cancel_order(ticket: int):
392403
"""Cancel a pending order."""
393404
try:
@@ -411,6 +422,7 @@ def cancel_order(ticket: int):
411422
# ==================== Market Data ====================
412423

413424
@mt5_bp.route("/quote", methods=["GET"])
425+
@login_required
414426
def get_quote():
415427
"""
416428
Get real-time quote.

docs/agent/AGENT_QUICKSTART.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ curl -s -X POST http://localhost:8888/api/agent/v1/backtests \
9999
-H "Content-Type: application/json" \
100100
-H "Idempotency-Key: ma-cross-2024-q1-001" \
101101
-d '{
102-
"code": "output = {\"signal\": df[\"close\"] > df[\"close\"].rolling(20).mean()}",
102+
"code": "fast = SMA(close, 10)\nslow = SMA(close, 30)\ndf[\"buy\"] = CROSSOVER(fast, slow).fillna(False).astype(bool)\ndf[\"sell\"] = CROSSUNDER(fast, slow).fillna(False).astype(bool)",
103103
"market": "Crypto",
104104
"symbol": "BTC/USDT",
105105
"timeframe": "1D",
@@ -120,6 +120,61 @@ When `status` becomes `succeeded`, the backtest result is in `result`.
120120
The `Idempotency-Key` header makes retries safe: the second call with the
121121
same key returns the original job instead of submitting a duplicate.
122122

123+
### 4.1 The `code` parameter contract
124+
125+
`code` is a **Python script** that the backend executes inside a sandbox.
126+
The script must mutate the pre-bound `df` DataFrame to add boolean signal
127+
columns. It is **not** a function, callable, or expression that returns
128+
signals — those shapes will fail validation in `_simulate_trading`.
129+
130+
**Pre-bound names** in the exec environment:
131+
132+
| Name | Type | Notes |
133+
|------|------|-------|
134+
| `df` | `pd.DataFrame` | Columns: `time, open, high, low, close, volume`. Mutate in place. |
135+
| `open`, `high`, `low`, `close`, `volume` | `pd.Series` | Convenience handles for the columns above |
136+
| `np`, `pd` | modules | Standard NumPy / pandas |
137+
| `params` | `dict` | Indicator params parsed from `# @param` comments + caller overrides |
138+
| `call_indicator(...)` | callable | Invoke another saved indicator from this script |
139+
| `SMA, EMA, RSI, MACD, BOLL, ATR, CROSSOVER, CROSSUNDER` | callables | Built-in technical helpers (see `app/services/backtest.py::_get_indicator_functions`) |
140+
141+
**Required output** — the script must add **either** of these to `df`:
142+
143+
| Style | Required columns | When to use |
144+
|-------|------------------|-------------|
145+
| 2-way (recommended) | `df['buy']`, `df['sell']` (boolean Series) | Most strategies — simple long-only or `trade_direction='both'` |
146+
| 4-way (advanced) | `df['open_long']`, `df['close_long']`, `df['open_short']`, `df['close_short']` (boolean Series) | When you need explicit control over each leg |
147+
148+
Minimal working SMA crossover:
149+
150+
```python
151+
fast = SMA(close, 10)
152+
slow = SMA(close, 30)
153+
df['buy'] = CROSSOVER(fast, slow).fillna(False).astype(bool)
154+
df['sell'] = CROSSUNDER(fast, slow).fillna(False).astype(bool)
155+
```
156+
157+
Trend-pullback with RSI filter (parameterized):
158+
159+
```python
160+
# @param fast_len int 20 Fast EMA length
161+
# @param slow_len int 50 Slow EMA length
162+
# @param rsi_floor float 45 Min RSI for long entry
163+
164+
ema_fast = EMA(close, params['fast_len'])
165+
ema_slow = EMA(close, params['slow_len'])
166+
rsi = RSI(close, 14)
167+
168+
raw_buy = (ema_fast > ema_slow) & (rsi >= params['rsi_floor'])
169+
raw_sell = (ema_fast < ema_slow)
170+
171+
df['buy'] = (raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))).astype(bool)
172+
df['sell'] = (raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))).astype(bool)
173+
```
174+
175+
See `docs/STRATEGY_DEV_GUIDE.md` for the full indicator-authoring guide,
176+
including TP/SL/trailing-stop hooks (`# @strategy ...` comments).
177+
123178
### 4.1 Stream partial results (SSE)
124179

125180
For long-running jobs (`ai-optimize`, `structured-tune`, multi-round

0 commit comments

Comments
 (0)