Files
AlphaPulse/backend/src/alphapulse/calendar/jin10.py
T
eezd adb73f956b
Development CI / check-and-build (push) Successful in 2m4s
GitHub Git mirror / mirror (push) Successful in 6s
Development CI / deploy-development (push) Successful in 2m30s
fix: harden news analysis and deploy verified image tags
2026-11-02 15:37:42 +08:00

151 lines
7.2 KiB
Python

"""Normalize authenticated Jin10 calendar responses without interpreting gold/USD signals."""
import base64
import binascii
import json
import re
import zlib
from dataclasses import dataclass
from datetime import UTC, date, datetime, time, timedelta
from zoneinfo import ZoneInfo
LOCAL = ZoneInfo("Asia/Shanghai")
START = date(2025, 1, 1)
KINDS = ("data", "event", "holiday")
COUNTRY_CODES = {
"美国": "US", "欧元区": "EA", "英国": "GB", "德国": "DE", "法国": "FR",
"中国": "CN", "中国香港": "HK", "中国台湾": "TW", "日本": "JP", "韩国": "KR",
"加拿大": "CA", "澳大利亚": "AU", "新西兰": "NZ", "瑞士": "CH", "瑞典": "SE",
"挪威": "NO", "意大利": "IT", "西班牙": "ES", "印度": "IN", "俄罗斯": "RU",
"巴西": "BR", "南非": "ZA", "新加坡": "SG", "乌克兰": "UA", "墨西哥": "MX",
"土耳其": "TR", "波兰": "PL", "印度尼西亚": "ID", "马来西亚": "MY", "泰国": "TH",
}
@dataclass(frozen=True)
class Entry:
identity: str
at: datetime
title: str
values: dict
def day_url(day: date) -> str:
return f"https://rili.jin10.com/day/{day.isoformat()}/"
def records(body: dict, kind: str, day: date) -> list[dict]:
if kind not in KINDS or not isinstance(body, dict) or body.get("date") != day.isoformat():
raise ValueError("金十响应日期或类别与请求不符")
rows = body.get("list")
if kind == "holiday":
# The public SDK's legacy daily holiday method returns zlib/base64 text.
if not isinstance(rows, str) or len(rows) > 2_000_000:
raise ValueError("金十假期响应格式已变化")
try:
decoder = zlib.decompressobj()
decoded = decoder.decompress(base64.b64decode(rows, validate=True), 4_000_000)
if not decoder.eof or decoder.unused_data or decoder.unconsumed_tail:
raise ValueError("金十假期压缩内容不完整或过大")
rows = json.loads(decoded)
except (ValueError, zlib.error, binascii.Error) as exc:
raise ValueError("金十假期内容无法解析") from exc
if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
raise ValueError("金十日历列表格式已变化")
return rows
def text(row: dict, key: str, *, nullable: bool = False) -> str | None:
value = row.get(key)
if nullable and value is None:
return None
if not isinstance(value, str) or not value.strip():
raise ValueError(f"金十字段 {key} 缺失或类型错误")
return value.strip()
def identifier(row: dict, key: str) -> int:
value = row.get(key)
if type(value) is not int or value <= 0:
raise ValueError(f"金十字段 {key} 不是有效标识")
return value
def normalize(body: dict, kind: str, day: date) -> list[Entry]:
if day < START:
raise ValueError("金十历史范围不得早于2025-01-01北京时间")
result, identities = [], set()
for raw in records(body, kind, day):
identity = f"{kind}:{identifier(raw, 'data_id' if kind == 'data' else 'id')}"
if identity in identities:
raise ValueError("金十同一响应存在重复事件标识")
identities.add(identity)
country = text(raw, "country", nullable=True)
precision = "day" if kind == "holiday" else "minute"
if kind == "holiday":
if raw.get("date") != day.isoformat():
raise ValueError("金十假期记录日期与请求不符")
at = datetime.combine(day, time.min, LOCAL)
title = text(raw, "name")
description = " · ".join(v for v in [text(raw, "exchange_name"), text(raw, "rest_note", nullable=True),
text(raw, "remark", nullable=True)] if v)
else:
timestamp = text(raw, "pub_time" if kind == "data" else "event_time")
at = datetime.strptime(timestamp, "%Y-%m-%d %H:%M").replace(tzinfo=LOCAL)
unix = raw.get("pub_time_unix" if kind == "data" else "event_time_unix")
if type(unix) is not int or not int(at.timestamp()) <= unix < int(at.timestamp()) + 60:
raise ValueError("金十公布时间与时间戳不符")
at = datetime.fromtimestamp(unix, LOCAL)
if raw.get("determine") not in (0, 1):
raise ValueError("金十时间确定性字段已变化")
if raw["determine"] == 1 or raw.get("time_status"):
precision = "tentative"
title = text(raw, "indicator_name" if kind == "data" else "event_content")
description = text(raw, "note", nullable=True) if kind == "event" else None
if kind != "data" and raw.get("category") != "cj":
raise ValueError("金十记录不属于宏观日历")
importance = raw.get("star") if kind != "holiday" else 0
if type(importance) is not int or not 0 <= importance <= 5:
raise ValueError("金十重要性字段已变化")
key = f"jin10_{identifier(raw, 'indicator_id')}" if kind == "data" else f"jin10_{kind}"
meta = {"kind": kind, "date": day.isoformat(), "precision": precision, "country_name": country or "未标注",
"country_code": COUNTRY_CODES.get(country, country or "UNKNOWN"),
"name": title, "description": description, "importance": importance,
"period_label": text(raw, "time_period", nullable=True) if kind == "data" else None,
"indicator": key, "unit": ("" if raw.get("unit") == "" else text(raw, "unit", nullable=True)) if kind == "data" else None,
"exchange": text(raw, "exchange_name") if kind == "holiday" else None}
values = {"_jin10": meta}
if kind == "data":
# Preserve nonnumeric values (including ranges); never coerce a missing value to zero.
values[key] = {"actual": text(raw, "actual", nullable=True), "previous": text(raw, "previous", nullable=True),
"forecast": text(raw, "consensus", nullable=True),
"previous_revised": text(raw, "revised", nullable=True)}
result.append(Entry(identity, at.astimezone(UTC), title, values))
return result
def period(label: str | None, at: datetime) -> str | None:
"""Resolve only verified monthly/weekly labels close to their publication date."""
local = at.astimezone(LOCAL).date()
month = re.fullmatch(r"(\d{1,2})月", label or "")
week = re.fullmatch(r"至(\d{1,2})月(\d{1,2})日", label or "")
if month:
number = int(month[1])
if not 1 <= number <= 12:
return None
year = local.year - (number > local.month)
return f"{year}-{number:02}"
if week:
try:
day = date(local.year, int(week[1]), int(week[2]))
if day > local:
day = day.replace(year=day.year - 1)
return day.isoformat() if local - day < timedelta(days=45) else None
except ValueError:
return None
quarter = re.fullmatch(r"第([一二三四])季度", label or "")
if quarter:
number = "一二三四".index(quarter[1]) + 1
year = local.year - (number > (local.month - 1) // 3 + 1)
return f"{year}-Q{number}"
return None