From ba61ed5de7e5ef3b9b25f1205788eb3912eede2a Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 7 Sep 2026 21:22:51 +0800 Subject: [PATCH] optz --- py-client/.gitignore | 3 + py-client/.python-version | 1 + py-client/README.md | 65 ++++++++++ py-client/benchmarks/hotpaths.py | 66 ++++++++++ py-client/config/__init__.py | 2 - py-client/libs/calc.py | 9 +- py-client/libs/lockfile.py | 2 - py-client/libs/order.py | 14 +-- py-client/libs/orderbook.py | 7 +- py-client/libs/runtime.py | 2 - py-client/requirements.txt | 17 ++- py-client/sdk/client.py | 6 +- py-client/sdk/context.py | 2 - py-client/sdk/data.py | 2 - py-client/sdk/get.py | 2 - py-client/sdk/models.py | 15 ++- py-client/sdk/portfolio.py | 2 - py-client/sdk/sys.py | 2 - py-client/sdk/trade.py | 2 - py-client/strategy/ipo/boot.py | 2 - py-client/strategy/trend/boot.py | 4 +- py-client/strategy/trend/open.py | 4 +- py-client/strategy/trend/positions.py | 6 +- py-client/strategy/zt/boot.py | 6 +- py-client/strategy/zt/open.py | 2 - py-client/strategy/zt/positions.py | 2 - py-client/strategy/zt/state.py | 7 +- py-client/tests/test_python314_performance.py | 113 ++++++++++++++++++ 28 files changed, 303 insertions(+), 64 deletions(-) create mode 100644 py-client/.gitignore create mode 100644 py-client/.python-version create mode 100644 py-client/README.md create mode 100644 py-client/benchmarks/hotpaths.py create mode 100644 py-client/tests/test_python314_performance.py diff --git a/py-client/.gitignore b/py-client/.gitignore new file mode 100644 index 0000000..f0ccc32 --- /dev/null +++ b/py-client/.gitignore @@ -0,0 +1,3 @@ +.venv/ +__pycache__/ +*.py[cod] diff --git a/py-client/.python-version b/py-client/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/py-client/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/py-client/README.md b/py-client/README.md new file mode 100644 index 0000000..91da137 --- /dev/null +++ b/py-client/README.md @@ -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 启动路径已可用于实盘。 diff --git a/py-client/benchmarks/hotpaths.py b/py-client/benchmarks/hotpaths.py new file mode 100644 index 0000000..e5dbdbc --- /dev/null +++ b/py-client/benchmarks/hotpaths.py @@ -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() diff --git a/py-client/config/__init__.py b/py-client/config/__init__.py index 43e68f4..40857b4 100644 --- a/py-client/config/__init__.py +++ b/py-client/config/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import socket from dataclasses import dataclass, field from pathlib import Path diff --git a/py-client/libs/calc.py b/py-client/libs/calc.py index 34d53a3..afc0548 100644 --- a/py-client/libs/calc.py +++ b/py-client/libs/calc.py @@ -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% \ No newline at end of file + return 9 * profit_mult # 9% diff --git a/py-client/libs/lockfile.py b/py-client/libs/lockfile.py index 10d4cb6..a272318 100644 --- a/py-client/libs/lockfile.py +++ b/py-client/libs/lockfile.py @@ -1,7 +1,5 @@ """简单的文件锁标记工具。""" -from __future__ import annotations - from os import PathLike from pathlib import Path diff --git a/py-client/libs/order.py b/py-client/libs/order.py index 9e725bf..cead696 100644 --- a/py-client/libs/order.py +++ b/py-client/libs/order.py @@ -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) diff --git a/py-client/libs/orderbook.py b/py-client/libs/orderbook.py index 8f3b689..e45c766 100644 --- a/py-client/libs/orderbook.py +++ b/py-client/libs/orderbook.py @@ -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: diff --git a/py-client/libs/runtime.py b/py-client/libs/runtime.py index bb8a607..e757773 100644 --- a/py-client/libs/runtime.py +++ b/py-client/libs/runtime.py @@ -1,7 +1,5 @@ """策略单次运行所需的公共上下文对象。""" -from __future__ import annotations - from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass diff --git a/py-client/requirements.txt b/py-client/requirements.txt index fbbb499..ef40016 100644 --- a/py-client/requirements.txt +++ b/py-client/requirements.txt @@ -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 diff --git a/py-client/sdk/client.py b/py-client/sdk/client.py index 04c2bcb..a2eb2cf 100644 --- a/py-client/sdk/client.py +++ b/py-client/sdk/client.py @@ -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 diff --git a/py-client/sdk/context.py b/py-client/sdk/context.py index 95942a8..a95c82e 100644 --- a/py-client/sdk/context.py +++ b/py-client/sdk/context.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any diff --git a/py-client/sdk/data.py b/py-client/sdk/data.py index 131269d..d5b678d 100644 --- a/py-client/sdk/data.py +++ b/py-client/sdk/data.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from .models import Tick diff --git a/py-client/sdk/get.py b/py-client/sdk/get.py index 6a8a27a..991c5a6 100644 --- a/py-client/sdk/get.py +++ b/py-client/sdk/get.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any from urllib.parse import urlencode diff --git a/py-client/sdk/models.py b/py-client/sdk/models.py index a260b70..425810a 100644 --- a/py-client/sdk/models.py +++ b/py-client/sdk/models.py @@ -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( diff --git a/py-client/sdk/portfolio.py b/py-client/sdk/portfolio.py index fef2b69..00a234a 100644 --- a/py-client/sdk/portfolio.py +++ b/py-client/sdk/portfolio.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any from .models import Assets, DealItem, OrderItem, Portfolio, PositionItem diff --git a/py-client/sdk/sys.py b/py-client/sdk/sys.py index 114efdf..8ab0966 100644 --- a/py-client/sdk/sys.py +++ b/py-client/sdk/sys.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any diff --git a/py-client/sdk/trade.py b/py-client/sdk/trade.py index c3339dc..25ff0a4 100644 --- a/py-client/sdk/trade.py +++ b/py-client/sdk/trade.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any diff --git a/py-client/strategy/ipo/boot.py b/py-client/strategy/ipo/boot.py index b7c8e18..fbf3d1e 100644 --- a/py-client/strategy/ipo/boot.py +++ b/py-client/strategy/ipo/boot.py @@ -1,7 +1,5 @@ """新股自动申购,提供交易日校验、券商对账和本地幂等保护。""" -from __future__ import annotations - import json import logging from datetime import datetime, time diff --git a/py-client/strategy/trend/boot.py b/py-client/strategy/trend/boot.py index 4da149f..0048671 100644 --- a/py-client/strategy/trend/boot.py +++ b/py-client/strategy/trend/boot.py @@ -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) diff --git a/py-client/strategy/trend/open.py b/py-client/strategy/trend/open.py index 89da94f..744c03a 100644 --- a/py-client/strategy/trend/open.py +++ b/py-client/strategy/trend/open.py @@ -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: diff --git a/py-client/strategy/trend/positions.py b/py-client/strategy/trend/positions.py index 838a5d3..78019aa 100644 --- a/py-client/strategy/trend/positions.py +++ b/py-client/strategy/trend/positions.py @@ -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, "卖出委托处理中") diff --git a/py-client/strategy/zt/boot.py b/py-client/strategy/zt/boot.py index 92670e0..2880c1a 100644 --- a/py-client/strategy/zt/boot.py +++ b/py-client/strategy/zt/boot.py @@ -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) \ No newline at end of file + log.exception("[运行] %s线程失败", name) diff --git a/py-client/strategy/zt/open.py b/py-client/strategy/zt/open.py index 3479004..ee53224 100644 --- a/py-client/strategy/zt/open.py +++ b/py-client/strategy/zt/open.py @@ -1,7 +1,5 @@ """使用 dcm 信号建立做 T 底仓。""" -from __future__ import annotations - from datetime import datetime import logging as log import math diff --git a/py-client/strategy/zt/positions.py b/py-client/strategy/zt/positions.py index f1677cc..19d3f41 100644 --- a/py-client/strategy/zt/positions.py +++ b/py-client/strategy/zt/positions.py @@ -1,7 +1,5 @@ """日内先卖后买的做 T 规则,不包含趋势补仓或整仓止盈。""" -from __future__ import annotations - import logging as log import math diff --git a/py-client/strategy/zt/state.py b/py-client/strategy/zt/state.py index b21f0bc..cb16246 100644 --- a/py-client/strategy/zt/state.py +++ b/py-client/strategy/zt/state.py @@ -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-'): diff --git a/py-client/tests/test_python314_performance.py b/py-client/tests/test_python314_performance.py new file mode 100644 index 0000000..e748f19 --- /dev/null +++ b/py-client/tests/test_python314_performance.py @@ -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()