quantcli 0.1.4__tar.gz → 0.1.6__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.
Files changed (59) hide show
  1. {quantcli-0.1.4/quantcli.egg-info → quantcli-0.1.6}/PKG-INFO +1 -1
  2. {quantcli-0.1.4 → quantcli-0.1.6}/pyproject.toml +1 -1
  3. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/cli.py +25 -0
  4. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/baostock.py +13 -2
  5. quantcli-0.1.6/quantcli/datasources/fundamentals/__init__.py +8 -0
  6. quantcli-0.1.6/quantcli/datasources/fundamentals/provider.py +278 -0
  7. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/mysql.py +168 -0
  8. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/sync/base.py +27 -27
  9. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/factors/screening_executor.py +41 -35
  10. {quantcli-0.1.4 → quantcli-0.1.6/quantcli.egg-info}/PKG-INFO +1 -1
  11. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli.egg-info/SOURCES.txt +2 -0
  12. {quantcli-0.1.4 → quantcli-0.1.6}/LICENSE +0 -0
  13. {quantcli-0.1.4 → quantcli-0.1.6}/README.md +0 -0
  14. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/core/__init__.py +0 -0
  15. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/core/backtest.py +0 -0
  16. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/core/data.py +0 -0
  17. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/core/factor.py +0 -0
  18. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/__init__.py +0 -0
  19. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/akshare.py +0 -0
  20. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/base.py +0 -0
  21. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/cache.py +0 -0
  22. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/mixed.py +0 -0
  23. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/sync/__init__.py +0 -0
  24. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/sync/akshare.py +0 -0
  25. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/sync/gm.py +0 -0
  26. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/factors/__init__.py +0 -0
  27. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/factors/base.py +0 -0
  28. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/factors/compute.py +0 -0
  29. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/factors/loader.py +0 -0
  30. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/factors/pipeline.py +0 -0
  31. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/factors/ranking.py +0 -0
  32. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/factors/ranking_executor.py +0 -0
  33. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/factors/screening.py +0 -0
  34. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/models/bar.py +0 -0
  35. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/parser/__init__.py +0 -0
  36. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/parser/constants.py +0 -0
  37. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/parser/formula.py +0 -0
  38. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/utils/__init__.py +0 -0
  39. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/utils/logger.py +0 -0
  40. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/utils/path.py +0 -0
  41. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/utils/symbol_utils.py +0 -0
  42. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/utils/time.py +0 -0
  43. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/utils/validate.py +0 -0
  44. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli.egg-info/dependency_links.txt +0 -0
  45. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli.egg-info/entry_points.txt +0 -0
  46. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli.egg-info/requires.txt +0 -0
  47. {quantcli-0.1.4 → quantcli-0.1.6}/quantcli.egg-info/top_level.txt +0 -0
  48. {quantcli-0.1.4 → quantcli-0.1.6}/setup.cfg +0 -0
  49. {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_akshare_integration.py +0 -0
  50. {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_builtin_factors.py +0 -0
  51. {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_cli.py +0 -0
  52. {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_datasources.py +0 -0
  53. {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_factors.py +0 -0
  54. {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_gm_executors.py +0 -0
  55. {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_mixed_datasource.py +0 -0
  56. {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_multi_factor.py +0 -0
  57. {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_pipeline_integration.py +0 -0
  58. {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_symbol_utils.py +0 -0
  59. {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_time.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quantcli
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: 面向AI的多因子量化选股策略挖掘工具,AI Agent 友好 CLI
5
5
  Author-email: QuantCLI Team <quantcli@example.com>
6
6
  Project-URL: repository, https://github.com/wumu2013/quantcli
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "quantcli"
7
- version = "0.1.4"
7
+ version = "0.1.6"
8
8
  description = "面向AI的多因子量化选股策略挖掘工具,AI Agent 友好 CLI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -183,6 +183,31 @@ def data_health(ctx):
183
183
  click.echo(f" {k}: {v}")
184
184
 
185
185
 
186
+ @data.command("sync-fundamentals")
187
+ @click.option("--years", type=str, default="2022-2024", help="Year range (e.g., 2020-2024)")
188
+ @click.pass_context
189
+ def data_sync_fundamentals(ctx, years):
190
+ """同步基本面数据到 MySQL (从 akshare 批量获取)"""
191
+ from .datasources.mysql import MySQLDataSource
192
+
193
+ # 解析年份范围
194
+ try:
195
+ start_year, end_year = map(int, years.split("-"))
196
+ except ValueError:
197
+ click.echo(f"Invalid year format: {years}. Use format: 2020-2024")
198
+ return
199
+
200
+ click.echo(f"Syncing fundamentals from {start_year} to {end_year}...")
201
+
202
+ try:
203
+ with MySQLDataSource() as ds:
204
+ ds.sync_fundamentals(start_year, end_year)
205
+ click.echo("Sync completed successfully")
206
+ except Exception as e:
207
+ click.echo(f"Sync failed: {e}", err=True)
208
+ sys.exit(1)
209
+
210
+
186
211
  # =============================================================================
187
212
  # Factors 命令 - 列出内置因子
188
213
  # =============================================================================
@@ -64,8 +64,19 @@ class BaostockDataSource(DataSource):
64
64
  raise RuntimeError(f"Baostock 登录失败: {lg.error_msg}")
65
65
 
66
66
  def _to_bs_symbol(self, symbol: str) -> str:
67
- """转换为 baostock 格式"""
68
- symbol = symbol.replace(".SH", "").replace(".SZ", "")
67
+ """转换为 baostock 格式
68
+
69
+ 支持格式:
70
+ - "600519" -> "sh.600519"
71
+ - "SH600519" -> "sh.600519"
72
+ - "sh.600519" -> "sh.600519"
73
+ - "SZ000001" -> "sz.000001"
74
+ """
75
+ #转 统一大写,移除前缀
76
+ symbol = symbol.upper()
77
+ symbol = symbol.replace(".SH", "").replace(".SZ", "").replace("SH", "").replace("SZ", "")
78
+
79
+ # 判断交易所
69
80
  if symbol.startswith(("6", "5", "9")):
70
81
  return f"sh.{symbol}"
71
82
  return f"sz.{symbol}"
@@ -0,0 +1,8 @@
1
+ """基本面数据模块
2
+
3
+ 提供批量获取东方财富基本面数据的接口。
4
+ """
5
+
6
+ from .provider import FundamentalsProvider
7
+
8
+ __all__ = ["FundamentalsProvider"]
@@ -0,0 +1,278 @@
1
+ """基本面数据 Provider - 批量获取东方财富基本面数据"""
2
+
3
+ from datetime import date
4
+ from typing import Dict, List, Optional
5
+ import pandas as pd
6
+
7
+ import akshare as ak
8
+
9
+ from ...utils import get_logger
10
+ from ...utils.symbol_utils import to_mysql_from_akshare
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ class FundamentalsProvider:
16
+ """基本面数据提供者(批量获取)
17
+
18
+ 使用东方财富数据中心接口,一次获取指定日期的所有股票基本面数据。
19
+ 支持缓存,避免重复请求。
20
+
21
+ 使用示例:
22
+ >>> provider = FundamentalsProvider()
23
+ >>> df = provider.get_yjbb("20240630")
24
+ >>> reports = provider.get_all_reports(["20240331", "20240630"])
25
+ """
26
+
27
+ # 字段映射:原始字段 -> 标准字段名
28
+ YJBB_FIELDS = {
29
+ '股票代码': 'symbol',
30
+ '每股收益': 'eps',
31
+ '净利润': 'net_profits',
32
+ '营业总收入-营业总收入': 'revenue',
33
+ '营业总收入-同比增长': 'revenue_yoy',
34
+ '净利润-同比增长': 'netprofit_yoy',
35
+ '净利润-季度环比增长': 'netprofit_qoq',
36
+ '销售毛利率': 'grossprofitmargin',
37
+ '每股净资产': 'bps', # 每股净资产
38
+ '净资产收益率': 'roe',
39
+ '每股经营现金流量': 'ocf_per_share',
40
+ '最新公告日期': 'announce_date',
41
+ }
42
+
43
+ LRB_FIELDS = {
44
+ '股票代码': 'symbol',
45
+ '净利润': 'net_profits',
46
+ '净利润同比': 'netprofit_yoy',
47
+ '营业总收入': 'revenue',
48
+ '营业总收入同比': 'revenue_yoy',
49
+ }
50
+
51
+ ZCFZ_FIELDS = {
52
+ '股票代码': 'symbol',
53
+ }
54
+
55
+ XJLL_FIELDS = {
56
+ '股票代码': 'symbol',
57
+ '经营活动产生的现金流量净额': 'ocf',
58
+ }
59
+
60
+ def __init__(self, use_cache: bool = True):
61
+ """初始化 Provider
62
+
63
+ Args:
64
+ use_cache: 是否缓存请求结果,默认 True
65
+ """
66
+ self._use_cache = use_cache
67
+ self._cache: Dict[str, pd.DataFrame] = {}
68
+
69
+ def _fetch_and_cache(self, report_type: str, date: str) -> pd.DataFrame:
70
+ """获取数据并缓存"""
71
+ cache_key = f"{report_type}_{date}"
72
+
73
+ if self._use_cache and cache_key in self._cache:
74
+ logger.debug(f"缓存命中: {cache_key}")
75
+ return self._cache[cache_key].copy()
76
+
77
+ # 根据类型调用接口
78
+ fetchers = {
79
+ 'yjbb': ak.stock_yjbb_em,
80
+ 'lrb': ak.stock_lrb_em,
81
+ 'zcfz': ak.stock_zcfz_em,
82
+ 'xjll': ak.stock_xjll_em,
83
+ }
84
+
85
+ fetcher = fetchers.get(report_type)
86
+ if not fetcher:
87
+ raise ValueError(f"未知报表类型: {report_type}")
88
+
89
+ try:
90
+ df = fetcher(date=date)
91
+ if df.empty:
92
+ logger.warning(f"无数据: {report_type} {date}")
93
+ return pd.DataFrame()
94
+
95
+ # 转换代码格式
96
+ df['symbol'] = df['股票代码'].apply(to_mysql_from_akshare)
97
+
98
+ # 缓存
99
+ if self._use_cache:
100
+ self._cache[cache_key] = df.copy()
101
+
102
+ return df
103
+ except Exception as e:
104
+ logger.error(f"获取 {report_type} {date} 失败: {e}")
105
+ return pd.DataFrame()
106
+
107
+ def get_yjbb(self, date: str) -> pd.DataFrame:
108
+ """获取业绩报表数据
109
+
110
+ Args:
111
+ date: 财报日期,如 "20240630"
112
+
113
+ Returns:
114
+ 包含所有股票的业绩报表数据
115
+ """
116
+ df = self._fetch_and_cache('yjbb', date)
117
+ if df.empty:
118
+ return pd.DataFrame(columns=['symbol', 'eps', 'net_profits', 'revenue',
119
+ 'revenue_yoy', 'netprofit_yoy', 'grossprofitmargin',
120
+ 'bps', 'roe'])
121
+
122
+ # 原始列名 -> 重命名后列名(注意:symbol 已由 _fetch_and_cache 转换)
123
+ rename_map = {
124
+ '每股收益': 'eps',
125
+ '净利润-净利润': 'net_profits',
126
+ '营业总收入-营业总收入': 'revenue',
127
+ '营业总收入-同比增长': 'revenue_yoy',
128
+ '净利润-同比增长': 'netprofit_yoy',
129
+ '净利润-季度环比增长': 'netprofit_qoq',
130
+ '销售毛利率': 'grossprofitmargin',
131
+ '每股净资产': 'bps',
132
+ '净资产收益率': 'roe',
133
+ '每股经营现金流量': 'ocf_per_share',
134
+ '最新公告日期': 'announce_date',
135
+ }
136
+
137
+ # symbol 已由 _fetch_and_cache 转换好,只选择需要的列
138
+ columns = ['symbol'] + [k for k in rename_map.keys() if k in df.columns]
139
+ result = df[columns].rename(columns=rename_map)
140
+
141
+ # 将 NaN 替换为 None(用于 MySQL)
142
+ import numpy as np
143
+ result = result.replace({np.nan: None})
144
+ return result
145
+
146
+ def get_lrb(self, date: str) -> pd.DataFrame:
147
+ """获取利润表数据
148
+
149
+ Args:
150
+ date: 财报日期,如 "20240630"
151
+
152
+ Returns:
153
+ 包含所有股票的利润表数据
154
+ """
155
+ df = self._fetch_and_cache('lrb', date)
156
+ if df.empty:
157
+ return pd.DataFrame(columns=['symbol', 'net_profits', 'revenue'])
158
+
159
+ columns = ['symbol', 'net_profits', 'revenue']
160
+ available = [c for c in columns if c in df.columns]
161
+ return df[available].copy()
162
+
163
+ def get_zcfz(self, date: str) -> pd.DataFrame:
164
+ """获取资产负债表数据"""
165
+ df = self._fetch_and_cache('zcfz', date)
166
+ if df.empty:
167
+ return pd.DataFrame(columns=['symbol'])
168
+ return df[['symbol']].copy()
169
+
170
+ def get_xjll(self, date: str) -> pd.DataFrame:
171
+ """获取现金流量表数据"""
172
+ df = self._fetch_and_cache('xjll', date)
173
+ if df.empty:
174
+ return pd.DataFrame(columns=['symbol', 'ocf'])
175
+ columns = ['symbol', 'ocf']
176
+ available = [c for c in columns if c in df.columns]
177
+ return df[available].copy()
178
+
179
+ def get_all_reports(self, dates: List[str]) -> Dict[str, Dict[str, pd.DataFrame]]:
180
+ """批量获取多个日期的所有报表数据
181
+
182
+ Args:
183
+ dates: 财报日期列表,如 ["20240331", "20240630"]
184
+
185
+ Returns:
186
+ 嵌套字典: {日期: {报表类型: DataFrame}}
187
+ """
188
+ result = {}
189
+ for d in dates:
190
+ result[d] = {
191
+ 'yjbb': self.get_yjbb(d),
192
+ 'lrb': self.get_lrb(d),
193
+ 'zcfz': self.get_zcfz(d),
194
+ 'xjll': self.get_xjll(d),
195
+ }
196
+ return result
197
+
198
+ def get_merged_fundamentals(self, dates: List[str]) -> pd.DataFrame:
199
+ """获取合并后的基本面数据
200
+
201
+ 将多个日期的报表数据合并,按股票代码和报告日期对齐。
202
+
203
+ Args:
204
+ dates: 财报日期列表
205
+
206
+ Returns:
207
+ 合并后的基本面数据 DataFrame
208
+ """
209
+ all_data = []
210
+
211
+ for d in dates:
212
+ # 只获取业绩报表,包含主要财务指标
213
+ yjbb = self.get_yjbb(d)
214
+
215
+ if yjbb.empty:
216
+ continue
217
+
218
+ # 重命名 YJBB 中的关键字段,匹配 MySQL 表结构
219
+ rename_map = {
220
+ '净资产收益率': 'roe',
221
+ '销售毛利率': 'grossprofitmargin',
222
+ }
223
+ merged = yjbb.copy()
224
+ for old_name, new_name in rename_map.items():
225
+ if old_name in merged.columns:
226
+ merged = merged.rename(columns={old_name: new_name})
227
+
228
+ merged['report_date'] = d
229
+ all_data.append(merged)
230
+
231
+ if not all_data:
232
+ return pd.DataFrame()
233
+
234
+ return pd.concat(all_data, ignore_index=True)
235
+
236
+ @staticmethod
237
+ def generate_report_dates(start_year: int, end_year: int) -> List[str]:
238
+ """生成财报日期列表
239
+
240
+ Args:
241
+ start_year: 开始年份
242
+ end_year: 结束年份
243
+
244
+ Returns:
245
+ 日期列表,如 ["20200331", "20200630", ...]
246
+ """
247
+ dates = []
248
+ for year in range(start_year, end_year + 1):
249
+ # 季度财报日期
250
+ dates.extend([
251
+ f"{year}0331", # 一季报
252
+ f"{year}0630", # 中报
253
+ f"{year}0930", # 三季报
254
+ f"{year}1231", # 年报
255
+ ])
256
+ return dates
257
+
258
+ def clear_cache(self, date: str = None):
259
+ """清除缓存
260
+
261
+ Args:
262
+ date: 指定日期则清除该日期缓存,默认清除全部
263
+ """
264
+ if date:
265
+ keys_to_remove = [k for k in self._cache if k.endswith(f"_{date}")]
266
+ for k in keys_to_remove:
267
+ del self._cache[k]
268
+ logger.info(f"已清除缓存: {date}")
269
+ else:
270
+ self._cache.clear()
271
+ logger.info("已清除全部缓存")
272
+
273
+ def get_cache_info(self) -> Dict[str, int]:
274
+ """获取缓存信息"""
275
+ return {
276
+ "cached_dates": len(self._cache),
277
+ "cache_keys": list(self._cache.keys())[:10], # 只返回前10个
278
+ }
@@ -722,6 +722,174 @@ class MySQLDataSource(DataSource):
722
722
 
723
723
  logger.info(f"Synced {len(trading_days)} trading days")
724
724
 
725
+ def sync_fundamentals(
726
+ self,
727
+ start_year: int = 2020,
728
+ end_year: int = 2024
729
+ ):
730
+ """从 akshare 同步基本面数据到 MySQL(批量获取)
731
+
732
+ 使用东方财富数据中心接口,一次获取指定日期的所有股票基本面数据。
733
+ 计算 PE = avg_close / eps,PB = avg_close / bps
734
+
735
+ Args:
736
+ start_year: 开始年份
737
+ end_year: 结束年份
738
+ """
739
+ from .fundamentals import FundamentalsProvider
740
+
741
+ # 生成财报日期列表
742
+ dates = FundamentalsProvider.generate_report_dates(start_year, end_year)
743
+ logger.info(f"准备同步 {start_year}-{end_year} 年财报,共 {len(dates)} 个报告期")
744
+
745
+ # 使用 Provider 批量获取数据
746
+ provider = FundamentalsProvider(use_cache=True)
747
+ conn = self._get_connection()
748
+
749
+ total_inserted = 0
750
+ total_updated = 0
751
+
752
+ for report_date in dates:
753
+ logger.info(f"正在处理 {report_date}...")
754
+
755
+ # 获取合并后的基本面数据
756
+ df = provider.get_merged_fundamentals([report_date])
757
+
758
+ if df.empty:
759
+ logger.warning(f"{report_date} 无数据")
760
+ continue
761
+
762
+ logger.info(f"获取 {report_date} 数据 {len(df)} 条,计算 PE/PB...")
763
+
764
+ # 计算季度日均收盘价
765
+ # 财报截止日所在的季度:Q1(3月), Q2(6月), Q3(9月), Q4(12月)
766
+ month = int(report_date[4:6])
767
+ if month == 3:
768
+ quarter_start = f"{report_date[:4]}-01-01"
769
+ quarter_end = report_date
770
+ elif month == 6:
771
+ quarter_start = f"{report_date[:4]}-04-01"
772
+ quarter_end = report_date
773
+ elif month == 9:
774
+ quarter_start = f"{report_date[:4]}-07-01"
775
+ quarter_end = report_date
776
+ else: # 12月
777
+ quarter_start = f"{report_date[:4]}-10-01"
778
+ quarter_end = report_date
779
+
780
+ # 获取季度日均收盘价
781
+ avg_prices = self._get_quarter_avg_close(df['symbol'].tolist(), quarter_start, quarter_end)
782
+
783
+ # 插入数据库
784
+ with conn.cursor() as cursor:
785
+ for _, row in df.iterrows():
786
+ symbol = row.get('symbol')
787
+ if not symbol:
788
+ continue
789
+
790
+ eps = row.get('eps')
791
+ bps = row.get('bps')
792
+ avg_close = avg_prices.get(symbol)
793
+
794
+ # 转换为 float 避免 Decimal 运算问题
795
+ eps_val = float(eps) if eps else None
796
+ bps_val = float(bps) if bps else None
797
+ avg_close_val = float(avg_close) if avg_close else None
798
+
799
+ # 计算 PE 和 PB
800
+ pe_ttm = None
801
+ pb = None
802
+ if avg_close_val and eps_val and eps_val != 0:
803
+ pe_ttm = round(avg_close_val / eps_val, 2)
804
+ if avg_close_val and bps_val and bps_val != 0:
805
+ pb = round(avg_close_val / bps_val, 2)
806
+
807
+ try:
808
+ cursor.execute(f"""
809
+ INSERT INTO {self._table('fundamental_data')}
810
+ (symbol, report_date, eps, bps, net_profits, revenue, netprofit_yoy, roe, grossprofitmargin, pe_ttm, pb)
811
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
812
+ ON DUPLICATE KEY UPDATE
813
+ eps = COALESCE(VALUES(eps), eps),
814
+ bps = COALESCE(VALUES(bps), bps),
815
+ net_profits = COALESCE(VALUES(net_profits), net_profits),
816
+ revenue = COALESCE(VALUES(revenue), revenue),
817
+ netprofit_yoy = COALESCE(VALUES(netprofit_yoy), netprofit_yoy),
818
+ roe = COALESCE(VALUES(roe), roe),
819
+ grossprofitmargin = COALESCE(VALUES(grossprofitmargin), grossprofitmargin),
820
+ pe_ttm = COALESCE(VALUES(pe_ttm), pe_ttm),
821
+ pb = COALESCE(VALUES(pb), pb)
822
+ """, (
823
+ symbol,
824
+ report_date,
825
+ eps,
826
+ bps,
827
+ row.get('net_profits'),
828
+ row.get('revenue'),
829
+ row.get('netprofit_yoy'),
830
+ row.get('roe'),
831
+ row.get('grossprofitmargin'),
832
+ pe_ttm,
833
+ pb,
834
+ ))
835
+ total_inserted += 1
836
+ except Exception as e:
837
+ logger.warning(f"插入失败 {symbol}: {e}")
838
+
839
+ logger.info(f"{report_date} 完成")
840
+
841
+ def _get_quarter_avg_close(self, symbols: List[str], start_date: str, end_date: str) -> Dict[str, float]:
842
+ """计算股票在指定期间的日均收盘价
843
+
844
+ Args:
845
+ symbols: 股票代码列表
846
+ start_date: 开始日期
847
+ end_date: 结束日期
848
+
849
+ Returns:
850
+ {symbol: avg_close}
851
+ """
852
+ if not symbols:
853
+ return {}
854
+
855
+ conn = self._get_connection()
856
+ placeholders = ",".join(["%s"] * len(symbols))
857
+
858
+ sql = f"""
859
+ SELECT symbol, AVG(close) as avg_close
860
+ FROM {self._table('daily_prices')}
861
+ WHERE symbol IN ({placeholders})
862
+ AND trade_date BETWEEN %s AND %s
863
+ GROUP BY symbol
864
+ """
865
+
866
+ try:
867
+ with conn.cursor() as cursor:
868
+ cursor.execute(sql, tuple(symbols) + (start_date, end_date))
869
+ rows = cursor.fetchall()
870
+ return {row['symbol']: round(row['avg_close'], 2) for row in rows}
871
+ except Exception as e:
872
+ logger.warning(f"计算季度日均收盘价失败: {e}")
873
+ return {}
874
+
875
+ logger.info(f"基本面同步完成: 共插入/更新 {total_inserted} 条记录")
876
+
877
+ def sync_fundamentals_from_baostock(
878
+ self,
879
+ symbols: List[str] = None,
880
+ start_year: int = 2020,
881
+ end_year: int = 2024
882
+ ):
883
+ """从 baostock 同步基本面数据到 MySQL(已废弃,使用 sync_fundamentals)
884
+
885
+ Args:
886
+ symbols: 股票列表(默认同步所有)
887
+ start_year: 开始年份
888
+ end_year: 结束年份
889
+ """
890
+ logger.warning("sync_fundamentals_from_baostock 已废弃,请使用 sync_fundamentals")
891
+ self.sync_fundamentals(start_year, end_year)
892
+
725
893
  # ==================== 辅助方法 ====================
726
894
 
727
895
  def health_check(self) -> Dict[str, Any]:
@@ -150,24 +150,24 @@ class DataSync(ABC):
150
150
  def create_sync(
151
151
  source: str,
152
152
  token: str = None,
153
- mysql_host: str = None,
154
- mysql_port: int = None,
155
- mysql_user: str = None,
156
- mysql_password: str = None,
157
- mysql_database: str = None,
158
- mysql_table_prefix: str = None,
153
+ host: str = None,
154
+ port: int = None,
155
+ user: str = None,
156
+ password: str = None,
157
+ database: str = None,
158
+ table_prefix: str = None,
159
159
  ) -> "DataSync":
160
160
  """创建同步器
161
161
 
162
162
  Args:
163
163
  source: 数据源名称 ("gm", "akshare", "baostock")
164
164
  token: API token (仅 gm 需要)
165
- mysql_host: MySQL 主机地址
166
- mysql_port: MySQL 端口
167
- mysql_user: MySQL 用户名
168
- mysql_password: MySQL 密码
169
- mysql_database: MySQL 数据库名
170
- mysql_table_prefix: 表前缀
165
+ host: MySQL 主机地址
166
+ port: MySQL 端口
167
+ user: MySQL 用户名
168
+ password: MySQL 密码
169
+ database: MySQL 数据库名
170
+ table_prefix: 表前缀
171
171
 
172
172
  Returns:
173
173
  DataSync 实例
@@ -184,20 +184,20 @@ def create_sync(
184
184
  >>> sync = create_sync(
185
185
  ... "gm",
186
186
  ... token="your_token",
187
- ... mysql_host="192.168.1.100",
188
- ... mysql_port=3307,
189
- ... mysql_user="quant",
190
- ... mysql_password="secret",
191
- ... mysql_database="quantdb",
192
- ... mysql_table_prefix="test_"
187
+ ... host="192.168.1.100",
188
+ ... port=3307,
189
+ ... user="quant",
190
+ ... password="secret",
191
+ ... database="quantdb",
192
+ ... table_prefix="test_"
193
193
  ... )
194
194
 
195
195
  >>> # 使用 akshare 同步到自定义数据库
196
196
  >>> sync = create_sync(
197
197
  ... "akshare",
198
- ... mysql_host="localhost",
199
- ... mysql_database="mydata",
200
- ... mysql_table_prefix="prod_"
198
+ ... host="localhost",
199
+ ... database="mydata",
200
+ ... table_prefix="prod_"
201
201
  ... )
202
202
  """
203
203
  from ..mysql import MySQLDataSource
@@ -206,12 +206,12 @@ def create_sync(
206
206
 
207
207
  # 创建 MySQL 数据源(传入连接参数)
208
208
  mysql = MySQLDataSource(
209
- host=mysql_host,
210
- port=mysql_port,
211
- user=mysql_user,
212
- password=mysql_password,
213
- database=mysql_database,
214
- table_prefix=mysql_table_prefix,
209
+ host=host,
210
+ port=port,
211
+ user=user,
212
+ password=password,
213
+ database=database,
214
+ table_prefix=table_prefix,
215
215
  )
216
216
 
217
217
  if source == "gm":
@@ -56,7 +56,8 @@ class ScreeningExecutor:
56
56
  def run(
57
57
  self,
58
58
  symbols: Optional[List[str]] = None,
59
- lookback_days: int = 30
59
+ lookback_days: int = 30,
60
+ as_of_date: Optional[date] = None,
60
61
  ) -> List[str]:
61
62
  """执行 screening
62
63
 
@@ -65,11 +66,15 @@ class ScreeningExecutor:
65
66
  Args:
66
67
  symbols: 初始股票列表(None 则从 stock_list 获取全部)
67
68
  lookback_days: 日线数据回溯天数
69
+ as_of_date: 指定日期(回测时使用,None 则使用 MySQL 最新日期)
68
70
 
69
71
  Returns:
70
72
  筛选后的股票代码列表
71
73
  """
72
- end_date = self._get_last_trading_day()
74
+ if as_of_date:
75
+ end_date = as_of_date
76
+ else:
77
+ end_date = self._get_last_trading_day()
73
78
 
74
79
  # 1. 获取股票列表
75
80
  if symbols is None:
@@ -105,18 +110,21 @@ class ScreeningExecutor:
105
110
  return candidates
106
111
 
107
112
  def run_fundamental_only(
108
- self,
109
- symbols: Optional[List[str]] = None
113
+ self, symbols: Optional[List[str]] = None, as_of_date: Optional[date] = None
110
114
  ) -> List[str]:
111
115
  """仅执行基本面筛选
112
116
 
113
117
  Args:
114
118
  symbols: 初始股票列表
119
+ as_of_date: 指定日期(回测时使用,None 则使用 MySQL 最新日期)
115
120
 
116
121
  Returns:
117
122
  满足基本面条件的股票列表
118
123
  """
119
- end_date = self._get_last_trading_day()
124
+ if as_of_date:
125
+ end_date = as_of_date
126
+ else:
127
+ end_date = self._get_last_trading_day()
120
128
 
121
129
  if symbols is None:
122
130
  symbols = self._get_stock_list()
@@ -126,18 +134,23 @@ class ScreeningExecutor:
126
134
  def run_daily_only(
127
135
  self,
128
136
  symbols: List[str],
129
- lookback_days: int = 30
137
+ lookback_days: int = 30,
138
+ as_of_date: Optional[date] = None,
130
139
  ) -> List[str]:
131
140
  """仅执行日线筛选
132
141
 
133
142
  Args:
134
143
  symbols: 股票列表
135
144
  lookback_days: 回溯天数
145
+ as_of_date: 指定日期(回测时使用,None 则使用 MySQL 最新日期)
136
146
 
137
147
  Returns:
138
148
  满足日线条件的股票列表
139
149
  """
140
- end_date = self._get_last_trading_day()
150
+ if as_of_date:
151
+ end_date = as_of_date
152
+ else:
153
+ end_date = self._get_last_trading_day()
141
154
  return self._daily_screening(symbols, end_date, lookback_days)
142
155
 
143
156
  def _get_stock_list(self) -> List[str]:
@@ -146,7 +159,7 @@ class ScreeningExecutor:
146
159
  df = self.mysql.get_stock_list()
147
160
  if df.empty:
148
161
  return []
149
- return df['symbol'].tolist()
162
+ return df["symbol"].tolist()
150
163
  except Exception as e:
151
164
  logger.error(f"获取股票列表失败: {e}")
152
165
  return []
@@ -163,11 +176,7 @@ class ScreeningExecutor:
163
176
  # 降级:返回昨天
164
177
  return date.today() - timedelta(1)
165
178
 
166
- def _fundamental_screening(
167
- self,
168
- symbols: List[str],
169
- end_date: date
170
- ) -> List[str]:
179
+ def _fundamental_screening(self, symbols: List[str], end_date: date) -> List[str]:
171
180
  """基本面筛选"""
172
181
  conditions = self.config.screening.get("fundamental_conditions", [])
173
182
 
@@ -187,17 +196,12 @@ class ScreeningExecutor:
187
196
 
188
197
  # 应用别名并筛选
189
198
  processed_df = self.evaluator.apply_aliases(fundamental)
190
- candidates = self.evaluator.filter_by_conditions(
191
- conditions, processed_df
192
- )
199
+ candidates = self.evaluator.filter_by_conditions(conditions, processed_df)
193
200
 
194
201
  return candidates
195
202
 
196
203
  def _daily_screening(
197
- self,
198
- symbols: List[str],
199
- end_date: date,
200
- lookback_days: int
204
+ self, symbols: List[str], end_date: date, lookback_days: int
201
205
  ) -> List[str]:
202
206
  """日线筛选"""
203
207
  conditions = self.config.screening.get("daily_conditions", [])
@@ -209,9 +213,7 @@ class ScreeningExecutor:
209
213
  try:
210
214
  start_date = end_date - timedelta(lookback_days)
211
215
  price_data = self.mysql.get_multi_daily(
212
- symbols=symbols,
213
- start_date=start_date,
214
- end_date=end_date
216
+ symbols=symbols, start_date=start_date, end_date=end_date
215
217
  )
216
218
  except Exception as e:
217
219
  logger.error(f"获取日线数据失败: {e}")
@@ -227,7 +229,7 @@ class ScreeningExecutor:
227
229
  if df.empty:
228
230
  continue
229
231
  df = df.copy()
230
- df['symbol'] = symbol
232
+ df["symbol"] = symbol
231
233
  dfs.append(df)
232
234
 
233
235
  if not dfs:
@@ -236,15 +238,15 @@ class ScreeningExecutor:
236
238
  all_data = pd.concat(dfs, ignore_index=True)
237
239
 
238
240
  # 获取最新一天的数据
239
- latest_date = all_data['date'].max()
240
- latest_data = all_data[all_data['date'] == latest_date].copy()
241
+ latest_date = all_data["date"].max()
242
+ latest_data = all_data[all_data["date"] == latest_date].copy()
241
243
 
242
244
  if latest_data.empty:
243
245
  return symbols
244
246
 
245
247
  # 提取需要的因子
246
248
  factor_names = self.computer.get_factor_names_from_conditions(
247
- conditions, {'close', 'open', 'high', 'low', 'volume', 'date', 'symbol'}
249
+ conditions, {"close", "open", "high", "low", "volume", "date", "symbol"}
248
250
  )
249
251
 
250
252
  if factor_names:
@@ -255,7 +257,7 @@ class ScreeningExecutor:
255
257
 
256
258
  # 将因子值写入 latest_data
257
259
  for symbol, values in computed_factors.items():
258
- mask = latest_data['symbol'] == symbol
260
+ mask = latest_data["symbol"] == symbol
259
261
  for name, value in values.items():
260
262
  latest_data.loc[mask, name] = value
261
263
 
@@ -264,9 +266,10 @@ class ScreeningExecutor:
264
266
 
265
267
  # 收集通过的股票
266
268
  candidates = [
267
- s for s in symbols
268
- if s in latest_data['symbol'].values and
269
- passed.get(latest_data[latest_data['symbol'] == s].index[0], True)
269
+ s
270
+ for s in symbols
271
+ if s in latest_data["symbol"].values
272
+ and passed.get(latest_data[latest_data["symbol"] == s].index[0], True)
270
273
  ]
271
274
 
272
275
  return candidates
@@ -275,7 +278,7 @@ class ScreeningExecutor:
275
278
  self,
276
279
  factor_names: List[str],
277
280
  price_data: Dict[str, pd.DataFrame],
278
- symbols: List[str]
281
+ symbols: List[str],
279
282
  ) -> Dict[str, Dict[str, float]]:
280
283
  """为筛选条件计算因子值"""
281
284
  results = {}
@@ -297,6 +300,7 @@ class ScreeningExecutor:
297
300
  continue
298
301
 
299
302
  from ..parser import Formula
303
+
300
304
  formula = Formula(factor.expr, name=factor_name)
301
305
  result = formula.compute(df)
302
306
 
@@ -316,18 +320,20 @@ class ScreeningExecutor:
316
320
 
317
321
  for f in factors:
318
322
  # 支持因子对象或字典
319
- if hasattr(f, 'name') and f.name == factor_name:
323
+ if hasattr(f, "name") and f.name == factor_name:
320
324
  return f
321
- elif isinstance(f, dict) and f.get('name') == factor_name:
325
+ elif isinstance(f, dict) and f.get("name") == factor_name:
322
326
  from .base import FactorDefinition
327
+
323
328
  return FactorDefinition(**f)
324
329
 
325
330
  # 支持路径匹配
326
331
  if factor_name in str(f).lower():
327
- if hasattr(f, 'name'):
332
+ if hasattr(f, "name"):
328
333
  return f
329
334
  elif isinstance(f, dict):
330
335
  from .base import FactorDefinition
336
+
331
337
  return FactorDefinition(**f)
332
338
 
333
339
  return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quantcli
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: 面向AI的多因子量化选股策略挖掘工具,AI Agent 友好 CLI
5
5
  Author-email: QuantCLI Team <quantcli@example.com>
6
6
  Project-URL: repository, https://github.com/wumu2013/quantcli
@@ -19,6 +19,8 @@ quantcli/datasources/base.py
19
19
  quantcli/datasources/cache.py
20
20
  quantcli/datasources/mixed.py
21
21
  quantcli/datasources/mysql.py
22
+ quantcli/datasources/fundamentals/__init__.py
23
+ quantcli/datasources/fundamentals/provider.py
22
24
  quantcli/datasources/sync/__init__.py
23
25
  quantcli/datasources/sync/akshare.py
24
26
  quantcli/datasources/sync/base.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes