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
|
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)
|
@dataclass(frozen=True)
|
||||||
class PositionSummary:
|
class PositionSummary:
|
||||||
instrument_id: int
|
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