optz
This commit is contained in:
3
py-client/.gitignore
vendored
Normal file
3
py-client/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
1
py-client/.python-version
Normal file
1
py-client/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.14
|
||||
65
py-client/README.md
Normal file
65
py-client/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Python 3.14 客户端
|
||||
|
||||
运行目标为 Windows x64、标准 CPython 3.14;本次验证版本为 3.14.7。
|
||||
迁移仅针对本目录,QMT 服务端及其内置 Python 不变。
|
||||
|
||||
## 安装与运行
|
||||
|
||||
在 `py-client` 目录执行 PowerShell 命令:
|
||||
|
||||
```powershell
|
||||
py -3.14 -m venv .venv
|
||||
.venv/Scripts/python.exe -m pip install -r requirements.txt
|
||||
.venv/Scripts/python.exe -m pip check
|
||||
.venv/Scripts/python.exe main.py
|
||||
```
|
||||
|
||||
`requirements.txt` 锁定本次在 Python 3.14 下实际验证的完整依赖版本。
|
||||
不要复用 Python 3.11 的虚拟环境。`.python-version` 为支持该文件的工具声明版本。
|
||||
|
||||
## 优化范围与行为约束
|
||||
|
||||
已审查本目录全部 39 个原有 Python 源文件;只修改有适用优化或迁移需求的文件。
|
||||
|
||||
- 移除旧的 `from __future__ import annotations`,使用 Python 3.14 原生延迟求值注解,前向引用不再手工加引号。验证所有业务模块、类及方法注解可正常解析。
|
||||
- 订单时间解析采用上限 4096 项的 LRU 缓存;每次刷新只读取一次时间和转换一次订单状态。缓存键为日期与时间原值,订单字段变化立即生效,继续使用原 `strptime` 解析规则。
|
||||
- 信号时间边界解析采用上限 256 项的 LRU 缓存,当前时刻与允许交易的结果不缓存。
|
||||
- 趋势、做 T 信号筛选直接查持仓字典,避免逐信号扫描持仓列表;候选顺序、重复信号及行情请求顺序不变。
|
||||
- 交易时段常量复用,订单方向映射复用;避免构建单元素集合、合并校验列表和已存在状态的默认对象。
|
||||
- 保持原浮点计算、价格阈值、资金规则、调度频率、线程结构、HTTP 重试、SQLite 事务及深复制隔离语义。
|
||||
|
||||
未启用 free-threaded Python、JIT 或多解释器线程池。现有交易任务共享客户端、锁和可变状态,切换并发模型不是等价替换。
|
||||
延迟注解的运行时读取语义由原字符串注解变为按需求值,外部 SDK 调用者若需要字符串形式,应使用 `annotationlib.get_annotations(..., format=Format.STRING)`。
|
||||
|
||||
官方说明:[Python 3.14 延迟注解](https://docs.python.org/3.14/whatsnew/3.14.html#pep-649-pep-749-deferred-evaluation-of-annotations)。
|
||||
|
||||
## 验证与性能
|
||||
|
||||
```powershell
|
||||
.venv/Scripts/python.exe -B -m unittest discover -s tests -v
|
||||
.venv/Scripts/python.exe -B benchmarks/hotpaths.py
|
||||
```
|
||||
|
||||
25 项离线测试通过,包括原 18 项测试和新增的时间边界、缓存上限、可变订单、信号顺序、原生注解回归测试。
|
||||
测试使用模拟客户端、临时 SQLite 数据库,不启动真实交易。
|
||||
|
||||
同一 CPython 3.14.7、原算法与优化算法对比;每组重复 5 次取中位数:
|
||||
|
||||
| 场景 | 原算法 µs/次 | 优化后 µs/次 | 倍率 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 订单日期解析(缓存命中) | 4.263 | 0.061 | 69.90× |
|
||||
| 信号时间边界解析(缓存命中) | 0.230 | 0.045 | 5.09× |
|
||||
| 交易时段判断(下午) | 0.413 | 0.183 | 2.25× |
|
||||
| 订单方向解析 | 0.163 | 0.092 | 1.78× |
|
||||
| 1000 持仓、2000 信号筛选 | 13127.276 | 67.827 | 193.54× |
|
||||
|
||||
以上是局部微基准,缓存未命中仍执行原解析逻辑;不是 3.11 对 3.14 的整轮交易加速数据。
|
||||
网络及数据库耗时未纳入,未做实盘端到端性能测量。
|
||||
|
||||
## 基线问题
|
||||
|
||||
修改前 18 项测试中 12 项失败,原因是模型仅有 `get_local_order_id` 属性,调用处却使用缺失的 `local_order_id`,存储层还将属性当方法调用。
|
||||
本次增加同一属性的兼容别名,并统一存储层属性访问,保留原属性名和 API 数据字段;这些是使既有撤单、成交对账测试恢复的接口修复。
|
||||
|
||||
审查还发现既有 `strategy/zt/boot.py` 向做 T 的 `manage_positions`、`open_signal` 提交的参数与函数签名不匹配。
|
||||
本次未改其调度和资金流程,因此 25 项测试通过不代表该既有做 T 启动路径已可用于实盘。
|
||||
66
py-client/benchmarks/hotpaths.py
Normal file
66
py-client/benchmarks/hotpaths.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Offline microbenchmarks; run with .venv/Scripts/python benchmarks/hotpaths.py."""
|
||||
|
||||
import sys
|
||||
from datetime import datetime, time
|
||||
from pathlib import Path
|
||||
from statistics import median
|
||||
from timeit import repeat
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from libs.calc import trading_time
|
||||
from sdk.models import _parse_datetime, _side
|
||||
from strategy.trend.open import _parse_minutes
|
||||
|
||||
|
||||
def original_date(date, clock):
|
||||
clock = clock.replace(':', '').zfill(6)
|
||||
try:
|
||||
return datetime.strptime(date.replace('-', '') + clock, '%Y%m%d%H%M%S')
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def original_minutes(value):
|
||||
try:
|
||||
hour_text, minute_text = value.strip().split(':')
|
||||
hour, minute = int(hour_text), int(minute_text)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not 0 <= hour <= 23 or not 0 <= minute <= 59:
|
||||
return None
|
||||
return hour * 60 + minute
|
||||
|
||||
|
||||
def original_trading_time(now):
|
||||
if now.weekday() >= 5:
|
||||
return False
|
||||
return time(9, 30) <= now.time() <= time(11, 30) or time(13) <= now.time() <= time(15)
|
||||
|
||||
|
||||
def measure(name, before, after, number=10000):
|
||||
assert before() == after(), name
|
||||
old = median(repeat(before, number=number, repeat=5)) / number
|
||||
new = median(repeat(after, number=number, repeat=5)) / number
|
||||
print(f'{name:26} {old * 1e6:10.3f} -> {new * 1e6:10.3f} us {old / new:7.2f}x')
|
||||
|
||||
|
||||
def main():
|
||||
print(sys.version)
|
||||
print('Same interpreter, original versus optimized; cache timings are warm.')
|
||||
now = datetime(2026, 9, 7, 14)
|
||||
measure('order date', lambda: original_date('20260907', '100000'),
|
||||
lambda: _parse_datetime('20260907', '100000'))
|
||||
measure('signal time bound', lambda: original_minutes('9:30'), lambda: _parse_minutes('9:30'))
|
||||
measure('trading session', lambda: original_trading_time(now), lambda: trading_time(now))
|
||||
measure('order side', lambda: {'23': 'BUY', '24': 'SELL', '48': 'BUY', '49': 'SELL'}.get(str(23), ''),
|
||||
lambda: _side(23))
|
||||
positions = {f'{i:06}.SH': None for i in range(1000)}
|
||||
codes = list(positions)
|
||||
signals = [f'{i:06}.SH' for i in range(500, 2500)]
|
||||
measure('1000 positions/2000 signals', lambda: [c for c in signals if c not in codes],
|
||||
lambda: [c for c in signals if c not in positions], number=100)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
@@ -2,9 +2,14 @@ from datetime import datetime, time
|
||||
from math import floor
|
||||
|
||||
|
||||
_MORNING_START, _MORNING_END = time(9, 30), time(11, 30)
|
||||
_AFTERNOON_START, _AFTERNOON_END = time(13), time(15)
|
||||
|
||||
|
||||
def trading_time(now: datetime) -> bool:
|
||||
if now.weekday() >= 5: return False
|
||||
return time(9, 30) <= now.time() <= time(11, 30) or time(13) <= now.time() <= time(15)
|
||||
clock = now.time()
|
||||
return _MORNING_START <= clock <= _MORNING_END or _AFTERNOON_START <= clock <= _AFTERNOON_END
|
||||
|
||||
|
||||
def calc_buy_volume(price: float, buy_value: float) -> int:
|
||||
@@ -29,4 +34,4 @@ def calculate_min_profit_rate(price: float, profit_mult: int) -> float:
|
||||
elif price >= 100:
|
||||
return 7 * profit_mult # 7%
|
||||
else:
|
||||
return 9 * profit_mult # 9%
|
||||
return 9 * profit_mult # 9%
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""简单的文件锁标记工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from os import PathLike
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""策略共用委托簿。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
@@ -69,17 +67,19 @@ class OrderBook:
|
||||
canceled = 0
|
||||
|
||||
for item in orders:
|
||||
status = str(item.order_status)
|
||||
# 不处理状态不对的
|
||||
if str(item.order_status) not in TRACKED_STATUSES:
|
||||
if status not in TRACKED_STATUSES:
|
||||
continue
|
||||
if str(item.order_status) in BUSY_STATUSES:
|
||||
if status in BUSY_STATUSES:
|
||||
busy_keys.add(self._busy_key(item.side, item.stock_code))
|
||||
# 清理过期的
|
||||
created_at = item.created_at
|
||||
if (
|
||||
item.created_at is not None
|
||||
created_at is not None
|
||||
and item.local_order_id.startswith(f"{self.order_prefix}-")
|
||||
and str(item.order_status) in CANCELABLE_STATUSES
|
||||
and current - item.created_at > self.cancel_timeout_sec
|
||||
and status in CANCELABLE_STATUSES
|
||||
and current - created_at > self.cancel_timeout_sec
|
||||
):
|
||||
try:
|
||||
client.cancel_by_id(item.order_sys_id)
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"""SQLite positions and deals, aligned with SDK models; one writer per database."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from dataclasses import asdict, fields
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from itertools import chain
|
||||
|
||||
from sdk import DealItem, PositionItem
|
||||
|
||||
@@ -106,7 +105,7 @@ class OrderBook:
|
||||
raise ValueError('System order ID and positive volume are required')
|
||||
|
||||
row = asdict(deal)
|
||||
row['order_local_id'] = deal.get_local_order_id()
|
||||
row['order_local_id'] = deal.local_order_id
|
||||
if not row['order_local_id']:
|
||||
raise ValueError('Local order ID is required')
|
||||
|
||||
@@ -145,7 +144,7 @@ class OrderBook:
|
||||
raise ValueError('Execution history is append-only')
|
||||
new_deals = deals[len(self.deals):]
|
||||
positions = [{**POSITION_DEFAULTS, **item} for item in items.values()]
|
||||
for row in [*positions, *new_deals]:
|
||||
for row in chain(positions, new_deals):
|
||||
if any(isinstance(value, float) and not math.isfinite(value) for value in row.values()):
|
||||
raise ValueError('Numeric values must be finite')
|
||||
with closing(self._connect()) as db, db:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""策略单次运行所需的公共上下文对象。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
httpx>=0.27,<1
|
||||
PyYAML>=6.0
|
||||
APScheduler>=3.10,<4
|
||||
CacheLib>=0.13,<1
|
||||
# Validated on Windows x64, CPython 3.14.7. Install with pip -r.
|
||||
anyio==4.15.1
|
||||
APScheduler==3.11.3
|
||||
cachelib==0.17.0
|
||||
certifi==2026.7.22
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
idna==3.19
|
||||
PyYAML==6.0.3
|
||||
typing_extensions==4.16.0
|
||||
tzdata==2026.3
|
||||
tzlocal==5.4.4
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from typing import Any
|
||||
@@ -33,13 +31,13 @@ class HTTPClient:
|
||||
def close(self) -> None:
|
||||
self.http.close()
|
||||
|
||||
def __enter__(self) -> "HTTPClient":
|
||||
def __enter__(self) -> HTTPClient:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
self.close()
|
||||
|
||||
def set_account_type(self, account_type: str) -> "HTTPClient":
|
||||
def set_account_type(self, account_type: str) -> HTTPClient:
|
||||
if account_type.strip():
|
||||
self.account_type = account_type
|
||||
return self
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import Tick
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
_SIDES = {"23": "BUY", "24": "SELL", "48": "BUY", "49": "SELL"}
|
||||
|
||||
|
||||
def _number(value: Any, kind: type = float) -> Any:
|
||||
@@ -40,6 +43,9 @@ class OrderItem:
|
||||
def get_local_order_id(self) -> str:
|
||||
return self.remark.split("|", 1)[0]
|
||||
|
||||
# Keep the existing property name available to SDK callers.
|
||||
local_order_id = get_local_order_id
|
||||
|
||||
@property
|
||||
def created_at(self) -> datetime | None:
|
||||
return _parse_datetime(self.insert_date, self.insert_time)
|
||||
@@ -71,6 +77,8 @@ class DealItem:
|
||||
def get_local_order_id(self) -> str:
|
||||
return self.remark.split("|", 1)[0]
|
||||
|
||||
local_order_id = get_local_order_id
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PositionItem:
|
||||
@@ -111,9 +119,10 @@ class Portfolio:
|
||||
|
||||
|
||||
def _side(offset_flag: int) -> str:
|
||||
return {"23": "BUY", "24": "SELL", "48": "BUY", "49": "SELL"}.get(str(offset_flag), "")
|
||||
return _SIDES.get(str(offset_flag), "")
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _parse_datetime(date: str, clock: str) -> datetime | None:
|
||||
clock = clock.replace(":", "").zfill(6)
|
||||
try:
|
||||
@@ -129,7 +138,7 @@ class Tick:
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Any) -> "Tick":
|
||||
def from_dict(cls, data: Any) -> Tick:
|
||||
if not isinstance(data, dict):
|
||||
return cls()
|
||||
return cls(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .models import Assets, DealItem, OrderItem, Portfolio, PositionItem
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""新股自动申购,提供交易日校验、券商对账和本地幂等保护。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, time
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
该模块负责组合 SDK、配置、状态存储和趋势策略组件,供 main.py 调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import logging as log
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
@@ -161,7 +159,7 @@ def RunOnce(run: Runtime, signals: list[SignalItem]) -> None:
|
||||
allow_open: list[SignalItem] = []
|
||||
allow_codes: list[str] = []
|
||||
for signal in signals:
|
||||
if signal.code not in position_codes:
|
||||
if signal.code not in portfolio.positions:
|
||||
allow_open.append(signal)
|
||||
allow_codes.append(signal.code)
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""趋势策略开仓逻辑。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
|
||||
from libs import calc_buy_volume
|
||||
from sdk import OP_BUY
|
||||
@@ -164,6 +163,7 @@ def check_timezone(timezone: str, now: datetime | None = None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _parse_minutes(value: str) -> int | None:
|
||||
"""把 ``时:分`` 转换为当天分钟数,无效值返回 None。"""
|
||||
try:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""趋势策略持仓止盈与分级补仓。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from libs.calc import calc_buy_volume, calculate_min_profit_rate
|
||||
@@ -34,7 +32,7 @@ def manage_positions(
|
||||
# 遍历处理每个持仓
|
||||
for position in positions:
|
||||
try:
|
||||
available = max(0, 0, available)
|
||||
available = max(0, available)
|
||||
code = position.stock_code
|
||||
tick = ticks.get(code)
|
||||
if code in runtime.account_cfg.excluded_codes:
|
||||
@@ -130,7 +128,7 @@ def handle_profit(
|
||||
False,
|
||||
f"上涨至 {pnl_rate:.2f}%,峰值网格={observation.current_grid}",
|
||||
)
|
||||
if observation.state in {GridState.STEADY}:
|
||||
if observation.state == GridState.STEADY:
|
||||
return TradeDecision(False)
|
||||
if runtime.orders.busy(position.stock_code, "SELL"):
|
||||
return TradeDecision(False, "卖出委托处理中")
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
该模块负责组合 SDK、配置、状态存储和做 T 策略组件,供 main.py 调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
import logging as log
|
||||
import time
|
||||
@@ -143,7 +141,7 @@ def RunOnce(run: Runtime, state: TState, signals: list[SignalItem]) -> None:
|
||||
allow_open: list[SignalItem] = []
|
||||
allow_codes: list[str] = []
|
||||
for signal in signals:
|
||||
if signal.code not in position_codes:
|
||||
if signal.code not in portfolio.positions:
|
||||
allow_open.append(signal)
|
||||
allow_codes.append(signal.code)
|
||||
|
||||
@@ -196,4 +194,4 @@ def _wait_worker(name: str, future: Future) -> None:
|
||||
try:
|
||||
future.result()
|
||||
except Exception:
|
||||
log.exception("[运行] %s线程失败", name)
|
||||
log.exception("[运行] %s线程失败", name)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""使用 dcm 信号建立做 T 底仓。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import logging as log
|
||||
import math
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""日内先卖后买的做 T 规则,不包含趋势补仓或整仓止盈。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging as log
|
||||
import math
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""做 T 策略的持仓状态和逐笔实际成交记录。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
@@ -115,7 +113,10 @@ class TState:
|
||||
self.items[code] = TStateItem(code, -delta)
|
||||
|
||||
for row in rows:
|
||||
item = self.items.setdefault(row['stock_code'], TStateItem(row['stock_code']))
|
||||
code = row['stock_code']
|
||||
item = self.items.get(code)
|
||||
if item is None:
|
||||
item = self.items[code] = TStateItem(code)
|
||||
self._reset(item, row['trade_date'])
|
||||
qty, amount = row['volume'], row['trade_amount']
|
||||
if row['order_local_id'].startswith('zt-base-'):
|
||||
|
||||
113
py-client/tests/test_python314_performance.py
Normal file
113
py-client/tests/test_python314_performance.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Offline regression checks for the Python 3.14 performance changes."""
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import unittest
|
||||
from annotationlib import Format, get_annotations
|
||||
from concurrent.futures import Future
|
||||
from datetime import datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from libs.calc import trading_time
|
||||
from libs.signal import SignalItem
|
||||
from sdk.models import OrderItem, _parse_datetime
|
||||
from strategy.trend import boot
|
||||
from strategy.trend.open import _parse_minutes, check_timezone
|
||||
|
||||
|
||||
class PerformanceRegressionTests(unittest.TestCase):
|
||||
def test_trading_time_matches_original_for_week_and_boundaries(self):
|
||||
start = datetime(2026, 9, 7)
|
||||
for minute in range(7 * 24 * 60):
|
||||
now = start + timedelta(minutes=minute)
|
||||
expected = now.weekday() < 5 and (
|
||||
time(9, 30) <= now.time() <= time(11, 30)
|
||||
or time(13) <= now.time() <= time(15)
|
||||
)
|
||||
self.assertEqual(trading_time(now), expected, now)
|
||||
for clock in ((11, 30), (15, 0)):
|
||||
self.assertFalse(trading_time(start.replace(hour=clock[0], minute=clock[1], microsecond=1)))
|
||||
|
||||
def test_date_parser_preserves_strptime_acceptance(self):
|
||||
for date in ('20260907', '2026-09-07', '', '20260229', '20240229', '202691'):
|
||||
for clock in ('100000', '10:00:00', '93000', '', '240000', 'bad', '1'):
|
||||
try:
|
||||
expected = datetime.strptime(date.replace('-', '') + clock.replace(':', '').zfill(6), '%Y%m%d%H%M%S')
|
||||
except ValueError:
|
||||
expected = None
|
||||
self.assertEqual(_parse_datetime(date, clock), expected, (date, clock))
|
||||
|
||||
def test_date_cache_tracks_mutable_order_fields(self):
|
||||
order = OrderItem(insert_date='20260907', insert_time='100000', remark='first|trend')
|
||||
self.assertEqual(order.created_at, datetime(2026, 9, 7, 10))
|
||||
order.insert_time = '110000'
|
||||
order.remark = 'second|trend'
|
||||
self.assertEqual(order.created_at, datetime(2026, 9, 7, 11))
|
||||
self.assertEqual(order.local_order_id, 'second')
|
||||
self.assertEqual(order.get_local_order_id, 'second')
|
||||
|
||||
def test_caches_are_bounded(self):
|
||||
_parse_minutes.cache_clear()
|
||||
_parse_datetime.cache_clear()
|
||||
for i in range(4200):
|
||||
_parse_datetime('invalid', str(i))
|
||||
_parse_minutes(str(i))
|
||||
self.assertLessEqual(_parse_datetime.cache_info().currsize, 4096)
|
||||
self.assertLessEqual(_parse_minutes.cache_info().currsize, 256)
|
||||
|
||||
def test_timezone_boundaries_and_current_time_not_cached(self):
|
||||
for hour in range(24):
|
||||
for minute in range(60):
|
||||
now = datetime(2026, 9, 7, hour, minute)
|
||||
m = hour * 60 + minute
|
||||
self.assertEqual(check_timezone('9:30-10:30,invalid,23:00-1:00', now),
|
||||
570 <= m <= 630 or m >= 1380 or m <= 60)
|
||||
self.assertTrue(check_timezone('*'))
|
||||
self.assertFalse(check_timezone('24:00-25:00'))
|
||||
self.assertFalse(check_timezone(''))
|
||||
|
||||
def test_signal_order_duplicates_and_request_order_preserved(self):
|
||||
future = Future()
|
||||
future.set_result(None)
|
||||
assets = SimpleNamespace(available=100, total=100)
|
||||
portfolio = SimpleNamespace(assets=assets, positions={'held': object()}, orders=[])
|
||||
run = SimpleNamespace(client=Mock(), orders=Mock(), executor=Mock(),
|
||||
account_cfg=SimpleNamespace(account_id='test', min_cash_ratio=0.1))
|
||||
run.client.portfolio.return_value = portfolio
|
||||
run.client.full_tick.return_value = {}
|
||||
run.executor.submit.return_value = future
|
||||
signals = [SignalItem(code=c) for c in ('new-b', 'held', 'new-a', 'new-b')]
|
||||
with patch.object(boot, 'trading_time', return_value=True), \
|
||||
patch.object(boot, 'market_allow_open', return_value=True), \
|
||||
patch.object(boot, '_cache_portfolio'), patch('builtins.print'):
|
||||
boot.RunOnce(run, signals)
|
||||
run.client.full_tick.assert_called_once_with(['held', 'new-b', 'new-a'])
|
||||
self.assertEqual(run.executor.submit.call_args_list[1].args,
|
||||
(boot.open_signal, run, {}, [signals[0], signals[2], signals[3]]))
|
||||
|
||||
def test_native_annotations_resolve_for_all_application_modules(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
paths = [p for name in ('config', 'sdk', 'libs', 'strategy')
|
||||
for p in (root / name).rglob('*.py')]
|
||||
for path in paths:
|
||||
parts = list(path.relative_to(root).with_suffix('').parts)
|
||||
if parts[-1] == '__init__':
|
||||
parts.pop()
|
||||
module = importlib.import_module('.'.join(parts))
|
||||
for obj in vars(module).values():
|
||||
if (inspect.isclass(obj) or inspect.isfunction(obj)) and obj.__module__ == module.__name__:
|
||||
get_annotations(obj, format=Format.VALUE)
|
||||
if inspect.isclass(obj):
|
||||
for member in vars(obj).values():
|
||||
if isinstance(member, (classmethod, staticmethod)):
|
||||
member = member.__func__
|
||||
elif isinstance(member, property):
|
||||
member = member.fget
|
||||
if inspect.isfunction(member):
|
||||
get_annotations(member, format=Format.VALUE)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user