fix trend,zt

This commit is contained in:
2026-09-06 13:12:48 +08:00
parent bcc6f02398
commit 2eafbb8303
15 changed files with 502 additions and 480 deletions

165
py-client/libs/order.py Normal file
View File

@@ -0,0 +1,165 @@
"""策略共用委托簿。"""
from __future__ import annotations
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta
from threading import Lock
from cachelib import SimpleCache
import logging
import httpx
from sdk import Client, ORDER_SIDE_BY_OFFSET, APIError, OrderItem
# 表示委托仍在处理、可能继续成交的 QMT 状态。
BUSY_STATUSES = {"48", "49", "50", "51", "52", "55"}
COMPLETED_STATUSES = {"56"}
TRACKED_STATUSES = BUSY_STATUSES | COMPLETED_STATUSES
CANCELABLE_STATUSES = {"49", "50", "51", "52"}
@dataclass(slots=True)
class PlaceOrderRequest:
"""``OrderBook.place`` 提交委托所需的全部参数。"""
op: int
code: str
volume: int
order_id: str
strategy_name: str
kind: str = ""
class OrderBook:
"""线程安全的活动委托缓存。"""
def __init__(
self, order_prefix: str, lock_timeout_sec: int = 180, cancel_timeout_sec: float = 10
) -> None:
self.order_prefix = order_prefix
self.lock_timeout_sec = max(1, lock_timeout_sec)
self.cancel_timeout_sec = timedelta(seconds=cancel_timeout_sec)
self.data: list[OrderItem] = []
self.busy_keys: set[str] = set()
self.busy_cache = SimpleCache(
threshold=10_000, default_timeout=self.lock_timeout_sec
)
self.mutex = Lock()
def new_order_id(self, side: str) -> str:
"""生成带策略前缀的本地订单号。"""
return f"{self.order_prefix}-{side}-{secrets.token_hex(10)}"
def busy(self, code: str, side: str) -> bool:
"""判断证券是否存在仍在处理中的同方向委托。"""
with self.mutex:
key = self._busy_key(side, code)
return key in self.busy_keys or self.busy_cache.has(key)
@staticmethod
def _busy_key(side: str, code: str) -> str:
return f"{side}-{code}"
def refresh(self, client: Client, orders: list[OrderItem]) -> None:
"""用账户快照刷新委托,并撤销超时的活动委托。"""
current = datetime.now()
data: list[OrderItem] = []
busy_keys: set[str] = set()
canceled = 0
for item in orders:
# 不处理状态不对的
if item.status not in TRACKED_STATUSES:
continue
if item.status in BUSY_STATUSES:
busy_keys.add(self._busy_key(item.side, item.code))
# 清理过期的
if (
item.created_at is not None
and item.local_order_id.startswith(f"{self.order_prefix}-")
and item.status in CANCELABLE_STATUSES
and current - item.created_at > self.cancel_timeout_sec
):
try:
client.cancel_by_id(item.id)
canceled += 1
logging.info(
"[Order] 超时撤单,代码=%s,方向=%s,柜台订单=%s",
item.code,
item.side,
item.id,
)
except Exception:
logging.exception(
"[Order] 撤单失败,保留在途状态,订单=%s", item.id
)
# 缓存本次有效订单
data.append(item)
with self.mutex:
self.data = data
self.busy_keys = busy_keys
logging.info(
"[Order] 刷新完成,跟踪=%d,处理中=%d,撤销=%d",
len(data),
len(busy_keys),
canceled,
)
def place(self, client: Client, request: PlaceOrderRequest) -> bool:
"""按最新价提交委托,并立即写入本地方向锁。"""
side = ORDER_SIDE_BY_OFFSET.get(str(request.op), "")
if not side:
logging.warning(
"[Order] 下单失败,代码=%s,原因=未知买卖方向(%s)",
request.code,
request.op,
)
return False
key = self._busy_key(side, request.code)
with self.mutex:
if key in self.busy_keys or self.busy_cache.has(key):
logging.info(
"[Order] 跳过重复下单,代码=%s,方向=%s", request.code, side
)
return False
self.busy_cache.set(key, True, timeout=self.lock_timeout_sec)
try:
result = client.passorder(
op_type=request.op,
stock_code=request.code,
volume=request.volume,
strategy_name=request.strategy_name,
order_id=request.order_id,
)
except APIError as exc:
logging.exception(
"[Order] 下单失败,代码=%s,本地订单=%sHTTP状态=%d,错误=%s",
request.code,
request.order_id,
exc.status_code,
exc.message or str(exc),
)
return False
except (httpx.RequestError, ValueError):
# 响应异常不能证明柜台未受理,保留缓存防重,不自动重试。
logging.exception(
"[Order] 下单请求或响应异常,代码=%s,本地订单=%s",
request.code,
request.order_id,
)
return False
logging.info(
"[Order] 下单已受理,代码=%s,方向=%s,数量=%d,本地订单=%s,返回=%s",
request.code,
side,
request.volume,
request.order_id,
result,
)
return True

View File

@@ -0,0 +1,39 @@
"""账户启动概览日志。"""
import logging as log
import config
def Overview(assets, positions, account_cfg=None) -> None:
"""记录策略启动时的账户、资金和持仓概览。"""
account_cfg = account_cfg or config.account_config
if account_cfg is not None:
log.info(
"[启动] 账户=%s,主机=%s,单笔金额=%.2f",
account_cfg.account_id,
account_cfg.host_key,
account_cfg.buy_value,
)
if assets is not None:
log.info("[启动] 总资产=%.2f,可用资金=%.2f", assets.total, assets.available)
else:
log.warning("[启动] 获取资金概览失败")
for position in positions:
if position.volume <= 0:
continue
log.info(
"[启动] %s %s %s,持仓=%d,可用=%d,成本=%.2f(%.2f),现价=%.2f,盈亏=%.2f%%",
position.trade_id,
position.stock_code,
position.stock_name,
position.volume,
position.can_use_volume,
position.open_price,
position.open_cost,
position.last_price,
position.profit_rate * 100,
)

26
py-client/libs/runtime.py Normal file
View File

@@ -0,0 +1,26 @@
"""策略单次运行所需的公共上下文对象。"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from config import AccountConfig, GlobalConfig
from sdk import Client
from libs.grid_take_profit import GridTrailingTracker
from libs.order import OrderBook
from libs.watch import DipWatch
@dataclass(slots=True)
class Runtime:
"""集中保存策略运行期间共享的客户端、配置和内存组件。"""
client: Client
global_cfg: GlobalConfig
account_cfg: AccountConfig
orders: OrderBook
open_watch: DipWatch
add_watch: DipWatch
profit_tracker: GridTrailingTracker
executor: ThreadPoolExecutor | None = None

104
py-client/libs/watch.py Normal file
View File

@@ -0,0 +1,104 @@
import logging as log
from dataclasses import dataclass
from datetime import datetime, timedelta
from threading import Lock
@dataclass(slots=True)
class _Entry:
last_close: float
expires_at: datetime
class DipWatch:
"""观察价格低点,并在价格达到指定反弹幅度时触发。"""
def __init__(
self,
expire_seconds: float = 300,
rebound_threshold: float = 1.5, # 反弹力度 1.5%
) -> None:
self.expire_seconds = expire_seconds
self.rebound_threshold = rebound_threshold
self.data: dict[str, _Entry] = {}
self.lock = Lock()
def triggered(
self,
tag: str,
code: str,
price: float,
now: datetime | None = None,
) -> bool:
"""更新观察价格;达到反弹阈值时返回 ``True``。"""
if price <= 0:
log.warning("[%s Watch] %s 价格无效:%.2f", tag, code, price)
return False
current = now or datetime.now()
with self.lock:
watch = self.data.get(code)
if watch is None:
self._start(code, price, current)
log.info(
"[%s Watch] %s 开始观察,收盘价=%.2f",
tag,
code,
price,
)
return False
if current >= watch.expires_at:
self._start(code, price, current)
log.info("[%sWatch] %s 观察已过期,重新观察,收盘价=%.2f", tag, code, price)
return False
if price < watch.last_close:
old_price = watch.last_close
self._start(code, price, current)
log.info(
"[%s Watch] %s 刷新低点,原收盘价=%.2f,新收盘价=%.2f",
tag,
code,
old_price,
price,
)
return False
rebound = (price - watch.last_close) / watch.last_close * 100
if rebound < self.rebound_threshold:
log.info(
"[%s Watch] %s 等待反弹,收盘价=%.2f,现价=%.2f,反弹=%.2f%%,阈值=%.2f%%",
tag,
code,
watch.last_close,
price,
rebound,
self.rebound_threshold,
)
return False
del self.data[code]
log.info(
"[%s Watch] %s 反弹触发,收盘价=%.2f,现价=%.2f,反弹=%.2f%%",
tag,
code,
watch.last_close,
price,
rebound,
)
return True
def forget(self, code: str) -> None:
"""清除指定股票的价格观察状态。"""
with self.lock:
removed = self.data.pop(code, None)
if removed is not None:
log.info("[Watch] %s 已清除观察状态", code)
def _start(self, code: str, price: float, now: datetime) -> None:
self.data[code] = _Entry(
last_close=price,
expires_at=now + timedelta(seconds=self.expire_seconds),
)