quantcli 0.1.0__py3-none-any.whl
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/cli.py +996 -0
- quantcli/core/__init__.py +50 -0
- quantcli/core/backtest.py +534 -0
- quantcli/core/data.py +512 -0
- quantcli/core/factor.py +507 -0
- quantcli/datasources/__init__.py +83 -0
- quantcli/datasources/akshare.py +313 -0
- quantcli/datasources/baostock.py +478 -0
- quantcli/datasources/base.py +220 -0
- quantcli/datasources/cache.py +377 -0
- quantcli/datasources/mixed.py +174 -0
- quantcli/factors/__init__.py +29 -0
- quantcli/factors/base.py +163 -0
- quantcli/factors/compute.py +281 -0
- quantcli/factors/loader.py +293 -0
- quantcli/factors/pipeline.py +463 -0
- quantcli/factors/ranking.py +538 -0
- quantcli/factors/screening.py +138 -0
- quantcli/parser/__init__.py +70 -0
- quantcli/parser/constants.py +24 -0
- quantcli/parser/formula.py +397 -0
- quantcli/utils/__init__.py +163 -0
- quantcli/utils/logger.py +207 -0
- quantcli/utils/path.py +422 -0
- quantcli/utils/time.py +522 -0
- quantcli/utils/validate.py +491 -0
- quantcli-0.1.0.dist-info/METADATA +79 -0
- quantcli-0.1.0.dist-info/RECORD +31 -0
- quantcli-0.1.0.dist-info/WHEEL +5 -0
- quantcli-0.1.0.dist-info/entry_points.txt +2 -0
- quantcli-0.1.0.dist-info/top_level.txt +1 -0
quantcli/cli.py
ADDED
|
@@ -0,0 +1,996 @@
|
|
|
1
|
+
"""QuantCLI 命令行界面
|
|
2
|
+
|
|
3
|
+
提供数据获取、因子计算、回测和配置管理命令。
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
quantcli --help
|
|
7
|
+
quantcli data fetch --symbol 600519 --start 2020-01-01
|
|
8
|
+
quantcli factor run -n momentum -e "(close / delay(close, 20)) - 1"
|
|
9
|
+
quantcli backtest run -s dual_ma.py --start 2020-01-01
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import date, timedelta
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
import pandas as pd
|
|
18
|
+
from tabulate import tabulate
|
|
19
|
+
|
|
20
|
+
from .core import DataManager, DataConfig, FactorEngine, BacktestEngine, BacktestConfig, Strategy
|
|
21
|
+
from .factors import FactorDefinition
|
|
22
|
+
from .utils import parse_date, format_date, today, get_logger, TimeContext
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# =============================================================================
|
|
28
|
+
# 通用选项
|
|
29
|
+
# =============================================================================
|
|
30
|
+
|
|
31
|
+
def verbose_option(f):
|
|
32
|
+
"""Verbose output option"""
|
|
33
|
+
def callback(ctx, param, value):
|
|
34
|
+
ctx.ensure_object(dict)
|
|
35
|
+
ctx.obj["verbose"] = value
|
|
36
|
+
if value:
|
|
37
|
+
import logging
|
|
38
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
39
|
+
return click.option("-v", "--verbose", is_flag=True, help="Enable verbose output", callback=callback)(f)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def date_type(s: str) -> date:
|
|
43
|
+
"""Click type for date validation"""
|
|
44
|
+
try:
|
|
45
|
+
return parse_date(s)
|
|
46
|
+
except Exception:
|
|
47
|
+
raise click.BadParameter(f"Invalid date: {s}. Use YYYY-MM-DD format")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# =============================================================================
|
|
51
|
+
# 主命令
|
|
52
|
+
# =============================================================================
|
|
53
|
+
|
|
54
|
+
@click.group()
|
|
55
|
+
@click.version_option(version="0.1.0", prog_name="quantcli")
|
|
56
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output", default=False)
|
|
57
|
+
@click.pass_context
|
|
58
|
+
def quantcli(ctx, verbose):
|
|
59
|
+
"""QuantCLI - 量化因子挖掘与回测工具"""
|
|
60
|
+
ctx.ensure_object(dict)
|
|
61
|
+
ctx.obj["verbose"] = verbose
|
|
62
|
+
|
|
63
|
+
if verbose:
|
|
64
|
+
import logging
|
|
65
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
66
|
+
|
|
67
|
+
logger.info(f"QuantCLI v0.1.0 started")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# =============================================================================
|
|
71
|
+
# Data 命令
|
|
72
|
+
# =============================================================================
|
|
73
|
+
|
|
74
|
+
@quantcli.group()
|
|
75
|
+
def data():
|
|
76
|
+
"""数据获取与管理"""
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@data.command("fetch")
|
|
81
|
+
@click.argument("symbol", type=str)
|
|
82
|
+
@click.option("--start", type=date_type, required=True, help="Start date (YYYY-MM-DD)")
|
|
83
|
+
@click.option("--end", type=date_type, default=None, help="End date (YYYY-MM-DD)")
|
|
84
|
+
@click.option("--source", type=click.Choice(["akshare", "baostock", "mixed"]), default="mixed", help="Data source")
|
|
85
|
+
@click.option("--use-cache/--no-cache", default=True, help="Use cache")
|
|
86
|
+
@click.option("--output", type=click.Path(), default=None, help="Output file path")
|
|
87
|
+
@click.pass_context
|
|
88
|
+
def data_fetch(ctx, symbol, start, end, source, use_cache, output):
|
|
89
|
+
"""获取股票日线数据"""
|
|
90
|
+
if end is None:
|
|
91
|
+
end = today()
|
|
92
|
+
|
|
93
|
+
click.echo(f"Fetching {symbol} data from {format_date(start)} to {format_date(end)}...")
|
|
94
|
+
|
|
95
|
+
config = DataConfig(source=source)
|
|
96
|
+
dm = DataManager(config)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
df = dm.get_daily(symbol, start, end, use_cache=use_cache)
|
|
100
|
+
|
|
101
|
+
if df.empty:
|
|
102
|
+
click.echo(f"No data found for {symbol}")
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
click.echo(f"Retrieved {len(df)} rows")
|
|
106
|
+
|
|
107
|
+
# 数据统计
|
|
108
|
+
if "close" in df.columns:
|
|
109
|
+
click.echo(f"Close: {df['close'].min():.2f} - {df['close'].max():.2f}")
|
|
110
|
+
|
|
111
|
+
# 输出到文件
|
|
112
|
+
if output:
|
|
113
|
+
if output.endswith(".csv"):
|
|
114
|
+
df.to_csv(output, index=False)
|
|
115
|
+
elif output.endswith(".parquet"):
|
|
116
|
+
df.to_parquet(output, index=False)
|
|
117
|
+
else:
|
|
118
|
+
df.to_csv(output, index=False)
|
|
119
|
+
click.echo(f"Saved to {output}")
|
|
120
|
+
|
|
121
|
+
except Exception as e:
|
|
122
|
+
click.echo(f"Error: {e}", err=True)
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@data.group("cache")
|
|
127
|
+
@click.pass_context
|
|
128
|
+
def data_cache():
|
|
129
|
+
"""缓存管理"""
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@data_cache.command("ls")
|
|
134
|
+
@click.pass_context
|
|
135
|
+
def data_cache_ls(ctx):
|
|
136
|
+
"""列出缓存文件"""
|
|
137
|
+
config = DataConfig()
|
|
138
|
+
dm = DataManager(config)
|
|
139
|
+
sizes = dm.get_cache_size()
|
|
140
|
+
|
|
141
|
+
if not sizes or "_total" not in sizes:
|
|
142
|
+
click.echo("No cached data found")
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
rows = []
|
|
146
|
+
for k, v in sizes.items():
|
|
147
|
+
if k != "_total":
|
|
148
|
+
rows.append([k, v])
|
|
149
|
+
|
|
150
|
+
if rows:
|
|
151
|
+
click.echo("Cached files:")
|
|
152
|
+
click.echo(tabulate(rows, headers=["File", "Size"], tablefmt="simple"))
|
|
153
|
+
else:
|
|
154
|
+
click.echo("No cached files found")
|
|
155
|
+
|
|
156
|
+
click.echo(f"\nTotal: {sizes.get('_total', '0')}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@data_cache.command("clean")
|
|
160
|
+
@click.option("--older-than", type=int, default=None, help="Remove files older than N days")
|
|
161
|
+
@click.pass_context
|
|
162
|
+
def data_cache_clean(ctx, older_than):
|
|
163
|
+
"""清理缓存文件"""
|
|
164
|
+
config = DataConfig()
|
|
165
|
+
dm = DataManager(config)
|
|
166
|
+
|
|
167
|
+
count = dm.clear_cache(older_than=older_than)
|
|
168
|
+
click.echo(f"Cleaned {count} cache files")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@data.command("health")
|
|
172
|
+
@click.pass_context
|
|
173
|
+
def data_health(ctx):
|
|
174
|
+
"""检查数据源健康状态"""
|
|
175
|
+
config = DataConfig()
|
|
176
|
+
dm = DataManager(config)
|
|
177
|
+
health = dm.health_check()
|
|
178
|
+
|
|
179
|
+
click.echo("Data Manager Health:")
|
|
180
|
+
for k, v in health.items():
|
|
181
|
+
if k == "cache":
|
|
182
|
+
continue
|
|
183
|
+
click.echo(f" {k}: {v}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# =============================================================================
|
|
187
|
+
# Factor 命令
|
|
188
|
+
# =============================================================================
|
|
189
|
+
|
|
190
|
+
@quantcli.group()
|
|
191
|
+
def factor():
|
|
192
|
+
"""因子定义与计算"""
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@factor.command("run")
|
|
197
|
+
@click.option("--name", "-n", required=True, help="Factor name")
|
|
198
|
+
@click.option("--expr", "-e", required=True, help="Factor expression/formula")
|
|
199
|
+
@click.option("--symbol", default="600519", help="Stock symbol for testing")
|
|
200
|
+
@click.option("--start", type=date_type, default="2020-01-01", help="Start date")
|
|
201
|
+
@click.option("--end", type=date_type, default=None, help="End date")
|
|
202
|
+
@click.option("--output", type=click.Path(), default=None, help="Output file path")
|
|
203
|
+
@click.pass_context
|
|
204
|
+
def factor_run(ctx, name, expr, symbol, start, end, output):
|
|
205
|
+
"""运行因子计算"""
|
|
206
|
+
if end is None:
|
|
207
|
+
end = today()
|
|
208
|
+
|
|
209
|
+
click.echo(f"Computing factor '{name}'...")
|
|
210
|
+
|
|
211
|
+
# 创建引擎
|
|
212
|
+
config = DataConfig()
|
|
213
|
+
dm = DataManager(config)
|
|
214
|
+
engine = FactorEngine(dm)
|
|
215
|
+
|
|
216
|
+
# 注册因子
|
|
217
|
+
factor = FactorDefinition(name=name, type="technical", expr=expr)
|
|
218
|
+
engine.register(factor)
|
|
219
|
+
|
|
220
|
+
# 获取数据
|
|
221
|
+
df = dm.get_daily(symbol, start, end)
|
|
222
|
+
if df.empty:
|
|
223
|
+
click.echo(f"No data for {symbol}")
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
# 添加 returns 列
|
|
227
|
+
if "close" in df.columns:
|
|
228
|
+
df = df.copy()
|
|
229
|
+
df["returns"] = df["close"].pct_change()
|
|
230
|
+
|
|
231
|
+
# 计算因子
|
|
232
|
+
try:
|
|
233
|
+
result = engine.compute(name, df)
|
|
234
|
+
|
|
235
|
+
click.echo(f"Factor computed: {len(result)} values")
|
|
236
|
+
if not result.empty:
|
|
237
|
+
click.echo(f" Mean: {result.mean():.4f}")
|
|
238
|
+
click.echo(f" Std: {result.std():.4f}")
|
|
239
|
+
click.echo(f" Min: {result.min():.4f}")
|
|
240
|
+
click.echo(f" Max: {result.max():.4f}")
|
|
241
|
+
|
|
242
|
+
# 保存结果
|
|
243
|
+
if output:
|
|
244
|
+
result_df = result.reset_index()
|
|
245
|
+
result_df.columns = ["date", name]
|
|
246
|
+
result_df.to_csv(output, index=False)
|
|
247
|
+
click.echo(f"Saved to {output}")
|
|
248
|
+
|
|
249
|
+
except Exception as e:
|
|
250
|
+
click.echo(f"Error computing factor: {e}", err=True)
|
|
251
|
+
sys.exit(1)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@factor.command("eval")
|
|
255
|
+
@click.argument("name", type=str)
|
|
256
|
+
@click.option("--symbol", default="600519", help="Stock symbol")
|
|
257
|
+
@click.option("--start", type=date_type, default="2020-01-01", help="Start date")
|
|
258
|
+
@click.option("--end", type=date_type, default=None, help="End date")
|
|
259
|
+
@click.option("--method", type=click.Choice(["ic", "quantile", "full"]), default="ic", help="Evaluation method")
|
|
260
|
+
@click.pass_context
|
|
261
|
+
def factor_eval(ctx, name, symbol, start, end, method):
|
|
262
|
+
"""评估因子表现"""
|
|
263
|
+
if end is None:
|
|
264
|
+
end = today()
|
|
265
|
+
|
|
266
|
+
click.echo(f"Evaluating factor '{name}'...")
|
|
267
|
+
|
|
268
|
+
config = DataConfig()
|
|
269
|
+
dm = DataManager(config)
|
|
270
|
+
engine = FactorEngine(dm)
|
|
271
|
+
|
|
272
|
+
df = dm.get_daily(symbol, start, end)
|
|
273
|
+
if df.empty:
|
|
274
|
+
click.echo(f"No data for {symbol}")
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
# 添加 returns 列
|
|
278
|
+
df = df.copy()
|
|
279
|
+
df["returns"] = df["close"].pct_change()
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
if method == "ic":
|
|
283
|
+
result = engine.evaluate_ic(name, df)
|
|
284
|
+
click.echo(f"\nIC Analysis for {name}:")
|
|
285
|
+
if "ic_stats" in result:
|
|
286
|
+
stats = result["ic_stats"]
|
|
287
|
+
click.echo(f" IC Mean: {stats.get('ic_mean', 0):.4f}")
|
|
288
|
+
click.echo(f" IC Std: {stats.get('ic_std', 0):.4f}")
|
|
289
|
+
click.echo(f" IC IR: {stats.get('ic_ir', 0):.4f}")
|
|
290
|
+
|
|
291
|
+
elif method == "quantile":
|
|
292
|
+
result = engine.evaluate_quantiles(name, df)
|
|
293
|
+
click.echo(f"\nQuantile Analysis for {name}:")
|
|
294
|
+
click.echo(f" Groups: {result.get('groups', 10)}")
|
|
295
|
+
click.echo(f" Long-Short Return: {result.get('long_short_return', 0):.4f}")
|
|
296
|
+
|
|
297
|
+
elif method == "full":
|
|
298
|
+
result = engine.evaluate_full(name, df)
|
|
299
|
+
click.echo(f"\nFull Evaluation for {name}:")
|
|
300
|
+
click.echo(f" IC Mean: {result.ic_mean:.4f}")
|
|
301
|
+
click.echo(f" IC IR: {result.ic_ir:.4f}")
|
|
302
|
+
click.echo(f" Sample Size: {result.sample_size}")
|
|
303
|
+
|
|
304
|
+
except Exception as e:
|
|
305
|
+
click.echo(f"Error evaluating factor: {e}", err=True)
|
|
306
|
+
sys.exit(1)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@factor.command("list")
|
|
310
|
+
@click.pass_context
|
|
311
|
+
def factor_list(ctx):
|
|
312
|
+
"""列出已注册的因子"""
|
|
313
|
+
engine = FactorEngine()
|
|
314
|
+
factors = engine.registry.list_all()
|
|
315
|
+
|
|
316
|
+
if not factors:
|
|
317
|
+
click.echo("No factors registered")
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
click.echo("Registered factors:")
|
|
321
|
+
for name in factors:
|
|
322
|
+
factor = engine.registry.get(name)
|
|
323
|
+
if factor:
|
|
324
|
+
click.echo(f" - {name}: {factor.formula[:50]}...")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@factor.command("run-file")
|
|
328
|
+
@click.option("--file", "-f", required=True, type=click.Path(exists=True), help="Factor config YAML file")
|
|
329
|
+
@click.option("--symbol", default="600519", help="Stock symbol for testing")
|
|
330
|
+
@click.option("--start", type=date_type, default="2020-01-01", help="Start date")
|
|
331
|
+
@click.option("--end", type=date_type, default=None, help="End date")
|
|
332
|
+
@click.option("--output", type=click.Path(), default=None, help="Output file path")
|
|
333
|
+
@click.pass_context
|
|
334
|
+
def factor_run_file(ctx, file, symbol, start, end, output):
|
|
335
|
+
"""从 YAML 配置文件运行因子计算"""
|
|
336
|
+
from .factors import load_strategy, FactorComputer
|
|
337
|
+
|
|
338
|
+
if end is None:
|
|
339
|
+
end = today()
|
|
340
|
+
|
|
341
|
+
click.echo(f"Loading factor config from {file}...")
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
config = load_strategy(file)
|
|
345
|
+
click.echo(f"Config: {config.name} (v{config.version})")
|
|
346
|
+
|
|
347
|
+
# 创建数据管理器
|
|
348
|
+
config_data = DataConfig()
|
|
349
|
+
dm = DataManager(config_data)
|
|
350
|
+
|
|
351
|
+
# 获取数据
|
|
352
|
+
df = dm.get_daily(symbol, start, end)
|
|
353
|
+
if df.empty:
|
|
354
|
+
click.echo(f"No data for {symbol}")
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
click.echo(f"Data: {len(df)} rows")
|
|
358
|
+
|
|
359
|
+
# 获取权重配置中的因子
|
|
360
|
+
weights = config.ranking.get("weights", {})
|
|
361
|
+
from .factors.loader import load_all_factors
|
|
362
|
+
factors = load_all_factors(weights, file)
|
|
363
|
+
|
|
364
|
+
# 计算因子
|
|
365
|
+
computer = FactorComputer()
|
|
366
|
+
factor_data = computer.compute_all_factors(factors, {symbol: df}, {}, [symbol])
|
|
367
|
+
|
|
368
|
+
# 显示结果
|
|
369
|
+
click.echo("\nFactor Results:")
|
|
370
|
+
if factor_data:
|
|
371
|
+
for row in factor_data:
|
|
372
|
+
for name, value in row.items():
|
|
373
|
+
if name != "symbol":
|
|
374
|
+
click.echo(f" {name}: {value:.4f}" if isinstance(value, (int, float)) else f" {name}: {value}")
|
|
375
|
+
|
|
376
|
+
# 保存结果
|
|
377
|
+
if output and factor_data:
|
|
378
|
+
result_df = pd.DataFrame(factor_data)
|
|
379
|
+
result_df.to_csv(output, index=False)
|
|
380
|
+
click.echo(f"\nSaved to {output}")
|
|
381
|
+
|
|
382
|
+
except Exception as e:
|
|
383
|
+
click.echo(f"Error: {e}", err=True)
|
|
384
|
+
sys.exit(1)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@factor.command("score")
|
|
388
|
+
@click.option("--file", "-f", required=True, type=click.Path(exists=True), help="Factor config YAML file")
|
|
389
|
+
@click.option("--symbols", default=None, help="Stock symbols (comma-separated)")
|
|
390
|
+
@click.option("--start", type=date_type, default="2020-01-01", help="Start date")
|
|
391
|
+
@click.option("--end", type=date_type, default=None, help="End date")
|
|
392
|
+
@click.option("--top", type=int, default=None, help="Show top N results")
|
|
393
|
+
@click.option("--output", type=click.Path(), default=None, help="Output file path")
|
|
394
|
+
@click.pass_context
|
|
395
|
+
def factor_score(ctx, file, symbols, start, end, top, output):
|
|
396
|
+
"""根据 YAML 配置文件对股票进行评分选股"""
|
|
397
|
+
from .factors import load_strategy, FactorPipeline
|
|
398
|
+
|
|
399
|
+
if end is None:
|
|
400
|
+
end = today()
|
|
401
|
+
|
|
402
|
+
click.echo(f"Loading factor config from {file}...")
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
config = load_strategy(file)
|
|
406
|
+
click.echo(f"Config: {config.name} (v{config.version})")
|
|
407
|
+
|
|
408
|
+
# 创建数据管理器
|
|
409
|
+
config_data = DataConfig()
|
|
410
|
+
dm = DataManager(config_data)
|
|
411
|
+
|
|
412
|
+
# 解析股票列表
|
|
413
|
+
if symbols:
|
|
414
|
+
symbol_list = [s.strip() for s in symbols.split(",")]
|
|
415
|
+
else:
|
|
416
|
+
symbol_list = ["600519"]
|
|
417
|
+
|
|
418
|
+
click.echo(f"Fetching data for {len(symbol_list)} symbols...")
|
|
419
|
+
|
|
420
|
+
# 获取多只股票数据
|
|
421
|
+
stock_data = {}
|
|
422
|
+
for symbol in symbol_list:
|
|
423
|
+
df = dm.get_daily(symbol, start, end)
|
|
424
|
+
if not df.empty:
|
|
425
|
+
stock_data[symbol] = df
|
|
426
|
+
|
|
427
|
+
if not stock_data:
|
|
428
|
+
click.echo("No data found for any symbol")
|
|
429
|
+
return
|
|
430
|
+
|
|
431
|
+
click.echo(f"Got data for {len(stock_data)} symbols")
|
|
432
|
+
|
|
433
|
+
# 使用 Pipeline 执行评分
|
|
434
|
+
pipeline = FactorPipeline(file)
|
|
435
|
+
results = pipeline.run(symbol_list, end, stock_data, pd.DataFrame(), limit=top)
|
|
436
|
+
|
|
437
|
+
if results.empty:
|
|
438
|
+
click.echo("No results")
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
# 显示结果
|
|
442
|
+
click.echo("\n=== Scoring Results ===")
|
|
443
|
+
click.echo(results.to_string(index=False))
|
|
444
|
+
|
|
445
|
+
# 保存结果
|
|
446
|
+
if output:
|
|
447
|
+
results.to_csv(output, index=False)
|
|
448
|
+
click.echo(f"\nSaved to {output}")
|
|
449
|
+
|
|
450
|
+
except Exception as e:
|
|
451
|
+
click.echo(f"Error: {e}", err=True)
|
|
452
|
+
import traceback
|
|
453
|
+
if ctx.obj.get("verbose"):
|
|
454
|
+
traceback.print_exc()
|
|
455
|
+
sys.exit(1)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# =============================================================================
|
|
459
|
+
# Filter 命令
|
|
460
|
+
# =============================================================================
|
|
461
|
+
|
|
462
|
+
@quantcli.group()
|
|
463
|
+
def filter():
|
|
464
|
+
"""多阶段因子筛选"""
|
|
465
|
+
pass
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@filter.command("run")
|
|
469
|
+
@click.option("--file", "-f", required=True, type=click.Path(exists=True), help="Strategy config YAML file")
|
|
470
|
+
@click.option("--symbols", default=None, help="Stock symbols (comma-separated, default: all)")
|
|
471
|
+
@click.option("--start", type=date_type, default=None, help="Start date for price data (default: 60 days ago)")
|
|
472
|
+
@click.option("--end", type=date_type, default=None, help="End date for price data")
|
|
473
|
+
@click.option("--as-of", type=date_type, default=None, help="Time baseline date (for filtering as of this date)")
|
|
474
|
+
@click.option("--fundamental-date", type=date_type, default=None, help="Date for fundamental data")
|
|
475
|
+
@click.option("--top", type=int, default=None, help="Show top N results")
|
|
476
|
+
@click.option("--output", type=click.Path(), default=None, help="Output file path")
|
|
477
|
+
@click.option("--intraday/--no-intraday", default=True, help="Enable intraday data fetching")
|
|
478
|
+
@click.pass_context
|
|
479
|
+
def filter_run(ctx, file, symbols, start, end, as_of, fundamental_date, top, output, intraday):
|
|
480
|
+
"""运行多阶段因子筛选(基本面筛选 + 日线筛选 + 权重排序)"""
|
|
481
|
+
from .factors.pipeline import FactorPipeline
|
|
482
|
+
from .datasources import create_datasource
|
|
483
|
+
|
|
484
|
+
# 设置时间基线
|
|
485
|
+
if as_of is not None:
|
|
486
|
+
TimeContext.set_date(as_of)
|
|
487
|
+
click.echo(f"Time baseline set to: {format_date(as_of)}")
|
|
488
|
+
|
|
489
|
+
if end is None:
|
|
490
|
+
end = today()
|
|
491
|
+
if start is None:
|
|
492
|
+
start = end - timedelta(days=60) # 默认最近60天
|
|
493
|
+
if fundamental_date is None:
|
|
494
|
+
fundamental_date = end
|
|
495
|
+
|
|
496
|
+
click.echo(f"Loading strategy config from {file}...")
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
pipeline = FactorPipeline(file)
|
|
500
|
+
click.echo(f"Strategy: {pipeline.config.name} (v{pipeline.config.version})")
|
|
501
|
+
|
|
502
|
+
# 检查是否为多阶段配置
|
|
503
|
+
screening_config = pipeline.config.screening
|
|
504
|
+
is_multi_stage = bool(screening_config.get("fundamental_conditions") or
|
|
505
|
+
screening_config.get("daily_conditions"))
|
|
506
|
+
intraday_config = getattr(pipeline.config, 'intraday', {})
|
|
507
|
+
has_intraday = bool(intraday_config.get("weights", {}))
|
|
508
|
+
|
|
509
|
+
# 解析股票列表
|
|
510
|
+
if symbols:
|
|
511
|
+
symbol_list = [s.strip() for s in symbols.split(",")]
|
|
512
|
+
else:
|
|
513
|
+
# 获取全部股票
|
|
514
|
+
click.echo("Fetching all stock symbols...")
|
|
515
|
+
source = create_datasource("mixed")
|
|
516
|
+
stocks_df = source.get_stock_list()
|
|
517
|
+
symbol_list = stocks_df["symbol"].tolist()
|
|
518
|
+
click.echo(f"Found {len(symbol_list)} stocks")
|
|
519
|
+
|
|
520
|
+
if not symbol_list:
|
|
521
|
+
click.echo("No symbols to filter")
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
# ==================== 多阶段流程 ====================
|
|
525
|
+
if is_multi_stage and intraday and has_intraday:
|
|
526
|
+
# 完整多阶段流程:基本面 → 日线 → 分钟
|
|
527
|
+
click.echo("Running multi-stage filtering...")
|
|
528
|
+
|
|
529
|
+
# 阶段1: 基本面筛选
|
|
530
|
+
click.echo(f"\n=== Stage 1: Fundamental Screening ===")
|
|
531
|
+
click.echo(f"Fetching fundamental data for {len(symbol_list)} symbols...")
|
|
532
|
+
source = create_datasource("mixed")
|
|
533
|
+
fundamental_data = source.get_fundamental(symbol_list, fundamental_date)
|
|
534
|
+
click.echo(f"Got fundamental data for {len(fundamental_data)} symbols")
|
|
535
|
+
|
|
536
|
+
# 基本面条件筛选
|
|
537
|
+
fundamental_conditions = screening_config.get("fundamental_conditions", [])
|
|
538
|
+
candidates = pipeline.screening_only(symbol_list, fundamental_data) if fundamental_conditions else symbol_list
|
|
539
|
+
if fundamental_conditions:
|
|
540
|
+
click.echo(f"After fundamental screening: {len(candidates)}")
|
|
541
|
+
|
|
542
|
+
if not candidates:
|
|
543
|
+
click.echo("No candidates after fundamental screening")
|
|
544
|
+
return
|
|
545
|
+
|
|
546
|
+
# 阶段2: 日线筛选
|
|
547
|
+
click.echo(f"\n=== Stage 2: Daily Screening ===")
|
|
548
|
+
daily_conditions = screening_config.get("daily_conditions", [])
|
|
549
|
+
if daily_conditions:
|
|
550
|
+
# 只对候选获取日线数据
|
|
551
|
+
click.echo(f"Fetching daily data for {len(candidates)} candidates...")
|
|
552
|
+
dm = DataManager(DataConfig(source="mixed"))
|
|
553
|
+
|
|
554
|
+
price_data = {}
|
|
555
|
+
for symbol in candidates:
|
|
556
|
+
try:
|
|
557
|
+
df = dm.get_daily(symbol, start, end)
|
|
558
|
+
if not df.empty:
|
|
559
|
+
df = df.copy()
|
|
560
|
+
df["returns"] = df["close"].pct_change()
|
|
561
|
+
price_data[symbol] = df
|
|
562
|
+
except Exception as e:
|
|
563
|
+
logger.warning(f"Failed to get daily data for {symbol}: {e}")
|
|
564
|
+
continue
|
|
565
|
+
|
|
566
|
+
click.echo(f"Got daily data for {len(price_data)} symbols")
|
|
567
|
+
|
|
568
|
+
# 执行日线条件筛选
|
|
569
|
+
if price_data and daily_conditions:
|
|
570
|
+
# 收集价格数据
|
|
571
|
+
candidate_price_data = []
|
|
572
|
+
for symbol in candidates:
|
|
573
|
+
if symbol in price_data:
|
|
574
|
+
df = price_data[symbol].copy()
|
|
575
|
+
df["symbol"] = symbol
|
|
576
|
+
candidate_price_data.append(df)
|
|
577
|
+
|
|
578
|
+
if candidate_price_data:
|
|
579
|
+
price_df = pd.concat(candidate_price_data, ignore_index=True)
|
|
580
|
+
latest_date = price_df["date"].max()
|
|
581
|
+
latest_df = price_df[price_df["date"] == latest_date].copy()
|
|
582
|
+
|
|
583
|
+
passed = pipeline._evaluate_screening(daily_conditions, latest_df)
|
|
584
|
+
candidates = [
|
|
585
|
+
s for s in candidates
|
|
586
|
+
if s in latest_df["symbol"].values and
|
|
587
|
+
passed.get(latest_df[latest_df["symbol"] == s].index[0], True)
|
|
588
|
+
]
|
|
589
|
+
click.echo(f"After daily screening: {len(candidates)}")
|
|
590
|
+
else:
|
|
591
|
+
price_data = {}
|
|
592
|
+
|
|
593
|
+
if not candidates:
|
|
594
|
+
click.echo("No candidates after daily screening")
|
|
595
|
+
return
|
|
596
|
+
|
|
597
|
+
# 阶段3: 分钟级排名
|
|
598
|
+
click.echo(f"\n=== Stage 3: Intraday Ranking ===")
|
|
599
|
+
click.echo(f"Fetching intraday data for {len(candidates)} candidates...")
|
|
600
|
+
source = create_datasource("mixed")
|
|
601
|
+
|
|
602
|
+
intraday_data = {}
|
|
603
|
+
for symbol in candidates:
|
|
604
|
+
try:
|
|
605
|
+
df = source.get_intraday(symbol, start, end)
|
|
606
|
+
if not df.empty:
|
|
607
|
+
intraday_data[symbol] = df
|
|
608
|
+
except Exception as e:
|
|
609
|
+
logger.warning(f"Failed to get intraday data for {symbol}: {e}")
|
|
610
|
+
continue
|
|
611
|
+
|
|
612
|
+
click.echo(f"Got intraday data for {len(intraday_data)} symbols")
|
|
613
|
+
|
|
614
|
+
# 执行分钟级排名
|
|
615
|
+
results = pipeline.run_multi_stage(
|
|
616
|
+
symbols=candidates,
|
|
617
|
+
date=fundamental_date,
|
|
618
|
+
price_data=price_data,
|
|
619
|
+
intraday_data=intraday_data,
|
|
620
|
+
fundamental_data=fundamental_data,
|
|
621
|
+
limit=top
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
elif is_multi_stage:
|
|
625
|
+
# 简化的多阶段:基本面 → 日线
|
|
626
|
+
click.echo("Running simplified multi-stage filtering...")
|
|
627
|
+
|
|
628
|
+
# 阶段1: 基本面
|
|
629
|
+
click.echo(f"\n=== Stage 1: Fundamental Screening ===")
|
|
630
|
+
click.echo(f"Fetching fundamental data for {len(symbol_list)} symbols...")
|
|
631
|
+
source = create_datasource("mixed")
|
|
632
|
+
fundamental_data = source.get_fundamental(symbol_list, fundamental_date)
|
|
633
|
+
click.echo(f"Got fundamental data for {len(fundamental_data)} symbols")
|
|
634
|
+
|
|
635
|
+
candidates = pipeline.screening_only(symbol_list, fundamental_data)
|
|
636
|
+
click.echo(f"After fundamental screening: {len(candidates)}")
|
|
637
|
+
|
|
638
|
+
if not candidates:
|
|
639
|
+
click.echo("No candidates after fundamental screening")
|
|
640
|
+
return
|
|
641
|
+
|
|
642
|
+
# 阶段2: 日线
|
|
643
|
+
click.echo(f"\n=== Stage 2: Daily Screening ===")
|
|
644
|
+
click.echo(f"Fetching daily data for {len(candidates)} candidates...")
|
|
645
|
+
dm = DataManager(DataConfig(source="mixed"))
|
|
646
|
+
|
|
647
|
+
price_data = {}
|
|
648
|
+
for symbol in candidates:
|
|
649
|
+
try:
|
|
650
|
+
df = dm.get_daily(symbol, start, end)
|
|
651
|
+
if not df.empty:
|
|
652
|
+
df = df.copy()
|
|
653
|
+
df["returns"] = df["close"].pct_change()
|
|
654
|
+
price_data[symbol] = df
|
|
655
|
+
except Exception as e:
|
|
656
|
+
logger.warning(f"Failed to get daily data for {symbol}: {e}")
|
|
657
|
+
continue
|
|
658
|
+
|
|
659
|
+
click.echo(f"Got daily data for {len(price_data)} symbols")
|
|
660
|
+
|
|
661
|
+
# 获取分钟数据(用于混合 ranking)
|
|
662
|
+
click.echo(f"Fetching intraday data for {len(candidates)} candidates...")
|
|
663
|
+
intraday_data = {}
|
|
664
|
+
for symbol in candidates:
|
|
665
|
+
try:
|
|
666
|
+
df = source.get_intraday(symbol, start, end)
|
|
667
|
+
if not df.empty:
|
|
668
|
+
intraday_data[symbol] = df
|
|
669
|
+
except Exception as e:
|
|
670
|
+
logger.warning(f"Failed to get intraday data for {symbol}: {e}")
|
|
671
|
+
continue
|
|
672
|
+
|
|
673
|
+
click.echo(f"Got intraday data for {len(intraday_data)} symbols")
|
|
674
|
+
|
|
675
|
+
results = pipeline.run_multi_stage(
|
|
676
|
+
symbols=candidates,
|
|
677
|
+
date=fundamental_date,
|
|
678
|
+
price_data=price_data,
|
|
679
|
+
intraday_data=intraday_data,
|
|
680
|
+
fundamental_data=fundamental_data,
|
|
681
|
+
limit=top
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
else:
|
|
685
|
+
# 原有单阶段流程
|
|
686
|
+
click.echo("Running single-stage filtering...")
|
|
687
|
+
|
|
688
|
+
click.echo(f"Fetching fundamental data for {len(symbol_list)} symbols...")
|
|
689
|
+
source = create_datasource("mixed")
|
|
690
|
+
fundamental_data = source.get_fundamental(symbol_list, fundamental_date)
|
|
691
|
+
click.echo(f"Got fundamental data for {len(fundamental_data)} symbols")
|
|
692
|
+
|
|
693
|
+
candidates = pipeline.screening_only(symbol_list, fundamental_data)
|
|
694
|
+
click.echo(f"Candidates after screening: {len(candidates)}")
|
|
695
|
+
|
|
696
|
+
if not candidates:
|
|
697
|
+
click.echo("No candidates after screening")
|
|
698
|
+
return
|
|
699
|
+
|
|
700
|
+
click.echo(f"Fetching price data for {len(candidates)} candidates...")
|
|
701
|
+
dm = DataManager(DataConfig(source="mixed"))
|
|
702
|
+
|
|
703
|
+
price_data = {}
|
|
704
|
+
for symbol in candidates:
|
|
705
|
+
try:
|
|
706
|
+
df = dm.get_daily(symbol, start, end)
|
|
707
|
+
if not df.empty:
|
|
708
|
+
df = df.copy()
|
|
709
|
+
df["returns"] = df["close"].pct_change()
|
|
710
|
+
price_data[symbol] = df
|
|
711
|
+
except Exception as e:
|
|
712
|
+
logger.warning(f"Failed to get data for {symbol}: {e}")
|
|
713
|
+
continue
|
|
714
|
+
|
|
715
|
+
click.echo(f"Got price data for {len(price_data)} symbols")
|
|
716
|
+
|
|
717
|
+
results = pipeline.run(
|
|
718
|
+
symbols=candidates,
|
|
719
|
+
date=fundamental_date,
|
|
720
|
+
price_data=price_data,
|
|
721
|
+
fundamental_data=fundamental_data,
|
|
722
|
+
limit=top
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
if results.empty:
|
|
726
|
+
click.echo("No results")
|
|
727
|
+
return
|
|
728
|
+
|
|
729
|
+
# 显示结果
|
|
730
|
+
click.echo("\n=== Filter Results ===")
|
|
731
|
+
click.echo(results.to_string(index=False))
|
|
732
|
+
|
|
733
|
+
# 保存结果
|
|
734
|
+
if output:
|
|
735
|
+
results.to_csv(output, index=False)
|
|
736
|
+
click.echo(f"\nSaved to {output}")
|
|
737
|
+
|
|
738
|
+
except Exception as e:
|
|
739
|
+
click.echo(f"Error: {e}", err=True)
|
|
740
|
+
import traceback
|
|
741
|
+
if ctx.obj.get("verbose"):
|
|
742
|
+
traceback.print_exc()
|
|
743
|
+
sys.exit(1)
|
|
744
|
+
finally:
|
|
745
|
+
TimeContext.reset()
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
@filter.command("screening")
|
|
749
|
+
@click.option("--file", "-f", required=True, type=click.Path(exists=True), help="Strategy config YAML file")
|
|
750
|
+
@click.option("--symbols", default=None, help="Stock symbols (comma-separated)")
|
|
751
|
+
@click.option("--fundamental-date", type=date_type, default=None, help="Date for fundamental data")
|
|
752
|
+
@click.option("--output", type=click.Path(), default=None, help="Output file path")
|
|
753
|
+
@click.pass_context
|
|
754
|
+
def filter_screening(ctx, file, symbols, fundamental_date, output):
|
|
755
|
+
"""仅执行筛选阶段"""
|
|
756
|
+
from .factors.pipeline import FactorPipeline
|
|
757
|
+
from .datasources import create_datasource
|
|
758
|
+
|
|
759
|
+
if fundamental_date is None:
|
|
760
|
+
fundamental_date = today()
|
|
761
|
+
|
|
762
|
+
click.echo(f"Loading strategy config from {file}...")
|
|
763
|
+
|
|
764
|
+
try:
|
|
765
|
+
pipeline = FactorPipeline(file)
|
|
766
|
+
click.echo(f"Strategy: {pipeline.config.name}")
|
|
767
|
+
|
|
768
|
+
# 解析股票列表
|
|
769
|
+
if symbols:
|
|
770
|
+
symbol_list = [s.strip() for s in symbols.split(",")]
|
|
771
|
+
else:
|
|
772
|
+
source = create_datasource("mixed")
|
|
773
|
+
stocks_df = source.get_stock_list()
|
|
774
|
+
symbol_list = stocks_df["symbol"].tolist()
|
|
775
|
+
|
|
776
|
+
# 获取基本面数据
|
|
777
|
+
source = create_datasource("mixed")
|
|
778
|
+
fundamental_data = source.get_fundamental(symbol_list, fundamental_date)
|
|
779
|
+
|
|
780
|
+
# 执行筛选
|
|
781
|
+
candidates = pipeline.screening_only(symbol_list, fundamental_data)
|
|
782
|
+
|
|
783
|
+
click.echo(f"\nScreening Results: {len(candidates)} stocks passed")
|
|
784
|
+
if candidates:
|
|
785
|
+
click.echo(", ".join(candidates[:50]))
|
|
786
|
+
if len(candidates) > 50:
|
|
787
|
+
click.echo(f"... and {len(candidates) - 50} more")
|
|
788
|
+
|
|
789
|
+
# 保存结果
|
|
790
|
+
if output:
|
|
791
|
+
with open(output, "w") as f:
|
|
792
|
+
for s in candidates:
|
|
793
|
+
f.write(f"{s}\n")
|
|
794
|
+
click.echo(f"\nSaved to {output}")
|
|
795
|
+
|
|
796
|
+
except Exception as e:
|
|
797
|
+
click.echo(f"Error: {e}", err=True)
|
|
798
|
+
sys.exit(1)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
# =============================================================================
|
|
802
|
+
# Backtest 命令
|
|
803
|
+
# =============================================================================
|
|
804
|
+
|
|
805
|
+
@quantcli.group()
|
|
806
|
+
def backtest():
|
|
807
|
+
"""回测引擎"""
|
|
808
|
+
pass
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
@backtest.command("run")
|
|
812
|
+
@click.option("--strategy", "-s", required=True, type=str, help="Strategy file or class name")
|
|
813
|
+
@click.option("--symbol", default="600519", help="Stock symbol")
|
|
814
|
+
@click.option("--start", type=date_type, default="2020-01-01", help="Start date")
|
|
815
|
+
@click.option("--end", type=date_type, default=None, help="End date")
|
|
816
|
+
@click.option("--as-of", type=date_type, default=None, help="Time baseline date (for backtesting as of this date)")
|
|
817
|
+
@click.option("--capital", type=float, default=1000000.0, help="Initial capital")
|
|
818
|
+
@click.option("--fee", type=float, default=0.0003, help="Transaction fee rate")
|
|
819
|
+
@click.pass_context
|
|
820
|
+
def backtest_run(ctx, strategy, symbol, start, end, as_of, capital, fee):
|
|
821
|
+
"""运行回测"""
|
|
822
|
+
# 设置时间基线
|
|
823
|
+
if as_of is not None:
|
|
824
|
+
TimeContext.set_date(as_of)
|
|
825
|
+
click.echo(f"Time baseline set to: {format_date(as_of)}")
|
|
826
|
+
|
|
827
|
+
if end is None:
|
|
828
|
+
end = today()
|
|
829
|
+
|
|
830
|
+
click.echo(f"Running backtest for {symbol}...")
|
|
831
|
+
|
|
832
|
+
# 加载策略
|
|
833
|
+
try:
|
|
834
|
+
strategy_cls = load_strategy(strategy)
|
|
835
|
+
except Exception as e:
|
|
836
|
+
click.echo(f"Error loading strategy: {e}", err=True)
|
|
837
|
+
sys.exit(1)
|
|
838
|
+
|
|
839
|
+
# 获取数据
|
|
840
|
+
config = DataConfig()
|
|
841
|
+
dm = DataManager(config)
|
|
842
|
+
df = dm.get_daily(symbol, start, end)
|
|
843
|
+
|
|
844
|
+
if df.empty:
|
|
845
|
+
click.echo(f"No data for {symbol}")
|
|
846
|
+
return
|
|
847
|
+
|
|
848
|
+
click.echo(f"Data: {len(df)} rows")
|
|
849
|
+
|
|
850
|
+
# 配置回测
|
|
851
|
+
bt_config = BacktestConfig(
|
|
852
|
+
initial_capital=capital,
|
|
853
|
+
fee=fee,
|
|
854
|
+
start_date=start,
|
|
855
|
+
end_date=end
|
|
856
|
+
)
|
|
857
|
+
engine = BacktestEngine(bt_config)
|
|
858
|
+
engine.add_data(symbol, df)
|
|
859
|
+
|
|
860
|
+
# 运行回测
|
|
861
|
+
try:
|
|
862
|
+
result = engine.run(strategy_cls)
|
|
863
|
+
|
|
864
|
+
click.echo(f"\n=== Backtest Results ===")
|
|
865
|
+
click.echo(f"Total Return: {result.total_return:.2%}")
|
|
866
|
+
click.echo(f"Annual Return: {result.annual_return:.2%}")
|
|
867
|
+
click.echo(f"Max Drawdown: {result.max_drawdown:.2%}")
|
|
868
|
+
click.echo(f"Sharpe Ratio: {result.sharpe:.2f}")
|
|
869
|
+
click.echo(f"Sortino Ratio: {result.sortino:.2f}")
|
|
870
|
+
click.echo(f"Win Rate: {result.win_rate:.2%}")
|
|
871
|
+
click.echo(f"Total Trades: {result.total_trades}")
|
|
872
|
+
|
|
873
|
+
except Exception as e:
|
|
874
|
+
click.echo(f"Error running backtest: {e}", err=True)
|
|
875
|
+
sys.exit(1)
|
|
876
|
+
finally:
|
|
877
|
+
TimeContext.reset()
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
@backtest.command("list")
|
|
881
|
+
@click.pass_context
|
|
882
|
+
def backtest_list(ctx):
|
|
883
|
+
"""列出历史回测结果"""
|
|
884
|
+
click.echo("Historical backtests:")
|
|
885
|
+
click.echo("(Not implemented yet)")
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
# =============================================================================
|
|
889
|
+
# Config 命令
|
|
890
|
+
# =============================================================================
|
|
891
|
+
|
|
892
|
+
@quantcli.group()
|
|
893
|
+
def config():
|
|
894
|
+
"""配置管理"""
|
|
895
|
+
pass
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
@config.command("show")
|
|
899
|
+
@click.pass_context
|
|
900
|
+
def config_show(ctx):
|
|
901
|
+
"""显示当前配置"""
|
|
902
|
+
config = DataConfig()
|
|
903
|
+
click.echo("QuantCLI Configuration:")
|
|
904
|
+
click.echo(f" data.source: {config.source}")
|
|
905
|
+
click.echo(f" data.cache_dir: {config.cache_dir}")
|
|
906
|
+
click.echo(f" data.parallel: {config.parallel}")
|
|
907
|
+
click.echo(f" data.fillna: {config.fillna}")
|
|
908
|
+
click.echo(f" data.outlier_method: {config.outlier_method}")
|
|
909
|
+
click.echo(f" data.outlier_threshold: {config.outlier_threshold}")
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
@config.command("set")
|
|
913
|
+
@click.argument("key", type=str)
|
|
914
|
+
@click.argument("value", type=str)
|
|
915
|
+
@click.pass_context
|
|
916
|
+
def config_set(ctx, key, value):
|
|
917
|
+
"""设置配置项"""
|
|
918
|
+
click.echo(f"Setting {key} = {value}")
|
|
919
|
+
# TODO: 实现配置持久化
|
|
920
|
+
click.echo("(Configuration persistence not implemented yet)")
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
# =============================================================================
|
|
924
|
+
# 辅助函数
|
|
925
|
+
# =============================================================================
|
|
926
|
+
|
|
927
|
+
def load_strategy(strategy_spec: str):
|
|
928
|
+
"""加载策略类
|
|
929
|
+
|
|
930
|
+
Args:
|
|
931
|
+
strategy_spec: 策略文件路径或类名
|
|
932
|
+
|
|
933
|
+
Returns:
|
|
934
|
+
Strategy 类
|
|
935
|
+
"""
|
|
936
|
+
from pathlib import Path
|
|
937
|
+
|
|
938
|
+
# 尝试作为文件加载
|
|
939
|
+
strategy_path = Path(strategy_spec)
|
|
940
|
+
if strategy_path.exists() and strategy_path.suffix == ".py":
|
|
941
|
+
import importlib.util
|
|
942
|
+
spec = importlib.util.spec_from_file_location("strategy", strategy_path)
|
|
943
|
+
module = importlib.util.module_from_spec(spec)
|
|
944
|
+
spec.loader.exec_module(module)
|
|
945
|
+
|
|
946
|
+
# 查找 Strategy 类
|
|
947
|
+
for attr_name in dir(module):
|
|
948
|
+
attr = getattr(module, attr_name)
|
|
949
|
+
if isinstance(attr, type) and issubclass(attr, type) and attr.__name__ != "Strategy":
|
|
950
|
+
# 找到继承自 object 的策略类 (非 Strategy 基类)
|
|
951
|
+
if hasattr(attr, "__bases__") and Strategy in attr.__mro__[1:]:
|
|
952
|
+
return attr
|
|
953
|
+
|
|
954
|
+
raise ValueError(f"No Strategy class found in {strategy_spec}")
|
|
955
|
+
|
|
956
|
+
# 尝试作为内置策略名加载
|
|
957
|
+
built_in_strategies = {
|
|
958
|
+
"ma_cross": MaCrossStrategy,
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if strategy_spec in built_in_strategies:
|
|
962
|
+
return built_in_strategies[strategy_spec]
|
|
963
|
+
|
|
964
|
+
raise ValueError(f"Unknown strategy: {strategy_spec}")
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
class MaCrossStrategy(Strategy):
|
|
968
|
+
"""简单均线交叉策略 (内置示例)"""
|
|
969
|
+
|
|
970
|
+
name = "MA Cross"
|
|
971
|
+
params = {"fast": 5, "slow": 20}
|
|
972
|
+
|
|
973
|
+
def __init__(self):
|
|
974
|
+
super().__init__()
|
|
975
|
+
import backtrader as bt
|
|
976
|
+
self.ma_fast = bt.indicators.SMA(self.data.close, period=self.params.fast)
|
|
977
|
+
self.ma_slow = bt.indicators.SMA(self.data.close, period=self.params.slow)
|
|
978
|
+
|
|
979
|
+
def next(self):
|
|
980
|
+
if self.ma_fast[0] > self.ma_slow[0] and self.ma_fast[-1] <= self.ma_slow[-1]:
|
|
981
|
+
self.buy()
|
|
982
|
+
elif self.ma_fast[0] < self.ma_slow[0] and self.ma_fast[-1] >= self.ma_slow[-1]:
|
|
983
|
+
self.sell()
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
# =============================================================================
|
|
987
|
+
# 入口点
|
|
988
|
+
# =============================================================================
|
|
989
|
+
|
|
990
|
+
def main():
|
|
991
|
+
"""CLI 入口点"""
|
|
992
|
+
quantcli(obj={})
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
if __name__ == "__main__":
|
|
996
|
+
main()
|