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.
- {quantcli-0.1.4/quantcli.egg-info → quantcli-0.1.6}/PKG-INFO +1 -1
- {quantcli-0.1.4 → quantcli-0.1.6}/pyproject.toml +1 -1
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/cli.py +25 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/baostock.py +13 -2
- quantcli-0.1.6/quantcli/datasources/fundamentals/__init__.py +8 -0
- quantcli-0.1.6/quantcli/datasources/fundamentals/provider.py +278 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/mysql.py +168 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/sync/base.py +27 -27
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/factors/screening_executor.py +41 -35
- {quantcli-0.1.4 → quantcli-0.1.6/quantcli.egg-info}/PKG-INFO +1 -1
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli.egg-info/SOURCES.txt +2 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/LICENSE +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/README.md +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/core/__init__.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/core/backtest.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/core/data.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/core/factor.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/__init__.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/akshare.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/base.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/cache.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/mixed.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/sync/__init__.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/sync/akshare.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/datasources/sync/gm.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/factors/__init__.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/factors/base.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/factors/compute.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/factors/loader.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/factors/pipeline.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/factors/ranking.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/factors/ranking_executor.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/factors/screening.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/models/bar.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/parser/__init__.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/parser/constants.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/parser/formula.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/utils/__init__.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/utils/logger.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/utils/path.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/utils/symbol_utils.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/utils/time.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli/utils/validate.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli.egg-info/dependency_links.txt +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli.egg-info/entry_points.txt +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli.egg-info/requires.txt +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/quantcli.egg-info/top_level.txt +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/setup.cfg +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_akshare_integration.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_builtin_factors.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_cli.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_datasources.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_factors.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_gm_executors.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_mixed_datasource.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_multi_factor.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_pipeline_integration.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_symbol_utils.py +0 -0
- {quantcli-0.1.4 → quantcli-0.1.6}/tests/test_time.py +0 -0
|
@@ -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
|
-
|
|
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,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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
...
|
|
188
|
-
...
|
|
189
|
-
...
|
|
190
|
-
...
|
|
191
|
-
...
|
|
192
|
-
...
|
|
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
|
-
...
|
|
199
|
-
...
|
|
200
|
-
...
|
|
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=
|
|
210
|
-
port=
|
|
211
|
-
user=
|
|
212
|
-
password=
|
|
213
|
-
database=
|
|
214
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
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[
|
|
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[
|
|
240
|
-
latest_data = all_data[all_data[
|
|
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, {
|
|
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[
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
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,
|
|
323
|
+
if hasattr(f, "name") and f.name == factor_name:
|
|
320
324
|
return f
|
|
321
|
-
elif isinstance(f, dict) and f.get(
|
|
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,
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|