重置项目
This commit is contained in:
@@ -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:
|
||||
"""按资产类型写入 enriched;stock/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)
|
||||
|
||||
Reference in New Issue
Block a user