fix trend,zt
This commit is contained in:
165
py-client/libs/order.py
Normal file
165
py-client/libs/order.py
Normal 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,本地订单=%s,HTTP状态=%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
|
||||
39
py-client/libs/overview.py
Normal file
39
py-client/libs/overview.py
Normal 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
26
py-client/libs/runtime.py
Normal 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
104
py-client/libs/watch.py
Normal 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),
|
||||
)
|
||||
Reference in New Issue
Block a user