Files
set50-system/backend/app/prices.py
Kunthawat Greethong 2e492b375a [verified] Extend price universe to full SET50 (49 symbols + index) so simulation allocates across all names
- DEFAULT_SYMBOL_MAP now covers full SET50 (from Siamchart master snapshot) instead of 8 names
- Collected real Yahoo price snapshot: 49 symbols + SET50, ~646 bars each (2024-01-01 → 2026-08-24), quality=revised_vendor_history point_in_time=false
- Full suite 185 OK; live simulation HTTP 200
2026-08-25 17:04:06 +07:00

1221 lines
62 KiB
Python

"""Daily SET price snapshots from Yahoo Finance Chart API.
This provider is for research plumbing only. Historical vendor responses are
revision-prone and are deliberately marked ``point_in_time=false``.
"""
from __future__ import annotations
import copy
import hashlib
import json
import math
import re
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Mapping
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, build_opener
from zoneinfo import ZoneInfo
DEFAULT_SYMBOL_MAP = {
"AOT": "AOT.BK",
"MINT": "MINT.BK",
"AWC": "AWC.BK",
"CPN": "CPN.BK",
"CPALL": "CPALL.BK",
"CRC": "CRC.BK",
"BEM": "BEM.BK",
"PTT": "PTT.BK",
# Full SET50 universe (from Siamchart master snapshot) so the capital
# simulation can allocate across all names, not just the 8 above.
"ADVANC": "ADVANC.BK",
"BANPU": "BANPU.BK",
"BBL": "BBL.BK",
"BDMS": "BDMS.BK",
"BGRIM": "BGRIM.BK",
"BH": "BH.BK",
"BTS": "BTS.BK",
"CBG": "CBG.BK",
"CENTEL": "CENTEL.BK",
"COM7": "COM7.BK",
"CPF": "CPF.BK",
"DELTA": "DELTA.BK",
"EA": "EA.BK",
"EGCO": "EGCO.BK",
"GLOBAL": "GLOBAL.BK",
"GPSC": "GPSC.BK",
"GULF": "GULF.BK",
"HMPRO": "HMPRO.BK",
"IVL": "IVL.BK",
"JMART": "JMART.BK",
"JMT": "JMT.BK",
"KBANK": "KBANK.BK",
"KTB": "KTB.BK",
"KTC": "KTC.BK",
"LH": "LH.BK",
"MTC": "MTC.BK",
"OR": "OR.BK",
"OSP": "OSP.BK",
"PTTEP": "PTTEP.BK",
"PTTGC": "PTTGC.BK",
"RATCH": "RATCH.BK",
"SAWAD": "SAWAD.BK",
"SCB": "SCB.BK",
"SCC": "SCC.BK",
"SCGP": "SCGP.BK",
"TIDLOR": "TIDLOR.BK",
"TISCO": "TISCO.BK",
"TOP": "TOP.BK",
"TRUE": "TRUE.BK",
"TTB": "TTB.BK",
"TU": "TU.BK",
"SET50": "^SET.BK",
}
PRICE_SCHEMA_VERSION = 1
PRICE_PARSER_VERSION = "yahoo-chart-v1"
NORMALIZED_HASH_ALGORITHM = "sha256-json-canonical-v1"
PIT_ARCHIVE_CONTRACT = "pit-daily-v1"
MAX_PAYLOAD_BYTES = 5_000_000
_PROVIDER_SYMBOL_RE = re.compile(r"^[A-Za-z0-9^._-]{1,64}$")
_SNAPSHOT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_HASH_RE = re.compile(r"^[a-f0-9]{64}$")
_OBSERVATION_ID_RE = re.compile(r"^price-observation-[a-f0-9]{24}$")
_OBSERVATION_STATUSES = frozenset({"initial", "unchanged", "revised"})
OBSERVATION_HISTORY_CONTRACT = "price-observation-v1"
_OBSERVATION_DIFF_KEYS = frozenset(
{"raw_payload_changed", "normalized_content_changed", "added_paths", "removed_paths", "changed_paths"}
)
class PriceSourceError(RuntimeError):
"""Raised when a price payload cannot be trusted or normalized."""
def _parse_date(value: str) -> date:
try:
return date.fromisoformat(value)
except (TypeError, ValueError) as exc:
raise PriceSourceError("price period must be YYYY-MM-DD") from exc
def _parse_timestamp(value: Any, field: str) -> datetime:
if not isinstance(value, str):
raise PriceSourceError(f"{field} must be an ISO-8601 timestamp")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise PriceSourceError(f"{field} must be an ISO-8601 timestamp") from exc
if parsed.tzinfo is None:
raise PriceSourceError(f"{field} must include a timezone")
return parsed.astimezone(timezone.utc)
def _require_text(value: Any, field: str) -> str:
if not isinstance(value, str) or not value.strip():
raise PriceSourceError(f"point-in-time archive {field} is required")
return value
def _finite_number(value: Any, field: str, *, minimum: float) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise PriceSourceError(f"{field} is invalid")
try:
parsed = float(value)
except (OverflowError, TypeError, ValueError) as exc:
raise PriceSourceError(f"{field} is invalid") from exc
if not math.isfinite(parsed) or parsed < minimum:
raise PriceSourceError(f"{field} is invalid")
return parsed
def _validate_pit_archive_snapshot(snapshot: Mapping[str, Any]) -> None:
"""Validate the evidence-bearing contract required for PIT price data."""
source = snapshot.get("source")
if not isinstance(source, dict):
raise PriceSourceError("point-in-time archive source metadata is required")
if source.get("point_in_time") is not True:
raise PriceSourceError("point-in-time archive flag is not true")
if source.get("quality") != "point_in_time_archive" or source.get("archive_contract") != PIT_ARCHIVE_CONTRACT:
raise PriceSourceError("point-in-time archive contract is required")
for field in ("source_id", "source_url", "provider_release_id", "parser_version"):
_require_text(source.get(field), field)
retrieved_at = _parse_timestamp(source.get("retrieved_at"), "retrieved_at")
period_start_value = source.get("period_start")
period_end_value = source.get("period_end")
if not isinstance(period_start_value, str) or not isinstance(period_end_value, str):
raise PriceSourceError("point-in-time archive period is invalid")
period_start = _parse_date(period_start_value)
period_end = _parse_date(period_end_value)
if period_end < period_start:
raise PriceSourceError("point-in-time archive period is invalid")
evidence = source.get("point_in_time_evidence")
if not isinstance(evidence, dict):
raise PriceSourceError("point-in-time archive evidence is required")
if evidence.get("known_at_field") != "known_at" or evidence.get("known_at_semantics") != "provider_release_time":
raise PriceSourceError("point-in-time archive known_at evidence is invalid")
if evidence.get("provider_release_id") != source.get("provider_release_id"):
raise PriceSourceError("point-in-time archive release evidence mismatch")
release_published_at = _parse_timestamp(evidence.get("release_published_at"), "release_published_at")
if release_published_at > retrieved_at:
raise PriceSourceError("point-in-time archive release was retrieved before publication")
series = snapshot.get("series")
if not isinstance(series, dict) or not series:
raise PriceSourceError("point-in-time archive series is required")
for symbol, raw_series in series.items():
if not isinstance(symbol, str) or not symbol.strip() or not isinstance(raw_series, dict):
raise PriceSourceError("point-in-time archive series entry is invalid")
if raw_series.get("canonical_symbol") != symbol:
raise PriceSourceError(f"point-in-time archive symbol identity mismatch for {symbol}")
timezone_name = _require_text(raw_series.get("timezone"), f"timezone for {symbol}")
try:
market_zone = ZoneInfo(timezone_name)
except Exception as exc:
raise PriceSourceError(f"point-in-time archive timezone is invalid for {symbol}") from exc
bars = raw_series.get("bars")
if not isinstance(bars, list) or not bars:
raise PriceSourceError(f"point-in-time archive bars are missing for {symbol}")
previous_date: date | None = None
seen_dates: set[date] = set()
for bar in bars:
if not isinstance(bar, dict):
raise PriceSourceError(f"point-in-time archive bar is invalid for {symbol}")
session_date_value = bar.get("session_date")
if not isinstance(session_date_value, str):
raise PriceSourceError(f"point-in-time archive session_date is invalid for {symbol}")
try:
session_date = _parse_date(session_date_value)
except PriceSourceError as exc:
raise PriceSourceError(f"point-in-time archive session_date is invalid for {symbol}") from exc
if session_date < period_start or session_date > period_end:
raise PriceSourceError(f"point-in-time archive session_date is outside period for {symbol}")
if session_date in seen_dates or (previous_date is not None and session_date <= previous_date):
raise PriceSourceError(f"point-in-time archive session dates are not strictly increasing for {symbol}")
seen_dates.add(session_date)
previous_date = session_date
known_at = _parse_timestamp(bar.get("known_at"), "known_at")
if known_at.astimezone(market_zone).date() > session_date:
raise PriceSourceError(f"point-in-time archive known_at is after session_date for {symbol}")
if known_at > retrieved_at:
raise PriceSourceError(f"point-in-time archive known_at is after retrieved_at for {symbol}")
for field in ("open", "high", "low", "close", "adjusted_close"):
parsed_value = _finite_number(bar.get(field), f"point-in-time archive {field} for {symbol}", minimum=0.0)
if parsed_value <= 0:
raise PriceSourceError(f"point-in-time archive {field} is invalid for {symbol}")
_finite_number(bar.get("volume"), f"point-in-time archive volume for {symbol}", minimum=0.0)
def _combined_hash(raw_payloads: Mapping[str, bytes]) -> str:
digest = hashlib.sha256()
for symbol in sorted(raw_payloads):
digest.update(symbol.encode("utf-8"))
digest.update(b"\0")
digest.update(raw_payloads[symbol])
digest.update(b"\0")
return digest.hexdigest()
def _normalized_snapshot_hash(snapshot: dict[str, Any]) -> str:
payload = copy.deepcopy(snapshot)
source = payload.get("source")
if isinstance(source, dict):
source.pop("normalized_snapshot_hash", None)
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
_VOLATILE_DIFF_KEYS = frozenset(
{
"retrieved_at",
"raw_payload_hash",
"raw_payload_files",
"snapshot_file",
"snapshot_id",
"normalized_snapshot_hash",
"normalized_hash_algorithm",
}
)
def _revision_comparison_payload(snapshot: Mapping[str, Any]) -> Any:
"""Return snapshot content with transport and identity metadata removed."""
if isinstance(snapshot, dict):
return {
key: _revision_comparison_payload(value)
for key, value in snapshot.items()
if key not in _VOLATILE_DIFF_KEYS
}
if isinstance(snapshot, list):
return [_revision_comparison_payload(value) for value in snapshot]
return snapshot
def _diff_paths(before: Any, after: Any, path: str = "") -> tuple[list[str], list[str], list[str]]:
added: list[str] = []
removed: list[str] = []
changed: list[str] = []
if isinstance(before, dict) and isinstance(after, dict):
for key in sorted(set(before) | set(after)):
child_path = f"{path}.{key}" if path else str(key)
if key not in before:
added.append(child_path)
elif key not in after:
removed.append(child_path)
else:
child_added, child_removed, child_changed = _diff_paths(before[key], after[key], child_path)
added.extend(child_added)
removed.extend(child_removed)
changed.extend(child_changed)
return added, removed, changed
if isinstance(before, list) and isinstance(after, list):
shared_length = min(len(before), len(after))
for index in range(shared_length):
child_added, child_removed, child_changed = _diff_paths(before[index], after[index], f"{path}[{index}]")
added.extend(child_added)
removed.extend(child_removed)
changed.extend(child_changed)
for index in range(shared_length, len(before)):
removed.append(f"{path}[{index}]")
for index in range(shared_length, len(after)):
added.append(f"{path}[{index}]")
return added, removed, changed
if before != after:
changed.append(path or "$")
return added, removed, changed
def _snapshot_diff(before: Mapping[str, Any], after: Mapping[str, Any], *, raw_changed: bool | None) -> dict[str, Any]:
added, removed, changed = _diff_paths(_revision_comparison_payload(before), _revision_comparison_payload(after))
return {
"raw_payload_changed": raw_changed,
"normalized_content_changed": bool(added or removed or changed),
"added_paths": added,
"removed_paths": removed,
"changed_paths": changed,
}
def _observation_id(payload: Mapping[str, Any]) -> str:
return "price-observation-" + hashlib.sha256(
json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()[:24]
def _validate_observation_entry(entry: Mapping[str, Any]) -> None:
required = {
"observation_id",
"snapshot_id",
"source_id",
"period_start",
"period_end",
"retrieved_at",
"raw_payload_hash",
"previous_snapshot_id",
"previous_raw_payload_hash",
"revision_status",
"diff",
}
if not isinstance(entry, dict) or not required.issubset(entry):
raise PriceSourceError("price manifest observation entry is incomplete")
observation_id = entry.get("observation_id")
snapshot_id = entry.get("snapshot_id")
source_id = entry.get("source_id")
period_start = entry.get("period_start")
period_end = entry.get("period_end")
retrieved_at = entry.get("retrieved_at")
raw_payload_hash = entry.get("raw_payload_hash")
previous_snapshot_id = entry.get("previous_snapshot_id")
previous_raw_payload_hash = entry.get("previous_raw_payload_hash")
revision_status = entry.get("revision_status")
diff = entry.get("diff")
if not isinstance(observation_id, str) or not _OBSERVATION_ID_RE.fullmatch(observation_id):
raise PriceSourceError("price manifest observation entry has an invalid observation_id")
if not isinstance(snapshot_id, str) or not _SNAPSHOT_ID_RE.fullmatch(snapshot_id):
raise PriceSourceError("price manifest observation entry has an invalid snapshot_id")
if not isinstance(source_id, str) or not source_id.strip():
raise PriceSourceError("price manifest observation entry has an invalid source_id")
if not isinstance(period_start, str) or not isinstance(period_end, str):
raise PriceSourceError("price manifest observation entry has an invalid period")
parsed_start = _parse_date(period_start)
parsed_end = _parse_date(period_end)
if parsed_end < parsed_start:
raise PriceSourceError("price manifest observation entry has an invalid period")
_parse_timestamp(retrieved_at, "observation retrieved_at")
if not isinstance(raw_payload_hash, str) or not _HASH_RE.fullmatch(raw_payload_hash):
raise PriceSourceError("price manifest observation entry has an invalid raw_payload_hash")
if previous_snapshot_id is not None and (not isinstance(previous_snapshot_id, str) or not _SNAPSHOT_ID_RE.fullmatch(previous_snapshot_id)):
raise PriceSourceError("price manifest observation entry has an invalid previous_snapshot_id")
if previous_raw_payload_hash is not None and (not isinstance(previous_raw_payload_hash, str) or not _HASH_RE.fullmatch(previous_raw_payload_hash)):
raise PriceSourceError("price manifest observation entry has an invalid previous_raw_payload_hash")
if revision_status not in _OBSERVATION_STATUSES:
raise PriceSourceError("price manifest observation entry has an invalid revision_status")
if not isinstance(diff, dict) or set(diff) != _OBSERVATION_DIFF_KEYS:
raise PriceSourceError("price manifest observation entry has an invalid diff")
if diff["raw_payload_changed"] is not None and not isinstance(diff["raw_payload_changed"], bool):
raise PriceSourceError("price manifest observation diff has an invalid raw_payload_changed flag")
if not isinstance(diff["normalized_content_changed"], bool):
raise PriceSourceError("price manifest observation diff has an invalid normalized_content_changed flag")
for field in ("added_paths", "removed_paths", "changed_paths"):
paths = diff[field]
if not isinstance(paths, list) or any(not isinstance(path, str) or not path for path in paths) or paths != sorted(set(paths)):
raise PriceSourceError(f"price manifest observation diff has invalid {field}")
if previous_snapshot_id is None:
if previous_raw_payload_hash is not None or revision_status != "initial" or diff != _snapshot_diff({}, {}, raw_changed=None):
raise PriceSourceError("price manifest observation predecessor chain metadata is invalid")
elif previous_raw_payload_hash is None or revision_status == "initial" or diff["raw_payload_changed"] is None:
raise PriceSourceError("price manifest revised observation predecessor metadata is invalid")
payload = {key: value for key, value in entry.items() if key != "observation_id"}
if observation_id != _observation_id(payload):
raise PriceSourceError("price manifest observation integrity validation failed")
def _observation_order_base(entry: Mapping[str, Any]) -> tuple[datetime, str]:
# Retrieval timestamps can collide; snapshot ID gives equal-time observations a stable order.
return (_parse_timestamp(entry["retrieved_at"], "observation retrieved_at"), str(entry["snapshot_id"]))
def _observation_order_key(entry: Mapping[str, Any]) -> tuple[datetime, str, str]:
return (*_observation_order_base(entry), str(entry["observation_id"]))
def _earliest_timestamp(values: list[str]) -> str:
return min(values, key=lambda value: _parse_timestamp(value, "observation timestamp"))
def _latest_timestamp(values: list[str]) -> str:
return max(values, key=lambda value: _parse_timestamp(value, "observation timestamp"))
def _raw_filename(provider_symbol: str) -> str:
if not _PROVIDER_SYMBOL_RE.fullmatch(provider_symbol):
raise PriceSourceError("invalid provider symbol")
safe = provider_symbol.replace("^", "index_").replace(".", "_")
return f"{safe}.json"
def _validate_non_pit_snapshot_shape(snapshot: Mapping[str, Any]) -> None:
if snapshot.get("schema_version") != PRICE_SCHEMA_VERSION:
raise PriceSourceError("price snapshot schema is unsupported")
source = snapshot.get("source")
if not isinstance(source, dict):
raise PriceSourceError("price snapshot source metadata is required")
if source.get("point_in_time") is not False:
raise PriceSourceError("revised price snapshot point_in_time must be false")
for field in ("source_id", "quality"):
if not isinstance(source.get(field), str) or not source[field].strip():
raise PriceSourceError(f"price snapshot {field} is required")
_parse_timestamp(source.get("retrieved_at"), "retrieved_at")
period_start_value = source.get("period_start")
period_end_value = source.get("period_end")
if not isinstance(period_start_value, str) or not isinstance(period_end_value, str):
raise PriceSourceError("price snapshot period is invalid")
period_start = _parse_date(period_start_value)
period_end = _parse_date(period_end_value)
if period_end < period_start:
raise PriceSourceError("price snapshot period is invalid")
series = snapshot.get("series")
if not isinstance(series, dict) or not series:
raise PriceSourceError("price snapshot normalized series is required")
for canonical_symbol, raw_series in series.items():
if not isinstance(canonical_symbol, str) or not canonical_symbol.strip() or not isinstance(raw_series, dict):
raise PriceSourceError("price snapshot normalized series entry is invalid")
if raw_series.get("canonical_symbol") != canonical_symbol:
raise PriceSourceError("price snapshot normalized series identity mismatch")
provider_symbol = raw_series.get("provider_symbol")
if not isinstance(provider_symbol, str) or not _PROVIDER_SYMBOL_RE.fullmatch(provider_symbol):
raise PriceSourceError("price snapshot normalized series provider symbol is invalid")
raw_payload_hash = raw_series.get("raw_payload_hash")
if not isinstance(raw_payload_hash, str) or not _HASH_RE.fullmatch(raw_payload_hash):
raise PriceSourceError("price snapshot normalized series raw hash is invalid")
timezone_name = raw_series.get("timezone")
if not isinstance(timezone_name, str) or not timezone_name.strip():
raise PriceSourceError("price snapshot normalized series timezone is invalid")
try:
ZoneInfo(timezone_name)
except Exception as exc:
raise PriceSourceError("price snapshot normalized series timezone is invalid") from exc
bars = raw_series.get("bars")
if not isinstance(bars, list) or not bars:
raise PriceSourceError("price snapshot normalized series bars are invalid")
previous_date: date | None = None
seen_dates: set[date] = set()
for bar in bars:
if not isinstance(bar, dict):
raise PriceSourceError("price snapshot normalized series bar is invalid")
session_date = bar.get("session_date", bar.get("date"))
if not isinstance(session_date, str):
raise PriceSourceError("price snapshot normalized series bar date is invalid")
parsed_date = _parse_date(session_date)
if parsed_date < period_start or parsed_date > period_end:
raise PriceSourceError("price snapshot normalized series bar date is outside period")
if parsed_date in seen_dates or (previous_date is not None and parsed_date <= previous_date):
raise PriceSourceError("price snapshot normalized series bar dates are not strictly increasing")
seen_dates.add(parsed_date)
previous_date = parsed_date
close = bar.get("close")
if _provider_float(close, "close", str(provider_symbol)) <= 0:
raise PriceSourceError("price snapshot normalized series close is invalid")
for field in ("open", "high", "low", "adjusted_close"):
if field in bar:
value = bar.get(field)
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise PriceSourceError(f"price snapshot normalized series {field} is invalid")
try:
parsed = float(value)
except (OverflowError, TypeError, ValueError) as exc:
raise PriceSourceError(f"price snapshot normalized series {field} is invalid") from exc
if not math.isfinite(parsed) or parsed <= 0:
raise PriceSourceError(f"price snapshot normalized series {field} is invalid")
if "volume" in bar:
volume = bar.get("volume")
if isinstance(volume, bool) or not isinstance(volume, (int, float)):
raise PriceSourceError("price snapshot normalized series volume is invalid")
try:
parsed_volume = float(volume)
except (OverflowError, TypeError, ValueError) as exc:
raise PriceSourceError("price snapshot normalized series volume is invalid") from exc
if not math.isfinite(parsed_volume) or parsed_volume < 0:
raise PriceSourceError("price snapshot normalized series volume is invalid")
def _validate_series_raw_lineage(snapshot: Mapping[str, Any], raw_payloads: Mapping[str, bytes]) -> None:
source = snapshot.get("source")
series = snapshot.get("series")
if not isinstance(source, dict) or not isinstance(series, dict):
raise PriceSourceError("price snapshot series lineage is invalid")
raw_files = source.get("raw_payload_files")
if not isinstance(raw_files, dict) or set(raw_files) != {str(item.get("provider_symbol")) for item in series.values() if isinstance(item, dict)}:
raise PriceSourceError("price snapshot series/raw payload lineage mismatch")
providers: set[str] = set()
for raw_series in series.values():
if not isinstance(raw_series, dict):
raise PriceSourceError("price snapshot series lineage is invalid")
provider_symbol = raw_series.get("provider_symbol")
raw_payload_hash = raw_series.get("raw_payload_hash")
if not isinstance(provider_symbol, str) or not _PROVIDER_SYMBOL_RE.fullmatch(provider_symbol) or provider_symbol in providers:
raise PriceSourceError("price snapshot series provider symbol lineage mismatch")
if provider_symbol not in raw_payloads or raw_files.get(provider_symbol) != _raw_filename(provider_symbol):
raise PriceSourceError("price snapshot series provider symbol lineage mismatch")
if not isinstance(raw_payload_hash, str) or not _HASH_RE.fullmatch(raw_payload_hash) or hashlib.sha256(raw_payloads[provider_symbol]).hexdigest() != raw_payload_hash:
raise PriceSourceError("price snapshot series raw hash lineage mismatch")
providers.add(provider_symbol)
if providers != set(raw_payloads):
raise PriceSourceError("price snapshot series/raw payload lineage mismatch")
def _atomic_write(path: Path, content: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.tmp")
temporary.write_bytes(content)
temporary.replace(path)
def _provider_float(value: Any, field: str, provider_symbol: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise PriceSourceError(f"invalid Yahoo {field} for {provider_symbol}")
try:
parsed = float(value)
except (OverflowError, TypeError, ValueError) as exc:
raise PriceSourceError(f"invalid Yahoo {field} for {provider_symbol}") from exc
if not math.isfinite(parsed):
raise PriceSourceError(f"invalid Yahoo {field} for {provider_symbol}")
return parsed
def normalize_yahoo_chart(
payload: dict[str, Any],
*,
canonical_symbol: str,
provider_symbol: str,
retrieved_at: str,
raw_payload_hash: str,
) -> dict[str, Any]:
if not isinstance(payload, dict):
raise PriceSourceError("Yahoo chart payload must be an object")
try:
chart = payload["chart"]
result = chart["result"][0]
meta = result["meta"]
timestamps = result["timestamp"]
quote = result["indicators"]["quote"][0]
adjusted = result["indicators"]["adjclose"][0]["adjclose"]
except (KeyError, IndexError, TypeError) as exc:
raise PriceSourceError("Yahoo chart result is missing required fields") from exc
if not isinstance(meta, dict) or not isinstance(quote, dict) or not isinstance(timestamps, list) or not timestamps or not isinstance(adjusted, list):
raise PriceSourceError("Yahoo chart result has no price rows")
if meta.get("exchangeName") != "SET":
raise PriceSourceError(f"unexpected exchange for {provider_symbol}")
timezone_name = str(meta.get("exchangeTimezoneName") or "Asia/Bangkok")
try:
market_zone = ZoneInfo(timezone_name)
except Exception as exc:
raise PriceSourceError("Yahoo chart returned an unknown exchange timezone") from exc
bars: list[dict[str, Any]] = []
seen_dates: set[str] = set()
def at(values: Any, index: int) -> Any:
return values[index] if isinstance(values, list) and index < len(values) else None
opens = quote.get("open", [])
highs = quote.get("high", [])
lows = quote.get("low", [])
closes = quote.get("close", [])
volumes = quote.get("volume", [])
for index, timestamp in enumerate(timestamps):
raw_close = at(closes, index)
raw_adjusted_close = at(adjusted, index)
if timestamp is None or raw_close is None or raw_adjusted_close is None:
continue
timestamp_value = _provider_float(timestamp, "timestamp", provider_symbol)
close = _provider_float(raw_close, "close", provider_symbol)
adjusted_close = _provider_float(raw_adjusted_close, "adjusted_close", provider_symbol)
if close <= 0 or adjusted_close <= 0:
raise PriceSourceError(f"non-positive Yahoo close for {provider_symbol}")
try:
trading_date = datetime.fromtimestamp(timestamp_value, tz=timezone.utc).astimezone(market_zone).date().isoformat()
except (OverflowError, OSError, ValueError) as exc:
raise PriceSourceError(f"invalid Yahoo timestamp for {provider_symbol}") from exc
if trading_date in seen_dates:
raise PriceSourceError(f"duplicate Yahoo trading date for {provider_symbol}")
seen_dates.add(trading_date)
bar: dict[str, Any] = {
"date": trading_date,
"open": at(opens, index),
"high": at(highs, index),
"low": at(lows, index),
"close": round(close, 8),
"adjusted_close": round(adjusted_close, 8),
"volume": at(volumes, index),
}
for key in ("open", "high", "low"):
if bar[key] is not None:
value = _provider_float(bar[key], key, provider_symbol)
if value <= 0:
raise PriceSourceError(f"invalid Yahoo {key} for {provider_symbol}")
bar[key] = round(value, 8)
if bar["volume"] is not None:
volume = _provider_float(bar["volume"], "volume", provider_symbol)
if volume < 0:
raise PriceSourceError(f"invalid Yahoo volume for {provider_symbol}")
bar["volume"] = int(volume)
bars.append(bar)
if not bars:
raise PriceSourceError(f"Yahoo chart has no usable rows for {provider_symbol}")
bars.sort(key=lambda bar: bar["date"])
return {
"canonical_symbol": canonical_symbol,
"provider_symbol": provider_symbol,
"exchange": meta["exchangeName"],
"currency": meta.get("currency"),
"timezone": timezone_name,
"retrieved_at": retrieved_at,
"raw_payload_hash": raw_payload_hash,
"bars": bars,
}
def trading_dates(series: Mapping[str, Any]) -> list[str]:
return [bar["date"] for bar in series.get("bars", [])]
class YahooChartProvider:
"""Minimal public Yahoo chart API client with bounded response reads."""
def __init__(self, *, opener: Any | None = None, timeout: float = 30.0) -> None:
self.opener = opener or build_opener()
self.timeout = timeout
def fetch_series(self, canonical_symbol: str, provider_symbol: str, start: str, end: str) -> tuple[dict[str, Any], bytes]:
start_date = _parse_date(start)
end_date = _parse_date(end)
if end_date <= start_date:
raise PriceSourceError("price end must be after price start")
period1 = int(datetime.combine(start_date, datetime.min.time(), tzinfo=timezone.utc).timestamp())
period2 = int(datetime.combine(end_date + timedelta(days=1), datetime.min.time(), tzinfo=timezone.utc).timestamp())
query = urlencode({"period1": period1, "period2": period2, "interval": "1d", "events": "div,splits", "includeAdjustedClose": "true"})
url = f"https://query1.finance.yahoo.com/v8/finance/chart/{provider_symbol}?{query}"
request = Request(url, headers={"User-Agent": "SET50-Alternative-Data-Platform/0.4"})
try:
with self.opener.open(request, timeout=self.timeout) as response:
if getattr(response, "status", 200) >= 400:
raise PriceSourceError(f"Yahoo price source returned HTTP {response.status}")
raw_bytes = response.read(MAX_PAYLOAD_BYTES + 1)
except PriceSourceError:
raise
except (HTTPError, URLError, TimeoutError, OSError) as exc:
raise PriceSourceError(f"Yahoo price source request failed: {exc.__class__.__name__}") from exc
if len(raw_bytes) > MAX_PAYLOAD_BYTES:
raise PriceSourceError("Yahoo price payload exceeds size limit")
try:
payload = json.loads(raw_bytes)
except json.JSONDecodeError as exc:
raise PriceSourceError("Yahoo price payload is not valid JSON") from exc
retrieved_at = datetime.now(timezone.utc).isoformat()
normalized = normalize_yahoo_chart(
payload,
canonical_symbol=canonical_symbol,
provider_symbol=provider_symbol,
retrieved_at=retrieved_at,
raw_payload_hash=hashlib.sha256(raw_bytes).hexdigest(),
)
return normalized, raw_bytes
class PriceSnapshotStore:
"""Filesystem store for normalized price snapshots and raw provider payloads."""
def __init__(self, root: Path | str) -> None:
self.root = Path(root).resolve()
self.raw_dir = self.root / "raw"
self.snapshot_dir = self.root / "snapshots"
self.manifest_path = self.root / "manifest.json"
def load_manifest(self) -> dict[str, Any]:
if not self.manifest_path.is_file():
return {"schema_version": PRICE_SCHEMA_VERSION, "snapshots": {}}
try:
manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise PriceSourceError("price manifest is unreadable") from exc
if not isinstance(manifest, dict) or manifest.get("schema_version") != PRICE_SCHEMA_VERSION or not isinstance(manifest.get("snapshots"), dict):
raise PriceSourceError("unsupported price manifest schema")
observations = manifest.setdefault("observations", [])
if not isinstance(observations, list):
raise PriceSourceError("price manifest observations must be a list")
for observation in observations:
if not isinstance(observation, dict):
raise PriceSourceError("price manifest observation entry is invalid")
_validate_observation_entry(observation)
self._validate_observation_history(manifest)
return manifest
def _validate_observation_history(
self,
manifest: Mapping[str, Any],
snapshot_overrides: Mapping[str, Mapping[str, Any]] | None = None,
) -> None:
observations = manifest.get("observations", [])
snapshots = manifest.get("snapshots", {})
if not isinstance(observations, list) or not isinstance(snapshots, dict):
raise PriceSourceError("price manifest observation history is invalid")
snapshot_overrides = snapshot_overrides or {}
seen_observation_ids: set[str] = set()
observations_by_snapshot: dict[str, list[dict[str, Any]]] = {}
for observation in observations:
if not isinstance(observation, dict):
raise PriceSourceError("price manifest observation entry is invalid")
observation_id = observation["observation_id"]
if observation_id in seen_observation_ids:
raise PriceSourceError("price manifest observation history contains duplicate observation_id")
seen_observation_ids.add(observation_id)
snapshot_id = observation["snapshot_id"]
manifest_entry = snapshots.get(snapshot_id)
if not isinstance(manifest_entry, dict):
raise PriceSourceError("price manifest observation references an unknown snapshot")
if manifest_entry.get("snapshot_id", snapshot_id) != snapshot_id:
raise PriceSourceError("price manifest observation snapshot identity mismatch")
for field in ("source_id", "period_start", "period_end", "raw_payload_hash"):
if manifest_entry.get(field) != observation.get(field):
reason = "point-in-time metadata" if manifest_entry.get("point_in_time") is True else "observation metadata"
raise PriceSourceError(f"price manifest {reason} mismatch")
observations_by_snapshot.setdefault(snapshot_id, []).append(observation)
for snapshot_id, manifest_entry in snapshots.items():
if not isinstance(manifest_entry, dict):
raise PriceSourceError("price snapshot manifest entry is invalid")
observations_for_snapshot = observations_by_snapshot.get(snapshot_id, [])
declared_count = manifest_entry.get("observation_count")
first_seen = manifest_entry.get("first_seen_at")
last_seen = manifest_entry.get("last_seen_at")
has_timing_metadata = first_seen is not None or last_seen is not None
observation_contract = manifest_entry.get("observation_history_contract")
if observation_contract is None and declared_count is None and not has_timing_metadata and not observations_for_snapshot:
continue
if observation_contract != OBSERVATION_HISTORY_CONTRACT:
raise PriceSourceError("price manifest observation history contract is invalid")
try:
if has_timing_metadata:
_parse_timestamp(first_seen, "observation first_seen_at")
_parse_timestamp(last_seen, "observation last_seen_at")
except PriceSourceError as exc:
raise PriceSourceError("price manifest observation timing metadata is invalid") from exc
if declared_count is None or isinstance(declared_count, bool) or not isinstance(declared_count, int) or declared_count < 1 or declared_count != len(observations_for_snapshot):
raise PriceSourceError("price manifest observation count mismatch")
retrieved_values = [str(item["retrieved_at"]) for item in observations_for_snapshot]
expected_first = _earliest_timestamp(retrieved_values)
expected_last = _latest_timestamp(retrieved_values)
if first_seen != expected_first or last_seen != expected_last:
raise PriceSourceError("price manifest observation timing metadata mismatch")
snapshot_cache: dict[str, dict[str, Any]] = {}
def load_verified_snapshot(snapshot_id: str) -> dict[str, Any]:
cached = snapshot_cache.get(snapshot_id)
if cached is None:
override = snapshot_overrides.get(snapshot_id)
cached = dict(override) if override is not None else self._load_snapshot(snapshot_id, manifest)
snapshot_cache[snapshot_id] = cached
return cached
for observation in observations:
snapshot_id = observation["snapshot_id"]
current_snapshot = load_verified_snapshot(snapshot_id)
current_source = current_snapshot.get("source")
if not isinstance(current_source, dict):
raise PriceSourceError("price manifest observation snapshot source metadata is invalid")
snapshot_manifest_entry = snapshots.get(snapshot_id)
if not isinstance(snapshot_manifest_entry, dict):
raise PriceSourceError("price manifest observation snapshot entry is invalid")
snapshot_retrieved_at = _parse_timestamp(snapshot_manifest_entry.get("retrieved_at"), "snapshot retrieved_at")
observation_retrieved_at = _parse_timestamp(observation["retrieved_at"], "observation retrieved_at")
if observation_retrieved_at < snapshot_retrieved_at:
raise PriceSourceError("price manifest observation was recorded before snapshot first capture")
for field in ("source_id", "period_start", "period_end", "raw_payload_hash"):
if current_source.get(field) != observation.get(field):
raise PriceSourceError("price manifest observation snapshot metadata mismatch")
previous_snapshot_id = observation["previous_snapshot_id"]
current_base = _observation_order_base(observation)
if any(
candidate is not observation
and (
candidate["source_id"],
candidate["period_start"],
candidate["period_end"],
)
== (
observation["source_id"],
observation["period_start"],
observation["period_end"],
)
and _observation_order_base(candidate) == current_base
for candidate in observations
):
raise PriceSourceError("price manifest contains ambiguous observation timestamp")
candidates = [
candidate
for candidate in observations
if candidate is not observation
and (
candidate["source_id"],
candidate["period_start"],
candidate["period_end"],
)
== (
observation["source_id"],
observation["period_start"],
observation["period_end"],
)
and _observation_order_base(candidate) < current_base
]
expected_previous = max(candidates, key=_observation_order_key, default=None)
if expected_previous is None:
if previous_snapshot_id is not None:
raise PriceSourceError("price manifest observation predecessor chain mismatch")
elif previous_snapshot_id != expected_previous["snapshot_id"]:
raise PriceSourceError("price manifest observation predecessor chain mismatch")
previous_snapshot = None
if previous_snapshot_id is not None:
previous_entry = snapshots.get(previous_snapshot_id)
if not isinstance(previous_entry, dict):
raise PriceSourceError("price manifest observation references an unknown previous snapshot")
if previous_entry.get("raw_payload_hash") != observation["previous_raw_payload_hash"]:
raise PriceSourceError("price manifest previous raw payload hash mismatch")
if (
previous_entry.get("source_id"),
previous_entry.get("period_start"),
previous_entry.get("period_end"),
) != (
observation["source_id"],
observation["period_start"],
observation["period_end"],
):
raise PriceSourceError("price manifest previous snapshot scope mismatch")
previous_snapshot = load_verified_snapshot(previous_snapshot_id)
previous_source = previous_snapshot.get("source")
if not isinstance(previous_source, dict):
raise PriceSourceError("price manifest previous snapshot source metadata is invalid")
for field in ("source_id", "period_start", "period_end", "raw_payload_hash"):
if previous_source.get(field) != previous_entry.get(field):
raise PriceSourceError("price manifest previous snapshot metadata mismatch")
expected_diff = (
_snapshot_diff(
previous_snapshot,
current_snapshot,
raw_changed=observation["raw_payload_hash"] != observation["previous_raw_payload_hash"],
)
if previous_snapshot is not None
else _snapshot_diff(current_snapshot, current_snapshot, raw_changed=None)
)
if observation["diff"] != expected_diff:
raise PriceSourceError("price manifest observation diff validation failed")
expected_status = "initial" if previous_snapshot is None else ("revised" if expected_diff["normalized_content_changed"] else "unchanged")
if observation["revision_status"] != expected_status:
raise PriceSourceError("price manifest observation revision status mismatch")
def _snapshot_path(self, snapshot_id: str) -> Path:
if not isinstance(snapshot_id, str) or not _SNAPSHOT_ID_RE.fullmatch(snapshot_id):
raise PriceSourceError("invalid price snapshot id")
candidate = (self.snapshot_dir / f"{snapshot_id}.json").resolve()
if candidate.parent != self.snapshot_dir.resolve():
raise PriceSourceError("price snapshot path escaped store root")
return candidate
def load_snapshot(self, snapshot_id: str) -> dict[str, Any]:
manifest = self.load_manifest()
return self._load_snapshot(snapshot_id, manifest)
def _load_snapshot(self, snapshot_id: str, manifest: Mapping[str, Any]) -> dict[str, Any]:
snapshot_path = self._snapshot_path(snapshot_id)
if not snapshot_path.is_file():
raise FileNotFoundError(snapshot_id)
try:
snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise PriceSourceError("price snapshot is unreadable") from exc
if not isinstance(snapshot, dict):
raise PriceSourceError("price snapshot must be an object")
source = snapshot.get("source")
if not isinstance(source, dict) or source.get("snapshot_id") != snapshot_id:
raise PriceSourceError("price snapshot identity mismatch")
if not isinstance(source.get("point_in_time"), bool):
raise PriceSourceError("price snapshot metadata point_in_time must be boolean")
raw_files = source.get("raw_payload_files")
if not isinstance(raw_files, dict) or not raw_files:
raise PriceSourceError("price snapshot raw payload manifest is missing")
raw_payloads: dict[str, bytes] = {}
raw_root = (self.raw_dir / snapshot_id).resolve()
if raw_root.parent != self.raw_dir.resolve():
raise PriceSourceError("price raw path escaped store root")
for provider_symbol, filename in raw_files.items():
if _raw_filename(str(provider_symbol)) != filename:
raise PriceSourceError("price raw filename mismatch")
raw_path = (raw_root / filename).resolve()
if raw_path.parent != raw_root or not raw_path.is_file():
raise PriceSourceError("price raw payload is missing")
raw_payloads[str(provider_symbol)] = raw_path.read_bytes()
actual_hash = _combined_hash(raw_payloads)
if actual_hash != source.get("raw_payload_hash"):
raise PriceSourceError("price raw payload hash mismatch")
if source.get("point_in_time") is True:
_validate_pit_archive_snapshot(snapshot)
else:
_validate_non_pit_snapshot_shape(snapshot)
_validate_series_raw_lineage(snapshot, raw_payloads)
declared_snapshot_hash = source.get("normalized_snapshot_hash")
if source.get("normalized_hash_algorithm") != NORMALIZED_HASH_ALGORITHM or not isinstance(declared_snapshot_hash, str) or _normalized_snapshot_hash(snapshot) != declared_snapshot_hash:
raise PriceSourceError("price normalized snapshot hash mismatch")
manifest_entry = manifest.get("snapshots", {}).get(snapshot_id)
if not isinstance(manifest_entry, dict):
raise PriceSourceError("price manifest entry is missing")
if manifest_entry.get("snapshot_id") != snapshot_id or manifest_entry.get("snapshot_file") != snapshot_path.name:
raise PriceSourceError("price manifest metadata mismatch")
if manifest_entry.get("source_id") != source.get("source_id") or manifest_entry.get("quality") != source.get("quality"):
raise PriceSourceError("price manifest metadata mismatch")
if manifest_entry.get("point_in_time") is not source.get("point_in_time"):
raise PriceSourceError("price manifest metadata mismatch")
if manifest_entry.get("raw_payload_hash") != actual_hash or manifest_entry.get("normalized_snapshot_hash") != declared_snapshot_hash or manifest_entry.get("normalized_hash_algorithm") != NORMALIZED_HASH_ALGORITHM:
raise PriceSourceError("price manifest hash mismatch")
metadata_error = "price manifest point-in-time metadata mismatch" if source.get("point_in_time") is True else "price manifest metadata mismatch"
hardened_entry = manifest_entry.get("observation_history_contract") == OBSERVATION_HISTORY_CONTRACT
for field in (
"source_url",
"retrieved_at",
"period_start",
"period_end",
"parser_version",
"archive_contract",
"provider_release_id",
"point_in_time_evidence",
"adjusted_prices",
"return_price_field",
"snapshot_file",
):
if (field in manifest_entry or hardened_entry) and manifest_entry.get(field) != source.get(field):
raise PriceSourceError(metadata_error)
if ("symbols" in manifest_entry or hardened_entry) and manifest_entry.get("symbols") != sorted(snapshot.get("series", {}).keys()):
raise PriceSourceError(metadata_error)
return snapshot
def list_snapshots(self) -> list[dict[str, Any]]:
entries = list(self.load_manifest().get("snapshots", {}).values())
return sorted(
entries,
key=lambda item: (
_parse_timestamp(item.get("last_seen_at", item.get("retrieved_at")), "snapshot retrieved_at"),
str(item.get("snapshot_id", "")),
),
)
def latest_snapshot_entry(self) -> dict[str, Any] | None:
entries = self.list_snapshots()
return entries[-1] if entries else None
def list_observations(self) -> list[dict[str, Any]]:
observations = list(self.load_manifest().get("observations", []))
return sorted(observations, key=_observation_order_key)
def _validate_observation_append_order(
self,
manifest: Mapping[str, Any],
snapshot: Mapping[str, Any],
*,
raw_hash: str,
snapshot_id: str,
) -> dict[str, Any] | None:
source = snapshot.get("source")
if not isinstance(source, dict):
raise PriceSourceError("price snapshot source metadata is required")
retrieved_at = source.get("retrieved_at")
if not isinstance(retrieved_at, str) or not retrieved_at.strip():
raise PriceSourceError("price snapshot retrieved_at is required")
current_base = (_parse_timestamp(retrieved_at, "observation retrieved_at"), snapshot_id)
scope = (
str(source.get("source_id") or ""),
str(source.get("period_start") or ""),
str(source.get("period_end") or ""),
)
observations = manifest.get("observations", [])
matching = [
item
for item in observations
if isinstance(item, dict)
and (
str(item.get("source_id") or ""),
str(item.get("period_start") or ""),
str(item.get("period_end") or ""),
)
== scope
]
for item in matching:
if (
item.get("snapshot_id") == snapshot_id
and item.get("retrieved_at") == retrieved_at
and item.get("raw_payload_hash") == raw_hash
):
return item
latest = max(matching, key=_observation_order_key, default=None)
if latest is not None:
latest_base = _observation_order_base(latest)
if current_base < latest_base:
raise PriceSourceError("cannot append out-of-order observation with an immutable predecessor chain")
if current_base == latest_base:
raise PriceSourceError("cannot append ambiguous observation with an immutable predecessor chain")
return None
def _append_observation(
self,
manifest: dict[str, Any],
snapshot: Mapping[str, Any],
*,
raw_hash: str,
snapshot_id: str,
) -> dict[str, Any] | None:
source = snapshot.get("source")
if not isinstance(source, dict):
return None
retrieved_at = source.get("retrieved_at")
if not isinstance(retrieved_at, str) or not retrieved_at.strip():
return None
_parse_timestamp(retrieved_at, "observation retrieved_at")
duplicate = self._validate_observation_append_order(
manifest,
snapshot,
raw_hash=raw_hash,
snapshot_id=snapshot_id,
)
if duplicate is not None:
return duplicate
scope = (
str(source.get("source_id") or ""),
str(source.get("period_start") or ""),
str(source.get("period_end") or ""),
)
observations = manifest.setdefault("observations", [])
matching = [
item
for item in observations
if isinstance(item, dict)
and (
str(item.get("source_id") or ""),
str(item.get("period_start") or ""),
str(item.get("period_end") or ""),
)
== scope
]
current_base = (_parse_timestamp(retrieved_at, "observation retrieved_at"), snapshot_id)
previous = max(
(
item
for item in matching
if _observation_order_base(item) < current_base
),
key=_observation_order_key,
default=None,
)
previous_snapshot: dict[str, Any] | None = None
previous_raw_hash: str | None = None
previous_snapshot_id: str | None = None
if previous is not None:
previous_snapshot_id = str(previous.get("snapshot_id") or "") or None
previous_raw_hash = str(previous.get("raw_payload_hash") or "") or None
if previous_snapshot_id:
try:
previous_snapshot = self.load_snapshot(previous_snapshot_id)
except (FileNotFoundError, PriceSourceError) as exc:
raise PriceSourceError("previous price snapshot integrity validation failed") from exc
diff = (
_snapshot_diff(
previous_snapshot,
snapshot,
raw_changed=previous_raw_hash != raw_hash,
)
if previous_snapshot is not None
else _snapshot_diff(snapshot, snapshot, raw_changed=None)
)
if previous_snapshot is not None and previous_raw_hash == raw_hash and diff["normalized_content_changed"]:
raise PriceSourceError("cannot change normalized content for an unchanged raw payload")
revision_status = "initial" if previous is None else ("revised" if diff["normalized_content_changed"] else "unchanged")
observation_without_id = {
"snapshot_id": snapshot_id,
"source_id": source.get("source_id"),
"period_start": source.get("period_start"),
"period_end": source.get("period_end"),
"retrieved_at": retrieved_at,
"raw_payload_hash": raw_hash,
"previous_snapshot_id": previous_snapshot_id,
"previous_raw_payload_hash": previous_raw_hash,
"revision_status": revision_status,
"diff": diff,
}
observation_id = _observation_id(observation_without_id)
observation = {"observation_id": observation_id, **observation_without_id}
_validate_observation_entry(observation)
if not any(isinstance(item, dict) and item.get("observation_id") == observation_id for item in observations):
observations.append(observation)
manifest_entry = manifest["snapshots"].get(snapshot_id)
if isinstance(manifest_entry, dict):
first_seen = str(manifest_entry.get("first_seen_at") or retrieved_at)
manifest_entry["first_seen_at"] = _earliest_timestamp([first_seen, retrieved_at])
last_seen = str(manifest_entry.get("last_seen_at") or retrieved_at)
manifest_entry["last_seen_at"] = _latest_timestamp([last_seen, retrieved_at])
manifest_entry["observation_count"] = sum(
1 for item in observations if isinstance(item, dict) and item.get("snapshot_id") == snapshot_id
)
return observation
def persist(self, snapshot: dict[str, Any], raw_payloads: Mapping[str, bytes]) -> dict[str, Any]:
source = snapshot.get("source")
if not isinstance(source, dict) or not source.get("snapshot_id"):
raise PriceSourceError("price snapshot source metadata is required")
if not raw_payloads or any(not isinstance(symbol, str) or not _PROVIDER_SYMBOL_RE.fullmatch(symbol) for symbol in raw_payloads) or any(not isinstance(value, bytes) or not value for value in raw_payloads.values()):
raise PriceSourceError("price raw payloads must be non-empty bytes")
raw_hash = _combined_hash(raw_payloads)
if source.get("raw_payload_hash") and source["raw_payload_hash"] != raw_hash:
raise PriceSourceError("raw payload hash does not match price snapshot")
if "point_in_time" in source and not isinstance(source["point_in_time"], bool):
raise PriceSourceError("price snapshot metadata point_in_time must be boolean")
stored = copy.deepcopy(snapshot)
stored_source = stored["source"]
snapshot_id = str(stored_source["snapshot_id"])
if not _SNAPSHOT_ID_RE.fullmatch(snapshot_id):
raise PriceSourceError("invalid price snapshot id")
stored_source["raw_payload_hash"] = raw_hash
stored_source["raw_payload_files"] = {symbol: _raw_filename(symbol) for symbol in raw_payloads}
stored_source["snapshot_file"] = f"{snapshot_id}.json"
stored_source["normalized_hash_algorithm"] = NORMALIZED_HASH_ALGORITHM
stored_source["normalized_snapshot_hash"] = _normalized_snapshot_hash(stored)
manifest = self.load_manifest()
if stored_source.get("point_in_time") is True:
_validate_pit_archive_snapshot(stored)
else:
_validate_non_pit_snapshot_shape(stored)
_validate_series_raw_lineage(stored, raw_payloads)
existing_entry = manifest["snapshots"].get(snapshot_id)
existing_snapshot: dict[str, Any] | None = None
if existing_entry is not None:
if not isinstance(existing_entry, dict):
raise PriceSourceError("price manifest entry is invalid")
if existing_entry.get("raw_payload_hash") != raw_hash:
raise PriceSourceError("cannot overwrite immutable price snapshot")
existing_snapshot = self.load_snapshot(snapshot_id)
if _revision_comparison_payload(existing_snapshot) != _revision_comparison_payload(stored):
raise PriceSourceError("cannot overwrite immutable price snapshot")
staged_manifest = copy.deepcopy(manifest)
if existing_entry is None:
staged_manifest["snapshots"][snapshot_id] = {
"snapshot_id": snapshot_id,
"source_id": stored_source.get("source_id"),
"retrieved_at": stored_source.get("retrieved_at"),
"period_start": stored_source.get("period_start"),
"period_end": stored_source.get("period_end"),
"raw_payload_hash": raw_hash,
"normalized_snapshot_hash": stored_source["normalized_snapshot_hash"],
"normalized_hash_algorithm": NORMALIZED_HASH_ALGORITHM,
"parser_version": stored_source.get("parser_version"),
"quality": stored_source.get("quality"),
"point_in_time": stored_source.get("point_in_time"),
"archive_contract": stored_source.get("archive_contract"),
"provider_release_id": stored_source.get("provider_release_id"),
"source_url": stored_source.get("source_url"),
"point_in_time_evidence": stored_source.get("point_in_time_evidence"),
"adjusted_prices": stored_source.get("adjusted_prices"),
"return_price_field": stored_source.get("return_price_field"),
"symbols": sorted(stored.get("series", {}).keys()),
"snapshot_file": stored_source["snapshot_file"],
"observation_history_contract": OBSERVATION_HISTORY_CONTRACT,
}
self._append_observation(staged_manifest, stored, raw_hash=raw_hash, snapshot_id=snapshot_id)
self._validate_observation_history(staged_manifest, snapshot_overrides={snapshot_id: stored})
if existing_entry is None:
for provider_symbol, payload in raw_payloads.items():
_atomic_write(self.raw_dir / snapshot_id / _raw_filename(provider_symbol), payload)
_atomic_write(self.snapshot_dir / stored_source["snapshot_file"], (json.dumps(stored, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8"))
_atomic_write(self.manifest_path, (json.dumps(staged_manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8"))
return existing_snapshot if existing_snapshot is not None else stored
def collect_price_snapshot(
root: Path | str,
*,
start: str,
end: str,
symbol_map: Mapping[str, str] = DEFAULT_SYMBOL_MAP,
provider: YahooChartProvider | Any | None = None,
retrieved_at: str | None = None,
) -> dict[str, Any]:
if not symbol_map:
raise PriceSourceError("symbol_map must not be empty")
provider = provider or YahooChartProvider()
normalized: dict[str, Any] = {}
raw_payloads: dict[str, bytes] = {}
for canonical_symbol, provider_symbol in symbol_map.items():
series, raw_bytes = provider.fetch_series(canonical_symbol, provider_symbol, start, end)
normalized[canonical_symbol] = series
raw_payloads[provider_symbol] = raw_bytes
raw_hash = _combined_hash(raw_payloads)
retrieved = retrieved_at or datetime.now(timezone.utc).isoformat()
snapshot_id = f"prices-yahoo-chart-{start}-{end}-{raw_hash[:12]}"
source = {
"source_id": "yahoo.chart",
"source_url": "https://query1.finance.yahoo.com/v8/finance/chart/{provider_symbol}",
"retrieved_at": retrieved,
"period_start": start,
"period_end": end,
"raw_payload_hash": raw_hash,
"parser_version": PRICE_PARSER_VERSION,
"quality": "revised_vendor_history",
"point_in_time": False,
"adjusted_prices": True,
"return_price_field": "close",
"snapshot_id": snapshot_id,
"bar_counts": {symbol: len(series["bars"]) for symbol, series in normalized.items()},
}
snapshot = {"schema_version": PRICE_SCHEMA_VERSION, "source": source, "series": normalized, "benchmark_symbol": "SET50" if "SET50" in normalized else None}
return PriceSnapshotStore(root).persist(snapshot, raw_payloads)