diff --git a/src/grid_trading/domain/models.py b/src/grid_trading/domain/models.py index f23cbba..a16bd95 100644 --- a/src/grid_trading/domain/models.py +++ b/src/grid_trading/domain/models.py @@ -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 diff --git a/src/grid_trading/market/__init__.py b/src/grid_trading/market/__init__.py new file mode 100644 index 0000000..ec4ccf0 --- /dev/null +++ b/src/grid_trading/market/__init__.py @@ -0,0 +1 @@ +"""Market data providers.""" diff --git a/src/grid_trading/market/tencent.py b/src/grid_trading/market/tencent.py new file mode 100644 index 0000000..57ca380 --- /dev/null +++ b/src/grid_trading/market/tencent.py @@ -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 diff --git a/tests/test_tencent_quotes.py b/tests/test_tencent_quotes.py new file mode 100644 index 0000000..35412ce --- /dev/null +++ b/tests/test_tencent_quotes.py @@ -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")