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 = """ +
| 序号 | 日期 | 合约 | 开盘价 | 收盘价 |
|---|---|---|---|---|
| 1 | 2026-09-17 | Au99.99 | 937.50 | 934.81 |
| 2 | 2026-09-18 | Au99.99 | 937.00 | 947.09 |
| 3 | 2026-09-16 | Au99.99 | - | - |