quantcli 0.1.17__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {quantcli-0.1.17/quantcli.egg-info → quantcli-0.2.1}/PKG-INFO +1 -1
- {quantcli-0.1.17 → quantcli-0.2.1}/pyproject.toml +1 -1
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/core/data.py +15 -7
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/datasources/akshare.py +30 -1
- {quantcli-0.1.17 → quantcli-0.2.1/quantcli.egg-info}/PKG-INFO +1 -1
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli.egg-info/SOURCES.txt +1 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/tests/test_cli.py +188 -122
- quantcli-0.2.1/tests/test_data_manager.py +270 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/LICENSE +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/README.md +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/cli.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/core/__init__.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/core/backtest.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/core/factor.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/datasources/__init__.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/datasources/baostock.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/datasources/base.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/datasources/cache.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/datasources/fundamentals/__init__.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/datasources/fundamentals/provider.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/datasources/mixed.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/datasources/mysql.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/datasources/sync/__init__.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/datasources/sync/akshare.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/datasources/sync/base.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/datasources/sync/gm.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/datasources/sync/gm_fundamental.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/__init__.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_001.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_002.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_003.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_004.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_005.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_006.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_007.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_008.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_009.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_010.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_011.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_012.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_013.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_014.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_015.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_016.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_017.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_018.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_019.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_020.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_021.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_022.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_023.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_024.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_025.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_026.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_027.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_028.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_029.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_030.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_031.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_032.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_033.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_034.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_035.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_036.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_037.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_038.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_039.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/alpha101/alpha_040.yaml +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/base.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/compute.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/loader.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/pipeline.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/ranking.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/ranking_executor.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/screening.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/factors/screening_executor.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/models/bar.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/parser/__init__.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/parser/constants.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/parser/formula.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/utils/__init__.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/utils/env.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/utils/logger.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/utils/path.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/utils/symbol_utils.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/utils/time.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli/utils/validate.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli.egg-info/dependency_links.txt +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli.egg-info/entry_points.txt +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli.egg-info/requires.txt +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/quantcli.egg-info/top_level.txt +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/setup.cfg +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/tests/test_akshare_integration.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/tests/test_builtin_factors.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/tests/test_datasources.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/tests/test_factors.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/tests/test_gm_executors.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/tests/test_mixed_datasource.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/tests/test_multi_factor.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/tests/test_pipeline_integration.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/tests/test_symbol_utils.py +0 -0
- {quantcli-0.1.17 → quantcli-0.2.1}/tests/test_time.py +0 -0
|
@@ -117,16 +117,24 @@ class DataManager:
|
|
|
117
117
|
"""获取单只股票日线数据"""
|
|
118
118
|
cache_file = self.cache_dir / "raw" / "stock_daily" / f"{symbol}.parquet"
|
|
119
119
|
|
|
120
|
-
#
|
|
121
|
-
|
|
120
|
+
# 尝试获取交易日历,计算预期交易日数
|
|
121
|
+
expected_count = 0
|
|
122
|
+
has_calendar = False
|
|
123
|
+
try:
|
|
124
|
+
trading_days = self.get_trading_calendar()
|
|
125
|
+
expected_count = sum(1 for d in trading_days if start_date <= d <= end_date)
|
|
126
|
+
has_calendar = True
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
# 检查缓存完整性
|
|
131
|
+
if use_cache and cache_file.exists() and has_calendar:
|
|
122
132
|
cached = self._load_cached_range(cache_file)
|
|
123
|
-
if cached is not None:
|
|
133
|
+
if cached is not None and len(cached) >= expected_count:
|
|
124
134
|
mask = (cached["date"] >= start_date) & (cached["date"] <= end_date)
|
|
125
|
-
|
|
126
|
-
if not result.empty:
|
|
127
|
-
return result
|
|
135
|
+
return cached[mask].copy()
|
|
128
136
|
|
|
129
|
-
#
|
|
137
|
+
# 全量拉取
|
|
130
138
|
df = self.datasource.get_daily(symbol, start_date, end_date, **kwargs)
|
|
131
139
|
|
|
132
140
|
if df.empty:
|
|
@@ -235,13 +235,16 @@ class AkshareDataSource(DataSource):
|
|
|
235
235
|
"""获取交易日历"""
|
|
236
236
|
if self.config.use_cache:
|
|
237
237
|
cached = self._calendar_cache.get_calendar(exchange)
|
|
238
|
-
if cached is not None:
|
|
238
|
+
if cached is not None and self._validate_calendar(cached):
|
|
239
239
|
return sorted(pd.to_datetime(cached["trade_date"]).dt.date.tolist())
|
|
240
240
|
|
|
241
241
|
df = self._ak.tool_trade_date_hist_sina()
|
|
242
242
|
if df.empty:
|
|
243
243
|
return []
|
|
244
244
|
|
|
245
|
+
if not self._validate_calendar(df):
|
|
246
|
+
return []
|
|
247
|
+
|
|
245
248
|
trading_dates = pd.to_datetime(df["trade_date"]).dt.date.tolist()
|
|
246
249
|
|
|
247
250
|
if self.config.use_cache:
|
|
@@ -249,6 +252,32 @@ class AkshareDataSource(DataSource):
|
|
|
249
252
|
|
|
250
253
|
return sorted(trading_dates)
|
|
251
254
|
|
|
255
|
+
def _validate_calendar(self, df: pd.DataFrame) -> bool:
|
|
256
|
+
"""验证交易日历是否完整"""
|
|
257
|
+
if df.empty:
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
dates = pd.to_datetime(df["trade_date"])
|
|
261
|
+
min_date = dates.min()
|
|
262
|
+
max_date = dates.max()
|
|
263
|
+
|
|
264
|
+
# 检查日期范围(默认应该覆盖近1年)
|
|
265
|
+
from datetime import timedelta
|
|
266
|
+
|
|
267
|
+
one_year_ago = pd.Timestamp.now() - timedelta(days=365)
|
|
268
|
+
|
|
269
|
+
if min_date > one_year_ago:
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
# 检查数量(1年应该有约250个交易日)
|
|
273
|
+
expected_min = 200
|
|
274
|
+
actual = len(dates)
|
|
275
|
+
|
|
276
|
+
if actual < expected_min:
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
return True
|
|
280
|
+
|
|
252
281
|
# ==================== 辅助方法 ====================
|
|
253
282
|
|
|
254
283
|
def _filter_fields(
|
|
@@ -31,15 +31,17 @@ def mock_price_data():
|
|
|
31
31
|
dates = pd.date_range(start=start_date, periods=100, freq="B")
|
|
32
32
|
|
|
33
33
|
close = 100 + np.cumsum(np.random.randn(100) * 0.5)
|
|
34
|
-
return pd.DataFrame(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
return pd.DataFrame(
|
|
35
|
+
{
|
|
36
|
+
"date": dates,
|
|
37
|
+
"symbol": ["600519"] * 100,
|
|
38
|
+
"open": close * (1 + np.random.randn(100) * 0.005),
|
|
39
|
+
"high": close * (1 + np.abs(np.random.randn(100) * 0.01)),
|
|
40
|
+
"low": close * (1 - np.abs(np.random.randn(100) * 0.01)),
|
|
41
|
+
"close": close,
|
|
42
|
+
"volume": np.random.randint(1000000, 10000000, 100),
|
|
43
|
+
}
|
|
44
|
+
)
|
|
43
45
|
|
|
44
46
|
|
|
45
47
|
@pytest.fixture
|
|
@@ -58,17 +60,27 @@ class TestQuant3Main:
|
|
|
58
60
|
result = runner.invoke(cli.quantcli, ["--help"])
|
|
59
61
|
assert result.exit_code == 0
|
|
60
62
|
assert "QuantCLI" in result.output
|
|
61
|
-
assert
|
|
63
|
+
assert (
|
|
64
|
+
"quantitative" in result.output.lower() or "quant" in result.output.lower()
|
|
65
|
+
)
|
|
62
66
|
|
|
63
67
|
def test_version(self, runner):
|
|
64
68
|
"""Test quantcli --version"""
|
|
65
69
|
result = runner.invoke(cli.quantcli, ["--version"])
|
|
66
70
|
assert result.exit_code == 0
|
|
67
|
-
assert "
|
|
71
|
+
assert "quantcli" in result.output.lower()
|
|
72
|
+
import re
|
|
73
|
+
|
|
74
|
+
match = re.search(r"version (\d+\.\d+\.\d+)", result.output)
|
|
75
|
+
assert match is not None
|
|
76
|
+
version = match.group(1)
|
|
77
|
+
assert re.match(r"\d+\.\d+\.\d+", version)
|
|
68
78
|
|
|
69
79
|
def test_verbose_flag(self, runner):
|
|
70
80
|
"""Test verbose flag sets debug level"""
|
|
71
|
-
result = runner.invoke(
|
|
81
|
+
result = runner.invoke(
|
|
82
|
+
cli.quantcli, ["--verbose", "data", "health"], catch_exceptions=False
|
|
83
|
+
)
|
|
72
84
|
# Should execute without error even if verbose mode
|
|
73
85
|
assert result.exit_code == 0 or "Error" in result.output
|
|
74
86
|
|
|
@@ -81,8 +93,8 @@ class TestQuant3Main:
|
|
|
81
93
|
class TestDataCommands:
|
|
82
94
|
"""Tests for data commands"""
|
|
83
95
|
|
|
84
|
-
@patch.object(cli.DataManager,
|
|
85
|
-
@patch.object(cli.DataManager,
|
|
96
|
+
@patch.object(cli.DataManager, "__init__")
|
|
97
|
+
@patch.object(cli.DataManager, "get_daily")
|
|
86
98
|
def test_data_fetch_basic(self, mock_get_daily, mock_init, runner, mock_price_data):
|
|
87
99
|
"""Test data fetch command"""
|
|
88
100
|
mock_init.return_value = None
|
|
@@ -93,42 +105,43 @@ class TestDataCommands:
|
|
|
93
105
|
assert result.exit_code == 0
|
|
94
106
|
assert "600519" in result.output
|
|
95
107
|
|
|
96
|
-
@patch.object(cli.DataManager,
|
|
97
|
-
@patch.object(cli.DataManager,
|
|
98
|
-
def test_data_fetch_with_dates(
|
|
108
|
+
@patch.object(cli.DataManager, "__init__")
|
|
109
|
+
@patch.object(cli.DataManager, "get_daily")
|
|
110
|
+
def test_data_fetch_with_dates(
|
|
111
|
+
self, mock_get_daily, mock_init, runner, mock_price_data
|
|
112
|
+
):
|
|
99
113
|
"""Test data fetch with start and end dates"""
|
|
100
114
|
mock_init.return_value = None
|
|
101
115
|
mock_get_daily.return_value = mock_price_data
|
|
102
116
|
|
|
103
|
-
result = runner.invoke(
|
|
104
|
-
"600519",
|
|
105
|
-
|
|
106
|
-
"--end", "2023-06-01"
|
|
107
|
-
])
|
|
117
|
+
result = runner.invoke(
|
|
118
|
+
cli.data_fetch, ["600519", "--start", "2023-01-01", "--end", "2023-06-01"]
|
|
119
|
+
)
|
|
108
120
|
|
|
109
121
|
assert result.exit_code == 0
|
|
110
122
|
assert "2023-01-01" in result.output or "2023-06-01" in result.output
|
|
111
123
|
|
|
112
|
-
@patch.object(cli.DataManager,
|
|
113
|
-
@patch.object(cli.DataManager,
|
|
114
|
-
def test_data_fetch_with_output_csv(
|
|
124
|
+
@patch.object(cli.DataManager, "__init__")
|
|
125
|
+
@patch.object(cli.DataManager, "get_daily")
|
|
126
|
+
def test_data_fetch_with_output_csv(
|
|
127
|
+
self, mock_get_daily, mock_init, runner, mock_price_data, tmp_path
|
|
128
|
+
):
|
|
115
129
|
"""Test data fetch with CSV output"""
|
|
116
130
|
mock_init.return_value = None
|
|
117
131
|
mock_get_daily.return_value = mock_price_data
|
|
118
132
|
|
|
119
133
|
output_file = tmp_path / "output.csv"
|
|
120
134
|
|
|
121
|
-
result = runner.invoke(
|
|
122
|
-
|
|
123
|
-
"--start", "2023-01-01",
|
|
124
|
-
|
|
125
|
-
])
|
|
135
|
+
result = runner.invoke(
|
|
136
|
+
cli.data_fetch,
|
|
137
|
+
["600519", "--start", "2023-01-01", "--output", str(output_file)],
|
|
138
|
+
)
|
|
126
139
|
|
|
127
140
|
assert result.exit_code == 0
|
|
128
141
|
assert "Saved to" in result.output
|
|
129
142
|
|
|
130
|
-
@patch.object(cli.DataManager,
|
|
131
|
-
@patch.object(cli.DataManager,
|
|
143
|
+
@patch.object(cli.DataManager, "__init__")
|
|
144
|
+
@patch.object(cli.DataManager, "get_daily")
|
|
132
145
|
def test_data_fetch_empty_result(self, mock_get_daily, mock_init, runner):
|
|
133
146
|
"""Test data fetch with no data"""
|
|
134
147
|
mock_init.return_value = None
|
|
@@ -146,15 +159,15 @@ class TestDataCommands:
|
|
|
146
159
|
assert result.exit_code != 0
|
|
147
160
|
assert "Invalid date" in result.output or "Error" in result.output
|
|
148
161
|
|
|
149
|
-
@patch.object(cli.DataManager,
|
|
150
|
-
@patch.object(cli.DataManager,
|
|
162
|
+
@patch.object(cli.DataManager, "__init__")
|
|
163
|
+
@patch.object(cli.DataManager, "get_cache_size")
|
|
151
164
|
def test_data_cache_ls(self, mock_get_cache_size, mock_init, runner):
|
|
152
165
|
"""Test data cache list command"""
|
|
153
166
|
mock_init.return_value = None
|
|
154
167
|
mock_get_cache_size.return_value = {
|
|
155
168
|
"600519.csv": "1.2MB",
|
|
156
169
|
"000001.csv": "0.8MB",
|
|
157
|
-
"_total": "2.0MB"
|
|
170
|
+
"_total": "2.0MB",
|
|
158
171
|
}
|
|
159
172
|
|
|
160
173
|
result = runner.invoke(cli.data_cache_ls, [])
|
|
@@ -162,8 +175,8 @@ class TestDataCommands:
|
|
|
162
175
|
assert result.exit_code == 0
|
|
163
176
|
assert "Cached files" in result.output or "600519" in result.output
|
|
164
177
|
|
|
165
|
-
@patch.object(cli.DataManager,
|
|
166
|
-
@patch.object(cli.DataManager,
|
|
178
|
+
@patch.object(cli.DataManager, "__init__")
|
|
179
|
+
@patch.object(cli.DataManager, "clear_cache")
|
|
167
180
|
def test_data_cache_clean(self, mock_clear_cache, mock_init, runner):
|
|
168
181
|
"""Test data cache clean command"""
|
|
169
182
|
mock_init.return_value = None
|
|
@@ -174,15 +187,15 @@ class TestDataCommands:
|
|
|
174
187
|
assert result.exit_code == 0
|
|
175
188
|
assert "5" in result.output or "Cleaned" in result.output
|
|
176
189
|
|
|
177
|
-
@patch.object(cli.DataManager,
|
|
178
|
-
@patch.object(cli.DataManager,
|
|
190
|
+
@patch.object(cli.DataManager, "__init__")
|
|
191
|
+
@patch.object(cli.DataManager, "health_check")
|
|
179
192
|
def test_data_health(self, mock_health_check, mock_init, runner):
|
|
180
193
|
"""Test data health check command"""
|
|
181
194
|
mock_init.return_value = None
|
|
182
195
|
mock_health_check.return_value = {
|
|
183
196
|
"status": "ok",
|
|
184
197
|
"cache_dir": "/tmp/quantcli_cache",
|
|
185
|
-
"source": "akshare"
|
|
198
|
+
"source": "akshare",
|
|
186
199
|
}
|
|
187
200
|
|
|
188
201
|
result = runner.invoke(cli.data_health, [])
|
|
@@ -194,87 +207,117 @@ class TestDataCommands:
|
|
|
194
207
|
class TestFactorCommands:
|
|
195
208
|
"""Tests for factor commands"""
|
|
196
209
|
|
|
197
|
-
@patch.object(cli.DataManager,
|
|
198
|
-
@patch.object(cli.DataManager,
|
|
210
|
+
@patch.object(cli.DataManager, "__init__")
|
|
211
|
+
@patch.object(cli.DataManager, "get_daily")
|
|
199
212
|
def test_factor_run_basic(self, mock_get_daily, mock_init, runner, mock_price_data):
|
|
200
213
|
"""Test factor run command with basic formula"""
|
|
201
214
|
mock_init.return_value = None
|
|
202
215
|
mock_get_daily.return_value = mock_price_data
|
|
203
216
|
|
|
204
|
-
result = runner.invoke(
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
217
|
+
result = runner.invoke(
|
|
218
|
+
cli.factor_run,
|
|
219
|
+
[
|
|
220
|
+
"--name",
|
|
221
|
+
"momentum",
|
|
222
|
+
"--expr",
|
|
223
|
+
"(close / delay(close, 20)) - 1",
|
|
224
|
+
"--symbol",
|
|
225
|
+
"600519",
|
|
226
|
+
"--start",
|
|
227
|
+
"2023-01-01",
|
|
228
|
+
],
|
|
229
|
+
)
|
|
210
230
|
|
|
211
231
|
assert result.exit_code == 0
|
|
212
232
|
assert "momentum" in result.output or "computed" in result.output.lower()
|
|
213
233
|
|
|
214
|
-
@patch.object(cli.DataManager,
|
|
215
|
-
@patch.object(cli.DataManager,
|
|
216
|
-
def test_factor_run_with_ma(
|
|
234
|
+
@patch.object(cli.DataManager, "__init__")
|
|
235
|
+
@patch.object(cli.DataManager, "get_daily")
|
|
236
|
+
def test_factor_run_with_ma(
|
|
237
|
+
self, mock_get_daily, mock_init, runner, mock_price_data
|
|
238
|
+
):
|
|
217
239
|
"""Test factor run with moving average formula"""
|
|
218
240
|
mock_init.return_value = None
|
|
219
241
|
mock_get_daily.return_value = mock_price_data
|
|
220
242
|
|
|
221
|
-
result = runner.invoke(
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
243
|
+
result = runner.invoke(
|
|
244
|
+
cli.factor_run,
|
|
245
|
+
[
|
|
246
|
+
"--name",
|
|
247
|
+
"ma_20",
|
|
248
|
+
"--expr",
|
|
249
|
+
"ma(close, 20)",
|
|
250
|
+
"--symbol",
|
|
251
|
+
"600519",
|
|
252
|
+
"--start",
|
|
253
|
+
"2023-01-01",
|
|
254
|
+
],
|
|
255
|
+
)
|
|
227
256
|
|
|
228
257
|
assert result.exit_code == 0
|
|
229
258
|
assert "ma_20" in result.output or "computed" in result.output.lower()
|
|
230
259
|
|
|
231
|
-
@patch.object(cli.DataManager,
|
|
232
|
-
@patch.object(cli.DataManager,
|
|
233
|
-
def test_factor_run_with_output(
|
|
260
|
+
@patch.object(cli.DataManager, "__init__")
|
|
261
|
+
@patch.object(cli.DataManager, "get_daily")
|
|
262
|
+
def test_factor_run_with_output(
|
|
263
|
+
self, mock_get_daily, mock_init, runner, mock_price_data, tmp_path
|
|
264
|
+
):
|
|
234
265
|
"""Test factor run with CSV output"""
|
|
235
266
|
mock_init.return_value = None
|
|
236
267
|
mock_get_daily.return_value = mock_price_data
|
|
237
268
|
|
|
238
269
|
output_file = tmp_path / "factor_output.csv"
|
|
239
270
|
|
|
240
|
-
result = runner.invoke(
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
271
|
+
result = runner.invoke(
|
|
272
|
+
cli.factor_run,
|
|
273
|
+
[
|
|
274
|
+
"--name",
|
|
275
|
+
"momentum",
|
|
276
|
+
"--expr",
|
|
277
|
+
"close - close",
|
|
278
|
+
"--symbol",
|
|
279
|
+
"600519",
|
|
280
|
+
"--start",
|
|
281
|
+
"2023-01-01",
|
|
282
|
+
"--output",
|
|
283
|
+
str(output_file),
|
|
284
|
+
],
|
|
285
|
+
)
|
|
247
286
|
|
|
248
287
|
assert result.exit_code == 0
|
|
249
288
|
assert "Saved to" in result.output
|
|
250
289
|
|
|
251
|
-
@patch.object(cli.DataManager,
|
|
252
|
-
@patch.object(cli.DataManager,
|
|
253
|
-
@patch.object(cli.FactorEngine,
|
|
254
|
-
def test_factor_eval_ic(
|
|
290
|
+
@patch.object(cli.DataManager, "__init__")
|
|
291
|
+
@patch.object(cli.DataManager, "get_daily")
|
|
292
|
+
@patch.object(cli.FactorEngine, "evaluate_ic")
|
|
293
|
+
def test_factor_eval_ic(
|
|
294
|
+
self, mock_eval_ic, mock_get_daily, mock_init, runner, mock_price_data
|
|
295
|
+
):
|
|
255
296
|
"""Test factor eval with IC method"""
|
|
256
297
|
mock_init.return_value = None
|
|
257
298
|
mock_get_daily.return_value = mock_price_data
|
|
258
299
|
mock_eval_ic.return_value = {
|
|
259
|
-
"ic_stats": {
|
|
260
|
-
"ic_mean": 0.05,
|
|
261
|
-
"ic_std": 0.15,
|
|
262
|
-
"ic_ir": 0.33
|
|
263
|
-
}
|
|
300
|
+
"ic_stats": {"ic_mean": 0.05, "ic_std": 0.15, "ic_ir": 0.33}
|
|
264
301
|
}
|
|
265
302
|
|
|
266
|
-
result = runner.invoke(
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
303
|
+
result = runner.invoke(
|
|
304
|
+
cli.factor_eval,
|
|
305
|
+
[
|
|
306
|
+
"momentum",
|
|
307
|
+
"--symbol",
|
|
308
|
+
"600519",
|
|
309
|
+
"--start",
|
|
310
|
+
"2023-01-01",
|
|
311
|
+
"--method",
|
|
312
|
+
"ic",
|
|
313
|
+
],
|
|
314
|
+
)
|
|
272
315
|
|
|
273
316
|
assert result.exit_code == 0
|
|
274
317
|
assert "IC" in result.output or "IC Mean" in result.output
|
|
275
318
|
|
|
276
|
-
@patch.object(cli.DataManager,
|
|
277
|
-
@patch.object(cli.DataManager,
|
|
319
|
+
@patch.object(cli.DataManager, "__init__")
|
|
320
|
+
@patch.object(cli.DataManager, "get_daily")
|
|
278
321
|
def test_factor_list(self, mock_get_daily, mock_init, runner):
|
|
279
322
|
"""Test factor list command"""
|
|
280
323
|
mock_init.return_value = None
|
|
@@ -289,9 +332,11 @@ class TestFactorCommands:
|
|
|
289
332
|
class TestBacktestCommands:
|
|
290
333
|
"""Tests for backtest commands"""
|
|
291
334
|
|
|
292
|
-
@patch.object(cli.DataManager,
|
|
293
|
-
@patch.object(cli.DataManager,
|
|
294
|
-
def test_backtest_run_ma_cross(
|
|
335
|
+
@patch.object(cli.DataManager, "__init__")
|
|
336
|
+
@patch.object(cli.DataManager, "get_daily")
|
|
337
|
+
def test_backtest_run_ma_cross(
|
|
338
|
+
self, mock_get_daily, mock_init, runner, mock_price_data
|
|
339
|
+
):
|
|
295
340
|
"""Test backtest run with built-in MA cross strategy
|
|
296
341
|
|
|
297
342
|
Note: May fail due to existing backtest engine issues with order handling.
|
|
@@ -300,39 +345,47 @@ class TestBacktestCommands:
|
|
|
300
345
|
mock_get_daily.return_value = mock_price_data
|
|
301
346
|
|
|
302
347
|
# Use built-in strategy
|
|
303
|
-
result = runner.invoke(
|
|
304
|
-
|
|
305
|
-
"--symbol", "600519",
|
|
306
|
-
|
|
307
|
-
])
|
|
348
|
+
result = runner.invoke(
|
|
349
|
+
cli.backtest_run,
|
|
350
|
+
["--strategy", "ma_cross", "--symbol", "600519", "--start", "2023-01-01"],
|
|
351
|
+
)
|
|
308
352
|
|
|
309
353
|
# Either succeeds or fails with known backtest engine issue
|
|
310
354
|
# Check that it attempted to run backtest
|
|
311
355
|
assert "Running backtest" in result.output or "Error" in result.output
|
|
312
356
|
|
|
313
|
-
@patch.object(cli.DataManager,
|
|
314
|
-
@patch.object(cli.DataManager,
|
|
315
|
-
def test_backtest_run_with_custom_capital(
|
|
357
|
+
@patch.object(cli.DataManager, "__init__")
|
|
358
|
+
@patch.object(cli.DataManager, "get_daily")
|
|
359
|
+
def test_backtest_run_with_custom_capital(
|
|
360
|
+
self, mock_get_daily, mock_init, runner, mock_price_data
|
|
361
|
+
):
|
|
316
362
|
"""Test backtest run with custom capital"""
|
|
317
363
|
mock_init.return_value = None
|
|
318
364
|
mock_get_daily.return_value = mock_price_data
|
|
319
365
|
|
|
320
|
-
result = runner.invoke(
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
366
|
+
result = runner.invoke(
|
|
367
|
+
cli.backtest_run,
|
|
368
|
+
[
|
|
369
|
+
"--strategy",
|
|
370
|
+
"ma_cross",
|
|
371
|
+
"--symbol",
|
|
372
|
+
"600519",
|
|
373
|
+
"--start",
|
|
374
|
+
"2023-01-01",
|
|
375
|
+
"--capital",
|
|
376
|
+
"500000",
|
|
377
|
+
],
|
|
378
|
+
)
|
|
326
379
|
|
|
327
380
|
# Either succeeds or fails with known backtest engine issue
|
|
328
381
|
assert "Running backtest" in result.output or "Error" in result.output
|
|
329
382
|
|
|
330
383
|
def test_backtest_run_invalid_strategy(self, runner):
|
|
331
384
|
"""Test backtest run with invalid strategy"""
|
|
332
|
-
result = runner.invoke(
|
|
333
|
-
|
|
334
|
-
"--symbol", "600519"
|
|
335
|
-
|
|
385
|
+
result = runner.invoke(
|
|
386
|
+
cli.backtest_run,
|
|
387
|
+
["--strategy", "nonexistent_strategy", "--symbol", "600519"],
|
|
388
|
+
)
|
|
336
389
|
|
|
337
390
|
assert result.exit_code != 0
|
|
338
391
|
assert "Error" in result.output or "Unknown strategy" in result.output
|
|
@@ -348,7 +401,7 @@ class TestBacktestCommands:
|
|
|
348
401
|
class TestConfigCommands:
|
|
349
402
|
"""Tests for config commands"""
|
|
350
403
|
|
|
351
|
-
@patch.object(cli.DataManager,
|
|
404
|
+
@patch.object(cli.DataManager, "__init__")
|
|
352
405
|
def test_config_show(self, mock_init, runner):
|
|
353
406
|
"""Test config show command"""
|
|
354
407
|
mock_init.return_value = None
|
|
@@ -372,20 +425,26 @@ class TestDateType:
|
|
|
372
425
|
|
|
373
426
|
def test_valid_date(self, runner):
|
|
374
427
|
"""Test valid date format"""
|
|
375
|
-
result = runner.invoke(
|
|
428
|
+
result = runner.invoke(
|
|
429
|
+
cli.quantcli, ["data", "fetch", "600519", "--start", "2023-12-31"]
|
|
430
|
+
)
|
|
376
431
|
# Should not fail on date parsing
|
|
377
432
|
assert "Error" not in result.output or result.exit_code == 0
|
|
378
433
|
|
|
379
434
|
def test_invalid_date_format(self, runner):
|
|
380
435
|
"""Test invalid date format"""
|
|
381
|
-
result = runner.invoke(
|
|
436
|
+
result = runner.invoke(
|
|
437
|
+
cli.quantcli, ["data", "fetch", "600519", "--start", "31-12-2023"]
|
|
438
|
+
)
|
|
382
439
|
|
|
383
440
|
assert result.exit_code != 0
|
|
384
441
|
assert "Invalid date" in result.output or "Error" in result.output
|
|
385
442
|
|
|
386
443
|
def test_invalid_date_value(self, runner):
|
|
387
444
|
"""Test invalid date value"""
|
|
388
|
-
result = runner.invoke(
|
|
445
|
+
result = runner.invoke(
|
|
446
|
+
cli.quantcli, ["data", "fetch", "600519", "--start", "2023-13-01"]
|
|
447
|
+
)
|
|
389
448
|
|
|
390
449
|
assert result.exit_code != 0
|
|
391
450
|
|
|
@@ -393,19 +452,26 @@ class TestDateType:
|
|
|
393
452
|
class TestEdgeCases:
|
|
394
453
|
"""Tests for edge cases and error handling"""
|
|
395
454
|
|
|
396
|
-
@patch.object(cli.DataManager,
|
|
397
|
-
@patch.object(cli.DataManager,
|
|
455
|
+
@patch.object(cli.DataManager, "__init__")
|
|
456
|
+
@patch.object(cli.DataManager, "get_daily")
|
|
398
457
|
def test_factor_run_empty_data(self, mock_get_daily, mock_init, runner):
|
|
399
458
|
"""Test factor run with empty data"""
|
|
400
459
|
mock_init.return_value = None
|
|
401
460
|
mock_get_daily.return_value = pd.DataFrame()
|
|
402
461
|
|
|
403
|
-
result = runner.invoke(
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
462
|
+
result = runner.invoke(
|
|
463
|
+
cli.factor_run,
|
|
464
|
+
[
|
|
465
|
+
"--name",
|
|
466
|
+
"test_factor",
|
|
467
|
+
"--expr",
|
|
468
|
+
"close",
|
|
469
|
+
"--symbol",
|
|
470
|
+
"600519",
|
|
471
|
+
"--start",
|
|
472
|
+
"2023-01-01",
|
|
473
|
+
],
|
|
474
|
+
)
|
|
409
475
|
|
|
410
476
|
assert result.exit_code == 0
|
|
411
477
|
assert "No data" in result.output
|
|
@@ -420,12 +486,12 @@ screening:
|
|
|
420
486
|
conditions: []
|
|
421
487
|
""")
|
|
422
488
|
|
|
423
|
-
with patch(
|
|
489
|
+
with patch("quantcli.core.backtest.YAMLBacktestEngine.run") as mock_run:
|
|
424
490
|
mock_run.side_effect = Exception("No data")
|
|
425
|
-
result = runner.invoke(
|
|
426
|
-
|
|
427
|
-
"--start", "2023-01-01"
|
|
428
|
-
|
|
491
|
+
result = runner.invoke(
|
|
492
|
+
cli.backtest_run,
|
|
493
|
+
["--strategy", str(strategy_file), "--start", "2023-01-01"],
|
|
494
|
+
)
|
|
429
495
|
|
|
430
496
|
# Should fail due to no data
|
|
431
497
|
assert result.exit_code != 0
|