feat
This commit is contained in:
BIN
py-client/__pycache__/main.cpython-311.pyc
Normal file
BIN
py-client/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/__pycache__/test.cpython-311.pyc
Normal file
BIN
py-client/__pycache__/test.cpython-311.pyc
Normal file
Binary file not shown.
146
py-client/config/__init__.py
Normal file
146
py-client/config/__init__.py
Normal 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
|
||||
BIN
py-client/config/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
py-client/config/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
11
py-client/etc/_global.yaml
Normal file
11
py-client/etc/_global.yaml
Normal 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
11
py-client/etc/dev.yaml
Normal 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"
|
||||
5
py-client/libs/__init__.py
Normal file
5
py-client/libs/__init__.py
Normal 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"]
|
||||
BIN
py-client/libs/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/calc.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/calc.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/grid_take_profit.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/grid_take_profit.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/http.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/http.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/market.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/market.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/signal.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/signal.cpython-311.pyc
Normal file
Binary file not shown.
32
py-client/libs/calc.py
Normal file
32
py-client/libs/calc.py
Normal 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%
|
||||
80
py-client/libs/grid_take_profit.py
Normal file
80
py-client/libs/grid_take_profit.py
Normal 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
8
py-client/libs/http.py
Normal 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
24
py-client/libs/market.py
Normal 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
29
py-client/libs/signal.py
Normal 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
111
py-client/main.py
Normal 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
14
py-client/sdk/__init__.py
Normal 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"]
|
||||
BIN
py-client/sdk/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
py-client/sdk/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/sdk/__pycache__/account.cpython-311.pyc
Normal file
BIN
py-client/sdk/__pycache__/account.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/sdk/__pycache__/client.cpython-311.pyc
Normal file
BIN
py-client/sdk/__pycache__/client.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/sdk/__pycache__/data.cpython-311.pyc
Normal file
BIN
py-client/sdk/__pycache__/data.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/sdk/__pycache__/errors.cpython-311.pyc
Normal file
BIN
py-client/sdk/__pycache__/errors.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/sdk/__pycache__/misc.cpython-311.pyc
Normal file
BIN
py-client/sdk/__pycache__/misc.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/sdk/__pycache__/models.cpython-311.pyc
Normal file
BIN
py-client/sdk/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/sdk/__pycache__/trade.cpython-311.pyc
Normal file
BIN
py-client/sdk/__pycache__/trade.cpython-311.pyc
Normal file
Binary file not shown.
33
py-client/sdk/account.py
Normal file
33
py-client/sdk/account.py
Normal 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
59
py-client/sdk/client.py
Normal 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
83
py-client/sdk/data.py
Normal 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
14
py-client/sdk/errors.py
Normal 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
31
py-client/sdk/misc.py
Normal 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
106
py-client/sdk/models.py
Normal 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
54
py-client/sdk/trade.py
Normal 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")
|
||||
5
py-client/strategy/__init__.py
Normal file
5
py-client/strategy/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""交易策略启动入口。"""
|
||||
|
||||
from .trend.boot import StartTrend
|
||||
|
||||
__all__ = ["StartTrend"]
|
||||
BIN
py-client/strategy/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
py-client/strategy/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/strategy/__pycache__/boot.cpython-311.pyc
Normal file
BIN
py-client/strategy/__pycache__/boot.cpython-311.pyc
Normal file
Binary file not shown.
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)
|
||||
25
py-client/test.py
Normal file
25
py-client/test.py
Normal 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()
|
||||
Reference in New Issue
Block a user