重置项目

This commit is contained in:
2026-07-04 15:59:20 +08:00
parent 374e587f2d
commit 648a8b7f1c
224 changed files with 19700 additions and 9547 deletions
+514 -8
View File
@@ -13,6 +13,7 @@
from __future__ import annotations
import logging
import sys
import threading
from datetime import date
from pathlib import Path
@@ -32,17 +33,26 @@ class DataStore:
self.data_dir = Path(data_dir or settings.data_dir)
self.data_dir.mkdir(parents=True, exist_ok=True)
# 一次性数据迁移: 旧桌面版把数据放在 exe 同级的兄弟目录 TickFlowStockPanel_Data/,
# 新版改为 {app}/data/。老用户首次启动时自动把旧数据搬过来, 无感升级。
self._migrate_legacy_data_dir()
# 关键子目录(§7.2)
for sub in (
"kline_daily",
"kline_daily_enriched",
"kline_index_daily",
"kline_index_enriched",
"kline_etf_daily",
"kline_etf_enriched",
"kline_etf_minute",
"kline_minute",
"adj_factor",
"adj_factor_etf",
"financials",
"instruments",
"instruments_index",
"instruments_etf",
"instruments_ext",
"kline_ext",
"pools",
@@ -62,6 +72,64 @@ class DataStore:
self.db = duckdb.connect(database=":memory:")
self._register_views()
def _migrate_legacy_data_dir(self) -> None:
"""把旧桌面版数据目录 (<安装目录>/../TickFlowStockPanel_Data/) 迁移到新位置 (<安装目录>/data/)。
背景: 旧版 data_dir = exe_dir.parent / "TickFlowStockPanel_Data" (兄弟目录),
新版改为 exe_dir / "data" (子目录)。老用户首次升级时旧数据在兄弟目录,
若不迁移会导致历史行情/策略/回测/监控全部"丢失"(实际还在旧位置)。
策略 (仅打包桌面版触发, 开发/Docker 不受影响):
1. 旧目录存在且新 data/ 还基本为空 → 整目录搬迁 (shutil.move, 跨盘符安全)。
2. 新旧目录都已有数据 (用户在两套路径都跑过) → 不自动搬, 仅记日志, 避免覆盖。
3. 旧目录不存在 → 新装用户, 无需迁移。
所有异常都吞掉只记警告 —— 数据迁移失败绝不能阻塞应用启动。
"""
# 仅打包桌面版需要迁移; 开发/Docker 模式 _PROJECT_ROOT/data 本就是唯一路径
if not getattr(sys, "frozen", False):
return
import shutil
try:
legacy_dir = self.data_dir.parent / "TickFlowStockPanel_Data"
if not legacy_dir.exists():
return # 新装用户, 无旧数据
# 新 data/ 目录里已有实质性内容 → 用户已在新路径跑过, 不覆盖
# (用 .parquet 作为"有真实数据"的判据, 避免空子目录误判)
has_new_data = any(self.data_dir.rglob("*.parquet")) or any(
self.data_dir.rglob("*.jsonl")
)
if has_new_data:
logger.info(
"legacy data dir %s exists but new %s already has data, skip migration",
legacy_dir, self.data_dir,
)
return
logger.info("migrating legacy data %s -> %s", legacy_dir, self.data_dir)
# 逐项 move 而非整目录 move: data/ 可能已被 __init__ 创建了空子目录,
# 直接 shutil.move(legacy, data) 会因目标已存在失败。
for item in legacy_dir.iterdir():
dest = self.data_dir / item.name
if dest.exists():
# 同名子目录 (如 kline_daily): 合并内容
if dest.is_dir():
shutil.move(str(item), str(dest / item.name))
else:
item.unlink() # 同名文件, 以新路径为准, 删旧
else:
shutil.move(str(item), str(dest))
# 搬完后清理空的旧目录
try:
shutil.rmtree(legacy_dir)
except OSError:
logger.warning("legacy dir %s not empty, kept", legacy_dir)
logger.info("legacy data migration done")
except Exception as e: # noqa: BLE001
logger.warning("legacy data migration failed (startup continues): %s", e)
def _register_views(self) -> None:
"""把 Parquet 目录挂载为 DuckDB 视图(§7.3)。"""
d = self.data_dir.as_posix()
@@ -74,14 +142,24 @@ class DataStore:
SELECT * FROM read_parquet('{d}/kline_index_daily/**/*.parquet', union_by_name=true)""",
f"""CREATE OR REPLACE VIEW kline_index_enriched AS
SELECT * FROM read_parquet('{d}/kline_index_enriched/**/*.parquet', union_by_name=true)""",
f"""CREATE OR REPLACE VIEW kline_etf_daily AS
SELECT * FROM read_parquet('{d}/kline_etf_daily/**/*.parquet', union_by_name=true)""",
f"""CREATE OR REPLACE VIEW kline_etf_enriched AS
SELECT * FROM read_parquet('{d}/kline_etf_enriched/**/*.parquet', union_by_name=true)""",
f"""CREATE OR REPLACE VIEW kline_etf_minute AS
SELECT * FROM read_parquet('{d}/kline_etf_minute/**/*.parquet', union_by_name=true)""",
f"""CREATE OR REPLACE VIEW kline_minute AS
SELECT * FROM read_parquet('{d}/kline_minute/**/*.parquet', union_by_name=true)""",
f"""CREATE OR REPLACE VIEW adj_factor AS
SELECT * FROM read_parquet('{d}/adj_factor/**/*.parquet', union_by_name=true)""",
f"""CREATE OR REPLACE VIEW adj_factor_etf AS
SELECT * FROM read_parquet('{d}/adj_factor_etf/**/*.parquet', union_by_name=true)""",
f"""CREATE OR REPLACE VIEW instruments AS
SELECT * FROM read_parquet('{d}/instruments/**/*.parquet', union_by_name=true)""",
f"""CREATE OR REPLACE VIEW instruments_index AS
SELECT * FROM read_parquet('{d}/instruments_index/**/*.parquet', union_by_name=true)""",
f"""CREATE OR REPLACE VIEW instruments_etf AS
SELECT * FROM read_parquet('{d}/instruments_etf/**/*.parquet', union_by_name=true)""",
f"""CREATE OR REPLACE VIEW instruments_ext AS
SELECT * FROM read_parquet('{d}/instruments_ext/**/*.parquet', union_by_name=true)""",
f"""CREATE OR REPLACE VIEW kline_ext AS
@@ -104,6 +182,91 @@ class DataStore:
self.db.execute(sql)
except duckdb.IOException:
logger.debug("view registration skipped (no parquet yet): %s", sql[:60])
self._register_unified_views()
def _has_parquet(self, subdir: str) -> bool:
return any((self.data_dir / subdir).rglob("*.parquet"))
def _register_unified_views(self) -> None:
"""Register optional all-asset views when their backing parquet exists.
Physical storage remains split for performance and compatibility. These
views are convenience read models for new APIs/features.
"""
daily_parts: list[str] = []
enriched_parts: list[str] = []
minute_parts: list[str] = []
inst_parts: list[str] = []
if self._has_parquet("kline_daily"):
daily_parts.append("""
SELECT symbol, date, open, high, low, close, volume, amount,
'stock' AS asset_type, 'tickflow' AS source
FROM kline_daily
""")
if self._has_parquet("kline_index_daily"):
daily_parts.append("""
SELECT symbol, date, open, high, low, close, volume, amount,
'index' AS asset_type, 'tickflow' AS source
FROM kline_index_daily
""")
if self._has_parquet("kline_etf_daily"):
daily_parts.append("""
SELECT symbol, date, open, high, low, close, volume, amount,
'etf' AS asset_type, 'tickflow' AS source
FROM kline_etf_daily
""")
if self._has_parquet("kline_daily_enriched"):
enriched_parts.append("SELECT *, 'stock' AS asset_type, 'tickflow' AS source FROM kline_enriched")
if self._has_parquet("kline_index_enriched"):
enriched_parts.append("SELECT *, 'index' AS asset_type, 'tickflow' AS source FROM kline_index_enriched")
if self._has_parquet("kline_etf_enriched"):
enriched_parts.append("SELECT *, 'etf' AS asset_type, 'tickflow' AS source FROM kline_etf_enriched")
if self._has_parquet("kline_minute"):
minute_parts.append("""
SELECT symbol, datetime, open, high, low, close, volume, amount,
'stock' AS asset_type, 'tickflow' AS source
FROM kline_minute
""")
if self._has_parquet("kline_etf_minute"):
minute_parts.append("""
SELECT symbol, datetime, open, high, low, close, volume, amount,
'etf' AS asset_type, 'tickflow' AS source
FROM kline_etf_minute
""")
if self._has_parquet("instruments"):
inst_parts.append("""
SELECT symbol, name, code, exchange, 'stock' AS asset_type, 'tickflow' AS source
FROM instruments
""")
if self._has_parquet("instruments_index"):
inst_parts.append("""
SELECT symbol, name, code, NULL AS exchange, 'index' AS asset_type, 'tickflow' AS source
FROM instruments_index
WHERE coalesce(asset_type, 'index') != 'etf'
""")
if self._has_parquet("instruments_etf"):
inst_parts.append("""
SELECT symbol, name, code, NULL AS exchange, 'etf' AS asset_type, 'tickflow' AS source
FROM instruments_etf
""")
unions = {
"kline_daily_all": daily_parts,
"kline_enriched_all": enriched_parts,
"kline_minute_all": minute_parts,
"instruments_all": inst_parts,
}
for name, parts in unions.items():
if not parts:
continue
try:
self.db.execute(f"CREATE OR REPLACE VIEW {name} AS " + " UNION ALL BY NAME ".join(parts))
except Exception as e: # noqa: BLE001
logger.debug("unified view %s skipped: %s", name, e)
class KlineRepository:
@@ -119,18 +282,27 @@ class KlineRepository:
self._enriched_cache_date: date | None = None
self._live_agg_cache: pl.DataFrame | None = None # 预计算聚合表 (~5500行)
self._live_agg_cache_date: date | None = None
self._live_agg_check_date: date | None = None # 上次跨日校验时的 today (快路径节流)
self._instruments_cache: pl.DataFrame | None = None
# 完整 enriched 历史 (含所有指标, 供 filter_history 策略使用)
self._enriched_history_cache: pl.DataFrame | None = None # ~100万行
self._enriched_history_start: date | 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
self._etf_live_agg_cache: pl.DataFrame | None = None
self._etf_live_agg_cache_date: date | None = None
self._etf_instruments_cache: pl.DataFrame | None = None
# parquet glob 路径
self._enriched_glob = str(store.data_dir / "kline_daily_enriched" / "**" / "*.parquet")
self._index_enriched_glob = str(store.data_dir / "kline_index_enriched" / "**" / "*.parquet")
self._etf_enriched_glob = str(store.data_dir / "kline_etf_enriched" / "**" / "*.parquet")
self._minute_glob = str(store.data_dir / "kline_minute" / "**" / "*.parquet")
self._etf_minute_glob = str(store.data_dir / "kline_etf_minute" / "**" / "*.parquet")
self._inst_glob = str(store.data_dir / "instruments" / "**" / "*.parquet")
self._index_inst_glob = str(store.data_dir / "instruments_index" / "**" / "*.parquet")
self._etf_inst_glob = str(store.data_dir / "instruments_etf" / "**" / "*.parquet")
def execute_all(self, sql: str, params: list | None = None) -> list[tuple]:
"""线程安全的 SELECT → fetchall。DuckDB 单 connection 非线程安全,所有读路径须走此方法。"""
@@ -150,6 +322,7 @@ class KlineRepository:
"""刷新 Polars 缓存。在 pipeline 完成后、服务启动时调用。"""
self._refresh_instruments()
self._refresh_index_instruments()
self._refresh_etf_instruments()
self._refresh_enriched()
def clear_cache(self) -> None:
@@ -165,8 +338,14 @@ class KlineRepository:
self._enriched_history_start = None
self._live_agg_cache = None
self._live_agg_cache_date = None
self._live_agg_check_date = None
self._instruments_cache = None
self._index_instruments_cache = None
self._etf_enriched_cache = None
self._etf_enriched_cache_date = None
self._etf_live_agg_cache = None
self._etf_live_agg_cache_date = None
self._etf_instruments_cache = None
def _refresh_enriched(self) -> None:
"""从 parquet 加载 enriched 最新日到内存 + 构建聚合表。
@@ -462,6 +641,47 @@ class KlineRepository:
return df_hist, agg_a
def _refresh_etf_enriched(self) -> None:
"""从 ETF enriched parquet 加载最新日到内存缓存。"""
try:
enriched_dir = self.store.data_dir / "kline_etf_enriched"
dates = sorted(
p.name[5:] for p in enriched_dir.glob("date=*")
if p.is_dir() and p.name.startswith("date=")
) if enriched_dir.exists() else []
if not dates:
self._etf_enriched_cache = None
self._etf_enriched_cache_date = None
return
latest = date.fromisoformat(dates[-1])
target_parquet = enriched_dir / f"date={dates[-1]}" / "part.parquet"
df_latest = pl.read_parquet(target_parquet)
if df_latest.is_empty():
return
from datetime import timedelta
from app.indicators.pipeline import compute_indicators, compute_signals
start_full = latest - timedelta(days=300)
read_cols = [c for c in ["symbol", "date", "open", "high", "low", "close",
"volume", "amount", "raw_close", "raw_high", "raw_low"]
if c in df_latest.columns]
df_hist = (
pl.scan_parquet(self._etf_enriched_glob,
cast_options=pl.ScanCastOptions(integer_cast="allow-float"))
.filter(pl.col("date") >= start_full)
.select(read_cols)
.sort(["symbol", "date"])
.collect()
)
if df_hist.is_empty():
self._etf_enriched_cache = df_latest.sort(["symbol"])
else:
df_full = compute_signals(compute_indicators(df_hist))
self._etf_enriched_cache = df_full.filter(pl.col("date") == latest).sort(["symbol"])
self._etf_enriched_cache_date = latest
except Exception as e: # noqa: BLE001
logger.debug("ETF enriched 缓存刷新跳过: %s", e)
def _refresh_instruments(self) -> None:
"""加载 instruments 到内存。"""
try:
@@ -482,6 +702,28 @@ class KlineRepository:
except Exception as e: # noqa: BLE001
logger.debug("index instruments 缓存刷新跳过: %s", e)
def _refresh_etf_instruments(self) -> None:
"""加载 ETF instruments 到内存;兼容旧版 instruments_index 中的 ETF。"""
parts: list[pl.DataFrame] = []
try:
df = pl.scan_parquet(self._etf_inst_glob).collect()
if not df.is_empty():
parts.append(df)
except Exception as e: # noqa: BLE001
logger.debug("etf instruments 缓存刷新跳过(new): %s", e)
try:
legacy = self.get_index_instruments()
if not legacy.is_empty() and "asset_type" in legacy.columns:
legacy = legacy.filter(pl.col("asset_type") == "etf")
if not legacy.is_empty():
parts.append(legacy)
except Exception as e: # noqa: BLE001
logger.debug("etf instruments legacy fallback skipped: %s", e)
if parts:
df_all = pl.concat(parts, how="diagonal_relaxed").unique(subset=["symbol"], keep="last").sort("symbol")
self._etf_instruments_cache = df_all
logger.info("ETF instruments 缓存已加载: %d", len(df_all))
def get_enriched_latest(self) -> tuple[pl.DataFrame, date | None]:
"""返回缓存的 enriched 最新日 DataFrame + 日期。如无缓存则懒加载。"""
if self._enriched_cache is None:
@@ -490,6 +732,18 @@ class KlineRepository:
return pl.DataFrame(), self._enriched_cache_date
return self._enriched_cache, self._enriched_cache_date
def get_enriched_latest_asset(self, asset_type: str) -> tuple[pl.DataFrame, date | None]:
"""按资产类型返回最新 enriched 缓存。stock 保持旧缓存语义。"""
if asset_type == "stock":
return self.get_enriched_latest()
if asset_type == "etf":
if self._etf_enriched_cache is None:
self._refresh_etf_enriched()
if self._etf_enriched_cache is None:
return pl.DataFrame(), self._etf_enriched_cache_date
return self._etf_enriched_cache, self._etf_enriched_cache_date
return pl.DataFrame(), None
def get_enriched_history(self, target_date: date, lookback_days: int) -> pl.DataFrame | None:
"""返回预计算的 enriched 历史数据 (仅 lookback 范围, 不含 warmup)。
@@ -544,9 +798,36 @@ class KlineRepository:
return df.sort(["symbol", "date"])
def get_live_agg(self) -> pl.DataFrame:
"""返回盘中实时指标预计算聚合表。如无缓存则懒加载。"""
"""返回盘中实时指标预计算聚合表。如无缓存则懒加载。
live_agg 的核心列 _prev_consec_up/down (昨日连板数) 取自基准日 enriched。
基准日由 _live_agg_baseline_date 决定: 盘中(today 有实时分区) 取上一交易日,
非盘中(磁盘最新日 < today) 取该最新日本身。一旦跨日, 期望基准日会前移,
旧缓存会把连板数整体少算一档, 故这里除首次懒加载外还要校验基准日是否仍
符合当前预期, 不符则重建 (无需等盘后管道刷缓存)。
性能: get_live_agg 被每轮实时行情调用 (expert 档 1s 一次)。跨日只在
date.today() 翻天时发生, 故先用 today 做廉价的 fast-path (μs 级),
仅当 today 变化时才查磁盘确认 (DuckDB 扫 132 万行约 100ms+) 并按需重建。
"""
if self._live_agg_cache is None:
self._refresh_enriched()
self._live_agg_check_date = date.today() # 刚建过, 当天不必再查磁盘
else:
today = date.today()
if self._live_agg_check_date != today:
# today 翻天了 (次日开盘首次轮询): 校验基准日是否需要前移重建。
# 同一天内多次调用直接跳过, 避免每轮都扫 parquet。
self._live_agg_check_date = today
disk_latest = self._latest_enriched_date_duckdb()
if disk_latest is not None:
expected = self._live_agg_baseline_date(disk_latest)
if self._live_agg_cache_date != expected:
logger.info(
"live_agg 跨日失效, 重建: 缓存基准=%s, 期望基准=%s",
self._live_agg_cache_date, expected,
)
self._refresh_enriched()
if self._live_agg_cache is None:
return pl.DataFrame()
return self._live_agg_cache
@@ -567,6 +848,27 @@ class KlineRepository:
return pl.DataFrame()
return self._index_instruments_cache
def get_etf_instruments(self) -> pl.DataFrame:
"""返回缓存的 ETF instruments DataFrame;兼容旧版 instruments_index 中的 ETF。"""
if self._etf_instruments_cache is None:
self._refresh_etf_instruments()
if self._etf_instruments_cache is None:
return pl.DataFrame()
return self._etf_instruments_cache
def get_instruments_asset(self, asset_type: str) -> pl.DataFrame:
"""按资产类型返回 instruments;老 stock 路径保持原样。"""
if asset_type == "stock":
return self.get_instruments()
if asset_type == "index":
df = self.get_index_instruments()
if not df.is_empty() and "asset_type" in df.columns:
return df.filter(pl.col("asset_type") != "etf")
return df
if asset_type == "etf":
return self.get_etf_instruments()
return pl.DataFrame()
def get_index_symbol_set(self) -> set[str]:
"""返回已缓存指数 symbol 集合。"""
df = self.get_index_instruments()
@@ -656,6 +958,45 @@ class KlineRepository:
df = df.select(existing)
return df
def get_etf_daily(
self,
symbol: str,
start: date,
end: date,
columns: list[str] | None = None,
) -> pl.DataFrame:
"""ETF 日K查询 — 优先读独立 ETF enriched,兼容旧版 index enriched 中的 ETF。"""
from datetime import timedelta
warmup_start = start - timedelta(days=150)
df = self._scan_etf_daily_symbol(symbol, warmup_start, end, None)
if df.is_empty():
# 旧版 ETF 曾存入 kline_index_enriched;没有独立数据时回退读取。
df = self._scan_index_daily_symbol(symbol, warmup_start, end, None)
if not df.is_empty():
df = self._compute_index_enriched_range(df)
df = df.filter((pl.col("date") >= start) & (pl.col("date") <= end))
if columns and not df.is_empty():
existing = [c for c in columns if c in df.columns]
df = df.select(existing)
return df
def get_daily_asset(
self,
asset_type: str,
symbol: str,
start: date,
end: date,
columns: list[str] | None = None,
) -> pl.DataFrame:
if asset_type == "stock":
return self.get_daily(symbol, start, end, columns)
if asset_type == "index":
return self.get_index_daily(symbol, start, end, columns)
if asset_type == "etf":
return self.get_etf_daily(symbol, start, end, columns)
return pl.DataFrame()
def get_minute(
self,
symbol: str,
@@ -770,6 +1111,23 @@ class KlineRepository:
logger.warning("指数日K查询失败: %s", e)
return pl.DataFrame()
def _scan_etf_daily_symbol(self, symbol: str, start: date, end: date, columns: list[str] | None) -> pl.DataFrame:
try:
lf = pl.scan_parquet(self._etf_enriched_glob,
cast_options=pl.ScanCastOptions(integer_cast="allow-float")).filter(
(pl.col("symbol") == symbol)
& (pl.col("date") >= start)
& (pl.col("date") <= end)
).sort("date")
if columns:
schema_names = lf.collect_schema().names()
existing = [c for c in columns if c in schema_names]
lf = lf.select(existing)
return lf.collect()
except Exception as e: # noqa: BLE001
logger.debug("ETF 日K查询跳过: %s", e)
return pl.DataFrame()
def _merge_cached_and_scan(
self,
cached: pl.DataFrame,
@@ -911,6 +1269,39 @@ class KlineRepository:
df_storage = df.select(storage_cols)
self._write_daily_partition(df_storage, "kline_index_enriched")
def append_etf_daily(self, df: pl.DataFrame) -> None:
"""按日分区写入 ETF 日K数据 (merge-upsert)。"""
if df.is_empty():
return
self._write_daily_partition(df, "kline_etf_daily")
def append_etf_enriched(self, df: pl.DataFrame) -> None:
"""按日分区写入 ETF enriched 数据。磁盘仅写入基础行情窄表。"""
if df.is_empty():
return
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)
self._write_daily_partition(df_storage, "kline_etf_enriched")
def append_daily_asset(self, asset_type: str, df: pl.DataFrame) -> None:
"""按资产类型写入日K;stock/index 保持旧目录兼容。"""
if asset_type == "stock":
self.append_daily(df)
elif asset_type == "index":
self.append_index_daily(df)
elif asset_type == "etf":
self.append_etf_daily(df)
def append_enriched_asset(self, asset_type: str, df: pl.DataFrame) -> None:
"""按资产类型写入 enrichedstock/index 保持旧目录兼容。"""
if asset_type == "stock":
self.append_enriched(df)
elif asset_type == "index":
self.append_index_enriched(df)
elif asset_type == "etf":
self.append_etf_enriched(df)
def save_index_instruments(self, df: pl.DataFrame) -> None:
"""保存指数标的维表。"""
if df.is_empty() or "symbol" not in df.columns:
@@ -919,8 +1310,21 @@ class KlineRepository:
out.parent.mkdir(parents=True, exist_ok=True)
df.unique(subset=["symbol"], keep="last").sort("symbol").write_parquet(out)
self._index_instruments_cache = None
self._etf_instruments_cache = None
self._refresh_index_instruments()
def save_etf_instruments(self, df: pl.DataFrame) -> None:
"""保存 ETF 标的维表到独立目录。"""
if df.is_empty() or "symbol" not in df.columns:
return
if "asset_type" not in df.columns:
df = df.with_columns(pl.lit("etf").alias("asset_type"))
out = self.store.data_dir / "instruments_etf" / "instruments_etf.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
df.unique(subset=["symbol"], keep="last").sort("symbol").write_parquet(out)
self._etf_instruments_cache = None
self._refresh_etf_instruments()
def refresh_index_views(self) -> None:
"""刷新指数相关 DuckDB 视图。"""
d = self.store.data_dir.as_posix()
@@ -929,15 +1333,23 @@ class KlineRepository:
SELECT * FROM read_parquet('{d}/kline_index_daily/**/*.parquet', union_by_name=true)""",
f"""CREATE OR REPLACE VIEW kline_index_enriched AS
SELECT * FROM read_parquet('{d}/kline_index_enriched/**/*.parquet', union_by_name=true)""",
f"""CREATE OR REPLACE VIEW kline_etf_daily AS
SELECT * FROM read_parquet('{d}/kline_etf_daily/**/*.parquet', union_by_name=true)""",
f"""CREATE OR REPLACE VIEW kline_etf_enriched AS
SELECT * FROM read_parquet('{d}/kline_etf_enriched/**/*.parquet', union_by_name=true)""",
f"""CREATE OR REPLACE VIEW instruments_index AS
SELECT * FROM read_parquet('{d}/instruments_index/**/*.parquet', union_by_name=true)""",
f"""CREATE OR REPLACE VIEW instruments_etf AS
SELECT * FROM read_parquet('{d}/instruments_etf/**/*.parquet', union_by_name=true)""",
]
for sql in statements:
try:
with self._lock:
self.db.execute(sql)
except Exception as e: # noqa: BLE001
logger.debug("index view refresh skipped: %s", e)
logger.debug("index/etf view refresh skipped: %s", e)
with self._lock:
self.store._register_unified_views()
def _write_daily_partition(self, df: pl.DataFrame, table: str) -> None:
"""按 date 分区写入 parquet,每个日期一个文件,支持 merge-upsert。"""
@@ -955,11 +1367,92 @@ class KlineRepository:
date_df = date_df.sort(["symbol", "date"])
date_df.write_parquet(out)
def merge_live_daily_asset(self, asset_type: str, df: pl.DataFrame) -> None:
"""按 symbol 合并当天指定资产日K分区。用于少量自选实时,不覆盖全市场。"""
if df.is_empty() or "date" not in df.columns:
return
table = {
"stock": "kline_daily",
"index": "kline_index_daily",
"etf": "kline_etf_daily",
}.get(asset_type)
if not table:
return
base = self.store.data_dir / table
dt = 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)
date_df = df.sort(["symbol", "date"])
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.sort(["symbol", "date"]).write_parquet(out)
def merge_live_enriched_asset(self, asset_type: str, df: pl.DataFrame) -> None:
"""按 symbol 合并当天 enriched 分区和内存缓存。用于少量自选实时。"""
if df.is_empty() or "date" not in df.columns:
return
dt = df["date"][0]
if asset_type == "stock":
table = "kline_daily_enriched"
existing_cache = self._enriched_cache if self._enriched_cache_date == dt else pl.DataFrame()
elif asset_type == "etf":
table = "kline_etf_enriched"
existing_cache = self._etf_enriched_cache if self._etf_enriched_cache_date == dt else pl.DataFrame()
elif asset_type == "index":
table = "kline_index_enriched"
existing_cache = pl.DataFrame()
else:
return
merged_cache = df
if existing_cache is not None and not existing_cache.is_empty():
merged_cache = pl.concat([existing_cache, df], how="diagonal_relaxed").unique(
subset=["symbol", "date"], keep="last"
)
merged_cache = merged_cache.sort(["symbol"])
if asset_type == "stock":
self._enriched_cache = merged_cache
self._enriched_cache_date = dt
elif asset_type == "etf":
self._etf_enriched_cache = merged_cache
self._etf_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)
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.sort(["symbol"]).write_parquet(out)
def flush_live_daily(self, df: pl.DataFrame) -> None:
"""覆写当天 kline_daily 分区 (实时行情落盘, 非merge)。"""
if df.is_empty() or "date" not in df.columns:
return
base = self.store.data_dir / "kline_daily"
self.flush_live_daily_asset("stock", df)
def flush_live_daily_asset(self, asset_type: str, df: pl.DataFrame) -> None:
"""覆写当天指定资产日K分区 (实时行情落盘, 非merge)。"""
if df.is_empty() or "date" not in df.columns:
return
table = {
"stock": "kline_daily",
"index": "kline_index_daily",
"etf": "kline_etf_daily",
}.get(asset_type)
if not table:
return
base = self.store.data_dir / table
dt = df["date"][0]
ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
out = base / f"date={ds}" / "part.parquet"
@@ -971,17 +1464,30 @@ class KlineRepository:
内存缓存保留完整指标列供各服务使用,磁盘仅写入 14 列存储列。
"""
self.flush_live_enriched_asset("stock", df)
def flush_live_enriched_asset(self, asset_type: str, df: pl.DataFrame) -> None:
"""覆写当天指定资产 enriched 分区 (实时 enriched 落盘, 非merge)。"""
if df.is_empty() or "date" not in df.columns:
return
# 内存缓存: 保留完整 66 列
self._enriched_cache = df.sort(["symbol"])
dt = df["date"][0]
self._enriched_cache_date = dt
# 磁盘写入: 仅 14 列存储列
if asset_type == "stock":
self._enriched_cache = df.sort(["symbol"])
self._enriched_cache_date = dt
table = "kline_daily_enriched"
elif asset_type == "etf":
self._etf_enriched_cache = df.sort(["symbol"])
self._etf_enriched_cache_date = dt
table = "kline_etf_enriched"
elif asset_type == "index":
table = "kline_index_enriched"
else:
return
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 / "kline_daily_enriched"
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)