Skip to content
Merged
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ and deployment remain under the operator's control.

### Strategy evolution and signal-only virtual accounts

- **Factor Lab** loads one point-in-time universe panel once and benchmarks up
to 40 registered technical, fundamental, TA-Lib, or Qlib factors. It uses a
time-ordered holdout, out-of-sample IC retention, Benjamini-Hochberg
false-discovery control, cost-aware scoring, factor-life-cycle labels, and
cross-factor IC correlation. The same workflow is available from the web UI,
Human API, Agent Gateway, and MCP.
- **Strategy evolution** searches declared tunable parameters with random,
grid, or TPE optimization, using bar-count walk-forward validation and a final
blind holdout. Jobs run asynchronously with per-strategy history, automatic
Expand Down
4 changes: 4 additions & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ QuantDinger 是面向独立交易者、Python 策略开发者和小型团队的*

### 策略进化与仅通知虚拟账户

- **因子实验室**:一次加载同一个时点化标的池面板,批量评估最多 40 个技术、基本面、
TA-Lib 或 Qlib 因子;使用按时间切分的样本外验证、IC 保持率、Benjamini-Hochberg
错误发现率控制、成本感知评分、因子生命周期分类和跨因子 IC 相关矩阵。Web 界面、
Human API、Agent Gateway 与 MCP 均可调用同一套流程。
- **策略进化**:对策略声明的可调参数执行随机、网格或 TPE 搜索,按实际 K 线数量进行
walk-forward 验证,并保留最终盲测区间。研究任务异步运行且按策略保存历史,支持自动剪枝、
综合评分、PBO、Deflated Sharpe、区块自助法蒙特卡洛和交易成本压力测试。结果用于比较参数
Expand Down
113 changes: 112 additions & 1 deletion backend_api_python/app/routes/agent_v1/research.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Research workspace endpoints: universes, factors, and watchlists."""
from __future__ import annotations

from datetime import datetime
from flask import request

from app.services.factors import (
Expand All @@ -16,12 +17,56 @@
remove_watchlist_item,
)
from app.services.universe import UniverseError, UniverseService
from app.utils.agent_auth import SCOPE_R, SCOPE_W, agent_required, current_user_id
from app.services.strategy_v2 import StrategyV2BacktestService
from app.utils.agent_auth import (
SCOPE_B,
SCOPE_R,
SCOPE_W,
agent_required,
current_token,
current_user_id,
instrument_allowed,
market_allowed,
with_idempotency,
)
from app.utils.agent_jobs import _job_receipt, submit_job

from . import agent_v1_bp
from ._helpers import clip_int, envelope, error, get_json_or_400

_universes = UniverseService()
_factor_benchmark = StrategyV2BacktestService(universe_service=_universes)


def _run_factor_benchmark(payload: dict, on_progress=None):
if on_progress:
on_progress({"phase": "loading_universe", "percent": 5})
start_date = datetime.strptime(str(payload["startDate"]), "%Y-%m-%d")
end_date = datetime.strptime(str(payload["endDate"]), "%Y-%m-%d").replace(
hour=23, minute=59, second=59
)
result = _factor_benchmark.benchmark_universe_factors(
user_id=int(payload["__user_id"]),
universe_id=int(payload["universeId"]),
frequency=str(payload.get("frequency") or "1d"),
start_date=start_date,
end_date=end_date,
factors=list(payload.get("factors") or []),
groups=int(payload.get("groups") or 5),
holding_period=int(payload.get("holdingPeriod") or 5),
commission=float(payload.get("commission") or 0.0005),
slippage=float(payload.get("slippage") or 0.0005),
neutralize_industry=bool(payload.get("neutralizeIndustry", False)),
train_fraction=float(payload.get("trainFraction") or 0.6),
false_discovery_rate=float(payload.get("falseDiscoveryRate") or 0.1),
minimum_oos_observations=int(payload.get("minimumOosObservations") or 5),
minimum_abs_oos_ic=float(payload.get("minimumAbsOosIc") or 0.02),
minimum_coverage=float(payload.get("minimumCoverage") or 0.5),
)
result.pop("factorResults", None)
if on_progress:
on_progress({"phase": "validated", "percent": 100})
return result


@agent_v1_bp.route("/research/universes", methods=["GET"])
Expand Down Expand Up @@ -78,6 +123,72 @@ def get_research_factor(factor_id: str):
return error(404, exc.code, http=404)


@agent_v1_bp.route("/research/factor-benchmarks", methods=["POST"])
@agent_required(SCOPE_B)
def create_factor_benchmark():
body, err = get_json_or_400()
if err:
return err
allowed_fields = {
"universeId", "frequency", "startDate", "endDate", "factors",
"groups", "holdingPeriod", "commission", "slippage",
"neutralizeIndustry", "trainFraction", "falseDiscoveryRate",
"minimumOosObservations", "minimumAbsOosIc", "minimumCoverage",
}
unsupported = sorted(set(body) - allowed_fields)
if unsupported:
return error(400, f"Unsupported factor benchmark fields: {', '.join(unsupported)}")
try:
universe_id = int(body.get("universeId") or 0)
datetime.strptime(str(body.get("startDate") or ""), "%Y-%m-%d")
datetime.strptime(str(body.get("endDate") or ""), "%Y-%m-%d")
except (TypeError, ValueError):
return error(400, "universeId and YYYY-MM-DD startDate/endDate are required")
factors = body.get("factors")
if not isinstance(factors, list) or len(factors) < 2:
return error(400, "factors must contain at least two factor definitions")
try:
universe = _universes.get_universe(current_user_id(), universe_id)
members = _universes.candidate_members(
current_user_id(),
universe_id,
start=datetime.strptime(str(body["startDate"]), "%Y-%m-%d").date(),
end=datetime.strptime(str(body["endDate"]), "%Y-%m-%d").date(),
)
except UniverseError as exc:
return error(exc.status_code, exc.code, http=exc.status_code)
market = str(universe.get("market") or "")
if market and not market_allowed(market):
return error(403, f"Market not allowed: {market}", http=403)
for member in members:
member_market = str(member.get("market") or market)
symbol = str(member.get("symbol") or "")
if member_market and not market_allowed(member_market):
return error(403, f"Market not allowed: {member_market}", http=403)
if symbol and not instrument_allowed(symbol):
return error(403, f"Instrument not allowed: {symbol}", http=403)

with with_idempotency("factor_benchmark") as existing:
if existing:
return envelope(
_job_receipt(existing, duplicate=True),
message="idempotent replay",
)
payload = dict(body)
payload["__user_id"] = current_user_id()
job = submit_job(
user_id=current_user_id(),
agent_token_id=int(current_token().get("id") or 0),
kind="factor_benchmark",
request_payload=payload,
runner=_run_factor_benchmark,
idempotency_key=request.headers.get("Idempotency-Key"),
)
if job["status"] == "failed":
return error(503, "AGENT_JOB_DISPATCH_FAILED", details=job, http=503)
return envelope(job, message="queued", status=202)


@agent_v1_bp.route("/research/watchlist", methods=["GET"])
@agent_required(SCOPE_R)
def get_research_watchlist():
Expand Down
Loading
Loading