mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user