diff --git a/README.md b/README.md index 58d77fea8..e45b1ae9d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_CN.md b/README_CN.md index b5657d522..ceb423160 100644 --- a/README_CN.md +++ b/README_CN.md @@ -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、区块自助法蒙特卡洛和交易成本压力测试。结果用于比较参数 diff --git a/backend_api_python/app/routes/agent_v1/research.py b/backend_api_python/app/routes/agent_v1/research.py index 38dff9712..b03b7fa6e 100644 --- a/backend_api_python/app/routes/agent_v1/research.py +++ b/backend_api_python/app/routes/agent_v1/research.py @@ -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 ( @@ -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"]) @@ -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(): diff --git a/backend_api_python/app/routes/backtest_center.py b/backend_api_python/app/routes/backtest_center.py index dd63c1901..cbc0df262 100644 --- a/backend_api_python/app/routes/backtest_center.py +++ b/backend_api_python/app/routes/backtest_center.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from copy import deepcopy from datetime import datetime from typing import Any from uuid import uuid4 @@ -25,6 +26,7 @@ StrategyBacktestRepository, StrategyV2BacktestService, ) +from app.services.strategy_v2.factor_benchmark import FactorBenchmarkEngine from app.utils.auth import login_required from app.utils.logger import get_logger from app.utils import agent_jobs @@ -36,6 +38,28 @@ _repository: StrategyBacktestRepository | None = None _factor_repository: FactorResearchRepository | None = None +_PUBLIC_REQUEST_ERROR_KEYS = frozenset({ + "factor.outputNotFound", + "strategyV2.codeRequired", + "strategyV2.dateRangeRequired", + "strategyV2.factorBenchmarkFactorsRequired", + "strategyV2.factorBenchmarkIdRequired", + "strategyV2.factorResearchInsufficientObservations", + "strategyV2.factorResearchNoData", + "strategyV2.factorResearchUniverseRequired", + "strategyV2.runIdRequired", + "strategyV2.sourceContractRequired", + "strategyV2.sourceNotFound", + "strategyV2.strategyNotFound", +}) + + +def _public_request_error(exc: BaseException) -> str: + value = exc.args[0] if exc.args else None + if isinstance(value, str) and value in _PUBLIC_REQUEST_ERROR_KEYS: + return value + return "strategyV2.generationInvalid" + def get_strategy_backtest_service() -> StrategyV2BacktestService: global _service @@ -246,9 +270,14 @@ def create_strategy_backtest_job(): "msg": exc.code, "data": {"error_type": exc.code, **exc.details}, }), exc.status - except (BacktestRangeLimitError, ValueError) as exc: - data = exc.details if isinstance(exc, BacktestRangeLimitError) else None - return jsonify({"code": 0, "msg": str(exc), "data": data}), 400 + except BacktestRangeLimitError as exc: + return jsonify({ + "code": 0, + "msg": "strategyV2.backtestRangeLimit", + "data": exc.details, + }), 400 + except ValueError as exc: + return jsonify({"code": 0, "msg": _public_request_error(exc), "data": None}), 400 except Exception: logger.exception("Strategy backtest job submission failed") return jsonify({"code": 0, "msg": "strategyV2.backtest.runFailed", "data": None}), 500 @@ -318,14 +347,18 @@ def run_strategy_backtest(): }) except BacktestRangeLimitError as exc: _refund_backtest_credits(billing, user_id, charge) - return jsonify({"code": 0, "msg": str(exc), "data": exc.details}), 400 + return jsonify({ + "code": 0, + "msg": "strategyV2.backtestRangeLimit", + "data": exc.details, + }), 400 except ValueError as exc: _refund_backtest_credits(billing, user_id, charge) - return jsonify({"code": 0, "msg": str(exc), "data": None}), 400 - except Exception as exc: + return jsonify({"code": 0, "msg": _public_request_error(exc), "data": None}), 400 + except Exception: _refund_backtest_credits(billing, user_id, charge) logger.exception("Strategy backtest failed") - return jsonify({"code": 0, "msg": str(exc), "data": None}), 500 + return jsonify({"code": 0, "msg": "strategyV2.backtest.runFailed", "data": None}), 500 @backtest_center_blp.route("/factor-research", methods=["POST"]) @@ -417,28 +450,309 @@ def run_factor_research(): ) return jsonify({"code": 1, "msg": "success", "data": {**result, "runId": run_id}}) except BacktestRangeLimitError as exc: - return jsonify({"code": 0, "msg": str(exc), "data": exc.details}), 400 + return jsonify({ + "code": 0, + "msg": "strategyV2.backtestRangeLimit", + "data": exc.details, + }), 400 except ValueError as exc: - return jsonify({"code": 0, "msg": str(exc), "data": None}), 400 - except Exception as exc: + return jsonify({"code": 0, "msg": _public_request_error(exc), "data": None}), 400 + except Exception: logger.exception("Factor research failed") - return jsonify({"code": 0, "msg": str(exc), "data": None}), 500 + return jsonify({"code": 0, "msg": "strategyV2.backtest.runFailed", "data": None}), 500 @backtest_center_blp.route("/factor-research/history", methods=["GET"]) @login_required def list_factor_research_runs(): try: + research_type = str(request.args.get("researchType") or "all").strip().lower() + if research_type not in {"all", "single", "benchmark"}: + research_type = "all" rows = get_factor_research_repository().list_runs( user_id=int(g.user_id), source_id=_positive_int(request.args.get("sourceId")), + research_type=research_type, limit=max(1, min(200, int(request.args.get("limit") or 50))), offset=max(0, int(request.args.get("offset") or 0)), ) return jsonify({"code": 1, "msg": "success", "data": rows}) - except Exception as exc: + except Exception: logger.exception("Factor research history query failed") - return jsonify({"code": 0, "msg": str(exc), "data": None}), 500 + return jsonify({"code": 0, "msg": "strategyV2.backtest.runFailed", "data": None}), 500 + + +@backtest_center_blp.route("/factor-benchmark", methods=["POST"]) +@backtest_center_blp.doc( + summary="Benchmark multiple factors with out-of-sample validation", + description=( + "Loads a managed universe once, evaluates multiple factors, applies " + "out-of-sample validation and Benjamini-Hochberg false-discovery control, " + "then persists every successful factor run." + ), +) +@login_required +def run_factor_benchmark(): + try: + payload = request.get_json(silent=True) or {} + start_raw = str(payload.get("startDate") or "").strip() + end_raw = str(payload.get("endDate") or "").strip() + universe_id = _positive_int(payload.get("universeId")) + if not start_raw or not end_raw: + raise ValueError("strategyV2.dateRangeRequired") + if not universe_id: + raise ValueError("strategyV2.factorResearchUniverseRequired") + factors = payload.get("factors") + if not isinstance(factors, list): + raise ValueError("strategyV2.factorBenchmarkFactorsRequired") + groups = max(2, min(10, int(payload.get("groups") or 5))) + holding_period = max(1, int(payload.get("holdingPeriod") or 5)) + commission = parse_rate( + payload.get("commission"), + default=default_commission_if_missing(None), + ) + slippage = parse_rate( + payload.get("slippage"), + default=default_slippage_if_missing(None), + ) + neutralize_industry = bool(payload.get("neutralizeIndustry", False)) + result = get_strategy_backtest_service().benchmark_universe_factors( + user_id=int(g.user_id), + universe_id=universe_id, + frequency=str(payload.get("frequency") or "1d"), + start_date=datetime.strptime(start_raw, "%Y-%m-%d"), + end_date=datetime.strptime(end_raw, "%Y-%m-%d").replace( + hour=23, minute=59, second=59 + ), + factors=factors, + groups=groups, + holding_period=holding_period, + commission=commission, + slippage=slippage, + neutralize_industry=neutralize_industry, + 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), + ) + benchmark_id = uuid4().hex + result["benchmarkId"] = benchmark_id + benchmark_snapshot = deepcopy(result) + benchmark_snapshot.pop("factorResults", None) + manifest = dict(result.get("manifest") or {}) + universe = dict(result.get("universe") or {}) + ranking_by_factor = { + str(item.get("factorId") or ""): item + for item in result.get("rankings") or [] + } + run_ids: dict[str, int | None] = {} + for factor_result in result.get("factorResults") or []: + factor_id = str(factor_result.get("factorId") or "") + ranking = ranking_by_factor.get(factor_id, {}) + factor_result.update({ + "benchmarkId": benchmark_id, + "benchmarkSnapshot": benchmark_snapshot, + "benchmarkRank": ranking.get("rank"), + "benchmarkClassification": ranking.get("classification"), + "benchmarkScore": ranking.get("score"), + "benchmarkPValue": ranking.get("pValue"), + "benchmarkQValue": ranking.get("qValue"), + "manifest": manifest, + "universeId": universe_id, + "universe": universe, + "frequency": result.get("frequency"), + "symbolsRequested": result.get("symbolsRequested"), + "symbolsUsed": result.get("symbolsUsed"), + "symbolsSkipped": result.get("symbolsSkipped"), + }) + run_id = get_factor_research_repository().persist_run( + user_id=int(g.user_id), + source_id=None, + source_name="", + universe_id=universe_id, + universe_name=str(universe.get("name") or universe.get("code") or ""), + research_mode="benchmark", + market=",".join(manifest.get("markets") or []), + timeframe=str(result.get("frequency") or ""), + start_date=start_raw, + end_date=end_raw, + factor_id=factor_id, + groups=int(factor_result.get("effectiveGroups") or groups), + holding_period=holding_period, + commission=commission, + slippage=slippage, + neutralize_industry=neutralize_industry, + manifest=manifest, + result=factor_result, + code="", + ) + run_ids[factor_id] = run_id + for ranking in result.get("rankings") or []: + ranking["runId"] = run_ids.get(str(ranking.get("factorId") or "")) + result.pop("factorResults", None) + return jsonify({"code": 1, "msg": "success", "data": result}) + except BacktestRangeLimitError as exc: + return jsonify({ + "code": 0, + "msg": "strategyV2.backtestRangeLimit", + "data": exc.details, + }), 400 + except ValueError as exc: + return jsonify({"code": 0, "msg": _public_request_error(exc), "data": None}), 400 + except Exception: + logger.exception("Factor benchmark failed") + return jsonify({"code": 0, "msg": "strategyV2.backtest.runFailed", "data": None}), 500 + + +def _benchmark_snapshot_from_runs( + benchmark_id: str, + runs: list[dict[str, Any]], +) -> dict[str, Any]: + for run in runs: + result = dict(run.get("result") or {}) + snapshot = result.get("benchmarkSnapshot") + if isinstance(snapshot, dict) and snapshot: + output = deepcopy(snapshot) + output["benchmarkId"] = benchmark_id + output["researchMode"] = "benchmark" + return output + + factor_results = [ + dict(run.get("result") or {}) + for run in runs + if isinstance(run.get("result"), dict) + ] + if not factor_results: + return {} + benchmark = FactorBenchmarkEngine().run(factor_results) + first_run = runs[0] + first_result = factor_results[0] + benchmark.update({ + "benchmarkId": benchmark_id, + "researchMode": "benchmark", + "manifest": first_run.get("manifest") or first_result.get("manifest") or {}, + "universe": first_result.get("universe") or { + "id": first_run.get("universe_id"), + "name": first_run.get("universe_name"), + }, + "frequency": first_result.get("frequency") or first_run.get("timeframe"), + "symbolsRequested": first_result.get("symbolsRequested"), + "symbolsUsed": first_result.get("symbolsUsed") or first_run.get("universe_size"), + "symbolsSkipped": first_result.get("symbolsSkipped") or [], + "errors": [], + }) + benchmark["summary"]["requestedFactorCount"] = len(factor_results) + return benchmark + + +def _factor_benchmark_history_items(runs: list[dict[str, Any]]) -> list[dict[str, Any]]: + grouped: dict[str, list[dict[str, Any]]] = {} + for run in runs: + result = dict(run.get("result") or {}) + benchmark_id = str(result.get("benchmarkId") or "").strip() + if benchmark_id: + grouped.setdefault(benchmark_id, []).append(run) + + items: list[dict[str, Any]] = [] + for benchmark_id, group in grouped.items(): + snapshot = _benchmark_snapshot_from_runs(benchmark_id, group) + rankings = list(snapshot.get("rankings") or []) + summary = dict(snapshot.get("summary") or {}) + first = group[0] + validated_count = sum( + str(row.get("classification") or "") in {"alive", "reversed"} + for row in rankings + ) + items.append({ + "id": benchmark_id, + "benchmark_id": benchmark_id, + "history_type": "benchmark", + "universe_id": first.get("universe_id"), + "universe_name": first.get("universe_name"), + "market": first.get("market"), + "timeframe": first.get("timeframe"), + "start_date": first.get("start_date"), + "end_date": first.get("end_date"), + "created_at": first.get("created_at"), + "factor_count": int(summary.get("requestedFactorCount") or len(group)), + "completed_count": len(rankings) or len(group), + "validated_count": validated_count, + "best_factor_id": summary.get("bestFactorId") or ( + rankings[0].get("factorId") if rankings else None + ), + "best_score": rankings[0].get("score") if rankings else None, + "failed_count": len(snapshot.get("errors") or []), + }) + return sorted(items, key=lambda item: str(item.get("created_at") or ""), reverse=True) + + +@backtest_center_blp.route("/factor-benchmark/history", methods=["GET"]) +@login_required +def list_factor_benchmark_runs(): + try: + limit = max(1, min(100, int(request.args.get("limit") or 24))) + runs = get_factor_research_repository().list_benchmark_runs( + user_id=int(g.user_id), + limit=min(5000, limit * 50), + ) + return jsonify({ + "code": 1, + "msg": "success", + "data": _factor_benchmark_history_items(runs)[:limit], + }) + except Exception: + logger.exception("Factor benchmark history query failed") + return jsonify({"code": 0, "msg": "strategyV2.backtest.runFailed", "data": None}), 500 + + +@backtest_center_blp.route("/factor-benchmark/get", methods=["GET"]) +@login_required +def get_factor_benchmark_run(): + try: + benchmark_id = str(request.args.get("benchmarkId") or "").strip() + if not benchmark_id: + raise ValueError("strategyV2.factorBenchmarkIdRequired") + runs = get_factor_research_repository().list_benchmark_runs( + user_id=int(g.user_id), + benchmark_id=benchmark_id, + ) + if not runs: + return jsonify({ + "code": 0, + "msg": "strategyV2.factorBenchmarkRunNotFound", + "data": None, + }), 404 + first = runs[0] + return jsonify({ + "code": 1, + "msg": "success", + "data": { + "id": benchmark_id, + "benchmark_id": benchmark_id, + "research_mode": "benchmark", + "universe_id": first.get("universe_id"), + "universe_name": first.get("universe_name"), + "market": first.get("market"), + "timeframe": first.get("timeframe"), + "start_date": first.get("start_date"), + "end_date": first.get("end_date"), + "commission": first.get("commission"), + "slippage": first.get("slippage"), + "groups_count": first.get("groups_count"), + "holding_period": first.get("holding_period"), + "neutralize_industry": first.get("neutralize_industry"), + "created_at": first.get("created_at"), + "result": _benchmark_snapshot_from_runs(benchmark_id, runs), + }, + }) + except ValueError as exc: + return jsonify({"code": 0, "msg": _public_request_error(exc), "data": None}), 400 + except Exception: + logger.exception("Factor benchmark detail query failed") + return jsonify({"code": 0, "msg": "strategyV2.backtest.runFailed", "data": None}), 500 @backtest_center_blp.route("/factor-research/get", methods=["GET"]) @@ -456,10 +770,10 @@ def get_factor_research_run(): return jsonify({"code": 0, "msg": "strategyV2.factorResearchRunNotFound", "data": None}), 404 return jsonify({"code": 1, "msg": "success", "data": row}) except ValueError as exc: - return jsonify({"code": 0, "msg": str(exc), "data": None}), 400 - except Exception as exc: + return jsonify({"code": 0, "msg": _public_request_error(exc), "data": None}), 400 + except Exception: logger.exception("Factor research lookup failed") - return jsonify({"code": 0, "msg": str(exc), "data": None}), 500 + return jsonify({"code": 0, "msg": "strategyV2.backtest.runFailed", "data": None}), 500 @backtest_center_blp.route("/history", methods=["GET"]) @@ -476,9 +790,9 @@ def list_strategy_backtests(): offset=max(0, int(request.args.get("offset") or 0)), ) return jsonify({"code": 1, "msg": "success", "data": rows}) - except Exception as exc: + except Exception: logger.exception("Backtest history query failed") - return jsonify({"code": 0, "msg": str(exc), "data": None}), 500 + return jsonify({"code": 0, "msg": "strategyV2.backtest.runFailed", "data": None}), 500 @backtest_center_blp.route("/get", methods=["GET"]) @@ -493,10 +807,10 @@ def get_strategy_backtest(): return jsonify({"code": 0, "msg": "strategyV2.runNotFound", "data": None}), 404 return jsonify({"code": 1, "msg": "success", "data": row}) except ValueError as exc: - return jsonify({"code": 0, "msg": str(exc), "data": None}), 400 - except Exception as exc: + return jsonify({"code": 0, "msg": _public_request_error(exc), "data": None}), 400 + except Exception: logger.exception("Backtest lookup failed") - return jsonify({"code": 0, "msg": str(exc), "data": None}), 500 + return jsonify({"code": 0, "msg": "strategyV2.backtest.runFailed", "data": None}), 500 def _positive_int(value: Any) -> int | None: diff --git a/backend_api_python/app/routes/factors.py b/backend_api_python/app/routes/factors.py index 6aa91cf3a..bff7a2d26 100644 --- a/backend_api_python/app/routes/factors.py +++ b/backend_api_python/app/routes/factors.py @@ -1,6 +1,6 @@ """Factor catalog and research APIs.""" -from flask import jsonify, request +from flask import g, jsonify, request from app.openapi.blueprint import HumanBlueprint as Blueprint from app.services.factors import ( @@ -12,6 +12,14 @@ ) from app.services.factors.research import information_coefficient, quantile_returns, winsorize_zscore from app.services.fundamental_data import get_fundamental_data_service +from app.services.custom_factors import ( + CustomFactorError, + analyze_custom_factor, + archive_custom_factor, + get_custom_factor, + list_custom_factors, + save_custom_factor, +) from app.utils.auth import login_required from app.utils.logger import get_logger @@ -32,6 +40,12 @@ def factor_catalog(): factor_type=factor_type, include_feature_families=True, ) + custom = list_custom_factors(int(g.user_id)) + if category: + custom = [item for item in custom if item.get("category") == category] + if factor_type: + custom = [item for item in custom if item.get("factor_type") == factor_type] + data.extend(custom) if provider in ("ta-lib", "talib"): data = [item for item in data if item.get("provider") == "ta-lib"] elif provider in ("microsoft-qlib", "qlib"): @@ -48,6 +62,7 @@ def factor_catalog(): "builtInCount": len(data) - talib_count - qlib_count, "talibCount": talib_count, "qlibCount": qlib_count, + "customCount": len(custom), "talibAvailable": is_talib_available(), }, }) @@ -62,8 +77,53 @@ def factor_catalog(): @login_required def factor_detail(factor_id: str): try: + if factor_id.lower().startswith("custom:"): + return jsonify({"code": 1, "msg": "success", "data": get_custom_factor(int(g.user_id), factor_id)}) return jsonify({"code": 1, "msg": "success", "data": get_factor_metadata(factor_id)}) - except (FactorError, TalibFactorError) as exc: + except (CustomFactorError, FactorError, TalibFactorError) as exc: + return jsonify({"code": 0, "msg": exc.code, "data": None}), 404 + + +@factors_blp.route("/custom/validate", methods=["POST"]) +@login_required +def validate_custom_factor(): + try: + payload = request.get_json(silent=True) or {} + params = payload.get("defaultParams") or {} + analysis = analyze_custom_factor(str(payload.get("expression") or ""), params) + return jsonify({ + "code": 1, + "msg": "success", + "data": { + "requiredFields": list(analysis.required_fields), + "dependencies": list(analysis.dependencies), + "warmupBars": analysis.warmup_bars, + }, + }) + except CustomFactorError as exc: + return jsonify({"code": 0, "msg": exc.code, "data": None}), 400 + + +@factors_blp.route("/custom", methods=["POST"]) +@login_required +def create_custom_factor(): + try: + data = save_custom_factor(int(g.user_id), request.get_json(silent=True) or {}) + return jsonify({"code": 1, "msg": "success", "data": data}), 201 + except CustomFactorError as exc: + return jsonify({"code": 0, "msg": exc.code, "data": None}), 400 + except Exception: + logger.exception("custom factor creation failed") + return jsonify({"code": 0, "msg": "factor.customSaveFailed", "data": None}), 500 + + +@factors_blp.route("/custom/", methods=["DELETE"]) +@login_required +def delete_custom_factor(factor_id: int): + try: + archive_custom_factor(int(g.user_id), factor_id) + return jsonify({"code": 1, "msg": "success", "data": {"id": factor_id}}) + except CustomFactorError as exc: return jsonify({"code": 0, "msg": exc.code, "data": None}), 404 diff --git a/backend_api_python/app/routes/quick_trade.py b/backend_api_python/app/routes/quick_trade.py index d9ab6effe..0f21c01bc 100644 --- a/backend_api_python/app/routes/quick_trade.py +++ b/backend_api_python/app/routes/quick_trade.py @@ -11,6 +11,7 @@ GET /api/quick-trade/balance - Get available balance GET /api/quick-trade/position - Get current position for symbol GET /api/quick-trade/history - Get quick trade history + GET /api/quick-trade/order-book - Get a public crypto order-book snapshot """ from __future__ import annotations @@ -80,6 +81,7 @@ serialized_quick_trade_close, serialized_quick_trade_order, verify_full_market_close, ) +from app.services.quick_trade.orderbook import OrderBookError, fetch_order_book from app.services.pending_orders.order_quantities import exchange_quantity_snapshot from app.services.quick_trade.spot_positions import ( enrich_spot_positions as _enrich_spot_positions_impl, @@ -93,6 +95,37 @@ quick_trade_blp.before_request(open_quick_trade_transport) quick_trade_blp.teardown_request(close_quick_trade_transport) + +@quick_trade_blp.route('/order-book', methods=['GET']) +@login_required +def get_order_book(): + """Return a normalized public order-book snapshot for a supported crypto venue.""" + exchange_id = str(request.args.get('exchange_id') or '').strip().lower() + symbol = str(request.args.get('symbol') or '').strip() + market_type = str(request.args.get('market_type') or 'swap').strip().lower() + try: + limit = int(request.args.get('limit') or 25) + except (TypeError, ValueError): + limit = 25 + if not exchange_id or not symbol: + return jsonify({"code": 0, "msg": "quickTrade.selectSymbol"}), 400 + if not is_supported_crypto_exchange(exchange_id): + return jsonify({"code": 0, "msg": "quickTrade.unsupportedMarket"}), 400 + try: + snapshot = fetch_order_book( + g.quick_trade_session, + exchange_id=exchange_id, + symbol=symbol, + market_type=market_type, + limit=limit, + ) + return jsonify({"code": 1, "msg": "success", "data": snapshot}) + except (OrderBookError, ValueError): + return jsonify({"code": 0, "msg": "quickTrade.orderFailed"}), 400 + except Exception as exc: + logger.warning("quick-trade order book failed exchange=%s symbol=%s: %s", exchange_id, symbol, exc) + return jsonify({"code": 0, "msg": "quickTrade.errorHints.networkError"}), 502 + def _reject_quick_trade_if_desktop_broker(exchange_id: str): """Quick Trade is USDT-centric and only wired to crypto exchange clients.""" if not is_supported_crypto_exchange(exchange_id): diff --git a/backend_api_python/app/services/ai_tool_registry.py b/backend_api_python/app/services/ai_tool_registry.py index 30557b12b..4f1f6c2be 100644 --- a/backend_api_python/app/services/ai_tool_registry.py +++ b/backend_api_python/app/services/ai_tool_registry.py @@ -634,6 +634,7 @@ def prompt_line(self, language: str) -> str: "list_universe_members": "/api/agent/v1/research/universes/{universe_id}/members", "list_factors": "/api/agent/v1/research/factors", "get_factor": "/api/agent/v1/research/factors/{factor_id}", + "submit_factor_benchmark": "/api/agent/v1/research/factor-benchmarks", "list_watchlist": "/api/agent/v1/research/watchlist", "add_watchlist": "/api/agent/v1/research/watchlist", "remove_watchlist": "/api/agent/v1/research/watchlist", @@ -657,6 +658,7 @@ def prompt_line(self, language: str) -> str: "link_indicator_config", "add_watchlist", "remove_watchlist", + "submit_factor_benchmark", "create_signal_alert", "update_signal_alert", "set_signal_alert_status", @@ -670,7 +672,7 @@ def prompt_line(self, language: str) -> str: "research" if name in { "list_universes", "get_universe", "list_universe_members", "list_factors", "get_factor", "list_watchlist", "add_watchlist", - "remove_watchlist", + "remove_watchlist", "submit_factor_benchmark", } else "notifications" if "signal_alert" in name else "jobs" if name == "cancel_job" diff --git a/backend_api_python/app/services/custom_factors.py b/backend_api_python/app/services/custom_factors.py new file mode 100644 index 000000000..1aa238a5a --- /dev/null +++ b/backend_api_python/app/services/custom_factors.py @@ -0,0 +1,414 @@ +"""User-owned formula factors with a bounded, deterministic expression runtime.""" + +from __future__ import annotations + +import ast +import json +import math +import re +from dataclasses import dataclass +from typing import Any, Mapping + +import pandas as pd + +from app.services.factors import ( + FactorError, + compute_catalog_factor, + factor_warmup_bars, + get_factor_metadata, +) +from app.utils.db import get_db_connection + + +_FACTOR_REF = re.compile(r"^custom:(\d+)$", re.IGNORECASE) +_RAW_FIELDS = { + "open", "high", "low", "close", "volume", "amount", + "market_cap", "net_income", "shareholder_equity", "revenue_growth", + "total_debt", "free_cash_flow", "pe_ratio", "pb_ratio", + "return_on_equity", "debt_to_equity", +} +_SAFE_FUNCTIONS = {"abs", "min", "max", "log", "log1p", "sqrt", "clip", "factor"} +_SAFE_BINARY = { + ast.Add: lambda left, right: left + right, + ast.Sub: lambda left, right: left - right, + ast.Mult: lambda left, right: left * right, + ast.Div: lambda left, right: left / right, + ast.Pow: lambda left, right: left ** right, + ast.Mod: lambda left, right: left % right, +} +_SAFE_COMPARE = { + ast.Gt: lambda left, right: left > right, + ast.GtE: lambda left, right: left >= right, + ast.Lt: lambda left, right: left < right, + ast.LtE: lambda left, right: left <= right, + ast.Eq: lambda left, right: left == right, + ast.NotEq: lambda left, right: left != right, +} + + +class CustomFactorError(ValueError): + def __init__(self, code: str): + super().__init__(code) + self.code = code + + +@dataclass(frozen=True) +class CustomFactorAnalysis: + required_fields: tuple[str, ...] + warmup_bars: int + dependencies: tuple[str, ...] + + +def _parameter_schema(values: Mapping[str, Any]) -> dict[str, dict[str, Any]]: + schema: dict[str, dict[str, Any]] = {} + for key, value in values.items(): + if isinstance(value, bool): + schema[key] = {"type": "boolean"} + elif isinstance(value, int): + schema[key] = {"type": "integer", "minimum": 1, "maximum": 5000, "step": 1} + elif isinstance(value, float): + schema[key] = {"type": "number", "step": 0.1} + else: + raise CustomFactorError("factor.customInvalidParameter") + return schema + + +def _parse_expression(expression: str) -> ast.Expression: + source = str(expression or "").strip() + if not source: + raise CustomFactorError("factor.customExpressionRequired") + if len(source) > 2000: + raise CustomFactorError("factor.customExpressionTooLarge") + try: + tree = ast.parse(source, mode="eval") + except SyntaxError as exc: + raise CustomFactorError("factor.customSyntaxInvalid") from exc + if sum(1 for _ in ast.walk(tree)) > 120: + raise CustomFactorError("factor.customExpressionTooComplex") + return tree + + +def _literal_or_parameter(node: ast.AST, parameters: Mapping[str, Any]) -> Any: + if isinstance(node, ast.Constant) and isinstance(node.value, (str, int, float, bool)): + return node.value + if isinstance(node, ast.Name) and node.id in parameters: + return parameters[node.id] + raise CustomFactorError("factor.customParameterMustBeLiteral") + + +def analyze_custom_factor(expression: str, default_params: Mapping[str, Any] | None = None) -> CustomFactorAnalysis: + parameters = dict(default_params or {}) + _parameter_schema(parameters) + tree = _parse_expression(expression) + required_fields: set[str] = set() + dependencies: set[str] = set() + warmup = 1 + + def visit(node: ast.AST) -> None: + nonlocal warmup + if isinstance(node, ast.Expression): + visit(node.body) + return + if isinstance(node, ast.Constant): + if not isinstance(node.value, (str, int, float, bool)): + raise CustomFactorError("factor.customNodeUnsupported") + return + if isinstance(node, ast.Name): + if node.id in parameters: + return + if node.id in _RAW_FIELDS: + required_fields.add(node.id) + return + raise CustomFactorError("factor.customUnknownName") + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub, ast.Not)): + visit(node.operand) + return + if isinstance(node, ast.BinOp) and type(node.op) in _SAFE_BINARY: + visit(node.left) + visit(node.right) + return + if isinstance(node, ast.BoolOp) and isinstance(node.op, (ast.And, ast.Or)): + for value in node.values: + visit(value) + return + if isinstance(node, ast.Compare) and all(type(item) in _SAFE_COMPARE for item in node.ops): + visit(node.left) + for comparator in node.comparators: + visit(comparator) + return + if isinstance(node, ast.IfExp): + visit(node.test) + visit(node.body) + visit(node.orelse) + return + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): + raise CustomFactorError("factor.customNodeUnsupported") + function_name = node.func.id + if function_name in _SAFE_FUNCTIONS - {"factor"}: + if node.keywords: + raise CustomFactorError("factor.customKeywordUnsupported") + for argument in node.args: + visit(argument) + return + if function_name == "factor": + if not node.args: + raise CustomFactorError("factor.customDependencyRequired") + dependency = str(_literal_or_parameter(node.args[0], parameters)).strip() + if dependency.lower().startswith("custom:"): + raise CustomFactorError("factor.customNestedDependencyUnsupported") + for argument in node.args[1:]: + visit(argument) + else: + dependency = function_name + for argument in node.args: + visit(argument) + kwargs = { + item.arg: _literal_or_parameter(item.value, parameters) + for item in node.keywords + if item.arg + } + try: + metadata = get_factor_metadata(dependency) + except (FactorError, ValueError) as exc: + raise CustomFactorError("factor.customDependencyNotFound") from exc + dependencies.add(dependency) + required_fields.update(str(item) for item in metadata.get("required_fields") or []) + try: + warmup = max(warmup, factor_warmup_bars(dependency.lower(), kwargs)) + except FactorError: + warmup = max(warmup, int(metadata.get("default_warmup_bars") or 1)) + + visit(tree) + return CustomFactorAnalysis( + required_fields=tuple(sorted(required_fields)), + warmup_bars=max(1, int(warmup)), + dependencies=tuple(sorted(dependencies)), + ) + + +def compute_custom_factor( + definition: Mapping[str, Any], + frame: pd.DataFrame, + params: Mapping[str, Any] | None = None, +) -> float: + if frame is None or frame.empty: + raise CustomFactorError("factor.noData") + defaults = dict(definition.get("default_params") or definition.get("defaultParams") or {}) + values = {**defaults, **dict(params or {})} + _parameter_schema(values) + tree = _parse_expression(str(definition.get("expression") or "")) + + def evaluate(node: ast.AST) -> Any: + if isinstance(node, ast.Expression): + return evaluate(node.body) + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name): + if node.id in values: + return values[node.id] + if node.id in _RAW_FIELDS and node.id in frame.columns: + series = pd.to_numeric(frame[node.id], errors="coerce").dropna() + if series.empty: + raise CustomFactorError("factor.missingFields") + return float(series.iloc[-1]) + raise CustomFactorError("factor.customUnknownName") + if isinstance(node, ast.UnaryOp): + value = evaluate(node.operand) + if isinstance(node.op, ast.UAdd): + return +value + if isinstance(node.op, ast.USub): + return -value + if isinstance(node.op, ast.Not): + return not value + if isinstance(node, ast.BinOp) and type(node.op) in _SAFE_BINARY: + return _SAFE_BINARY[type(node.op)](evaluate(node.left), evaluate(node.right)) + if isinstance(node, ast.BoolOp): + resolved = [bool(evaluate(item)) for item in node.values] + return all(resolved) if isinstance(node.op, ast.And) else any(resolved) + if isinstance(node, ast.Compare): + left = evaluate(node.left) + for operator, comparator in zip(node.ops, node.comparators): + right = evaluate(comparator) + if not _SAFE_COMPARE[type(operator)](left, right): + return False + left = right + return True + if isinstance(node, ast.IfExp): + return evaluate(node.body) if evaluate(node.test) else evaluate(node.orelse) + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): + raise CustomFactorError("factor.customNodeUnsupported") + name = node.func.id + args = [evaluate(item) for item in node.args] + kwargs = {item.arg: evaluate(item.value) for item in node.keywords if item.arg} + if name == "abs": + return abs(args[0]) + if name == "min": + return min(args) + if name == "max": + return max(args) + if name == "log": + return math.log(args[0]) + if name == "log1p": + return math.log1p(args[0]) + if name == "sqrt": + return math.sqrt(args[0]) + if name == "clip": + return min(max(args[0], args[1]), args[2]) + if name == "factor": + dependency = str(args[0]) + return compute_catalog_factor(dependency, frame, kwargs) + return compute_catalog_factor(name, frame, kwargs) + + try: + result = float(evaluate(tree)) + except CustomFactorError: + raise + except (ArithmeticError, FactorError, TypeError, ValueError) as exc: + raise CustomFactorError("factor.customComputeFailed") from exc + if not math.isfinite(result): + raise CustomFactorError("factor.customComputeFailed") + return result + + +def _loads(value: Any, fallback: Any) -> Any: + if isinstance(value, type(fallback)): + return value + if value in (None, ""): + return fallback + try: + return json.loads(value) + except (TypeError, ValueError): + return fallback + + +def _metadata(row: Mapping[str, Any]) -> dict[str, Any]: + factor_id = int(row.get("id") or 0) + return { + "id": factor_id, + "factor_id": f"custom:{factor_id}", + "version": str(row.get("version") or 1), + "display_name": str(row.get("name") or ""), + "description": str(row.get("description") or ""), + "category": str(row.get("category") or "custom"), + "factor_type": "technical", + "provider": "user", + "family": "custom", + "required_fields": list(_loads(row.get("required_fields"), [])), + "default_params": dict(_loads(row.get("default_params"), {})), + "parameter_schema": dict(_loads(row.get("parameter_schema"), {})), + "direction_hint": str(row.get("direction_hint") or "neutral"), + "supported_contexts": ["portfolio"], + "default_warmup_bars": int(row.get("warmup_bars") or 1), + "expression": str(row.get("expression") or ""), + "dependencies": list(_loads(row.get("dependencies"), [])), + "is_custom": True, + "created_at": str(row.get("created_at") or ""), + "updated_at": str(row.get("updated_at") or ""), + } + + +def list_custom_factors(user_id: int) -> list[dict[str, Any]]: + with get_db_connection() as db: + cursor = db.cursor() + cursor.execute( + """ + SELECT * FROM qd_user_factors + WHERE user_id = ? AND status = 'active' + ORDER BY updated_at DESC, id DESC + """, + (int(user_id),), + ) + rows = cursor.fetchall() + cursor.close() + return [_metadata(row) for row in rows] + + +def get_custom_factor(user_id: int, factor_ref: str | int) -> dict[str, Any]: + match = _FACTOR_REF.match(str(factor_ref or "").strip()) + factor_id = int(match.group(1)) if match else int(factor_ref) + with get_db_connection() as db: + cursor = db.cursor() + cursor.execute( + "SELECT * FROM qd_user_factors WHERE id = ? AND user_id = ? AND status = 'active'", + (factor_id, int(user_id)), + ) + row = cursor.fetchone() + cursor.close() + if not row: + raise CustomFactorError("factor.customNotFound") + return _metadata(row) + + +def save_custom_factor(user_id: int, payload: Mapping[str, Any]) -> dict[str, Any]: + name = str(payload.get("name") or "").strip() + description = str(payload.get("description") or "").strip() + expression = str(payload.get("expression") or "").strip() + category = str(payload.get("category") or "custom").strip().lower()[:40] + direction = str(payload.get("directionHint") or payload.get("direction_hint") or "neutral").strip() + if not name or len(name) > 120: + raise CustomFactorError("factor.customNameInvalid") + if direction not in {"neutral", "higher_is_bullish", "lower_is_bullish"}: + raise CustomFactorError("factor.customDirectionInvalid") + default_params = payload.get("defaultParams") or payload.get("default_params") or {} + if not isinstance(default_params, Mapping): + raise CustomFactorError("factor.customInvalidParameter") + default_params = dict(default_params) + schema = _parameter_schema(default_params) + analysis = analyze_custom_factor(expression, default_params) + + with get_db_connection() as db: + cursor = db.cursor() + cursor.execute( + """ + INSERT INTO qd_user_factors ( + user_id, name, description, category, direction_hint, expression, + default_params, parameter_schema, required_fields, dependencies, + warmup_bars, version, status + ) VALUES (?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?::jsonb, ?::jsonb, ?, 1, 'active') + RETURNING id + """, + ( + int(user_id), name, description, category, direction, expression, + json.dumps(default_params), json.dumps(schema), + json.dumps(list(analysis.required_fields)), + json.dumps(list(analysis.dependencies)), analysis.warmup_bars, + ), + ) + created = cursor.fetchone() + factor_id = int(created["id"]) + cursor.execute( + """ + INSERT INTO qd_user_factor_versions ( + factor_id, user_id, version_no, expression, metadata + ) VALUES (?, ?, 1, ?, ?::jsonb) + """, + ( + factor_id, int(user_id), expression, + json.dumps({ + "name": name, + "description": description, + "category": category, + "directionHint": direction, + "defaultParams": default_params, + "requiredFields": list(analysis.required_fields), + "dependencies": list(analysis.dependencies), + "warmupBars": analysis.warmup_bars, + }), + ), + ) + db.commit() + cursor.close() + return get_custom_factor(user_id, factor_id) + + +def archive_custom_factor(user_id: int, factor_id: int) -> None: + with get_db_connection() as db: + cursor = db.cursor() + cursor.execute( + "UPDATE qd_user_factors SET status = 'archived', updated_at = NOW() WHERE id = ? AND user_id = ?", + (int(factor_id), int(user_id)), + ) + if cursor.rowcount != 1: + raise CustomFactorError("factor.customNotFound") + db.commit() + cursor.close() diff --git a/backend_api_python/app/services/live_trading/base.py b/backend_api_python/app/services/live_trading/base.py index 1bdf87354..aae73bf93 100644 --- a/backend_api_python/app/services/live_trading/base.py +++ b/backend_api_python/app/services/live_trading/base.py @@ -117,6 +117,16 @@ def _get_proxies() -> Optional[Dict[str, str]]: return _proxies_value +def get_live_trading_requests_verify() -> Union[bool, str]: + """Expose the shared TLS verification policy to read-only exchange services.""" + return _get_requests_verify() + + +def get_live_trading_proxies() -> Optional[Dict[str, str]]: + """Expose the shared exchange proxy policy to read-only exchange services.""" + return _get_proxies() + + def cancel_all_algo_orders_enabled() -> bool: """Whether this process may cancel *all* algo orders on an OKX instrument/account. diff --git a/backend_api_python/app/services/live_trading/bitget_compat.py b/backend_api_python/app/services/live_trading/bitget_compat.py index be8c845d3..af30aaa13 100644 --- a/backend_api_python/app/services/live_trading/bitget_compat.py +++ b/backend_api_python/app/services/live_trading/bitget_compat.py @@ -2,7 +2,6 @@ from __future__ import annotations -import hashlib import threading import time from typing import Any, Dict, Optional, Tuple @@ -12,6 +11,7 @@ from app.services.live_trading.bitget_classic import BitgetClassicMixClient from app.services.live_trading.bitget_spot import BitgetSpotClient from app.services.live_trading.bitget_spot_classic import BitgetClassicSpotClient +from app.utils.security_fingerprint import opaque_fingerprint _MODE_CACHE: Dict[str, Tuple[float, str]] = {} @@ -29,7 +29,7 @@ def _normalize_account_mode(value: Any) -> str: def _cache_key(*, api_key: str, base_url: str, market_type: str, simulated: bool) -> str: - digest = hashlib.sha256(str(api_key or "").encode("utf-8")).hexdigest() + digest = opaque_fingerprint(api_key, namespace="bitget-account-mode") return f"{str(base_url or '').rstrip('/')}:{market_type}:{int(bool(simulated))}:{digest}" diff --git a/backend_api_python/app/services/live_trading/ws_order_transport.py b/backend_api_python/app/services/live_trading/ws_order_transport.py index 4c5cd80cf..d695096a9 100644 --- a/backend_api_python/app/services/live_trading/ws_order_transport.py +++ b/backend_api_python/app/services/live_trading/ws_order_transport.py @@ -22,6 +22,7 @@ from urllib.parse import urlencode, urlparse from app.services.live_trading.base import LiveTradingError, _get_requests_verify +from app.utils.security_fingerprint import opaque_fingerprint logger = logging.getLogger(__name__) @@ -286,7 +287,7 @@ def request( def _pool_key(venue: str, url: str, api_key: str, qualifier: str = "") -> str: - digest = hashlib.sha256(f"{url}|{api_key}|{qualifier}".encode("utf-8")).hexdigest() + digest = opaque_fingerprint(url, api_key, qualifier, namespace="ws-order-pool") return f"{venue}:{digest}" diff --git a/backend_api_python/app/services/pending_order_position_sync.py b/backend_api_python/app/services/pending_order_position_sync.py index 2c03f73f6..acfab0766 100644 --- a/backend_api_python/app/services/pending_order_position_sync.py +++ b/backend_api_python/app/services/pending_order_position_sync.py @@ -575,10 +575,10 @@ def _sync_positions_best_effort(self, target_strategy_id: Optional[int] = None) snapshot_started_at = time.time() if is_exchange_sync_backoff(cache_key): logger.warning( - "[PositionSync] Strategy %s skipped: %s sync backoff active (key=%s)", + "[PositionSync] Strategy %s skipped: %s %s sync backoff active", sid, exchange_id, - cache_key, + market_type, ) continue @@ -616,8 +616,9 @@ def _sync_positions_best_effort(self, target_strategy_id: Optional[int] = None) if is_exchange_rate_limit_error(msg): set_exchange_sync_backoff(cache_key) logger.error( - "[PositionSync] Binance rate limit for key=%s; backing off %ss. error=%s", - cache_key, + "[PositionSync] Binance rate limit for strategy=%s market=%s; backing off %ss. error=%s", + sid, + market_type, int(exchange_sync_backoff_sec()), msg, ) @@ -1057,7 +1058,13 @@ def _sync_positions_best_effort(self, target_strategy_id: Optional[int] = None) snapshot_started_at=snapshot_started_at, ) except Exception as l1_err: - logger.warning("[PositionSync] L1 account sync failed key=%s: %s", cache_key, l1_err) + logger.warning( + "[PositionSync] L1 account sync failed strategy=%s exchange=%s market=%s: %s", + sid, + exchange_id, + market_type, + l1_err, + ) if client is None: try: diff --git a/backend_api_python/app/services/pending_orders/position_sync_cache.py b/backend_api_python/app/services/pending_orders/position_sync_cache.py index 6fe19bed3..73d40c917 100644 --- a/backend_api_python/app/services/pending_orders/position_sync_cache.py +++ b/backend_api_python/app/services/pending_orders/position_sync_cache.py @@ -7,6 +7,8 @@ import time from typing import Any, Dict, Optional, Tuple +from app.utils.security_fingerprint import opaque_fingerprint + PositionSnapshot = Tuple[ Dict[str, Dict[str, float]], Dict[str, Dict[str, float]], @@ -28,8 +30,11 @@ def position_sync_cache_key( cred_id = exchange_config.get("credential_id") or exchange_config.get("credentials_id") if cred_id: return f"u{int(user_id)}:{exchange_id}:{market_type}:cred:{int(cred_id)}" - hint = str(exchange_config.get("api_key") or exchange_config.get("apiKey") or "")[-16:] - return f"u{int(user_id)}:{exchange_id}:{market_type}:inline:{hint}" + fingerprint = opaque_fingerprint( + exchange_config.get("api_key") or exchange_config.get("apiKey") or "", + namespace="position-sync-credential", + ) + return f"u{int(user_id)}:{exchange_id}:{market_type}:inline:{fingerprint}" def position_sync_cache_ttl_sec() -> float: diff --git a/backend_api_python/app/services/quick_trade/orderbook.py b/backend_api_python/app/services/quick_trade/orderbook.py new file mode 100644 index 000000000..7ba45cfb2 --- /dev/null +++ b/backend_api_python/app/services/quick_trade/orderbook.py @@ -0,0 +1,326 @@ +"""Public crypto order-book snapshots for the Quick Trade console.""" + +from __future__ import annotations + +import math +import time +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Sequence, Tuple + +import requests + +from app.services.live_trading.base import get_live_trading_proxies, get_live_trading_requests_verify +from app.services.quick_trade.symbols import is_supported_crypto_exchange + + +class OrderBookError(RuntimeError): + """Raised when a supported venue cannot return a usable snapshot.""" + + +_CONTRACT_QUANTITY_UNITS = { + "binance": "base", + "okx": "contracts", + "bitget": "base", + "bybit": "base", + "gate": "contracts", + "htx": "contracts", +} +_NATIVE_CONTRACT_EXCHANGES = { + exchange_id for exchange_id, quantity_unit in _CONTRACT_QUANTITY_UNITS.items() if quantity_unit == "contracts" +} +_CONTRACT_METADATA_TTL_SECONDS = 6 * 60 * 60 +_contract_metadata_cache: Dict[Tuple[str, str], Tuple[float, float]] = {} + + +@dataclass(frozen=True) +class SymbolParts: + base: str + quote: str + + @property + def compact(self) -> str: + return f"{self.base}{self.quote}" + + @property + def dashed(self) -> str: + return f"{self.base}-{self.quote}" + + @property + def underscored(self) -> str: + return f"{self.base}_{self.quote}" + + +def normalize_symbol(symbol: str) -> SymbolParts: + raw = str(symbol or "").strip().upper() + if ":" in raw: + raw = raw.rsplit(":", 1)[-1] + for suffix in ("-SWAP", "_SWAP", "/SWAP", "SWAP", "-PERP", "_PERP", "PERP"): + if raw.endswith(suffix): + raw = raw[: -len(suffix)] + break + for separator in ("/", "-", "_"): + if separator in raw: + parts = [part for part in raw.split(separator) if part] + if len(parts) >= 2: + return SymbolParts(parts[0], parts[1]) + compact = "".join(ch for ch in raw if ch.isalnum()) + for quote in ("USDT", "USDC", "FDUSD", "BUSD", "USD", "EUR", "BTC", "ETH"): + if compact.endswith(quote) and len(compact) > len(quote): + return SymbolParts(compact[: -len(quote)], quote) + raise OrderBookError("Unsupported crypto symbol format") + + +def _safe_number(value: Any) -> float: + try: + number = float(value) + except (TypeError, ValueError): + return 0.0 + return number if math.isfinite(number) and number > 0 else 0.0 + + +def _levels(rows: Iterable[Sequence[Any]], *, descending: bool, limit: int) -> List[Dict[str, float]]: + normalized: List[Dict[str, float]] = [] + for row in rows or []: + if isinstance(row, dict): + price = _safe_number(row.get("p") or row.get("price")) + size = _safe_number(row.get("s") or row.get("size") or row.get("amount")) + elif isinstance(row, (list, tuple)) and len(row) >= 2: + price = _safe_number(row[0]) + size = _safe_number(row[1]) + else: + continue + if price <= 0 or size <= 0: + continue + normalized.append({"price": price, "size": size, "notional": price * size}) + normalized.sort(key=lambda item: item["price"], reverse=descending) + return normalized[:limit] + + +def _extract_rows(exchange_id: str, payload: Any) -> Tuple[Iterable[Sequence[Any]], Iterable[Sequence[Any]], int]: + now_ms = int(time.time() * 1000) + if exchange_id == "binance": + return payload.get("bids") or [], payload.get("asks") or [], int(payload.get("E") or now_ms) + if exchange_id == "okx": + rows = (payload.get("data") or [{}])[0] + return rows.get("bids") or [], rows.get("asks") or [], int(rows.get("ts") or now_ms) + if exchange_id == "bitget": + data = payload.get("data") or {} + if isinstance(data, list): + data = data[0] if data else {} + return ( + data.get("bids") or data.get("b") or [], + data.get("asks") or data.get("a") or [], + int(data.get("ts") or payload.get("requestTime") or now_ms), + ) + if exchange_id == "bybit": + data = payload.get("result") or {} + return data.get("b") or [], data.get("a") or [], int(data.get("ts") or payload.get("time") or now_ms) + if exchange_id == "gate": + return payload.get("bids") or [], payload.get("asks") or [], int(payload.get("current") or payload.get("update") or now_ms) + if exchange_id == "htx": + tick = payload.get("tick") or {} + return tick.get("bids") or [], tick.get("asks") or [], int(payload.get("ts") or now_ms) + raise OrderBookError("Unsupported crypto exchange") + + +def normalize_order_book( + exchange_id: str, + payload: Any, + *, + symbol: str, + market_type: str, + limit: int, + contract_multiplier: float = 1.0, +) -> Dict[str, Any]: + bids_raw, asks_raw, timestamp = _extract_rows(exchange_id, payload if isinstance(payload, dict) else {}) + if timestamp < 1_000_000_000_000: + timestamp *= 1000 + bids = _levels(bids_raw, descending=True, limit=limit) + asks = _levels(asks_raw, descending=False, limit=limit) + if not bids or not asks: + raise OrderBookError("Exchange returned an empty order book") + best_bid = bids[0]["price"] + best_ask = asks[0]["price"] + mid = (best_bid + best_ask) / 2 + spread = max(0.0, best_ask - best_bid) + bid_total = sum(row["notional"] for row in bids) + ask_total = sum(row["notional"] for row in asks) + total = bid_total + ask_total + bid_share = (bid_total / total * 100) if total > 0 else 50.0 + return { + "exchange_id": exchange_id, + "symbol": symbol, + "market_type": market_type, + "base_asset": normalize_symbol(symbol).base, + "quantity_unit": "base" if market_type == "spot" else _CONTRACT_QUANTITY_UNITS.get(exchange_id, "unknown"), + "contract_multiplier": contract_multiplier, + "timestamp": timestamp, + "bids": bids, + "asks": asks, + "best_bid": best_bid, + "best_ask": best_ask, + "mid": mid, + "spread": spread, + "spread_bps": (spread / mid * 10000) if mid > 0 else 0.0, + "bid_total": bid_total, + "ask_total": ask_total, + "bid_share": bid_share, + "ask_share": 100 - bid_share, + "buy_wall": max(bids, key=lambda row: row["notional"]), + "sell_wall": max(asks, key=lambda row: row["notional"]), + } + + +def _contract_request_spec(exchange_id: str, parts: SymbolParts): + if exchange_id == "okx": + return "https://www.okx.com/api/v5/public/instruments", { + "instType": "SWAP", + "instId": f"{parts.dashed}-SWAP", + } + if exchange_id == "gate": + return f"https://fx-api.gateio.ws/api/v4/futures/usdt/contracts/{parts.underscored}", {} + if exchange_id == "htx": + return "https://api.hbdm.com/linear-swap-api/v1/swap_contract_info", { + "contract_code": parts.dashed, + } + raise OrderBookError("Unsupported contract metadata exchange") + + +def _extract_contract_multiplier(exchange_id: str, payload: Any, parts: SymbolParts) -> float: + data = payload if isinstance(payload, dict) else {} + if exchange_id == "okx": + rows = data.get("data") or [] + instrument = rows[0] if rows and isinstance(rows[0], dict) else {} + contract_currency = str(instrument.get("ctValCcy") or "").upper() + contract_type = str(instrument.get("ctType") or "").lower() + if contract_type == "inverse" or (contract_currency and contract_currency != parts.base): + return 0.0 + contract_value = _safe_number(instrument.get("ctVal")) + contract_multiple = _safe_number(instrument.get("ctMult")) or 1.0 + return contract_value * contract_multiple + if exchange_id == "gate": + return _safe_number(data.get("quanto_multiplier") or data.get("contract_size")) + if exchange_id == "htx": + rows = data.get("data") or [] + contract = rows[0] if rows and isinstance(rows[0], dict) else {} + return _safe_number(contract.get("contract_size")) + return 0.0 + + +def _contract_multiplier( + session: requests.Session, + *, + exchange_id: str, + parts: SymbolParts, + timeout: float, +) -> float: + if exchange_id not in _NATIVE_CONTRACT_EXCHANGES: + return 1.0 + cache_key = (exchange_id, parts.compact) + cached = _contract_metadata_cache.get(cache_key) + now = time.monotonic() + if cached and now - cached[0] < _CONTRACT_METADATA_TTL_SECONDS: + return cached[1] + try: + url, params = _contract_request_spec(exchange_id, parts) + response = session.get( + url, + params=params, + timeout=timeout, + verify=get_live_trading_requests_verify(), + proxies=get_live_trading_proxies(), + ) + response.raise_for_status() + multiplier = _extract_contract_multiplier(exchange_id, response.json(), parts) + except (requests.RequestException, TypeError, ValueError): + return 0.0 + if multiplier > 0: + _contract_metadata_cache[cache_key] = (now, multiplier) + return multiplier + + +def _request_spec(exchange_id: str, parts: SymbolParts, market_type: str, limit: int): + spot = market_type == "spot" + if exchange_id == "binance": + base = "https://api.binance.com" if spot else "https://fapi.binance.com" + path = "/api/v3/depth" if spot else "/fapi/v1/depth" + return base + path, {"symbol": parts.compact, "limit": limit} + if exchange_id == "okx": + inst_id = parts.dashed if spot else f"{parts.dashed}-SWAP" + return "https://www.okx.com/api/v5/market/books", {"instId": inst_id, "sz": limit} + if exchange_id == "bitget": + return "https://api.bitget.com/api/v3/market/orderbook", { + "category": "SPOT" if spot else "USDT-FUTURES", + "symbol": parts.compact, + "limit": limit, + } + if exchange_id == "bybit": + return "https://api.bybit.com/v5/market/orderbook", { + "category": "spot" if spot else "linear", + "symbol": parts.compact, + "limit": limit, + } + if exchange_id == "gate": + if spot: + return "https://api.gateio.ws/api/v4/spot/order_book", { + "currency_pair": parts.underscored, + "limit": limit, + "with_id": "true", + } + return "https://fx-api.gateio.ws/api/v4/futures/usdt/order_book", { + "contract": parts.underscored, + "limit": limit, + "with_id": "true", + } + if exchange_id == "htx": + if spot: + return "https://api.htx.com/market/depth", {"symbol": parts.compact.lower(), "type": "step0", "depth": limit} + return "https://api.hbdm.com/linear-swap-ex/market/depth", { + "contract_code": parts.dashed, + "type": "step0", + "depth": limit, + } + raise OrderBookError("Unsupported crypto exchange") + + +def fetch_order_book( + session: requests.Session, + *, + exchange_id: str, + symbol: str, + market_type: str = "swap", + limit: int = 25, + timeout: float = 4.0, +) -> Dict[str, Any]: + exchange = str(exchange_id or "").strip().lower() + if not is_supported_crypto_exchange(exchange): + raise OrderBookError("Unsupported crypto exchange") + normalized_market_type = "spot" if str(market_type or "").strip().lower() == "spot" else "swap" + bounded_limit = max(5, min(int(limit or 25), 50)) + parts = normalize_symbol(symbol) + url, params = _request_spec(exchange, parts, normalized_market_type, bounded_limit) + response = session.get( + url, + params=params, + timeout=timeout, + verify=get_live_trading_requests_verify(), + proxies=get_live_trading_proxies(), + ) + response.raise_for_status() + payload = response.json() + contract_multiplier = 1.0 + if normalized_market_type != "spot": + contract_multiplier = _contract_multiplier( + session, + exchange_id=exchange, + parts=parts, + timeout=timeout, + ) + return normalize_order_book( + exchange, + payload, + symbol=symbol, + market_type=normalized_market_type, + limit=bounded_limit, + contract_multiplier=contract_multiplier, + ) diff --git a/backend_api_python/app/services/signal_notifier.py b/backend_api_python/app/services/signal_notifier.py index 33bc9a422..34a3cbd86 100644 --- a/backend_api_python/app/services/signal_notifier.py +++ b/backend_api_python/app/services/signal_notifier.py @@ -21,8 +21,10 @@ import hashlib import hmac import html +import ipaddress import json import os +import socket import smtplib import time from datetime import datetime, timezone @@ -45,6 +47,59 @@ def _shorten(value: Any, max_len: int = 200) -> str: return text if len(text) <= max_len else text[: max(0, max_len - 3)] + "..." +def _host_matches(host: str, *allowed_hosts: str) -> bool: + normalized = str(host or "").strip().lower().rstrip(".") + return any( + normalized == allowed or normalized.endswith(f".{allowed}") + for allowed in allowed_hosts + ) + + +def _validate_outbound_http_url( + url: str, + *, + allowed_hosts: tuple[str, ...] = (), +) -> Tuple[str, str]: + raw = str(url or "").strip() + if not raw or len(raw) > 4096: + return "", "invalid_webhook_url" + try: + parsed = urlsplit(raw) + host = str(parsed.hostname or "").strip().lower().rstrip(".") + port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80) + except ValueError: + return "", "invalid_webhook_url" + if parsed.scheme.lower() not in {"http", "https"} or not host: + return "", "invalid_webhook_url" + if parsed.username is not None or parsed.password is not None: + return "", "webhook_credentials_not_allowed" + if allowed_hosts and not _host_matches(host, *allowed_hosts): + return "", "webhook_host_not_allowed" + + allow_private = str(os.getenv("SIGNAL_WEBHOOK_ALLOW_PRIVATE_HOSTS") or "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + if not allow_private: + try: + addresses = { + str(info[4][0]).split("%", 1)[0] + for info in socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + } + except (OSError, ValueError): + return "", "webhook_host_unresolvable" + if not addresses: + return "", "webhook_host_unresolvable" + try: + if any(not ipaddress.ip_address(address).is_global for address in addresses): + return "", "webhook_private_address_blocked" + except ValueError: + return "", "webhook_host_unresolvable" + return raw, "" + + def _fmt_float(value: Any, max_decimals: int = 8) -> str: try: num = float(value or 0) @@ -116,17 +171,18 @@ def _payload_text(payload: Dict[str, Any]) -> str: def _detect_webhook_dialect(url: str) -> str: - host = (urlsplit(str(url or "")).netloc or "").lower() - raw = str(url or "").lower() - if "discord.com/api/webhooks" in raw: + parsed = urlsplit(str(url or "")) + host = str(parsed.hostname or "").lower().rstrip(".") + path = str(parsed.path or "") + if _host_matches(host, "discord.com", "discordapp.com") and path.startswith("/api/webhooks/"): return "discord" - if "open.feishu.cn" in host or "larksuite.com" in host: + if _host_matches(host, "open.feishu.cn", "open.larksuite.com"): return "feishu" - if "oapi.dingtalk.com" in host: + if _host_matches(host, "oapi.dingtalk.com"): return "dingtalk" - if "qyapi.weixin.qq.com" in host or "work.weixin.qq.com" in host: + if _host_matches(host, "qyapi.weixin.qq.com", "work.weixin.qq.com"): return "wecom" - if "hooks.slack.com" in host: + if _host_matches(host, "hooks.slack.com"): return "slack" return "generic" @@ -738,10 +794,11 @@ def _notify_webhook( """ if not url: return False, "missing_webhook_url" - if not (str(url).startswith("http://") or str(url).startswith("https://")): - return False, "invalid_webhook_url" + validated_url, validation_error = _validate_outbound_http_url(url) + if validation_error: + return False, validation_error - dialect = _detect_webhook_dialect(url) + dialect = _detect_webhook_dialect(validated_url) adapted_payload = _adapt_payload_for_dialect(dialect, payload) headers: Dict[str, str] = { @@ -784,7 +841,7 @@ def _notify_webhook( # the body (feishu). We accept failure here gracefully — if # the user provided a secret but the algorithm fails, we fail # loud rather than send unsigned. - post_url = url + post_url = validated_url post_body_bytes: Optional[bytes] = None if signing_secret and dialect == 'feishu': try: @@ -798,7 +855,7 @@ def _notify_webhook( return False, f"feishu_sign_failed:{e}" elif signing_secret and dialect == 'dingtalk': try: - post_url = _dingtalk_signed_url(url, signing_secret) + post_url = _dingtalk_signed_url(validated_url, signing_secret) except Exception as e: return False, f"dingtalk_sign_failed:{e}" elif signing_secret and dialect == 'generic': @@ -820,8 +877,20 @@ def _notify_webhook( def _post_once(timeout: float) -> requests.Response: if post_body_bytes is not None: - return requests.post(post_url, data=post_body_bytes, headers=headers, timeout=timeout) - return requests.post(post_url, json=adapted_payload, headers=headers, timeout=timeout) + return requests.post( + post_url, + data=post_body_bytes, + headers=headers, + timeout=timeout, + allow_redirects=False, + ) + return requests.post( + post_url, + json=adapted_payload, + headers=headers, + timeout=timeout, + allow_redirects=False, + ) try: resp = _post_once(self.timeout_sec) @@ -855,7 +924,13 @@ def _post_once(timeout: float) -> requests.Response: def _notify_discord(self, *, url: str, payload: Dict[str, Any], fallback_text: str) -> Tuple[bool, str]: if not url: return False, "missing_discord_webhook_url" - if not (str(url).startswith("http://") or str(url).startswith("https://")): + validated_url, validation_error = _validate_outbound_http_url( + url, + allowed_hosts=("discord.com", "discordapp.com"), + ) + if validation_error: + return False, validation_error + if not str(urlsplit(validated_url).path or "").startswith("/api/webhooks/"): return False, "invalid_discord_webhook_url" if str((payload or {}).get("event") or "") == "qd.profile_test": @@ -867,7 +942,7 @@ def _notify_discord(self, *, url: str, payload: Dict[str, Any], fallback_text: s } if (payload or {}).get("timestamp_iso"): embed["timestamp"] = str((payload or {}).get("timestamp_iso") or "") - return self._post_discord_embed(url=url, embed=embed, fallback_text=fallback_text) + return self._post_discord_embed(url=validated_url, embed=embed, fallback_text=fallback_text) strategy = (payload or {}).get("strategy") or {} instrument = (payload or {}).get("instrument") or {} @@ -906,7 +981,7 @@ def _notify_discord(self, *, url: str, payload: Dict[str, Any], fallback_text: s embed["timestamp"] = str(payload.get("timestamp_iso") or "") if trace.get("pending_order_id"): embed["footer"] = {"text": f"Generated by QuantDinger signal engine | pending_order_id={int(trace.get('pending_order_id'))}"} - return self._post_discord_embed(url=url, embed=embed, fallback_text=fallback_text) + return self._post_discord_embed(url=validated_url, embed=embed, fallback_text=fallback_text) def _post_discord_embed(self, *, url: str, embed: Dict[str, Any], fallback_text: str) -> Tuple[bool, str]: headers = { @@ -915,7 +990,13 @@ def _post_discord_embed(self, *, url: str, embed: Dict[str, Any], fallback_text: } def _post(payload_json: Dict[str, Any]) -> requests.Response: - return requests.post(url, json=payload_json, headers=headers, timeout=self.timeout_sec) + return requests.post( + url, + json=payload_json, + headers=headers, + timeout=self.timeout_sec, + allow_redirects=False, + ) try: resp = _post({"content": "", "embeds": [embed]}) diff --git a/backend_api_python/app/services/strategy_ai_capabilities.py b/backend_api_python/app/services/strategy_ai_capabilities.py index 3682ff551..6faf728c6 100644 --- a/backend_api_python/app/services/strategy_ai_capabilities.py +++ b/backend_api_python/app/services/strategy_ai_capabilities.py @@ -588,12 +588,10 @@ def resolve_strategy_generation_intent( conversion_request = context.pop("conversionRequest", None) if conversion_request is None: # Older clients embed the request between template and indicator source. - match = re.search( - r"User conversion request:\s*\n(.*?)\nIndicator source code:", - str(prompt or ""), - flags=re.DOTALL, - ) - conversion_request = match.group(1) if match else prompt + normalized_prompt = str(prompt or "").replace("\r\n", "\n") + _, start, request_tail = normalized_prompt.partition("User conversion request:") + request_text, end, _ = request_tail.partition("\nIndicator source code:") + conversion_request = request_text.strip() if start and end else prompt prompt = str(conversion_request or "") serialized_context = json.dumps(context, ensure_ascii=False, sort_keys=True) combined = "\n".join((str(prompt or ""), str(existing_code or ""), serialized_context)) diff --git a/backend_api_python/app/services/strategy_v2/factor_benchmark.py b/backend_api_python/app/services/strategy_v2/factor_benchmark.py new file mode 100644 index 000000000..331e5d2c9 --- /dev/null +++ b/backend_api_python/app/services/strategy_v2/factor_benchmark.py @@ -0,0 +1,317 @@ +"""Cross-factor benchmark ranking with out-of-sample and false-discovery controls.""" + +from __future__ import annotations + +import math +from statistics import mean, stdev +from typing import Any, Mapping, Sequence + + +class FactorBenchmarkEngine: + """Rank completed factor-research results without re-reading market data.""" + + METHODOLOGY_VERSION = 2 + + def run( + self, + results: Sequence[Mapping[str, Any]], + *, + train_fraction: float = 0.6, + false_discovery_rate: float = 0.1, + minimum_oos_observations: int = 5, + minimum_abs_oos_ic: float = 0.02, + minimum_coverage: float = 0.5, + holding_period: int = 5, + ) -> dict[str, Any]: + split = min(0.8, max(0.5, float(train_fraction or 0.6))) + alpha = min(0.5, max(0.001, float(false_discovery_rate or 0.1))) + min_oos = max(3, int(minimum_oos_observations or 5)) + min_abs_ic = max(0.0, float(minimum_abs_oos_ic or 0.0)) + min_coverage = min(1.0, max(0.0, float(minimum_coverage or 0.0))) + holding_period = max(1, int(holding_period or 5)) + + rows = [ + self._summarize( + result, + train_fraction=split, + minimum_oos_observations=min_oos, + holding_period=holding_period, + ) + for result in results + ] + q_values = self._benjamini_hochberg([row["pValue"] for row in rows]) + for row, q_value in zip(rows, q_values): + row["qValue"] = q_value + row["classification"] = self._classify( + row, + false_discovery_rate=alpha, + minimum_abs_oos_ic=min_abs_ic, + minimum_coverage=min_coverage, + minimum_oos_observations=min_oos, + ) + row["score"] = self._score(row, alpha) + + rows.sort( + key=lambda item: ( + item["classification"] == "insufficient", + -float(item["score"]), + float(item["qValue"]), + str(item["factorId"]), + ) + ) + for rank, row in enumerate(rows, start=1): + row["rank"] = rank + + counts = { + label: sum(row["classification"] == label for row in rows) + for label in ("alive", "reversed", "candidate", "decaying", "dead", "insufficient") + } + strongest_oos = max( + rows, + key=lambda item: abs(float(item["testRankIc"])), + default=None, + ) + return { + "methodologyVersion": self.METHODOLOGY_VERSION, + "rankings": rows, + "summary": { + "factorCount": len(rows), + "counts": counts, + "bestFactorId": rows[0]["factorId"] if rows else None, + "positiveOosCount": sum(float(row["testRankIc"]) > 0 for row in rows), + "negativeOosCount": sum(float(row["testRankIc"]) < 0 for row in rows), + "zeroOosCount": sum(float(row["testRankIc"]) == 0 for row in rows), + "strongestOosFactorId": strongest_oos["factorId"] if strongest_oos else None, + "strongestOosRankIc": strongest_oos["testRankIc"] if strongest_oos else None, + }, + "correlation": self._correlation_matrix(results), + "validation": { + "trainFraction": split, + "testFraction": 1.0 - split, + "falseDiscoveryRate": alpha, + "multipleTesting": "benjamini_hochberg", + "minimumOosObservations": min_oos, + "minimumAbsOosIc": min_abs_ic, + "minimumCoverage": min_coverage, + "classificationOrder": [ + "alive", "reversed", "candidate", "decaying", "dead", "insufficient" + ], + }, + } + + def _summarize( + self, + result: Mapping[str, Any], + *, + train_fraction: float, + minimum_oos_observations: int, + holding_period: int, + ) -> dict[str, Any]: + observations = self._ic_observations(result) + values = [value for _, value in observations] + split_index = max(1, int(len(values) * train_fraction)) + if len(values) >= minimum_oos_observations + 2: + split_index = min(split_index, len(values) - minimum_oos_observations) + train = values[:split_index] + test = values[split_index:] + train_ic, train_std = self._mean_std(train) + test_ic, test_std = self._mean_std(test) + full_ic, full_std = self._mean_std(values) + annualization = float( + (result.get("executionAssumptions") or {}).get("periodsPerYear") or 252.0 + ) + p_value = self._mean_p_value(test) + same_sign = train_ic * test_ic > 0 + retention = ( + min(2.0, abs(test_ic) / abs(train_ic)) + if same_sign and abs(train_ic) > 1e-12 + else 0.0 + ) + direction = "inverse" if full_ic < 0 else "normal" + net_return = self._number(result.get("netLongShortReturn")) + oriented_return = -net_return if direction == "inverse" else net_return + return { + "factorId": str(result.get("factorId") or ""), + "factorParams": dict(result.get("factorParams") or {}), + "direction": direction, + "observationCount": len(values), + "trainObservationCount": len(train), + "testObservationCount": len(test), + "rankIc": full_ic, + "trainRankIc": train_ic, + "testRankIc": test_ic, + "trainIcStd": train_std, + "testIcStd": test_std, + "testIcir": self._icir(test_ic, test_std, annualization, holding_period), + "testPositiveRate": self._positive_rate(test), + "sameSign": same_sign, + "oosRetention": retention, + "pValue": p_value, + "qValue": 1.0, + "coverage": self._number(result.get("coverage")), + "averageTurnover": self._number(result.get("averageTurnover")), + "netLongShortReturn": net_return, + "orientedNetLongShortReturn": oriented_return, + "monotonicity": self._number(result.get("monotonicity")), + "pointInTimeUniverseApplied": bool(result.get("pointInTimeUniverseApplied")), + } + + @staticmethod + def _classify( + row: Mapping[str, Any], + *, + false_discovery_rate: float, + minimum_abs_oos_ic: float, + minimum_coverage: float, + minimum_oos_observations: int, + ) -> str: + if ( + int(row["testObservationCount"]) < minimum_oos_observations + or float(row["coverage"]) < minimum_coverage + ): + return "insufficient" + train_ic = float(row["trainRankIc"]) + test_ic = float(row["testRankIc"]) + stable = bool(row["sameSign"]) + significant = float(row["qValue"]) <= false_discovery_rate + usable = ( + abs(test_ic) >= minimum_abs_oos_ic + and float(row["coverage"]) >= minimum_coverage + ) + if stable and significant and usable: + return "alive" if test_ic > 0 else "reversed" + if abs(train_ic) >= minimum_abs_oos_ic and ( + not stable or abs(test_ic) < abs(train_ic) * 0.35 + ): + return "decaying" + if stable and usable: + return "candidate" + return "dead" + + @staticmethod + def _score(row: Mapping[str, Any], false_discovery_rate: float) -> float: + if row.get("classification") == "insufficient": + return 0.0 + test_ic = abs(float(row["testRankIc"])) + edge = min(1.0, test_ic / 0.08) + retention = min(1.0, float(row["oosRetention"])) if row["sameSign"] else 0.0 + significance = max( + 0.0, + 1.0 - float(row["qValue"]) / max(false_discovery_rate, 1e-12), + ) + coverage = min(1.0, max(0.0, float(row["coverage"]))) + oriented_return = max(0.0, float(row["orientedNetLongShortReturn"])) + return_score = min(1.0, oriented_return / 0.15) + turnover_efficiency = 1.0 - min(1.0, max(0.0, float(row["averageTurnover"])) / 2.0) + score = ( + edge * 35.0 + + retention * 20.0 + + significance * 15.0 + + coverage * 10.0 + + return_score * 10.0 + + turnover_efficiency * 10.0 + ) + return round(score, 4) + + @staticmethod + def _ic_observations(result: Mapping[str, Any]) -> list[tuple[str, float]]: + output: list[tuple[str, float]] = [] + for item in result.get("icSeries") or []: + if not isinstance(item, Mapping): + continue + value = FactorBenchmarkEngine._optional_number(item.get("value")) + if value is None: + continue + output.append((str(item.get("time") or ""), value)) + return sorted(output, key=lambda item: item[0]) + + @staticmethod + def _mean_std(values: Sequence[float]) -> tuple[float, float]: + if not values: + return 0.0, 0.0 + return float(mean(values)), float(stdev(values)) if len(values) > 1 else 0.0 + + @staticmethod + def _mean_p_value(values: Sequence[float]) -> float: + average, deviation = FactorBenchmarkEngine._mean_std(values) + if len(values) < 2 or deviation <= 1e-12: + return 0.0 if abs(average) > 1e-12 else 1.0 + statistic = average / (deviation / math.sqrt(len(values))) + return min(1.0, max(0.0, math.erfc(abs(statistic) / math.sqrt(2.0)))) + + @staticmethod + def _benjamini_hochberg(p_values: Sequence[float]) -> list[float]: + count = len(p_values) + if not count: + return [] + ordered = sorted(range(count), key=lambda index: float(p_values[index])) + adjusted = [1.0] * count + running = 1.0 + for reverse_rank in range(count - 1, -1, -1): + index = ordered[reverse_rank] + rank = reverse_rank + 1 + candidate = min(1.0, float(p_values[index]) * count / rank) + running = min(running, candidate) + adjusted[index] = running + return adjusted + + @staticmethod + def _icir(mean_ic: float, std_ic: float, periods_per_year: float, holding_period: int) -> float: + if std_ic <= 1e-12: + return 0.0 + return float(mean_ic / std_ic * math.sqrt(max(1.0, periods_per_year) / holding_period)) + + @staticmethod + def _positive_rate(values: Sequence[float]) -> float: + return sum(value > 0 for value in values) / len(values) if values else 0.0 + + @classmethod + def _correlation_matrix(cls, results: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + names = [str(result.get("factorId") or "") for result in results] + series = [dict(cls._ic_observations(result)) for result in results] + matrix: list[list[float | None]] = [] + observations: list[list[int]] = [] + for left_index, left in enumerate(series): + value_row: list[float | None] = [] + count_row: list[int] = [] + for right_index, right in enumerate(series): + shared = sorted(set(left) & set(right)) + count_row.append(len(shared)) + if left_index == right_index and shared: + value_row.append(1.0) + continue + value_row.append(cls._pearson( + [left[key] for key in shared], + [right[key] for key in shared], + )) + matrix.append(value_row) + observations.append(count_row) + return {"factors": names, "matrix": matrix, "observations": observations} + + @staticmethod + def _pearson(left: Sequence[float], right: Sequence[float]) -> float | None: + if len(left) < 3 or len(left) != len(right): + return None + left_mean = mean(left) + right_mean = mean(right) + numerator = sum((x - left_mean) * (y - right_mean) for x, y in zip(left, right)) + left_scale = math.sqrt(sum((x - left_mean) ** 2 for x in left)) + right_scale = math.sqrt(sum((y - right_mean) ** 2 for y in right)) + if left_scale <= 1e-12 or right_scale <= 1e-12: + return None + return float(numerator / (left_scale * right_scale)) + + @staticmethod + def _number(value: Any) -> float: + parsed = FactorBenchmarkEngine._optional_number(value) + return parsed if parsed is not None else 0.0 + + @staticmethod + def _optional_number(value: Any) -> float | None: + if isinstance(value, bool): + return None + try: + parsed = float(value) + except (TypeError, ValueError): + return None + return parsed if math.isfinite(parsed) else None diff --git a/backend_api_python/app/services/strategy_v2/factor_research.py b/backend_api_python/app/services/strategy_v2/factor_research.py index 657d855d0..b04e07349 100644 --- a/backend_api_python/app/services/strategy_v2/factor_research.py +++ b/backend_api_python/app/services/strategy_v2/factor_research.py @@ -17,6 +17,7 @@ get_factor, get_factor_metadata, ) +from app.services.custom_factors import CustomFactorError, compute_custom_factor class FactorResearchEngine: @@ -93,6 +94,8 @@ def run( neutralize_industry: bool = False, members: Sequence[Mapping[str, Any]] | None = None, annualization_periods: float = 252.0, + include_factor_correlation: bool = True, + custom_factor: Mapping[str, Any] | None = None, ) -> dict[str, Any]: requested_groups = max(2, min(10, int(groups or 5))) holding_period = max(1, int(holding_period or 5)) @@ -118,6 +121,7 @@ def run( close, normalized_factor_params, research_dates, + custom_factor=custom_factor, ) entry = open_price.shift(-1) exit_price = open_price.shift(-(holding_period + 1)) @@ -312,13 +316,18 @@ def run( "effectiveGroups": effective_groups, "sampleDiagnostics": sample_diagnostics, "pointInTimeUniverseApplied": self._has_dated_membership(members or ()), - "factorCorrelation": self._factor_correlation( - prepared, - close, - membership, - research_dates, - factor_name, - normalized_factor_params, + "factorCorrelation": ( + self._factor_correlation( + prepared, + close, + membership, + research_dates, + factor_name, + normalized_factor_params, + custom_factor, + ) + if include_factor_correlation + else {"factors": [], "matrix": [], "observations": []} ), "stability": { "firstHalfIc": float(first_half.mean()) if not first_half.empty else 0.0, @@ -374,7 +383,30 @@ def _factor_panel( close: pd.DataFrame, params: Mapping[str, Any] | None = None, timestamps: Sequence[pd.Timestamp] | None = None, + custom_factor: Mapping[str, Any] | None = None, ) -> pd.DataFrame: + if custom_factor is not None: + result = pd.DataFrame(index=close.index, columns=close.columns, dtype=float) + requested = close.index if timestamps is None else pd.Index(timestamps) + requested = requested.intersection(close.index, sort=False) + for timestamp in requested: + for symbol, frame in frames.items(): + if symbol not in result.columns or frame.empty or frame.index[0] > timestamp: + continue + try: + result.at[timestamp, symbol] = compute_custom_factor( + custom_factor, + frame.loc[frame.index <= timestamp], + params, + ) + except CustomFactorError as exc: + if exc.code not in { + "factor.noData", + "factor.missingFields", + "factor.customComputeFailed", + }: + raise + return result if factor_id.startswith("qlib:"): return self._catalog_factor_panel( frames, @@ -591,6 +623,7 @@ def _factor_correlation( research_dates: Sequence[pd.Timestamp], selected_factor: str, selected_params: Mapping[str, Any] | None = None, + custom_factor: Mapping[str, Any] | None = None, ) -> dict[str, Any]: names = list(dict.fromkeys((selected_factor, *self._CORRELATION_FACTORS))) panels: dict[str, pd.DataFrame] = {} @@ -601,6 +634,7 @@ def _factor_correlation( close, selected_params if name == selected_factor else None, research_dates, + custom_factor=custom_factor if name == selected_factor else None, ) visible = panel.reindex(index=research_dates) if int(visible.notna().sum().sum()) >= 3: diff --git a/backend_api_python/app/services/strategy_v2/runtime.py b/backend_api_python/app/services/strategy_v2/runtime.py index ad43148dd..2dc7c2ab0 100644 --- a/backend_api_python/app/services/strategy_v2/runtime.py +++ b/backend_api_python/app/services/strategy_v2/runtime.py @@ -21,6 +21,7 @@ get_factor, is_talib_available, ) +from app.services.custom_factors import compute_custom_factor from app.services.backtest.metrics import ( calculate_calmar_ratio, calculate_downside_risk, @@ -229,11 +230,13 @@ def __init__( portal: MultiAssetDataPortal, portfolio: PortfolioState, params: Mapping[str, Any] | None = None, + custom_factor_resolver=None, ) -> None: self.portal = portal self.data = StrategyDataView(portal) self.portfolio = portfolio self.params = dict(params or {}) + self.custom_factor_resolver = custom_factor_resolver self.current_dt: pd.Timestamp | None = None self.previous_trading_date: pd.Timestamp | None = None self._orders: list[OrderIntent] = [] @@ -528,6 +531,14 @@ def factor(self, name: object, symbol: object = None, **params: Any) -> float: ) frame = self.portal.visible_frame(target, frequency=frequency) factor_id = str(name or "").strip() + if factor_id.lower().startswith("custom:"): + if self.custom_factor_resolver is None: + raise FactorError("factor.customNotFound") + return compute_custom_factor( + self.custom_factor_resolver(factor_id), + frame, + params, + ) return compute_catalog_factor(factor_id, frame, params) def get_factors(self, symbols: object, names: object, **params: Any) -> pd.DataFrame: @@ -1777,6 +1788,7 @@ def __init__( slippage: float = 0.0005, universe_resolver=None, instrument_rules: InstrumentRulesSnapshot | Mapping[str, InstrumentRules | Mapping[str, Any]] | None = None, + custom_factor_resolver=None, ) -> None: self.program: CompiledStrategyV2 = compile_strategy_v2(code) requested_leverage = max(1.0, float(leverage or 1.0)) if leverage_enabled else 1.0 @@ -1805,6 +1817,7 @@ def __init__( portal=self.portal, portfolio=self.broker.portfolio, params=runtime_params, + custom_factor_resolver=custom_factor_resolver, ) self.logs: list[str] = [] self._order_status_cursor = 0 diff --git a/backend_api_python/app/services/strategy_v2/service.py b/backend_api_python/app/services/strategy_v2/service.py index 6d6e6eb0d..b5010d7ea 100644 --- a/backend_api_python/app/services/strategy_v2/service.py +++ b/backend_api_python/app/services/strategy_v2/service.py @@ -24,6 +24,8 @@ validate_backtest_range, ) from app.services.fundamental_data import get_fundamental_data_service +from app.services.factors import FactorError +from app.services.custom_factors import get_custom_factor from app.services.instrument_rules import InstrumentRulesProvider, get_instrument_rules_provider from app.services.market.instrument_products import PRODUCT_CRYPTO from app.services.market.product_catalog import get_catalog_product @@ -31,6 +33,7 @@ from app.utils.logger import get_logger from .contract import StrategyV2ContractError, compile_strategy_v2 +from .factor_benchmark import FactorBenchmarkEngine from .factor_research import FactorResearchEngine from .frequencies import SUPPORTED_SUBSCRIPTION_FREQUENCIES, normalize_frequency, periods_per_year from .instruments import instrument_key_from_metadata @@ -123,10 +126,20 @@ def research_factor( f"strategyV2.factorResearchUniverseTooSmall:{minimum_symbols}" ) frequency = manifest.driving_frequency + custom_factor = ( + get_custom_factor(user_id, factor_id) + if str(factor_id).lower().startswith("custom:") + else None + ) + factor_warmup = ( + int(custom_factor.get("default_warmup_bars") or 1) + if custom_factor + else FactorResearchEngine.warmup_bars(factor_id, factor_params) + ) warmup_bars = max( 40, manifest.warmup_bars, - FactorResearchEngine.warmup_bars(factor_id, factor_params), + factor_warmup, ) fetch_start = start_date - timedelta(days=_warmup_calendar_days(frequency, warmup_bars, candidates)) _enforce_backtest_range( @@ -144,7 +157,11 @@ def research_factor( raise StrategyV2ContractError( f"strategyV2.factorResearchUsableUniverseTooSmall:{minimum_symbols}" ) - factor_fundamental_fields = FactorResearchEngine.required_fields(factor_id) + factor_fundamental_fields = ( + set(custom_factor.get("required_fields") or []) + if custom_factor + else FactorResearchEngine.required_fields(factor_id) + ) if manifest.fundamental_dependencies or factor_fundamental_fields: enricher = self.fundamental_enricher or get_fundamental_data_service().enrich_panel frames = enricher(frames, candidates) @@ -164,6 +181,7 @@ def research_factor( frequency, {str(item.get("market") or "") for item in candidates}, ), + custom_factor=custom_factor, ) result.update({ "manifest": manifest.metadata(), @@ -215,9 +233,19 @@ def research_universe_factor( raise StrategyV2ContractError( f"strategyV2.factorResearchUniverseTooSmall:{minimum_symbols}" ) + custom_factor = ( + get_custom_factor(user_id, factor_id) + if str(factor_id).lower().startswith("custom:") + else None + ) + factor_warmup = ( + int(custom_factor.get("default_warmup_bars") or 1) + if custom_factor + else FactorResearchEngine.warmup_bars(factor_id, factor_params) + ) warmup_bars = max( 40, - FactorResearchEngine.warmup_bars(factor_id, factor_params), + factor_warmup, ) fetch_start = start_date - timedelta( days=_warmup_calendar_days(normalized_frequency, warmup_bars, candidates) @@ -242,7 +270,12 @@ def research_universe_factor( raise StrategyV2ContractError( f"strategyV2.factorResearchUsableUniverseTooSmall:{minimum_symbols}" ) - if FactorResearchEngine.required_fields(factor_id): + required_fields = ( + set(custom_factor.get("required_fields") or []) + if custom_factor + else FactorResearchEngine.required_fields(factor_id) + ) + if required_fields - {"open", "high", "low", "close", "volume", "amount"}: enricher = self.fundamental_enricher or get_fundamental_data_service().enrich_panel frames = enricher(frames, candidates) @@ -281,6 +314,7 @@ def research_universe_factor( neutralize_industry=neutralize_industry, members=candidates, annualization_periods=periods_per_year(normalized_frequency, set(markets)), + custom_factor=custom_factor, ) result.update({ "researchMode": "universe", @@ -294,6 +328,211 @@ def research_universe_factor( }) return result + def benchmark_universe_factors( + self, + *, + user_id: int, + universe_id: int, + frequency: str, + start_date: datetime, + end_date: datetime, + factors: list[dict[str, Any]], + groups: int = 5, + holding_period: int = 5, + commission: float = 0.0005, + slippage: float = 0.0005, + neutralize_industry: bool = False, + train_fraction: float = 0.6, + false_discovery_rate: float = 0.1, + minimum_oos_observations: int = 5, + minimum_abs_oos_ic: float = 0.02, + minimum_coverage: float = 0.5, + ) -> dict[str, Any]: + normalized_frequency = normalize_frequency(frequency) + if normalized_frequency not in SUPPORTED_SUBSCRIPTION_FREQUENCIES: + raise StrategyV2ContractError( + f"strategyV2.unsupportedFrequency:{normalized_frequency}" + ) + maximum_factors = max( + 2, + int(os.getenv("FACTOR_BENCHMARK_MAX_FACTORS", "40") or 40), + ) + normalized_factors: list[dict[str, Any]] = [] + seen: set[str] = set() + for item in factors or []: + if not isinstance(item, dict): + continue + factor_id = str(item.get("factorId") or "").strip().lower() + if not factor_id or factor_id in seen: + continue + seen.add(factor_id) + normalized_factors.append({ + "factorId": factor_id, + "factorParams": ( + dict(item.get("factorParams") or {}) + if isinstance(item.get("factorParams"), dict) + else {} + ), + }) + if len(normalized_factors) < 2: + raise StrategyV2ContractError("strategyV2.factorBenchmarkMinimumFactors:2") + if len(normalized_factors) > maximum_factors: + raise StrategyV2ContractError( + f"strategyV2.factorBenchmarkTooManyFactors:{maximum_factors}" + ) + + universe = self.universe_service.get_universe(user_id, int(universe_id)) + validate_universe_history(universe, start_date) + candidates = self.universe_service.candidate_members( + user_id, + int(universe_id), + start=start_date.date(), + end=end_date.date(), + ) + limit = strategy_universe_symbol_limit(normalized_frequency) + if len(candidates) > limit: + raise StrategyV2ContractError("strategyV2.universeTooLarge") + candidates = [{**item, "key": _member_key(item)} for item in candidates] + _attach_catalog_products(candidates) + if len(candidates) < 3: + raise StrategyV2ContractError("strategyV2.factorResearchUniverseTooSmall:3") + + custom_factors = { + item["factorId"]: get_custom_factor(user_id, item["factorId"]) + for item in normalized_factors + if item["factorId"].startswith("custom:") + } + warmup_bars = max( + 40, + *[ + int(custom_factors[item["factorId"]].get("default_warmup_bars") or 1) + if item["factorId"] in custom_factors + else FactorResearchEngine.warmup_bars(item["factorId"], item["factorParams"]) + for item in normalized_factors + ], + ) + fetch_start = start_date - timedelta( + days=_warmup_calendar_days( + normalized_frequency, warmup_bars, candidates + ) + ) + _enforce_backtest_range( + candidates=candidates, + timeframe=normalized_frequency, + start_date=start_date, + end_date=end_date, + warmup_bars=warmup_bars, + fetch_start=fetch_start, + ) + frames, skipped = self.fetch_frames( + candidates, + normalized_frequency, + fetch_start, + end_date, + ) + if len(frames) < 3: + raise StrategyV2ContractError( + "strategyV2.factorResearchUsableUniverseTooSmall:3" + ) + benchmark_required_fields = set().union(*[ + set(custom_factors[item["factorId"]].get("required_fields") or []) + if item["factorId"] in custom_factors + else FactorResearchEngine.required_fields(item["factorId"]) + for item in normalized_factors + ]) + if benchmark_required_fields - {"open", "high", "low", "close", "volume", "amount"}: + enricher = ( + self.fundamental_enricher + or get_fundamental_data_service().enrich_panel + ) + frames = enricher(frames, candidates) + + markets = sorted({ + str(item.get("market") or "") + for item in candidates + if item.get("market") + }) + annualization = periods_per_year(normalized_frequency, set(markets)) + results: list[dict[str, Any]] = [] + errors: list[dict[str, str]] = [] + for item in normalized_factors: + try: + results.append(FactorResearchEngine().run( + frames=frames, + factor_id=item["factorId"], + factor_params=item["factorParams"], + start_date=start_date, + end_date=end_date, + groups=groups, + holding_period=holding_period, + commission=commission, + slippage=slippage, + neutralize_industry=neutralize_industry, + members=candidates, + annualization_periods=annualization, + include_factor_correlation=False, + custom_factor=custom_factors.get(item["factorId"]), + )) + except (FactorError, ValueError) as exc: + errors.append({"factorId": item["factorId"], "error": str(exc)}) + if not results: + raise StrategyV2ContractError("strategyV2.factorBenchmarkNoSuccessfulFactors") + + benchmark = FactorBenchmarkEngine().run( + results, + train_fraction=train_fraction, + false_discovery_rate=false_discovery_rate, + minimum_oos_observations=minimum_oos_observations, + minimum_abs_oos_ic=minimum_abs_oos_ic, + minimum_coverage=minimum_coverage, + holding_period=holding_period, + ) + universe_metadata = { + "id": int(universe_id), + "code": str(universe.get("code") or ""), + "name": str(universe.get("name") or ""), + "nameI18nKey": str(universe.get("name_i18n_key") or ""), + "market": str(universe.get("market") or ""), + "memberCount": len(candidates), + "historyFrom": universe.get("history_from"), + } + manifest = { + "strategyType": "factor_benchmark", + "primaryFrequency": normalized_frequency, + "drivingFrequency": normalized_frequency, + "frequencies": [normalized_frequency], + "markets": markets, + "universe": { + "kind": "managed", + "reference": f"UNIVERSE:{universe.get('code') or universe_id}", + "instruments": [], + }, + } + benchmark.update({ + "researchMode": "benchmark", + "manifest": manifest, + "universeId": int(universe_id), + "universe": universe_metadata, + "frequency": normalized_frequency, + "symbolsRequested": len(candidates), + "symbolsUsed": len(frames), + "symbolsSkipped": skipped, + "factorResults": results, + "errors": errors, + "executionAssumptions": { + "holdingPeriod": max(1, int(holding_period)), + "groups": max(2, min(10, int(groups))), + "commission": float(commission), + "slippage": float(slippage), + "neutralized": bool(neutralize_industry), + }, + }) + benchmark["summary"].update({ + "requestedFactorCount": len(normalized_factors), + "failedFactorCount": len(errors), + }) + return benchmark + def run( self, *, @@ -324,6 +563,16 @@ def emit(phase: str, percent: int, **details: Any) -> None: emit("preparing", 5) program = compile_strategy_v2(code) manifest = program.manifest + custom_factor_cache = { + str(reference).lower(): get_custom_factor(user_id, reference) + for reference in manifest.factor_dependencies + if str(reference).lower().startswith("custom:") + } + custom_warmup_bars = max( + [int(item.get("default_warmup_bars") or 1) for item in custom_factor_cache.values()], + default=0, + ) + effective_warmup_bars = max(manifest.warmup_bars, custom_warmup_bars) if end_date <= start_date: raise StrategyV2ContractError("strategyV2.invalidDateRange") if initial_capital <= 0: @@ -344,7 +593,7 @@ def emit(phase: str, percent: int, **details: Any) -> None: _validate_product_frequencies(instrument_products, manifest.frequencies) fetch_starts = { item: start_date - - timedelta(days=_warmup_calendar_days(item, manifest.warmup_bars, candidates)) + - timedelta(days=_warmup_calendar_days(item, effective_warmup_bars, candidates)) for item in manifest.frequencies } for item in manifest.frequencies: @@ -353,7 +602,7 @@ def emit(phase: str, percent: int, **details: Any) -> None: timeframe=item, start_date=start_date, end_date=end_date, - warmup_bars=manifest.warmup_bars, + warmup_bars=effective_warmup_bars, fetch_start=fetch_starts[item], ) data_at = perf_counter() @@ -373,11 +622,11 @@ def emit(phase: str, percent: int, **details: Any) -> None: if not frames: raise StrategyV2ContractError("strategyV2.noMarketData") if manifest.universe.kind == "static": - validate_warmup(frequency_frames, manifest.warmup_bars, start_date, candidates) + validate_warmup(frequency_frames, effective_warmup_bars, start_date, candidates) else: shortfalls = warmup_shortfalls( frequency_frames, - manifest.warmup_bars, + effective_warmup_bars, start_date, candidates, ) @@ -387,7 +636,7 @@ def emit(phase: str, percent: int, **details: Any) -> None: if not remaining_symbols: validate_warmup( frequency_frames, - manifest.warmup_bars, + effective_warmup_bars, start_date, candidates, ) @@ -402,7 +651,14 @@ def emit(phase: str, percent: int, **details: Any) -> None: "requiredBars": item["required"], } for item in shortfalls) frames = frequency_frames.get(frequency, {}) - if manifest.fundamental_dependencies: + custom_required_fields = set().union(*[ + set(item.get("required_fields") or []) + for item in custom_factor_cache.values() + ]) if custom_factor_cache else set() + custom_fundamental_fields = custom_required_fields - { + "open", "high", "low", "close", "volume", "amount" + } + if manifest.fundamental_dependencies or custom_fundamental_fields: enricher = self.fundamental_enricher or get_fundamental_data_service().enrich_panel frames = enricher(frames, candidates) frequency_frames[frequency] = frames @@ -410,7 +666,7 @@ def emit(phase: str, percent: int, **details: Any) -> None: required = { _normalize_field(item) for item in manifest.fundamental_dependencies - } + } | {_normalize_field(item) for item in custom_fundamental_fields} fundamental_gaps = fundamental_shortfalls(frames, required) if fundamental_gaps: excluded_fields: dict[str, set[str]] = {} @@ -475,6 +731,7 @@ def resolve_universe(reference: str, timestamp: pd.Timestamp) -> list[str]: slippage=slippage, universe_resolver=resolve_universe, instrument_rules=rules_snapshot, + custom_factor_resolver=lambda factor_id: custom_factor_cache[str(factor_id).lower()], ) result = runner.run(start_date=start_date, end_date=end_date) report_at = perf_counter() diff --git a/backend_api_python/app/services/strategy_v2/storage.py b/backend_api_python/app/services/strategy_v2/storage.py index b96564183..9a230c4a1 100644 --- a/backend_api_python/app/services/strategy_v2/storage.py +++ b/backend_api_python/app/services/strategy_v2/storage.py @@ -709,6 +709,7 @@ def list_runs( *, user_id: int, source_id: int | None = None, + research_type: str = "all", limit: int = 50, offset: int = 0, ) -> list[dict[str, Any]]: @@ -717,6 +718,10 @@ def list_runs( if source_id is not None: where.append("source_id = ?") params.append(int(source_id)) + if research_type == "single": + where.append("research_mode <> 'benchmark'") + elif research_type == "benchmark": + where.append("research_mode = 'benchmark'") with get_db_connection() as db: cur = db.cursor() cur.execute( @@ -738,6 +743,41 @@ def list_runs( cur.close() return [self._hydrate_summary(row) for row in rows] + def list_benchmark_runs( + self, + *, + user_id: int, + benchmark_id: str | None = None, + limit: int = 1000, + ) -> list[dict[str, Any]]: + with get_db_connection() as db: + cur = db.cursor() + cur.execute( + """ + SELECT id, user_id, source_id, source_name, universe_id, universe_name, + research_mode, market, timeframe, start_date, + end_date, factor_id, groups_count, holding_period, commission, slippage, + neutralize_industry, universe_size, manifest_json, code_hash, result_json, + status, created_at + FROM qd_factor_research_runs + WHERE user_id = ? AND research_mode = 'benchmark' + ORDER BY id DESC + LIMIT ? + """, + (int(user_id), max(1, min(5000, int(limit)))), + ) + rows = cur.fetchall() or [] + cur.close() + hydrated = [self._hydrate(row, include_result=True) for row in rows] + if not benchmark_id: + return hydrated + target = str(benchmark_id) + return [ + item + for item in hydrated + if str((item.get("result") or {}).get("benchmarkId") or "") == target + ] + def get_run(self, *, user_id: int, run_id: int) -> Optional[dict[str, Any]]: with get_db_connection() as db: cur = db.cursor() diff --git a/backend_api_python/app/tasks/agent_jobs.py b/backend_api_python/app/tasks/agent_jobs.py index 62d5fab15..3700165fd 100644 --- a/backend_api_python/app/tasks/agent_jobs.py +++ b/backend_api_python/app/tasks/agent_jobs.py @@ -13,6 +13,7 @@ SUPPORTED_KINDS = frozenset( { "backtest", + "factor_benchmark", "strategy_evolution", } ) @@ -28,6 +29,10 @@ def _execute(kind: str, payload: dict, on_progress): from app.routes.agent_v1.backtests import _run_backtest return _run_backtest(request_payload, on_progress) + if kind == "factor_benchmark": + from app.routes.agent_v1.research import _run_factor_benchmark + + return _run_factor_benchmark(request_payload, on_progress) if kind == "strategy_evolution": from app.routes.strategy_evolution import _run_evolution_job diff --git a/backend_api_python/app/utils/security_fingerprint.py b/backend_api_python/app/utils/security_fingerprint.py new file mode 100644 index 000000000..8fd382926 --- /dev/null +++ b/backend_api_python/app/utils/security_fingerprint.py @@ -0,0 +1,25 @@ +"""Process-local opaque fingerprints for sensitive cache key material.""" + +from __future__ import annotations + +import hashlib +import hmac +import secrets +from typing import Any + + +_PROCESS_FINGERPRINT_KEY = secrets.token_bytes(32) + + +def opaque_fingerprint(*parts: Any, namespace: str) -> str: + """Return a stable process-local HMAC without exposing the source values.""" + payload = bytearray() + for part in parts: + encoded = str(part or "").encode("utf-8") + payload.extend(len(encoded).to_bytes(8, "big")) + payload.extend(encoded) + return hmac.new( + _PROCESS_FINGERPRINT_KEY, + namespace.encode("utf-8") + b"\x00" + bytes(payload), + hashlib.sha256, + ).hexdigest() diff --git a/backend_api_python/migrations/20261101_user_factors.sql b/backend_api_python/migrations/20261101_user_factors.sql new file mode 100644 index 000000000..571c9131b --- /dev/null +++ b/backend_api_python/migrations/20261101_user_factors.sql @@ -0,0 +1,35 @@ +CREATE TABLE IF NOT EXISTS qd_user_factors ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE, + name VARCHAR(120) NOT NULL, + description TEXT NOT NULL DEFAULT '', + category VARCHAR(40) NOT NULL DEFAULT 'custom', + direction_hint VARCHAR(40) NOT NULL DEFAULT 'neutral', + expression TEXT NOT NULL, + default_params JSONB NOT NULL DEFAULT '{}'::jsonb, + parameter_schema JSONB NOT NULL DEFAULT '{}'::jsonb, + required_fields JSONB NOT NULL DEFAULT '[]'::jsonb, + dependencies JSONB NOT NULL DEFAULT '[]'::jsonb, + warmup_bars INTEGER NOT NULL DEFAULT 1, + version INTEGER NOT NULL DEFAULT 1, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_factors_owner + ON qd_user_factors(user_id, status, updated_at DESC); + +CREATE TABLE IF NOT EXISTS qd_user_factor_versions ( + id BIGSERIAL PRIMARY KEY, + factor_id BIGINT NOT NULL REFERENCES qd_user_factors(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE, + version_no INTEGER NOT NULL, + expression TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(factor_id, version_no) +); + +CREATE INDEX IF NOT EXISTS idx_user_factor_versions_owner + ON qd_user_factor_versions(user_id, factor_id, version_no DESC); diff --git a/backend_api_python/migrations/init.sql b/backend_api_python/migrations/init.sql index 27e77acde..2727407cc 100644 --- a/backend_api_python/migrations/init.sql +++ b/backend_api_python/migrations/init.sql @@ -1965,6 +1965,40 @@ CREATE INDEX IF NOT EXISTS idx_factor_research_runs_user_source_recent CREATE INDEX IF NOT EXISTS idx_factor_research_runs_user_universe_recent ON qd_factor_research_runs(user_id, universe_id, id DESC); +CREATE TABLE IF NOT EXISTS qd_user_factors ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE, + name VARCHAR(120) NOT NULL, + description TEXT NOT NULL DEFAULT '', + category VARCHAR(40) NOT NULL DEFAULT 'custom', + direction_hint VARCHAR(40) NOT NULL DEFAULT 'neutral', + expression TEXT NOT NULL, + default_params JSONB NOT NULL DEFAULT '{}'::jsonb, + parameter_schema JSONB NOT NULL DEFAULT '{}'::jsonb, + required_fields JSONB NOT NULL DEFAULT '[]'::jsonb, + dependencies JSONB NOT NULL DEFAULT '[]'::jsonb, + warmup_bars INTEGER NOT NULL DEFAULT 1, + version INTEGER NOT NULL DEFAULT 1, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_user_factors_owner + ON qd_user_factors(user_id, status, updated_at DESC); + +CREATE TABLE IF NOT EXISTS qd_user_factor_versions ( + id BIGSERIAL PRIMARY KEY, + factor_id BIGINT NOT NULL REFERENCES qd_user_factors(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE, + version_no INTEGER NOT NULL, + expression TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(factor_id, version_no) +); +CREATE INDEX IF NOT EXISTS idx_user_factor_versions_owner + ON qd_user_factor_versions(user_id, factor_id, version_no DESC); + -- ============================================================================= -- 13. Exchange Credentials -- ============================================================================= diff --git a/backend_api_python/scripts/backend_quality_baseline.json b/backend_api_python/scripts/backend_quality_baseline.json index 2e01bf7e3..faba3766e 100644 --- a/backend_api_python/scripts/backend_quality_baseline.json +++ b/backend_api_python/scripts/backend_quality_baseline.json @@ -4,7 +4,7 @@ "app/services/pending_order_worker.py": 3069, "app/services/fast_analysis.py": 1848, "app/routes/strategy.py": 1247, - "app/routes/quick_trade.py": 1994 + "app/routes/quick_trade.py": 2027 }, "max_function_lines": { "app/services/trading_executor.py::_run_strategy_loop": 1176, diff --git a/backend_api_python/tests/test_agent_factor_benchmark.py b/backend_api_python/tests/test_agent_factor_benchmark.py new file mode 100644 index 000000000..062cde3aa --- /dev/null +++ b/backend_api_python/tests/test_agent_factor_benchmark.py @@ -0,0 +1,34 @@ +from app.routes.agent_v1 import research + + +def test_agent_factor_benchmark_runner_maps_job_payload(monkeypatch): + captured = {} + progress = [] + + class BenchmarkService: + @staticmethod + def benchmark_universe_factors(**kwargs): + captured.update(kwargs) + return {"researchMode": "benchmark", "rankings": []} + + monkeypatch.setattr(research, "_factor_benchmark", BenchmarkService()) + result = research._run_factor_benchmark({ + "__user_id": 7, + "universeId": 19, + "frequency": "1d", + "startDate": "2025-01-01", + "endDate": "2026-01-01", + "factors": [{"factorId": "quality"}, {"factorId": "value"}], + "trainFraction": 0.7, + "falseDiscoveryRate": 0.08, + "minimumAbsOosIc": 0.03, + }, progress.append) + + assert result["researchMode"] == "benchmark" + assert captured["user_id"] == 7 + assert captured["universe_id"] == 19 + assert captured["train_fraction"] == 0.7 + assert captured["false_discovery_rate"] == 0.08 + assert captured["minimum_abs_oos_ic"] == 0.03 + assert progress[0]["phase"] == "loading_universe" + assert progress[-1]["phase"] == "validated" diff --git a/backend_api_python/tests/test_custom_factors.py b/backend_api_python/tests/test_custom_factors.py new file mode 100644 index 000000000..4cb4b5997 --- /dev/null +++ b/backend_api_python/tests/test_custom_factors.py @@ -0,0 +1,54 @@ +import numpy as np +import pandas as pd +import pytest + +from app.services.custom_factors import ( + CustomFactorError, + analyze_custom_factor, + compute_custom_factor, +) + + +def _frame(length=80): + step = np.arange(length, dtype=float) + close = 100.0 + step * 0.5 + return pd.DataFrame({ + "open": close - 0.2, + "high": close + 1.0, + "low": close - 1.0, + "close": close, + "volume": 1_000.0 + step * 10.0, + }) + + +def test_custom_factor_analyzes_dependencies_fields_and_warmup(): + analysis = analyze_custom_factor( + "momentum(period=lookback) / max(realized_volatility(period=vol_window), floor)", + {"lookback": 20, "vol_window": 15, "floor": 0.0001}, + ) + + assert analysis.dependencies == ("momentum", "realized_volatility") + assert analysis.required_fields == ("close",) + assert analysis.warmup_bars == 21 + + +def test_custom_factor_computes_registered_factor_expression(): + definition = { + "expression": "momentum(period=lookback) / max(realized_volatility(period=vol_window), floor)", + "default_params": {"lookback": 20, "vol_window": 15, "floor": 0.0001}, + } + + value = compute_custom_factor(definition, _frame()) + + assert np.isfinite(value) + assert value > 0 + + +@pytest.mark.parametrize("expression", [ + "__import__('os').system('whoami')", + "close.__class__", + "factor('custom:2')", +]) +def test_custom_factor_rejects_unsafe_or_recursive_expressions(expression): + with pytest.raises(CustomFactorError): + analyze_custom_factor(expression) diff --git a/backend_api_python/tests/test_factor_benchmark.py b/backend_api_python/tests/test_factor_benchmark.py new file mode 100644 index 000000000..47438b28e --- /dev/null +++ b/backend_api_python/tests/test_factor_benchmark.py @@ -0,0 +1,90 @@ +import pytest + +from app.services.strategy_v2.factor_benchmark import FactorBenchmarkEngine + + +def _result(factor_id, values, *, coverage=0.95, net_return=0.1, turnover=0.4): + return { + "factorId": factor_id, + "factorParams": {}, + "icSeries": [ + {"time": f"2026-01-{index + 1:02d}", "value": value} + for index, value in enumerate(values) + ], + "coverage": coverage, + "averageTurnover": turnover, + "netLongShortReturn": net_return, + "monotonicity": 0.8, + "pointInTimeUniverseApplied": True, + "executionAssumptions": {"periodsPerYear": 252}, + } + + +def test_benchmark_ranks_alive_and_reversed_factors_with_oos_metrics(): + engine = FactorBenchmarkEngine() + result = engine.run([ + _result("quality", [0.05, 0.06, 0.04, 0.07, 0.05, 0.06, 0.05, 0.07, 0.06, 0.05]), + _result("inverse_value", [-0.06, -0.05, -0.07, -0.04, -0.06, -0.05, -0.06, -0.07, -0.05, -0.06], net_return=-0.12), + ], minimum_oos_observations=3) + + by_factor = {row["factorId"]: row for row in result["rankings"]} + assert by_factor["quality"]["classification"] == "alive" + assert by_factor["inverse_value"]["classification"] == "reversed" + assert by_factor["quality"]["testObservationCount"] >= 3 + assert by_factor["inverse_value"]["orientedNetLongShortReturn"] == pytest.approx(0.12) + assert result["summary"]["counts"]["alive"] == 1 + assert result["summary"]["counts"]["reversed"] == 1 + + +def test_benchmark_marks_sign_flip_as_decaying(): + result = FactorBenchmarkEngine().run([ + _result("decay", [0.08, 0.07, 0.06, 0.05, 0.07, 0.06, -0.03, -0.04, -0.05, -0.02]), + ], minimum_oos_observations=3) + + row = result["rankings"][0] + assert row["classification"] == "decaying" + assert row["sameSign"] is False + + +def test_benchmark_applies_benjamini_hochberg_monotonically(): + adjusted = FactorBenchmarkEngine._benjamini_hochberg([0.01, 0.04, 0.03, 0.5]) + + assert adjusted == pytest.approx([0.04, 0.0533333333, 0.0533333333, 0.5]) + + +def test_benchmark_reports_insufficient_oos_without_inventing_strength(): + result = FactorBenchmarkEngine().run([ + _result("short", [0.1, 0.1, 0.1, 0.1]), + ], minimum_oos_observations=5) + + row = result["rankings"][0] + assert row["classification"] == "insufficient" + assert row["score"] == 0.0 + + +def test_benchmark_treats_below_gate_coverage_as_insufficient(): + result = FactorBenchmarkEngine().run([ + _result("low_coverage", [0.08] * 10, coverage=0.4), + _result("usable", [0.03] * 10, coverage=0.95), + ], minimum_oos_observations=3, minimum_coverage=0.5) + + by_factor = {row["factorId"]: row for row in result["rankings"]} + assert by_factor["low_coverage"]["classification"] == "insufficient" + assert by_factor["low_coverage"]["score"] == 0.0 + assert result["summary"]["bestFactorId"] == "usable" + assert result["summary"]["counts"]["insufficient"] == 1 + + +def test_benchmark_summarizes_oos_direction_and_absolute_strength(): + result = FactorBenchmarkEngine().run([ + _result("positive", [0.01] * 6 + [0.03] * 4), + _result("negative", [-0.01] * 6 + [-0.06] * 4, net_return=-0.1), + _result("flat", [0.0] * 10), + ], minimum_oos_observations=3) + + summary = result["summary"] + assert summary["positiveOosCount"] == 1 + assert summary["negativeOosCount"] == 1 + assert summary["zeroOosCount"] == 1 + assert summary["strongestOosFactorId"] == "negative" + assert summary["strongestOosRankIc"] == pytest.approx(-0.06) diff --git a/backend_api_python/tests/test_factor_research.py b/backend_api_python/tests/test_factor_research.py index bb2f552bd..8cd065532 100644 --- a/backend_api_python/tests/test_factor_research.py +++ b/backend_api_python/tests/test_factor_research.py @@ -5,6 +5,72 @@ from app.services.strategy_v2.factor_research import FactorResearchEngine +@pytest.mark.parametrize("direction", [1.0, -1.0]) +def test_factor_research_rank_ic_sign_uses_next_open_forward_return(direction): + index = pd.date_range("2026-01-01", periods=8, freq="B") + frames = {} + for offset, symbol in enumerate(["A", "B", "C", "D", "E", "F"], start=1): + open_prices = [ + 100.0 * (1.0 + direction * offset * 0.005 * day) + for day in range(len(index)) + ] + close_prices = [ + 100.0 * (1.0 - direction * offset * 0.005 * day) + for day in range(len(index)) + ] + frames[symbol] = pd.DataFrame({ + "open": open_prices, + "close": close_prices, + "signal": [float(offset)] * len(index), + }, index=index) + + result = FactorResearchEngine().run( + frames=frames, + factor_id="signal", + start_date=index[0], + end_date=index[-1], + groups=3, + holding_period=1, + commission=0.0, + slippage=0.0, + ) + + assert result["rankIc"] == pytest.approx(direction) + assert result["icSeries"][0]["value"] == pytest.approx(direction) + + +def test_factor_research_evaluates_custom_formula_point_in_time(): + index = pd.date_range("2025-01-01", periods=90, freq="B") + frames = {} + for offset, symbol in enumerate(["A", "B", "C", "D", "E", "F"], start=1): + prices = [100.0 + day * offset * 0.1 for day in range(len(index))] + frames[symbol] = pd.DataFrame({ + "open": prices, + "high": [value + 1.0 for value in prices], + "low": [value - 1.0 for value in prices], + "close": prices, + "volume": [1000.0 + offset] * len(index), + }, index=index) + + result = FactorResearchEngine().run( + frames=frames, + factor_id="custom:1", + factor_params={"lookback": 20}, + custom_factor={ + "expression": "momentum(period=lookback)", + "default_params": {"lookback": 20}, + }, + start_date=index[30], + end_date=index[-1], + groups=3, + holding_period=5, + ) + + assert result["factorId"] == "custom:1" + assert result["rankIc"] > 0 + assert result["coverage"] == pytest.approx(1.0) + + def test_factor_research_returns_ic_groups_costs_and_stability(): index = pd.date_range("2025-01-01", periods=90, freq="B") frames = {} diff --git a/backend_api_python/tests/test_quick_trade_orderbook.py b/backend_api_python/tests/test_quick_trade_orderbook.py new file mode 100644 index 000000000..72f41f8f6 --- /dev/null +++ b/backend_api_python/tests/test_quick_trade_orderbook.py @@ -0,0 +1,181 @@ +import inspect + +import pytest +from flask import g + +from app.routes import quick_trade +from app.services.quick_trade.orderbook import ( + OrderBookError, + _contract_request_spec, + _extract_contract_multiplier, + _request_spec, + normalize_order_book, + normalize_symbol, +) + + +def test_normalize_symbol_supports_common_formats(): + assert normalize_symbol("BTC/USDT").compact == "BTCUSDT" + assert normalize_symbol("Crypto:ETH-USDT-SWAP").dashed == "ETH-USDT" + assert normalize_symbol("SOL_USDC").underscored == "SOL_USDC" + + +@pytest.mark.parametrize( + ("exchange_id", "payload"), + [ + ("binance", {"bids": [["100", "2"]], "asks": [["101", "3"]]}), + ("okx", {"data": [{"bids": [["100", "2", "0", "1"]], "asks": [["101", "3", "0", "1"]], "ts": "10"}]}), + ("bitget", {"data": {"bids": [["100", "2"]], "asks": [["101", "3"]], "ts": "10"}}), + ("bybit", {"result": {"b": [["100", "2"]], "a": [["101", "3"]], "ts": "10"}}), + ("gate", {"bids": [{"p": "100", "s": 2}], "asks": [{"p": "101", "s": 3}], "current": 10}), + ("htx", {"tick": {"bids": [["100", "2"]], "asks": [["101", "3"]]}, "ts": 10}), + ], +) +def test_normalize_order_book_for_supported_exchanges(exchange_id, payload): + result = normalize_order_book(exchange_id, payload, symbol="BTC/USDT", market_type="swap", limit=10) + assert result["best_bid"] == 100 + assert result["best_ask"] == 101 + assert result["mid"] == 100.5 + assert result["spread"] == 1 + assert result["bid_share"] == pytest.approx(39.761431, rel=1e-5) + assert result["buy_wall"]["price"] == 100 + assert result["sell_wall"]["price"] == 101 + + +@pytest.mark.parametrize( + ("exchange_id", "expected_unit"), + [ + ("binance", "base"), + ("okx", "contracts"), + ("bitget", "base"), + ("bybit", "base"), + ("gate", "contracts"), + ("htx", "contracts"), + ], +) +def test_derivative_quantity_units_are_explicit_for_every_supported_exchange(exchange_id, expected_unit): + payloads = { + "binance": {"bids": [["100", "2"]], "asks": [["101", "3"]]}, + "okx": {"data": [{"bids": [["100", "2"]], "asks": [["101", "3"]]}]}, + "bitget": {"data": {"b": [["100", "2"]], "a": [["101", "3"]]}}, + "bybit": {"result": {"b": [["100", "2"]], "a": [["101", "3"]]}}, + "gate": {"bids": [{"p": "100", "s": 2}], "asks": [{"p": "101", "s": 3}]}, + "htx": {"tick": {"bids": [["100", "2"]], "asks": [["101", "3"]]}}, + } + result = normalize_order_book( + exchange_id, + payloads[exchange_id], + symbol="SOL/USDT", + market_type="swap", + limit=10, + ) + assert result["base_asset"] == "SOL" + assert result["quantity_unit"] == expected_unit + + +def test_empty_order_book_fails_closed(): + with pytest.raises(OrderBookError, match="empty order book"): + normalize_order_book("binance", {"bids": [], "asks": []}, symbol="BTC/USDT", market_type="spot", limit=10) + + +@pytest.mark.parametrize( + ("market_type", "category"), + [("spot", "SPOT"), ("swap", "USDT-FUTURES")], +) +def test_bitget_order_book_uses_unified_v3_endpoint(market_type, category): + url, params = _request_spec("bitget", normalize_symbol("BTC/USDT"), market_type, 25) + assert url == "https://api.bitget.com/api/v3/market/orderbook" + assert params == {"category": category, "symbol": "BTCUSDT", "limit": 25} + + +def test_bitget_v3_order_book_fields_are_normalized(): + result = normalize_order_book( + "bitget", + {"data": {"b": [["100", "2"]], "a": [["101", "3"]], "ts": "1730969017964"}}, + symbol="BTC/USDT", + market_type="spot", + limit=10, + ) + assert result["best_bid"] == 100 + assert result["best_ask"] == 101 + assert result["timestamp"] == 1730969017964 + + +@pytest.mark.parametrize( + ("exchange_id", "expected_url"), + [ + ("okx", "https://www.okx.com/api/v5/public/instruments"), + ("gate", "https://fx-api.gateio.ws/api/v4/futures/usdt/contracts/BTC_USDT"), + ("htx", "https://api.hbdm.com/linear-swap-api/v1/swap_contract_info"), + ], +) +def test_contract_metadata_uses_public_venue_endpoints(exchange_id, expected_url): + url, _ = _contract_request_spec(exchange_id, normalize_symbol("BTC/USDT")) + assert url == expected_url + + +@pytest.mark.parametrize( + ("exchange_id", "symbol", "payload", "expected"), + [ + ("okx", "BTC/USDT", {"data": [{"ctVal": "0.01", "ctValCcy": "BTC", "ctType": "linear"}]}, 0.01), + ("okx", "ETH/USDT", {"data": [{"ctVal": "0.1", "ctValCcy": "ETH", "ctType": "linear"}]}, 0.1), + ("okx", "DOGE/USDT", {"data": [{"ctVal": "1000", "ctValCcy": "DOGE", "ctType": "linear"}]}, 1000), + ("gate", "BTC/USDT", {"quanto_multiplier": "0.0001"}, 0.0001), + ("gate", "DOGE/USDT", {"quanto_multiplier": "10"}, 10), + ("htx", "BTC/USDT", {"data": [{"contract_size": "0.001"}]}, 0.001), + ("htx", "SOL/USDT", {"data": [{"contract_size": "1"}]}, 1), + ], +) +def test_contract_multiplier_is_normalized_to_base_asset(exchange_id, symbol, payload, expected): + assert _extract_contract_multiplier(exchange_id, payload, normalize_symbol(symbol)) == pytest.approx(expected) + + +def test_contract_metadata_requests_are_symbol_specific(): + okx_url, okx_params = _contract_request_spec("okx", normalize_symbol("SOL/USDT")) + gate_url, gate_params = _contract_request_spec("gate", normalize_symbol("DOGE/USDT")) + htx_url, htx_params = _contract_request_spec("htx", normalize_symbol("ETH/USDT")) + assert okx_url == "https://www.okx.com/api/v5/public/instruments" + assert okx_params["instId"] == "SOL-USDT-SWAP" + assert gate_url.endswith("/DOGE_USDT") + assert gate_params == {} + assert htx_url == "https://api.hbdm.com/linear-swap-api/v1/swap_contract_info" + assert htx_params["contract_code"] == "ETH-USDT" + + +def test_inverse_contract_multiplier_fails_closed(): + payload = {"data": [{"ctVal": "100", "ctValCcy": "USD", "ctType": "inverse"}]} + assert _extract_contract_multiplier("okx", payload, normalize_symbol("BTC/USDT")) == 0 + + +def test_order_book_route_rejects_exception_detail_exposure(app, monkeypatch): + def reject_order_book(*_args, **_kwargs): + raise OrderBookError("credential=secret") + + monkeypatch.setattr(quick_trade, "fetch_order_book", reject_order_book) + + with app.test_request_context( + "/api/quick-trade/order-book?exchange_id=binance&symbol=BTC%2FUSDT", + method="GET", + ): + g.user_id = 7 + g.quick_trade_session = object() + response, status = inspect.unwrap(quick_trade.get_order_book)() + + body = response.get_json() + assert status == 400 + assert body["msg"] == "quickTrade.orderFailed" + assert "secret" not in response.get_data(as_text=True) + + +def test_order_book_reports_native_contract_unit_metadata(): + result = normalize_order_book( + "okx", + {"data": [{"bids": [["100", "2"]], "asks": [["101", "3"]]}]}, + symbol="BTC/USDT", + market_type="swap", + limit=10, + contract_multiplier=0.01, + ) + assert result["quantity_unit"] == "contracts" + assert result["base_asset"] == "BTC" + assert result["contract_multiplier"] == pytest.approx(0.01) diff --git a/backend_api_python/tests/test_security_fingerprints.py b/backend_api_python/tests/test_security_fingerprints.py new file mode 100644 index 000000000..8a7a1420c --- /dev/null +++ b/backend_api_python/tests/test_security_fingerprints.py @@ -0,0 +1,51 @@ +from app.services.live_trading.bitget_compat import _cache_key +from app.services.live_trading.ws_order_transport import _pool_key +from app.services.pending_orders.position_sync_cache import position_sync_cache_key +from app.utils.security_fingerprint import opaque_fingerprint + + +def test_opaque_fingerprint_is_stable_and_separates_namespaces(): + first = opaque_fingerprint("secret", "value", namespace="first") + repeated = opaque_fingerprint("secret", "value", namespace="first") + other_namespace = opaque_fingerprint("secret", "value", namespace="second") + + assert first == repeated + assert first != other_namespace + assert "secret" not in first + assert len(first) == 64 + + +def test_sensitive_cache_keys_do_not_contain_api_key_material(): + api_key = "public-prefix-super-sensitive-api-key-suffix" + + position_key = position_sync_cache_key( + 7, + "binance", + "swap", + {"api_key": api_key}, + ) + bitget_key = _cache_key( + api_key=api_key, + base_url="https://api.bitget.com", + market_type="swap", + simulated=False, + ) + websocket_key = _pool_key( + "binance", + "wss://ws-api.binance.com/ws-api/v3", + api_key, + "live", + ) + + for cache_key in (position_key, bitget_key, websocket_key): + assert api_key not in cache_key + assert api_key[-16:] not in cache_key + + +def test_credential_id_cache_key_remains_readable_without_secret_material(): + assert position_sync_cache_key( + 7, + "binance", + "swap", + {"credential_id": 42, "api_key": "ignored-secret"}, + ) == "u7:binance:swap:cred:42" diff --git a/backend_api_python/tests/test_signal_notifier_security.py b/backend_api_python/tests/test_signal_notifier_security.py new file mode 100644 index 000000000..5c78fc226 --- /dev/null +++ b/backend_api_python/tests/test_signal_notifier_security.py @@ -0,0 +1,113 @@ +import socket + +from app.services import signal_notifier as notifier_module +from app.services.signal_notifier import ( + SignalNotifier, + _detect_webhook_dialect, + _validate_outbound_http_url, +) + + +class _Response: + status_code = 200 + text = "{}" + + +def _public_dns(*_args, **_kwargs): + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("93.184.216.34", 443), + ) + ] + + +def test_outbound_webhook_rejects_private_and_loopback_addresses(monkeypatch): + monkeypatch.delenv("SIGNAL_WEBHOOK_ALLOW_PRIVATE_HOSTS", raising=False) + monkeypatch.setattr( + notifier_module.socket, + "getaddrinfo", + lambda *_args, **_kwargs: [ + (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("127.0.0.1", 80)) + ], + ) + + validated, error = _validate_outbound_http_url("http://localhost/internal") + + assert validated == "" + assert error == "webhook_private_address_blocked" + + +def test_outbound_webhook_rejects_embedded_credentials(monkeypatch): + monkeypatch.setattr(notifier_module.socket, "getaddrinfo", _public_dns) + + validated, error = _validate_outbound_http_url("https://user:password@example.com/hook") + + assert validated == "" + assert error == "webhook_credentials_not_allowed" + + +def test_vendor_detection_requires_real_hostname_and_path(): + assert _detect_webhook_dialect("https://discord.com/api/webhooks/1/token") == "discord" + assert _detect_webhook_dialect("https://discord.com.attacker.example/api/webhooks/1/token") == "generic" + assert _detect_webhook_dialect("https://attacker.example/discord.com/api/webhooks/1") == "generic" + + +def test_webhook_delivery_blocks_private_target_before_request(monkeypatch): + monkeypatch.delenv("SIGNAL_WEBHOOK_ALLOW_PRIVATE_HOSTS", raising=False) + monkeypatch.setattr( + notifier_module.socket, + "getaddrinfo", + lambda *_args, **_kwargs: [ + (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("10.0.0.7", 443)) + ], + ) + + def unexpected_post(*_args, **_kwargs): + raise AssertionError("private webhook must not be requested") + + monkeypatch.setattr(notifier_module.requests, "post", unexpected_post) + + ok, error = SignalNotifier()._notify_webhook( + url="https://internal.example/hook", + payload={"event": "test"}, + ) + + assert not ok + assert error == "webhook_private_address_blocked" + + +def test_webhook_delivery_disables_redirects(monkeypatch): + monkeypatch.setattr(notifier_module.socket, "getaddrinfo", _public_dns) + calls = [] + + def capture_post(*args, **kwargs): + calls.append((args, kwargs)) + return _Response() + + monkeypatch.setattr(notifier_module.requests, "post", capture_post) + + ok, error = SignalNotifier()._notify_webhook( + url="https://example.com/hook", + payload={"event": "test"}, + ) + + assert ok + assert error == "" + assert calls[0][1]["allow_redirects"] is False + + +def test_discord_channel_rejects_lookalike_host(monkeypatch): + monkeypatch.setattr(notifier_module.socket, "getaddrinfo", _public_dns) + + ok, error = SignalNotifier()._notify_discord( + url="https://discord.com.attacker.example/api/webhooks/1/token", + payload={"event": "test"}, + fallback_text="test", + ) + + assert not ok + assert error == "webhook_host_not_allowed" diff --git a/backend_api_python/tests/test_strategy_ai_capabilities.py b/backend_api_python/tests/test_strategy_ai_capabilities.py index 8eff59b91..4b69c51a8 100644 --- a/backend_api_python/tests/test_strategy_ai_capabilities.py +++ b/backend_api_python/tests/test_strategy_ai_capabilities.py @@ -50,6 +50,27 @@ def test_indicator_conversion_preserves_explicit_capability_requests(): assert {"crypto_swap", "one_way_reversal", "protection", "persistent_state", "order_lifecycle"} <= set(intent.capabilities) +def test_indicator_conversion_parses_legacy_crlf_prompt_without_regex(): + prompt = ( + "Conversion template\r\n" + "User conversion request:\r\n" + "Add a stop loss and keep the strategy long-only\r\n" + "Indicator source code:\r\n" + "```python\r\npass\r\n```" + ) + + intent = resolve_strategy_generation_intent( + prompt=prompt, + context={ + "source": "indicator_ide_conversion", + "instrument": "Crypto:BTC/USDT@spot", + }, + ) + + assert intent.requested_direction_mode == "long_only" + assert "protection" in intent.capabilities + + @pytest.mark.parametrize("history", ['data.history("Crypto:SOL/USDT@spot", count=35)', 'get_history(35, "1d", "close", "Crypto:SOL/USDT@spot")']) def test_rolling_history_length_cannot_drive_persistent_cooldown(history): source = f''' diff --git a/backend_api_python/tests/test_strategy_v2_routes.py b/backend_api_python/tests/test_strategy_v2_routes.py index 079315011..c7240160a 100644 --- a/backend_api_python/tests/test_strategy_v2_routes.py +++ b/backend_api_python/tests/test_strategy_v2_routes.py @@ -119,6 +119,220 @@ def persist_run(**kwargs): assert captured["storage"]["research_mode"] == "universe" +def test_factor_benchmark_persists_ranked_factor_runs(app, monkeypatch): + captured = {"persisted": []} + + class BacktestService: + @staticmethod + def benchmark_universe_factors(**kwargs): + captured["execution"] = kwargs + return { + "researchMode": "benchmark", + "manifest": { + "markets": ["USStock"], + "primaryFrequency": "1d", + }, + "universe": {"name": "NASDAQ 100"}, + "frequency": "1d", + "symbolsRequested": 100, + "symbolsUsed": 98, + "symbolsSkipped": [], + "rankings": [ + { + "factorId": "quality", + "rank": 1, + "classification": "alive", + "score": 88.0, + "pValue": 0.01, + "qValue": 0.02, + }, + { + "factorId": "value", + "rank": 2, + "classification": "candidate", + "score": 67.0, + "pValue": 0.04, + "qValue": 0.04, + }, + ], + "factorResults": [ + {"factorId": "quality", "effectiveGroups": 5}, + {"factorId": "value", "effectiveGroups": 5}, + ], + } + + class ResearchRepository: + @staticmethod + def persist_run(**kwargs): + captured["persisted"].append(kwargs) + return 100 + len(captured["persisted"]) + + monkeypatch.setattr(backtest_center, "get_strategy_backtest_service", lambda: BacktestService()) + monkeypatch.setattr(backtest_center, "get_factor_research_repository", lambda: ResearchRepository()) + + with app.test_request_context("/api/backtest/factor-benchmark", method="POST", json={ + "universeId": 9, + "frequency": "1d", + "startDate": "2025-01-01", + "endDate": "2026-01-01", + "factors": [{"factorId": "quality"}, {"factorId": "value"}], + "trainFraction": 0.65, + "falseDiscoveryRate": 0.08, + }): + g.user_id = 7 + response = inspect.unwrap(backtest_center.run_factor_benchmark)() + + body = response.get_json() + assert body["code"] == 1, body + assert body["data"]["benchmarkId"] + assert body["data"]["rankings"][0]["runId"] == 101 + assert body["data"]["rankings"][1]["runId"] == 102 + assert "factorResults" not in body["data"] + assert captured["execution"]["train_fraction"] == 0.65 + assert captured["execution"]["false_discovery_rate"] == 0.08 + assert [item["research_mode"] for item in captured["persisted"]] == [ + "benchmark", "benchmark" + ] + assert captured["persisted"][0]["result"]["benchmarkClassification"] == "alive" + assert captured["persisted"][0]["result"]["benchmarkSnapshot"]["benchmarkId"] == body["data"]["benchmarkId"] + assert "factorResults" not in captured["persisted"][0]["result"]["benchmarkSnapshot"] + + +def test_factor_benchmark_history_groups_child_runs(app, monkeypatch): + benchmark_id = "benchmark-batch-1" + snapshot = { + "benchmarkId": benchmark_id, + "researchMode": "benchmark", + "rankings": [ + {"factorId": "quality", "classification": "alive", "score": 88.0}, + {"factorId": "value", "classification": "candidate", "score": 67.0}, + ], + "summary": {"requestedFactorCount": 3, "bestFactorId": "quality"}, + "errors": [{"factorId": "size"}], + } + runs = [ + { + "id": index, + "universe_id": 9, + "universe_name": "NASDAQ 100", + "market": "USStock", + "timeframe": "1d", + "created_at": "2026-11-01T10:00:00", + "result": {"benchmarkId": benchmark_id, "benchmarkSnapshot": snapshot}, + } + for index in (101, 102) + ] + + class ResearchRepository: + @staticmethod + def list_benchmark_runs(**kwargs): + assert kwargs["user_id"] == 7 + return runs + + monkeypatch.setattr(backtest_center, "get_factor_research_repository", lambda: ResearchRepository()) + + with app.test_request_context("/api/backtest/factor-benchmark/history", method="GET"): + g.user_id = 7 + response = inspect.unwrap(backtest_center.list_factor_benchmark_runs)() + + body = response.get_json() + assert body["code"] == 1 + assert body["data"][0]["id"] == benchmark_id + assert body["data"][0]["factor_count"] == 3 + assert body["data"][0]["completed_count"] == 2 + assert body["data"][0]["validated_count"] == 1 + assert body["data"][0]["failed_count"] == 1 + + +def test_factor_benchmark_detail_restores_complete_snapshot(app, monkeypatch): + benchmark_id = "benchmark-batch-2" + snapshot = { + "benchmarkId": benchmark_id, + "researchMode": "benchmark", + "rankings": [{"factorId": "quality", "classification": "alive"}], + "summary": {"requestedFactorCount": 1}, + } + run = { + "universe_id": 9, + "universe_name": "NASDAQ 100", + "timeframe": "1d", + "groups_count": 5, + "holding_period": 5, + "result": {"benchmarkId": benchmark_id, "benchmarkSnapshot": snapshot}, + } + + class ResearchRepository: + @staticmethod + def list_benchmark_runs(**kwargs): + assert kwargs["benchmark_id"] == benchmark_id + return [run] + + monkeypatch.setattr(backtest_center, "get_factor_research_repository", lambda: ResearchRepository()) + + with app.test_request_context( + f"/api/backtest/factor-benchmark/get?benchmarkId={benchmark_id}", + method="GET", + ): + g.user_id = 7 + response = inspect.unwrap(backtest_center.get_factor_benchmark_run)() + + body = response.get_json() + assert body["code"] == 1 + assert body["data"]["research_mode"] == "benchmark" + assert body["data"]["result"]["rankings"][0]["factorId"] == "quality" + + +def test_factor_benchmark_rejects_exception_detail_exposure(app, monkeypatch): + class BacktestService: + @staticmethod + def benchmark_universe_factors(**_kwargs): + raise ValueError("database password=secret") + + monkeypatch.setattr(backtest_center, "get_strategy_backtest_service", lambda: BacktestService()) + + with app.test_request_context("/api/backtest/factor-benchmark", method="POST", json={ + "universeId": 9, + "startDate": "2025-01-01", + "endDate": "2026-01-01", + "factors": [{"factorId": "quality"}], + }): + g.user_id = 7 + response, status = inspect.unwrap(backtest_center.run_factor_benchmark)() + + body = response.get_json() + assert status == 400 + assert body["msg"] == "strategyV2.generationInvalid" + assert "secret" not in response.get_data(as_text=True) + + +def test_factor_benchmark_history_rejects_exception_detail_exposure(app, monkeypatch): + class ResearchRepository: + @staticmethod + def list_benchmark_runs(**_kwargs): + raise RuntimeError("postgres://user:password@database") + + monkeypatch.setattr(backtest_center, "get_factor_research_repository", lambda: ResearchRepository()) + + with app.test_request_context("/api/backtest/factor-benchmark/history", method="GET"): + g.user_id = 7 + response, status = inspect.unwrap(backtest_center.list_factor_benchmark_runs)() + + body = response.get_json() + assert status == 500 + assert body["msg"] == "strategyV2.backtest.runFailed" + assert "password" not in response.get_data(as_text=True) + + +def test_factor_benchmark_missing_id_returns_public_error_key(app): + with app.test_request_context("/api/backtest/factor-benchmark/get", method="GET"): + g.user_id = 7 + response, status = inspect.unwrap(backtest_center.get_factor_benchmark_run)() + + body = response.get_json() + assert status == 400 + assert body["msg"] == "strategyV2.factorBenchmarkIdRequired" + + def test_human_backtest_submission_uses_persisted_async_job(app, monkeypatch): captured = {} diff --git a/backend_api_python/tests/test_strategy_v2_service.py b/backend_api_python/tests/test_strategy_v2_service.py index b50df2051..3d3c198c4 100644 --- a/backend_api_python/tests/test_strategy_v2_service.py +++ b/backend_api_python/tests/test_strategy_v2_service.py @@ -776,6 +776,68 @@ def frame_fetcher(_market, symbol, *_args, **_kwargs): assert result["manifest"]["strategyType"] == "factor_research" +def test_factor_benchmark_reuses_one_market_panel_for_multiple_factors(): + symbols = ["A", "B", "C", "D", "E", "F"] + fetches = [] + + class ResearchUniverse: + @staticmethod + def get_universe(_user_id, universe_id): + return { + "id": universe_id, + "code": "research-pool", + "name": "Research Pool", + "market": "USStock", + "member_count": len(symbols), + "history_from": "2020-01-01", + } + + @staticmethod + def candidate_members(_user_id, _universe_id, *, start, end): + assert start <= end + return [{"market": "USStock", "symbol": symbol} for symbol in symbols] + + index = pd.bdate_range("2025-01-01", periods=180) + + def frame_fetcher(_market, symbol, *_args, **_kwargs): + fetches.append(symbol) + offset = symbols.index(symbol) + prices = [ + 100 + offset * 2 + day * (0.08 + offset * 0.025) + (day % 7) * offset * 0.02 + for day in range(len(index)) + ] + return pd.DataFrame({"open": prices, "close": prices}, index=index) + + service = StrategyV2BacktestService( + repository=_Repository(), + universe_service=ResearchUniverse(), + frame_fetcher=frame_fetcher, + ) + result = service.benchmark_universe_factors( + user_id=1, + universe_id=17, + frequency="1d", + start_date=datetime(2025, 3, 1), + end_date=datetime(2025, 8, 29, 23, 59), + factors=[ + {"factorId": "momentum_20"}, + {"factorId": "reversal_5"}, + ], + groups=3, + holding_period=5, + minimum_oos_observations=3, + ) + + assert sorted(fetches) == symbols + assert result["researchMode"] == "benchmark" + assert result["summary"]["factorCount"] == 2 + assert result["summary"]["requestedFactorCount"] == 2 + assert result["summary"]["failedFactorCount"] == 0 + assert len(result["rankings"]) == 2 + assert result["correlation"]["factors"] == ["momentum_20", "reversal_5"] + assert result["errors"] == [] + + def test_factor_research_rejects_portfolios_smaller_than_group_count(): code = """ def initialize(context): diff --git a/docs/agent/agent-openapi.json b/docs/agent/agent-openapi.json index 1aaf6bf2c..98bfc2db7 100644 --- a/docs/agent/agent-openapi.json +++ b/docs/agent/agent-openapi.json @@ -570,6 +570,56 @@ "responses": { "200": { "description": "OK" }, "404": { "description": "Not found" } } } }, + "/api/agent/v1/research/factor-benchmarks": { + "post": { + "summary": "Submit a multi-factor out-of-sample benchmark job", + "description": "Evaluates multiple registered factors on one point-in-time universe, ranks out-of-sample evidence, and controls false discoveries with Benjamini-Hochberg correction.", + "x-scope-class": "B", + "parameters": [ + { "in": "header", "name": "Idempotency-Key", "required": true, "schema": { "type": "string", "maxLength": 120 } } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["universeId", "factors", "startDate", "endDate"], + "properties": { + "universeId": { "type": "integer", "minimum": 1 }, + "factors": { + "type": "array", + "minItems": 2, + "maxItems": 40, + "items": { + "type": "object", + "required": ["factorId"], + "properties": { + "factorId": { "type": "string" }, + "factorParams": { "type": "object" } + } + } + }, + "startDate": { "type": "string", "format": "date" }, + "endDate": { "type": "string", "format": "date" }, + "frequency": { "type": "string", "default": "1d" }, + "groups": { "type": "integer", "minimum": 2, "maximum": 10 }, + "holdingPeriod": { "type": "integer", "minimum": 1 }, + "trainFraction": { "type": "number", "minimum": 0.5, "maximum": 0.8 }, + "falseDiscoveryRate": { "type": "number", "minimum": 0.001, "maximum": 0.5 }, + "minimumAbsOosIc": { "type": "number", "minimum": 0 } + } + } + } + } + }, + "responses": { + "202": { "description": "Queued" }, + "400": { "description": "Invalid benchmark request" }, + "403": { "description": "Token allowlist denied the universe" } + } + } + }, "/api/agent/v1/research/watchlist": { "get": { "summary": "List tenant watchlist", diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 61937ef8f..ac2fdcf51 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2283,6 +2283,69 @@ paths: operationId: getApiBacktestFactorResearchHistory summary: Get Api Backtest Factor Research History x-visibility: internal + /api/backtest/factor-benchmark: + post: + responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HumanErrorEnvelope' + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HumanSuccessEnvelope' + tags: + - Strategy + summary: Benchmark multiple factors with out-of-sample validation + description: Loads a managed universe once, evaluates multiple factors, applies + out-of-sample validation and Benjamini-Hochberg false-discovery control, then + persists every successful factor run. + operationId: postApiBacktestFactorBenchmark + x-visibility: internal + /api/backtest/factor-benchmark/history: + get: + responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HumanErrorEnvelope' + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HumanSuccessEnvelope' + tags: + - Strategy + operationId: getApiBacktestFactorBenchmarkHistory + summary: Get Api Backtest Factor Benchmark History + x-visibility: internal + /api/backtest/factor-benchmark/get: + get: + responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HumanErrorEnvelope' + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HumanSuccessEnvelope' + tags: + - Strategy + operationId: getApiBacktestFactorBenchmarkGet + summary: Get Api Backtest Factor Benchmark Get + x-visibility: internal /api/backtest/factor-research/get: get: responses: @@ -3140,6 +3203,79 @@ paths: schema: type: string minLength: 1 + /api/factors/custom/validate: + post: + responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HumanErrorEnvelope' + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HumanSuccessEnvelope' + tags: + - Factor + operationId: postApiFactorsCustomValidate + summary: Post Api Factors Custom Validate + x-visibility: internal + /api/factors/custom: + post: + responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HumanErrorEnvelope' + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HumanSuccessEnvelope' + tags: + - Factor + operationId: postApiFactorsCustom + summary: Post Api Factors Custom + x-visibility: internal + /api/factors/custom/{factor_id}: + delete: + responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HumanErrorEnvelope' + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HumanSuccessEnvelope' + tags: + - Factor + operationId: deleteApiFactorsCustomFactorId + summary: Delete Api Factors Custom Factor Id + parameters: + - name: factor_id + in: path + required: true + schema: + type: integer + x-visibility: internal + parameters: + - in: path + name: factor_id + required: true + schema: + type: integer + minimum: 0 /api/factors/research: post: responses: @@ -8843,6 +8979,27 @@ paths: schema: type: integer minimum: 0 + /api/quick-trade/order-book: + get: + responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HumanErrorEnvelope' + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/HumanSuccessEnvelope' + summary: Return a normalized public order-book snapshot for a supported crypto + venue. + tags: + - QuickTrade + operationId: getApiQuickTradeOrderBook + x-visibility: private /api/quick-trade/place-order: post: responses: diff --git a/mcp_server/README.md b/mcp_server/README.md index 553ba0752..9155ddc2a 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -47,6 +47,7 @@ Never place an agent token in prompts, logs, screenshots, source control, or MCP | `list_strategy_source_versions`, `restore_strategy_source_version` | R/W | Source history and explicitly confirmed restore | | `create_strategy`, `update_strategy`, `list_strategies`, `get_strategy` | R/W | Strategy API V2 deployments | | `submit_backtest` | B | Strategy API V2 backtest job | +| `submit_factor_benchmark` | B | Multi-factor out-of-sample benchmark job with false-discovery control | | `list_jobs`, `get_job`, `wait_for_job`, `stream_job_until_done`, `cancel_job` | R/B | Bounded jobs and confirmed cancellation | | `runtime_overview`, `stop_strategy` | R/T | Runtime inspection and confirmed stop | | Broker account, strategy position/trade, and quick-trade observation tools | R | Secret-free execution observations | diff --git a/mcp_server/src/quantdinger_mcp/server.py b/mcp_server/src/quantdinger_mcp/server.py index 8a2da1b30..04932af07 100644 --- a/mcp_server/src/quantdinger_mcp/server.py +++ b/mcp_server/src/quantdinger_mcp/server.py @@ -72,6 +72,7 @@ "list_universe_members", "list_factors", "get_factor", + "submit_factor_benchmark", "list_watchlist", "add_watchlist", "remove_watchlist", @@ -910,6 +911,48 @@ def get_factor(factor_id: str) -> Any: return _get(f"/api/agent/v1/research/factors/{factor_id}") +@_tool +def submit_factor_benchmark( + universe_id: int, + factor_ids: list[str], + start_date: str, + end_date: str, + idempotency_key: str, + frequency: str = "1d", + groups: int = 5, + holding_period: int = 5, + commission: float = 0.0005, + slippage: float = 0.0005, + neutralize_industry: bool = False, + train_fraction: float = 0.6, + false_discovery_rate: float = 0.1, + minimum_abs_oos_ic: float = 0.02, +) -> Any: + """Submit a multi-factor out-of-sample benchmark job.""" + cleaned = list(dict.fromkeys(str(item).strip() for item in factor_ids if str(item).strip())) + if len(cleaned) < 2: + raise ValueError("factor_ids must contain at least two unique factors") + return _post( + "/api/agent/v1/research/factor-benchmarks", + json={ + "universeId": int(universe_id), + "factors": [{"factorId": factor_id} for factor_id in cleaned], + "startDate": start_date, + "endDate": end_date, + "frequency": frequency, + "groups": max(2, min(10, int(groups))), + "holdingPeriod": max(1, int(holding_period)), + "commission": max(0.0, float(commission)), + "slippage": max(0.0, float(slippage)), + "neutralizeIndustry": bool(neutralize_industry), + "trainFraction": min(0.8, max(0.5, float(train_fraction))), + "falseDiscoveryRate": min(0.5, max(0.001, float(false_discovery_rate))), + "minimumAbsOosIc": max(0.0, float(minimum_abs_oos_ic)), + }, + headers=_idempotency_headers(idempotency_key), + ) + + @_tool def list_watchlist(limit: int = 100, cursor: int = 0) -> Any: """List the tenant watchlist.""" diff --git a/mcp_server/src/quantdinger_mcp/tool_contract.py b/mcp_server/src/quantdinger_mcp/tool_contract.py index 7dcb8a2ce..325aea066 100644 --- a/mcp_server/src/quantdinger_mcp/tool_contract.py +++ b/mcp_server/src/quantdinger_mcp/tool_contract.py @@ -21,6 +21,7 @@ "compile_strategy_code": False, "restore_strategy_source_version": True, "submit_backtest": False, + "submit_factor_benchmark": False, "cancel_open_paper_orders": True, "add_watchlist": True, "remove_watchlist": True, diff --git a/mcp_server/tests/test_security_and_tools.py b/mcp_server/tests/test_security_and_tools.py index 8ad6f31aa..46c71209d 100644 --- a/mcp_server/tests/test_security_and_tools.py +++ b/mcp_server/tests/test_security_and_tools.py @@ -30,7 +30,7 @@ def fresh_module(monkeypatch): def test_mcp_tool_registry_complete(fresh_module): - assert len(fresh_module.MCP_TOOL_NAMES) == 58 + assert len(fresh_module.MCP_TOOL_NAMES) == 59 # Every exported name should correspond to a registered @mcp.tool function. for name in fresh_module.MCP_TOOL_NAMES: assert hasattr(fresh_module, name), f"missing tool function: {name}"