From ed123b710ff13068c89d51b6f2b895cf069b7ece Mon Sep 17 00:00:00 2001 From: bboysoul Date: Sun, 20 Sep 2026 08:33:55 +0800 Subject: [PATCH] fix: fall back to latest SGE trading price --- backend/today_environment.py | 58 +++++++++++++++++++++++++++++++-- tests/test_today_environment.py | 55 +++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/backend/today_environment.py b/backend/today_environment.py index 5a0ad3a..671e169 100644 --- a/backend/today_environment.py +++ b/backend/today_environment.py @@ -19,11 +19,16 @@ from .lunar_support import solar_to_lunar_text SHANGHAI_TZ = ZoneInfo("Asia/Shanghai") WEATHER_URL = "https://api.open-meteo.com/v1/forecast" GOLD_URL = "https://www.sge.com.cn/sjzx/yshqbg" +GOLD_HISTORY_URL = ( + "https://www.sge.com.cn/sjzx/quotation_daily_new" + "?start_date={start_date}&end_date={end_date}" +) WEATHER_SOURCE = "Open-Meteo" GOLD_SOURCE = "上海黄金交易所" WEEKDAYS = ("星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日") SOURCE_TIMEOUT_SECONDS = 3.0 -TOTAL_TIMEOUT_SECONDS = 3.5 +GOLD_SOURCE_TIMEOUT_SECONDS = 6.0 +TOTAL_TIMEOUT_SECONDS = 6.5 WEATHER_TTL = timedelta(minutes=15) WEATHER_STALE_TTL = timedelta(hours=6) GOLD_TTL = timedelta(minutes=5) @@ -107,6 +112,36 @@ def parse_sge_au9999(html: str) -> tuple[Decimal, str]: raise ValueError("Au99.99 quote not found") +def parse_sge_au9999_history(html: str) -> tuple[Decimal, str]: + rows = [[_text(cell) for cell in _CELL_RE.findall(row)] for row in _ROW_RE.findall(html)] + header = next( + (cells for cells in rows if "日期" in cells and "合约" in cells and "收盘价" in cells), + None, + ) + if header is None: + raise ValueError("SGE history table header not found") + date_index = header.index("日期") + contract_index = header.index("合约") + price_index = header.index("收盘价") + required_length = max(date_index, contract_index, price_index) + 1 + quotes = [] + for cells in rows[rows.index(header) + 1 :]: + if len(cells) < required_length or cells[contract_index] != "Au99.99": + continue + try: + market_date = date.fromisoformat(cells[date_index]).isoformat() + price = Decimal(cells[price_index].replace(",", "")) + except (InvalidOperation, ValueError): + continue + if not price.is_finite() or price <= 0: + continue + quotes.append((market_date, price)) + if not quotes: + raise ValueError("Au99.99 history quote not found") + market_date, price = max(quotes) + return price, market_date + + async def fetch_weather(request: Callable[..., Awaitable[dict[str, Any]]]) -> dict[str, Any]: payload = await request( WEATHER_URL, @@ -158,7 +193,22 @@ async def fetch_weather(request: Callable[..., Awaitable[dict[str, Any]]]) -> di async def fetch_gold(request: Callable[..., Awaitable[str]]) -> dict[str, Any]: - price, market_date = parse_sge_au9999(await request(GOLD_URL)) + html = await request(GOLD_URL) + try: + price, market_date = parse_sge_au9999(html) + except ValueError: + date_match = _DATE_RE.search(_text(html)) + if date_match is None: + raise + try: + quote_date = date(*map(int, date_match.groups())) + except ValueError as exc: + raise ValueError("invalid SGE market date") from exc + start_date = quote_date - timedelta(days=31) + history_url = GOLD_HISTORY_URL.format( + start_date=start_date.isoformat(), end_date=quote_date.isoformat() + ) + price, market_date = parse_sge_au9999_history(await request(history_url)) return { "contract": "Au99.99", "latest_price": price, @@ -192,7 +242,9 @@ async def _refresh_source( fetched_at: datetime, generation: int, ) -> dict[str, Any]: - value = await asyncio.wait_for(fetcher(), timeout=SOURCE_TIMEOUT_SECONDS) + value = await asyncio.wait_for( + fetcher(), timeout=GOLD_SOURCE_TIMEOUT_SECONDS if name == "gold" else SOURCE_TIMEOUT_SECONDS + ) with _state_guard: if generation == _generation: newest = _cache.get(name) diff --git a/tests/test_today_environment.py b/tests/test_today_environment.py index 93abb8c..24ed390 100644 --- a/tests/test_today_environment.py +++ b/tests/test_today_environment.py @@ -56,6 +56,61 @@ def test_parse_sge_rejects_changed_structure_or_invalid_quote(html): environment.parse_sge_au9999(html) +@pytest.mark.asyncio +async def test_fetch_gold_falls_back_to_latest_trading_day_when_delayed_quote_is_zero(): + delayed_html = SGE_HTML.replace("2026年09月16日", "2026年09月20日").replace( + "935.99", "0.0" + ) + history_html = """ + + + + + +
序号日期合约开盘价收盘价
12026-09-17Au99.99937.50934.81
22026-09-18Au99.99937.00947.09
32026-09-16Au99.99--
+ """ + seen = [] + + async def request(url, **_kwargs): + seen.append(url) + return delayed_html if url == environment.GOLD_URL else history_html + + result = await environment.fetch_gold(request) + + assert seen == [ + environment.GOLD_URL, + environment.GOLD_HISTORY_URL.format(start_date="2026-08-20", end_date="2026-09-20"), + ] + assert result["latest_price"] == Decimal("947.09") + assert result["market_date"] == "2026-09-18" + + +@pytest.mark.asyncio +async def test_gold_source_has_time_for_two_sequential_upstream_requests(monkeypatch): + environment.clear_cache() + monkeypatch.setattr(environment, "SOURCE_TIMEOUT_SECONDS", 0.01) + monkeypatch.setattr(environment, "GOLD_SOURCE_TIMEOUT_SECONDS", 0.05, raising=False) + monkeypatch.setattr(environment, "TOTAL_TIMEOUT_SECONDS", 0.06) + + async def weather(): + return {"temperature_c": 25, "source": "Open-Meteo"} + + async def gold(): + await asyncio.sleep(0.03) + return { + "contract": "Au99.99", + "latest_price": Decimal("947.09"), + "market_date": "2026-09-18", + "delayed": True, + "source": "上海黄金交易所", + } + + result = await environment.get_environment(weather_fetcher=weather, gold_fetcher=gold) + + assert result["gold"]["latest_price"] == "947.09" + assert result["errors"] == {} + + @pytest.mark.asyncio async def test_fetch_weather_uses_fixed_haishu_location_and_current_conditions(): seen = {}