feat: add Tencent quote parser
This commit is contained in:
@@ -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
|
||||
|
||||
1
src/grid_trading/market/__init__.py
Normal file
1
src/grid_trading/market/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Market data providers."""
|
||||
122
src/grid_trading/market/tencent.py
Normal file
122
src/grid_trading/market/tencent.py
Normal 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
|
||||
66
tests/test_tencent_quotes.py
Normal file
66
tests/test_tencent_quotes.py
Normal 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")
|
||||
Reference in New Issue
Block a user