feat(bridge): bridge稳定性完善—自动重连miniQMT+health探活+交易日判断
- xt_gateway: _open_session抽离, reconnect(stop旧trader+重建), is_alive(query探活), _retry_with_reconnect(query/order失败重连重试一次) - query_account/positions/place_order包重试: 断线(异常/None)→reconnect→重试, broker拒单(order_id<=0)不重连 - bridge /health: is_alive真实探活(5s缓存)+断线后台reconnect(不阻塞), 不再假阳性 - trade_calendar: is_trading_day(周一-周五), /order非交易日加warning(120141提示) - test_gateway15+test_trade_calendar6=NAS21passed, 回归bridge_client/d4a 10绿 - 修复Issue#4运维发现: miniQMT重启后bridge自动重连(无需手动重启)
This commit is contained in:
@@ -7,6 +7,8 @@ token 鉴权(auth.py),/health 豁免。
|
||||
启动:uvicorn bridge:app --host 127.0.0.1 --port 8765
|
||||
"""
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Literal
|
||||
|
||||
@@ -14,6 +16,7 @@ from fastapi import Depends, FastAPI
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from auth import verify_token
|
||||
from trade_calendar import is_trading_day
|
||||
from xt_gateway import gateway
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -51,12 +54,44 @@ async def lifespan(_app: FastAPI):
|
||||
app = FastAPI(title="sanguo QMT bridge", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
|
||||
# ===== /health 探活缓存(避免每次 health 都 query_stock_asset)=====
|
||||
_PROBE_CACHE_SECONDS = 5.0
|
||||
_last_probe_time: float = 0.0
|
||||
_last_probe_alive: bool = False
|
||||
|
||||
|
||||
def _probe_alive() -> bool:
|
||||
"""探活 miniQMT 真实连接(5 秒缓存)。
|
||||
|
||||
缓存过期时调 gateway.is_alive()(query_stock_asset 探活);
|
||||
探活失败且 gateway 自认已连接 → 后台触发 reconnect(不阻塞 health 响应)。
|
||||
"""
|
||||
global _last_probe_time, _last_probe_alive
|
||||
now = time.monotonic()
|
||||
if now - _last_probe_time < _PROBE_CACHE_SECONDS:
|
||||
return _last_probe_alive
|
||||
|
||||
alive = gateway.is_alive()
|
||||
_last_probe_time = now
|
||||
_last_probe_alive = alive
|
||||
|
||||
if not alive and gateway.connected:
|
||||
# _connected 假阳性(miniQMT 可能重启),后台重连不阻塞响应
|
||||
threading.Thread(target=gateway.reconnect, daemon=True).start()
|
||||
|
||||
return alive
|
||||
|
||||
|
||||
# ===== 接口 =====
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
"""健康检查(无需鉴权,供 frpc/Caddy 探活)。"""
|
||||
return {"status": "ok", "miniqmt_connected": gateway.connected}
|
||||
"""健康检查(无需鉴权,供 frpc/Caddy 探活)。
|
||||
|
||||
miniqmt_connected 调 gateway.is_alive() 真实探活(5 秒缓存),
|
||||
不再依赖可能假阳性的 _connected 标志。
|
||||
"""
|
||||
return {"status": "ok", "miniqmt_connected": _probe_alive()}
|
||||
|
||||
|
||||
@app.post("/order", dependencies=[Depends(verify_token)])
|
||||
@@ -86,7 +121,10 @@ async def place_order(req: OrderRequest) -> dict:
|
||||
if order_id <= 0:
|
||||
return {"ok": False, "error": f"报单失败 order_id={order_id}"}
|
||||
logger.info("下单成功 order_id=%s code=%s action=%s", order_id, req.code, req.action)
|
||||
return {"ok": True, "order_id": order_id}
|
||||
result: dict = {"ok": True, "order_id": order_id}
|
||||
if not is_trading_day():
|
||||
result["warning"] = "非交易日,miniQMT 可能拒绝(120141)"
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/account", dependencies=[Depends(verify_token)])
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""交易日判断(首版仅周末判断,节假日后续扩展)。
|
||||
|
||||
TODO: 后续接入 A 股法定节假日列表(交易所日历数据源或手动配置文件)。
|
||||
"""
|
||||
from datetime import date
|
||||
|
||||
|
||||
def is_trading_day(day: date | None = None) -> bool:
|
||||
"""判断是否为 A 股交易日。
|
||||
|
||||
首版:周一至周五 True,周六周日 False。
|
||||
法定节假日(国庆/春节/清明等)暂不处理——miniQMT broker 会自行拒绝,
|
||||
调用方可根据返回值在响应中加 warning 提示。
|
||||
|
||||
Args:
|
||||
day: 待判断日期,None = 今天。
|
||||
|
||||
Returns:
|
||||
True = 工作日(大概率交易日),False = 周末。
|
||||
"""
|
||||
if day is None:
|
||||
day = date.today()
|
||||
# TODO: 接入 A 股法定节假日列表(目前仅排除周末)
|
||||
return day.weekday() < 5 # 0=Mon..4=Fri, 5=Sat, 6=Sun
|
||||
@@ -58,6 +58,7 @@ class XtGateway:
|
||||
self._xt: Any = None
|
||||
self._account: Any = None
|
||||
self._connected: bool = False
|
||||
self._last_query_ok: bool = False # 上次 query 是否成功(供 /health 探活参考)
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
@@ -67,14 +68,24 @@ class XtGateway:
|
||||
def connect(self) -> bool:
|
||||
"""连接 miniQMT 客户端。失败记日志不崩溃,返回 False。"""
|
||||
try:
|
||||
from xtquant import xttrader
|
||||
from xtquant.xttype import StockAccount
|
||||
return self._open_session()
|
||||
except ImportError as e:
|
||||
logger.error("xtquant import 失败(检查 site-packages): %s", e)
|
||||
self._connected = False
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("xtquant 连接异常: %s", e)
|
||||
self._connected = False
|
||||
return False
|
||||
|
||||
def _open_session(self) -> bool:
|
||||
"""建 XtQuantTrader + start + connect + subscribe(延迟 import xtquant)。
|
||||
|
||||
从 connect() 抽出,供 reconnect() 复用。raise 异常由调用方捕获。
|
||||
"""
|
||||
from xtquant import xttrader
|
||||
from xtquant.xttype import StockAccount
|
||||
|
||||
try:
|
||||
xt = xttrader.XtQuantTrader(MINIQMT_USERDATA, SESSION_ID)
|
||||
xt.start()
|
||||
ret = xt.connect()
|
||||
@@ -96,18 +107,72 @@ class XtGateway:
|
||||
self._connected = True
|
||||
logger.info("xtquant 连接成功 account=%s", ACCOUNT_ID)
|
||||
return True
|
||||
|
||||
def reconnect(self) -> bool:
|
||||
"""重连:stop 旧 trader(若有),再 _open_session 重建连接。
|
||||
|
||||
miniQMT 客户端重启后旧 XtQuantTrader 连接失效,必须重建。
|
||||
"""
|
||||
if self._xt is not None and hasattr(self._xt, "stop"):
|
||||
try:
|
||||
self._xt.stop()
|
||||
except Exception as e:
|
||||
logger.error("xtquant 连接异常: %s", e)
|
||||
logger.warning("旧 trader stop 异常(忽略,继续重建): %s", e)
|
||||
self._xt = None
|
||||
self._account = None
|
||||
self._connected = False
|
||||
logger.info("开始重连 miniQMT ...")
|
||||
return self.connect()
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""轻量探活:query_stock_asset 返回非空 = True。
|
||||
|
||||
供 /health 调用检测真实连接状态(_connected 标志可能假阳性)。
|
||||
不触发 reconnect(保持轻量),由调用方决定是否重连。
|
||||
"""
|
||||
try:
|
||||
if not self._connected:
|
||||
return False
|
||||
asset = self._xt.query_stock_asset(self._account)
|
||||
alive = asset is not None
|
||||
self._last_query_ok = alive
|
||||
return alive
|
||||
except Exception as e:
|
||||
logger.debug("is_alive 探活异常: %s", e)
|
||||
self._last_query_ok = False
|
||||
return False
|
||||
|
||||
def _retry_with_reconnect(self, fn: Any, fail_msg: str) -> Any:
|
||||
"""执行 fn(),失败(异常)时 reconnect 一次再重试。
|
||||
|
||||
重连仍失败则 raise(由调用方按原逻辑降级返回空/False)。
|
||||
|
||||
Args:
|
||||
fn: 无参可调用,执行实际 query/order。
|
||||
fail_msg: 日志标识(如 "query_account")。
|
||||
"""
|
||||
try:
|
||||
result = fn()
|
||||
self._last_query_ok = True
|
||||
return result
|
||||
except Exception as e:
|
||||
self._last_query_ok = False
|
||||
logger.warning("%s 首次失败,尝试重连: %s", fail_msg, e)
|
||||
if not self.reconnect():
|
||||
raise RuntimeError(f"重连失败,放弃 {fail_msg}: {e}") from e
|
||||
result = fn() # 重试一次(不再重连)
|
||||
self._last_query_ok = True
|
||||
return result
|
||||
|
||||
def query_account(self) -> dict[str, float]:
|
||||
"""查资金:{cash, frozen, market_value, total}。"""
|
||||
"""查资金:{cash, frozen, market_value, total}(断线自动重连重试一次)。"""
|
||||
|
||||
def _do() -> dict[str, float]:
|
||||
if not self._connected:
|
||||
raise RuntimeError("xtquant 未连接")
|
||||
asset = self._xt.query_stock_asset(self._account)
|
||||
if asset is None:
|
||||
raise RuntimeError("query_stock_asset 返回空(账户ID/权限问题)")
|
||||
raise RuntimeError("query_stock_asset 返回空(账户ID/权限问题或断线)")
|
||||
return {
|
||||
"cash": float(asset.cash),
|
||||
"frozen": float(asset.frozen_cash),
|
||||
@@ -115,11 +180,20 @@ class XtGateway:
|
||||
"total": float(asset.total_asset),
|
||||
}
|
||||
|
||||
return self._retry_with_reconnect(_do, "query_account")
|
||||
|
||||
def query_positions(self) -> list[dict[str, Any]]:
|
||||
"""查持仓:[{code, volume, can_use, avg_price}],code 已转 sanguo 格式。"""
|
||||
"""查持仓:[{code, volume, can_use, avg_price}],code 已转 sanguo 格式。
|
||||
|
||||
None(断线)触发重连重试,空列表 [](真没持仓)是正常结果。
|
||||
"""
|
||||
|
||||
def _do() -> list[dict[str, Any]]:
|
||||
if not self._connected:
|
||||
raise RuntimeError("xtquant 未连接")
|
||||
positions = self._xt.query_stock_positions(self._account) or []
|
||||
positions = self._xt.query_stock_positions(self._account)
|
||||
if positions is None:
|
||||
raise RuntimeError("query_stock_positions 返回 None(疑似断线)")
|
||||
return [
|
||||
{
|
||||
"code": to_sanguo_code(p.stock_code),
|
||||
@@ -130,6 +204,8 @@ class XtGateway:
|
||||
for p in positions
|
||||
]
|
||||
|
||||
return self._retry_with_reconnect(_do, "query_positions")
|
||||
|
||||
def place_order(
|
||||
self,
|
||||
code: str,
|
||||
@@ -140,6 +216,9 @@ class XtGateway:
|
||||
) -> int:
|
||||
"""下单,返回 order_id(>0 = 报单成功)。
|
||||
|
||||
断线(调用抛异常)自动重连重试一次;order_id<=0 是 broker 拒单
|
||||
(如非交易日/资金不足),不重试直接返回。
|
||||
|
||||
Args:
|
||||
code: sanguo 格式(sh600000/sz000001),内部转 xtquant 格式。
|
||||
action: "buy" / "sell"。
|
||||
@@ -149,8 +228,10 @@ class XtGateway:
|
||||
|
||||
Raises:
|
||||
ValueError: action/price_type/code 不合法。
|
||||
RuntimeError: xtquant 未连接。
|
||||
RuntimeError: xtquant 未连接 / 重连失败。
|
||||
"""
|
||||
|
||||
def _do() -> int:
|
||||
if not self._connected:
|
||||
raise RuntimeError("xtquant 未连接")
|
||||
from xtquant import xtconstant
|
||||
@@ -177,6 +258,8 @@ class XtGateway:
|
||||
)
|
||||
return int(order_id)
|
||||
|
||||
return self._retry_with_reconnect(_do, "place_order")
|
||||
|
||||
|
||||
# 模块级单例(bridge.py 启动时调 connect)
|
||||
gateway = XtGateway()
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
"""D-2 gateway 稳定性测试:自动重连 + 探活(mock xtquant,Mac 无 xtquant)。
|
||||
|
||||
注入 mock xtquant 模块到 sys.modules,验证:
|
||||
- connect 成功/失败
|
||||
- query 失败 → reconnect → 重试一次(query_account / query_positions / place_order)
|
||||
- place_order broker 拒单(order_id<=0) 不触发重连重试
|
||||
- is_alive(query_stock_asset 非空=True,None/异常/未连接=False)
|
||||
- reconnect 显式调用 stop 旧 trader
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from sanguo_qmt_bridge.xt_gateway import XtGateway
|
||||
|
||||
|
||||
# ===== mock xtquant 注入/清理工具 =====
|
||||
|
||||
def _install_mock_xtquant(*, connect_ret=0, asset=None, positions=None, order_id=1):
|
||||
"""注入 mock xtquant 到 sys.modules,返回 (mock_xt, patches)。"""
|
||||
mock_xt = MagicMock()
|
||||
mock_xt.connect.return_value = connect_ret
|
||||
mock_xt.query_stock_asset.return_value = asset
|
||||
mock_xt.query_stock_positions.return_value = positions
|
||||
mock_xt.order_stock.return_value = order_id
|
||||
|
||||
xttrader_mod = types.ModuleType("xtquant.xttrader")
|
||||
xttrader_mod.XtQuantTrader = MagicMock(return_value=mock_xt)
|
||||
|
||||
xttype_mod = types.ModuleType("xtquant.xttype")
|
||||
xttype_mod.StockAccount = MagicMock()
|
||||
|
||||
xtconstant_mod = types.ModuleType("xtquant.xtconstant")
|
||||
xtconstant_mod.STOCK_BUY = 23
|
||||
xtconstant_mod.STOCK_SELL = 24
|
||||
xtconstant_mod.FIX_PRICE = 11
|
||||
xtconstant_mod.LATEST_PRICE = 5
|
||||
|
||||
xtquant_mod = types.ModuleType("xtquant")
|
||||
|
||||
patches: dict[str, object] = {}
|
||||
for name, mod in [
|
||||
("xtquant", xtquant_mod),
|
||||
("xtquant.xttrader", xttrader_mod),
|
||||
("xtquant.xttype", xttype_mod),
|
||||
("xtquant.xtconstant", xtconstant_mod),
|
||||
]:
|
||||
patches[name] = sys.modules.get(name)
|
||||
sys.modules[name] = mod
|
||||
|
||||
return mock_xt, patches
|
||||
|
||||
|
||||
def _remove_mock_xtquant(patches):
|
||||
"""恢复 sys.modules 到注入前状态。"""
|
||||
for name, mod in patches.items():
|
||||
if mod is None:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_xtquant():
|
||||
"""注入 mock xtquant,yield mock_xt 供测试调整返回值,结束后清理。"""
|
||||
mock_asset = MagicMock(
|
||||
cash=100000.0, frozen_cash=0.0, market_value=50000.0, total_asset=150000.0,
|
||||
)
|
||||
mock_xt, patches = _install_mock_xtquant(
|
||||
connect_ret=0, asset=mock_asset, positions=[], order_id=12345,
|
||||
)
|
||||
yield mock_xt
|
||||
_remove_mock_xtquant(patches)
|
||||
|
||||
|
||||
def _make_mock_position(code="600000.SH", volume=100, can_use=100, avg_price=10.5):
|
||||
"""构造 mock 持仓对象(属性与 xtquant XtPosition 一致)。"""
|
||||
return MagicMock(
|
||||
stock_code=code, volume=volume, can_use_volume=can_use, avg_price=avg_price,
|
||||
)
|
||||
|
||||
|
||||
# ===== connect =====
|
||||
|
||||
class TestConnect:
|
||||
def test_connect_success(self, mock_xtquant):
|
||||
gw = XtGateway()
|
||||
assert gw.connect() is True
|
||||
assert gw.connected is True
|
||||
|
||||
def test_connect_failure_returns_false(self, mock_xtquant):
|
||||
mock_xtquant.connect.return_value = -1
|
||||
gw = XtGateway()
|
||||
assert gw.connect() is False
|
||||
assert gw.connected is False
|
||||
|
||||
|
||||
# ===== reconnect =====
|
||||
|
||||
class TestReconnect:
|
||||
def test_reconnect_stops_old_trader(self, mock_xtquant):
|
||||
gw = XtGateway()
|
||||
gw.connect()
|
||||
assert mock_xtquant.stop.call_count == 0
|
||||
gw.reconnect()
|
||||
assert mock_xtquant.stop.call_count == 1
|
||||
assert gw.connected is True
|
||||
|
||||
def test_reconnect_returns_true_on_success(self, mock_xtquant):
|
||||
gw = XtGateway()
|
||||
gw.connect()
|
||||
assert gw.reconnect() is True
|
||||
|
||||
|
||||
# ===== query 重连重试 =====
|
||||
|
||||
class TestQueryRetry:
|
||||
def test_query_account_reconnects_then_succeeds(self, mock_xtquant):
|
||||
"""首次 query_stock_asset 返回 None → 重连 → 重试成功。"""
|
||||
mock_asset = MagicMock(
|
||||
cash=80000.0, frozen_cash=0.0, market_value=40000.0, total_asset=120000.0,
|
||||
)
|
||||
mock_xtquant.query_stock_asset.side_effect = [None, mock_asset]
|
||||
|
||||
gw = XtGateway()
|
||||
gw.connect()
|
||||
data = gw.query_account()
|
||||
|
||||
assert data["cash"] == 80000.0
|
||||
assert data["total"] == 120000.0
|
||||
assert gw._last_query_ok is True
|
||||
assert mock_xtquant.stop.call_count == 1 # 确实触发了 reconnect
|
||||
|
||||
def test_query_account_fails_after_retry(self, mock_xtquant):
|
||||
"""重连后仍返回 None → RuntimeError 透出。"""
|
||||
mock_xtquant.query_stock_asset.return_value = None
|
||||
|
||||
gw = XtGateway()
|
||||
gw.connect()
|
||||
with pytest.raises(RuntimeError, match="query_stock_asset 返回空"):
|
||||
gw.query_account()
|
||||
assert gw._last_query_ok is False
|
||||
|
||||
def test_query_positions_reconnects_then_succeeds(self, mock_xtquant):
|
||||
"""首次 query_stock_positions 返回 None → 重连 → 重试返回持仓列表。"""
|
||||
pos = _make_mock_position()
|
||||
mock_xtquant.query_stock_positions.side_effect = [None, [pos]]
|
||||
|
||||
gw = XtGateway()
|
||||
gw.connect()
|
||||
positions = gw.query_positions()
|
||||
|
||||
assert len(positions) == 1
|
||||
assert positions[0]["code"] == "sh600000"
|
||||
assert positions[0]["volume"] == 100
|
||||
assert mock_xtquant.stop.call_count == 1
|
||||
|
||||
def test_query_positions_empty_list_is_not_disconnection(self, mock_xtquant):
|
||||
"""空列表 [](真没持仓)不触发重连。"""
|
||||
mock_xtquant.query_stock_positions.return_value = []
|
||||
|
||||
gw = XtGateway()
|
||||
gw.connect()
|
||||
positions = gw.query_positions()
|
||||
|
||||
assert positions == []
|
||||
assert mock_xtquant.stop.call_count == 0 # 没重连
|
||||
assert gw._last_query_ok is True
|
||||
|
||||
def test_place_order_reconnects_on_exception(self, mock_xtquant):
|
||||
"""order_stock 首次抛异常 → 重连 → 重试成功。"""
|
||||
mock_xtquant.order_stock.side_effect = [Exception("连接断开"), 99999]
|
||||
|
||||
gw = XtGateway()
|
||||
gw.connect()
|
||||
order_id = gw.place_order("sh600000", "buy", 10.5, 100)
|
||||
|
||||
assert order_id == 99999
|
||||
assert mock_xtquant.stop.call_count == 1
|
||||
assert gw._last_query_ok is True
|
||||
|
||||
def test_place_order_no_retry_on_broker_reject(self, mock_xtquant):
|
||||
"""order_id<=0(broker 拒单)不触发重连,直接返回。"""
|
||||
mock_xtquant.order_stock.return_value = -1
|
||||
|
||||
gw = XtGateway()
|
||||
gw.connect()
|
||||
order_id = gw.place_order("sh600000", "buy", 10.5, 100)
|
||||
|
||||
assert order_id == -1
|
||||
assert mock_xtquant.stop.call_count == 0 # 没重连
|
||||
|
||||
|
||||
# ===== is_alive =====
|
||||
|
||||
class TestIsAlive:
|
||||
def test_is_alive_true_when_asset_returned(self, mock_xtquant):
|
||||
gw = XtGateway()
|
||||
gw.connect()
|
||||
assert gw.is_alive() is True
|
||||
assert gw._last_query_ok is True
|
||||
|
||||
def test_is_alive_false_when_none(self, mock_xtquant):
|
||||
mock_xtquant.query_stock_asset.return_value = None
|
||||
gw = XtGateway()
|
||||
gw.connect()
|
||||
assert gw.is_alive() is False
|
||||
assert gw._last_query_ok is False
|
||||
|
||||
def test_is_alive_false_when_not_connected(self, mock_xtquant):
|
||||
gw = XtGateway() # 未 connect
|
||||
assert gw.is_alive() is False
|
||||
|
||||
def test_is_alive_false_on_exception(self, mock_xtquant):
|
||||
mock_xtquant.query_stock_asset.side_effect = RuntimeError("断线")
|
||||
gw = XtGateway()
|
||||
gw.connect()
|
||||
assert gw.is_alive() is False # 不崩溃
|
||||
assert gw._last_query_ok is False
|
||||
|
||||
def test_is_alive_does_not_trigger_reconnect(self, mock_xtquant):
|
||||
"""is_alive 保持轻量,探活失败不自动重连。"""
|
||||
mock_xtquant.query_stock_asset.return_value = None
|
||||
gw = XtGateway()
|
||||
gw.connect()
|
||||
gw.is_alive()
|
||||
assert mock_xtquant.stop.call_count == 0 # 不重连
|
||||
@@ -0,0 +1,37 @@
|
||||
"""D-2 交易日判断单元测试(纯函数,无外部依赖)。"""
|
||||
from datetime import date
|
||||
|
||||
from sanguo_qmt_bridge.trade_calendar import is_trading_day
|
||||
|
||||
|
||||
class TestIsTradingDay:
|
||||
def test_weekday_is_trading_day(self):
|
||||
assert is_trading_day(date(2026, 7, 13)) is True # 周一
|
||||
|
||||
def test_friday_is_trading_day(self):
|
||||
assert is_trading_day(date(2026, 7, 17)) is True # 周五
|
||||
|
||||
def test_saturday_is_not_trading_day(self):
|
||||
assert is_trading_day(date(2026, 7, 18)) is False # 周六
|
||||
|
||||
def test_sunday_is_not_trading_day(self):
|
||||
assert is_trading_day(date(2026, 7, 19)) is False # 周日
|
||||
|
||||
def test_default_uses_today(self):
|
||||
"""无参数时用今天,至少返回 bool 不崩溃。"""
|
||||
result = is_trading_day()
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_full_week(self):
|
||||
"""完整一周:周一到周五 True,周六周日 False。"""
|
||||
week = [
|
||||
(date(2026, 7, 13), True), # Mon
|
||||
(date(2026, 7, 14), True), # Tue
|
||||
(date(2026, 7, 15), True), # Wed
|
||||
(date(2026, 7, 16), True), # Thu
|
||||
(date(2026, 7, 17), True), # Fri
|
||||
(date(2026, 7, 18), False), # Sat
|
||||
(date(2026, 7, 19), False), # Sun
|
||||
]
|
||||
for day, expected in week:
|
||||
assert is_trading_day(day) is expected, f"{day} expected {expected}"
|
||||
Reference in New Issue
Block a user