[verified] add vintage collector and point-in-time API
This commit is contained in:
18
README.md
18
README.md
@@ -59,11 +59,22 @@ Data-health and replay endpoints:
|
||||
|
||||
```text
|
||||
GET /api/v1/data-health
|
||||
GET /api/v1/vintages?as_of=<ISO-8601 timestamp>
|
||||
GET /api/v1/replay/tourism?vintage_id=<vintage_id>
|
||||
```
|
||||
|
||||
Source: `https://app.bot.or.th/BTWS_STAT/statistics/ReportPage.aspx?reportID=875&language=eng`
|
||||
|
||||
## Collect a vintage manually
|
||||
|
||||
The collector is intentionally one-shot and idempotent. Run it after a source update; it preserves raw bytes, normalized snapshots, and a manifest under `backend/data/`:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=backend .venv/bin/python backend/scripts/collect_tourism_vintage.py --root backend/data
|
||||
```
|
||||
|
||||
Repeated collection of the same source hash keeps one `vintage_id` and increments `seen_count` without changing `first_seen_at`. A new hash for the same publication timestamp is recorded as a separate `revised` vintage.
|
||||
|
||||
## Tests and build
|
||||
|
||||
```bash
|
||||
@@ -71,18 +82,19 @@ PYTHONPATH=backend .venv/bin/python -m unittest discover -s backend/tests -v
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
## Current M1 boundary
|
||||
## Current M2 boundary
|
||||
|
||||
- English UI and analysis vocabulary
|
||||
- Research mode and paper mode only
|
||||
- Tourism Pulse fixture adapter and BOT Tourism Indicators adapter
|
||||
- Data lineage: source, publication time, retrieval time, vintage
|
||||
- Raw response hash and normalized snapshot persistence
|
||||
- Read-only data-health and vintage replay endpoints
|
||||
- Immutable vintage manifest with first-seen/revision metadata
|
||||
- Read-only data-health, vintage timeline and vintage replay endpoints
|
||||
- Deterministic surprise × exposure × confidence score
|
||||
- Paper ledger endpoint
|
||||
- No LLM call yet; the deterministic result is the source of truth
|
||||
- No webhook receiver yet
|
||||
- No MT5 bridge yet
|
||||
|
||||
The next implementation step is to validate multiple BOT vintages and add the next real Tourism metric before introducing LLM analysis.
|
||||
The next implementation step is the event-study/backtest layer using only vintages whose `published_at` is known at each test date.
|
||||
|
||||
@@ -16,8 +16,9 @@ from flask import Flask, jsonify, request
|
||||
from .bot_tourism import BotTourismSource, TourismSourceError
|
||||
from .paper import PaperLedger
|
||||
from .tourism import compute_tourism_signal
|
||||
from .vintages import VintageStore, VintageStoreError
|
||||
|
||||
APP_VERSION = "0.2.0"
|
||||
APP_VERSION = "0.3.0"
|
||||
|
||||
|
||||
def _load_default_snapshot() -> dict[str, Any]:
|
||||
@@ -46,20 +47,21 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
|
||||
PAPER_SESSION_SECONDS=int(os.getenv("PAPER_SESSION_SECONDS", "3600")),
|
||||
TOURISM_SOURCE=os.getenv("TOURISM_SOURCE", "fixture"),
|
||||
TOURISM_ADAPTER=None,
|
||||
TOURISM_DATA_ROOT=Path(os.getenv("TOURISM_DATA_ROOT", str(data_root))),
|
||||
TOURISM_RAW_DIR=Path(os.getenv("TOURISM_RAW_DIR", str(data_root / "raw" / "tourism"))),
|
||||
SNAPSHOT_DIR=Path(os.getenv("TOURISM_SNAPSHOT_DIR", str(data_root / "snapshots"))),
|
||||
VINTAGE_STORE=None,
|
||||
SNAPSHOT=_load_default_snapshot(),
|
||||
)
|
||||
if config:
|
||||
app.config.update(config)
|
||||
|
||||
vintage_store = app.config.get("VINTAGE_STORE") or VintageStore(app.config["TOURISM_DATA_ROOT"])
|
||||
app.extensions["vintage_store"] = vintage_store
|
||||
source_snapshot = app.config["SNAPSHOT"]
|
||||
source_mode = str(app.config["TOURISM_SOURCE"]).lower()
|
||||
if source_mode == "bot":
|
||||
adapter = app.config.get("TOURISM_ADAPTER") or BotTourismSource(
|
||||
raw_dir=Path(app.config["TOURISM_RAW_DIR"]),
|
||||
snapshot_dir=Path(app.config["SNAPSHOT_DIR"]),
|
||||
)
|
||||
adapter = app.config.get("TOURISM_ADAPTER") or BotTourismSource(vintage_store=vintage_store)
|
||||
try:
|
||||
source_snapshot = adapter.fetch(exposures=source_snapshot.get("exposures", []))
|
||||
except TourismSourceError as exc:
|
||||
@@ -164,6 +166,15 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
|
||||
}
|
||||
)
|
||||
|
||||
@app.get("/api/v1/vintages")
|
||||
def vintages():
|
||||
as_of = request.args.get("as_of")
|
||||
try:
|
||||
entries = app.extensions["vintage_store"].list_vintages(as_of)
|
||||
except VintageStoreError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify({"as_of": as_of, "count": len(entries), "vintages": entries})
|
||||
|
||||
@app.get("/api/v1/replay/tourism")
|
||||
def replay_tourism():
|
||||
vintage_id = request.args.get("vintage_id", "")
|
||||
|
||||
@@ -19,6 +19,8 @@ from urllib.request import HTTPCookieProcessor, Request, build_opener
|
||||
|
||||
from http.cookiejar import CookieJar
|
||||
|
||||
from .vintages import VintageStore
|
||||
|
||||
BOT_TOURISM_URL = "https://app.bot.or.th/BTWS_STAT/statistics/ReportPage.aspx?reportID=875&language=eng"
|
||||
BOT_SOURCE_ID = "bot.ec_ei_028_s2"
|
||||
PARSER_VERSION = "bot-tourism-v1"
|
||||
@@ -273,6 +275,7 @@ class BotTourismSource:
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
raw_dir: Path | None = None,
|
||||
snapshot_dir: Path | None = None,
|
||||
vintage_store: VintageStore | None = None,
|
||||
) -> None:
|
||||
self.url = url
|
||||
self.timeout = timeout
|
||||
@@ -280,6 +283,7 @@ class BotTourismSource:
|
||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
self.raw_dir = raw_dir
|
||||
self.snapshot_dir = snapshot_dir
|
||||
self.vintage_store = vintage_store
|
||||
|
||||
def _open(self, request: Request) -> bytes:
|
||||
try:
|
||||
@@ -336,6 +340,8 @@ class BotTourismSource:
|
||||
source_url=self.url,
|
||||
exposures=exposures,
|
||||
)
|
||||
if self.vintage_store is not None:
|
||||
return self.vintage_store.persist(report_bytes, snapshot)
|
||||
source_meta = snapshot["source"]
|
||||
if self.raw_dir is not None:
|
||||
_atomic_write(self.raw_dir / source_meta["raw_snapshot_file"], report_bytes)
|
||||
|
||||
45
backend/app/collector.py
Normal file
45
backend/app/collector.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""One-shot Tourism vintage collector."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from .bot_tourism import BotTourismSource
|
||||
from .vintages import VintageStore
|
||||
|
||||
|
||||
def _default_exposures() -> list[dict[str, Any]]:
|
||||
fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "tourism_snapshot.json"
|
||||
snapshot = json.loads(fixture_path.read_text(encoding="utf-8"))
|
||||
return [dict(item) for item in snapshot.get("exposures", [])]
|
||||
|
||||
|
||||
def collect_bot_vintage(
|
||||
root: Path | str,
|
||||
*,
|
||||
opener: Any | None = None,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
timeout: float = 30.0,
|
||||
exposures: Iterable[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch one BOT vintage and register it in the immutable manifest."""
|
||||
|
||||
store = VintageStore(root)
|
||||
source = BotTourismSource(
|
||||
opener=opener,
|
||||
clock=clock,
|
||||
timeout=timeout,
|
||||
vintage_store=store,
|
||||
)
|
||||
snapshot = source.fetch(exposures=_default_exposures() if exposures is None else exposures)
|
||||
vintage_id = snapshot["source"]["vintage_id"]
|
||||
entry = store.load_manifest()["vintages"][vintage_id]
|
||||
return {
|
||||
"snapshot": snapshot,
|
||||
"manifest_path": str(store.manifest_path),
|
||||
"seen_count": entry["seen_count"],
|
||||
"revision_status": entry["revision_status"],
|
||||
}
|
||||
167
backend/app/vintages.py
Normal file
167
backend/app/vintages.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Immutable tourism vintage manifest and point-in-time helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
MANIFEST_SCHEMA_VERSION = 1
|
||||
_VINTAGE_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
|
||||
_FILENAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,191}\.(?:html|json)")
|
||||
|
||||
|
||||
class VintageStoreError(ValueError):
|
||||
"""Raised when a vintage cannot be safely stored or loaded."""
|
||||
|
||||
|
||||
def _parse_timestamp(value: str) -> datetime:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise VintageStoreError("timestamp must be ISO-8601") from exc
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def _safe_vintage_id(vintage_id: str) -> str:
|
||||
if not _VINTAGE_ID_RE.fullmatch(vintage_id):
|
||||
raise VintageStoreError("invalid vintage_id")
|
||||
return vintage_id
|
||||
|
||||
|
||||
def _safe_filename(filename: str, suffix: str) -> str:
|
||||
if not _FILENAME_RE.fullmatch(filename) or not filename.endswith(suffix):
|
||||
raise VintageStoreError("invalid vintage filename")
|
||||
if Path(filename).name != filename:
|
||||
raise VintageStoreError("vintage filename must not contain a path")
|
||||
return filename
|
||||
|
||||
|
||||
def filter_vintages(entries: Iterable[dict[str, Any]], as_of: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Return vintages whose publication timestamp was known by ``as_of``."""
|
||||
|
||||
cutoff = _parse_timestamp(as_of) if as_of else None
|
||||
selected = []
|
||||
for entry in entries:
|
||||
published_at = _parse_timestamp(str(entry["published_at"]))
|
||||
if cutoff is None or published_at <= cutoff:
|
||||
selected.append(copy.deepcopy(entry))
|
||||
return sorted(selected, key=lambda item: (_parse_timestamp(str(item["published_at"])), item["vintage_id"]))
|
||||
|
||||
|
||||
def _atomic_write_bytes(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 _atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
_atomic_write_bytes(path, (json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8"))
|
||||
|
||||
|
||||
class VintageStore:
|
||||
"""Filesystem-backed immutable raw/snapshot store with a compact manifest."""
|
||||
|
||||
def __init__(self, root: Path | str) -> None:
|
||||
self.root = Path(root).resolve()
|
||||
self.raw_dir = self.root / "raw" / "tourism"
|
||||
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": MANIFEST_SCHEMA_VERSION, "vintages": {}}
|
||||
try:
|
||||
payload = json.loads(self.manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise VintageStoreError("vintage manifest is unreadable") from exc
|
||||
if not isinstance(payload, dict) or payload.get("schema_version") != MANIFEST_SCHEMA_VERSION:
|
||||
raise VintageStoreError("unsupported vintage manifest schema")
|
||||
if not isinstance(payload.get("vintages"), dict):
|
||||
raise VintageStoreError("vintage manifest has invalid vintages map")
|
||||
return payload
|
||||
|
||||
def persist(self, raw_bytes: bytes, snapshot: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(raw_bytes, bytes) or not raw_bytes:
|
||||
raise VintageStoreError("raw vintage payload must be non-empty bytes")
|
||||
stored_snapshot = copy.deepcopy(snapshot)
|
||||
source = stored_snapshot.get("source")
|
||||
if not isinstance(source, dict):
|
||||
raise VintageStoreError("snapshot source metadata is required")
|
||||
vintage_id = _safe_vintage_id(str(source.get("vintage_id", "")))
|
||||
published_at = str(source.get("published_at", ""))
|
||||
retrieved_at = str(source.get("retrieved_at", ""))
|
||||
_parse_timestamp(published_at)
|
||||
_parse_timestamp(retrieved_at)
|
||||
raw_hash = hashlib.sha256(raw_bytes).hexdigest()
|
||||
declared_hash = source.get("raw_payload_hash")
|
||||
if declared_hash and declared_hash != raw_hash:
|
||||
raise VintageStoreError("raw payload hash does not match snapshot metadata")
|
||||
source["raw_payload_hash"] = raw_hash
|
||||
source["raw_snapshot_file"] = _safe_filename(
|
||||
str(source.get("raw_snapshot_file") or f"{vintage_id}.html"), ".html"
|
||||
)
|
||||
source["snapshot_file"] = _safe_filename(
|
||||
str(source.get("snapshot_file") or f"{vintage_id}.json"), ".json"
|
||||
)
|
||||
|
||||
manifest = self.load_manifest()
|
||||
existing = manifest["vintages"].get(vintage_id)
|
||||
same_release = any(
|
||||
item.get("source_id") == source.get("source_id")
|
||||
and item.get("published_at") == published_at
|
||||
and item.get("vintage_id") != vintage_id
|
||||
for item in manifest["vintages"].values()
|
||||
)
|
||||
revision_status = existing.get("revision_status") if existing else ("revised" if same_release else "initial")
|
||||
source["revision_status"] = revision_status
|
||||
stored_snapshot["source"] = source
|
||||
|
||||
_atomic_write_bytes(self.raw_dir / source["raw_snapshot_file"], raw_bytes)
|
||||
_atomic_write_json(self.snapshot_dir / source["snapshot_file"], stored_snapshot)
|
||||
|
||||
first_seen_at = existing.get("first_seen_at", retrieved_at) if existing else retrieved_at
|
||||
last_seen_at = max(_parse_timestamp(first_seen_at), _parse_timestamp(retrieved_at)).isoformat()
|
||||
manifest["vintages"][vintage_id] = {
|
||||
"vintage_id": vintage_id,
|
||||
"source_id": source.get("source_id"),
|
||||
"source_url": source.get("source_url"),
|
||||
"as_of": stored_snapshot.get("as_of"),
|
||||
"published_at": published_at,
|
||||
"release_status": source.get("release_status"),
|
||||
"revision_status": revision_status,
|
||||
"raw_payload_hash": raw_hash,
|
||||
"parser_version": source.get("parser_version"),
|
||||
"raw_snapshot_file": source["raw_snapshot_file"],
|
||||
"snapshot_file": source["snapshot_file"],
|
||||
"first_seen_at": first_seen_at,
|
||||
"last_seen_at": last_seen_at,
|
||||
"seen_count": int(existing.get("seen_count", 0)) + 1 if existing else 1,
|
||||
}
|
||||
_atomic_write_json(self.manifest_path, manifest)
|
||||
return stored_snapshot
|
||||
|
||||
def list_vintages(self, as_of: str | None = None) -> list[dict[str, Any]]:
|
||||
return filter_vintages(self.load_manifest()["vintages"].values(), as_of)
|
||||
|
||||
def load_snapshot(self, vintage_id: str) -> dict[str, Any]:
|
||||
vintage_id = _safe_vintage_id(vintage_id)
|
||||
snapshot_path = (self.snapshot_dir / f"{vintage_id}.json").resolve()
|
||||
if snapshot_path.parent != self.snapshot_dir.resolve():
|
||||
raise VintageStoreError("snapshot path escaped store root")
|
||||
if not snapshot_path.is_file():
|
||||
raise FileNotFoundError(vintage_id)
|
||||
try:
|
||||
snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise VintageStoreError("vintage snapshot is unreadable") from exc
|
||||
if snapshot.get("source", {}).get("vintage_id") != vintage_id:
|
||||
raise VintageStoreError("vintage snapshot identity mismatch")
|
||||
return snapshot
|
||||
44
backend/scripts/collect_tourism_vintage.py
Normal file
44
backend/scripts/collect_tourism_vintage.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Collect one live BOT Tourism vintage into the local manifest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.collector import collect_bot_vintage
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
type=Path,
|
||||
default=Path(__file__).resolve().parents[1] / "data",
|
||||
help="vintage store root (default: backend/data)",
|
||||
)
|
||||
parser.add_argument("--timeout", type=float, default=30.0)
|
||||
args = parser.parse_args()
|
||||
result = collect_bot_vintage(args.root, timeout=args.timeout)
|
||||
snapshot = result["snapshot"]
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"source_id": snapshot["source"]["source_id"],
|
||||
"vintage_id": snapshot["source"]["vintage_id"],
|
||||
"as_of": snapshot["as_of"],
|
||||
"published_at": snapshot["source"]["published_at"],
|
||||
"release_status": snapshot["source"]["release_status"],
|
||||
"seen_count": result["seen_count"],
|
||||
"revision_status": result["revision_status"],
|
||||
"manifest_path": result["manifest_path"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from app import create_app
|
||||
from app.vintages import VintageStore
|
||||
|
||||
|
||||
class ApiTests(unittest.TestCase):
|
||||
@@ -94,6 +95,23 @@ class ApiTests(unittest.TestCase):
|
||||
self.assertEqual(body["source_id"], "bot.ec_ei_028_s2")
|
||||
self.assertEqual(body["status"], "provisional")
|
||||
|
||||
def test_vintages_endpoint_filters_by_publication_timestamp(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
store = VintageStore(Path(temp_dir))
|
||||
store.persist(b"fixture raw", self.snapshot)
|
||||
app = create_app({"TESTING": True, "SNAPSHOT": self.snapshot, "VINTAGE_STORE": store})
|
||||
client = app.test_client()
|
||||
before = client.get("/api/v1/vintages?as_of=2026-08-20T00:00:00Z")
|
||||
after = client.get("/api/v1/vintages?as_of=2026-08-22T00:00:00Z")
|
||||
self.assertEqual(before.status_code, 200)
|
||||
self.assertEqual(before.get_json()["count"], 0)
|
||||
self.assertEqual(after.status_code, 200)
|
||||
self.assertEqual(after.get_json()["count"], 1)
|
||||
|
||||
def test_vintages_endpoint_rejects_invalid_as_of(self):
|
||||
response = self.client.get("/api/v1/vintages?as_of=not-a-timestamp")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_paper_ledger_requires_token(self):
|
||||
response = self.client.post(
|
||||
"/api/v1/paper/ledger",
|
||||
|
||||
131
backend/tests/test_vintages.py
Normal file
131
backend/tests/test_vintages.py
Normal file
@@ -0,0 +1,131 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from app.bot_tourism import BotTourismSource
|
||||
from app.collector import collect_bot_vintage
|
||||
from app.vintages import VintageStore, filter_vintages
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
REPORT_HTML = (FIXTURES / "bot_tourism_report.html").read_text(encoding="utf-8")
|
||||
FORM_HTML = (FIXTURES / "bot_tourism_form.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, body):
|
||||
self.body = body.encode("utf-8")
|
||||
self.status = 200
|
||||
|
||||
def read(self):
|
||||
return self.body
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
|
||||
class FakeOpener:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def open(self, request, timeout):
|
||||
body = FORM_HTML if request.data is None else REPORT_HTML
|
||||
self.calls.append({"url": request.full_url, "body": request.data, "timeout": timeout})
|
||||
return FakeResponse(body)
|
||||
|
||||
|
||||
def sample_snapshot(retrieved_at="2026-08-23T01:00:00+00:00"):
|
||||
from app.bot_tourism import parse_bot_tourism_report
|
||||
|
||||
return parse_bot_tourism_report(REPORT_HTML, retrieved_at=retrieved_at, exposures=[])
|
||||
|
||||
|
||||
class VintageStoreTests(unittest.TestCase):
|
||||
def test_manifest_preserves_first_seen_and_deduplicates_same_vintage(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
store = VintageStore(Path(temp_dir))
|
||||
first = sample_snapshot("2026-08-23T01:00:00+00:00")
|
||||
store.persist(REPORT_HTML.encode("utf-8"), first)
|
||||
second = copy.deepcopy(first)
|
||||
second["source"]["retrieved_at"] = "2026-08-24T01:00:00+00:00"
|
||||
store.persist(REPORT_HTML.encode("utf-8"), second)
|
||||
|
||||
manifest = store.load_manifest()
|
||||
self.assertEqual(len(manifest["vintages"]), 1)
|
||||
entry = next(iter(manifest["vintages"].values()))
|
||||
self.assertEqual(entry["first_seen_at"], "2026-08-23T01:00:00+00:00")
|
||||
self.assertEqual(entry["last_seen_at"], "2026-08-24T01:00:00+00:00")
|
||||
self.assertEqual(entry["seen_count"], 2)
|
||||
self.assertEqual(entry["revision_status"], "initial")
|
||||
|
||||
def test_manifest_marks_same_release_with_new_hash_as_revision(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
store = VintageStore(Path(temp_dir))
|
||||
first = sample_snapshot()
|
||||
store.persist(REPORT_HTML.encode("utf-8"), first)
|
||||
revised = copy.deepcopy(first)
|
||||
revised_raw = b"revised raw"
|
||||
revised_hash = hashlib.sha256(revised_raw).hexdigest()
|
||||
revised["source"]["raw_payload_hash"] = revised_hash
|
||||
revised["source"]["vintage_id"] = revised["source"]["vintage_id"][:-12] + revised_hash[:12]
|
||||
revised["source"]["raw_snapshot_file"] = revised["source"]["vintage_id"] + ".html"
|
||||
revised["source"]["snapshot_file"] = revised["source"]["vintage_id"] + ".json"
|
||||
store.persist(revised_raw, revised)
|
||||
|
||||
entries = list(store.load_manifest()["vintages"].values())
|
||||
self.assertEqual(len(entries), 2)
|
||||
self.assertEqual(sorted(entry["revision_status"] for entry in entries), ["initial", "revised"])
|
||||
|
||||
def test_point_in_time_filter_excludes_future_publications(self):
|
||||
entries = [
|
||||
{"vintage_id": "old", "published_at": "2026-07-31T14:30:00+07:00"},
|
||||
{"vintage_id": "future", "published_at": "2026-08-31T14:30:00+07:00"},
|
||||
]
|
||||
result = filter_vintages(entries, "2026-08-01T00:00:00+00:00")
|
||||
self.assertEqual([entry["vintage_id"] for entry in result], ["old"])
|
||||
|
||||
def test_snapshot_load_rejects_path_traversal(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
store = VintageStore(Path(temp_dir))
|
||||
with self.assertRaises(ValueError):
|
||||
store.load_snapshot("../secret")
|
||||
|
||||
def test_collector_returns_manifested_snapshot_summary(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
result = collect_bot_vintage(
|
||||
Path(temp_dir),
|
||||
opener=FakeOpener(),
|
||||
clock=lambda: datetime(2026, 8, 23, 2, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
snapshot = result["snapshot"]
|
||||
self.assertEqual(result["seen_count"], 1)
|
||||
self.assertTrue(Path(result["manifest_path"]).is_file())
|
||||
self.assertEqual(snapshot["source"]["source_id"], "bot.ec_ei_028_s2")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
store = VintageStore(root)
|
||||
source = BotTourismSource(
|
||||
opener=FakeOpener(),
|
||||
clock=lambda: datetime(2026, 8, 23, 2, 0, tzinfo=timezone.utc),
|
||||
vintage_store=store,
|
||||
)
|
||||
snapshot = source.fetch()
|
||||
manifest = store.load_manifest()
|
||||
self.assertEqual(len(manifest["vintages"]), 1)
|
||||
self.assertEqual(manifest["vintages"][snapshot["source"]["vintage_id"]]["seen_count"], 1)
|
||||
loaded = store.load_snapshot(snapshot["source"]["vintage_id"])
|
||||
self.assertEqual(loaded["source"]["vintage_id"], snapshot["source"]["vintage_id"])
|
||||
self.assertTrue((root / "raw" / "tourism").exists())
|
||||
self.assertTrue((root / "snapshots").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -5,10 +5,10 @@
|
||||
- Path: `/Users/kunthawat/Gitea/set50-alternative-data-platform`
|
||||
- Branch: `main`
|
||||
- Verified code commit: `a113a51` — `[verified] add BOT tourism source adapter`
|
||||
- Current milestone: M1 complete and independently reviewed
|
||||
- Current milestone: M2 vintage foundation complete; event study deferred
|
||||
- Mode: research + paper only
|
||||
- Frontend: Vue 3 + Vite
|
||||
- Backend: Flask `0.2.0`
|
||||
- Backend: Flask `0.3.0`
|
||||
- Current runtime source: BOT Tourism Indicators (`TOURISM_SOURCE=bot`)
|
||||
|
||||
## Completed
|
||||
@@ -19,6 +19,9 @@
|
||||
- Atomic raw HTML and normalized snapshot persistence under ignored `backend/data/`.
|
||||
- Read-only `/api/v1/data-health` endpoint.
|
||||
- Safe `/api/v1/replay/tourism?vintage_id=...` endpoint with identity and path validation.
|
||||
- Immutable `VintageStore` manifest with first-seen, last-seen, seen-count and revision metadata.
|
||||
- One-shot collector: `backend/scripts/collect_tourism_vintage.py`.
|
||||
- Point-in-time vintage query: `GET /api/v1/vintages?as_of=<ISO-8601>`.
|
||||
- Ranked target weights and LONG/SHORT/NEUTRAL classification.
|
||||
- English dashboard with live/provisional source label, sign-aware surprise copy and lineage fields.
|
||||
- HttpOnly paper session and internal paper ledger.
|
||||
@@ -50,13 +53,19 @@ npm run build
|
||||
Vite build completed successfully.
|
||||
|
||||
GET /api/v1/health
|
||||
HTTP 200; {"mode":"research","status":"ok","version":"0.2.0"}
|
||||
HTTP 200; {"mode":"research","status":"ok","version":"0.3.0"}
|
||||
|
||||
GET /api/v1/data-health
|
||||
HTTP 200; source_mode=bot, status=provisional, replayable=true
|
||||
|
||||
GET /api/v1/replay/tourism?vintage_id=bot.ec_ei_028_s2-2026-07-31-665981f88b4a
|
||||
HTTP 200; replay theme surprise matched live summary exactly.
|
||||
|
||||
GET /api/v1/vintages?as_of=2026-07-01T00:00:00Z
|
||||
HTTP 200; count=0
|
||||
|
||||
GET /api/v1/vintages?as_of=2026-08-01T00:00:00Z
|
||||
HTTP 200; count=1; manifest seen_count=4
|
||||
```
|
||||
|
||||
Paper writes use a server-side token exchange and HttpOnly `paper_session` cookie; the token is not embedded in the frontend bundle.
|
||||
@@ -67,8 +76,9 @@ Paper writes use a server-side token exchange and HttpOnly `paper_session` cooki
|
||||
- Only foreign-arrival YoY is live in this slice; occupancy and airport passenger metrics are not yet connected.
|
||||
- Snapshot storage is local filesystem and single-process; shared persistence is required before multi-worker deployment.
|
||||
- No investment edge, transaction-cost result, or backtest conclusion has been established.
|
||||
- One independent source release is not enough for a valid event study; current historical rows are not treated as point-in-time vintages.
|
||||
- Browser screenshot verification remains blocked by the Chrome remote-debugging permission prompt; served HTML/source, live API, fresh Vite build and replay integrity were verified instead.
|
||||
|
||||
## Exact next action
|
||||
|
||||
Validate multiple BOT vintages and add the next real Tourism metric without changing the snapshot contract. Run event-study/backtest checks before adding LLM analysis.
|
||||
Collect independent BOT releases over time, then implement event-study/backtest checks using only vintages whose `published_at` is known at each test date. Add another metric only when its historical release coverage is real.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|---|---|---|---|
|
||||
| M0 repo foundation | complete | Flask API, Vue/Vite shell | keep research/paper guardrails |
|
||||
| M1 BOT Tourism adapter | complete | 18 tests, live BOT fetch, raw/snapshot persistence | validate multiple vintages |
|
||||
| M2 vintage collector | complete | 25 tests, manifest idempotency, live collector and point-in-time API | collect independent releases |
|
||||
| Tourism deterministic signal | complete | live foreign-arrivals YoY surprise | add occupancy/airport metric |
|
||||
| Internal paper ledger | complete | POST/readback through live API | persist in PostgreSQL later |
|
||||
| Dashboard | complete | Vite build + served source check with live-sign copy | visual browser capture after permission is available |
|
||||
@@ -23,7 +24,7 @@
|
||||
|
||||
## Verification
|
||||
|
||||
- Backend: 18 unittest tests pass.
|
||||
- Backend: 25 unittest tests pass.
|
||||
- Independent M1 review: **PASSED**; no concrete security or logic blockers.
|
||||
- Reviewer suggestions: set `PAPER_COOKIE_SECURE=1` outside local HTTP; replace in-memory sessions before multi-worker deployment.
|
||||
- M1 reviewer backlog: add schema-drift, duplicate/reordered-row, and malformed-vintage regression fixtures.
|
||||
@@ -34,4 +35,6 @@
|
||||
- BOT source live fetch parsed 138 monthly periods and persisted raw HTML plus normalized snapshot.
|
||||
- Data-health reports `source_mode=bot`, `status=provisional`, and `replayable=true`.
|
||||
- Live vintage replay returned the same theme surprise as the current dashboard summary.
|
||||
- Vintage collector preserved one live `vintage_id` with `seen_count=4` and point-in-time API excluded it before `published_at`.
|
||||
- Independent M2 review: **PASSED**; no concrete security or logic blockers.
|
||||
- Browser visual capture was blocked by Chrome remote-debugging permission; no permission dialog was clicked.
|
||||
|
||||
82
docs/engineering-log/2026-08-23-vintage-collector.md
Normal file
82
docs/engineering-log/2026-08-23-vintage-collector.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 2026-08-23 — M2 vintage collector and point-in-time query
|
||||
|
||||
## Plan status
|
||||
|
||||
- Vintage collector: complete.
|
||||
- Immutable manifest: complete.
|
||||
- Point-in-time filtering API: complete.
|
||||
- Event study/backtest: deferred until enough independently collected releases exist.
|
||||
|
||||
## Changed files
|
||||
|
||||
- `backend/app/vintages.py` — manifest schema, safe snapshot store, first/last seen metadata, revision classification, and publication-time filtering.
|
||||
- `backend/app/collector.py` — one-shot collector function using the BOT adapter and default exposure matrix.
|
||||
- `backend/scripts/collect_tourism_vintage.py` — CLI entrypoint.
|
||||
- `backend/app/bot_tourism.py` — optional `VintageStore` persistence path.
|
||||
- `backend/app/__init__.py` — shared store wiring and `/api/v1/vintages` endpoint.
|
||||
- `backend/tests/test_vintages.py` — manifest, idempotency, revision, traversal, collector and point-in-time tests.
|
||||
- `backend/tests/test_api.py` — vintage API filter and invalid timestamp tests.
|
||||
- `README.md` — collector and M2 boundary documentation.
|
||||
|
||||
## Manifest contract
|
||||
|
||||
Each entry stores:
|
||||
|
||||
```text
|
||||
vintage_id
|
||||
source_id
|
||||
source_url
|
||||
as_of
|
||||
published_at
|
||||
release_status
|
||||
revision_status
|
||||
raw_payload_hash
|
||||
parser_version
|
||||
raw_snapshot_file
|
||||
snapshot_file
|
||||
first_seen_at
|
||||
last_seen_at
|
||||
seen_count
|
||||
```
|
||||
|
||||
Repeated collection of the same raw hash keeps one vintage and increments `seen_count`. A different hash for the same source publication timestamp becomes a separate `revised` vintage.
|
||||
|
||||
## Live evidence
|
||||
|
||||
The real collector ran twice manually and the app fetched once on restart. The manifest contains one BOT vintage with `seen_count=4`, `revision_status=initial`, `published_at=2026-07-31T14:30:00+07:00`, and the same raw hash `665981f88b4a30c5bd30026cf1e96279c244ad83725558f4136d952b29756a31`.
|
||||
|
||||
Point-in-time API checks:
|
||||
|
||||
```text
|
||||
GET /api/v1/vintages
|
||||
count: 1
|
||||
|
||||
GET /api/v1/vintages?as_of=2026-07-01T00:00:00Z
|
||||
count: 0
|
||||
|
||||
GET /api/v1/vintages?as_of=2026-08-01T00:00:00Z
|
||||
count: 1
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
- `PYTHONPATH=backend .venv/bin/python -W error -m unittest discover -s backend/tests -v` — **25 tests passed**.
|
||||
- `npm run build` — passed.
|
||||
- `npm audit --omit=dev --audit-level=high` — 0 vulnerabilities.
|
||||
- `GET /api/v1/health` — HTTP 200, version `0.3.0`.
|
||||
- `GET /api/v1/data-health` — HTTP 200, `source_mode=bot`, `replayable=true`.
|
||||
- Collector and API used the same manifest/snapshot root.
|
||||
|
||||
## Independent review
|
||||
|
||||
```text
|
||||
passed: true
|
||||
security_concerns: []
|
||||
logic_errors: []
|
||||
```
|
||||
|
||||
Non-blocking backlog: add concurrent-writer/restart recovery coverage and more timezone/revision edge cases.
|
||||
|
||||
## Risks and next action
|
||||
|
||||
One collected release is not enough for an event study. The current historical series is not treated as historical point-in-time knowledge. Continue collecting new releases, then implement event windows and cost-aware backtesting only over vintages whose publication timestamps are known.
|
||||
47
docs/test-evidence/2026-08-23-vintage-collector.md
Normal file
47
docs/test-evidence/2026-08-23-vintage-collector.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Test evidence — 2026-08-23 vintage collector
|
||||
|
||||
## Automated
|
||||
|
||||
```text
|
||||
PYTHONPATH=backend .venv/bin/python -W error -m unittest discover -s backend/tests -v
|
||||
Ran 25 tests ... OK
|
||||
|
||||
npm run build
|
||||
Vite build completed successfully.
|
||||
|
||||
npm audit --omit=dev --audit-level=high
|
||||
found 0 vulnerabilities
|
||||
```
|
||||
|
||||
## Live collector
|
||||
|
||||
The one-shot collector ran twice against the BOT source and the application fetched once on restart. Results:
|
||||
|
||||
```text
|
||||
manifest vintages: 1
|
||||
seen_count: 4
|
||||
revision_status: initial
|
||||
source_id: bot.ec_ei_028_s2
|
||||
published_at: 2026-07-31T14:30:00+07:00
|
||||
```
|
||||
|
||||
## Point-in-time API
|
||||
|
||||
```text
|
||||
as_of=2026-07-01T00:00:00Z → count 0
|
||||
as_of=2026-08-01T00:00:00Z → count 1
|
||||
```
|
||||
|
||||
## Manual visual/network limitation
|
||||
|
||||
The frontend build and served Vite source were previously verified. Browser screenshot capture remains unavailable because the Chrome remote-debugging permission prompt has not been approved. Backend live endpoints were verified over HTTP.
|
||||
|
||||
## Independent review
|
||||
|
||||
```text
|
||||
passed: true
|
||||
security_concerns: []
|
||||
logic_errors: []
|
||||
```
|
||||
|
||||
Non-blocking backlog: add concurrent-writer/restart recovery coverage and more timezone/revision edge cases.
|
||||
Reference in New Issue
Block a user