模型,评分,修复网格策略市场状态监听

This commit is contained in:
2026-06-22 10:48:40 +08:00
parent 2e3202968d
commit f938c453e1
42 changed files with 3803 additions and 108 deletions
+7
View File
@@ -0,0 +1,7 @@
# 数据同步子包
from core.scoring.sync.base import BaseSync
from core.scoring.sync.kline_sync import KlineStockSync, KlineIndexSync
from core.scoring.sync.stocks_sync import StocksSync
from core.scoring.sync.industry_sync import IndustrySync
from core.scoring.sync.market_regime import MarketRegimeSync
from core.scoring.sync.sector_features import SectorFeaturesSync
+41
View File
@@ -0,0 +1,41 @@
"""
数据同步抽象基类
"""
import abc
from datetime import datetime
from core.logger import LogLevel, PrintLog
class BaseSync(abc.ABC):
"""数据同步抽象基类,所有同步操作遵循 _fetch → _upsert 模式"""
def __init__(self):
self.stats = {'inserted': 0, 'updated': 0, 'skipped': 0, 'errors': 0}
def run(self, **kwargs) -> dict:
"""同步入口: 拉取数据 → 写入数据库 → 返回统计"""
name = self.__class__.__name__
PrintLog(LogLevel.INFO, f'[sync] {name} 开始同步...')
t0 = datetime.now()
try:
data = self._fetch(**kwargs)
self._upsert(data)
elapsed = (datetime.now() - t0).total_seconds()
PrintLog(
LogLevel.INFO,
f'[sync] {name} 完成 ({elapsed:.1f}s) — '
f'insert={self.stats["inserted"]} update={self.stats["updated"]} '
f'skip={self.stats["skipped"]} err={self.stats["errors"]}'
)
except Exception as e:
PrintLog(LogLevel.ERROR, f'[sync] {name} 失败: {e}')
raise
return self.stats
@abc.abstractmethod
def _fetch(self, **kwargs):
"""从数据源拉取原始数据。子类实现。"""
@abc.abstractmethod
def _upsert(self, data):
"""将数据写入数据库。子类实现。"""
+84
View File
@@ -0,0 +1,84 @@
"""
行业映射同步 — 从 QMT get_sector_list + get_stock_list_in_sector 获取
"""
from datetime import date
from core.scoring.sync.base import BaseSync
from core.scoring.models import IndustryMapping
from core.database import db
from core.logger import LogLevel, PrintLog
class IndustrySync(BaseSync):
"""行业映射同步 — QMT 行业板块 → IndustryMapping 表(全量替换)"""
def _fetch(self, **kwargs):
"""从 QMT 拉取全部行业板块的成份股映射"""
from xtquant import xtdata
all_sectors = xtdata.get_sector_list()
PrintLog(LogLevel.INFO, f'[sync] Industry: 共 {len(all_sectors)} 个板块')
# 尝试使用 get_sector_info 过滤行业板块
industry_sectors = []
try:
sector_info = xtdata.get_sector_info()
if sector_info is not None and not sector_info.empty:
for _, row in sector_info.iterrows():
cat = row.get('category', '')
if '行业' in str(cat):
industry_sectors.append(row['sector'])
except Exception:
pass
# 如果 get_sector_info 无效,回退到名称过滤
if not industry_sectors:
for s in all_sectors:
# 排除明显非行业的板块
skip_markers = ['概念', '风格', '地域', '地区', '指数', '自定义',
'ETF', 'LOF', '债券', '基金', '期货', '期权']
if any(m in s for m in skip_markers):
continue
industry_sectors.append(s)
PrintLog(LogLevel.INFO, f'[sync] Industry: 筛选出 {len(industry_sectors)} 个行业板块')
# 构建 code → {industry_name, classification} 映射
mapping = {} # code → (industry_name, classification)
today = date.today()
for sector_name in industry_sectors:
try:
stocks = xtdata.get_stock_list_in_sector(sector_name)
for full_code in stocks:
code = full_code.split('.')[0]
if code not in mapping:
mapping[code] = {
'code': code,
'industry_name': sector_name,
'industry_classification': 'QMT',
'update_date': today,
}
except Exception:
self.stats['errors'] += 1
self.stats['inserted'] = len(mapping)
return list(mapping.values())
def _upsert(self, data: list):
"""全量替换: 清空旧数据 → 批量插入新数据"""
if not data:
PrintLog(LogLevel.WARNING, '[sync] Industry: 无数据, 跳过')
return
with db.atomic():
IndustryMapping.delete().execute()
for batch in _chunked(data, 500):
IndustryMapping.insert_many(batch).execute()
PrintLog(LogLevel.INFO,
f'[sync] Industry: 全量替换完成, {len(data)} 条映射')
def _chunked(lst: list, n: int):
for i in range(0, len(lst), n):
yield lst[i:i + n]
+222
View File
@@ -0,0 +1,222 @@
"""
K线数据同步 — 个股日K + 指数日K
数据源: QMT xtdata
增量同步: 只拉 max(trade_date) 之后的增量数据
线程锁: KlineStockSync / KlineIndexSync 各自内部锁
"""
import pandas as pd
from datetime import date, timedelta
from core.scoring.sync.base import BaseSync
from core.scoring.models import KlineStock, KlineIndex
from core.scoring.config import TRACKED_INDICES
from core.database import db
from core.logger import LogLevel, PrintLog
BATCH_SIZE = 50
DEFAULT_COUNT = 300
# 全局同步状态标记(字典引用传递,可被外部轮询)
_sync_state = {"kline": False, "index": False, "stocks": False,
"industry": False, "market": False, "sector": False}
def is_syncing(key="kline") -> bool:
return _sync_state.get(key, False)
def _latest_date(model_cls) -> date | None:
from peewee import fn
row = model_cls.select(fn.MAX(model_cls.trade_date)).scalar()
if isinstance(row, date):
return row
return None
def _safe_get(df_dict, code, dt, default=0.0) -> float:
"""安全获取 DataFrame 值"""
if df_dict is None:
return default
df = df_dict.get(code)
if df is None or code not in df.index:
return default
try:
val = df.loc[code, dt]
if pd.isna(val):
return default
return float(val)
except Exception:
return default
class KlineStockSync(BaseSync):
"""个股日K线同步 — 增量:只拉 max(trade_date) 之后的增量数据"""
def __init__(self, count: int = DEFAULT_COUNT):
super().__init__()
self.count = count
def _fetch(self, **kwargs):
from xtquant import xtdata
# 增量判断
latest = _latest_date(KlineStock)
today = date.today()
if latest is not None and latest >= today:
PrintLog(LogLevel.INFO, f'[sync] KlineStock: 已最新 ({latest}),跳过')
self.stats['skipped'] = 0
return self.stats
# 增量起点
start_date = (latest + timedelta(days=1)) if latest else None
start_str = start_date.strftime('%Y%m%d') if start_date else ""
PrintLog(LogLevel.INFO,
f'[sync] KlineStock: 增量同步,起点={start_str or "全部"}')
all_stocks = xtdata.get_stock_list_in_sector("沪深A股")
PrintLog(LogLevel.INFO, f'[sync] KlineStock: {len(all_stocks)} 只A股')
field_list = ['open', 'high', 'low', 'close', 'volume']
total = len(all_stocks)
inserted = 0
for i, code in enumerate(all_stocks):
if i > 0 and i % 50 == 0:
PrintLog(LogLevel.INFO, f'[sync] KlineStock: {i}/{total} ({i*100//total}%)')
try:
xtdata.download_history_data(code, period='1d', start_time=start_str)
except Exception:
self.stats['errors'] += 1
continue
try:
result = xtdata.get_market_data(
field_list=field_list, stock_list=[code], period='1d',
count=self.count, dividend_type='none', fill_data=False)
inserted += self._upsert_incremental(code, result, start_date)
except Exception:
self.stats['errors'] += 1
self.stats['inserted'] = inserted
PrintLog(LogLevel.INFO,
f'[sync] KlineStock 完成: 新增={inserted} '
f'跳过={self.stats["skipped"]} 错误={self.stats["errors"]}')
return self.stats
def _upsert_incremental(self, full_code: str, result: dict, start_date) -> int:
if not result:
return 0
close_df = result.get('close')
if close_df is None or close_df.empty:
return 0
stock_code = full_code.split('.')[0]
records = []
for td in close_df.columns:
td_date = td.date() if hasattr(td, 'date') else td
if start_date is not None and td_date <= start_date:
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
continue
close_val = close_df.loc[full_code, td]
if close_val is None or (isinstance(close_val, float) and pd.isna(close_val)):
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
continue
records.append({
'stock_code': stock_code,
'trade_date': td_date,
'open': _safe_get(result.get('open'), full_code, td),
'high': _safe_get(result.get('high'), full_code, td),
'low': _safe_get(result.get('low'), full_code, td),
'close': float(close_val),
'volume': _safe_get(result.get('volume'), full_code, td),
})
if records:
with db.atomic():
for batch in _chunked(records, 500):
KlineStock.insert_many(batch).on_conflict_replace().execute()
return len(records)
return 0
def _upsert(self, data):
pass
class KlineIndexSync(BaseSync):
"""指数日K线同步 — 增量同步"""
def __init__(self, indices: list = None, count: int = DEFAULT_COUNT):
super().__init__()
self.indices = indices or TRACKED_INDICES
self.count = count
def _fetch(self, **kwargs):
from xtquant import xtdata
index_codes = []
for code in self.indices:
if code.startswith(('000', '001')):
index_codes.append(f'{code}.SH')
elif code.startswith('399'):
index_codes.append(f'{code}.SZ')
else:
index_codes.append(f'{code}.SH')
latest = _latest_date(KlineIndex)
today = date.today()
if latest is not None and latest >= today:
PrintLog(LogLevel.INFO, f'[sync] KlineIndex: 已最新 ({latest}),跳过')
return {}
start_date = (latest + timedelta(days=1)) if latest else None
start_str = start_date.strftime('%Y%m%d') if start_date else ""
PrintLog(LogLevel.INFO,
f'[sync] KlineIndex: 增量同步,起点={start_str or "全部"}')
for code in index_codes:
try:
xtdata.download_history_data(code, period='1d', start_time=start_str)
except Exception:
self.stats['errors'] += 1
field_list = ['open', 'high', 'low', 'close', 'volume']
result = xtdata.get_market_data(
field_list=field_list, stock_list=index_codes, period='1d',
count=self.count, dividend_type='none', fill_data=False)
return result or {}
def _upsert(self, data):
if not data:
return
latest = _latest_date(KlineIndex)
records = []
close_df = data.get('close')
if close_df is None or close_df.empty:
return
for full_code in close_df.index:
index_code = full_code.split('.')[0]
for td in close_df.columns:
td_date = td.date() if hasattr(td, 'date') else td
if latest is not None and td_date <= latest:
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
continue
close_val = close_df.loc[full_code, td]
if close_val is None or (isinstance(close_val, float) and pd.isna(close_val)):
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
continue
records.append({
'index_code': index_code,
'trade_date': td_date,
'open': _safe_get(data.get('open'), full_code, td),
'high': _safe_get(data.get('high'), full_code, td),
'low': _safe_get(data.get('low'), full_code, td),
'close': float(close_val),
'volume': _safe_get(data.get('volume'), full_code, td),
})
if records:
with db.atomic():
for batch in _chunked(records, 500):
KlineIndex.insert_many(batch).on_conflict_replace().execute()
self.stats['inserted'] = self.stats.get('inserted', 0) + len(records)
def _chunked(lst: list, n: int):
for i in range(0, len(lst), n):
yield lst[i:i + n]
+98
View File
@@ -0,0 +1,98 @@
"""
市场状态计算 — 从 kline_stock 聚合生成 market_regime_daily
纯本地计算,不依赖外部数据源。
"""
import pandas as pd
from peewee import fn, Case
from core.scoring.sync.base import BaseSync
from core.scoring.models import KlineStock, MarketRegimeDaily
from core.scoring.config import PANIC_ADVANCE_RATIO, PANIC_TURNOVER_RATIO
from core.database import db
from core.logger import LogLevel, PrintLog
class MarketRegimeSync(BaseSync):
"""市场状态同步 — kline_stock 聚合 → market_regime_daily"""
def _fetch(self, **kwargs):
"""从 KlineStock 逐日聚合涨跌家数和成交额"""
PrintLog(LogLevel.INFO, '[sync] MarketRegime: 开始聚合全市场数据...')
# peewee 聚合查询: 逐日统计 advancers / decliners / turnover
query = (KlineStock
.select(
KlineStock.trade_date,
fn.SUM(
Case(None, [(KlineStock.close > KlineStock.open, 1)], 0)
).alias('advancers'),
fn.SUM(
Case(None, [(KlineStock.close < KlineStock.open, 1)], 0)
).alias('decliners'),
fn.SUM(KlineStock.close * KlineStock.volume).alias('turnover'),
)
.group_by(KlineStock.trade_date)
.order_by(KlineStock.trade_date))
rows = list(query.dicts())
if not rows:
PrintLog(LogLevel.WARNING, '[sync] MarketRegime: KlineStock 表为空')
return None
df = pd.DataFrame(rows)
df['trade_date'] = pd.to_datetime(df['trade_date'])
df = df.sort_values('trade_date').reset_index(drop=True)
# 计算涨跌比
total = df['advancers'] + df['decliners']
df['advance_ratio'] = (df['advancers'] / total.replace(0, 1)).round(4)
# 5日滚动均量
df['turnover_avg_5d'] = (df['turnover']
.rolling(window=5, min_periods=1)
.mean()
.round(2))
# 量比
df['turnover_ratio_5d'] = (df['turnover'] /
df['turnover_avg_5d'].replace(0, 1)).round(4)
# 极端恐慌标记
df['is_extreme_panic'] = (
(df['advance_ratio'] < PANIC_ADVANCE_RATIO) &
(df['turnover_ratio_5d'] > PANIC_TURNOVER_RATIO)
).astype(int)
PrintLog(LogLevel.INFO,
f'[sync] MarketRegime: 聚合完成, {len(df)} 个交易日')
return df
def _upsert(self, df):
"""写入 MarketRegimeDaily 表"""
if df is None or df.empty:
return
records = []
for _, row in df.iterrows():
records.append({
'trade_date': row['trade_date'].date(),
'advancers': int(row['advancers']),
'decliners': int(row['decliners']),
'advance_ratio': float(row['advance_ratio']),
'turnover': float(row['turnover']),
'turnover_avg_5d': float(row['turnover_avg_5d']),
'turnover_ratio_5d': float(row['turnover_ratio_5d']),
'source': 'qmt',
'is_extreme_panic': int(row['is_extreme_panic']),
})
if records:
with db.atomic():
for batch in _chunked(records, 500):
MarketRegimeDaily.insert_many(batch).on_conflict_replace().execute()
self.stats['inserted'] += len(records)
def _chunked(lst: list, n: int):
for i in range(0, len(lst), n):
yield lst[i:i + n]
+129
View File
@@ -0,0 +1,129 @@
"""
行业聚合指数计算 — 从 kline_stock + industry 生成 sector_features_daily
纯本地计算,不依赖外部数据源。
"""
import pandas as pd
from core.scoring.sync.base import BaseSync
from core.scoring.models import KlineStock, IndustryMapping, SectorFeaturesDaily
from core.database import db
from core.logger import LogLevel, PrintLog
class SectorFeaturesSync(BaseSync):
"""行业聚合指数同步 — kline_stock + industry → sector_features_daily"""
def _fetch(self, **kwargs):
"""从数据库加载原始数据, 计算行业指数特征"""
PrintLog(LogLevel.INFO, '[sync] SectorFeatures: 加载原始数据...')
# 1. 加载行业映射: code → industry_name
industries = list(IndustryMapping.select().dicts())
if not industries:
PrintLog(LogLevel.WARNING, '[sync] SectorFeatures: IndustryMapping 表为空, 请先执行 industry sync')
return None
code_to_industry = {row['code']: row['industry_name'] for row in industries}
PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: {len(code_to_industry)} 条行业映射')
# 2. 加载 K 线数据
kline_rows = (KlineStock
.select(
KlineStock.stock_code,
KlineStock.trade_date,
KlineStock.open,
KlineStock.high,
KlineStock.low,
KlineStock.close,
)
.order_by(KlineStock.stock_code, KlineStock.trade_date)
.dicts())
if not kline_rows:
PrintLog(LogLevel.WARNING, '[sync] SectorFeatures: KlineStock 表为空')
return None
df = pd.DataFrame(kline_rows)
df['trade_date'] = pd.to_datetime(df['trade_date'])
PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: {len(df)} 条K线数据')
# 3. 映射行业
df['sector_name'] = df['stock_code'].map(code_to_industry)
df = df.dropna(subset=['sector_name'])
# 4. 逐股计算日收益率和振幅
df = df.sort_values(['stock_code', 'trade_date'])
df['prev_close'] = df.groupby('stock_code')['close'].shift(1)
df['pct_chg'] = (df['close'] - df['prev_close']) / df['prev_close'] * 100
df['amplitude'] = (df['high'] - df['low']) / df['open'] * 100
# 清理无效值
df = df.dropna(subset=['pct_chg', 'amplitude'])
# 5. 按行业+日期聚合
agg = (df.groupby(['trade_date', 'sector_name'])
.agg(
sector_ret=('pct_chg', 'mean'),
sector_amplitude=('amplitude', 'mean'),
)
.reset_index())
# 6. 构建行业指数 (基值=100)
agg = agg.sort_values(['sector_name', 'trade_date'])
agg['sector_index'] = agg.groupby('sector_name')['sector_ret'].transform(
lambda x: (1 + x / 100).cumprod() * 100
)
# 重新基值=100 (每行业独立)
for name, group in agg.groupby('sector_name'):
idx = group.index
first_val = group['sector_index'].iloc[0]
agg.loc[idx, 'sector_index'] = group['sector_index'] / first_val * 100
# 7. 计算 EMA 均线
agg['ema10'] = (agg.groupby('sector_name')['sector_index']
.transform(lambda x: x.ewm(span=10, min_periods=1).mean()))
agg['ema20'] = (agg.groupby('sector_name')['sector_index']
.transform(lambda x: x.ewm(span=20, min_periods=1).mean()))
agg['ema200'] = (agg.groupby('sector_name')['sector_index']
.transform(lambda x: x.ewm(span=200, min_periods=1).mean()))
# 8. 趋势评分: close>ema200 得1分 + ema10>ema20 得1分
agg['score'] = (
(agg['sector_index'] > agg['ema200']).astype(int) +
(agg['ema10'] > agg['ema20']).astype(int)
)
PrintLog(LogLevel.INFO,
f'[sync] SectorFeatures: 计算完成, {len(agg)} 行, '
f'{agg["sector_name"].nunique()} 个行业')
return agg
def _upsert(self, agg):
"""写入 SectorFeaturesDaily 表"""
if agg is None or agg.empty:
return
records = []
for _, row in agg.iterrows():
records.append({
'trade_date': row['trade_date'].date()
if hasattr(row['trade_date'], 'date') else row['trade_date'],
'sector_name': str(row['sector_name']),
'sector_ret': round(float(row['sector_ret']), 4),
'sector_amplitude': round(float(row['sector_amplitude']), 4),
'close': round(float(row['sector_index']), 4),
'ema10': round(float(row['ema10']), 4),
'ema20': round(float(row['ema20']), 4),
'ema200': round(float(row['ema200']), 4),
'score': int(row['score']),
})
if records:
with db.atomic():
for batch in _chunked(records, 500):
SectorFeaturesDaily.insert_many(batch).on_conflict_replace().execute()
self.stats['inserted'] += len(records)
def _chunked(lst: list, n: int):
for i in range(0, len(lst), n):
yield lst[i:i + n]
+100
View File
@@ -0,0 +1,100 @@
"""
股票基础信息同步 — 从 QMT get_instrument_detail 获取
"""
from datetime import date
from core.scoring.sync.base import BaseSync
from core.scoring.models import StockInfo
from core.database import db
from core.logger import LogLevel, PrintLog
BATCH_SIZE = 200
class StocksSync(BaseSync):
"""股票基础信息同步 — QMT get_instrument_detail_list"""
def _fetch(self, **kwargs):
"""从 QMT 拉取全部A股基础信息"""
from xtquant import xtdata
all_stocks = xtdata.get_stock_list_in_sector("沪深A股")
PrintLog(LogLevel.INFO, f'[sync] Stocks: 获取到 {len(all_stocks)} 只A股')
result = {}
total = len(all_stocks)
for i in range(0, total, BATCH_SIZE):
batch = all_stocks[i:i + BATCH_SIZE]
try:
details = xtdata.get_instrument_detail_list(batch)
result.update(details)
except Exception as e:
PrintLog(LogLevel.ERROR,
f'[sync] Stocks batch {i}-{min(i + BATCH_SIZE, total)} failed: {e}')
self.stats['errors'] += len(batch)
return result
def _upsert(self, data: dict):
"""写入 StockInfo 表"""
records = []
today = date.today()
for full_code, inst in data.items():
if not inst:
self.stats['skipped'] += 1
continue
code_num = full_code.split('.')[0]
# 判断交易所
exchange = inst.get('ExchangeID', '')
if not exchange:
if '.SH' in full_code:
exchange = 'SH'
elif '.SZ' in full_code:
exchange = 'SZ'
elif '.BJ' in full_code:
exchange = 'BJ'
# OpenDate 格式: 'YYYYMMDD' 或 int
open_date = inst.get('OpenDate', '')
if open_date and len(str(open_date)) == 8:
list_date_val = f'{str(open_date)[:4]}-{str(open_date)[4:6]}-{str(open_date)[6:8]}'
else:
list_date_val = None
# InstrumentStatus 含义:
# 0/1 = 正常股票(含已退市但仍在QMT列表的)
# -1 = ST / *ST
# 30/31 = *ST
# 已退市股(名字含XD/退)K线不足120日,会在候选股过滤时被排除
status = inst.get('InstrumentStatus', -1)
if status in (-1, 30, 31):
listing_status = 'ST'
else:
listing_status = 'normal'
total_share = inst.get('TotalVolume', None)
float_share = inst.get('FloatVolume', None)
records.append({
'code': full_code,
'name': str(inst.get('InstrumentName', '')),
'exchange': exchange,
'list_date': list_date_val,
'listing_status': listing_status,
'total_share': float(total_share) if total_share else None,
'float_share': float(float_share) if float_share else None,
'share_updated_at': today,
})
if records:
with db.atomic():
for batch in _chunked(records, 500):
StockInfo.insert_many(batch).on_conflict_replace().execute()
self.stats['inserted'] += len(records)
def _chunked(lst: list, n: int):
for i in range(0, len(lst), n):
yield lst[i:i + n]