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

View File

@@ -16,9 +16,9 @@ from libs.signal import init_signals
from libs.collector import collector_push
from libs.grid_take_profit import GridTrailingTracker
from sdk import Client
from .order import OrderBook
from .watch import DipWatch
from .runtime import Runtime
from libs.order import OrderBook
from libs.watch import DipWatch
from libs.runtime import Runtime
from .state import TState, SOLD
from .open import open_signal
from .positions import manage_positions
@@ -26,20 +26,39 @@ from .positions import manage_positions
def StartZT() -> None:
"""初始化做 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)
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=client,
global_cfg=config.global_config,
account_cfg=config.account_config,
orders=OrderBook("zt"),
open_watch=DipWatch(),
add_watch=DipWatch(),
profit_tracker=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())
state.reconcile(
list(portfolio.positions.values()),
portfolio.orders,
now.date().isoformat(),
)
except Exception:
log.exception("[ZT] 收盘对账失败,保留本地待确认记录")
for item in state.items.values():
@@ -48,14 +67,14 @@ def StartZT() -> None:
return
# 单轮失败不能杀死唯一的交易定时线程。
try:
RunOnce(run)
RunOnce(run, state)
except Exception:
log.exception("[ZT] 本 tick 执行失败,下一个 tick 继续")
# 计算距离下一个目标时间点0秒或30秒的等待时间。
time.sleep(30 - datetime.now().second % 30)
def RunOnce(run: Runtime) -> None:
def RunOnce(run: Runtime, state: TState) -> None:
"""账户快照 → 成交对账 → 做 T 管理 → dcm 建仓,共用一份资金预算。"""
now = datetime.now()
if not trading_time(now) or now.time() >= clock_time(15):
@@ -68,7 +87,7 @@ def RunOnce(run: Runtime) -> None:
positions = list(portfolio.positions.values())
run.orders.refresh(run.client, portfolio.orders)
# 对账使用完整原始订单列表,不能丢弃撤单和废单的部分成交。
run.state.reconcile(positions, portfolio.orders, today)
state.reconcile(positions, portfolio.orders, today)
# 2. 获取本策略的信号开仓数据;信号失败不阻断已有做 T 买回。
try:
@@ -76,13 +95,23 @@ def RunOnce(run: Runtime) -> None:
except Exception:
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]
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]))
codes = list(
dict.fromkeys(
list(position_codes)
+ list(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):
@@ -91,7 +120,7 @@ def RunOnce(run: Runtime) -> None:
# 4. 先完成买回,避免开底仓抢占资金;交易逻辑串行,状态无需多线程写入。
available = max(0.0, portfolio.assets.available)
# 未确认买单可能尚未反映在资金快照中,保守预留,宁可少买也不重复使用。
for pending in run.state.pending.values():
for pending in state.pending.values():
if pending.kind != "sell":
tick = ticks.get(pending.code)
if tick is None or tick.last_price <= 0:
@@ -99,16 +128,22 @@ def RunOnce(run: Runtime) -> None:
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)
available = manage_positions(run, state, 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())
outstanding = bool(state.pending) or any(
item.phase == SOLD for item in state.items.values()
)
if not force and not outstanding and market_allow_open() and available > reserve:
open_signal(run, ticks, candidates, available - reserve)
open_signal(run, state, ticks, candidates, available - reserve)
# 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))
log.info(
"[ZT] 本轮完成,底仓=%d,待确认=%d,耗时=%d毫秒",
len(state.items),
len(state.pending),
int((time.monotonic() - started_at) * 1000),
)

View File

@@ -8,12 +8,12 @@ import math
from libs.calc import calc_buy_volume
from sdk import OP_BUY
from .runtime import Runtime
from .order import PlaceOrderRequest
from .state import PendingOrder
from libs.runtime import Runtime
from libs.order import PlaceOrderRequest
from .state import PendingOrder, TState
def open_signal(run: Runtime, ticks, signals, available: float) -> float:
def open_signal(run: Runtime, state: TState, ticks, signals, available: float) -> float:
"""逐个验证开仓信号并提交买入委托,返回本轮剩余资金。"""
for item in signals:
try:
@@ -22,20 +22,28 @@ def open_signal(run: Runtime, ticks, signals, available: float) -> float:
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:
item_state = state.items.get(item.code)
if item_state is not None and item_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"):
if (
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:
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)
@@ -47,8 +55,18 @@ def open_signal(run: Runtime, ticks, signals, available: float) -> float:
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()))
request = PlaceOrderRequest(
OP_BUY, item.code, volume, order_id, "zt", kind="base"
)
state.new_order(
PendingOrder(
order_id,
item.code,
"base",
volume,
datetime.now().date().isoformat(),
)
)
# 即使响应丢失,也保留资金预算和 pending不能继续使用这笔钱。
available -= amount
if run.orders.place(run.client, request):

View File

@@ -1,125 +0,0 @@
"""做 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

@@ -8,22 +8,29 @@ import math
from libs.grid_take_profit import GridState
from sdk import OP_BUY, OP_SELL, PositionItem
from .order import PlaceOrderRequest
from .runtime import Runtime
from .state import PendingOrder, READY, SOLD
from libs.order import PlaceOrderRequest
from libs.runtime import Runtime
from .state import PendingOrder, READY, SOLD, TState
def manage_positions(run: Runtime, ticks, positions: list[PositionItem], available: float,
today: str, force_buy_back: bool = False) -> float:
def manage_positions(
run: Runtime,
state_store: TState,
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()):
for code, state in list(state_store.items.items()):
try:
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):
if code in run.account_cfg.excluded_codes or state_store.busy(code):
continue
if run.orders.busy(code, "BUY") or run.orders.busy(code, "SELL"):
continue
@@ -36,39 +43,65 @@ def manage_positions(run: Runtime, ticks, positions: list[PositionItem], availab
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)
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)
available = _try_buy_back(
run, state_store, 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)
_try_sell(run, state_store, state, position, price, today)
except Exception:
log.exception("[ZT 持仓] %s 处理异常,继续后续证券", code)
return available
def _try_sell(run: Runtime, state, position: PositionItem, price: float, today: str) -> None:
def _try_sell(
run: Runtime,
state_store: TState,
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
key = f"{run.account_cfg.account_id}:{state.code}:{today}"
observation = run.sell_tracker.observe(key, pnl_rate)
observation = run.profit_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))
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(OP_SELL, state.code, volume, order_id, "zt", kind="sell")
run.state.new_order(PendingOrder(order_id, state.code, "sell", volume, today))
request = PlaceOrderRequest(
OP_SELL, state.code, volume, order_id, "zt", kind="sell"
)
state_store.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: Runtime, state, price: float, available: float, today: str, force: bool) -> float:
def _try_buy_back(
run: Runtime,
state_store: TState,
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 price > run.account_cfg.zt_max_price):
@@ -78,17 +111,22 @@ def _try_buy_back(run: Runtime, state, price: float, available: float, today: st
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):
if not force and not run.add_watch.triggered("ZT 买回", state.code, price):
return available
order_id = run.orders.new_order_id("t-buy")
request = PlaceOrderRequest(OP_BUY, state.code, volume, order_id, "zt", kind="buy")
run.state.new_order(PendingOrder(order_id, state.code, "buy", volume, today))
state_store.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 "下跌后反弹")
run.add_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,31 +0,0 @@
"""做 T 策略单次运行所需的上下文对象。"""
from dataclasses import dataclass
from config import AccountConfig, GlobalConfig
from libs.grid_take_profit import GridTrailingTracker
from sdk import Client
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

@@ -35,7 +35,7 @@ class TStateItem:
class PendingOrder:
order_id: str
code: str
kind: str # base底仓sell做 T 卖出buy做 T 买回
kind: str # base底仓sell做 T 卖出buy做 T 买回
qty: int
trade_date: str
submit_at: float = field(default_factory=time)
@@ -52,7 +52,9 @@ class TState:
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 busy(self, code: str) -> bool:
@@ -69,7 +71,9 @@ class TState:
del self.pending[order.order_id]
raise
def reconcile(self, positions: list[PositionItem], orders: list[OrderItem], today: str) -> None:
def reconcile(
self, positions: list[PositionItem], orders: list[OrderItem], today: str
) -> None:
"""先按实际成交记账,再接管未知持仓;不覆盖已记录的底仓成本。"""
# 同一本地委托可能拆单;按券商订单号去重,数量齐全且全部结束才记账。
by_id: dict[str, dict[str, OrderItem]] = {}
@@ -78,8 +82,11 @@ class TState:
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]
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
@@ -87,11 +94,20 @@ class TState:
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):
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]
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)
@@ -108,31 +124,59 @@ class TState:
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_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]})
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)
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):
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
@@ -142,10 +186,15 @@ class TState:
def save(self) -> None:
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")
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) -> None:
@@ -153,12 +202,18 @@ class TState:
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.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 轮次未结束,需先核对成交再迁移")
raise ValueError(
f"[ZT 状态] {item.code} 旧做 T 轮次未结束,需先核对成交再迁移"
)

View File

@@ -1,104 +0,0 @@
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),
)