feat(v0.2): 市场阶段与主线识别 + 因子挖掘全链路 + 数据层完善

- 市场环境: 新增情绪周期6阶段(冰点/启动/主升/高潮/退潮/修复, 连板梯队驱动,
  EMA平滑+2日确认+弱档否决, 平均段长9.7天)与概念/行业主线排名(涨停梯队聚合,
  可配置宽基/风格标签过滤); 市场环境页重构, regime 透明加列, 与5档state并存
- 挖掘: 因子与策略挖掘全链路(API/worker/进程锁/候选库/前端工作台/文档),
  周度调度默认关闭且永不自动发布
- 回测: 财务快照因子(点时口径), 批量回测预计算共享下期收益,
  信号路径矩阵列依赖展开修复(consecutive_limit_ups 缺列报错)
- 数据/性能: enriched 生成与预热治理, 重任务限流, 行情/K线缓存复用, 时区修复
- 测试: 后端全量 914 通过; GUI 黑盒验证截图存证 gui-test-screenshots/
This commit is contained in:
shy3130
2026-08-16 23:39:07 +08:00
parent eb869c7ad2
commit 697c27bb02
129 changed files with 20324 additions and 602 deletions
+147 -65
View File
@@ -26,6 +26,13 @@ import duckdb
import polars as pl
from app.config import settings
from app.enriched_generation import (
EnrichedGenerationUnavailableError,
EnrichedPublication,
bump_enriched_generation,
get_enriched_generation,
)
from app.market_time import cn_today
from app.parquet import scan_enriched_parquet
logger = logging.getLogger(__name__)
@@ -318,6 +325,7 @@ class KlineRepository:
# 完整 enriched 历史 (含所有指标, 供 filter_history 策略使用)
self._enriched_history_cache: pl.DataFrame | None = None # ~100万行
self._enriched_history_start: date | None = None
self._enriched_history_generation: str | None = None
self._index_instruments_cache: pl.DataFrame | None = None
self._etf_enriched_cache: pl.DataFrame | None = None
self._etf_enriched_cache_date: date | None = None
@@ -485,6 +493,7 @@ class KlineRepository:
self._enriched_cache_date = None
self._enriched_history_cache = None
self._enriched_history_start = None
self._enriched_history_generation = None
self._live_agg_cache = None
self._live_agg_cache_date = None
self._live_agg_check_date = None
@@ -497,6 +506,7 @@ class KlineRepository:
self._etf_instruments_cache = None
self._index_symbol_set_cache = None
self._etf_symbol_set_cache = None
self._name_map_cache = None
self._index_enriched_cache = None
self._index_enriched_cache_date = None
@@ -510,6 +520,7 @@ class KlineRepository:
"""
try:
started = time.perf_counter()
refresh_generation = self.get_matrix_data_generation("stock")
logger.info("enriched refresh start")
step = time.perf_counter()
@@ -596,8 +607,13 @@ class KlineRepository:
logger.info("enriched refresh step done: join instruments (%.2fs)", time.perf_counter() - step)
# 缓存完整历史 (含指标+必要基础信息) 供 filter_history/backtest 直接复用
if self.get_matrix_data_generation("stock") != refresh_generation:
raise EnrichedGenerationUnavailableError(
"enriched data changed while refreshing its history cache"
)
self._enriched_history_cache = df_full
self._enriched_history_start = df_full["date"].min()
self._enriched_history_generation = refresh_generation
logger.info("enriched 历史缓存: %d rows, %s ~ %s",
len(df_full), self._enriched_history_start, latest)
@@ -626,6 +642,8 @@ class KlineRepository:
logger.info("enriched 缓存已计算: %d 只, 日期 %s (即时计算)", len(df_today), latest)
logger.info("enriched refresh done (%.2fs)", time.perf_counter() - started)
return
except EnrichedGenerationUnavailableError:
raise
except Exception as e: # noqa: BLE001
logger.warning("enriched 即时计算失败, 使用原始 14 列缓存: %s", e)
@@ -908,7 +926,7 @@ class KlineRepository:
def _live_agg_baseline_date(self, latest: date) -> date:
"""盘中递推基准日期。当天实时分区存在时使用上一可用交易日。"""
if latest != date.today():
if latest != cn_today():
return latest
try:
row = self.execute_one(
@@ -1051,6 +1069,7 @@ class KlineRepository:
df = pl.scan_parquet(self._inst_glob).collect()
if not df.is_empty():
self._instruments_cache = df
self._name_map_cache = None
logger.info("instruments 缓存已加载: %d", len(df))
except Exception as e: # noqa: BLE001
logger.warning("instruments 缓存刷新失败: %s", e)
@@ -1062,6 +1081,7 @@ class KlineRepository:
if not df.is_empty():
self._index_instruments_cache = df
self._index_symbol_set_cache = None
self._name_map_cache = None
logger.info("index instruments 缓存已加载: %d", len(df))
except Exception as e: # noqa: BLE001
logger.debug("index instruments 缓存刷新跳过: %s", e)
@@ -1087,6 +1107,7 @@ class KlineRepository:
df_all = pl.concat(parts, how="diagonal_relaxed").unique(subset=["symbol"], keep="last").sort("symbol")
self._etf_instruments_cache = df_all
self._etf_symbol_set_cache = None
self._name_map_cache = None
logger.info("ETF instruments 缓存已加载: %d", len(df_all))
def get_enriched_latest(self) -> tuple[pl.DataFrame, date | None]:
@@ -1164,8 +1185,21 @@ class KlineRepository:
) -> pl.DataFrame | None:
"""从预计算 enriched 历史缓存返回完整区间;缓存不覆盖时返回 None。"""
if self._enriched_history_cache is None:
if self._enriched_warming:
# 后台预热中: 返回 None (缓存不覆盖), 调用方各自走慢路径;
# 否则请求线程会与预热线程并发重复 300 天全量重算
# (同 get_enriched_latest 的守卫语义)。
return None
self._refresh_enriched()
cache = self._enriched_history_cache
data_dir = getattr(getattr(self, "store", None), "data_dir", None)
if data_dir is not None:
try:
current_generation = self.get_matrix_data_generation("stock")
except EnrichedGenerationUnavailableError:
return None
if self._enriched_history_generation != current_generation:
return None
if cache is None or cache.is_empty() or "date" not in cache.columns:
return None
@@ -1204,9 +1238,9 @@ class KlineRepository:
# 后台预热中: 返回空表, 不触发同步重算 (同 get_enriched_latest 守卫)
return pl.DataFrame()
self._refresh_enriched()
self._live_agg_check_date = date.today() # 刚建过, 当天不必再查磁盘
self._live_agg_check_date = cn_today() # 刚建过, 当天不必再查磁盘
else:
today = date.today()
today = cn_today()
if self._live_agg_check_date != today:
# today 翻天了 (次日开盘首次轮询): 校验基准日是否需要前移重建。
# 同一天内多次调用直接跳过, 避免每轮都扫 parquet。
@@ -1306,16 +1340,28 @@ class KlineRepository:
自选列表/名称批查等场景的统一名称解析入口, 避免各调用方自行合并两份缓存。
symbols 非 None 时只返回命中的条目。
全量结果缓存在 _name_map_cache (随三份 instruments 维表刷新失效),
避免每请求对 ~7000 行维表做 iter_rows 重建。
"""
if self._name_map_cache is not None:
if symbols is None:
return dict(self._name_map_cache)
wanted = set(symbols)
return {s: n for s, n in self._name_map_cache.items() if s in wanted}
# 只构建并缓存全量映射; symbols 过滤只作用于返回值。
# 若把过滤后的结果写入缓存, 后续不同 symbols 的查询会命中残缺缓存,
# 导致新加入自选的标的查不到名称。
name_map: dict[str, str] = {}
for df in (self.get_instruments(), self.get_etf_instruments(), self.get_instruments_asset("index")):
if df.is_empty() or "symbol" not in df.columns or "name" not in df.columns:
continue
if symbols is not None:
df = df.filter(pl.col("symbol").is_in(symbols))
for symbol, name in df.select(["symbol", "name"]).iter_rows():
name_map.setdefault(symbol, name)
return name_map
self._name_map_cache = name_map
if symbols is None:
return dict(name_map)
wanted = set(symbols)
return {s: n for s, n in name_map.items() if s in wanted}
def enriched_latest_date(self) -> date | None:
"""返回缓存中的 enriched 最新日期。"""
@@ -1355,10 +1401,26 @@ class KlineRepository:
# 扩展范围用于指标预热 (MA60 需要 ~60 交易日 ≈ 120 日历日)
warmup_start = start - timedelta(days=150)
# 扫描14列 parquet
df = self._scan_daily_symbol(symbol, warmup_start, end, None)
if not df.is_empty():
df = self._compute_enriched_range(df)
# 优先复用预计算 enriched 历史缓存 (300 天全指标, 与回测引擎同源):
# 个股对话框打开时本接口每个行情 tick 被调一次, 逐请求 150 天扫描 + 全套
# 指标重算是热路径上最大的重复计算。缓存最新日可能不含当日实时行,
# 由下方 get_enriched_latest 覆盖逻辑补齐; 覆盖不足时回退单股计算路径。
df = pl.DataFrame()
hist = self._enriched_history_cache
if hist is not None and not hist.is_empty() and "date" in hist.columns:
hist_min = self._enriched_history_start
hist_max = hist["date"].max()
if hist_min is not None and hist_min <= start and hist_max >= start:
df = hist.filter(
(pl.col("symbol") == symbol)
& (pl.col("date") >= start)
& (pl.col("date") <= end)
)
if df.is_empty():
# 扫描14列 parquet
df = self._scan_daily_symbol(symbol, warmup_start, end, None)
if not df.is_empty():
df = self._compute_enriched_range(df)
# 尝试用缓存数据覆盖最新日 (盘中更准确)
cached, cache_date = self.get_enriched_latest()
@@ -1829,30 +1891,11 @@ class KlineRepository:
return latest
def get_matrix_data_generation(self, asset_type: str = "stock") -> str:
"""Return a persistent generation bumped by every managed enriched write."""
path = self.store.data_dir / f".matrix_generation_{asset_type}.json"
try:
payload = json.loads(path.read_text(encoding="utf-8"))
generation = str(payload.get("generation") or "")
if generation:
return generation
except (OSError, TypeError, ValueError, json.JSONDecodeError):
pass
return self._bump_matrix_data_generation(asset_type)
"""Return the stable generation for managed enriched readers."""
return get_enriched_generation(self.store.data_dir, asset_type)
def _bump_matrix_data_generation(self, asset_type: str) -> str:
generation = uuid.uuid4().hex
path = self.store.data_dir / f".matrix_generation_{asset_type}.json"
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
temporary.write_text(
json.dumps({
"generation": generation,
"updated_at_ns": time.time_ns(),
}, separators=(",", ":")),
encoding="utf-8",
)
temporary.replace(path)
return generation
return bump_enriched_generation(self.store.data_dir, asset_type)
def symbols_lagging(self, reference_date: date, min_gap_days: int = 3) -> list[str]:
"""返回日K覆盖落后的标的: 其最新 bar 早于 reference_date - min_gap_days。
@@ -1962,6 +2005,7 @@ class KlineRepository:
self._atomic_write_parquet(df.unique(subset=["symbol"], keep="last").sort("symbol"), out)
self._index_instruments_cache = None
self._etf_instruments_cache = None
self._name_map_cache = None
self._refresh_index_instruments()
def save_etf_instruments(self, df: pl.DataFrame) -> None:
@@ -1974,6 +2018,7 @@ class KlineRepository:
out.parent.mkdir(parents=True, exist_ok=True)
self._atomic_write_parquet(df.unique(subset=["symbol"], keep="last").sort("symbol"), out)
self._etf_instruments_cache = None
self._name_map_cache = None
self._refresh_etf_instruments()
def refresh_index_views(self) -> None:
@@ -2052,25 +2097,36 @@ class KlineRepository:
def _write_daily_partition(self, df: pl.DataFrame, table: str) -> None:
"""按 date 分区写入 parquet,每个日期一个文件,支持 merge-upsert。"""
base = self.store.data_dir / table
generation_asset = {
"kline_daily_enriched": "stock",
"kline_etf_enriched": "etf",
}.get(table)
publication = (
EnrichedPublication(self.store.data_dir, generation_asset)
if generation_asset is not None
else None
)
with self._write_lock:
for date_df in df.partition_by("date"):
dt = date_df["date"][0]
ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
out = base / f"date={ds}" / "part.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
existing = pl.DataFrame()
if out.exists():
existing = pl.read_parquet(out)
date_df = pl.concat([existing, date_df], how="diagonal_relaxed").unique(
subset=["symbol", "date"], keep="last"
)
date_df = date_df.sort(["symbol", "date"])
self._atomic_write_parquet(date_df, out)
generation_asset = {
"kline_daily_enriched": "stock",
"kline_etf_enriched": "etf",
}.get(table)
if generation_asset is not None:
self._bump_matrix_data_generation(generation_asset)
if not existing.is_empty() and existing.equals(date_df):
continue
if publication is None:
self._atomic_write_parquet(date_df, out)
else:
publication.write_parquet(date_df, out)
if publication is not None:
publication.commit()
def merge_live_daily_asset(self, asset_type: str, df: pl.DataFrame) -> None:
"""按 symbol 合并当天指定资产日K分区。用于少量自选实时,不覆盖全市场。"""
@@ -2139,6 +2195,35 @@ class KlineRepository:
subset=["symbol", "date"], keep="last"
)
merged_cache = merged_cache.sort(["symbol"])
from app.indicators.pipeline import ENRICHED_STORAGE_COLS
storage_cols = [c for c in ENRICHED_STORAGE_COLS if c in df.columns]
df_storage = df.select(storage_cols).sort(["symbol"])
base = self.store.data_dir / table
ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
out = base / f"date={ds}" / "part.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
publication = (
EnrichedPublication(self.store.data_dir, asset_type)
if asset_type in {"stock", "etf"}
else None
)
with self._write_lock:
existing = pl.DataFrame()
if out.exists():
existing = pl.read_parquet(out)
df_storage = pl.concat([existing, df_storage], how="diagonal_relaxed").unique(
subset=["symbol", "date"], keep="last"
)
df_storage = df_storage.sort(["symbol"])
if existing.is_empty() or not existing.equals(df_storage):
if publication is None:
self._atomic_write_parquet(df_storage, out)
else:
publication.write_parquet(df_storage, out)
if publication is not None:
publication.commit()
if asset_type == "stock":
self._enriched_cache = merged_cache
self._enriched_cache_date = dt
@@ -2149,23 +2234,6 @@ class KlineRepository:
self._index_enriched_cache = merged_cache
self._index_enriched_cache_date = dt
from app.indicators.pipeline import ENRICHED_STORAGE_COLS
storage_cols = [c for c in ENRICHED_STORAGE_COLS if c in df.columns]
df_storage = df.select(storage_cols).sort(["symbol"])
base = self.store.data_dir / table
ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
out = base / f"date={ds}" / "part.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
with self._write_lock:
if out.exists():
existing = pl.read_parquet(out)
df_storage = pl.concat([existing, df_storage], how="diagonal_relaxed").unique(
subset=["symbol", "date"], keep="last"
)
self._atomic_write_parquet(df_storage.sort(["symbol"]), out)
if asset_type in {"stock", "etf"}:
self._bump_matrix_data_generation(asset_type)
def flush_live_daily(self, df: pl.DataFrame) -> None:
"""覆写当天 kline_daily 分区 (实时行情落盘, 非merge)。"""
if df.is_empty() or "date" not in df.columns:
@@ -2205,16 +2273,10 @@ class KlineRepository:
dt = df["date"][0]
cache_df = self._with_instrument_metadata(asset_type, df).sort(["symbol"])
if asset_type == "stock":
self._enriched_cache = cache_df
self._enriched_cache_date = dt
table = "kline_daily_enriched"
elif asset_type == "etf":
self._etf_enriched_cache = cache_df
self._etf_enriched_cache_date = dt
table = "kline_etf_enriched"
elif asset_type == "index":
self._index_enriched_cache = cache_df
self._index_enriched_cache_date = dt
table = "kline_index_enriched"
else:
return
@@ -2226,7 +2288,27 @@ class KlineRepository:
ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
out = base / f"date={ds}" / "part.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
publication = (
EnrichedPublication(self.store.data_dir, asset_type)
if asset_type in {"stock", "etf"}
else None
)
with self._write_lock:
self._atomic_write_parquet(df_storage, out)
if asset_type in {"stock", "etf"}:
self._bump_matrix_data_generation(asset_type)
existing = pl.read_parquet(out) if out.exists() else pl.DataFrame()
if existing.is_empty() or not existing.equals(df_storage):
if publication is None:
self._atomic_write_parquet(df_storage, out)
else:
publication.write_parquet(df_storage, out)
if publication is not None:
publication.commit()
if asset_type == "stock":
self._enriched_cache = cache_df
self._enriched_cache_date = dt
elif asset_type == "etf":
self._etf_enriched_cache = cache_df
self._etf_enriched_cache_date = dt
elif asset_type == "index":
self._index_enriched_cache = cache_df
self._index_enriched_cache_date = dt