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 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()