refactor QMT client and optimize API
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
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
|
||||
|
||||
import httpx
|
||||
|
||||
from .errors import APIError, BusinessError
|
||||
|
||||
@@ -14,11 +13,28 @@ def csv_join(items: list[str]) -> str:
|
||||
|
||||
|
||||
class Client:
|
||||
"""复用连接池的同步 QMT HTTP 客户端。"""
|
||||
|
||||
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"
|
||||
self.http = httpx.Client(
|
||||
base_url=self.base_url,
|
||||
headers={"X-Token": token, "Accept": "application/json"},
|
||||
timeout=httpx.Timeout(self.timeout),
|
||||
limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self.http.close()
|
||||
|
||||
def __enter__(self) -> "Client":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
self.close()
|
||||
|
||||
def set_account_type(self, account_type: str) -> "Client":
|
||||
if account_type.strip():
|
||||
@@ -26,28 +42,38 @@ class Client:
|
||||
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)
|
||||
if is_dataclass(body):
|
||||
body = asdict(body)
|
||||
attempts = 2 if _is_idempotent(method, path) else 1
|
||||
response: httpx.Response | None = None
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
response = self.http.request(method, path, json=body)
|
||||
break
|
||||
except (httpx.ConnectError, httpx.ReadTimeout):
|
||||
if attempt + 1 == attempts:
|
||||
raise
|
||||
assert response is not None
|
||||
if response.status_code >= 400:
|
||||
try:
|
||||
message = response.json().get("error", response.text)
|
||||
except (ValueError, AttributeError):
|
||||
message = response.text.strip()
|
||||
raise APIError(response.status_code, str(message))
|
||||
if not response.content:
|
||||
return None
|
||||
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
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"invalid JSON from {path}: {response.content[: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(self, path: str) -> Any:
|
||||
return self._request("GET", path)
|
||||
|
||||
def _post(self, path: str, body: Any = None) -> Any:
|
||||
return self._request("POST", path, {} if body is None else body)
|
||||
|
||||
def _get_field(self, path: str, key: str) -> Any:
|
||||
return self._get(path).get(key)
|
||||
@@ -57,3 +83,20 @@ class Client:
|
||||
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
|
||||
|
||||
|
||||
def _is_idempotent(method: str, path: str) -> bool:
|
||||
if method == "GET":
|
||||
return True
|
||||
prefixes = (
|
||||
"/api/v2/",
|
||||
"/api/holding",
|
||||
"/api/money/",
|
||||
"/api/context/",
|
||||
"/api/check/",
|
||||
"/api/data/",
|
||||
"/api/trade/trade_detail_data",
|
||||
"/api/order/deal",
|
||||
)
|
||||
unsafe = ("subscribe", "unsubscribe")
|
||||
return path.startswith(prefixes) and not any(word in path for word in unsafe)
|
||||
|
||||
@@ -11,7 +11,7 @@ def _number(value: Any, kind: type = float) -> Any:
|
||||
return kind()
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class Position:
|
||||
stock_code: str = ""
|
||||
stock_name: str = ""
|
||||
@@ -44,20 +44,20 @@ class Position:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class Assets:
|
||||
total: float = 0.0
|
||||
available: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class Tick:
|
||||
last_price: float = 0.0
|
||||
last_close: float = 0.0
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class HistoryDataRequest:
|
||||
length: int = 10
|
||||
period: str = ""
|
||||
@@ -66,7 +66,7 @@ class HistoryDataRequest:
|
||||
skip_paused: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class MarketDataRequest:
|
||||
fields: list[str] = field(default_factory=list)
|
||||
stocks: list[str] = field(default_factory=list)
|
||||
@@ -77,7 +77,7 @@ class MarketDataRequest:
|
||||
count: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class FinancialDataRequest:
|
||||
tabname: str = ""; colname: str = ""; market: str = ""; code: str = ""
|
||||
report_type: str = ""; barpos: int = 0
|
||||
@@ -85,22 +85,22 @@ class FinancialDataRequest:
|
||||
start_date: str = ""; end_date: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
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
|
||||
@dataclass(slots=True)
|
||||
class BSMPriceRequest:
|
||||
option_type: str; object_prices: Any; strike_price: float; risk_free: float; sigma: float; days: int; dividend: float
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class BSMIVRequest:
|
||||
option_type: str; object_prices: float; strike_price: float; option_price: float; risk_free: float; days: int; dividend: float
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class LocalDataRequest:
|
||||
stock_code: str; start_time: str = ""; end_time: str = ""; period: str = ""; divid_type: str = ""; count: int = 0
|
||||
|
||||
@@ -13,9 +13,20 @@ class TradeMixin:
|
||||
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 passorder_latest(self, side, stock, volume): return self.passorder_latest_tagged(side, stock, volume, "", "")
|
||||
def passorder_latest_tagged(self, side, stock, volume, strategy_name, order_id):
|
||||
body = {
|
||||
"opType": side,
|
||||
"orderType": ORDER_TYPE_VOLUME,
|
||||
"stock": stock,
|
||||
"prType": PR_TYPE_LATEST,
|
||||
"price": -1,
|
||||
"volume": volume,
|
||||
"quickTrade": QUICK_TRADE_NOW,
|
||||
"strategyName": strategy_name,
|
||||
"orderId": order_id,
|
||||
}
|
||||
return self._post("/api/trade/passorder", body)
|
||||
|
||||
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)
|
||||
@@ -46,6 +57,7 @@ class TradeMixin:
|
||||
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 cancel_by_id(self, order_id): return self._post("/api/order/cancel_by_id", {"order_id": order_id, "account_type": self.account_type})
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user