mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 19:04:15 +08:00
- 市场环境: 新增情绪周期6阶段(冰点/启动/主升/高潮/退潮/修复, 连板梯队驱动, EMA平滑+2日确认+弱档否决, 平均段长9.7天)与概念/行业主线排名(涨停梯队聚合, 可配置宽基/风格标签过滤); 市场环境页重构, regime 透明加列, 与5档state并存 - 挖掘: 因子与策略挖掘全链路(API/worker/进程锁/候选库/前端工作台/文档), 周度调度默认关闭且永不自动发布 - 回测: 财务快照因子(点时口径), 批量回测预计算共享下期收益, 信号路径矩阵列依赖展开修复(consecutive_limit_ups 缺列报错) - 数据/性能: enriched 生成与预热治理, 重任务限流, 行情/K线缓存复用, 时区修复 - 测试: 后端全量 914 通过; GUI 黑盒验证截图存证 gui-test-screenshots/
168 lines
6.6 KiB
Python
168 lines
6.6 KiB
Python
"""指数资产路由 — repository 层测试。"""
|
|
import os
|
|
|
|
import polars as pl
|
|
import pytest
|
|
|
|
from app.tickflow.repository import DataStore, KlineRepository
|
|
|
|
|
|
@pytest.fixture()
|
|
def repo(tmp_path):
|
|
return KlineRepository(DataStore(tmp_path))
|
|
|
|
|
|
def _write_index_instruments(repo, rows):
|
|
pl.DataFrame(rows).write_parquet(
|
|
repo.store.data_dir / "instruments_index" / "part.parquet"
|
|
)
|
|
repo._refresh_index_instruments()
|
|
|
|
|
|
def test_name_map_includes_index(repo):
|
|
_write_index_instruments(repo, {
|
|
"symbol": ["000001.SH"], "name": ["上证指数"],
|
|
"code": ["000001"], "asset_type": ["index"],
|
|
})
|
|
names = repo.get_name_map(["000001.SH", "600000.SH"])
|
|
assert names.get("000001.SH") == "上证指数"
|
|
assert "600000.SH" not in names # 未收录不造名
|
|
|
|
|
|
def test_name_map_stock_beats_index(repo):
|
|
"""同名 symbol 同时出现在股票/指数维表时, 股票名称优先。"""
|
|
_write_index_instruments(repo, {
|
|
"symbol": ["600000.SH"], "name": ["某指数"],
|
|
"code": ["600000"], "asset_type": ["index"],
|
|
})
|
|
pl.DataFrame({
|
|
"symbol": ["600000.SH"], "name": ["浦发银行"], "code": ["600000"],
|
|
"exchange": ["SH"], "region": ["CN"], "type": ["stock"],
|
|
"listing_date": [None], "total_shares": [None], "float_shares": [None],
|
|
"tick_size": [None], "limit_up": [None], "limit_down": [None],
|
|
"as_of": ["2026-07-25"],
|
|
}).write_parquet(repo.store.data_dir / "instruments" / "instruments.parquet")
|
|
repo._refresh_instruments()
|
|
assert repo.get_name_map(["600000.SH"]).get("600000.SH") == "浦发银行"
|
|
|
|
|
|
def _write_stock_instruments(repo, symbols, names):
|
|
pl.DataFrame({
|
|
"symbol": symbols, "name": names, "code": [s[:6] for s in symbols],
|
|
"exchange": ["SH"] * len(symbols), "region": ["CN"] * len(symbols),
|
|
"type": ["stock"] * len(symbols),
|
|
"listing_date": [None] * len(symbols), "total_shares": [None] * len(symbols),
|
|
"float_shares": [None] * len(symbols), "tick_size": [None] * len(symbols),
|
|
"limit_up": [None] * len(symbols), "limit_down": [None] * len(symbols),
|
|
"as_of": ["2026-08-14"] * len(symbols),
|
|
}).write_parquet(repo.store.data_dir / "instruments" / "instruments.parquet")
|
|
repo._refresh_instruments()
|
|
|
|
|
|
def test_name_map_partial_query_does_not_poison_cache(repo):
|
|
"""带 symbols 的部分查询不能把残缺映射写入缓存 (自选新加股票无名称的回归).
|
|
|
|
旧 bug: 首次 get_name_map(["600000.SH"]) 把只含 600000 的映射缓存住,
|
|
之后自选加入 000001.SZ 再查名称命中残缺缓存 → name=None。
|
|
"""
|
|
_write_stock_instruments(repo, ["600000.SH", "000001.SZ"], ["浦发银行", "平安银行"])
|
|
first = repo.get_name_map(["600000.SH"])
|
|
assert first == {"600000.SH": "浦发银行"}
|
|
# 缓存必须是全量: 后续其他 symbols 查询仍能命中
|
|
second = repo.get_name_map(["000001.SZ"])
|
|
assert second == {"000001.SZ": "平安银行"}
|
|
full = repo.get_name_map()
|
|
assert full == {"600000.SH": "浦发银行", "000001.SZ": "平安银行"}
|
|
|
|
|
|
def test_name_map_cache_invalidated_on_instruments_refresh(repo):
|
|
"""维表刷新后缓存必须失效: 新收录的股票能立刻查到名称。"""
|
|
_write_stock_instruments(repo, ["600000.SH"], ["浦发银行"])
|
|
assert repo.get_name_map(["600000.SH"]) == {"600000.SH": "浦发银行"}
|
|
_write_stock_instruments(repo, ["600000.SH", "301999.SZ"], ["浦发银行", "新股股份"])
|
|
assert repo.get_name_map(["301999.SZ"]) == {"301999.SZ": "新股股份"}
|
|
|
|
|
|
import datetime as _dt
|
|
|
|
|
|
def test_execute_one_releases_parquet_file(repo):
|
|
minute_dir = repo.store.data_dir / "kline_minute" / "date=2026-07-23"
|
|
minute_dir.mkdir(parents=True, exist_ok=True)
|
|
part = minute_dir / "part.parquet"
|
|
replacement = minute_dir / "part.parquet.tmp"
|
|
minute = pl.DataFrame({
|
|
"symbol": ["600000.SH"],
|
|
"datetime": [_dt.datetime(2026, 7, 23, 9, 30)],
|
|
"close": [10.0],
|
|
})
|
|
minute.write_parquet(part)
|
|
repo.rebuild_views()
|
|
|
|
assert repo.execute_one("SELECT max(datetime) FROM kline_minute")[0] == _dt.datetime(2026, 7, 23, 9, 30)
|
|
|
|
minute.write_parquet(replacement)
|
|
os.replace(replacement, part)
|
|
|
|
|
|
def _write_index_enriched(repo, dates_rows):
|
|
for ds, rows in dates_rows.items():
|
|
d = repo.store.data_dir / "kline_index_enriched" / f"date={ds}"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
pl.DataFrame(rows).write_parquet(d / "part.parquet")
|
|
|
|
|
|
def _index_rows(ds, close=3000.0):
|
|
return [{
|
|
"symbol": "000001.SH", "date": _dt.date.fromisoformat(ds),
|
|
"open": close - 10, "high": close + 20, "low": close - 20, "close": close,
|
|
"volume": 1_000_000, "amount": 1e9,
|
|
}]
|
|
|
|
|
|
def test_get_enriched_latest_asset_index(repo):
|
|
_write_index_enriched(repo, {
|
|
"2026-07-23": _index_rows("2026-07-23", 2990.0),
|
|
"2026-07-24": _index_rows("2026-07-24", 3000.0),
|
|
})
|
|
df, dt = repo.get_enriched_latest_asset("index")
|
|
assert str(dt) == "2026-07-24"
|
|
assert df["symbol"].to_list() == ["000001.SH"]
|
|
assert "ma5" in df.columns or "rsi_14" in df.columns # 重算产出指标列
|
|
|
|
|
|
def test_get_enriched_latest_asset_index_cold_no_refresh(repo):
|
|
df, dt = repo.get_enriched_latest_asset("index", refresh=False)
|
|
assert df.is_empty() and dt is None
|
|
|
|
|
|
def test_flush_live_enriched_asset_index_updates_cache(repo):
|
|
df = pl.DataFrame([{
|
|
"symbol": "000001.SH", "date": _dt.date(2026, 7, 25),
|
|
"open": 3000.0, "high": 3010.0, "low": 2990.0, "close": 3005.0,
|
|
"volume": 1_000_000, "amount": 1e9, "ma5": 3001.0, "rsi_14": 55.0,
|
|
}])
|
|
repo.flush_live_enriched_asset("index", df)
|
|
cached, dt = repo.get_enriched_latest_asset("index", refresh=False)
|
|
assert str(dt) == "2026-07-25"
|
|
assert cached["close"].to_list() == [3005.0]
|
|
assert (repo.store.data_dir / "kline_index_enriched" / "date=2026-07-25" / "part.parquet").exists()
|
|
|
|
|
|
def _merge_row(symbol, close):
|
|
return {
|
|
"symbol": symbol, "date": _dt.date(2026, 7, 25),
|
|
"open": close - 5, "high": close + 5, "low": close - 6, "close": close,
|
|
"volume": 1_000, "amount": 1e6,
|
|
}
|
|
|
|
|
|
def test_merge_live_enriched_asset_index_merges_cache(repo):
|
|
"""merge 路径: 两次合并缓存取并集 (不 NameError, 不丢已有缓存)。"""
|
|
repo.merge_live_enriched_asset("index", pl.DataFrame([_merge_row("000001.SH", 3000.0)]))
|
|
repo.merge_live_enriched_asset("index", pl.DataFrame([_merge_row("000300.SH", 4000.0)]))
|
|
cached, dt = repo.get_enriched_latest_asset("index", refresh=False)
|
|
assert str(dt) == "2026-07-25"
|
|
assert set(cached["symbol"].to_list()) == {"000001.SH", "000300.SH"}
|
|
|