fix: fall back to latest SGE trading price
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 3m14s

This commit is contained in:
2026-09-20 08:33:55 +08:00
parent 8ecc3e265f
commit ed123b710f
2 changed files with 110 additions and 3 deletions
+55 -3
View File
@@ -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)
+55
View File
@@ -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 = {}