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

View File

@@ -0,0 +1,66 @@
from decimal import Decimal
import pytest
from grid_trading.domain.models import Instrument
from grid_trading.market.tencent import (
QuoteFetchError,
TencentQuoteProvider,
infer_tencent_symbol,
parse_tencent_response,
)
def test_infer_tencent_symbol_from_a_share_code():
assert infer_tencent_symbol("000001") == "sz000001"
assert infer_tencent_symbol("600000") == "sh600000"
assert infer_tencent_symbol("510300") == "sh510300"
assert infer_tencent_symbol("sz000001") == "sz000001"
def test_parse_tencent_response_extracts_realtime_fields():
raw = (
'v_sz000001="51~平安银行~000001~10.60~10.47~10.44~0~0~0~'
'10.60~0~10.59~0~10.58~0~10.57~0~10.56~0~10.61~0~10.62~0~'
'10.63~0~10.64~0~10.65~0~~20260708161430~0.13~1.24~10.63~10.34";'
).encode("gbk")
[quote] = parse_tencent_response(raw)
assert quote.symbol == "sz000001"
assert quote.code == "000001"
assert quote.name == "平安银行"
assert quote.price == Decimal("10.60")
assert quote.previous_close == Decimal("10.47")
assert quote.open_price == Decimal("10.44")
assert quote.change == Decimal("0.13")
assert quote.change_percent == Decimal("1.24")
assert quote.high == Decimal("10.63")
assert quote.low == Decimal("10.34")
assert quote.quote_time == "20260708161430"
assert quote.source == "tencent"
def test_parse_tencent_response_rejects_missing_price():
raw = 'v_sz000001="51~平安银行~000001~~~";'.encode("gbk")
with pytest.raises(QuoteFetchError, match="price"):
parse_tencent_response(raw)
def test_provider_builds_batch_query_and_maps_by_code():
calls: list[str] = []
raw = (
'v_sz000001="51~平安银行~000001~10.60~10.47~10.44~0~0~0~'
'0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~~20260708161430~0.13~1.24~10.63~10.34";'
).encode("gbk")
def fake_fetch(url: str, timeout: float) -> bytes:
calls.append(url)
return raw
provider = TencentQuoteProvider(fetcher=fake_fetch)
quotes = provider.fetch_quotes([Instrument(id=1, code="000001", name="平安银行")])
assert "q=sz000001" in calls[0]
assert quotes["000001"].price == Decimal("10.60")