feat: add Tencent quote parser

This commit is contained in:
王鹏
2026-07-08 21:59:17 +08:00
parent c5d9a9a0ee
commit 653ac46e9d
4 changed files with 205 additions and 0 deletions

View File

@@ -114,6 +114,22 @@ class FeeEstimate:
return self.commission + self.stamp_tax + self.transfer_fee
@dataclass(frozen=True)
class QuoteSnapshot:
symbol: str
code: str
name: str
price: Decimal
source: str
quote_time: str | None = None
previous_close: Decimal | None = None
open_price: Decimal | None = None
high: Decimal | None = None
low: Decimal | None = None
change: Decimal | None = None
change_percent: Decimal | None = None
@dataclass(frozen=True)
class PositionSummary:
instrument_id: int

View File

@@ -0,0 +1 @@
"""Market data providers."""

View File

@@ -0,0 +1,122 @@
from __future__ import annotations
import re
from decimal import Decimal, InvalidOperation
from typing import Callable, Iterable
from urllib.request import urlopen
from grid_trading.domain.models import Instrument, QuoteSnapshot
TENCENT_ENDPOINT = "http://qt.gtimg.cn/q="
_RESPONSE_RE = re.compile(r"v_([a-z]{2}\d{6})=\"([^\"]*)\";?")
class QuoteFetchError(RuntimeError):
pass
def infer_tencent_symbol(code: str) -> str:
normalized = code.strip().lower()
if re.fullmatch(r"(sh|sz|bj)\d{6}", normalized):
return normalized
if not re.fullmatch(r"\d{6}", normalized):
raise QuoteFetchError(f"Unsupported Tencent quote code: {code}")
if normalized.startswith(("6", "5", "9")):
return f"sh{normalized}"
if normalized.startswith(("0", "1", "2", "3")):
return f"sz{normalized}"
if normalized.startswith(("4", "8")):
return f"bj{normalized}"
raise QuoteFetchError(f"Cannot infer Tencent market prefix for code: {code}")
def parse_tencent_response(raw: bytes) -> list[QuoteSnapshot]:
text = _decode_response(raw)
quotes: list[QuoteSnapshot] = []
for match in _RESPONSE_RE.finditer(text):
symbol = match.group(1)
fields = match.group(2).split("~")
quotes.append(_parse_quote_fields(symbol, fields))
if not quotes:
raise QuoteFetchError("Tencent quote response did not contain quote records")
return quotes
class TencentQuoteProvider:
def __init__(
self,
*,
endpoint: str = TENCENT_ENDPOINT,
timeout: float = 5.0,
fetcher: Callable[[str, float], bytes] | None = None,
):
self.endpoint = endpoint
self.timeout = timeout
self._fetcher = fetcher or _default_fetcher
def fetch_quotes(self, instruments: Iterable[Instrument]) -> dict[str, QuoteSnapshot]:
symbols = [infer_tencent_symbol(instrument.code) for instrument in instruments]
if not symbols:
return {}
query = ",".join(symbols)
url = f"{self.endpoint}{query}"
raw = self._fetcher(url, self.timeout)
return {quote.code: quote for quote in parse_tencent_response(raw)}
def _default_fetcher(url: str, timeout: float) -> bytes:
with urlopen(url, timeout=timeout) as response:
return response.read()
def _decode_response(raw: bytes) -> str:
for encoding in ("gbk", "gb18030", "utf-8"):
try:
return raw.decode(encoding)
except UnicodeDecodeError:
continue
raise QuoteFetchError("Tencent quote response encoding is unsupported")
def _parse_quote_fields(symbol: str, fields: list[str]) -> QuoteSnapshot:
if len(fields) > 3 and not fields[3].strip():
raise QuoteFetchError(f"Tencent quote {symbol} price is missing")
if len(fields) < 35:
raise QuoteFetchError(f"Tencent quote record for {symbol} is incomplete")
price = _required_decimal(fields[3], f"{symbol} price")
if price <= 0:
raise QuoteFetchError(f"Tencent quote record for {symbol} price is empty or zero")
code = fields[2].strip() or symbol[2:]
name = fields[1].strip() or code
return QuoteSnapshot(
symbol=symbol,
code=code,
name=name,
price=price,
source="tencent",
quote_time=fields[30].strip() or None,
previous_close=_optional_decimal(fields[4]),
open_price=_optional_decimal(fields[5]),
high=_optional_decimal(fields[33]),
low=_optional_decimal(fields[34]),
change=_optional_decimal(fields[31]),
change_percent=_optional_decimal(fields[32]),
)
def _required_decimal(raw: str, label: str) -> Decimal:
value = _optional_decimal(raw)
if value is None:
raise QuoteFetchError(f"Tencent quote {label} is missing")
return value
def _optional_decimal(raw: str) -> Decimal | None:
text = raw.strip()
if not text:
return None
try:
return Decimal(text)
except InvalidOperation as exc:
raise QuoteFetchError(f"Tencent quote value is not numeric: {raw}") from exc