This commit is contained in:
2026-09-06 11:53:28 +08:00
parent ac9e9193ad
commit bcc6f02398
7 changed files with 612 additions and 218 deletions

View File

@@ -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))

View File

@@ -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

View 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,本地订单=%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

@@ -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

View File

@@ -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

View File

@@ -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 轮次未结束,需先核对成交再迁移")

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),
)