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
+135 -27
View File
@@ -26,6 +26,7 @@ from app.backtest.matrix import (
load_market_data_matrix_from_parquet,
)
from app.config import settings
from app.enriched_generation import EnrichedGenerationUnavailableError
from app.parquet import scan_enriched_parquet
from app.tickflow.repository import KlineRepository
@@ -213,8 +214,11 @@ class PanelCache:
columns: list[str] | None,
compute_fn,
asset_type: str = "stock",
generation: str | None = None,
) -> pl.DataFrame:
key = self._make_key(symbols, start, end, columns, asset_type)
key = self._make_key(
symbols, start, end, columns, asset_type, generation
)
now = time.monotonic()
with self._lock:
@@ -280,13 +284,20 @@ class PanelCache:
self._cache.clear()
@staticmethod
def _make_key(symbols: list[str] | None, start: date, end: date, columns: list[str] | None, asset_type: str = "stock") -> str:
def _make_key(
symbols: list[str] | None,
start: date,
end: date,
columns: list[str] | None,
asset_type: str = "stock",
generation: str | None = None,
) -> str:
if symbols is None:
h = "all"
else:
h = hashlib.md5(",".join(sorted(symbols)).encode()).hexdigest()[:12]
cols = "all" if columns is None else hashlib.md5(",".join(sorted(columns)).encode()).hexdigest()[:8]
return f"{asset_type}:{h}:{start}:{end}:{cols}"
return f"{asset_type}:{generation or 'unmanaged'}:{h}:{start}:{end}:{cols}"
# ================================================================
@@ -302,6 +313,23 @@ class BacktestEngine:
# ── 数据加载 ──────────────────────────────────────
def data_generation(self, asset_type: str = "stock") -> str | None:
loader = getattr(self.repo, "get_matrix_data_generation", None)
return loader(asset_type) if callable(loader) else None
def assert_data_generation(
self,
asset_type: str,
expected: str | None,
) -> None:
if expected is None:
return
current = self.data_generation(asset_type)
if current != expected:
raise EnrichedGenerationUnavailableError(
"enriched data changed while the snapshot was being read"
)
def load_panel(
self,
symbols: list[str] | None,
@@ -309,9 +337,36 @@ class BacktestEngine:
end: date,
columns: list[str] | None = None,
asset_type: str = "stock",
*,
expected_generation: str | None = None,
) -> pl.DataFrame:
"""加载 enriched 数据面板,带缓存。asset_type='etf' 时读 ETF enriched。"""
return self._cache.get_or_compute(symbols, start, end, columns, self._load_panel_inner, asset_type=asset_type)
attempts = 1 if expected_generation is not None else 2
for attempt in range(attempts):
generation = (
expected_generation
if expected_generation is not None
else self.data_generation(asset_type)
)
panel = self._cache.get_or_compute(
symbols,
start,
end,
columns,
self._load_panel_inner,
asset_type=asset_type,
generation=generation,
)
try:
self.assert_data_generation(asset_type, generation)
except EnrichedGenerationUnavailableError:
if attempt + 1 >= attempts:
raise
continue
return panel
raise EnrichedGenerationUnavailableError(
"unable to read a stable enriched data snapshot"
)
def load_panel_for_backtest(
self,
@@ -338,6 +393,25 @@ class BacktestEngine:
if df.is_empty():
return df
from app.backtest.fundamentals import (
attach_fundamental_factors,
load_fundamental_snapshot,
)
fundamental_names = sorted(
getattr(feature_plan, "fundamental_columns", frozenset())
or frozenset()
)
if fundamental_names:
# 财务因子列不落 enriched 存储, 在加载口按公告日门控并入。
df = attach_fundamental_factors(
df,
load_fundamental_snapshot(
self.repo.store.data_dir if self.repo is not None else None
),
fundamental_names,
)
instruments = (
self.repo.get_instruments_asset(asset_type)
if self.repo is not None
@@ -402,6 +476,8 @@ class BacktestEngine:
cache_profile: MatrixCacheProfile | None = None,
coverage_start: date | None = None,
coverage_end: date | None = None,
expected_generation: str | None = None,
cancel_event: threading.Event | None = None,
) -> MarketDataMatrix:
"""Load a matrix-native backtest directly from projected parquet batches."""
if feature_plan.execution_backend != "matrix_native":
@@ -432,35 +508,67 @@ class BacktestEngine:
)
generation_loader = getattr(self.repo, "get_matrix_data_generation", None)
source_generation = (
generation_loader(asset_type)
if cache_root is not None and callable(generation_loader)
else None
)
try:
return load_market_data_matrix_from_parquet(
parquet_root,
start,
end,
field_columns=field_columns,
symbols=symbols,
instruments=instruments,
cache_root=cache_root,
coverage_start=coverage_start,
coverage_end=coverage_end,
cache_field_columns=cache_fields,
cache_max_bytes=cache_max_bytes,
profile_generation=(
cache_profile.generation if cache_profile is not None else "request"
),
source_generation=source_generation,
expected_generation
if expected_generation is not None
else (
generation_loader(asset_type)
if callable(generation_loader)
else None
)
except pa.ArrowException as exc:
raise ValueError(f"direct market matrix parquet scan failed: {exc}") from exc
)
attempts = 1 if expected_generation is not None else 2
for attempt in range(attempts):
try:
market = load_market_data_matrix_from_parquet(
parquet_root,
start,
end,
field_columns=field_columns,
symbols=symbols,
instruments=instruments,
cache_root=cache_root,
coverage_start=coverage_start,
coverage_end=coverage_end,
cache_field_columns=cache_fields,
cache_max_bytes=cache_max_bytes,
profile_generation=(
cache_profile.generation if cache_profile is not None else "request"
),
source_generation=source_generation,
cancel_event=cancel_event,
)
self.assert_data_generation(asset_type, source_generation)
from app.backtest.fundamentals import attach_matrix_fundamental_fields
fundamental_names = sorted(
getattr(feature_plan, "fundamental_columns", frozenset())
or frozenset()
)
if fundamental_names:
# 财务因子不落 enriched 存储: 矩阵加载后按公告日门控附加字段。
market = attach_matrix_fundamental_fields(
market,
self.repo.store.data_dir if self.repo is not None else None,
fundamental_names,
)
return market
except EnrichedGenerationUnavailableError:
if attempt + 1 >= attempts:
raise
source_generation = self.data_generation(asset_type)
except pa.ArrowException as exc:
raise ValueError(f"direct market matrix parquet scan failed: {exc}") from exc
raise EnrichedGenerationUnavailableError(
"unable to read a stable enriched matrix snapshot"
)
def cache_stats(self) -> dict:
"""暴露 PanelCache 遥测快照 (扫盘耗时/次数/命中/复用), 供上层量化 IO 占比。"""
return self._cache.stats()
def clear_panel_cache(self) -> None:
self._cache.invalidate()
def _load_panel_inner(
self,
symbols: list[str] | None,