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

@@ -13,36 +13,18 @@ from datetime import datetime
import config
from libs.calc import trading_time
from libs.market import market_allow_open
from libs.overview import Overview
from libs.signal import init_signals, SignalItem
from libs.collector import collector_push
from sdk import Client
from libs.grid_take_profit import GridTrailingTracker
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 .open import open_signal
from .positions import manage_positions
def Overview(assets, positions, account_cfg=None) -> None:
"""记录策略启动时的账户、资金和持仓概览。"""
account_cfg = account_cfg or config.account_config
if account_cfg is not None:
log.info("[启动] 账户=%s,主机=%s,单笔金额=%.2f", account_cfg.account_id, account_cfg.host_key, account_cfg.buy_value)
if assets is not None:
log.info("[启动] 总资产=%.2f,可用资金=%.2f", assets.total, assets.available)
else:
log.warning("[启动] 获取资金概览失败")
for position in positions:
if position.volume <= 0:
continue
log.info("[启动] %s %s %s,持仓=%d,可用=%d,成本=%.2f(%.2f),现价=%.2f,盈亏=%.2f%%",position.trade_id, position.stock_code, position.stock_name, position.volume, position.can_use_volume, position.open_price,position.open_cost, position.last_price, position.profit_rate * 100)
def StartTrend() -> None:
"""初始化趋势策略,并以 30 秒间隔持续执行。"""
client = Client(
@@ -55,7 +37,7 @@ def StartTrend() -> None:
portfolio = client.portfolio()
assets = portfolio.assets
positions = list(portfolio.positions.values())
order_book = OrderBook()
order_book = OrderBook("trend")
order_book.refresh(client, portfolio.orders)
# 获取本策略的信号开仓数据
@@ -63,7 +45,12 @@ def StartTrend() -> None:
config.global_config,
config.account_config.signal_allow,
)
log.info("[启动] 趋势策略已启动,账户=%s,信号=%d,持仓=%d", config.account_config.account_id, len(signals), len(positions))
log.info(
"[启动] 趋势策略已启动,账户=%s,信号=%d,持仓=%d",
config.account_config.account_id,
len(signals),
len(positions),
)
executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="trend")
run = Runtime(
client=client,
@@ -101,7 +88,9 @@ def StartTrend() -> None:
try:
RunOnce(run, signals)
except Exception as e:
log.error(f"[Trend] 本 tick 执行失败,下一 tick 继续: {e}", exc_info=True)
log.error(
f"[Trend] 本 tick 执行失败,下一 tick 继续: {e}", exc_info=True
)
finally:
try:
if executor is not None:
@@ -110,13 +99,15 @@ def StartTrend() -> None:
client.close()
def RunOnce(run: Runtime, signals:list[SignalItem]) -> None:
def RunOnce(run: Runtime, signals: list[SignalItem]) -> None:
"""按固定步骤执行一轮趋势策略, ``RunOnce``。"""
if not trading_time(datetime.now()):
return
print("=" * 40 + f" Ticker {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} " +"=" * 40)
print(
"=" * 40 + f" Ticker {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} " + "=" * 40
)
started_at = time.monotonic()
# 1. 一次获取资产、持仓和订单,并清理过期订单。
@@ -131,21 +122,27 @@ def RunOnce(run: Runtime, signals:list[SignalItem]) -> None:
return
futures: list[tuple[str, Future]] = [
(
"数据提交",
run.executor.submit(
collector_push,
run.account_cfg.account_id,
assets,
positions,
),
)
]
(
"数据提交",
run.executor.submit(
collector_push,
run.account_cfg.account_id,
assets,
positions,
),
)
]
# 2. 验证可用资金;低于资金安全线时禁止开新仓。
allow_open_by_cash = assets.available >= assets.total * run.account_cfg.min_cash_ratio
allow_open_by_cash = (
assets.available >= assets.total * run.account_cfg.min_cash_ratio
)
if not allow_open_by_cash:
log.info("[Status] 禁止开仓:可用资金不足,可用=%.2f,总资产=%.2f", assets.available, assets.total)
log.info(
"[Status] 禁止开仓:可用资金不足,可用=%.2f,总资产=%.2f",
assets.available,
assets.total,
)
# 3. 获取大盘状态,只有大盘信号允许时才执行开仓。
market_ok = market_allow_open()
@@ -169,20 +166,37 @@ def RunOnce(run: Runtime, signals:list[SignalItem]) -> None:
log.exception("[行情] 获取行情失败,代码数量=%d", len(all_codes))
return
log.info("[RunOnce] 本轮就绪,持仓=%d,候选=%d,大盘允许=%s,资金允许=%s", len(positions), len(allow_open), market_ok, allow_open_by_cash)
log.info(
"[RunOnce] 本轮就绪,持仓=%d,候选=%d,大盘允许=%s,资金允许=%s",
len(positions),
len(allow_open),
market_ok,
allow_open_by_cash,
)
# 启动线程,开始计算
# 7. 持仓计算。
futures.append(("持仓计算",run.executor.submit(manage_positions,run,ticks,positions,market_ok,assets.available)))
futures.append(
(
"持仓计算",
run.executor.submit(
manage_positions, run, ticks, positions, market_ok, assets.available
),
)
)
# 8. 开仓计算:必须同时存在有效信号且大盘允许开仓。
if allow_open and market_ok and allow_open_by_cash:
futures.append(("开仓计算", run.executor.submit(open_signal, run, ticks, allow_open)))
futures.append(
("开仓计算", run.executor.submit(open_signal, run, ticks, allow_open))
)
# 9. 开始执行
for name, future in futures:
_wait_worker(name, future)
log.info("[RunOnce] 本轮完成,耗时=%d毫秒", int((time.monotonic() - started_at) * 1000))
log.info(
"[RunOnce] 本轮完成,耗时=%d毫秒", int((time.monotonic() - started_at) * 1000)
)
def _wait_worker(name: str, future: Future) -> None:

View File

@@ -6,16 +6,18 @@ from datetime import datetime
from libs import calc_buy_volume
from sdk import OP_BUY
from .runtime import Runtime
from .order import PlaceOrderRequest
from libs.runtime import Runtime
from libs.order import PlaceOrderRequest
import logging as log
def open_signal(run:Runtime, ticks, open_signals) -> None:
def open_signal(run: Runtime, ticks, open_signals) -> None:
"""逐个验证开仓信号并提交买入委托。"""
for item in open_signals:
if item.code in run.account_cfg.excluded_codes:
log.info("[Open] %s 信号=%s,跳过:已配置为排除股票", item.code, item.signal_key)
log.info(
"[Open] %s 信号=%s,跳过:已配置为排除股票", item.code, item.signal_key
)
continue
# 1. 验证信号配置允许开仓的时间区间。
signal_config = run.global_cfg.signals.get(item.signal_key)
@@ -36,8 +38,10 @@ def open_signal(run:Runtime, ticks, open_signals) -> None:
continue
# 2. 检查该证券是否已有买入委托锁,防止重复下单。
if run.orders.busy(item.code,"BUY"):
log.info("[Open] %s 信号=%s,跳过:买入委托处理中", item.code, item.signal_key)
if run.orders.busy(item.code, "BUY"):
log.info(
"[Open] %s 信号=%s,跳过:买入委托处理中", item.code, item.signal_key
)
continue
# 3. 验证行情和最新价格是否有效。
@@ -54,14 +58,34 @@ def open_signal(run:Runtime, ticks, open_signals) -> None:
continue
# 当前价高于昨收价可开仓
if signal_config.gt_last_price_is_open and item.last_close>0 and price>item.last_close:
if (
signal_config.gt_last_price_is_open
and item.last_close > 0
and price > item.last_close
):
try:
do_open(run, item.code, volume, item.signal_key, price)
log.info("[Open] %s 信号=%s,买入=%d股,原因=现价高于昨收", item.code, item.signal_key, volume)
log.info(
"[Open] %s 信号=%s,买入=%d股,原因=现价高于昨收",
item.code,
item.signal_key,
volume,
)
except RuntimeError as exc:
log.info("[Open] %s 信号=%s,买入=%d股失败:%s", item.code, item.signal_key, volume, exc)
log.info(
"[Open] %s 信号=%s,买入=%d股失败:%s",
item.code,
item.signal_key,
volume,
exc,
)
except Exception:
log.exception("[Open] %s 信号=%s,买入=%d股异常", item.code, item.signal_key, volume)
log.exception(
"[Open] %s 信号=%s,买入=%d股异常",
item.code,
item.signal_key,
volume,
)
continue
# 4. 等待价格从观察低点反弹,防止直接接下跌中的“飞刀”。
@@ -70,14 +94,29 @@ def open_signal(run:Runtime, ticks, open_signals) -> None:
try:
do_open(run, item.code, volume, item.signal_key, price)
log.info("[Open] %s 信号=%s,买入=%d股,原因=反弹已确认", item.code, item.signal_key, volume)
log.info(
"[Open] %s 信号=%s,买入=%d股,原因=反弹已确认",
item.code,
item.signal_key,
volume,
)
except RuntimeError as exc:
log.warning("[Open] %s 信号=%s,买入=%d股失败:%s", item.code, item.signal_key, volume, exc)
log.warning(
"[Open] %s 信号=%s,买入=%d股失败:%s",
item.code,
item.signal_key,
volume,
exc,
)
except Exception:
log.exception("[Open] %s 信号=%s,买入=%d股异常", item.code, item.signal_key, volume)
log.exception(
"[Open] %s 信号=%s,买入=%d股异常", item.code, item.signal_key, volume
)
def do_open(run: Runtime, code: str, volume: int, signal_key: str, price: float) -> None:
def do_open(
run: Runtime, code: str, volume: int, signal_key: str, price: float
) -> None:
"""生成本地订单号并按最新价提交开仓委托。"""
order_id = run.orders.new_order_id("BUY")
request = PlaceOrderRequest(
@@ -89,9 +128,7 @@ def do_open(run: Runtime, code: str, volume: int, signal_key: str, price: float)
kind="base",
)
#run.state.new_order(PendingOrder(order_id, code, "base", volume))
if not run.orders.place(run.client,request):
if not run.orders.place(run.client, request):
raise RuntimeError("订单提交失败")
run.open_watch.forget(code)

View File

@@ -1,122 +0,0 @@
"""趋势策略委托簿,对应 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:
"""生成 ``trend-xxxxxxxx`` 格式的本地订单号。"""
return f"trend-{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.status in CANCELABLE_STATUSES
and current - item.created_at > self.cancel_timeout_sec
):
client.cancel_by_id(item.id)
canceled += 1
logging.info("[Order] 超时撤单,代码=%s,方向=%s,柜台订单=%s", item.code, item.side, item.id)
continue
# 缓存本次有效订单
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,8 +8,8 @@ from libs.calc import calc_buy_volume, calculate_min_profit_rate
from libs.grid_take_profit import GridState
from sdk import OP_BUY, OP_SELL, PositionItem, Tick
from .order import PlaceOrderRequest
from .runtime import Runtime
from libs.order import PlaceOrderRequest
from libs.runtime import Runtime
import logging as log
LOSS_TIERS = [-50.0]
@@ -34,11 +34,15 @@ def manage_positions(
# 遍历处理每个持仓
for position in positions:
try:
available = max(0,0,available)
available = max(0, 0, available)
code = position.stock_code
tick = ticks.get(code)
if code in runtime.account_cfg.excluded_codes:
log.info("[Position - ] 代码=%s,名称=%s,止盈=跳过,补仓=跳过,原因=已配置为排除股票", code, position.stock_name)
log.info(
"[Position - ] 代码=%s,名称=%s,止盈=跳过,补仓=跳过,原因=已配置为排除股票",
code,
position.stock_name,
)
continue
if (
not code
@@ -47,7 +51,11 @@ def manage_positions(
or tick is None
or tick.last_price <= 0
):
log.warning("[Position - ] 代码=%s,名称=%s,止盈=跳过,补仓=跳过,原因=持仓或行情数据无效", code or "未知", position.stock_name)
log.warning(
"[Position - ] 代码=%s,名称=%s,止盈=跳过,补仓=跳过,原因=持仓或行情数据无效",
code or "未知",
position.stock_name,
)
continue
pnl_rate = round(
@@ -77,18 +85,29 @@ def manage_positions(
elif runtime.account_cfg.enable_loss_add_position:
loss_add_action = "大盘信号不允许"
if pnl_rate>=0:
if pnl_rate >= 0:
log.info(
"[Position ↑ ] 代码=%s,名称=%s,盈亏=%.2f%%,止盈=%s,补仓=%s",
code, position.stock_name, pnl_rate, profit_action, loss_add_action,
code,
position.stock_name,
pnl_rate,
profit_action,
loss_add_action,
)
else:
log.info(
"[Position ↓ ] 代码=%s,名称=%s,盈亏=%.2f%%,止盈=%s,补仓=%s",
code, position.stock_name, pnl_rate, profit_action, loss_add_action,
code,
position.stock_name,
pnl_rate,
profit_action,
loss_add_action,
)
except Exception:
log.exception("[Position] 持仓处理异常,代码=%s,继续处理后续持仓", position.stock_code)
log.exception(
"[Position] 持仓处理异常,代码=%s,继续处理后续持仓",
position.stock_code,
)
def handle_profit(
@@ -132,8 +151,7 @@ def handle_profit(
)
if not runtime.orders.place(runtime.client, request):
return TradeDecision(False, "止盈委托失败")
return TradeDecision(True, f"卖出 {volume} 股,订单={order_id}")
@@ -142,10 +160,12 @@ def handle_loss(
position: PositionItem,
tick: Tick,
pnl_rate: float,
available: float
available: float,
) -> TradeDecision:
"""按亏损档位、反弹确认和本轮剩余预算提交补仓。"""
add_num = get_add_num(hands=int(position.volume/100),market_value=position.market_value)
add_num = get_add_num(
hands=int(position.volume / 100), market_value=position.market_value
)
if add_num >= len(LOSS_TIERS) or add_num < 0:
return TradeDecision(False, f"补仓次数无效:{add_num}")
if pnl_rate > LOSS_TIERS[add_num]:
@@ -171,10 +191,10 @@ def handle_loss(
strategy_name=runtime.account_cfg.strategy,
kind="add",
)
if not runtime.orders.place(runtime.client, request):
return TradeDecision(False, "补仓订单委托失败")
runtime.add_watch.forget(position.stock_code)
return TradeDecision(True, f"买入 {volume} 股,订单={order_id}", amount)
@@ -182,9 +202,10 @@ def handle_loss(
def _position_key(runtime: Runtime, code: str) -> str:
return f"{runtime.account_cfg.account_id}:{code}"
def get_add_num(hands:int,market_value:float) -> int:
if market_value>10000:
def get_add_num(hands: int, market_value: float) -> int:
if market_value > 10000:
return -1
if hands < 2:
return 0
return -1
return -1

View File

@@ -1,44 +0,0 @@
"""趋势策略单次运行所需的上下文对象。"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from config import AccountConfig, GlobalConfig
from sdk import Client
from libs.grid_take_profit import GridTrailingTracker
from .order import OrderBook
from .watch import DipWatch
@dataclass(slots=True)
class Runtime:
"""集中保存趋势策略运行期间共享的依赖和状态。
将这些对象集中到一个 dataclass 后,开仓、持仓管理和单轮调度函数
只需接收一个 ``Runtime``,无需重复传递大量参数。
Attributes:
client: QMT HTTP 客户端,用于查询账户、行情和提交委托。
global_cfg: 公共配置,包含 QMT、外部 API 和信号配置。
account_cfg: 当前主机的账户及交易策略配置。
state: 策略持仓状态的本地持久化存储。
orders: 当前活动委托和证券方向锁。
open_watch: 新开仓使用的价格反弹观察器。
add_watch: 亏损补仓使用的价格反弹观察器。
profit_tracker: 跨轮保存的账户持仓最高盈利网格跟踪器。
"""
# 外部服务与账户配置。
client: Client
global_cfg: GlobalConfig
account_cfg: AccountConfig
# 策略运行过程中共享的状态组件。
orders: OrderBook
open_watch: DipWatch
add_watch: DipWatch
profit_tracker: GridTrailingTracker
executor: ThreadPoolExecutor

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