mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
回测成交口径: - close_t 按收盘价成交的建仓日: 当日 high 发生在建仓之前, 不得进入移动止损 峰值 (峰值虚高会把风控线上移、退出提前) — 矩阵独立/矩阵组合/legacy 三条 路径同口径; 新增回归测试锁定 open_t+1 对照组不被误伤 - 矩阵路径补收盘封死跌停拦截 (含一字), 经 pending_exit 递延到下一可卖日, 与独立/legacy 路径同口径 (此前尾盘封板股按跌停价"卖出"低估亏损) - #388 当日指标纪律同口径: 盘中成交不可知当日收盘, 代数剔除当日 enriched - 分钟回放: 入场价用前复权口径 (与日线出场同尺度); 除权日涨停参考价按 ex_factor 还原 (直接用 raw_prev 会把涨停价算高, 除权日封板拒买漏检) - 指标管线: 涨停参考价除权口径修正 + 夏普用样本标准差 (ddof=1, 与 engine 一致) - 回测任务: _acquire_or_evict_job 收口同 key 复用/强制重建 (参数不同先取消 旧任务, 避免旧任务占着重任务槽跑完) 服务与并发: - task_registry 读-改-写加锁: 两个前端 fire-and-forget 并发上报时后写者 覆盖先写者 (丢终态翻转或整条记录); 文件写入本身原子, 锁只护 RMW 段 - quote_service 生命周期锁串行化 start/stop/enable 的 check-then-act (双标签页 并发开关会起两条轮询线程 → 双倍拉取配额 + 重复 SSE 广播) - 系统通知投递移入专用单线程池: notify_adapter 底层 subprocess 逐条最长 5s, 在 _poll_loop 同步执行会让告警风暴阻塞一轮监控评估, 拖慢行情拉取与推送 - sector_monitor / minute_refresh / wecom_bot: 同类单实例与停止时序加固 - ai_provider: 401 识别加词边界 (避免模型正文含 401 数字串误判鉴权失败并 附出不相关指引); 自定义可执行路径校验可执行位 - api/data: 清空数据期间暂停全量分钟增量线程 (否则"清完又被写回部分今日 数据"); 状态接口偏好/能力探测异常降级而不是 500 - api/kline: 失效内存 enriched 快照 (非空缓存不校验 generation, 只刷 DuckDB 视图会让重建结果对自选/筛选/监控不可见直到进程重启) - api/position_policies: 非法 ID 纵深防御按不存在处理 前端: - assistant store: 生成终态 (中断提示/流式收敛) 钉在发起会话, 切换或删除会话 不再把"已中断"写进无关新会话 - backtestTask: 键上挂着旧任务且新参数不同 (不同 job_key) 时先取消旧任务, 避免其占着重任务槽跑到自然结束; 同参重跑跳过取消 (后端按同 key 复用 / force 重建已完成任务) - 查询键集中与正确性: QK.instrumentNames (排序去重, 同一集合不同来源顺序 复用缓存)、QK.kline 滚动窗口空串边界 (useQuoteStream 焦点股增量合并可 修补)、regimeHistory 用实际请求三元组 + 裸前缀失效全部变体 - Data 页 / Regime / SectorRotationCard / RuleEditor / monitor / lots / stock-analysis 接入集中键, useQuoteStream 对齐 验证: 后端全量 3546 passed (含 close_t 峰值/分钟回测/跌停递延/并发上报等 新增用例); 前端 49 文件 348 用例 + pnpm build 通过; ruff 改动文件零新增 (141 vs 基线 142)。 Signed-off-by: shy3130 <shy3130@qq.com>
118 lines
4.3 KiB
Python
118 lines
4.3 KiB
Python
"""仓位策略 API — 列表/模板/校验/保存/删除/回测请求 400 校验。
|
|
|
|
沿用 tests/test_job_stall_and_cancel.py 的 mini-app 模式: 只挂需要的路由,
|
|
不进主应用 lifespan (避免拉起数据源/调度器)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.api import position_policies as position_policies_api
|
|
from app.backtest import position_policies as pp
|
|
from app.services import preferences
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
|
from app.api import backtest as backtest_api
|
|
from app.config import settings
|
|
|
|
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
|
monkeypatch.setattr(preferences, "load", lambda: {})
|
|
|
|
app = FastAPI()
|
|
app.include_router(backtest_api.router)
|
|
app.include_router(position_policies_api.router)
|
|
app.state.data_dir = tmp_path
|
|
pp.invalidate_cache()
|
|
return TestClient(app)
|
|
|
|
|
|
def test_templates_endpoint(client: TestClient) -> None:
|
|
r = client.get("/api/backtest/position-policies/templates")
|
|
assert r.status_code == 200
|
|
keys = [t["key"] for t in r.json()["templates"]]
|
|
assert keys == ["blank", "vol_target", "drawdown"]
|
|
|
|
|
|
def test_validate_rejects_bad_code(client: TestClient) -> None:
|
|
r = client.post(
|
|
"/api/backtest/position-policies/validate",
|
|
json={"label": "坏策略", "code": "def compute(ctx):\n import os\n return 1\n"},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["ok"] is False
|
|
assert any("安全校验" in i for i in body["issues"])
|
|
|
|
|
|
def test_validate_ok_for_template(client: TestClient) -> None:
|
|
r = client.post(
|
|
"/api/backtest/position-policies/validate",
|
|
json={
|
|
"label": "波动率目标",
|
|
"params": pp.TEMPLATE_VOL_TARGET["meta"]["params"],
|
|
"code": pp.TEMPLATE_VOL_TARGET["code"],
|
|
},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["ok"], body["issues"]
|
|
assert body["smoke"]["weights"] and abs(sum(body["smoke"]["weights"]) - 1.0) < 1e-3
|
|
|
|
|
|
def test_save_get_list_delete_cycle(client: TestClient, tmp_path: Path) -> None:
|
|
r = client.post(
|
|
"/api/backtest/position-policies",
|
|
json={
|
|
"label": "我的波动率仓位",
|
|
"description": "测试",
|
|
"params": pp.TEMPLATE_VOL_TARGET["meta"]["params"],
|
|
"code": pp.TEMPLATE_VOL_TARGET["code"],
|
|
},
|
|
)
|
|
assert r.status_code == 200
|
|
policy_id = r.json()["id"]
|
|
assert policy_id.startswith("up_")
|
|
|
|
got = client.get(f"/api/backtest/position-policies/{policy_id}").json()
|
|
assert got["meta"]["label"] == "我的波动率仓位"
|
|
assert "def compute(ctx)" in got["code"]
|
|
|
|
listed = client.get("/api/backtest/position-policies").json()
|
|
assert [p["id"] for p in listed["policies"]] == [policy_id]
|
|
|
|
assert client.delete(f"/api/backtest/position-policies/{policy_id}").status_code == 200
|
|
assert client.get("/api/backtest/position-policies").json()["policies"] == []
|
|
|
|
|
|
def test_backtest_run_with_unknown_policy_returns_400(client: TestClient) -> None:
|
|
r = client.post(
|
|
"/api/backtest/strategy/run",
|
|
json={"strategy_id": "any", "position_sizing": "policy", "position_policy": "up_missing"},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "仓位策略不存在" in r.json()["detail"]
|
|
|
|
|
|
def test_get_and_delete_reject_path_traversal_ids(client: TestClient, tmp_path: Path) -> None:
|
|
"""policy_id 直接拼文件路径: 非法 ID (路径穿越) 必须 400 fail-closed。
|
|
|
|
Windows 下 %5C 解码为分隔符, `..%5C..%5Cx` 可越界读/删目录外 .py 文件。
|
|
"""
|
|
victim = tmp_path / "victim.py"
|
|
victim.write_text("SECRET = 1", encoding="utf-8")
|
|
|
|
# 注: 未编码 ../ 被路径归一化、含 / 的被路由分段挡掉; 编码 %5C 才是真实攻击面
|
|
for bad_id in ("..%5C..%5Cvictim", "UP_BAD", "a", "..%2Eevil"):
|
|
resp = client.get(f"/api/backtest/position-policies/{bad_id}")
|
|
assert resp.status_code == 400, (bad_id, resp.status_code)
|
|
resp = client.delete(f"/api/backtest/position-policies/{bad_id}")
|
|
assert resp.status_code == 400, (bad_id, resp.status_code)
|
|
|
|
assert victim.exists(), "越界删除不得发生"
|