Skip to content

Commit f18d72d

Browse files
committed
fix(agent): unbreak async-job endpoints + document code contract
The async-job submit endpoints (`/backtests`, `/experiments/*`, `/quick-trade/orders` idempotent replay) all return a 500 with `TypeError: The view function did not return a valid response. ... but it was a tuple.` because `envelope()` already returns a `(jsonify, 200)` tuple, and the routes wrap it AGAIN as `return envelope(...), 202` — producing a nested `((Response, 200), 202)` Flask cannot decode. Fix: - Add `status=` kwarg to `envelope()` (defaults to 200), so callers pass status through the helper instead of wrapping the result. - Update 5 call sites to use `status=202` / drop the redundant `,200`. Verified end-to-end: a SMA(10/30) crossover backtest on BTC/USDT 1D now submits (HTTP 202), runs the indicator (`buy=2, sell=1` over 97 candles), reaches `_simulate_trading_new_format`, and returns a `succeeded` job with totalReturn=6.39%, sharpeRatio=0.73, maxDrawdown=-14.53%, 2 trades. Also: replace the existing AGENT_QUICKSTART.md backtest example (which used `output = {"signal": ...}` — never a supported shape) with a working SMA-crossover script, plus a new "code parameter contract" subsection that documents: - the pre-bound exec environment (`df`, `np`, `pd`, `params`, `call_indicator`, technical-indicator helpers like SMA/EMA/RSI/MACD/BOLL/ATR/CROSSOVER/CROSSUNDER) - the two supported signal shapes (2-way `df['buy']`/`df['sell']`, 4-way `df['open_long']`/`close_long`/`open_short`/`close_short`) - a parameterized trend-pullback example with `# @param` declarations
1 parent 365555c commit f18d72d

5 files changed

Lines changed: 70 additions & 10 deletions

File tree

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/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

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)