dev zt
This commit is contained in:
@@ -1,112 +1,114 @@
|
||||
"""日内做 T 策略启动器。"""
|
||||
"""做 T 策略启动器。
|
||||
|
||||
该模块负责组合 SDK、配置、状态存储和做 T 策略组件,供 main.py 调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import logging as log
|
||||
import time
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from datetime import datetime, time as clock_time
|
||||
|
||||
import config
|
||||
from libs.calc import trading_time
|
||||
from libs.grid_take_profit import GridTrailingTracker
|
||||
from libs.market import market_allow_open
|
||||
from libs.signal import init_signals
|
||||
from libs.collector import collector_push
|
||||
from libs.grid_take_profit import GridTrailingTracker
|
||||
from sdk import Client
|
||||
from strategy.trend.order import OrderBook
|
||||
from strategy.trend.watch import DipWatch
|
||||
|
||||
from .order import OrderBook
|
||||
from .watch import DipWatch
|
||||
from .runtime import Runtime
|
||||
from .state import TState, SOLD
|
||||
from .open import open_signal
|
||||
from .positions import manage_positions
|
||||
from .runtime import Runtime
|
||||
from .state import TState
|
||||
|
||||
|
||||
def StartZT() -> None:
|
||||
client = Client(config.global_config.qmt_base_url, config.global_config.qmt_token, config.HTTP_TIMEOUT)
|
||||
orders = OrderBook()
|
||||
orders.refresh(client)
|
||||
_, positions = client.positions()
|
||||
state = TState.for_strategy(config.global_config.qmt_data_dir, config.account_config.strategy, config.account_config.account_id)
|
||||
state.reconcile(positions, orders.data, datetime.now().date().isoformat())
|
||||
run = Runtime(client, config.global_config, config.account_config, state, orders, DipWatch(), GridTrailingTracker(config.account_config.grid_step_pct))
|
||||
while True:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
RunOnce(run)
|
||||
except Exception:
|
||||
logging.exception("ZT 策略本轮失败")
|
||||
time.sleep(max(0.0, 30.0 - (time.monotonic() - started)))
|
||||
"""初始化做 T 策略,并以 30 秒间隔持续执行。"""
|
||||
with Client(config.global_config.qmt_base_url, config.global_config.qmt_token,
|
||||
config.HTTP_TIMEOUT) as client:
|
||||
state = TState.for_strategy(config.global_config.qmt_data_dir, "zt", config.account_config.account_id)
|
||||
run = Runtime(client, config.global_config, config.account_config, state,
|
||||
OrderBook(), DipWatch(), DipWatch(),
|
||||
GridTrailingTracker(config.account_config.grid_step_pct))
|
||||
log.info("[ZT 启动] 账户=%s,底仓信号=dcm,状态文件=%s", run.account_cfg.account_id, state.path)
|
||||
while True:
|
||||
now = datetime.now()
|
||||
if now.time() >= clock_time(15):
|
||||
# 收盘前最后一次只读对账,不发新单;未完成买回继续持久保存。
|
||||
try:
|
||||
portfolio = client.portfolio()
|
||||
state.reconcile(list(portfolio.positions.values()), portfolio.orders, now.date().isoformat())
|
||||
except Exception:
|
||||
log.exception("[ZT] 收盘对账失败,保留本地待确认记录")
|
||||
for item in state.items.values():
|
||||
if item.phase == SOLD or state.busy(item.code):
|
||||
log.warning("[ZT] 收盘仍有待完成轮次:%s", item.code)
|
||||
return
|
||||
# 单轮失败不能杀死唯一的交易定时线程。
|
||||
try:
|
||||
RunOnce(run)
|
||||
except Exception:
|
||||
log.exception("[ZT] 本 tick 执行失败,下一个 tick 继续")
|
||||
# 计算距离下一个目标时间点(0秒或30秒)的等待时间。
|
||||
time.sleep(30 - datetime.now().second % 30)
|
||||
|
||||
|
||||
def RunOnce(run: Runtime) -> None:
|
||||
if not trading_time(datetime.now()):
|
||||
"""账户快照 → 成交对账 → 做 T 管理 → dcm 建仓,共用一份资金预算。"""
|
||||
now = datetime.now()
|
||||
if not trading_time(now) or now.time() >= clock_time(15):
|
||||
return
|
||||
today = now.date().isoformat()
|
||||
started_at = time.monotonic()
|
||||
|
||||
# 1. 一次获取资产、持仓和订单,并清理过期订单。
|
||||
portfolio = run.client.portfolio()
|
||||
positions = list(portfolio.positions.values())
|
||||
run.orders.refresh(run.client, portfolio.orders)
|
||||
# 对账使用完整原始订单列表,不能丢弃撤单和废单的部分成交。
|
||||
run.state.reconcile(positions, portfolio.orders, today)
|
||||
|
||||
# 2. 获取本策略的信号开仓数据;信号失败不阻断已有做 T 买回。
|
||||
try:
|
||||
run.orders.refresh(run.client)
|
||||
assets = run.client.assets()
|
||||
position_codes, positions = run.client.positions()
|
||||
signals = init_signals(run.global_cfg, ["dcm"])
|
||||
except Exception:
|
||||
logging.exception("[ZT] 刷新账户或订单失败")
|
||||
log.exception("[ZT] 获取 dcm 信号失败,本轮只管理已有底仓")
|
||||
signals = []
|
||||
position_codes = {position.stock_code for position in positions if position.volume > 0}
|
||||
candidates = [signal for signal in signals
|
||||
if signal.signal_key == "dcm" and signal.code not in position_codes]
|
||||
|
||||
# 3. 获取持仓和待开仓证券的实时行情 tick,零持仓的待买回证券也包含在内。
|
||||
codes = list(dict.fromkeys(list(position_codes) + list(run.state.items)
|
||||
+ [signal.code for signal in candidates]))
|
||||
ticks = run.client.full_tick(codes) if codes else {}
|
||||
now = datetime.now() # 网络请求可能跨过尾盘边界,提交前重新判断。
|
||||
if not trading_time(now) or now.time() >= clock_time(15):
|
||||
return
|
||||
today = datetime.now().date().isoformat()
|
||||
try:
|
||||
signals = init_signals(run.global_cfg, run.account_cfg.signal_allow)
|
||||
except Exception:
|
||||
logging.exception("[ZT] 获取 dcm 信号失败")
|
||||
return
|
||||
candidate_codes = [item.code for item in signals if item.code not in position_codes]
|
||||
codes = list(dict.fromkeys(position_codes + candidate_codes))
|
||||
try:
|
||||
ticks = run.client.full_tick(codes)
|
||||
except Exception:
|
||||
logging.exception("[ZT] 获取行情失败")
|
||||
return
|
||||
market_ok = market_allow_open(run.global_cfg.api_host)
|
||||
can_open = market_ok and assets.available >= assets.total * run.account_cfg.min_cash_ratio
|
||||
force_buy_back = datetime.now().time() >= clock_time(14, 50)
|
||||
|
||||
# 状态对账与开仓判断并行。持仓线程在自己的线程中等待对账完成,
|
||||
# 以保证它读取到最新的底仓和做 T 轮次状态,避免并发写 State。
|
||||
with ThreadPoolExecutor(max_workers=3, thread_name_prefix="zt") as executor:
|
||||
state_future = executor.submit(run.state.reconcile, positions, run.orders.data, today)
|
||||
open_future = executor.submit(_run_open_signal, state_future, run, ticks, signals, can_open)
|
||||
positions_future = executor.submit(
|
||||
_run_manage_positions,
|
||||
state_future,
|
||||
run,
|
||||
ticks,
|
||||
positions,
|
||||
assets.available,
|
||||
today,
|
||||
force_buy_back,
|
||||
)
|
||||
_wait_worker("状态对账", state_future)
|
||||
_wait_worker("开仓", open_future)
|
||||
_wait_worker("持仓管理", positions_future)
|
||||
# 4. 先完成买回,避免开底仓抢占资金;交易逻辑串行,状态无需多线程写入。
|
||||
available = max(0.0, portfolio.assets.available)
|
||||
# 未确认买单可能尚未反映在资金快照中,保守预留,宁可少买也不重复使用。
|
||||
for pending in run.state.pending.values():
|
||||
if pending.kind != "sell":
|
||||
tick = ticks.get(pending.code)
|
||||
if tick is None or tick.last_price <= 0:
|
||||
available = 0.0
|
||||
break
|
||||
available = max(0.0, available - pending.qty * tick.last_price * 1.01)
|
||||
force = now.time() >= clock_time(14, 50)
|
||||
available = manage_positions(run, ticks, positions, available, today, force)
|
||||
|
||||
# 5. 验证可用资金;低于资金安全线时禁止开新仓,尾盘只完成做 T 买回。
|
||||
reserve = max(0.0, portfolio.assets.total * run.account_cfg.min_cash_ratio)
|
||||
# 未完成的卖出/买回可能继续占用资金,不再额外开底仓。
|
||||
outstanding = bool(run.state.pending) or any(item.phase == SOLD for item in run.state.items.values())
|
||||
if not force and not outstanding and market_allow_open() and available > reserve:
|
||||
open_signal(run, ticks, candidates, available - reserve)
|
||||
|
||||
def _run_open_signal(state_future: Future, run: Runtime, ticks, signals, can_open: bool) -> None:
|
||||
state_future.result()
|
||||
if can_open:
|
||||
open_signal(run, ticks, signals)
|
||||
|
||||
|
||||
def _run_manage_positions(
|
||||
state_future: Future,
|
||||
run: Runtime,
|
||||
ticks,
|
||||
positions,
|
||||
available: float,
|
||||
today: str,
|
||||
force_buy_back: bool,
|
||||
) -> None:
|
||||
state_future.result()
|
||||
manage_positions(run, ticks, positions, available, today, force_buy_back)
|
||||
|
||||
|
||||
def _wait_worker(name: str, future: Future) -> None:
|
||||
try:
|
||||
future.result()
|
||||
except Exception:
|
||||
logging.exception("[ZT] %s线程失败", name)
|
||||
# 6. 数据采集不与交易逻辑争用状态;采集函数自身隔离传输异常。
|
||||
collector_push(run.account_cfg.account_id, portfolio.assets, positions)
|
||||
log.info("[ZT] 本轮完成,底仓=%d,待确认=%d,耗时=%d毫秒",
|
||||
len(run.state.items), len(run.state.pending), int((time.monotonic() - started_at) * 1000))
|
||||
|
||||
@@ -2,30 +2,100 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import logging as log
|
||||
import math
|
||||
|
||||
from libs.calc import calc_buy_volume
|
||||
from sdk import OP_BUY
|
||||
from strategy.trend.order import PlaceOrderRequest
|
||||
from .runtime import Runtime
|
||||
from .order import PlaceOrderRequest
|
||||
from .state import PendingOrder
|
||||
|
||||
|
||||
def open_signal(run, ticks, signals) -> None:
|
||||
"""仅处理 dcm 信号,使用趋势策略同款反弹确认建立底仓。"""
|
||||
for signal in signals:
|
||||
if signal.signal_key != "dcm" or run.orders.busy(signal.code, "BUY"):
|
||||
def open_signal(run: Runtime, ticks, signals, available: float) -> float:
|
||||
"""逐个验证开仓信号并提交买入委托,返回本轮剩余资金。"""
|
||||
for item in signals:
|
||||
try:
|
||||
now = datetime.now()
|
||||
if (now.hour, now.minute) >= (14, 50):
|
||||
break
|
||||
if item.signal_key != "dcm" or item.code in run.account_cfg.excluded_codes:
|
||||
continue
|
||||
state = run.state.items.get(item.code)
|
||||
if state is not None and state.base_qty > 0:
|
||||
continue
|
||||
# 1. 验证信号配置允许开仓的时间区间。
|
||||
signal_config = run.global_cfg.signals.get("dcm")
|
||||
if signal_config is None or not check_timezone(signal_config.timezone):
|
||||
continue
|
||||
# 2. 检查该证券是否已有买入委托锁,防止重复下单。
|
||||
if run.state.busy(item.code) or run.orders.busy(item.code, "BUY") or run.orders.busy(item.code, "SELL"):
|
||||
continue
|
||||
# 3. 验证行情和最新价格是否有效。
|
||||
tick = ticks.get(item.code)
|
||||
price = tick.last_price if tick else 0.0
|
||||
if not math.isfinite(price) or price <= 0 or price > run.account_cfg.zt_max_price:
|
||||
continue
|
||||
# 4. 根据单笔买入金额计算整手开仓数量,预留少量价差和费用。
|
||||
budget = min(run.account_cfg.buy_value, available)
|
||||
volume = calc_buy_volume(price, budget)
|
||||
amount = price * volume * 1.01
|
||||
if volume <= 0 or price * volume > budget or amount > available:
|
||||
continue
|
||||
# 5. 等待价格从观察低点反弹,防止直接接下跌中的“飞刀”。
|
||||
if not run.open_watch.triggered("ZT 建仓", item.code, price):
|
||||
continue
|
||||
order_id = run.orders.new_order_id("base")
|
||||
request = PlaceOrderRequest(OP_BUY, item.code, volume, order_id, "zt", kind="base")
|
||||
run.state.new_order(PendingOrder(order_id, item.code, "base", volume, datetime.now().date().isoformat()))
|
||||
# 即使响应丢失,也保留资金预算和 pending,不能继续使用这笔钱。
|
||||
available -= amount
|
||||
if run.orders.place(run.client, request):
|
||||
run.open_watch.forget(item.code)
|
||||
log.info("[ZT 建仓] %s 买入 %d 股,等待实际成交", item.code, volume)
|
||||
except Exception:
|
||||
log.exception("[ZT 建仓] %s 处理异常,继续后续信号", item.code)
|
||||
return available
|
||||
|
||||
|
||||
def check_timezone(timezone: str, now: datetime | None = None) -> bool:
|
||||
"""验证当前时间是否处于配置区间。
|
||||
|
||||
``*`` 表示全天允许;多个区间用逗号分隔,例如
|
||||
``9:30-10:30,13:30-14:30``。同时支持跨午夜区间。
|
||||
"""
|
||||
timezone = str(timezone or "").strip()
|
||||
if timezone == "*":
|
||||
return True
|
||||
|
||||
current = now or datetime.now()
|
||||
current_minutes = current.hour * 60 + current.minute
|
||||
|
||||
for section in timezone.split(","):
|
||||
bounds = section.strip().split("-")
|
||||
if len(bounds) != 2:
|
||||
continue
|
||||
tick = ticks.get(signal.code)
|
||||
price = tick.last_price if tick else 0.0
|
||||
if price <= 0 or price > run.account_cfg.zt_max_price:
|
||||
start = _parse_minutes(bounds[0])
|
||||
end = _parse_minutes(bounds[1])
|
||||
if start is None or end is None:
|
||||
continue
|
||||
volume = calc_buy_volume(price, run.account_cfg.buy_value)
|
||||
if volume <= 0 or not run.buy_watch.triggered("ZT 建仓", signal.code, price):
|
||||
continue
|
||||
request = PlaceOrderRequest(run.client, OP_BUY, signal.code, volume, run.orders.new_order_id("base"), run.account_cfg.strategy)
|
||||
if run.orders.place(request):
|
||||
run.buy_watch.forget(signal.code)
|
||||
logging.info("[ZT 建仓] %s 买入 %d 股", signal.code, volume)
|
||||
|
||||
if start <= end and start <= current_minutes <= end:
|
||||
return True
|
||||
if start > end and (current_minutes >= start or current_minutes <= end):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# 与 trend 策略的开仓函数命名保持一致。
|
||||
open_base = open_signal
|
||||
def _parse_minutes(value: str) -> int | None:
|
||||
"""把 ``时:分`` 转换为当天分钟数,无效值返回 None。"""
|
||||
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
|
||||
|
||||
125
py-client/strategy/zt/order.py
Normal file
125
py-client/strategy/zt/order.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""做 T 策略委托簿,对应 Go 客户端的 ``logic/order.go``。"""
|
||||
|
||||
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, lock_timeout_sec: int = 180, cancel_timeout_sec: float = 10) -> None:
|
||||
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()
|
||||
|
||||
@staticmethod
|
||||
def new_order_id(side:str) -> str:
|
||||
"""生成 ``zt-xxxxxxxx`` 格式的本地订单号。"""
|
||||
return f"zt-{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("zt-")
|
||||
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
|
||||
@@ -1,71 +1,94 @@
|
||||
"""日内先卖后买的做 T 规则。"""
|
||||
"""日内先卖后买的做 T 规则,不包含趋势补仓或整仓止盈。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import logging as log
|
||||
import math
|
||||
|
||||
from libs.grid_take_profit import GridState
|
||||
from sdk import OP_BUY, OP_SELL, PositionItem
|
||||
from strategy.trend.order import PlaceOrderRequest
|
||||
|
||||
from .state import BUYING, READY, SELLING, SOLD
|
||||
from .order import PlaceOrderRequest
|
||||
from .runtime import Runtime
|
||||
from .state import PendingOrder, READY, SOLD
|
||||
|
||||
|
||||
def manage_positions(run, ticks, positions: list[PositionItem], available: float, today: str, force_buy_back: bool = False) -> None:
|
||||
for position in positions:
|
||||
code = position.stock_code
|
||||
tick = ticks.get(code)
|
||||
if not code or code in run.account_cfg.excluded_codes or tick is None:
|
||||
continue
|
||||
price = tick.last_price
|
||||
if price <= 0 or price > run.account_cfg.zt_max_price:
|
||||
continue
|
||||
def manage_positions(run: Runtime, ticks, positions: list[PositionItem], available: float,
|
||||
today: str, force_buy_back: bool = False) -> float:
|
||||
"""遍历本地底仓记录;全部卖出后即使持仓快照为空,也必须处理买回。"""
|
||||
by_code = {position.stock_code: position for position in positions}
|
||||
for code, state in list(run.state.items.items()):
|
||||
try:
|
||||
state = run.state.get(code)
|
||||
except KeyError:
|
||||
continue
|
||||
if state.phase == READY and not force_buy_back:
|
||||
_try_sell(run, state, position, price, today)
|
||||
elif state.phase == SOLD:
|
||||
_try_buy_back(run, state, price, available, today, force_buy_back)
|
||||
now = datetime.now()
|
||||
if now.hour >= 15:
|
||||
break
|
||||
force_buy_back = force_buy_back or (now.hour, now.minute) >= (14, 50)
|
||||
if code in run.account_cfg.excluded_codes or run.state.busy(code):
|
||||
continue
|
||||
if run.orders.busy(code, "BUY") or run.orders.busy(code, "SELL"):
|
||||
continue
|
||||
tick = ticks.get(code)
|
||||
price = tick.last_price if tick else 0.0
|
||||
if not math.isfinite(price) or price <= 0:
|
||||
continue
|
||||
position = by_code.get(code)
|
||||
actual_qty = position.volume if position else 0
|
||||
expected_qty = state.base_qty - state.sell_qty + state.buy_qty
|
||||
# 快照延迟或手动增减仓不能当作新的做 T 信号,先核对数量差异。
|
||||
if actual_qty != expected_qty:
|
||||
log.warning("[ZT 持仓] %s 数量不符,记录=%d,实际=%d,暂停交易", code, expected_qty, actual_qty)
|
||||
continue
|
||||
if state.phase == SOLD:
|
||||
available = _try_buy_back(run, state, price, available, today, force_buy_back)
|
||||
elif state.phase == READY and position and not force_buy_back:
|
||||
if price <= run.account_cfg.zt_max_price:
|
||||
_try_sell(run, state, position, price, today)
|
||||
except Exception:
|
||||
log.exception("[ZT 持仓] %s 处理异常,继续后续证券", code)
|
||||
return available
|
||||
|
||||
|
||||
def _try_sell(run, state, position: PositionItem, price: float, today: str) -> None:
|
||||
if state.base_cost <= 0 or run.orders.busy(state.code, "SELL"):
|
||||
def _try_sell(run: Runtime, state, position: PositionItem, price: float, today: str) -> None:
|
||||
"""基于独立保存的底仓成本,用跨轮最高盈利网格判断做 T 卖出。"""
|
||||
if state.base_cost <= 0:
|
||||
return
|
||||
pnl_rate = (price - state.base_cost) / state.base_cost * 100
|
||||
observation = run.sell_tracker.observe(f"{run.account_cfg.account_id}:{state.code}", pnl_rate)
|
||||
key = f"{run.account_cfg.account_id}:{state.code}:{today}"
|
||||
observation = run.sell_tracker.observe(key, pnl_rate)
|
||||
if observation.state != GridState.RETREAT:
|
||||
return
|
||||
volume = min(position.can_use_volume, int(state.base_qty * run.account_cfg.zt_sell_ratio) // 100 * 100)
|
||||
volume = min(position.can_use_volume, int(state.base_qty * run.account_cfg.zt_sell_ratio))
|
||||
volume = volume // 100 * 100
|
||||
if volume <= 0:
|
||||
return
|
||||
order_id = run.orders.new_order_id("t-sell")
|
||||
request = PlaceOrderRequest(run.client, OP_SELL, state.code, volume, order_id, run.account_cfg.strategy)
|
||||
if not run.orders.place(request):
|
||||
return
|
||||
state.trade_date, state.phase = today, SELLING
|
||||
state.sell_order_id, state.sell_qty, state.sell_price = order_id, volume, price
|
||||
run.state.set(state)
|
||||
run.state.save()
|
||||
logging.info("[ZT 卖出] %s %d 股,网格回撤触发", state.code, volume)
|
||||
request = PlaceOrderRequest(OP_SELL, state.code, volume, order_id, "zt", kind="sell")
|
||||
run.state.new_order(PendingOrder(order_id, state.code, "sell", volume, today))
|
||||
if run.orders.place(run.client, request):
|
||||
log.info("[ZT 卖出] %s %d 股,等待成交后确定买回数量和价格", state.code, volume)
|
||||
|
||||
|
||||
def _try_buy_back(run, state, price: float, available: float, today: str, force: bool) -> None:
|
||||
def _try_buy_back(run: Runtime, state, price: float, available: float, today: str, force: bool) -> float:
|
||||
"""按实际卖出均价下跌后反弹买回;尾盘不再受下跌幅度、反弹及价格上限限制。"""
|
||||
target = state.sell_price * (1 - run.account_cfg.zt_buy_fall_pct / 100)
|
||||
if (not force and price > target) or run.orders.busy(state.code, "BUY"):
|
||||
return
|
||||
if state.sell_qty <= 0 or price * state.sell_qty > available:
|
||||
return
|
||||
if not force and (price > target or price > run.account_cfg.zt_max_price):
|
||||
return available
|
||||
volume = state.sell_qty - state.buy_qty
|
||||
amount = price * volume * 1.01
|
||||
if volume <= 0 or amount > available:
|
||||
log.warning("[ZT 买回] %s 买回资金不足或数量无效,保留未完成轮次", state.code)
|
||||
return available
|
||||
if not force and not run.buy_watch.triggered("ZT 买回", state.code, price):
|
||||
return
|
||||
return available
|
||||
order_id = run.orders.new_order_id("t-buy")
|
||||
request = PlaceOrderRequest(run.client, OP_BUY, state.code, state.sell_qty, order_id, run.account_cfg.strategy)
|
||||
if not run.orders.place(request):
|
||||
return
|
||||
state.trade_date, state.phase, state.buy_order_id = today, BUYING, order_id
|
||||
run.state.set(state)
|
||||
run.state.save()
|
||||
run.buy_watch.forget(state.code)
|
||||
reason = "尾盘强制买回" if force else f"回撤 {run.account_cfg.zt_buy_fall_pct:.2f}% 后反弹确认"
|
||||
logging.info("[ZT 买回] %s %d 股,%s", state.code, state.sell_qty, reason)
|
||||
request = PlaceOrderRequest(OP_BUY, state.code, volume, order_id, "zt", kind="buy")
|
||||
run.state.new_order(PendingOrder(order_id, state.code, "buy", volume, today))
|
||||
# pending 已落盘,任何请求结果都预留资金;下一轮再从柜台快照确认。
|
||||
available -= amount
|
||||
try:
|
||||
if run.orders.place(run.client, request):
|
||||
run.buy_watch.forget(state.code)
|
||||
log.info("[ZT 买回] %s %d 股,%s", state.code, volume, "尾盘强制买回" if force else "下跌后反弹")
|
||||
except Exception:
|
||||
log.exception("[ZT 买回] %s 请求结果未知,保留 pending 和预算", state.code)
|
||||
return available
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
"""做 T 策略的运行期依赖。"""
|
||||
"""做 T 策略单次运行所需的上下文对象。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from config import AccountConfig, GlobalConfig
|
||||
from libs.grid_take_profit import GridTrailingTracker
|
||||
from sdk import Client
|
||||
from strategy.trend.order import OrderBook
|
||||
from strategy.trend.watch import DipWatch
|
||||
|
||||
from .order import OrderBook
|
||||
from .watch import DipWatch
|
||||
from .state import TState
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Runtime:
|
||||
"""集中保存策略运行期间共享的依赖和状态。
|
||||
|
||||
将这些对象集中到一个 dataclass 后,开仓、持仓管理和单轮调度函数
|
||||
只需接收一个 Runtime,无需重复传递大量参数。
|
||||
"""
|
||||
|
||||
# 外部服务与账户配置。
|
||||
client: Client
|
||||
global_cfg: GlobalConfig
|
||||
account_cfg: AccountConfig
|
||||
|
||||
# 策略运行过程中共享的状态组件。
|
||||
state: TState
|
||||
orders: OrderBook
|
||||
open_watch: DipWatch
|
||||
buy_watch: DipWatch
|
||||
sell_tracker: GridTrailingTracker
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
"""做 T 策略的底仓与日内轮次状态。"""
|
||||
"""做 T 策略的底仓、待确认委托和实际成交记录。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
import logging as log
|
||||
import math
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Iterable
|
||||
from time import time
|
||||
|
||||
from sdk import OrderItem, PositionItem
|
||||
|
||||
READY = "READY"
|
||||
SELLING = "SELLING"
|
||||
SOLD = "SOLD"
|
||||
BUYING = "BUYING"
|
||||
DONE = "DONE"
|
||||
READY, SOLD, DONE = "READY", "SOLD", "DONE"
|
||||
TERMINAL_STATUSES = {"53", "54", "56", "57"}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -28,76 +26,139 @@ class TStateItem:
|
||||
sell_qty: int = 0
|
||||
sell_price: float = 0.0
|
||||
buy_order_id: str = ""
|
||||
base_order_id: str = ""
|
||||
buy_qty: int = 0
|
||||
buy_cost: float = 0.0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PendingOrder:
|
||||
order_id: str
|
||||
code: str
|
||||
kind: str # base:底仓;sell:做 T 卖出;buy:做 T 买回
|
||||
qty: int
|
||||
trade_date: str
|
||||
submit_at: float = field(default_factory=time)
|
||||
|
||||
|
||||
class TState:
|
||||
"""持久化 dcm 底仓和每只证券每日一次的做 T 进度。"""
|
||||
"""交易逻辑串行更新;JSON 保存底仓、待确认委托及成交历史。"""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
self.lock = Lock()
|
||||
self.items = self._load()
|
||||
self.items: dict[str, TStateItem] = {}
|
||||
self.pending: dict[str, PendingOrder] = {}
|
||||
self.records: list[dict] = []
|
||||
self._load()
|
||||
|
||||
@classmethod
|
||||
def for_strategy(cls, data_dir: str | Path, strategy: str, account_id: str) -> "TState":
|
||||
def for_strategy(cls, data_dir: str | Path, strategy: str, account_id: str) -> TState:
|
||||
return cls(Path(data_dir) / f"{strategy}_{account_id}_state.json")
|
||||
|
||||
def get(self, code: str) -> TStateItem:
|
||||
with self.lock:
|
||||
return self.items[code]
|
||||
def busy(self, code: str) -> bool:
|
||||
return any(order.code == code for order in self.pending.values())
|
||||
|
||||
def set(self, item: TStateItem) -> None:
|
||||
with self.lock:
|
||||
self.items[item.code] = item
|
||||
def new_order(self, order: PendingOrder) -> None:
|
||||
"""下单前落盘;请求超时不能当作失败删除,等待后续委托确认。"""
|
||||
if self.busy(order.code):
|
||||
raise ValueError(f"{order.code} 已有待确认委托")
|
||||
self.pending[order.order_id] = order
|
||||
try:
|
||||
self.save()
|
||||
except Exception:
|
||||
del self.pending[order.order_id]
|
||||
raise
|
||||
|
||||
def reconcile(self, positions: Iterable[PositionItem], orders: list[OrderItem], today: str) -> None:
|
||||
position_list = [item for item in positions if item.stock_code and item.volume > 0]
|
||||
position_codes = {item.stock_code for item in position_list}
|
||||
by_local_id: dict[str, list[OrderItem]] = {}
|
||||
def reconcile(self, positions: list[PositionItem], orders: list[OrderItem], today: str) -> None:
|
||||
"""先按实际成交记账,再接管未知持仓;不覆盖已记录的底仓成本。"""
|
||||
# 同一本地委托可能拆单;按券商订单号去重,数量齐全且全部结束才记账。
|
||||
by_id: dict[str, dict[str, OrderItem]] = {}
|
||||
for order in orders:
|
||||
if order.local_order_id:
|
||||
by_local_id.setdefault(order.local_order_id, []).append(order)
|
||||
|
||||
for position in position_list:
|
||||
if position.stock_code not in self.items and position.open_price > 0:
|
||||
self.set(TStateItem(position.stock_code, position.volume, position.open_price))
|
||||
|
||||
for code in list(self.items):
|
||||
item = self.get(code)
|
||||
if code not in position_codes:
|
||||
with self.lock:
|
||||
self.items.pop(code, None)
|
||||
by_id.setdefault(order.local_order_id, {})[order.id] = order
|
||||
for order_id, pending in list(self.pending.items()):
|
||||
side = "SELL" if pending.kind == "sell" else "BUY"
|
||||
rows = [row for row in by_id.get(order_id, {}).values()
|
||||
if row.code == pending.code and row.side == side]
|
||||
if not rows:
|
||||
log.warning("[ZT 状态] 委托暂未查到,保留待确认:%s", order_id)
|
||||
continue
|
||||
if item.trade_date and item.trade_date != today and item.phase in {DONE, READY}:
|
||||
item.trade_date, item.phase = "", READY
|
||||
if sum(row.volume for row in rows) != pending.qty:
|
||||
continue
|
||||
if any(row.status not in TERMINAL_STATUSES for row in rows):
|
||||
continue
|
||||
if any(row.status == "56" and row.traded_volume != row.volume for row in rows):
|
||||
continue
|
||||
qty = sum(row.traded_volume for row in rows)
|
||||
amounts = [row.trade_amount if row.trade_amount > 0 else row.trade_price * row.traded_volume
|
||||
for row in rows if row.traded_volume > 0]
|
||||
if any(not math.isfinite(amount) or amount <= 0 for amount in amounts):
|
||||
continue
|
||||
amount = sum(amounts)
|
||||
cost = amount / qty if qty else 0.0
|
||||
item = self.items.setdefault(pending.code, TStateItem(pending.code))
|
||||
if pending.kind == "base":
|
||||
item.base_order_id = order_id
|
||||
item.base_qty, item.base_cost = qty, cost
|
||||
elif pending.kind == "sell":
|
||||
item.trade_date = today # 跨日成交也占用确认当天的一轮。
|
||||
item.sell_order_id = order_id
|
||||
item.sell_qty, item.sell_price = qty, cost
|
||||
item.buy_qty, item.buy_cost = 0, 0.0
|
||||
item.phase = SOLD if qty else READY
|
||||
else:
|
||||
total = item.buy_qty + qty
|
||||
item.buy_cost = (item.buy_qty * item.buy_cost + amount) / total if total else 0.0
|
||||
item.buy_qty = total
|
||||
item.buy_order_id = order_id
|
||||
item.phase = DONE if total >= item.sell_qty else SOLD
|
||||
if item.phase == DONE:
|
||||
item.trade_date = today
|
||||
# 零成交撤单也记录,保留计划、实际数量、均价和柜台终态。
|
||||
self.records.append({**asdict(pending), "confirmed_date": today,
|
||||
"filled_qty": qty, "filled_cost": cost,
|
||||
"amount": amount, "statuses": [row.status for row in rows]})
|
||||
del self.pending[order_id]
|
||||
|
||||
for position in positions:
|
||||
code = position.stock_code
|
||||
if position.volume <= 0 or self.busy(code):
|
||||
continue
|
||||
if code not in self.items and math.isfinite(position.open_price) and position.open_price > 0:
|
||||
self.items[code] = TStateItem(code, position.volume, position.open_price)
|
||||
self.records.append({"kind": "import", "code": code, "date": today,
|
||||
"filled_qty": position.volume, "filled_cost": position.open_price})
|
||||
log.warning("[ZT 底仓] 首次接管 %s,使用当前均价,无法还原历史成本", code)
|
||||
|
||||
for item in self.items.values():
|
||||
# 未买回的轮次跨日继续,不删除零持仓的做 T 债务。
|
||||
if item.trade_date != today and item.phase == DONE and not self.busy(item.code):
|
||||
item.phase, item.trade_date = READY, ""
|
||||
item.sell_qty = item.buy_qty = 0
|
||||
item.sell_price = item.buy_cost = 0.0
|
||||
item.sell_order_id = item.buy_order_id = ""
|
||||
item.sell_qty = 0
|
||||
item.sell_price = 0.0
|
||||
if item.phase == SELLING and _completed(by_local_id.get(item.sell_order_id)):
|
||||
item.phase = SOLD
|
||||
elif item.phase == BUYING and _completed(by_local_id.get(item.buy_order_id)):
|
||||
item.phase = DONE
|
||||
self.set(item)
|
||||
self.save()
|
||||
|
||||
def save(self) -> None:
|
||||
with self.lock:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
temporary.write_text(json.dumps({key: asdict(value) for key, value in self.items.items()}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
temporary.replace(self.path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
payload = {"items": {key: asdict(item) for key, item in self.items.items()},
|
||||
"pending": {key: asdict(item) for key, item in self.pending.items()},
|
||||
"records": self.records}
|
||||
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
||||
temporary.replace(self.path)
|
||||
|
||||
def _load(self) -> dict[str, TStateItem]:
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"[ZT 状态] 读取失败: {exc}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("[ZT 状态] 根节点必须是对象")
|
||||
return {code: TStateItem(**value) for code, value in raw.items()}
|
||||
|
||||
|
||||
def _completed(orders: list[OrderItem] | None) -> bool:
|
||||
return bool(orders) and all(order.status == "56" for order in orders)
|
||||
def _load(self) -> None:
|
||||
if not self.path.is_file():
|
||||
return
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
# 兼容原 ZT 文件,保留原底仓成本;没有额外版本字段。
|
||||
self.items = {code: TStateItem(**item) for code, item in raw.get("items", raw).items()}
|
||||
self.pending = {key: PendingOrder(**item) for key, item in raw.get("pending", {}).items()}
|
||||
self.records = raw.get("records", [])
|
||||
if "items" not in raw:
|
||||
# 旧记录只有提交行情价,不把它伪装成真实成交历史。
|
||||
self.records.append({"kind": "legacy_import", "items": raw})
|
||||
for item in self.items.values():
|
||||
if item.phase in {"SELLING", "BUYING", SOLD}:
|
||||
raise ValueError(f"[ZT 状态] {item.code} 旧做 T 轮次未结束,需先核对成交再迁移")
|
||||
|
||||
104
py-client/strategy/zt/watch.py
Normal file
104
py-client/strategy/zt/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