Skip to content

Commit e60ba01

Browse files
committed
fix(analyzer): detect table keyword arg in get_fundamentals
strategy_data_analyzer only checked positional args for the table name. Keyword arguments like `table='valuation'` were silently ignored, causing valuation data not to load and get_fundamentals to return empty results.
1 parent b23c264 commit e60ba01

4 files changed

Lines changed: 181 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.12.1] - 2026-05-23
9+
10+
### Fixed
11+
12+
- **get_fundamentals keyword arg**`strategy_data_analyzer` now detects `table='valuation'` as a keyword argument, not just positional. Previously keyword-passed table names were silently ignored, causing valuation data not to load and `get_fundamentals` to return empty results.
13+
814
## [2.12.0] - 2026-05-21
915

1016
### Changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Poetry configuration
22
[tool.poetry]
33
name = "simtradelab"
4-
version = "2.12.0"
4+
version = "2.12.1"
55
description = "Lightweight quantitative backtesting framework with PTrade API simulation | 轻量级量化回测框架"
66
authors = ["kay <kayou@duck.com>"]
77
license = "AGPL-3.0-or-later"

src/simtradelab/ptrade/strategy_data_analyzer.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,17 @@ def visit_Call(self, node):
5151
if func_name:
5252
self.api_calls.add(func_name)
5353

54-
# 特殊处理get_fundamentals,提取表名参数
55-
if func_name == 'get_fundamentals' and len(node.args) >= 2:
56-
table_arg = node.args[1]
57-
if isinstance(table_arg, ast.Constant) and isinstance(table_arg.value, str):
54+
# 特殊处理get_fundamentals,提取表名参数(位置参数 + 关键字参数)
55+
if func_name == 'get_fundamentals':
56+
table_arg = None
57+
if len(node.args) >= 2:
58+
table_arg = node.args[1]
59+
else:
60+
for kw in node.keywords:
61+
if kw.arg == 'table' and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
62+
table_arg = kw.value
63+
break
64+
if table_arg is not None and isinstance(table_arg, ast.Constant) and isinstance(table_arg.value, str):
5865
self.fundamental_tables.add(table_arg.value)
5966

6067
self.generic_visit(node)
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""策略数据依赖分析器测试"""
2+
3+
import tempfile
4+
import os
5+
import ast
6+
7+
from simtradelab.ptrade.strategy_data_analyzer import (
8+
StrategyDataAnalyzer,
9+
analyze_strategy_data_requirements,
10+
DataDependencies,
11+
)
12+
13+
14+
def _analyze_code(code: str) -> DataDependencies:
15+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
16+
f.write(code)
17+
path = f.name
18+
try:
19+
return analyze_strategy_data_requirements(path)
20+
finally:
21+
os.unlink(path)
22+
23+
24+
class TestGetFundamentalsTableDetection:
25+
"""get_fundamentals 表名参数检测"""
26+
27+
# ── 位置参数 ──
28+
29+
def test_positional_table_valuation(self):
30+
deps = _analyze_code("df = get_fundamentals(stock, 'valuation')")
31+
assert deps.needs_fundamentals is True
32+
assert deps.needs_valuation is True
33+
assert deps.fundamental_tables == {"valuation"}
34+
35+
def test_positional_table_balance(self):
36+
deps = _analyze_code("df = get_fundamentals(stock, 'balance')")
37+
assert deps.needs_fundamentals is True
38+
assert deps.needs_valuation is False
39+
assert deps.fundamental_tables == {"balance"}
40+
41+
def test_positional_table_cashflow(self):
42+
deps = _analyze_code("df = get_fundamentals(stock, 'cash_flow')")
43+
assert deps.needs_fundamentals is True
44+
assert deps.fundamental_tables == {"cash_flow"}
45+
46+
# ── 关键字参数 ──
47+
48+
def test_keyword_table_valuation(self):
49+
deps = _analyze_code("df = get_fundamentals(context.test_stock, table='valuation')")
50+
assert deps.needs_fundamentals is True
51+
assert deps.needs_valuation is True
52+
assert deps.fundamental_tables == {"valuation"}
53+
54+
def test_keyword_table_balance(self):
55+
deps = _analyze_code("df = get_fundamentals(stock, table='balance', date='2025-01-01')")
56+
assert deps.needs_fundamentals is True
57+
assert deps.needs_valuation is False
58+
assert deps.fundamental_tables == {"balance"}
59+
60+
def test_keyword_table_income(self):
61+
deps = _analyze_code("df = get_fundamentals(stock, fields='net_profit', table='income')")
62+
assert deps.needs_fundamentals is True
63+
assert deps.fundamental_tables == {"income"}
64+
65+
# ── 单参数(无 table) ──
66+
67+
def test_single_arg_no_table(self):
68+
deps = _analyze_code("df = get_fundamentals(stock)")
69+
assert deps.needs_fundamentals is True
70+
assert deps.fundamental_tables == set()
71+
72+
# ── 多次调用 ──
73+
74+
def test_multiple_calls_different_tables(self):
75+
deps = _analyze_code("""
76+
df1 = get_fundamentals(stock, 'valuation')
77+
df2 = get_fundamentals(stock, table='balance')
78+
""")
79+
assert deps.needs_fundamentals is True
80+
assert deps.needs_valuation is True
81+
assert deps.fundamental_tables == {"valuation", "balance"}
82+
83+
def test_multiple_calls_same_table(self):
84+
deps = _analyze_code("""
85+
df1 = get_fundamentals(s1, 'valuation')
86+
df2 = get_fundamentals(s2, table='valuation')
87+
""")
88+
assert deps.fundamental_tables == {"valuation"}
89+
90+
91+
class TestPriceDataDetection:
92+
"""价格数据依赖检测"""
93+
94+
def test_get_history_triggers_price(self):
95+
deps = _analyze_code("hist = get_history(20, '1d', 'close', stocks)")
96+
assert deps.needs_price_data is True
97+
assert deps.needs_exrights is True
98+
99+
def test_get_price_triggers_price(self):
100+
deps = _analyze_code("p = get_price('000001.SZ')")
101+
assert deps.needs_price_data is True
102+
103+
def test_order_triggers_price(self):
104+
deps = _analyze_code("order('000001.SZ', 100)")
105+
assert deps.needs_price_data is True
106+
107+
def test_no_price_api(self):
108+
deps = _analyze_code("log.info('hello')")
109+
assert deps.needs_price_data is False
110+
assert deps.needs_exrights is False
111+
112+
113+
class TestErrorHandling:
114+
"""容错处理"""
115+
116+
def test_invalid_syntax(self):
117+
deps = _analyze_code("def broken(")
118+
assert deps.needs_price_data is True
119+
assert deps.needs_valuation is True
120+
assert deps.needs_fundamentals is True
121+
assert deps.needs_exrights is True
122+
123+
def test_empty_file(self):
124+
deps = _analyze_code("")
125+
assert deps.needs_price_data is False
126+
127+
128+
class TestVariableTableName:
129+
"""变量作为表名 — 静态分析无法解析,不崩溃即可"""
130+
131+
def test_variable_table_keyword(self):
132+
table_name = "valuation"
133+
code = f"df = get_fundamentals(stock, table=table_name)"
134+
deps = _analyze_code(code)
135+
assert deps.needs_fundamentals is True
136+
# 变量名无法静态解析,needs_valuation 不应被触发
137+
assert deps.needs_valuation is False
138+
139+
def test_variable_table_positional(self):
140+
code = "df = get_fundamentals(stock, table_var)"
141+
deps = _analyze_code(code)
142+
assert deps.needs_fundamentals is True
143+
assert deps.needs_valuation is False
144+
145+
146+
class TestStrategyDataAnalyzerDirect:
147+
"""直接测试 StrategyDataAnalyzer AST 遍历"""
148+
149+
def test_visit_call_keyword_table(self):
150+
tree = ast.parse("get_fundamentals(stock, table='valuation')")
151+
analyzer = StrategyDataAnalyzer()
152+
analyzer.visit(tree)
153+
deps = analyzer.analyze()
154+
assert deps.needs_valuation is True
155+
assert deps.fundamental_tables == {"valuation"}
156+
157+
def test_visit_call_no_table_arg(self):
158+
tree = ast.parse("get_fundamentals(stock)")
159+
analyzer = StrategyDataAnalyzer()
160+
analyzer.visit(tree)
161+
deps = analyzer.analyze()
162+
assert deps.needs_fundamentals is True
163+
assert deps.fundamental_tables == set()

0 commit comments

Comments
 (0)