dev zt
This commit is contained in:
@@ -1,112 +1,114 @@
|
|||||||
"""日内做 T 策略启动器。"""
|
"""做 T 策略启动器。
|
||||||
|
|
||||||
|
该模块负责组合 SDK、配置、状态存储和做 T 策略组件,供 main.py 调用。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging as log
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import Future, ThreadPoolExecutor
|
|
||||||
from datetime import datetime, time as clock_time
|
from datetime import datetime, time as clock_time
|
||||||
|
|
||||||
import config
|
import config
|
||||||
from libs.calc import trading_time
|
from libs.calc import trading_time
|
||||||
from libs.grid_take_profit import GridTrailingTracker
|
|
||||||
from libs.market import market_allow_open
|
from libs.market import market_allow_open
|
||||||
from libs.signal import init_signals
|
from libs.signal import init_signals
|
||||||
|
from libs.collector import collector_push
|
||||||
|
from libs.grid_take_profit import GridTrailingTracker
|
||||||
from sdk import Client
|
from sdk import Client
|
||||||
from strategy.trend.order import OrderBook
|
from .order import OrderBook
|
||||||
from strategy.trend.watch import DipWatch
|
from .watch import DipWatch
|
||||||
|
from .runtime import Runtime
|
||||||
|
from .state import TState, SOLD
|
||||||
from .open import open_signal
|
from .open import open_signal
|
||||||
from .positions import manage_positions
|
from .positions import manage_positions
|
||||||
from .runtime import Runtime
|
|
||||||
from .state import TState
|
|
||||||
|
|
||||||
|
|
||||||
def StartZT() -> None:
|
def StartZT() -> None:
|
||||||
client = Client(config.global_config.qmt_base_url, config.global_config.qmt_token, config.HTTP_TIMEOUT)
|
"""初始化做 T 策略,并以 30 秒间隔持续执行。"""
|
||||||
orders = OrderBook()
|
with Client(config.global_config.qmt_base_url, config.global_config.qmt_token,
|
||||||
orders.refresh(client)
|
config.HTTP_TIMEOUT) as client:
|
||||||
_, positions = client.positions()
|
state = TState.for_strategy(config.global_config.qmt_data_dir, "zt", config.account_config.account_id)
|
||||||
state = TState.for_strategy(config.global_config.qmt_data_dir, config.account_config.strategy, config.account_config.account_id)
|
run = Runtime(client, config.global_config, config.account_config, state,
|
||||||
state.reconcile(positions, orders.data, datetime.now().date().isoformat())
|
OrderBook(), DipWatch(), DipWatch(),
|
||||||
run = Runtime(client, config.global_config, config.account_config, state, orders, DipWatch(), GridTrailingTracker(config.account_config.grid_step_pct))
|
GridTrailingTracker(config.account_config.grid_step_pct))
|
||||||
while True:
|
log.info("[ZT 启动] 账户=%s,底仓信号=dcm,状态文件=%s", run.account_cfg.account_id, state.path)
|
||||||
started = time.monotonic()
|
while True:
|
||||||
try:
|
now = datetime.now()
|
||||||
RunOnce(run)
|
if now.time() >= clock_time(15):
|
||||||
except Exception:
|
# 收盘前最后一次只读对账,不发新单;未完成买回继续持久保存。
|
||||||
logging.exception("ZT 策略本轮失败")
|
try:
|
||||||
time.sleep(max(0.0, 30.0 - (time.monotonic() - started)))
|
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:
|
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
|
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:
|
try:
|
||||||
run.orders.refresh(run.client)
|
signals = init_signals(run.global_cfg, ["dcm"])
|
||||||
assets = run.client.assets()
|
|
||||||
position_codes, positions = run.client.positions()
|
|
||||||
except Exception:
|
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
|
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)
|
|
||||||
|
|
||||||
# 状态对账与开仓判断并行。持仓线程在自己的线程中等待对账完成,
|
# 4. 先完成买回,避免开底仓抢占资金;交易逻辑串行,状态无需多线程写入。
|
||||||
# 以保证它读取到最新的底仓和做 T 轮次状态,避免并发写 State。
|
available = max(0.0, portfolio.assets.available)
|
||||||
with ThreadPoolExecutor(max_workers=3, thread_name_prefix="zt") as executor:
|
# 未确认买单可能尚未反映在资金快照中,保守预留,宁可少买也不重复使用。
|
||||||
state_future = executor.submit(run.state.reconcile, positions, run.orders.data, today)
|
for pending in run.state.pending.values():
|
||||||
open_future = executor.submit(_run_open_signal, state_future, run, ticks, signals, can_open)
|
if pending.kind != "sell":
|
||||||
positions_future = executor.submit(
|
tick = ticks.get(pending.code)
|
||||||
_run_manage_positions,
|
if tick is None or tick.last_price <= 0:
|
||||||
state_future,
|
available = 0.0
|
||||||
run,
|
break
|
||||||
ticks,
|
available = max(0.0, available - pending.qty * tick.last_price * 1.01)
|
||||||
positions,
|
force = now.time() >= clock_time(14, 50)
|
||||||
assets.available,
|
available = manage_positions(run, ticks, positions, available, today, force)
|
||||||
today,
|
|
||||||
force_buy_back,
|
|
||||||
)
|
|
||||||
_wait_worker("状态对账", state_future)
|
|
||||||
_wait_worker("开仓", open_future)
|
|
||||||
_wait_worker("持仓管理", positions_future)
|
|
||||||
|
|
||||||
|
# 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:
|
# 6. 数据采集不与交易逻辑争用状态;采集函数自身隔离传输异常。
|
||||||
state_future.result()
|
collector_push(run.account_cfg.account_id, portfolio.assets, positions)
|
||||||
if can_open:
|
log.info("[ZT] 本轮完成,底仓=%d,待确认=%d,耗时=%d毫秒",
|
||||||
open_signal(run, ticks, signals)
|
len(run.state.items), len(run.state.pending), int((time.monotonic() - started_at) * 1000))
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|||||||
@@ -2,30 +2,100 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
from datetime import datetime
|
||||||
|
import logging as log
|
||||||
|
import math
|
||||||
|
|
||||||
from libs.calc import calc_buy_volume
|
from libs.calc import calc_buy_volume
|
||||||
from sdk import OP_BUY
|
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:
|
def open_signal(run: Runtime, ticks, signals, available: float) -> float:
|
||||||
"""仅处理 dcm 信号,使用趋势策略同款反弹确认建立底仓。"""
|
"""逐个验证开仓信号并提交买入委托,返回本轮剩余资金。"""
|
||||||
for signal in signals:
|
for item in signals:
|
||||||
if signal.signal_key != "dcm" or run.orders.busy(signal.code, "BUY"):
|
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
|
continue
|
||||||
tick = ticks.get(signal.code)
|
start = _parse_minutes(bounds[0])
|
||||||
price = tick.last_price if tick else 0.0
|
end = _parse_minutes(bounds[1])
|
||||||
if price <= 0 or price > run.account_cfg.zt_max_price:
|
if start is None or end is None:
|
||||||
continue
|
continue
|
||||||
volume = calc_buy_volume(price, run.account_cfg.buy_value)
|
|
||||||
if volume <= 0 or not run.buy_watch.triggered("ZT 建仓", signal.code, price):
|
if start <= end and start <= current_minutes <= end:
|
||||||
continue
|
return True
|
||||||
request = PlaceOrderRequest(run.client, OP_BUY, signal.code, volume, run.orders.new_order_id("base"), run.account_cfg.strategy)
|
if start > end and (current_minutes >= start or current_minutes <= end):
|
||||||
if run.orders.place(request):
|
return True
|
||||||
run.buy_watch.forget(signal.code)
|
|
||||||
logging.info("[ZT 建仓] %s 买入 %d 股", signal.code, volume)
|
return False
|
||||||
|
|
||||||
|
|
||||||
# 与 trend 策略的开仓函数命名保持一致。
|
def _parse_minutes(value: str) -> int | None:
|
||||||
open_base = open_signal
|
"""把 ``时:分`` 转换为当天分钟数,无效值返回 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
from datetime import datetime
|
||||||
|
import logging as log
|
||||||
|
import math
|
||||||
|
|
||||||
from libs.grid_take_profit import GridState
|
from libs.grid_take_profit import GridState
|
||||||
from sdk import OP_BUY, OP_SELL, PositionItem
|
from sdk import OP_BUY, OP_SELL, PositionItem
|
||||||
from strategy.trend.order import PlaceOrderRequest
|
from .order import PlaceOrderRequest
|
||||||
|
from .runtime import Runtime
|
||||||
from .state import BUYING, READY, SELLING, SOLD
|
from .state import PendingOrder, READY, SOLD
|
||||||
|
|
||||||
|
|
||||||
def manage_positions(run, ticks, positions: list[PositionItem], available: float, today: str, force_buy_back: bool = False) -> None:
|
def manage_positions(run: Runtime, ticks, positions: list[PositionItem], available: float,
|
||||||
for position in positions:
|
today: str, force_buy_back: bool = False) -> float:
|
||||||
code = position.stock_code
|
"""遍历本地底仓记录;全部卖出后即使持仓快照为空,也必须处理买回。"""
|
||||||
tick = ticks.get(code)
|
by_code = {position.stock_code: position for position in positions}
|
||||||
if not code or code in run.account_cfg.excluded_codes or tick is None:
|
for code, state in list(run.state.items.items()):
|
||||||
continue
|
|
||||||
price = tick.last_price
|
|
||||||
if price <= 0 or price > run.account_cfg.zt_max_price:
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
state = run.state.get(code)
|
now = datetime.now()
|
||||||
except KeyError:
|
if now.hour >= 15:
|
||||||
continue
|
break
|
||||||
if state.phase == READY and not force_buy_back:
|
force_buy_back = force_buy_back or (now.hour, now.minute) >= (14, 50)
|
||||||
_try_sell(run, state, position, price, today)
|
if code in run.account_cfg.excluded_codes or run.state.busy(code):
|
||||||
elif state.phase == SOLD:
|
continue
|
||||||
_try_buy_back(run, state, price, available, today, force_buy_back)
|
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:
|
def _try_sell(run: Runtime, state, position: PositionItem, price: float, today: str) -> None:
|
||||||
if state.base_cost <= 0 or run.orders.busy(state.code, "SELL"):
|
"""基于独立保存的底仓成本,用跨轮最高盈利网格判断做 T 卖出。"""
|
||||||
|
if state.base_cost <= 0:
|
||||||
return
|
return
|
||||||
pnl_rate = (price - state.base_cost) / state.base_cost * 100
|
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:
|
if observation.state != GridState.RETREAT:
|
||||||
return
|
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:
|
if volume <= 0:
|
||||||
return
|
return
|
||||||
order_id = run.orders.new_order_id("t-sell")
|
order_id = run.orders.new_order_id("t-sell")
|
||||||
request = PlaceOrderRequest(run.client, OP_SELL, state.code, volume, order_id, run.account_cfg.strategy)
|
request = PlaceOrderRequest(OP_SELL, state.code, volume, order_id, "zt", kind="sell")
|
||||||
if not run.orders.place(request):
|
run.state.new_order(PendingOrder(order_id, state.code, "sell", volume, today))
|
||||||
return
|
if run.orders.place(run.client, request):
|
||||||
state.trade_date, state.phase = today, SELLING
|
log.info("[ZT 卖出] %s %d 股,等待成交后确定买回数量和价格", state.code, volume)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
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"):
|
if not force and (price > target or price > run.account_cfg.zt_max_price):
|
||||||
return
|
return available
|
||||||
if state.sell_qty <= 0 or price * state.sell_qty > available:
|
volume = state.sell_qty - state.buy_qty
|
||||||
return
|
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):
|
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")
|
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)
|
request = PlaceOrderRequest(OP_BUY, state.code, volume, order_id, "zt", kind="buy")
|
||||||
if not run.orders.place(request):
|
run.state.new_order(PendingOrder(order_id, state.code, "buy", volume, today))
|
||||||
return
|
# pending 已落盘,任何请求结果都预留资金;下一轮再从柜台快照确认。
|
||||||
state.trade_date, state.phase, state.buy_order_id = today, BUYING, order_id
|
available -= amount
|
||||||
run.state.set(state)
|
try:
|
||||||
run.state.save()
|
if run.orders.place(run.client, request):
|
||||||
run.buy_watch.forget(state.code)
|
run.buy_watch.forget(state.code)
|
||||||
reason = "尾盘强制买回" if force else f"回撤 {run.account_cfg.zt_buy_fall_pct:.2f}% 后反弹确认"
|
log.info("[ZT 买回] %s %d 股,%s", state.code, volume, "尾盘强制买回" if force else "下跌后反弹")
|
||||||
logging.info("[ZT 买回] %s %d 股,%s", state.code, state.sell_qty, reason)
|
except Exception:
|
||||||
|
log.exception("[ZT 买回] %s 请求结果未知,保留 pending 和预算", state.code)
|
||||||
|
return available
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
"""做 T 策略的运行期依赖。"""
|
"""做 T 策略单次运行所需的上下文对象。"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from config import AccountConfig, GlobalConfig
|
from config import AccountConfig, GlobalConfig
|
||||||
from libs.grid_take_profit import GridTrailingTracker
|
from libs.grid_take_profit import GridTrailingTracker
|
||||||
from sdk import Client
|
from sdk import Client
|
||||||
from strategy.trend.order import OrderBook
|
from .order import OrderBook
|
||||||
from strategy.trend.watch import DipWatch
|
from .watch import DipWatch
|
||||||
|
|
||||||
from .state import TState
|
from .state import TState
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class Runtime:
|
class Runtime:
|
||||||
|
"""集中保存策略运行期间共享的依赖和状态。
|
||||||
|
|
||||||
|
将这些对象集中到一个 dataclass 后,开仓、持仓管理和单轮调度函数
|
||||||
|
只需接收一个 Runtime,无需重复传递大量参数。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 外部服务与账户配置。
|
||||||
client: Client
|
client: Client
|
||||||
global_cfg: GlobalConfig
|
global_cfg: GlobalConfig
|
||||||
account_cfg: AccountConfig
|
account_cfg: AccountConfig
|
||||||
|
|
||||||
|
# 策略运行过程中共享的状态组件。
|
||||||
state: TState
|
state: TState
|
||||||
orders: OrderBook
|
orders: OrderBook
|
||||||
|
open_watch: DipWatch
|
||||||
buy_watch: DipWatch
|
buy_watch: DipWatch
|
||||||
sell_tracker: GridTrailingTracker
|
sell_tracker: GridTrailingTracker
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
"""做 T 策略的底仓与日内轮次状态。"""
|
"""做 T 策略的底仓、待确认委托和实际成交记录。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from dataclasses import asdict, dataclass
|
import logging as log
|
||||||
|
import math
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock
|
from time import time
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
from sdk import OrderItem, PositionItem
|
from sdk import OrderItem, PositionItem
|
||||||
|
|
||||||
READY = "READY"
|
READY, SOLD, DONE = "READY", "SOLD", "DONE"
|
||||||
SELLING = "SELLING"
|
TERMINAL_STATUSES = {"53", "54", "56", "57"}
|
||||||
SOLD = "SOLD"
|
|
||||||
BUYING = "BUYING"
|
|
||||||
DONE = "DONE"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -28,76 +26,139 @@ class TStateItem:
|
|||||||
sell_qty: int = 0
|
sell_qty: int = 0
|
||||||
sell_price: float = 0.0
|
sell_price: float = 0.0
|
||||||
buy_order_id: str = ""
|
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:
|
class TState:
|
||||||
"""持久化 dcm 底仓和每只证券每日一次的做 T 进度。"""
|
"""交易逻辑串行更新;JSON 保存底仓、待确认委托及成交历史。"""
|
||||||
|
|
||||||
def __init__(self, path: str | Path) -> None:
|
def __init__(self, path: str | Path) -> None:
|
||||||
self.path = Path(path)
|
self.path = Path(path)
|
||||||
self.lock = Lock()
|
self.items: dict[str, TStateItem] = {}
|
||||||
self.items = self._load()
|
self.pending: dict[str, PendingOrder] = {}
|
||||||
|
self.records: list[dict] = []
|
||||||
|
self._load()
|
||||||
|
|
||||||
@classmethod
|
@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")
|
return cls(Path(data_dir) / f"{strategy}_{account_id}_state.json")
|
||||||
|
|
||||||
def get(self, code: str) -> TStateItem:
|
def busy(self, code: str) -> bool:
|
||||||
with self.lock:
|
return any(order.code == code for order in self.pending.values())
|
||||||
return self.items[code]
|
|
||||||
|
|
||||||
def set(self, item: TStateItem) -> None:
|
def new_order(self, order: PendingOrder) -> None:
|
||||||
with self.lock:
|
"""下单前落盘;请求超时不能当作失败删除,等待后续委托确认。"""
|
||||||
self.items[item.code] = item
|
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:
|
def reconcile(self, positions: list[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]] = {}
|
by_id: dict[str, dict[str, OrderItem]] = {}
|
||||||
for order in orders:
|
for order in orders:
|
||||||
if order.local_order_id:
|
if order.local_order_id:
|
||||||
by_local_id.setdefault(order.local_order_id, []).append(order)
|
by_id.setdefault(order.local_order_id, {})[order.id] = order
|
||||||
|
for order_id, pending in list(self.pending.items()):
|
||||||
for position in position_list:
|
side = "SELL" if pending.kind == "sell" else "BUY"
|
||||||
if position.stock_code not in self.items and position.open_price > 0:
|
rows = [row for row in by_id.get(order_id, {}).values()
|
||||||
self.set(TStateItem(position.stock_code, position.volume, position.open_price))
|
if row.code == pending.code and row.side == side]
|
||||||
|
if not rows:
|
||||||
for code in list(self.items):
|
log.warning("[ZT 状态] 委托暂未查到,保留待确认:%s", order_id)
|
||||||
item = self.get(code)
|
|
||||||
if code not in position_codes:
|
|
||||||
with self.lock:
|
|
||||||
self.items.pop(code, None)
|
|
||||||
continue
|
continue
|
||||||
if item.trade_date and item.trade_date != today and item.phase in {DONE, READY}:
|
if sum(row.volume for row in rows) != pending.qty:
|
||||||
item.trade_date, item.phase = "", READY
|
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_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()
|
self.save()
|
||||||
|
|
||||||
def save(self) -> None:
|
def save(self) -> None:
|
||||||
with self.lock:
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||||
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
|
payload = {"items": {key: asdict(item) for key, item in self.items.items()},
|
||||||
temporary.write_text(json.dumps({key: asdict(value) for key, value in self.items.items()}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
"pending": {key: asdict(item) for key, item in self.pending.items()},
|
||||||
temporary.replace(self.path)
|
"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]:
|
def _load(self) -> None:
|
||||||
try:
|
if not self.path.is_file():
|
||||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
return
|
||||||
except FileNotFoundError:
|
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
return {}
|
# 兼容原 ZT 文件,保留原底仓成本;没有额外版本字段。
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
self.items = {code: TStateItem(**item) for code, item in raw.get("items", raw).items()}
|
||||||
raise ValueError(f"[ZT 状态] 读取失败: {exc}") from exc
|
self.pending = {key: PendingOrder(**item) for key, item in raw.get("pending", {}).items()}
|
||||||
if not isinstance(raw, dict):
|
self.records = raw.get("records", [])
|
||||||
raise ValueError("[ZT 状态] 根节点必须是对象")
|
if "items" not in raw:
|
||||||
return {code: TStateItem(**value) for code, value in raw.items()}
|
# 旧记录只有提交行情价,不把它伪装成真实成交历史。
|
||||||
|
self.records.append({"kind": "legacy_import", "items": raw})
|
||||||
|
for item in self.items.values():
|
||||||
def _completed(orders: list[OrderItem] | None) -> bool:
|
if item.phase in {"SELLING", "BUYING", SOLD}:
|
||||||
return bool(orders) and all(order.status == "56" for order in orders)
|
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