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
|
import socket
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ from datetime import datetime, time
|
|||||||
from math import floor
|
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:
|
def trading_time(now: datetime) -> bool:
|
||||||
if now.weekday() >= 5: return False
|
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:
|
def calc_buy_volume(price: float, buy_value: float) -> int:
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""简单的文件锁标记工具。"""
|
"""简单的文件锁标记工具。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from os import PathLike
|
from os import PathLike
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""策略共用委托簿。"""
|
"""策略共用委托簿。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -69,17 +67,19 @@ class OrderBook:
|
|||||||
canceled = 0
|
canceled = 0
|
||||||
|
|
||||||
for item in orders:
|
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
|
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))
|
busy_keys.add(self._busy_key(item.side, item.stock_code))
|
||||||
# 清理过期的
|
# 清理过期的
|
||||||
|
created_at = item.created_at
|
||||||
if (
|
if (
|
||||||
item.created_at is not None
|
created_at is not None
|
||||||
and item.local_order_id.startswith(f"{self.order_prefix}-")
|
and item.local_order_id.startswith(f"{self.order_prefix}-")
|
||||||
and str(item.order_status) in CANCELABLE_STATUSES
|
and status in CANCELABLE_STATUSES
|
||||||
and current - item.created_at > self.cancel_timeout_sec
|
and current - created_at > self.cancel_timeout_sec
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
client.cancel_by_id(item.order_sys_id)
|
client.cancel_by_id(item.order_sys_id)
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"""SQLite positions and deals, aligned with SDK models; one writer per database."""
|
"""SQLite positions and deals, aligned with SDK models; one writer per database."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
import math
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
from dataclasses import asdict, fields
|
from dataclasses import asdict, fields
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from itertools import chain
|
||||||
|
|
||||||
from sdk import DealItem, PositionItem
|
from sdk import DealItem, PositionItem
|
||||||
|
|
||||||
@@ -106,7 +105,7 @@ class OrderBook:
|
|||||||
raise ValueError('System order ID and positive volume are required')
|
raise ValueError('System order ID and positive volume are required')
|
||||||
|
|
||||||
row = asdict(deal)
|
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']:
|
if not row['order_local_id']:
|
||||||
raise ValueError('Local order ID is required')
|
raise ValueError('Local order ID is required')
|
||||||
|
|
||||||
@@ -145,7 +144,7 @@ class OrderBook:
|
|||||||
raise ValueError('Execution history is append-only')
|
raise ValueError('Execution history is append-only')
|
||||||
new_deals = deals[len(self.deals):]
|
new_deals = deals[len(self.deals):]
|
||||||
positions = [{**POSITION_DEFAULTS, **item} for item in items.values()]
|
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()):
|
if any(isinstance(value, float) and not math.isfinite(value) for value in row.values()):
|
||||||
raise ValueError('Numeric values must be finite')
|
raise ValueError('Numeric values must be finite')
|
||||||
with closing(self._connect()) as db, db:
|
with closing(self._connect()) as db, db:
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""策略单次运行所需的公共上下文对象。"""
|
"""策略单次运行所需的公共上下文对象。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
httpx>=0.27,<1
|
# Validated on Windows x64, CPython 3.14.7. Install with pip -r.
|
||||||
PyYAML>=6.0
|
anyio==4.15.1
|
||||||
APScheduler>=3.10,<4
|
APScheduler==3.11.3
|
||||||
CacheLib>=0.13,<1
|
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
|
import json
|
||||||
from dataclasses import asdict, is_dataclass
|
from dataclasses import asdict, is_dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -33,13 +31,13 @@ class HTTPClient:
|
|||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
self.http.close()
|
self.http.close()
|
||||||
|
|
||||||
def __enter__(self) -> "HTTPClient":
|
def __enter__(self) -> HTTPClient:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, *_args: object) -> None:
|
def __exit__(self, *_args: object) -> None:
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
def set_account_type(self, account_type: str) -> "HTTPClient":
|
def set_account_type(self, account_type: str) -> HTTPClient:
|
||||||
if account_type.strip():
|
if account_type.strip():
|
||||||
self.account_type = account_type
|
self.account_type = account_type
|
||||||
return self
|
return self
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .models import Tick
|
from .models import Tick
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
from __future__ import annotations
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
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:
|
def _number(value: Any, kind: type = float) -> Any:
|
||||||
@@ -40,6 +43,9 @@ class OrderItem:
|
|||||||
def get_local_order_id(self) -> str:
|
def get_local_order_id(self) -> str:
|
||||||
return self.remark.split("|", 1)[0]
|
return self.remark.split("|", 1)[0]
|
||||||
|
|
||||||
|
# Keep the existing property name available to SDK callers.
|
||||||
|
local_order_id = get_local_order_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def created_at(self) -> datetime | None:
|
def created_at(self) -> datetime | None:
|
||||||
return _parse_datetime(self.insert_date, self.insert_time)
|
return _parse_datetime(self.insert_date, self.insert_time)
|
||||||
@@ -71,6 +77,8 @@ class DealItem:
|
|||||||
def get_local_order_id(self) -> str:
|
def get_local_order_id(self) -> str:
|
||||||
return self.remark.split("|", 1)[0]
|
return self.remark.split("|", 1)[0]
|
||||||
|
|
||||||
|
local_order_id = get_local_order_id
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class PositionItem:
|
class PositionItem:
|
||||||
@@ -111,9 +119,10 @@ class Portfolio:
|
|||||||
|
|
||||||
|
|
||||||
def _side(offset_flag: int) -> str:
|
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:
|
def _parse_datetime(date: str, clock: str) -> datetime | None:
|
||||||
clock = clock.replace(":", "").zfill(6)
|
clock = clock.replace(":", "").zfill(6)
|
||||||
try:
|
try:
|
||||||
@@ -129,7 +138,7 @@ class Tick:
|
|||||||
raw: dict[str, Any] = field(default_factory=dict)
|
raw: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: Any) -> "Tick":
|
def from_dict(cls, data: Any) -> Tick:
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return cls()
|
return cls()
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .models import Assets, DealItem, OrderItem, Portfolio, PositionItem
|
from .models import Assets, DealItem, OrderItem, Portfolio, PositionItem
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""新股自动申购,提供交易日校验、券商对账和本地幂等保护。"""
|
"""新股自动申购,提供交易日校验、券商对账和本地幂等保护。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, time
|
from datetime import datetime, time
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
该模块负责组合 SDK、配置、状态存储和趋势策略组件,供 main.py 调用。
|
该模块负责组合 SDK、配置、状态存储和趋势策略组件,供 main.py 调用。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import logging as log
|
import logging as log
|
||||||
from concurrent.futures import Future, ThreadPoolExecutor
|
from concurrent.futures import Future, ThreadPoolExecutor
|
||||||
@@ -161,7 +159,7 @@ def RunOnce(run: Runtime, signals: list[SignalItem]) -> None:
|
|||||||
allow_open: list[SignalItem] = []
|
allow_open: list[SignalItem] = []
|
||||||
allow_codes: list[str] = []
|
allow_codes: list[str] = []
|
||||||
for signal in signals:
|
for signal in signals:
|
||||||
if signal.code not in position_codes:
|
if signal.code not in portfolio.positions:
|
||||||
allow_open.append(signal)
|
allow_open.append(signal)
|
||||||
allow_codes.append(signal.code)
|
allow_codes.append(signal.code)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""趋势策略开仓逻辑。"""
|
"""趋势策略开仓逻辑。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
from libs import calc_buy_volume
|
from libs import calc_buy_volume
|
||||||
from sdk import OP_BUY
|
from sdk import OP_BUY
|
||||||
@@ -164,6 +163,7 @@ def check_timezone(timezone: str, now: datetime | None = None) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=256)
|
||||||
def _parse_minutes(value: str) -> int | None:
|
def _parse_minutes(value: str) -> int | None:
|
||||||
"""把 ``时:分`` 转换为当天分钟数,无效值返回 None。"""
|
"""把 ``时:分`` 转换为当天分钟数,无效值返回 None。"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""趋势策略持仓止盈与分级补仓。"""
|
"""趋势策略持仓止盈与分级补仓。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from libs.calc import calc_buy_volume, calculate_min_profit_rate
|
from libs.calc import calc_buy_volume, calculate_min_profit_rate
|
||||||
@@ -34,7 +32,7 @@ def manage_positions(
|
|||||||
# 遍历处理每个持仓
|
# 遍历处理每个持仓
|
||||||
for position in positions:
|
for position in positions:
|
||||||
try:
|
try:
|
||||||
available = max(0, 0, available)
|
available = max(0, available)
|
||||||
code = position.stock_code
|
code = position.stock_code
|
||||||
tick = ticks.get(code)
|
tick = ticks.get(code)
|
||||||
if code in runtime.account_cfg.excluded_codes:
|
if code in runtime.account_cfg.excluded_codes:
|
||||||
@@ -130,7 +128,7 @@ def handle_profit(
|
|||||||
False,
|
False,
|
||||||
f"上涨至 {pnl_rate:.2f}%,峰值网格={observation.current_grid}",
|
f"上涨至 {pnl_rate:.2f}%,峰值网格={observation.current_grid}",
|
||||||
)
|
)
|
||||||
if observation.state in {GridState.STEADY}:
|
if observation.state == GridState.STEADY:
|
||||||
return TradeDecision(False)
|
return TradeDecision(False)
|
||||||
if runtime.orders.busy(position.stock_code, "SELL"):
|
if runtime.orders.busy(position.stock_code, "SELL"):
|
||||||
return TradeDecision(False, "卖出委托处理中")
|
return TradeDecision(False, "卖出委托处理中")
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
该模块负责组合 SDK、配置、状态存储和做 T 策略组件,供 main.py 调用。
|
该模块负责组合 SDK、配置、状态存储和做 T 策略组件,供 main.py 调用。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from concurrent.futures import Future, ThreadPoolExecutor
|
from concurrent.futures import Future, ThreadPoolExecutor
|
||||||
import logging as log
|
import logging as log
|
||||||
import time
|
import time
|
||||||
@@ -143,7 +141,7 @@ def RunOnce(run: Runtime, state: TState, signals: list[SignalItem]) -> None:
|
|||||||
allow_open: list[SignalItem] = []
|
allow_open: list[SignalItem] = []
|
||||||
allow_codes: list[str] = []
|
allow_codes: list[str] = []
|
||||||
for signal in signals:
|
for signal in signals:
|
||||||
if signal.code not in position_codes:
|
if signal.code not in portfolio.positions:
|
||||||
allow_open.append(signal)
|
allow_open.append(signal)
|
||||||
allow_codes.append(signal.code)
|
allow_codes.append(signal.code)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""使用 dcm 信号建立做 T 底仓。"""
|
"""使用 dcm 信号建立做 T 底仓。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging as log
|
import logging as log
|
||||||
import math
|
import math
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""日内先卖后买的做 T 规则,不包含趋势补仓或整仓止盈。"""
|
"""日内先卖后买的做 T 规则,不包含趋势补仓或整仓止盈。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging as log
|
import logging as log
|
||||||
import math
|
import math
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""做 T 策略的持仓状态和逐笔实际成交记录。"""
|
"""做 T 策略的持仓状态和逐笔实际成交记录。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
import math
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -115,7 +113,10 @@ class TState:
|
|||||||
self.items[code] = TStateItem(code, -delta)
|
self.items[code] = TStateItem(code, -delta)
|
||||||
|
|
||||||
for row in rows:
|
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'])
|
self._reset(item, row['trade_date'])
|
||||||
qty, amount = row['volume'], row['trade_amount']
|
qty, amount = row['volume'], row['trade_amount']
|
||||||
if row['order_local_id'].startswith('zt-base-'):
|
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