feat
This commit is contained in:
5
py-client/strategy/trend/__init__.py
Normal file
5
py-client/strategy/trend/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .order import OrderBook, PlaceOrderRequest
|
||||
from .state import State, StateItem
|
||||
from .watch import DipWatch
|
||||
from .open import check_timezone, open_signal
|
||||
from .positions import manage_positions
|
||||
BIN
py-client/strategy/trend/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/boot.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/boot.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/open.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/open.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/order.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/order.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/positions.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/positions.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/run.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/run.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/runtime.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/runtime.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/state.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/state.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/strategy.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/strategy.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/watch.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/watch.cpython-311.pyc
Normal file
Binary file not shown.
176
py-client/strategy/trend/boot.py
Normal file
176
py-client/strategy/trend/boot.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""趋势策略启动器。
|
||||
|
||||
该模块负责组合 SDK、配置、状态存储和趋势策略组件,供 main.py 调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import config
|
||||
from libs import init_signals, market_allow_open, trading_time
|
||||
from sdk import Client
|
||||
from .state import State
|
||||
from .order import OrderBook
|
||||
from .watch import DipWatch
|
||||
from .runtime import Runtime
|
||||
from .open import open_signal
|
||||
from .positions import manage_positions
|
||||
|
||||
|
||||
def Overview(assets, positions, account_cfg=None) -> None:
|
||||
"""打印策略启动时的账户、资金和持仓概览。
|
||||
|
||||
该函数对应 Go 客户端 ``logic.Overview``。为便于单独测试,可以
|
||||
显式传入账户配置;未传入时使用 ``config.account_config``。
|
||||
"""
|
||||
account_cfg = account_cfg or config.account_config
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print(f"【时间】{datetime.now():%Y-%m-%d %H:%M:%S}")
|
||||
if account_cfg is not None:
|
||||
print(
|
||||
"【配置】"
|
||||
f"account_id: {account_cfg.account_id} "
|
||||
f"host_key: {account_cfg.host_key} "
|
||||
f"buy_value: {account_cfg.buy_value:.0f}"
|
||||
)
|
||||
|
||||
if assets is not None:
|
||||
print(
|
||||
f"【资金】总资产:{assets.total:.2f}元,"
|
||||
f"可用资金:{assets.available:.2f}元"
|
||||
)
|
||||
else:
|
||||
print("【资金】查询失败")
|
||||
|
||||
print(f"【持仓】{len(positions)}只")
|
||||
print("=" * 80)
|
||||
for position in positions:
|
||||
if position.volume <= 0:
|
||||
continue
|
||||
print(
|
||||
f"【持仓】{position.stock_code} {position.stock_name} "
|
||||
f"持仓={position.volume} 可用={position.can_use_volume} "
|
||||
f"冻结={position.frozen_volume} 在途={position.on_road_volume} "
|
||||
f"昨仓={position.yesterday_volume} 成本={position.open_price:.3f} "
|
||||
f"现价={position.last_price:.3f} 市值={position.market_value:.2f} "
|
||||
f"浮盈={position.float_profit:.2f} "
|
||||
f"盈亏比例={position.profit_rate * 100:.2f}%"
|
||||
)
|
||||
|
||||
|
||||
|
||||
def StartTrend() -> None:
|
||||
"""初始化趋势策略,并以 30 秒间隔持续执行。"""
|
||||
client = Client(
|
||||
config.global_config.qmt_base_url,
|
||||
config.global_config.qmt_token,
|
||||
config.HTTP_TIMEOUT,
|
||||
)
|
||||
assets = client.assets()
|
||||
_, positions = client.positions()
|
||||
|
||||
storeState = State.for_strategy(
|
||||
config.global_config.qmt_data_dir,
|
||||
config.account_config.strategy,
|
||||
config.account_config.account_id,
|
||||
)
|
||||
storeState.sync_positions(positions)
|
||||
|
||||
# 获取本策略的信号开仓数据
|
||||
signals = init_signals(config.global_config,["morning","tail","arbitrage"])
|
||||
run = Runtime(
|
||||
client=client,
|
||||
global_cfg=config.global_config,
|
||||
account_cfg=config.account_config,
|
||||
state=storeState,
|
||||
orders=OrderBook(),
|
||||
open_watch=DipWatch(),
|
||||
add_watch=DipWatch(),
|
||||
)
|
||||
|
||||
logging.info(
|
||||
"趋势策略启动:总资产=%.2f,持仓=%d,信号=%d",
|
||||
assets.total,
|
||||
len(positions),
|
||||
len(signals),
|
||||
)
|
||||
Overview(assets, positions, config.account_config)
|
||||
|
||||
while True:
|
||||
started_at = time.monotonic()
|
||||
try:
|
||||
RunOnce(run, signals)
|
||||
except Exception:
|
||||
# 单轮错误只记录日志,下一轮仍继续运行。
|
||||
logging.exception("趋势策略本轮执行失败")
|
||||
|
||||
elapsed = time.monotonic() - started_at
|
||||
time.sleep(max(0.0, 30.0 - elapsed))
|
||||
|
||||
|
||||
def RunOnce(run: Runtime, signals) -> None:
|
||||
"""按固定步骤执行一轮趋势策略, ``RunOnce``。"""
|
||||
if not trading_time(datetime.now()):
|
||||
return
|
||||
|
||||
# 1. 取消超过有效期仍未完成的委托订单。
|
||||
try:
|
||||
run.orders.cancel_expired(run.client)
|
||||
except Exception:
|
||||
logging.exception("取消过期订单失败")
|
||||
|
||||
# 2. 验证可用资金;低于资金安全线时禁止开新仓。
|
||||
try:
|
||||
assets = run.client.assets()
|
||||
except Exception:
|
||||
logging.exception("获取资产失败")
|
||||
return
|
||||
if assets.available < assets.total * run.account_cfg.min_cash_ratio:
|
||||
logging.info("资金总闸:可用金额太少,禁止开新仓")
|
||||
return
|
||||
|
||||
# 3. 获取大盘状态,只有大盘信号允许时才执行开仓。
|
||||
market_ok = market_allow_open(run.global_cfg.api_host)
|
||||
|
||||
# 4. 获取当前持仓及持仓证券代码。
|
||||
try:
|
||||
position_codes, positions = run.client.positions()
|
||||
except Exception:
|
||||
logging.exception("获取持仓失败")
|
||||
return
|
||||
|
||||
# 5. 验证有效开仓信号:排除已有持仓,并按 signal_allow 过滤。
|
||||
position_code_set = set(position_codes)
|
||||
allow_open = [
|
||||
signal for signal in signals if signal.code not in position_code_set
|
||||
]
|
||||
|
||||
# 6. 获取持仓和待开仓证券的实时行情 tick。
|
||||
all_codes = list(position_codes)
|
||||
all_codes.extend(
|
||||
signal.code for signal in allow_open if signal.code not in position_code_set
|
||||
)
|
||||
try:
|
||||
ticks = run.client.full_tick(list(dict.fromkeys(all_codes)))
|
||||
except Exception:
|
||||
logging.exception("获取行情失败")
|
||||
return
|
||||
|
||||
# 7. 执行开仓:必须同时存在有效信号且大盘允许开仓。
|
||||
if allow_open and market_ok:
|
||||
open_signal(run, ticks, allow_open)
|
||||
|
||||
# 8. 持仓计算。当前 Go 版本的 managePositions 为空,保留扩展入口。
|
||||
manage_positions(run, ticks, positions, market_ok,assets.available)
|
||||
|
||||
|
||||
def SignalFilter(signals, allowed_names):
|
||||
"""只保留账户配置明确允许使用的信号。"""
|
||||
if not allowed_names:
|
||||
return []
|
||||
allowed = set(allowed_names)
|
||||
return [signal for signal in signals if signal.signal_key in allowed]
|
||||
105
py-client/strategy/trend/open.py
Normal file
105
py-client/strategy/trend/open.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""趋势策略开仓逻辑,对应 Go 版本的 ``logic/open.go``。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from libs import calc_buy_volume
|
||||
from sdk import OP_BUY
|
||||
|
||||
from .order import PlaceOrderRequest
|
||||
from .state import STATUS_ING, StateItem
|
||||
|
||||
|
||||
def open_signal(run, ticks, open_signals) -> None:
|
||||
"""逐个验证开仓信号并提交买入委托。"""
|
||||
for item in open_signals:
|
||||
# 1. 验证信号配置允许开仓的时间区间。
|
||||
signal_config = run.global_cfg.signals.get(item.signal_key)
|
||||
if signal_config is None or not check_timezone(signal_config.timezone):
|
||||
continue
|
||||
|
||||
# 2. 检查该证券是否已有买入委托锁,防止重复下单。
|
||||
if run.orders.busy(item.code,"BUY"):
|
||||
continue
|
||||
|
||||
# 3. 验证行情和最新价格是否有效。
|
||||
tick = ticks.get(item.code)
|
||||
price = tick.last_price if tick is not None else 0
|
||||
if price <= 0:
|
||||
continue
|
||||
|
||||
# 4. 等待价格从观察低点反弹,防止直接接下跌中的“飞刀”。
|
||||
if not run.open_watch.triggered("开仓", item.code, price):
|
||||
continue
|
||||
|
||||
# 5. 根据单笔买入金额计算整手开仓数量。
|
||||
volume = calc_buy_volume(price, run.account_cfg.buy_value)
|
||||
if volume <= 0:
|
||||
continue
|
||||
|
||||
# 6. 生成本地订单号并按最新价提交开仓委托。
|
||||
order_id = run.orders.new_order_id("base")
|
||||
request = PlaceOrderRequest(run.client, OP_BUY, item.code, volume, order_id)
|
||||
if not run.orders.place(request):
|
||||
continue
|
||||
|
||||
# 7. 保存底仓订单、数量、成本和处理中状态。
|
||||
run.state.set(
|
||||
StateItem(
|
||||
code=item.code,
|
||||
base_order_id=order_id,
|
||||
base_qty=volume,
|
||||
base_cost=price,
|
||||
base_status=STATUS_ING,
|
||||
)
|
||||
)
|
||||
try:
|
||||
run.state.save()
|
||||
except OSError:
|
||||
logging.exception("[状态] %s 开仓状态保存失败", item.code)
|
||||
run.open_watch.forget(item.code)
|
||||
logging.info("[ZT][开仓] %s 买入 %d 股", item.code, volume)
|
||||
|
||||
|
||||
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
|
||||
start = _parse_minutes(bounds[0])
|
||||
end = _parse_minutes(bounds[1])
|
||||
if start is None or end is None:
|
||||
continue
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
148
py-client/strategy/trend/order.py
Normal file
148
py-client/strategy/trend/order.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""趋势策略委托簿,对应 Go 客户端的 ``logic/order.go``。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
# QMT 开平方向字段到本地买卖方向的映射。
|
||||
OFFSET_FLAG = {"23": "BUY", "24": "SELL", "48": "BUY", "49": "SELL"}
|
||||
|
||||
# 表示委托仍在处理、可能继续成交的 QMT 状态。
|
||||
BUSY_STATUSES = {"48", "49", "50", "51", "52", "55"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlaceOrderRequest:
|
||||
"""``OrderBook.place`` 提交委托所需的全部参数。"""
|
||||
|
||||
client: Any
|
||||
op: int
|
||||
code: str
|
||||
volume: int
|
||||
order_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderItem:
|
||||
"""从 QMT 委托明细转换得到的本地订单记录。"""
|
||||
|
||||
id: str
|
||||
code: str
|
||||
side: str
|
||||
remark: str
|
||||
status: str
|
||||
created_at: datetime | None
|
||||
volume: int
|
||||
|
||||
|
||||
class OrderBook:
|
||||
"""线程安全的活动委托缓存。"""
|
||||
|
||||
def __init__(self, timeout_seconds: float = 300) -> None:
|
||||
self.timeout = timedelta(seconds=timeout_seconds)
|
||||
self.data: dict[str, OrderItem] = {}
|
||||
self.index: list[str] = []
|
||||
self.lock = Lock()
|
||||
|
||||
@staticmethod
|
||||
def new_order_id(leg: str) -> str:
|
||||
"""生成不超过 24 个字符的策略订单号。"""
|
||||
return f"zt-{leg}-{secrets.token_hex(6)}"[:24]
|
||||
|
||||
def is_lock(self, side: str, code: str) -> bool:
|
||||
"""判断证券在指定买卖方向上是否已经被委托锁定。"""
|
||||
with self.lock:
|
||||
return f"{side}-{code}" in self.index
|
||||
|
||||
def busy(self, code: str, side: str) -> bool:
|
||||
"""判断证券是否存在仍在处理中的同方向委托。"""
|
||||
with self.lock:
|
||||
order = self.data.get(f"{side}-{code}")
|
||||
return bool(order and order.status in BUSY_STATUSES)
|
||||
|
||||
def refresh(self, client: Any) -> None:
|
||||
"""从 QMT 刷新当前委托明细和方向索引。"""
|
||||
parsed_orders = [
|
||||
parse_order(row) for row in client.trade_detail_data("order")
|
||||
]
|
||||
with self.lock:
|
||||
self.data = {key: item for key, item in parsed_orders}
|
||||
self.index = [key for key, _ in parsed_orders]
|
||||
|
||||
def cancel_expired(self, client: Any, now: datetime | None = None) -> None:
|
||||
"""尝试撤销超过有效期且具有委托编号的订单。"""
|
||||
self.refresh(client)
|
||||
current = now or datetime.now()
|
||||
|
||||
# 使用快照遍历,避免网络调用期间长期持有互斥锁。
|
||||
for order in list(self.data.values()):
|
||||
if (
|
||||
order.created_at is not None
|
||||
and current - order.created_at > self.timeout
|
||||
and order.id
|
||||
):
|
||||
client.can_cancel_order(order.id)
|
||||
|
||||
def place(self, request: PlaceOrderRequest) -> bool:
|
||||
"""按最新价提交委托,并立即写入本地方向锁。"""
|
||||
request.client.passorder_latest_tagged(
|
||||
request.op,
|
||||
request.code,
|
||||
request.volume,
|
||||
request.order_id,
|
||||
)
|
||||
|
||||
side = OFFSET_FLAG.get(str(request.op), "")
|
||||
with self.lock:
|
||||
self.index.append(f"{side}-{request.code}")
|
||||
return True
|
||||
|
||||
|
||||
def parse_order(row: dict[str, Any]) -> tuple[str, OrderItem]:
|
||||
"""把 QMT 原始委托字段转换为本地订单及其索引键。"""
|
||||
volume = _as_int(row.get("m_nVolumeTotal")) + _as_int(
|
||||
row.get("m_nVolumeTraded")
|
||||
)
|
||||
|
||||
timestamp = _as_int(row.get("m_nOrderTime"))
|
||||
if timestamp > 100_000_000_000:
|
||||
# QMT 某些版本返回毫秒时间戳。
|
||||
timestamp /= 1000
|
||||
created_at = (
|
||||
datetime.fromtimestamp(timestamp)
|
||||
if timestamp
|
||||
else _parse_insert_datetime(row)
|
||||
)
|
||||
|
||||
item = OrderItem(
|
||||
id=str(row.get("m_strOrderSysID") or ""),
|
||||
code=str(row.get("m_strInstrumentID") or ""),
|
||||
side=OFFSET_FLAG.get(str(row.get("m_nOffsetFlag")), ""),
|
||||
remark=str(row.get("m_strRemark") or ""),
|
||||
status=str(row.get("m_nOrderStatus") or ""),
|
||||
created_at=created_at,
|
||||
volume=volume,
|
||||
)
|
||||
return f"{item.side}-{item.code}", item
|
||||
|
||||
|
||||
def _as_int(value: Any) -> int:
|
||||
"""安全转换整数,无效值按 0 处理。"""
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _parse_insert_datetime(row: dict[str, Any]) -> datetime | None:
|
||||
"""使用委托日期和时间字段构造本地时间。"""
|
||||
date = str(row.get("m_strInsertDate") or "")
|
||||
clock = str(row.get("m_strInsertTime") or "").replace(":", "").zfill(6)
|
||||
try:
|
||||
return datetime.strptime(date + clock, "%Y%m%d%H%M%S")
|
||||
except ValueError:
|
||||
return None
|
||||
167
py-client/strategy/trend/positions.py
Normal file
167
py-client/strategy/trend/positions.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""趋势策略持仓管理逻辑,对应 Go 版本的 ``logic/positions.go``。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from math import floor
|
||||
|
||||
from libs.calc import calc_buy_volume,calculate_min_profit_rate
|
||||
from libs.grid_take_profit import GridState, GridTrailingTracker
|
||||
from sdk import OP_BUY, OP_SELL
|
||||
import config
|
||||
from .order import PlaceOrderRequest
|
||||
from .state import STATUS_ING, STATUS_NONE, STATUS_OK
|
||||
from .runtime import Runtime
|
||||
|
||||
LEG_BASE = "base"
|
||||
LEG_ADDED = "add"
|
||||
|
||||
# 止盈网格跟踪器延迟初始化,避免导入模块时账户配置尚未加载。
|
||||
profit_tracker = None
|
||||
|
||||
# 分级补仓档位(百分比)
|
||||
LOSS_TIERS = [-30, -50]
|
||||
# 补仓反弹确认阈值(百分比)
|
||||
LOSS_REBOUND_THRESHOLD = 0.5
|
||||
|
||||
def manage_positions(run:Runtime, ticks, positions, market_ok: bool,available:float) -> None:
|
||||
"""执行持仓计算。"""
|
||||
logging.info(f"持仓:{len(positions)} 支股票,开始处理")
|
||||
global profit_tracker
|
||||
profit_tracker = GridTrailingTracker(step=run.account_cfg.grid_step_pct)
|
||||
for idx,pos in positions:
|
||||
code = pos['stock_code']
|
||||
avg_price = pos.get('avg_price', 0)
|
||||
volume = pos.get('volume', 0)
|
||||
can_use_volume = pos.get('can_use_volume', 0)
|
||||
current_price = ticks.get(code, {}).get('lastPrice', 0)
|
||||
strategy_name = pos.get('strategy_name', '')
|
||||
market_value = pos.get('market_value',0)
|
||||
profit = pos.get('profit_rate', 0)
|
||||
|
||||
# 排除指定股票
|
||||
if code in config.account_config.excluded_codes:
|
||||
continue
|
||||
|
||||
# 过滤无效仓位
|
||||
if avg_price == 0 or can_use_volume == 0 or current_price == 0 or volume == 0:
|
||||
continue
|
||||
|
||||
# 计算盈亏率(百分比)
|
||||
pnl_ratio = (current_price - avg_price) / avg_price * 100 if avg_price != 0 else 0
|
||||
pnl_ratio = round(pnl_ratio, 2)
|
||||
|
||||
# 计算最小利润率:1倍
|
||||
min_profit_rate_val = calculate_min_profit_rate(avg_price, 1)
|
||||
|
||||
# 盈利处理
|
||||
is_closed, message = handle_profit(run,code,avg_price, pnl_ratio, min_profit_rate_val, can_use_volume, strategy_name)
|
||||
if is_closed:
|
||||
logging.info("profit", code, f"止盈执行 | {message}")
|
||||
if message != "":
|
||||
logging.info("profit", code, message)
|
||||
|
||||
# 补仓处理
|
||||
if config.account_config.enable_loss_add_position and market_ok:
|
||||
is_replenished, message = handle_loss(run,code,current_price,pnl_ratio,market_value,market_ok,available)
|
||||
if is_replenished:
|
||||
logging.info("loss", code, f"补仓执行 | {message}")
|
||||
if message != "":
|
||||
logging.info("loss", code, message)
|
||||
|
||||
# 盈利处理
|
||||
def handle_profit(run:Runtime, code: str, pnl_rate: float,
|
||||
min_profit_rate: float, vol: int) -> tuple[bool, str]:
|
||||
"""
|
||||
盈利处理 - 基于网格的止盈策略
|
||||
|
||||
Args:
|
||||
code: 股票代码
|
||||
open_price: 开仓价格
|
||||
pnl_rate: 当前盈亏率(百分比)
|
||||
min_profit_rate: 最小利润率阈值
|
||||
vol: 可用股数
|
||||
strategy_name: str
|
||||
|
||||
Returns:
|
||||
tuple[bool, str]: (是否执行平仓, 操作说明)
|
||||
"""
|
||||
# 预检查:未达到最小利润率
|
||||
if pnl_rate < min_profit_rate:
|
||||
return False, ""
|
||||
|
||||
position_key = f"{run.account_cfg.account_id}:{code}"
|
||||
observation = profit_tracker.observe(position_key, pnl_rate)
|
||||
|
||||
if observation.state == GridState.ARMED:
|
||||
msg = f"首次达到{pnl_rate}%,设置峰值网格{observation.current_grid}"
|
||||
return False, msg
|
||||
|
||||
if observation.state == GridState.RAISED:
|
||||
return False, f"上涨至{pnl_rate}%,更新峰值网格{observation.current_grid}"
|
||||
|
||||
# 执行平仓
|
||||
if observation.state == GridState.RETREAT:
|
||||
order_id = run.orders.new_order_id(LEG_BASE)
|
||||
request = PlaceOrderRequest(run.client, OP_SELL, code, vol, order_id)
|
||||
result = run.orders.place(request)
|
||||
if result :
|
||||
success_msg = f"✓ 委托成功 | {vol}股 订单号:{result} 等待成交"
|
||||
logging.info("profit", code, success_msg)
|
||||
return True, success_msg
|
||||
else:
|
||||
fail_msg = f"止盈委托失败: {code}"
|
||||
logging.error("profit", code, "✗ 止盈委托失败")
|
||||
return False, fail_msg
|
||||
|
||||
|
||||
def handle_loss(run:Runtime, code: str, current_price,pnl_rate,market_value: float,market_ok: bool, available: float) -> tuple[bool, str]:
|
||||
"""满足条件时提交补仓委托,并返回扣减后的剩余预算。"""
|
||||
state = run.state.get(code)
|
||||
added_num = state.get('added_num',0)
|
||||
# 预检查:未达到最低补仓阈值
|
||||
if pnl_rate > LOSS_TIERS[added_num]:
|
||||
return False, ""
|
||||
|
||||
# 强制条件
|
||||
if current_price>200 or market_value>=60000:
|
||||
return False, f"成本价{current_price}>200,仓位价值{market_value}>=60000, 不补仓"
|
||||
|
||||
# 1. 大盘必须允许开仓,且价格已从观察低点达到反弹阈值。
|
||||
if not market_ok or not run.add_watch.triggered("补仓", code, current_price):
|
||||
return False
|
||||
|
||||
# 2. 计算补仓数量和预计占用金额。
|
||||
volume = calc_buy_volume(current_price, run.account_cfg.buy_value)
|
||||
amount = current_price * volume
|
||||
|
||||
# 3. 检查预算。
|
||||
if amount > available:
|
||||
return False, f"f{code} f{amount} 仓位资金不够补仓"
|
||||
|
||||
# 是否已有未完成的买入委托
|
||||
if run.orders.busy(run, code, "BUY"):
|
||||
return False, f"{code}订单锁定中"
|
||||
|
||||
# 4. 生成补仓订单号并提交买入委托。
|
||||
order_id = run.orders.new_order_id(LEG_ADDED)
|
||||
request = PlaceOrderRequest(run.client, OP_BUY, code, volume, order_id)
|
||||
result = run.orders.place(request)
|
||||
if result :
|
||||
state.added_num = +1
|
||||
state.added_status = run.state.STATUS_ING
|
||||
state.added_order_id = order_id
|
||||
run.state.set(state)
|
||||
run.state.save()
|
||||
run.add_watch.forget(code)
|
||||
return True,f"补仓委托成功: {code} {volume}手, 等待成交确认"
|
||||
else:
|
||||
return False,f"补仓失败: {code}"
|
||||
|
||||
|
||||
def forget(run, code: str) -> None:
|
||||
"""持仓退出后清理开仓、补仓观察记录和止盈峰值。"""
|
||||
|
||||
|
||||
|
||||
run.peak_grids.pop(f"{code}|{LEG_ADDED}", None)
|
||||
43
py-client/strategy/trend/runtime.py
Normal file
43
py-client/strategy/trend/runtime.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""趋势策略单次运行所需的上下文对象。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from config import AccountConfig, GlobalConfig
|
||||
from sdk import Client
|
||||
|
||||
from .order import OrderBook
|
||||
from .state import State
|
||||
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: 亏损补仓使用的价格反弹观察器。
|
||||
peak_grids: ``证券代码|仓位类型`` 到最高盈利网格的映射。
|
||||
"""
|
||||
|
||||
# 外部服务与账户配置。
|
||||
client: Client
|
||||
global_cfg: GlobalConfig
|
||||
account_cfg: AccountConfig
|
||||
|
||||
# 策略运行过程中共享的状态组件。
|
||||
state: State
|
||||
orders: OrderBook
|
||||
open_watch: DipWatch
|
||||
add_watch: DipWatch
|
||||
|
||||
146
py-client/strategy/trend/state.py
Normal file
146
py-client/strategy/trend/state.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""趋势策略持仓状态的内存管理与 JSON 持久化。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Iterable
|
||||
|
||||
from sdk import Position
|
||||
|
||||
|
||||
# 委托状态:无操作、处理中、已完成。
|
||||
STATUS_NONE = ""
|
||||
STATUS_ING = "ING"
|
||||
STATUS_OK = "OK"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StateItem:
|
||||
"""单只证券的底仓和补仓状态。"""
|
||||
|
||||
# 证券代码。
|
||||
code: str
|
||||
|
||||
# 底仓订单、数量、成本和处理状态。
|
||||
base_order_id: str = ""
|
||||
base_qty: int = 0
|
||||
base_cost: float = 0.0
|
||||
base_status: str = STATUS_NONE
|
||||
|
||||
# 补仓订单、补仓次数、数量、成本和处理状态。
|
||||
added_order_id: str = ""
|
||||
added_num: int = 0
|
||||
added_qty: int = 0
|
||||
added_cost: float = 0.0
|
||||
added_status: str = STATUS_NONE
|
||||
|
||||
|
||||
class State:
|
||||
"""线程安全的策略状态存储。
|
||||
|
||||
状态以内存字典提供快速访问,并通过临时文件替换的方式写入 JSON,
|
||||
防止程序在写入过程中退出而破坏原状态文件。
|
||||
"""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
self.lock = Lock()
|
||||
self.items = self._load()
|
||||
|
||||
@classmethod
|
||||
def for_strategy(
|
||||
cls,
|
||||
data_dir: str | Path,
|
||||
strategy: str,
|
||||
account_id: str,
|
||||
) -> "State":
|
||||
"""根据数据目录、策略名称和账户生成独立状态文件。"""
|
||||
state_path = Path(data_dir) / f"{strategy}_{account_id}_state.json"
|
||||
return cls(state_path)
|
||||
|
||||
@property
|
||||
def codes(self) -> list[str]:
|
||||
"""返回当前已经接管的全部证券代码快照。"""
|
||||
with self.lock:
|
||||
return list(self.items)
|
||||
|
||||
def get(self, code: str) -> StateItem:
|
||||
"""获取指定证券的状态;不存在时抛出 KeyError。"""
|
||||
with self.lock:
|
||||
return self.items[code]
|
||||
|
||||
def set(self, item: StateItem) -> None:
|
||||
"""新增或覆盖一只证券的状态。"""
|
||||
with self.lock:
|
||||
self.items[item.code] = item
|
||||
|
||||
def delete(self, code: str) -> None:
|
||||
"""删除证券状态;证券不存在时不报错。"""
|
||||
with self.lock:
|
||||
self.items.pop(code, None)
|
||||
|
||||
def sync_positions(self, positions: Iterable[Position]) -> None:
|
||||
"""把尚未接管的真实持仓初始化为已完成底仓。
|
||||
|
||||
无证券代码、无持仓数量或成本无效的记录会被忽略。同步结束后
|
||||
立即保存,确保首次接管的持仓在程序重启后仍可恢复。
|
||||
"""
|
||||
known_codes = set(self.codes)
|
||||
for position in positions:
|
||||
if (
|
||||
not position.stock_code
|
||||
or position.volume <= 0
|
||||
or position.open_price <= 0
|
||||
or position.stock_code in known_codes
|
||||
):
|
||||
continue
|
||||
|
||||
self.set(
|
||||
StateItem(
|
||||
code=position.stock_code,
|
||||
base_qty=position.volume,
|
||||
base_cost=position.open_price,
|
||||
base_status=STATUS_OK,
|
||||
)
|
||||
)
|
||||
known_codes.add(position.stock_code)
|
||||
|
||||
self.save()
|
||||
|
||||
def save(self) -> None:
|
||||
"""将内存状态格式化写入 JSON,并原子替换正式文件。"""
|
||||
with self.lock:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary_path = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
payload = {
|
||||
code: asdict(item)
|
||||
for code, item in self.items.items()
|
||||
}
|
||||
temporary_path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary_path.replace(self.path)
|
||||
|
||||
def _load(self) -> dict[str, StateItem]:
|
||||
"""读取已有状态文件;文件不存在时从空状态开始。"""
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"[状态] 读取或解析失败: {exc}") from exc
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("[状态] 状态文件根节点必须是 JSON 对象")
|
||||
|
||||
try:
|
||||
return {
|
||||
code: StateItem(**item)
|
||||
for code, item in raw.items()
|
||||
}
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"[状态] 状态字段无效: {exc}") from exc
|
||||
34
py-client/strategy/trend/watch.py
Normal file
34
py-client/strategy/trend/watch.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Lock
|
||||
import logging
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Entry:
|
||||
last_close: float
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class DipWatch:
|
||||
def __init__(self, expire_seconds: float = 300, rebound_threshold: float = 0.61):
|
||||
self.expire_seconds, self.rebound_threshold = expire_seconds, rebound_threshold
|
||||
self.data: dict[str, _Entry] = {}; self.lock = Lock()
|
||||
|
||||
def triggered(self, tag: str, code: str, price: float, now: datetime | None = None) -> bool:
|
||||
if price <= 0: return False
|
||||
now = now or datetime.now()
|
||||
with self.lock:
|
||||
watch = self.data.get(code)
|
||||
if watch is None or now >= watch.expires_at:
|
||||
self.data[code] = _Entry(price, now + timedelta(seconds=self.expire_seconds)); return False
|
||||
if price < watch.last_close:
|
||||
self.data[code] = _Entry(price, now + timedelta(seconds=self.expire_seconds)); return False
|
||||
rebound = (price - watch.last_close) / watch.last_close * 100
|
||||
if rebound <= 0 or rebound < self.rebound_threshold: return False
|
||||
del self.data[code]
|
||||
logging.info("[%s-触发] %s 反弹=%.2f%%", tag, code, rebound)
|
||||
return True
|
||||
|
||||
def forget(self, code):
|
||||
with self.lock: self.data.pop(code, None)
|
||||
Reference in New Issue
Block a user