Files
easy_tdx_max/tests/unit/test_bars_min120_derived.py
GitHub 4bd5b5d833 release: v1.29.0 — 借鉴社区 Fork 六项特性:ZIG 策略 + 交易时段感知刷新 + 120M K 线 + 逐 bar 衍生字段 + 159 龙头池 + 多 Provider LLM 直连
- ZIG 右侧突破回补策略:MyTT 新增 ZIG 之字转向(未来函数,含前视偏差警示);
  波谷启动建仓挂硬止损(OCO)→ 见顶清仓记前高 → 右侧突破回补;路径依赖不实现
  entry_exit_masks(向量化守护测试白名单);含寻优预设网格与 --strategy-file 独立文件
- 交易时段感知刷新:realtime/session.py(09:15~11:30:30 / 13:00~15:05)+
  GET /market/session;看板 30/60/120s 轮询休市自动暂停(三态状态栏 + 开关持久化
  + 手动刷新不受限);SSE/WS 既有会话语义不动
- 120 分钟 K 线:/bars?category=MIN_120(MAC 原生 Period.MINS×120 优先,
  2×60M 相邻聚合兜底,标准客户端上限 400 根);前端周期选择器同步
- 逐 bar 衍生字段:/bars 与 /bars/index 附带 pre_close/change/change_pct/
  amplitude_pct(pre_close≤0.01 兜底防除零)
- 159 只核心龙头池:数据资产取自 Fork(东财全行业龙头名单,四组分层);
  universe=core 接入 screen scan / SignalScanner / StrengthRanker / market strength;
  GET /market/core-leaders + WebUI「龙头池」页(搜索/个股详情)
- 多 Provider LLM 直连:easy_tdx.ai + /llm/*(DeepSeek/通义/智谱/Kimi/MiniMax/
  OpenAI/Claude/Ollama/自定义,openai 兼容 + anthropic 原生双协议);
  配置落盘 ~/.easy_tdx/llm.json(WebUI「AI 设置」页 ⇆ 手工编辑双向兼容,
  文件>环境变量>预设;key 脱敏回显/CLEAR 清除);「AI 解读」后台任务化
  (复用 task_runner,提交+轮询,不占 HTTP 连接);思考型模型空白正文防御
  (reasoning_content 耗尽 max_tokens → 可操作报错;默认 16000);
  AI 解读历史页(自动归档 Prompt/正文/策略上下文 + 去回测带参引导)
- WebUI 加固:SPA fallback 对未知 /api/* 返回 JSON 404(不再 200 HTML 伪装解析错);
  index.html 一律 Cache-Control: no-store(防缓存旧资源引用);路由兜底重定向;
  全局风险提示常驻底栏 + 龙头池/AI 解读针对性免责声明
- 测试:新增 9 个单测文件共 59 例;黄金基线仅新增 zig_breakout 条目(其余零漂移);
  全量 1448 例通过
2026-09-02 20:19:17 +08:00

85 lines
3.1 KiB
Python

"""120 分钟 K 线重采样与逐 bar 衍生字段单元测试(bars.py 纯函数,v1.29)。"""
from __future__ import annotations
import numpy as np
import pandas as pd
from easy_tdx.web.routers.bars import (
_MIN_120_ALIASES,
_attach_derived,
_resample_pairs,
)
def _minute_df(n: int = 5) -> pd.DataFrame:
return pd.DataFrame(
{
"datetime": pd.date_range("2024-01-02 10:30", periods=n, freq="60min"),
"open": [10, 11, 12, 13, 14][:n],
"close": [10.5, 11.5, 12.5, 13.5, 14.5][:n],
"high": [10.8, 11.9, 12.9, 13.9, 14.9][:n],
"low": [9.9, 10.9, 11.9, 12.9, 13.9][:n],
"vol": [100, 200, 300, 400, 500][:n],
"amount": [1000, 2000, 3000, 4000, 5000][:n],
}
)
class TestResamplePairs:
def test_odd_count_drops_oldest(self):
"""奇数根丢最旧一根,保最新数据两两对齐。"""
r = _resample_pairs(_minute_df(5), 10)
assert len(r) == 2
row = r.iloc[0] # 原 bar1+bar2
assert row["open"] == 11 and row["close"] == 12.5
assert row["high"] == 12.9 and row["low"] == 10.9 # max/min
assert row["vol"] == 500 and row["amount"] == 5000 # sum
assert str(row["datetime"]) == "2024-01-02 12:30:00" # 后一根时间
def test_even_count_keeps_all(self):
r = _resample_pairs(_minute_df(4), 10)
assert len(r) == 2
assert r.iloc[0]["open"] == 10 # 从 bar0 起
def test_count_trims_oldest_side(self):
r = _resample_pairs(_minute_df(4), 1)
assert len(r) == 1 and r.iloc[0]["close"] == 13.5 # tail 保留
def test_empty_passthrough(self):
assert _resample_pairs(pd.DataFrame(), 10).empty
assert _resample_pairs(None, 10) is None # type: ignore[arg-type]
def test_missing_optional_columns(self):
df = _minute_df(4).drop(columns=["amount"])
r = _resample_pairs(df, 10)
assert "amount" not in r.columns and len(r) == 2
class TestAttachDerived:
def test_basic_fields(self):
d = _attach_derived(_minute_df(3))
assert {"pre_close", "change", "change_pct", "amplitude_pct"} <= set(d.columns)
assert d.iloc[0]["pre_close"] == 10 # 首根 = 本根开盘
assert d.iloc[0]["change"] == 0.5 and d.iloc[0]["change_pct"] == 5.0
assert d.iloc[1]["pre_close"] == 10.5
assert d.iloc[1]["change_pct"] == round((11.5 / 10.5 - 1) * 100, 4)
assert abs(d.iloc[0]["amplitude_pct"] - (10.8 - 9.9) / 10 * 100) < 1e-6
def test_nonpositive_preclose_floor(self):
"""QFQ 复权后前收为 0/负时按 0.01 兜底,不产生 inf。"""
df = _minute_df(3)
df.loc[0, "close"] = -5.0
d = _attach_derived(df)
assert np.isfinite(d["change_pct"]).all()
assert d.iloc[1]["change_pct"] == round((11.5 / 0.01 - 1) * 100, 4)
def test_empty_and_missing_close(self):
assert _attach_derived(pd.DataFrame()).empty
df = pd.DataFrame({"open": [1.0]})
assert "pre_close" not in _attach_derived(df).columns
def test_min_120_aliases():
assert _MIN_120_ALIASES == {"MIN_120", "120M", "120MIN"}