fix: fall back to latest SGE trading price
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 = """
|
||||
<table>
|
||||
<tr><th>序号</th><th>日期</th><th>合约</th><th>开盘价</th><th>收盘价</th></tr>
|
||||
<tr><td>1</td><td>2026-09-17</td><td>Au99.99</td><td>937.50</td><td>934.81</td></tr>
|
||||
<tr><td>2</td><td>2026-09-18</td><td>Au99.99</td><td>937.00</td><td>947.09</td></tr>
|
||||
<tr><td>3</td><td>2026-09-16</td><td>Au99.99</td><td>-</td><td>-</td></tr>
|
||||
</table>
|
||||
"""
|
||||
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 = {}
|
||||
|
||||
Reference in New Issue
Block a user