This commit is contained in:
2026-08-28 18:52:27 +08:00
parent e0ecdfba52
commit d09f271569
89 changed files with 1716 additions and 2515 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,146 @@
from __future__ import annotations
import socket
from dataclasses import dataclass, field
from pathlib import Path
import yaml
@dataclass
class SignalConfig:
"""单个交易信号的数据源及开仓限制配置。"""
# 信号接口相对于 api_host 的路径。
url: str = ""
# 允许使用该信号的时间段;"*" 表示不限制时间。
timezone: str = "*"
# 当前价格高于信号昨收价时是否仍允许开仓。
gt_last_price_is_open: bool = False
@dataclass
class GlobalConfig:
"""所有主机共享的系统配置。"""
qmt_base_url: str = ""
qmt_token: str = ""
api_host: str = ""
qmt_data_dir: str = ""
# Windows 主机名到对应账户配置文件的映射。
hosts: dict[str, str] = field(default_factory=dict)
# 信号名称到信号配置的映射。
signals: dict[str, SignalConfig] = field(default_factory=dict)
@dataclass
class AccountConfig:
"""当前主机所使用的账户及交易策略参数。"""
account_id: str = ""
host_key: str = ""
buy_value: float = 0
min_cash_ratio: float = 0
loss_trigger_pct: float = 0
grid_step_pct: float = 1
min_profit_pct: float = 0
enable_loss_add_position: bool = False
signal_allow: list[str] = field(default_factory=list)
excluded_codes: list[str] = field(default_factory=list)
# 当前账户启用的策略名称,例如 trend。
strategy: str = ""
# load() 成功后保存已加载的配置,供策略模块直接读取。
global_config: GlobalConfig | None = None
account_config: AccountConfig | None = None
# QMT 和外部 HTTP 接口的默认请求超时时间,单位为秒。
HTTP_TIMEOUT = 5.0
def load(
etc_dir: str | Path | None = None,
hostname: str | None = None,
) -> tuple[GlobalConfig, AccountConfig]:
"""加载公共配置以及当前主机对应的账户配置。
Args:
etc_dir: 配置文件目录,其中必须包含 ``_global.yaml``;为空时
默认使用 py-client 下的 ``etc`` 目录。
hostname: 指定要加载的主机名;为空时使用当前计算机名。
Returns:
由全局配置和账户配置组成的二元组。
Raises:
ValueError: 配置缺失、格式错误或策略参数不合法。
"""
global global_config, account_config
root = Path(etc_dir) if etc_dir is not None else Path(__file__).parent.parent / "etc"
raw = _yaml(root / "_global.yaml")
# 将原始字典转换为带类型的信号配置,方便业务代码使用属性访问。
signals = {
key: SignalConfig(**(value or {}))
for key, value in (raw.get("signals") or {}).items()
}
values = {
key: raw.get(key, "")
for key in ("qmt_base_url", "qmt_token", "api_host", "qmt_data_dir")
}
current = hostname or socket.gethostname()
hosts = raw.get("hosts") or {}
account_file = next(
(
value
for key, value in hosts.items()
if key.strip().lower() == current.strip().lower()
),
"",
)
# QMT 地址、外部 API 地址和数据目录是启动策略的必要参数。
if (
not values["qmt_base_url"]
or not values["api_host"]
or values["qmt_data_dir"] == "."
):
raise ValueError("Global 配置缺少必要参数")
if not account_file:
raise ValueError(f'_global.yaml 未配置计算机 "{current}"')
if not Path(account_file).suffix:
account_file += ".yaml"
global_config = GlobalConfig(**values, hosts=hosts, signals=signals)
# 策略状态文件写入该目录,启动时提前确保目录存在。
Path(global_config.qmt_data_dir).mkdir(parents=True, exist_ok=True)
account_config = AccountConfig(**_yaml(root / account_file))
if account_config.buy_value <= 0 or account_config.grid_step_pct <= 0:
raise ValueError("buy_value、grid_step_pct 必须大于 0")
if not account_config.strategy.strip():
raise ValueError("strategy 不能为空")
# host_key 统一为小写,避免不同模块比较时受大小写影响。
account_config.host_key = account_config.host_key.lower()
account_config.strategy = account_config.strategy.lower()
return global_config, account_config
def _yaml(path: Path) -> dict:
"""读取 YAML 文件,并将空文件转换为空字典。"""
try:
with path.open(encoding="utf-8") as handle:
return yaml.safe_load(handle) or {}
except (OSError, yaml.YAMLError) as exc:
raise ValueError(f"读取或解析配置 {path} 失败: {exc}") from exc

Binary file not shown.

View File

@@ -0,0 +1,11 @@
qmt_base_url: http://127.0.0.1:10086
qmt_token: QMTbyYanweidong
api_host: http://139.224.247.176:13499
qmt_data_dir: D:/qmt_strategy_data
hosts:
DESKTOP-39H91QV: dev.yaml
signals:
dcm: {url: /a/dcm_signal, timezone: "*", gt_last_price_is_open: false}
morning: {url: /a/morning_signal, timezone: "9:30-10:30", gt_last_price_is_open: true}
tail: {url: /a/tail_signal, timezone: "14:30-14:55", gt_last_price_is_open: false}
arbitrage: {url: /a/arbitrage_signal, timezone: "*", gt_last_price_is_open: false}

11
py-client/etc/dev.yaml Normal file
View File

@@ -0,0 +1,11 @@
account_id: 86037237
host_key: dev
buy_value: 5000
min_cash_ratio: 0.10
loss_trigger_pct: -30
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
enable_loss_add_position: True
excluded_codes:
- "00000.SZ"

View File

@@ -0,0 +1,5 @@
from .calc import calc_buy_volume, trading_time
from .market import market_allow_open, status
from .signal import SignalItem, SignalResult, fetch_signal, init_signals
__all__ = ["calc_buy_volume", "trading_time", "market_allow_open", "status", "SignalItem", "SignalResult", "fetch_signal", "init_signals"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

32
py-client/libs/calc.py Normal file
View File

@@ -0,0 +1,32 @@
from datetime import datetime, time
from math import floor
def trading_time(now: datetime) -> bool:
if now.weekday() >= 5: return False
return time(9, 30) <= now.time() <= time(11, 30) or time(13) <= now.time() <= time(15)
def calc_buy_volume(price: float, buy_value: float) -> int:
if price <= 0 or buy_value <= 0: return 0
return max(1, floor(buy_value / (price * 100))) * 100
def calculate_min_profit_rate(price: float, profit_mult: int) -> float:
"""
根据价格返回最小利润率
Args:
price: 股票价格
profit_mult: 利润倍数配置
Returns:
float: 最小利润率(百分比)
"""
if price >= 300:
return 3 * profit_mult # 3%
if price >= 200:
return 5 * profit_mult # 5%
elif price >= 100:
return 7 * profit_mult # 7%
else:
return 9 * profit_mult # 9%

View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
"""网格回撤止盈状态机。
该模块只负责记录每个持仓的最高盈利网格,并判断当前盈亏率是否从
峰值网格回撤。它不包含下单逻辑,由主策略和 Upmax 根据返回的状态决定是否卖出。
"""
from dataclasses import dataclass
from enum import Enum
import math
import threading
class GridState(str, Enum):
"""单次盈亏率观察后的网格状态。"""
ARMED = "armed" # 首次记录该持仓的峰值网格
RAISED = "raised" # 盈利继续上升,峰值网格已抬高
RETREAT = "retreat" # 从峰值网格回撤,应由调用方执行止盈
STEADY = "steady" # 仍处于当前峰值网格,继续持有
@dataclass(frozen=True)
class GridObservation:
"""一次网格观察的不可变结果。"""
state: GridState
current_grid: int # 当前盈亏率所处的网格
peak_grid: int # 该持仓自观察以来的最高网格
class GridTrailingTracker:
"""按持仓键隔离、线程安全的峰值网格跟踪器。"""
def __init__(self, step: float = 1.0):
"""
Args:
step: 单个网格的盈亏率跨度(百分点),必须大于 0。
"""
if step <= 0:
raise ValueError("grid step must be positive")
self._step = step
# key 由调用方组成“账户 + 股票代码”,防止多账户状态串扰。
self._peaks: dict[str, int] = {}
# 主策略和回调线程可能并发访问,所有峰值读写均在同一把锁内。
self._lock = threading.Lock()
def observe(self, position_key: str, pnl_rate: float) -> GridObservation:
"""记录当前盈亏率,并返回相对于历史峰值的状态。"""
# floor 保证负数盈亏率也按完整网格向下归档。
current_grid = math.floor(pnl_rate / self._step)
with self._lock:
peak_grid = self._peaks.get(position_key)
# 第一次看到该持仓:建立基准,不触发止盈。
if peak_grid is None:
self._peaks[position_key] = current_grid
return GridObservation(GridState.ARMED, current_grid, current_grid)
# 进入更高网格:更新峰值,继续持有。
if current_grid > peak_grid:
self._peaks[position_key] = current_grid
return GridObservation(GridState.RAISED, current_grid, current_grid)
# 跌破峰值网格:报告回撤,但保留峰值直到卖出成功后 clear。
if current_grid < peak_grid:
return GridObservation(GridState.RETREAT, current_grid, peak_grid)
return GridObservation(GridState.STEADY, current_grid, peak_grid)
def clear(self, position_key: str) -> None:
"""持仓卖出成功后删除峰值,使下次建仓从新状态开始。"""
with self._lock:
self._peaks.pop(position_key, None)
def retain(self, position_keys) -> None:
"""删除已不在券商持仓中的峰值,避免同代码重新开仓继承旧状态。"""
active = set(position_keys)
with self._lock:
self._peaks = {key: value for key, value in self._peaks.items() if key in active}

8
py-client/libs/http.py Normal file
View File

@@ -0,0 +1,8 @@
import json
from urllib.request import Request, urlopen
def get_json(url: str, timeout: float = 5.0):
request = Request(url, headers={"Accept": "application/json", "User-Agent": "big-qmt-python/1"})
with urlopen(request, timeout=timeout) as response:
return json.load(response)

24
py-client/libs/market.py Normal file
View File

@@ -0,0 +1,24 @@
import logging
import secrets
from .http import get_json
API_HOST = "http://139.224.247.176:13499"
MARKET_URL, PERIOD, HTTP_TIMEOUT = "/a/market", "60m", 5.0
def status(payload) -> str:
value = payload.get("data", payload) if isinstance(payload, dict) else payload
if isinstance(value, list): value = value[-1] if value else None
if isinstance(value, dict): value = value.get("action", value.get("status", value.get("signal")))
result = str(value).strip().upper()
return result if result in {"UP", "DOWN", "NEUTRAL"} else "UNKNOWN"
def market_allow_open(api_host: str = API_HOST) -> bool:
url = f"{api_host}{MARKET_URL}?period={PERIOD}&t={secrets.token_urlsafe(12)}"
try: result = status(get_json(url, HTTP_TIMEOUT))
except Exception as exc:
logging.error("获取大盘指数失败: %s %s", url, exc); return False
logging.info("大盘信号: url=%s status=%s", url, result)
return result == "UP"

29
py-client/libs/signal.py Normal file
View File

@@ -0,0 +1,29 @@
from dataclasses import dataclass, field
import secrets
from .http import get_json
@dataclass
class SignalItem:
signal_key: str = ""; code: str = ""; name: str = ""; desc: str = ""; last_close: float = 0
tech_indicator: dict[str, float] = field(default_factory=dict)
@dataclass
class SignalResult:
code: str = ""; total: int = 0; updated: str = ""; data: dict[str, SignalItem] = field(default_factory=dict); message: str = ""
def fetch_signal(api_host: str, sub_url: str, timeout: float = 5.0) -> SignalResult:
url = f"{api_host}{sub_url}?t={secrets.token_urlsafe(12)}"
raw = get_json(url, timeout)
items = {code: SignalItem(**item) for code, item in (raw.get("data") or {}).items()}
return SignalResult(raw.get("code", ""), raw.get("total", 0), raw.get("updated", ""), items, raw.get("message", ""))
def init_signals(global_config, allow: list[str]) -> list[SignalItem]:
result = []
for key, cfg in global_config.signals.items():
if key in allow:
for item in fetch_signal(global_config.api_host, cfg.url).data.values(): item.signal_key = key; result.append(item)
return result

111
py-client/main.py Normal file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import logging as log
import os
import sys
import time
import config
from dataclasses import dataclass
import yaml
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
GLOBAL_CONFIG_PATH = os.path.join(PROJECT_ROOT, "etc", "_global.yaml")
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from sdk import Client
from strategy.trend.boot import StartTrend
@dataclass(frozen=True)
class StrategyDefinition:
mutex_scope: str
start_strategy: object
STRATEGIES = {
"trend": StrategyDefinition("Trend", StartTrend),
}
def require_windows() -> bool:
return os.name == "nt"
def check_single_instance(project_root: str) -> bool:
"""使用 Windows 命名互斥锁保证单实例。"""
try:
import ctypes
error_already_exists = 183
invalid_handle_value = -1
safe_path = project_root.replace(":", "_").replace("\\", "_")
mutex_name = f"Global\\QMT_System_{safe_path}"
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
handle = kernel32.CreateMutexW(None, True, mutex_name)
if not handle or handle == invalid_handle_value:
log.error(f"无法创建互斥锁,错误代码:{ctypes.get_last_error()}")
return False
if ctypes.get_last_error() == error_already_exists:
log.error("程序已在运行中,无法启动多个实例")
kernel32.CloseHandle(handle)
return False
log.info(f"成功获取互斥锁:{mutex_name}")
return True
except Exception as exc:
log.error(f"单实例检测失败:{exc}", exc_info=True)
return False
def ping_api_host(
rpc_host: str,
retry_interval: float = 5.0,
connect_timeout: float = 3.0,
) -> None:
"""循环检查 API 地址,连通后才返回。"""
client = Client(config.global_config.qmt_base_url, config.global_config.qmt_token, config.HTTP_TIMEOUT)
while True:
try:
assets = client.assets()
log.info(f"API 服务已连通:{config.global_config.qmt_base_url}")
return
except:
log.warning(
f"API 服务未就绪:{config.global_config.qmt_base_url}{retry_interval:g} 秒后重试"
)
time.sleep(retry_interval)
def wait_for_any_key() -> None:
print("按任意键退出...", flush=True)
if os.name == "nt":
import msvcrt
msvcrt.getch()
elif sys.stdin.isatty():
sys.stdin.read(1)
def main() -> int:
try:
if not require_windows():
log.error("本程序仅支持 Windows 环境运行")
return 1
if not check_single_instance(PROJECT_ROOT):
return 1
config.load()
if config.global_config is None or config.account_config is None:
raise RuntimeError("配置尚未加载,请先调用 config.load()")
ping_api_host(config.global_config.qmt_base_url)
STRATEGIES[config.account_config.strategy].start_strategy()
return 0
except (OSError, yaml.YAMLError, ValueError) as exc:
print(f"启动失败: {exc}", file=sys.stderr, flush=True)
wait_for_any_key()
return 1
if __name__ == "__main__":
sys.exit(main())

14
py-client/sdk/__init__.py Normal file
View File

@@ -0,0 +1,14 @@
from .account import AccountMixin
from .client import Client as _HTTPClient
from .data import DataMixin
from .errors import APIError, BusinessError
from .misc import MiscMixin
from .models import *
from .trade import *
class Client(AccountMixin, DataMixin, TradeMixin, MiscMixin, _HTTPClient):
"""big-qmt 同步 HTTP 客户端。"""
__all__ = ["Client", "APIError", "BusinessError", "OP_BUY", "OP_SELL", "ORDER_TYPE_VOLUME", "PR_TYPE_LATEST", "QUICK_TRADE_NOW"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

33
py-client/sdk/account.py Normal file
View File

@@ -0,0 +1,33 @@
from typing import Any
from .models import Assets, Position
class AccountMixin:
account_type: str
def _positions(self, path: str) -> tuple[list[str], list[Position]]:
raw = self._post(path, {"account": self.account_type}) or {}
return list(raw), [Position.from_dict(value, code) for code, value in raw.items()]
def positions(self): return self._positions("/api/v2/positions")
def holding(self): return self._positions("/api/holding")
def assets(self) -> Assets:
data = self._post("/api/v2/assets", {"account": self.account_type})
return Assets(float(data.get("total", 0)), float(data.get("available", 0)))
def total_money(self) -> float: return float(self._post("/api/money/total", {"account": self.account_type}).get("total_money", 0))
def available_money(self) -> float: return float(self._post("/api/money/available", {"account": self.account_type}).get("available_money", 0))
def buy(self, stock: str, price: float, volume: int, pr_type: int = 0): return self._order("/api/order/buy", stock, price, volume, pr_type)
def sell(self, stock: str, price: float, volume: int, pr_type: int = 0): return self._order("/api/order/sell", stock, price, volume, pr_type)
def _order(self, path, stock, price, volume, pr_type):
body = {"stock": stock, "price": price, "volume": volume}
if pr_type: body["prType"] = pr_type
return self._post(path, body)
def order_status_list(self): return self._post("/api/order/status", {"account": self.account_type}).get("orders", [])
def cancel_all(self): return self._post("/api/order/cancel_all", {"account": self.account_type})
def cancel_by_rule(self, stock: str, volume: int): return self._post("/api/order/cancel_order", {"stock": stock, "volume": volume, "account": self.account_type})
def deals(self): return self._post("/api/order/deal", {"account": self.account_type}).get("deals", [])

59
py-client/sdk/client.py Normal file
View File

@@ -0,0 +1,59 @@
from __future__ import annotations
import json
from dataclasses import asdict, is_dataclass
from typing import Any
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from .errors import APIError, BusinessError
def csv_join(items: list[str]) -> str:
return ",".join(item.strip() for item in items if item.strip())
class Client:
def __init__(self, base_url: str, token: str, timeout: float = 15.0) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
self.timeout = timeout if timeout > 0 else 15.0
self.account_type = "stock"
def set_account_type(self, account_type: str) -> "Client":
if account_type.strip():
self.account_type = account_type
return self
def _request(self, method: str, path: str, body: Any = None) -> Any:
data = None
headers = {"X-Token": self.token, "Accept": "application/json"}
if method != "GET":
if body is None: body = {}
if is_dataclass(body): body = asdict(body)
data = json.dumps(body, ensure_ascii=False).encode()
headers["Content-Type"] = "application/json"
request = Request(self.base_url + path, data=data, headers=headers, method=method)
try:
with urlopen(request, timeout=self.timeout) as response:
raw = response.read()
except HTTPError as exc:
raw = exc.read()
try: message = json.loads(raw).get("error", raw.decode(errors="replace"))
except (ValueError, AttributeError): message = raw.decode(errors="replace").strip()
raise APIError(exc.code, str(message)) from exc
if not raw: return None
try: return json.loads(raw)
except ValueError as exc: raise ValueError(f"invalid JSON from {path}: {raw[:512]!r}") from exc
def _get(self, path: str) -> Any: return self._request("GET", path)
def _post(self, path: str, body: Any = None) -> Any: return self._request("POST", path, body)
def _get_field(self, path: str, key: str) -> Any:
return self._get(path).get(key)
def _post_field(self, path: str, body: Any, key: str) -> Any:
result = self._post(path, body)
if isinstance(result, dict) and result.get("error"):
raise BusinessError(result["error"])
return result.get(key, result) if key and isinstance(result, dict) else result

83
py-client/sdk/data.py Normal file
View File

@@ -0,0 +1,83 @@
from dataclasses import asdict
from typing import Any
from .client import csv_join
from .models import *
class DataMixin:
def _one(self, endpoint, arg, value, key): return self._post_field(f"/api/data/{endpoint}", {arg: value}, key)
def stock_name(self, code): return self._one("stock_name", "stockcode", code, "name")
def open_date(self, code): return self._one("open_date", "stockcode", code, "open_date")
def last_volume(self, code): return self._one("last_volume", "stockcode", code, "last_volume")
def bar_timetag(self, index): return self._one("bar_timetag", "index", index, "timetag")
def tick_timetag(self): return self._get_field("/api/data/tick_timetag", "timetag")
def sector(self, sector, realtime): return self._post("/api/data/sector", {"sector": sector, "realtime": realtime}).get("stocks", [])
def industry(self, industry): return self._post("/api/data/industry", {"industry": industry}).get("stocks", [])
def stock_list_in_sector(self, name): return self._post("/api/data/stock_list_in_sector", {"sectorname": name}).get("stocks", [])
def weight_in_index(self, indexcode, stockcode): return self._post_field("/api/data/weight_in_index", locals_body(indexcode=indexcode, stockcode=stockcode), "weight")
def contract_multiplier(self, code): return self._one("contract_multiplier", "contractcode", code, "multiplier")
def risk_free_rate(self, index): return self._one("risk_free_rate", "index", index, "risk_free_rate")
def date_location(self, date): return self._one("date_location", "strdate", date, "location")
def history_data(self, req: HistoryDataRequest):
return self._post_field("/api/data/history_data", {"len": req.length or 10, "period": req.period, "field": req.field, "dividend_type": req.dividend_type, "skip_paused": str(req.skip_paused).lower()}, "data")
def _market_body(self, req): return {"fields": csv_join(req.fields), "stock_code": csv_join(req.stocks), "start_time": req.start_time, "end_time": req.end_time, "period": req.period, "dividend_type": req.dividend_type, "count": req.count}
def market_data(self, req): return self._post_field("/api/data/market_data", self._market_body(req), "data")
def market_data_ex(self, req): return self._post_field("/api/data/market_data_ex", self._market_body(req), "data")
def full_tick(self, stocks):
raw = self._post("/api/data/full_tick", {"stocks": stocks}) or {}
def number(data, *names):
for name in names:
try: return float(data[name])
except (KeyError, TypeError, ValueError): pass
return 0.0
return {code: Tick(number(value, "lastPrice", "last_price", "LastPrice"), number(value, "lastClose", "last_close", "LastClose"), value if isinstance(value, dict) else {}) for code, value in raw.items()}
def divid_factors(self, code): return self._one("divid_factors", "stockcode", code, "factors")
def main_contract(self, code): return self._one("main_contract", "codemarket", code, "main_contract")
def timetag_to_datetime(self, timetag, format=""):
body = {"timetag": timetag}
if format: body["format"] = format
return self._post_field("/api/data/timetag_to_datetime", body, "datetime")
def total_share(self, code): return self._one("total_share", "stockcode", code, "total_share")
def trading_dates(self, stockcode, start_date, end_date, period, count=0):
body = locals_body(stockcode=stockcode, start_date=start_date, end_date=end_date, period=period)
if count: body["count"] = count
return self._post("/api/data/trading_dates", body).get("dates", [])
def svol(self, code): return self._one("svol", "stockcode", code, "svol")
def bvol(self, code): return self._one("bvol", "stockcode", code, "bvol")
def longhubang(self, stocks, start, end): return self._post_field("/api/data/longhubang", {"stock_list": csv_join(stocks), "startTime": start, "endTime": end}, "data")
def top10_share_holder(self, stocks, name, start, end): return self._post_field("/api/data/top10_share_holder", {"stock_list": csv_join(stocks), "data_name": name, "start_time": start, "end_time": end}, "data")
def option_detail(self, code): return self._one("option_detail", "optioncode", code, "detail")
def turnover_rate(self, stocks, start, end): return self._post_field("/api/data/turnover_rate", {"stock_list": csv_join(stocks), "startTime": start, "endTime": end}, "data")
def etf_info(self, code): return self._one("etf_info", "stockcode", code, "info")
def etf_iopv(self, code): return self._one("etf_iopv", "stockcode", code, "iopv")
def instrument_detail(self, code): return self._one("instrumentdetail", "stockcode", code, "detail")
def contract_expire_date(self, code): return self._one("contract_expire_date", "codemarket", code, "expire_date")
def option_undl_data(self, code): return self._one("option_undl_data", "undl_code_ref", code, "data")
def financial_data(self, req):
return self._post_field("/api/data/financial_data", {"tabname": req.tabname, "colname": req.colname, "market": req.market, "code": req.code, "report_type": req.report_type, "barpos": req.barpos, "fieldList": csv_join(req.field_list), "stockList": csv_join(req.stock_list), "startDate": req.start_date, "endDate": req.end_date}, "data")
def factor_data(self, req): return self._post_field("/api/data/factor_data", {"fieldList": csv_join(req.field_list), "stockList": csv_join(req.stock_list), "stockCode": req.stock_code, "startDate": req.start_date, "endDate": req.end_date}, "data")
def his_st_data(self, code): return self._one("his_st_data", "stockCode", code, "data")
def his_index_data(self, index): return self._one("his_index_data", "index", index, "data")
def all_subscription(self): return self._get_field("/api/data/all_subscription", "subscriptions")
def option_list(self, code, dedate, opttype, available): return self._post_field("/api/data/option_list", {"undl_code": code, "dedate": dedate, "opttype": opttype, "isavailable": str(available).lower()}, "option_list")
def his_contract_list(self, market): return self._one("his_contract_list", "market", market, "contracts")
def option_iv(self, code): return self._one("option_iv", "optioncode", code, "iv")
def bsm_price(self, req):
prices = ",".join(str(v) for v in req.object_prices) if isinstance(req.object_prices, list) else req.object_prices
return self._post_field("/api/data/bsm_price", {"optionType": req.option_type, "objectPrices": prices, "strikePrice": req.strike_price, "riskFree": req.risk_free, "sigma": req.sigma, "days": req.days, "dividend": req.dividend}, "price")
def bsm_iv(self, req): return self._post_field("/api/data/bsm_iv", camel_request(req), "iv")
def local_data(self, req): return self._post_field("/api/data/local_data", {"stock_code": req.stock_code, "start_time": req.start_time, "end_time": req.end_time, "period": req.period, "divid_type": req.divid_type, "count": req.count}, "data")
def subscribe_quote(self, code, period, dividend_type): return self._post("/api/data/subscribe_quote", {"stock_code": code, "period": period, "dividend_type": dividend_type})
def unsubscribe_quote(self, sub_id): return self._post("/api/data/unsubscribe_quote", {"sub_id": sub_id})
def locals_body(**kwargs): return kwargs
def camel_request(req):
data = asdict(req)
return {"optionType": data["option_type"], "objectPrices": data["object_prices"], "strikePrice": data["strike_price"], "optionPrice": data["option_price"], "riskFree": data["risk_free"], "days": data["days"], "dividend": data["dividend"]}

14
py-client/sdk/errors.py Normal file
View File

@@ -0,0 +1,14 @@
class APIError(RuntimeError):
def __init__(self, status_code: int, message: str = "") -> None:
self.status_code = status_code
self.message = message
text = f"qmt api: http {status_code}"
super().__init__(f"{text}: {message}" if message else text)
@property
def unauthorized(self) -> bool:
return self.status_code == 401
class BusinessError(RuntimeError):
pass

31
py-client/sdk/misc.py Normal file
View File

@@ -0,0 +1,31 @@
from typing import Any
class MiscMixin:
def context_period(self): return self._get_field("/api/context/period", "period")
def context_barpos(self): return self._get_field("/api/context/barpos", "barpos")
def context_time_tick_size(self): return self._get_field("/api/context/time_tick_size", "time_tick_size")
def context_stockcode(self): return self._get_field("/api/context/stockcode", "stockcode")
def context_dividend_type(self): return self._get_field("/api/context/dividend_type", "dividend_type")
def context_market(self): return self._get_field("/api/context/market", "market")
def context_do_back_test(self): return self._get_field("/api/context/do_back_test", "do_back_test")
def context_benchmark(self): return self._get_field("/api/context/benchmark", "benchmark")
def context_capital(self): return self._get_field("/api/context/capital", "capital")
def context_universe(self):
value = self._get_field("/api/context/universe", "universe")
if value is None: return []
return [str(v) for v in value if str(v)] if isinstance(value, list) else [str(value)]
def is_last_bar(self): return self._get_field("/api/check/is_last_bar", "is_last_bar")
def is_new_bar(self): return self._get_field("/api/check/is_new_bar", "is_new_bar")
def is_suspended_stock(self, stockcode): return self._post_field("/api/check/is_suspended_stock", {"stockcode": stockcode}, "is_suspended")
def is_sector_stock(self, sectorname, market, stockcode): return self._post_field("/api/check/is_sector_stock", {"sectorname": sectorname, "market": market, "stockcode": stockcode}, "is_in_sector")
def is_typed_stock(self, stocktypenum, market, stockcode): return self._post_field("/api/check/is_typed_stock", {"stocktypenum": stocktypenum, "market": market, "stockcode": stockcode}, "result")
def industry_name_of_stock(self, industry_type, stockcode): return self._post_field("/api/check/get_industry_name_of_stock", {"industryType": industry_type, "stockcode": stockcode}, "industry_name")
def ext_data(self, name, stockcode, deviation): return self._post_field("/api/ext/ext_data", {"extdataname": name, "stockcode": stockcode, "deviation": deviation}, "value")
def ext_data_rank(self, name, stockcode, deviation): return self._post_field("/api/ext/ext_data_rank", {"extdataname": name, "stockcode": stockcode, "deviation": deviation}, "rank")
def get_factor_value(self, name, stockcode, deviation): return self._post_field("/api/ext/get_factor_value", {"factorname": name, "stockcode": stockcode, "deviation": deviation}, "value")
def get_factor_rank(self, name, stockcode, deviation): return self._post_field("/api/ext/get_factor_rank", {"factorname": name, "stockcode": stockcode, "deviation": deviation}, "rank")
def python_version(self): return self._get("/api/sys/python_version")
def shutdown(self): return self._post("/api/sys/shutdown", {})

106
py-client/sdk/models.py Normal file
View File

@@ -0,0 +1,106 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
def _number(value: Any, kind: type = float) -> Any:
try:
return kind(value or 0)
except (TypeError, ValueError):
return kind()
@dataclass
class Position:
stock_code: str = ""
stock_name: str = ""
direction: Any = None
volume: int = 0
open_price: float = 0.0
float_profit: float = 0.0
market_value: float = 0.0
stock_holder: str = ""
frozen_volume: int = 0
can_use_volume: int = 0
on_road_volume: int = 0
yesterday_volume: int = 0
last_price: float = 0.0
profit_rate: float = 0.0
future_trade_type: Any = None
expire_date: str = ""
@classmethod
def from_dict(cls, data: dict[str, Any], code: str = "") -> "Position":
return cls(
stock_code=str(data.get("StockCode") or code), stock_name=str(data.get("StockName") or ""),
direction=data.get("Direction"), volume=_number(data.get("Volume"), int),
open_price=_number(data.get("OpenPrice")), float_profit=_number(data.get("FloatProfit")),
market_value=_number(data.get("MarketValue")), stock_holder=str(data.get("StockHolder") or ""),
frozen_volume=_number(data.get("FrozenVolume"), int), can_use_volume=_number(data.get("CanUseVolume"), int),
on_road_volume=_number(data.get("OnRoadVolume"), int), yesterday_volume=_number(data.get("YesterdayVolume"), int),
last_price=_number(data.get("LastPrice")), profit_rate=_number(data.get("ProfitRate")),
future_trade_type=data.get("FutureTradeType"), expire_date=str(data.get("ExpireDate") or ""),
)
@dataclass
class Assets:
total: float = 0.0
available: float = 0.0
@dataclass
class Tick:
last_price: float = 0.0
last_close: float = 0.0
raw: dict[str, Any] = field(default_factory=dict)
@dataclass
class HistoryDataRequest:
length: int = 10
period: str = ""
field: str = ""
dividend_type: int = 0
skip_paused: bool = True
@dataclass
class MarketDataRequest:
fields: list[str] = field(default_factory=list)
stocks: list[str] = field(default_factory=list)
start_time: str = ""
end_time: str = ""
period: str = ""
dividend_type: str = ""
count: int = 0
@dataclass
class FinancialDataRequest:
tabname: str = ""; colname: str = ""; market: str = ""; code: str = ""
report_type: str = ""; barpos: int = 0
field_list: list[str] = field(default_factory=list); stock_list: list[str] = field(default_factory=list)
start_date: str = ""; end_date: str = ""
@dataclass
class FactorDataRequest:
field_list: list[str] = field(default_factory=list); stock_list: list[str] = field(default_factory=list)
stock_code: str = ""; start_date: str = ""; end_date: str = ""
@dataclass
class BSMPriceRequest:
option_type: str; object_prices: Any; strike_price: float; risk_free: float; sigma: float; days: int; dividend: float
@dataclass
class BSMIVRequest:
option_type: str; object_prices: float; strike_price: float; option_price: float; risk_free: float; days: int; dividend: float
@dataclass
class LocalDataRequest:
stock_code: str; start_time: str = ""; end_time: str = ""; period: str = ""; divid_type: str = ""; count: int = 0

54
py-client/sdk/trade.py Normal file
View File

@@ -0,0 +1,54 @@
from typing import Any
OP_BUY, OP_SELL = 23, 24
ORDER_TYPE_VOLUME, PR_TYPE_LATEST, QUICK_TRADE_NOW = 1101, 5, 2
class TradeMixin:
account_type: str
def passorder(self, op_type, stock, volume, order_type=0, pr_type=0, price=0, quick_trade=0, strategy_name=""):
body = {"opType": op_type, "stock": stock, "price": price, "volume": volume}
for key, value in (("orderType", order_type), ("prType", pr_type), ("quickTrade", quick_trade), ("strategyName", strategy_name)):
if value: body[key] = value
return self._post("/api/trade/passorder", body)
def passorder_latest(self, side, stock, volume): return self.passorder_latest_tagged(side, stock, volume, "")
def passorder_latest_tagged(self, side, stock, volume, order_id):
return self.passorder(side, stock, volume, ORDER_TYPE_VOLUME, PR_TYPE_LATEST, -1, QUICK_TRADE_NOW, order_id)
def algo_passorder(self, **kwargs): return self._post("/api/trade/algo_passorder", kwargs)
def smart_algo_passorder(self, **kwargs): return self._post("/api/trade/smart_algo_passorder", kwargs)
def _style_order(self, path, stock, value_key, value, style, price):
return self._post(path, {"stock": stock, value_key: value, "style": style, "price": price})
def order_lots(self, stock, lots, style, price): return self._style_order("/api/trade/order_lots", stock, "lots", lots, style, price)
def order_value(self, stock, value, style, price): return self._style_order("/api/trade/order_value", stock, "value", value, style, price)
def order_percent(self, stock, percent, style, price): return self._style_order("/api/trade/order_percent", stock, "percent", percent, style, price)
def order_target_value(self, stock, value, style, price): return self._style_order("/api/trade/order_target_value", stock, "tar_value", value, style, price)
def order_target_percent(self, stock, percent, style, price): return self._style_order("/api/trade/order_target_percent", stock, "tar_percent", percent, style, price)
def order_shares(self, stock, shares, style, price): return self._style_order("/api/trade/order_shares", stock, "shares", shares, style, price)
def _future(self, action, stock, amount, style, price): return self._style_order(f"/api/trade/futures/{action}", stock, "amount", amount, style, price)
def futures_buy_open(self, *args): return self._future("buy_open", *args)
def futures_buy_close_tdayfirst(self, *args): return self._future("buy_close_tdayfirst", *args)
def futures_buy_close_ydayfirst(self, *args): return self._future("buy_close_ydayfirst", *args)
def futures_sell_open(self, *args): return self._future("sell_open", *args)
def futures_sell_close_tdayfirst(self, *args): return self._future("sell_close_tdayfirst", *args)
def futures_sell_close_ydayfirst(self, *args): return self._future("sell_close_ydayfirst", *args)
def _task(self, action, task_id): return self._post(f"/api/trade/{action}_task", {"taskId": task_id, "accountType": self.account_type})
def cancel_task(self, task_id): return self._task("cancel", task_id)
def pause_task(self, task_id): return self._task("pause", task_id)
def resume_task(self, task_id): return self._task("resume", task_id)
def do_order(self): return self._post("/api/trade/do_order")
def trade_detail_data(self, datatype): return self._post("/api/trade/trade_detail_data", {"account": self.account_type, "datatype": datatype}).get("data", [])
def value_by_order_id(self, order_id, datatype): return self._post("/api/trade/value_by_order_id", {"orderId": order_id, "accountType": self.account_type, "datatype": datatype}).get("data")
def last_order_id(self, datatype): return self._post("/api/trade/last_order_id", {"account": self.account_type, "datatype": datatype}).get("last_order_id")
def can_cancel_order(self, order_id): return self._post("/api/trade/can_cancel_order", {"orderId": order_id, "accountType": self.account_type}).get("can_cancel")
def debt_contract(self): return self._contract("debt_contract")
def assure_contract(self): return self._contract("assure_contract")
def enable_short_contract(self): return self._contract("enable_short_contract")
def _contract(self, name): return self._post(f"/api/trade/{name}").get("data", [])
def ipo_data(self, typ): return self._post_field("/api/trade/ipo_data", {"type": typ}, "data")
def new_purchase_limit(self): return self._post_field("/api/trade/new_purchase_limit", None, "data")

View File

@@ -0,0 +1,5 @@
"""交易策略启动入口。"""
from .trend.boot import StartTrend
__all__ = ["StartTrend"]

Binary file not shown.

View 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

View 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]

View 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

View 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

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

View 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

View 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

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

25
py-client/test.py Normal file
View File

@@ -0,0 +1,25 @@
import json
import os
from pathlib import Path
from sdk import Client
BASE_URL = "http://127.0.0.1:10086"
TOKEN = "QMTbyYanweidong"
def main():
client = Client(BASE_URL, TOKEN).set_account_type("stock")
assets = client.assets();
_, positions = client.positions()
print(f"总资产:{assets.total:.2f}元,可用资金:{assets.available:.2f}")
for p in sorted(positions, key=lambda item: item.stock_code):
if p.volume > 0: print(f"{p.stock_code} {p.stock_name} 持仓={p.volume} 可用={p.can_use_volume} 成本={p.open_price:.3f} 现价={p.last_price:.3f}")
data_dir = os.environ.get("QMT_DATA_DIR", "").strip()
if not data_dir: raise SystemExit("环境变量 QMT_DATA_DIR 为空")
codes = json.loads((Path(data_dir) / "pass_codes.json").read_text(encoding="utf-8"))
for code, tick in sorted(client.full_tick(codes).items()):
print(f"{code} last={tick.last_price:.3f} close={tick.last_close:.3f}")
if __name__ == "__main__": main()