Skip to content

Commit b36b2ac

Browse files
brokermr810tianhe
andauthored
feat: integrate TA-Lib 0.8.1 factors across research (#287)
Co-authored-by: TIANHE <TIANHE@GMAIL.COM>
1 parent 95b752c commit b36b2ac

25 files changed

Lines changed: 520 additions & 118 deletions

‎backend_api_python/app/routes/agent_v1/research.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33

44
from flask import request
55

6-
from app.services.factors import FactorError, get_factor, is_talib_available, list_factors
6+
from app.services.factors import (
7+
FactorError,
8+
TalibFactorError,
9+
get_factor_metadata,
10+
is_talib_available,
11+
list_factor_catalog,
12+
)
713
from app.services.market.watchlist import (
814
add_watchlist_item,
915
list_watchlist,
@@ -55,7 +61,7 @@ def list_research_universe_members(universe_id: int):
5561
@agent_v1_bp.route("/research/factors", methods=["GET"])
5662
@agent_required(SCOPE_R)
5763
def list_research_factors():
58-
rows = list_factors(
64+
rows = list_factor_catalog(
5965
category=(request.args.get("category") or "").strip(),
6066
factor_type=(request.args.get("factor_type") or "").strip(),
6167
)
@@ -66,8 +72,8 @@ def list_research_factors():
6672
@agent_required(SCOPE_R)
6773
def get_research_factor(factor_id: str):
6874
try:
69-
return envelope(get_factor(factor_id).metadata())
70-
except FactorError as exc:
75+
return envelope(get_factor_metadata(factor_id))
76+
except (FactorError, TalibFactorError) as exc:
7177
return error(404, exc.code, http=404)
7278

7379

‎backend_api_python/app/routes/backtest_center.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,14 @@ def run_strategy_backtest():
220220

221221

222222
@backtest_center_blp.route("/factor-research", methods=["POST"])
223+
@backtest_center_blp.doc(
224+
summary="Run factor research with built-in or TA-Lib 0.8.1 factors",
225+
description=(
226+
"Accepts factorId and optional factorParams. TA-Lib factors use the "
227+
"talib: namespace and support explicit output selection for "
228+
"multi-output functions."
229+
),
230+
)
223231
@login_required
224232
def run_factor_research():
225233
try:
@@ -232,6 +240,7 @@ def run_factor_research():
232240
if not start_raw or not end_raw:
233241
raise ValueError("strategyV2.dateRangeRequired")
234242
factor_id = str(payload.get("factorId") or "momentum_20")
243+
factor_params = payload.get("factorParams") if isinstance(payload.get("factorParams"), dict) else {}
235244
groups = max(2, min(10, int(payload.get("groups") or 5)))
236245
holding_period = max(1, int(payload.get("holdingPeriod") or 5))
237246
commission = parse_rate(payload.get("commission"), default=default_commission_if_missing(None))
@@ -243,6 +252,7 @@ def run_factor_research():
243252
start_date=datetime.strptime(start_raw, "%Y-%m-%d"),
244253
end_date=datetime.strptime(end_raw, "%Y-%m-%d").replace(hour=23, minute=59, second=59),
245254
factor_id=factor_id,
255+
factor_params=factor_params,
246256
groups=groups,
247257
holding_period=holding_period,
248258
commission=commission,

‎backend_api_python/app/routes/factors.py‎

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@
66
from app.services.factors import (
77
FactorError,
88
TalibFactorError,
9-
get_factor,
9+
get_factor_metadata,
1010
is_talib_available,
11-
list_factors,
12-
list_talib_factors,
11+
list_factor_catalog,
1312
)
1413
from app.services.factors.research import information_coefficient, quantile_returns, winsorize_zscore
1514
from app.services.fundamental_data import get_fundamental_data_service
@@ -28,23 +27,22 @@ def factor_catalog():
2827
factor_type = str(request.args.get("type") or request.args.get("factor_type") or "").strip()
2928
category = str(request.args.get("category") or "").strip()
3029
provider = str(request.args.get("provider") or "").strip().lower()
31-
data = list_factors(
30+
data = list_factor_catalog(
3231
category=category,
3332
factor_type=factor_type,
3433
)
35-
talib_data = []
36-
if provider in ("", "ta-lib", "talib") and is_talib_available() and factor_type != "fundamental":
37-
talib_data = [
38-
item for item in list_talib_factors()
39-
if not category or item.get("category") == category
40-
]
34+
if provider in ("ta-lib", "talib"):
35+
data = [item for item in data if item.get("provider") == "ta-lib"]
36+
elif provider:
37+
data = [item for item in data if item.get("provider") != "ta-lib"]
38+
talib_count = sum(1 for item in data if item.get("provider") == "ta-lib")
4139
return jsonify({
4240
"code": 1,
4341
"msg": "success",
44-
"data": data + talib_data,
42+
"data": data,
4543
"meta": {
46-
"builtInCount": len(data),
47-
"talibCount": len(talib_data),
44+
"builtInCount": len(data) - talib_count,
45+
"talibCount": talib_count,
4846
"talibAvailable": is_talib_available(),
4947
},
5048
})
@@ -59,8 +57,8 @@ def factor_catalog():
5957
@login_required
6058
def factor_detail(factor_id: str):
6159
try:
62-
return jsonify({"code": 1, "msg": "success", "data": get_factor(factor_id).metadata()})
63-
except FactorError as exc:
60+
return jsonify({"code": 1, "msg": "success", "data": get_factor_metadata(factor_id)})
61+
except (FactorError, TalibFactorError) as exc:
6462
return jsonify({"code": 0, "msg": exc.code, "data": None}), 404
6563

6664

‎backend_api_python/app/services/factors/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
is_talib_available,
1717
list_talib_factors,
1818
)
19+
from app.services.factors.catalog import (
20+
compute_catalog_factor,
21+
get_factor_metadata,
22+
list_factor_catalog,
23+
)
1924

2025
__all__ = [
2126
"FactorDefinition",
@@ -30,4 +35,7 @@
3035
"compute_talib_indicator",
3136
"is_talib_available",
3237
"list_talib_factors",
38+
"compute_catalog_factor",
39+
"get_factor_metadata",
40+
"list_factor_catalog",
3341
]
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Unified built-in and TA-Lib factor catalog helpers."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Mapping
6+
from typing import Any
7+
8+
import pandas as pd
9+
10+
from .registry import FactorError, compute_factor, get_factor, list_factors
11+
from .talib_adapter import (
12+
TalibFactorError,
13+
compute_talib_factor,
14+
is_talib_available,
15+
list_talib_factors,
16+
)
17+
18+
19+
def list_factor_catalog(*, category: str = "", factor_type: str = "") -> list[dict[str, Any]]:
20+
rows = list_factors(category=category, factor_type=factor_type)
21+
if factor_type == "fundamental" or not is_talib_available():
22+
return rows
23+
rows.extend(
24+
item for item in list_talib_factors()
25+
if not category or item.get("category") == category
26+
)
27+
return sorted(rows, key=lambda item: (
28+
str(item.get("factor_type") or ""),
29+
str(item.get("category") or ""),
30+
str(item.get("factor_id") or "").lower(),
31+
))
32+
33+
34+
def get_factor_metadata(factor_id: str) -> dict[str, Any]:
35+
canonical = str(factor_id or "").strip()
36+
if canonical.lower().startswith("talib:"):
37+
target = canonical.upper()
38+
if not is_talib_available():
39+
raise TalibFactorError("factor.talibUnavailable")
40+
item = next(
41+
(row for row in list_talib_factors() if row["factor_id"].upper() == target),
42+
None,
43+
)
44+
if item is None:
45+
raise TalibFactorError("factor.notFound")
46+
return item
47+
return get_factor(canonical.lower()).metadata()
48+
49+
50+
def compute_catalog_factor(
51+
factor_id: str,
52+
frame: pd.DataFrame,
53+
params: Mapping[str, Any] | None = None,
54+
) -> float:
55+
canonical = str(factor_id or "").strip()
56+
if canonical.lower().startswith("talib:"):
57+
normalized = dict(params or {})
58+
output = str(normalized.pop("output", "") or "")
59+
return compute_talib_factor(canonical, frame, normalized, output=output)
60+
try:
61+
return compute_factor(canonical.lower(), frame, params)
62+
except FactorError as exc:
63+
if exc.code != "factor.notFound":
64+
raise
65+
return compute_talib_factor(canonical, frame, params)

‎backend_api_python/app/services/factors/talib_adapter.py‎

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,16 @@
99
import pandas as pd
1010

1111

12-
MINIMUM_FUNCTION_COUNT = 129
12+
MINIMUM_FUNCTION_COUNT = 201
1313

14+
TA_LIB_081_FUNCTIONS = frozenset({
15+
"AC", "ADR", "AO", "CMF", "CMOU", "COPPOCK", "CUMSUM", "CVI",
16+
"DONCHIAN", "DPO", "EFI", "ER", "ERI", "FOSC", "FRACTAL", "HA",
17+
"HMA", "KC", "KDJ", "MARKETFI", "MASSI", "NVI", "PERCENTILE",
18+
"PERCENTRANK", "PVI", "PVO", "PVT", "QSTICK", "RMA", "RVI", "RVOL",
19+
"SMI", "SUPERTREND", "TSI", "VHF", "VORTEX", "VWAP", "VWMA", "WAD",
20+
"ZLEMA",
21+
})
1422

1523
class TalibFactorError(ValueError):
1624
def __init__(self, code: str):
@@ -41,32 +49,38 @@ def list_talib_factors() -> list[dict[str, Any]]:
4149
required_fields = tuple(sorted(_flatten_input_names(info.get("input_names") or {})))
4250
parameters = dict(info.get("parameters") or {})
4351
outputs = list(info.get("output_names") or [])
52+
if len(outputs) > 1:
53+
parameters["output"] = outputs[0]
4454
output.append({
4555
"factor_id": f"talib:{name.upper()}",
4656
"library_id": name.upper(),
4757
"version": str(getattr(talib, "__version__", "")),
58+
"display_name": str(info.get("display_name") or name),
4859
"name_i18n_key": f"factor.talib.{name.lower()}.name",
4960
"description_i18n_key": f"factor.talib.{name.lower()}.description",
5061
"category": _category(group_by_name.get(name.upper(), "")),
5162
"factor_type": "technical",
5263
"provider": "ta-lib",
64+
"introduced_in": "0.8.1" if name.upper() in TA_LIB_081_FUNCTIONS else "",
5365
"required_fields": list(required_fields),
5466
"default_params": parameters,
55-
"parameter_schema": {
56-
key: {"type": _parameter_type(value), "default": value}
57-
for key, value in parameters.items()
58-
},
67+
"parameter_schema": _parameter_schema(parameters, outputs),
5968
"outputs": outputs,
69+
"direction_hint": "neutral",
6070
"supported_contexts": ["cta", "portfolio"],
6171
"default_warmup_bars": int(getattr(function, "lookback", 0) or 0),
6272
})
6373
return output
6474

6575

6676
def assert_talib_catalog_ready() -> int:
67-
count = len(list_talib_factors())
68-
if count < MINIMUM_FUNCTION_COUNT:
69-
raise TalibFactorError(f"factor.talibCatalogIncomplete:{count}")
77+
catalog = list_talib_factors()
78+
count = len(catalog)
79+
available = {item["library_id"] for item in catalog}
80+
missing = sorted(TA_LIB_081_FUNCTIONS - available)
81+
if count < MINIMUM_FUNCTION_COUNT or missing:
82+
suffix = f":{','.join(missing)}" if missing else ""
83+
raise TalibFactorError(f"factor.talibCatalogIncomplete:{count}{suffix}")
7084
return count
7185

7286

@@ -88,10 +102,7 @@ def compute_talib_indicator(
88102
raise TalibFactorError(f"factor.missingFields:{','.join(missing)}")
89103
clean = frame.copy()
90104
clean.columns = [str(column).strip().lower() for column in clean.columns]
91-
normalized = dict(params or {})
92-
if library_id == "SMA" and "period" in normalized:
93-
period = normalized.pop("period")
94-
normalized.setdefault("timeperiod", period)
105+
normalized = _normalized_parameters(function, params)
95106
try:
96107
result = function(clean, **normalized)
97108
except Exception as exc:
@@ -126,9 +137,11 @@ def compute_talib_factor(
126137
*,
127138
output: str = "",
128139
) -> float:
129-
result = compute_talib_indicator(name, frame, params)
140+
normalized = dict(params or {})
141+
selected_from_params = normalized.pop("output", "")
142+
result = compute_talib_indicator(name, frame, normalized)
130143
if isinstance(result, pd.DataFrame):
131-
selected = str(output or "").strip()
144+
selected = str(output or selected_from_params or "").strip()
132145
if not selected:
133146
selected = str(result.columns[0])
134147
if selected not in result.columns:
@@ -177,6 +190,38 @@ def _parameter_type(value: Any) -> str:
177190
return "string"
178191

179192

193+
def _parameter_schema(parameters: Mapping[str, Any], outputs: list[str]) -> dict[str, dict[str, Any]]:
194+
schema = {}
195+
for key, value in parameters.items():
196+
if key == "output":
197+
schema[key] = {"type": "enum", "default": value, "options": list(outputs)}
198+
continue
199+
item = {"type": _parameter_type(value), "default": value}
200+
if item["type"] == "integer":
201+
item.update({"minimum": 1, "maximum": 5000, "step": 1})
202+
elif item["type"] == "number":
203+
item.update({"step": 0.1})
204+
schema[key] = item
205+
return schema
206+
207+
208+
def _normalized_parameters(function: Any, params: Mapping[str, Any] | None) -> dict[str, Any]:
209+
supported = set((function.info.get("parameters") or {}).keys())
210+
normalized = dict(params or {})
211+
normalized.pop("output", None)
212+
aliases = {
213+
"period": "timeperiod",
214+
"fast_period": "fastperiod",
215+
"slow_period": "slowperiod",
216+
"signal_period": "signalperiod",
217+
}
218+
for source, target in aliases.items():
219+
if source in normalized and target in supported:
220+
value = normalized.pop(source)
221+
normalized.setdefault(target, value)
222+
return normalized
223+
224+
180225
def _category(group: str) -> str:
181226
raw = str(group or "").strip().lower()
182227
if "pattern" in raw:

0 commit comments

Comments
 (0)