Skip to content

Commit 495e458

Browse files
v3.0.20
Signed-off-by: Dinger <[email protected]>
1 parent 3e49143 commit 495e458

26 files changed

Lines changed: 1974 additions & 381 deletions

.cursor/skills/quantdinger-agent-workflow/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ The Agent Gateway is mounted at **`/api/agent/v1`** by `app/routes/agent_v1/`.
4141
- Trading: `quick_trade.py` enforces paper-only by default; live execution
4242
requires both `paper_only=false` on the token AND env
4343
`AGENT_LIVE_TRADING_ENABLED=true`. Do not weaken this without explicit ask.
44-
- MCP: `mcp_server/` is a thin Python wrapper over R + B endpoints, with
45-
three transports selected by `QUANTDINGER_MCP_TRANSPORT`: `stdio` (default,
44+
- MCP: `mcp_server/` is a thin Python wrapper over R + W + B endpoints (no
45+
trading), with three transports selected by `QUANTDINGER_MCP_TRANSPORT`: `stdio` (default,
4646
desktop IDEs), `sse`, and `streamable-http` (cloud agents / remote IDEs;
4747
also bind `QUANTDINGER_MCP_HOST` / `QUANTDINGER_MCP_PORT`). Add new tools
4848
there only after exposing the underlying capability via REST.

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.0.18
1+
3.0.20

backend_api_python/app/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@
55
CI). Do **not** edit by hand — run the bump script instead.
66
"""
77

8-
APP_VERSION = "3.0.18"
8+
APP_VERSION = "3.0.20"

backend_api_python/app/routes/agent_v1/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def register(app) -> None:
4242
from . import portfolio # noqa: F401
4343
from . import quick_trade # noqa: F401
4444
from . import jobs as jobs_module # noqa: F401
45+
from . import indicators # noqa: F401
4546
from . import admin # noqa: F401
4647

4748
app.register_blueprint(agent_v1_bp, url_prefix="/api/agent/v1")
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Agent Gateway security helpers — secret redaction and payload bounds."""
2+
from __future__ import annotations
3+
4+
from typing import Any, Mapping, MutableMapping
5+
6+
# Upper bound for indicator Python source accepted via agent/MCP paths.
7+
MAX_INDICATOR_CODE_BYTES = 512 * 1024
8+
9+
# Keys stripped or masked anywhere in agent-facing JSON (case-sensitive).
10+
_SECRET_KEYS = frozenset({
11+
"api_key", "secret_key", "passphrase", "apiKey", "secret", "password",
12+
"private_key", "access_token", "refresh_token", "bot_token",
13+
"webhook_secret", "signing_secret", "client_secret",
14+
})
15+
16+
17+
def indicator_code_too_large(code: str) -> bool:
18+
return len((code or "").encode("utf-8")) > MAX_INDICATOR_CODE_BYTES
19+
20+
21+
def assert_indicator_code_size(code: str) -> None:
22+
if indicator_code_too_large(code):
23+
raise ValueError(
24+
f"Indicator code exceeds {MAX_INDICATOR_CODE_BYTES // 1024} KiB limit"
25+
)
26+
27+
28+
def redact_secrets(value: Any, *, depth: int = 0, max_depth: int = 6) -> Any:
29+
"""Return a copy with known credential fields masked."""
30+
if depth > max_depth:
31+
return value
32+
if isinstance(value, Mapping):
33+
out: dict[str, Any] = {}
34+
for k, v in value.items():
35+
key = str(k)
36+
if key in _SECRET_KEYS and v not in (None, "", False):
37+
out[key] = "***"
38+
elif isinstance(v, Mapping):
39+
out[key] = redact_secrets(v, depth=depth + 1, max_depth=max_depth)
40+
elif isinstance(v, list):
41+
out[key] = [
42+
redact_secrets(item, depth=depth + 1, max_depth=max_depth)
43+
for item in v
44+
]
45+
else:
46+
out[key] = v
47+
return out
48+
if isinstance(value, list):
49+
return [redact_secrets(item, depth=depth + 1, max_depth=max_depth) for item in value]
50+
return value
51+
52+
53+
def redact_strategy_row(row: dict | None) -> dict | None:
54+
"""Mask credential-like fields before returning a strategy to an agent."""
55+
if not row:
56+
return row
57+
out = dict(row)
58+
for field in ("exchange_config", "trading_config", "notification_config", "ai_model_config"):
59+
if isinstance(out.get(field), dict):
60+
out[field] = redact_secrets(out[field])
61+
return out

backend_api_python/app/routes/agent_v1/backtests.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from . import agent_v1_bp
1919
from ._helpers import envelope, error, get_json_or_400
20+
from ._security import assert_indicator_code_size
2021

2122
logger = get_logger(__name__)
2223
_backtest = BacktestService()
@@ -38,11 +39,13 @@ def _parse_date(s: Any) -> Any:
3839

3940

4041
def _run_backtest(payload: dict) -> Any:
41-
"""Adapter: call BacktestService.run with the agent payload shape.
42+
"""Adapter: call BacktestService.run_aligned with the agent payload shape."""
43+
from app.services.backtest_execution import (
44+
default_slippage_if_missing,
45+
merge_strict_mode_into_strategy_config,
46+
parse_strict_mode,
47+
)
4248

43-
The agent contract intentionally uses a small, snake_case required set
44-
so it is easy to remember. We map it onto the service's signature.
45-
"""
4649
code = (payload.get("code") or payload.get("indicator_code") or "").strip()
4750
if not code:
4851
raise ValueError("code (indicator code) is required")
@@ -58,19 +61,29 @@ def _run_backtest(payload: dict) -> Any:
5861
if not start_date or not end_date:
5962
raise ValueError("start_date and end_date are required (YYYY-MM-DD)")
6063

61-
return _backtest.run(
64+
strict_mode = parse_strict_mode(
65+
payload.get("strictMode", payload.get("strict_mode")),
66+
default=True,
67+
)
68+
strategy_config = merge_strict_mode_into_strategy_config(
69+
payload.get("strategy_config") or payload.get("strategyConfig") or {},
70+
strict_mode,
71+
)
72+
73+
return _backtest.run_aligned(
74+
strict_mode=strict_mode,
6275
indicator_code=code,
6376
market=market,
6477
symbol=symbol,
6578
timeframe=timeframe,
6679
start_date=start_date,
67-
end_date=end_date,
80+
end_date=end_date.replace(hour=23, minute=59, second=59),
6881
initial_capital=float(payload.get("initial_capital") or payload.get("initialCapital") or 10000),
6982
commission=float(payload.get("commission") or 0.001),
70-
slippage=float(payload.get("slippage") or 0.0),
83+
slippage=default_slippage_if_missing(payload.get("slippage")),
7184
leverage=int(payload.get("leverage") or 1),
7285
trade_direction=payload.get("trade_direction") or payload.get("tradeDirection") or "long",
73-
strategy_config=payload.get("strategy_config") or payload.get("strategyConfig") or {},
86+
strategy_config=strategy_config,
7487
indicator_params=payload.get("indicator_params") or payload.get("params") or {},
7588
user_id=int(payload.get("__user_id") or 1),
7689
)
@@ -86,6 +99,12 @@ def create_backtest():
8699

87100
market = body.get("market") or "Crypto"
88101
symbol = body.get("symbol")
102+
code = (body.get("code") or body.get("indicator_code") or "").strip()
103+
if code:
104+
try:
105+
assert_indicator_code_size(code)
106+
except ValueError as ve:
107+
return error(400, str(ve))
89108
if not market_allowed(market):
90109
return error(403, f"Market not allowed: {market}", http=403)
91110
if symbol and not instrument_allowed(symbol):
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Indicator workspace endpoints for external AI agents.
2+
3+
Read (R): contract, list, get, validate
4+
Write (W): save / update private indicators in ``qd_indicator_codes``
5+
"""
6+
from __future__ import annotations
7+
8+
from app.services.indicator_workspace import (
9+
get_indicator_authoring_contract,
10+
get_user_indicator,
11+
link_indicator_config,
12+
list_user_indicators,
13+
save_user_indicator,
14+
validate_indicator_code,
15+
)
16+
from app.utils.agent_auth import SCOPE_R, SCOPE_W, agent_required, current_user_id
17+
from app.utils.logger import get_logger
18+
from flask import request
19+
20+
from ._security import assert_indicator_code_size
21+
from . import agent_v1_bp
22+
from ._helpers import clip_int, envelope, error, get_json_or_400
23+
24+
logger = get_logger(__name__)
25+
26+
27+
@agent_v1_bp.route("/indicators/authoring-contract", methods=["GET"])
28+
@agent_required(SCOPE_R)
29+
def indicator_authoring_contract():
30+
"""Return starter template + required I/O contract for AI code generation."""
31+
return envelope(get_indicator_authoring_contract())
32+
33+
34+
@agent_v1_bp.route("/indicators", methods=["GET"])
35+
@agent_required(SCOPE_R)
36+
def list_indicators():
37+
"""List tenant indicators (compact; no code body)."""
38+
limit = clip_int(request.args.get("limit"), default=50, lo=1, hi=200)
39+
rows = list_user_indicators(current_user_id(), limit=limit)
40+
return envelope(rows)
41+
42+
43+
@agent_v1_bp.route("/indicators/<int:indicator_id>", methods=["GET"])
44+
@agent_required(SCOPE_R)
45+
def get_indicator(indicator_id: int):
46+
"""Fetch one indicator including ``code``."""
47+
row = get_user_indicator(current_user_id(), indicator_id)
48+
if not row:
49+
return error(404, "Indicator not found", http=404)
50+
return envelope(row)
51+
52+
53+
@agent_v1_bp.route("/indicators/validate", methods=["POST"])
54+
@agent_required(SCOPE_R)
55+
def validate_indicator():
56+
"""Sandbox-run indicator code without persisting."""
57+
body, err = get_json_or_400()
58+
if err:
59+
return err
60+
code = (body.get("code") or body.get("indicator_code") or "").strip()
61+
if not code:
62+
return error(400, "code is required")
63+
try:
64+
assert_indicator_code_size(code)
65+
except ValueError as ve:
66+
return error(400, str(ve))
67+
params = body.get("indicator_params") or body.get("params") or {}
68+
result = validate_indicator_code(code, params)
69+
return envelope(result, message="validated" if result.get("success") else "validation_failed")
70+
71+
72+
@agent_v1_bp.route("/indicators", methods=["POST"])
73+
@agent_required(SCOPE_W)
74+
def save_indicator():
75+
"""Save indicator into ``qd_indicator_codes`` (private; not community publish)."""
76+
body, err = get_json_or_400()
77+
if err:
78+
return err
79+
code = (body.get("code") or body.get("indicator_code") or "").strip()
80+
if not code:
81+
return error(400, "code is required")
82+
try:
83+
assert_indicator_code_size(code)
84+
except ValueError as ve:
85+
return error(400, str(ve))
86+
87+
validate_first = body.get("validate", True)
88+
if validate_first is not False and str(validate_first).lower() not in ("0", "false", "no"):
89+
validation = validate_indicator_code(
90+
code,
91+
body.get("indicator_params") or body.get("params") or {},
92+
)
93+
if not validation.get("success"):
94+
return error(
95+
400,
96+
validation.get("msg") or "Indicator validation failed",
97+
details=validation,
98+
http=400,
99+
)
100+
101+
try:
102+
indicator_id = int(body.get("id") or body.get("indicator_id") or 0)
103+
except (TypeError, ValueError):
104+
indicator_id = 0
105+
106+
try:
107+
new_id = save_user_indicator(
108+
user_id=current_user_id(),
109+
code=code,
110+
name=body.get("name") or body.get("indicator_name"),
111+
description=body.get("description") or body.get("indicator_description"),
112+
indicator_id=indicator_id,
113+
)
114+
except ValueError as ve:
115+
return error(400, str(ve))
116+
except Exception as exc:
117+
logger.error(f"agent_v1/indicators save failed: {exc}", exc_info=True)
118+
return error(500, "save_indicator failed", details=str(exc), http=500)
119+
120+
row = get_user_indicator(current_user_id(), new_id)
121+
return envelope(
122+
{"indicator_id": new_id, "indicator": row},
123+
message="saved",
124+
)
125+
126+
127+
@agent_v1_bp.route("/indicators/link-config", methods=["POST"])
128+
@agent_required(SCOPE_W)
129+
def link_indicator():
130+
"""Normalize ``indicator_config`` dict: auto-save embedded code + set indicator_id."""
131+
body, err = get_json_or_400()
132+
if err:
133+
return err
134+
ic = body.get("indicator_config") or body
135+
linked = link_indicator_config(current_user_id(), ic, auto_save=True)
136+
return envelope(linked, message="linked")

backend_api_python/app/routes/agent_v1/strategies.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from . import agent_v1_bp
1818
from ._helpers import clip_int, envelope, error, get_json_or_400
19+
from ._security import redact_strategy_row
1920

2021
logger = get_logger(__name__)
2122
_strategy_service = StrategyService()
@@ -52,15 +53,15 @@ def list_strategies():
5253
@agent_v1_bp.route("/strategies/<int:strategy_id>", methods=["GET"])
5354
@agent_required(SCOPE_R)
5455
def get_strategy(strategy_id: int):
55-
"""Tenant-scoped strategy lookup."""
56+
"""Tenant-scoped strategy lookup (includes indicator_config snapshot)."""
5657
try:
5758
row = _strategy_service.get_strategy(strategy_id, user_id=current_user_id())
5859
except Exception as exc:
5960
logger.error(f"agent_v1/strategies get failed: {exc}", exc_info=True)
6061
return error(500, "get_strategy failed", details=str(exc), http=500)
6162
if not row:
6263
return error(404, "Strategy not found", http=404)
63-
return envelope(row)
64+
return envelope(redact_strategy_row(row))
6465

6566

6667
@agent_v1_bp.route("/strategies", methods=["POST"])
@@ -83,6 +84,16 @@ def create_strategy():
8384
payload["user_id"] = current_user_id()
8485
payload.setdefault("status", "stopped") # never auto-start from agent path
8586

87+
if (payload.get("strategy_type") or "IndicatorStrategy") == "IndicatorStrategy":
88+
from app.services.indicator_workspace import link_indicator_config
89+
ic = payload.get("indicator_config") or {}
90+
if isinstance(ic, dict) and (ic.get("indicator_code") or ic.get("code")):
91+
payload["indicator_config"] = link_indicator_config(
92+
current_user_id(),
93+
ic,
94+
auto_save=True,
95+
)
96+
8697
try:
8798
new_id = _strategy_service.create_strategy(payload)
8899
except ValueError as ve:

0 commit comments

Comments
 (0)