chore: pre-existing in-tree work (event-study/research/vintages/prices + migration script + integrity docs)
Committing the prior uncommitted working-tree state that predates this session's data-source work (was already modified/untracked at session start) so the tree is clean before push. Includes: event-study + research report integrity/forward observation work, prices tests, research hash migration script, and the 2026-08-23/24 engineering-log + test-evidence notes. Verified green as part of the full 362-test suite.
This commit is contained in:
23
README.md
23
README.md
@@ -20,7 +20,7 @@ No external webhook receiver and no live MT5 execution are enabled.
|
|||||||
```bash
|
```bash
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
.venv/bin/pip install -r backend/requirements.txt
|
.venv/bin/pip install -r backend/requirements.txt
|
||||||
PAPER_WRITE_TOKEN=local-paper-token TOURISM_SOURCE=fixture PYTHONPATH=backend .venv/bin/python backend/run.py
|
PAPER_AUTH_MODE=demo HOST=127.0.0.1 TOURISM_SOURCE=fixture PYTHONPATH=backend .venv/bin/python backend/run.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Health check:
|
Health check:
|
||||||
@@ -41,18 +41,20 @@ npm run dev -- --host 127.0.0.1
|
|||||||
|
|
||||||
Open `http://127.0.0.1:5173`.
|
Open `http://127.0.0.1:5173`.
|
||||||
|
|
||||||
The frontend reads the live API through Vite's `/api` proxy. Paper writes require the operator to unlock an HttpOnly browser session using the backend `PAPER_WRITE_TOKEN`; the token is never embedded in the frontend bundle. The paper-entry action records an assumed fill in the local paper ledger only; it never sends an order.
|
The frontend reads the live API through Vite's `/api` proxy. The local command above uses explicit loopback-only `PAPER_AUTH_MODE=demo`: paper writes do not require a session token, and the dashboard shows a demo warning. This mode is for a single local operator only; it must not be bound to a network interface or used for live execution. For shared or network access, use `PAPER_AUTH_MODE=token`, set `PAPER_WRITE_TOKEN` in the server environment, and require the HttpOnly browser session; the token is never embedded in the frontend bundle. The paper-entry action records an assumed fill in the local paper ledger only; it never sends an order.
|
||||||
|
|
||||||
For HTTPS/non-local deployment, set `PAPER_COOKIE_SECURE=1`. Paper entries persist atomically under `backend/data/paper/ledger.json` by default. Sessions remain in-memory and single-process; use a shared session store before running multiple workers or replicas.
|
For HTTPS/non-local deployment, use protected token mode and set `PAPER_COOKIE_SECURE=1`. Paper entries persist atomically under `backend/data/paper/ledger.json` by default. Sessions remain in-memory and single-process; use a shared session store before running multiple workers or replicas.
|
||||||
|
|
||||||
## Run with the real BOT Tourism source
|
## Run with the real BOT Tourism source
|
||||||
|
|
||||||
Use the BOT-backed adapter when network access is available:
|
Use the BOT-backed adapter when network access is available:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PAPER_WRITE_TOKEN=local-paper-token TOURISM_SOURCE=bot PYTHONPATH=backend .venv/bin/python backend/run.py
|
PAPER_AUTH_MODE=demo HOST=127.0.0.1 TOURISM_SOURCE=bot PYTHONPATH=backend .venv/bin/python backend/run.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For a shared/network BOT deployment, replace `PAPER_AUTH_MODE=demo` with `PAPER_AUTH_MODE=token` and provide `PAPER_WRITE_TOKEN` through the server environment. The backend rejects demo mode when `HOST`/`PAPER_BIND_HOST` is not loopback.
|
||||||
|
|
||||||
At startup the adapter performs a read-only GET/POST against the BOT Tourism Indicators report, parses the available monthly history, computes the latest year-over-year arrival observation against a trailing 12-point baseline, and stores the raw HTML plus normalized snapshot under `backend/data/` (ignored by git). The dashboard labels provisional BOT data as `provisional`, not `high`.
|
At startup the adapter performs a read-only GET/POST against the BOT Tourism Indicators report, parses the available monthly history, computes the latest year-over-year arrival observation against a trailing 12-point baseline, and stores the raw HTML plus normalized snapshot under `backend/data/` (ignored by git). The dashboard labels provisional BOT data as `provisional`, not `high`.
|
||||||
|
|
||||||
Data-health and replay endpoints:
|
Data-health and replay endpoints:
|
||||||
@@ -107,10 +109,12 @@ cd frontend && npm run build
|
|||||||
- No webhook receiver yet
|
- No webhook receiver yet
|
||||||
- No MT5 bridge yet
|
- No MT5 bridge yet
|
||||||
|
|
||||||
The next implementation step is replacing or supplementing revised vendor history with a point-in-time daily price source, while continuing to collect independent BOT releases.
|
The PIT archive contract is implemented, but a real provider archive and release-time evidence are still required. Continue collecting independent BOT releases and do not promote revised vendor history by configuration alone.
|
||||||
|
|
||||||
The event-study gate is now exposed through `/api/v1/backtest/tourism`. It returns HTTP `409` with `status=blocked` when the independent-vintage minimum is not met, and it explicitly reports that a point-in-time daily price series is still required. The pure engine accepts events, daily prices, benchmark prices, event windows, and cost assumptions; it does not fetch or invent market prices.
|
The event-study gate is now exposed through `/api/v1/backtest/tourism`. It returns HTTP `409` with `status=blocked` when the independent-vintage minimum is not met, and it explicitly reports that a point-in-time daily price series is still required. The pure engine accepts events, daily prices, benchmark prices, event windows, and cost assumptions; it does not fetch or invent market prices.
|
||||||
|
|
||||||
|
The accepted PIT contract is `pit-daily-v1`. It requires explicit provider release/evidence metadata, an IANA market timezone per series, timezone-aware per-bar `session_date`/`known_at`/OHLC/adjusted-close/volume, and immutable raw/normalized/manifest hash binding. PIT event-study mode rejects missing or market-locally future-known prices. The current Yahoo collector intentionally remains `quality=revised_vendor_history` and `point_in_time=false`.
|
||||||
|
|
||||||
## Run a frozen research check
|
## Run a frozen research check
|
||||||
|
|
||||||
The research runner persists either a deterministic event-study result or a blocked report. A blocked report is still useful: it records the exact vintage IDs, hashes, price snapshot, configuration, and gate reason that prevented the study.
|
The research runner persists either a deterministic event-study result or a blocked report. A blocked report is still useful: it records the exact vintage IDs, hashes, price snapshot, configuration, and gate reason that prevented the study.
|
||||||
@@ -135,6 +139,15 @@ GET /api/v1/research/tourism/latest
|
|||||||
|
|
||||||
The default live state intentionally returns `status=blocked`: the current archive has one independent BOT release and the Yahoo price snapshot is marked `point_in_time=false`. The runner never converts that data into a backtest by inference.
|
The default live state intentionally returns `status=blocked`: the current archive has one independent BOT release and the Yahoo price snapshot is marked `point_in_time=false`. The runner never converts that data into a backtest by inference.
|
||||||
|
|
||||||
|
Research reports written before integrity hashes were introduced are intentionally not auto-migrated. Migrate a trusted local research store explicitly before loading it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=backend .venv/bin/python backend/scripts/migrate_research_report_hashes.py \
|
||||||
|
--root backend/data/research
|
||||||
|
```
|
||||||
|
|
||||||
|
The migration validates report identity and manifest metadata, writes the canonical report and manifest-entry hashes, and fails closed on malformed or partially migrated entries.
|
||||||
|
|
||||||
## Collect daily price snapshots
|
## Collect daily price snapshots
|
||||||
|
|
||||||
The initial research price provider uses Yahoo Finance Chart API with SET ticker mappings. It stores OHLCV plus adjusted close for the eight exposure names and `^SET.BK` as `SET50`. This is **revised vendor history**, not point-in-time market data, so the snapshot is visible for data plumbing but cannot unlock the backtest gate:
|
The initial research price provider uses Yahoo Finance Chart API with SET ticker mappings. It stores OHLCV plus adjusted close for the eight exposure names and `^SET.BK` as `SET50`. This is **revised vendor history**, not point-in-time market data, so the snapshot is visible for data plumbing but cannot unlock the backtest gate:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import math
|
|||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from statistics import fmean
|
from statistics import fmean
|
||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping, Sequence
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
|
||||||
class EventStudyError(ValueError):
|
class EventStudyError(ValueError):
|
||||||
@@ -34,6 +35,18 @@ def _canonical_timestamp(value: str) -> str:
|
|||||||
return parsed.astimezone(timezone.utc).isoformat()
|
return parsed.astimezone(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_known_at(value: Any, symbol: str) -> datetime:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise EventStudyError(f"known_at is required for {symbol}")
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise EventStudyError(f"invalid known_at for {symbol}") from exc
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise EventStudyError(f"known_at must include a timezone for {symbol}")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def assess_backtest_readiness(vintages: Sequence[Mapping[str, Any]], min_events: int = 12) -> dict[str, Any]:
|
def assess_backtest_readiness(vintages: Sequence[Mapping[str, Any]], min_events: int = 12) -> dict[str, Any]:
|
||||||
if isinstance(min_events, bool) or min_events < 1:
|
if isinstance(min_events, bool) or min_events < 1:
|
||||||
raise EventStudyError("min_events must be positive")
|
raise EventStudyError("min_events must be positive")
|
||||||
@@ -63,13 +76,16 @@ def assess_backtest_readiness(vintages: Sequence[Mapping[str, Any]], min_events:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _price_map(symbol: str, rows: Sequence[Mapping[str, Any]]) -> dict[date, float]:
|
def _price_map(symbol: str, rows: Sequence[Mapping[str, Any]], *, require_known_at: bool = False) -> dict[date, tuple[float, datetime | None]]:
|
||||||
if not rows:
|
if not rows:
|
||||||
raise EventStudyError(f"missing prices for {symbol}")
|
raise EventStudyError(f"missing prices for {symbol}")
|
||||||
values: dict[date, float] = {}
|
values: dict[date, tuple[float, datetime | None]] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
try:
|
try:
|
||||||
trading_day = _parse_date(str(row["date"]))
|
session_date_value = row.get("session_date")
|
||||||
|
if session_date_value is None:
|
||||||
|
session_date_value = row["date"]
|
||||||
|
trading_day = _parse_date(str(session_date_value))
|
||||||
close = float(row["close"])
|
close = float(row["close"])
|
||||||
except (KeyError, TypeError, ValueError) as exc:
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
raise EventStudyError(f"invalid price row for {symbol}") from exc
|
raise EventStudyError(f"invalid price row for {symbol}") from exc
|
||||||
@@ -77,11 +93,22 @@ def _price_map(symbol: str, rows: Sequence[Mapping[str, Any]]) -> dict[date, flo
|
|||||||
raise EventStudyError(f"invalid close for {symbol}")
|
raise EventStudyError(f"invalid close for {symbol}")
|
||||||
if trading_day in values:
|
if trading_day in values:
|
||||||
raise EventStudyError(f"duplicate price date for {symbol}")
|
raise EventStudyError(f"duplicate price date for {symbol}")
|
||||||
values[trading_day] = close
|
known_at = _parse_known_at(row.get("known_at"), symbol) if require_known_at else None
|
||||||
|
if known_at is not None:
|
||||||
|
timezone_name = row.get("market_timezone")
|
||||||
|
if not isinstance(timezone_name, str) or not timezone_name.strip():
|
||||||
|
raise EventStudyError(f"market_timezone is required for {symbol}")
|
||||||
|
try:
|
||||||
|
market_date = known_at.astimezone(ZoneInfo(timezone_name)).date()
|
||||||
|
except Exception as exc:
|
||||||
|
raise EventStudyError(f"invalid market_timezone for {symbol}") from exc
|
||||||
|
if market_date > trading_day:
|
||||||
|
raise EventStudyError(f"known_at is after session date for {symbol}")
|
||||||
|
values[trading_day] = (close, known_at)
|
||||||
return dict(sorted(values.items()))
|
return dict(sorted(values.items()))
|
||||||
|
|
||||||
|
|
||||||
def _window_return(series: dict[date, float], event_date: date, window: int, symbol: str, execution_lag_sessions: int) -> float:
|
def _window_return(series: dict[date, tuple[float, datetime | None]], event_date: date, window: int, symbol: str, execution_lag_sessions: int) -> float:
|
||||||
dates = list(series)
|
dates = list(series)
|
||||||
anchor_candidates = [index for index, trading_day in enumerate(dates) if trading_day >= event_date]
|
anchor_candidates = [index for index, trading_day in enumerate(dates) if trading_day >= event_date]
|
||||||
if not anchor_candidates:
|
if not anchor_candidates:
|
||||||
@@ -96,7 +123,7 @@ def _window_return(series: dict[date, float], event_date: date, window: int, sym
|
|||||||
end = anchor + window
|
end = anchor + window
|
||||||
if end >= len(dates):
|
if end >= len(dates):
|
||||||
raise EventStudyError(f"insufficient price history for {symbol} window {window}")
|
raise EventStudyError(f"insufficient price history for {symbol} window {window}")
|
||||||
return series[dates[end]] / series[dates[anchor]] - 1.0
|
return series[dates[end]][0] / series[dates[anchor]][0] - 1.0
|
||||||
|
|
||||||
|
|
||||||
def run_event_study(
|
def run_event_study(
|
||||||
@@ -108,6 +135,7 @@ def run_event_study(
|
|||||||
cost_bps: float = 0.0,
|
cost_bps: float = 0.0,
|
||||||
min_events: int = 12,
|
min_events: int = 12,
|
||||||
execution_lag_sessions: int = 1,
|
execution_lag_sessions: int = 1,
|
||||||
|
require_price_known_at: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Calculate weighted post-publication returns from point-in-time events."""
|
"""Calculate weighted post-publication returns from point-in-time events."""
|
||||||
|
|
||||||
@@ -128,8 +156,13 @@ def run_event_study(
|
|||||||
if not math.isfinite(cost_bps) or cost_bps < 0:
|
if not math.isfinite(cost_bps) or cost_bps < 0:
|
||||||
raise EventStudyError("cost_bps must be finite and non-negative")
|
raise EventStudyError("cost_bps must be finite and non-negative")
|
||||||
|
|
||||||
normalized_prices = {str(symbol).upper(): _price_map(str(symbol).upper(), rows) for symbol, rows in prices.items()}
|
if not isinstance(require_price_known_at, bool):
|
||||||
normalized_benchmark = _price_map("benchmark", benchmark_prices) if benchmark_prices is not None else None
|
raise EventStudyError("require_price_known_at must be boolean")
|
||||||
|
normalized_prices = {
|
||||||
|
str(symbol).upper(): _price_map(str(symbol).upper(), rows, require_known_at=require_price_known_at)
|
||||||
|
for symbol, rows in prices.items()
|
||||||
|
}
|
||||||
|
normalized_benchmark = _price_map("benchmark", benchmark_prices, require_known_at=require_price_known_at) if benchmark_prices is not None else None
|
||||||
seen_event_ids: set[str] = set()
|
seen_event_ids: set[str] = set()
|
||||||
event_rows: list[dict[str, Any]] = []
|
event_rows: list[dict[str, Any]] = []
|
||||||
for event in events:
|
for event in events:
|
||||||
@@ -189,4 +222,5 @@ def run_event_study(
|
|||||||
"event_count": len(event_rows),
|
"event_count": len(event_rows),
|
||||||
"windows": window_results,
|
"windows": window_results,
|
||||||
"min_events": min_events,
|
"min_events": min_events,
|
||||||
|
"price_known_at_required": require_price_known_at,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Frozen, replayable Tourism research runs.
|
"""Frozen, replayable Tourism research runs.
|
||||||
|
|
||||||
A run either produces a deterministic event-study result from point-in-time
|
Validated runs require point-in-time inputs. Explicit exploratory runs may use
|
||||||
inputs or persists a blocked report explaining the exact missing gate. It never
|
revised vendor history, but their result is permanently labelled descriptive
|
||||||
falls back to revised vendor history.
|
only and does not satisfy the validated backtest gate.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -17,12 +17,15 @@ from pathlib import Path
|
|||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
from .event_study import EventStudyError, _canonical_timestamp, assess_backtest_readiness, run_event_study
|
from .event_study import EventStudyError, _canonical_timestamp, assess_backtest_readiness, run_event_study
|
||||||
from .prices import PriceSnapshotStore, PriceSourceError
|
from .prices import PIT_ARCHIVE_CONTRACT, PriceSnapshotStore, PriceSourceError
|
||||||
from .tourism import compute_tourism_signal
|
from .tourism import compute_tourism_signal
|
||||||
from .vintages import VintageStore, VintageStoreError
|
from .vintages import VintageStore, VintageStoreError
|
||||||
|
|
||||||
RUN_SCHEMA_VERSION = 1
|
RUN_SCHEMA_VERSION = 1
|
||||||
|
REPORT_HASH_ALGORITHM = "sha256-json-canonical-v1"
|
||||||
_RUN_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
|
_RUN_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
|
||||||
|
RESEARCH_MODES = {"validated", "exploratory"}
|
||||||
|
_OBSERVATION_RUNTIME_FIELDS = frozenset({"first_seen_at", "last_seen_at", "observation_count"})
|
||||||
|
|
||||||
|
|
||||||
class ResearchRunError(ValueError):
|
class ResearchRunError(ValueError):
|
||||||
@@ -34,6 +37,20 @@ def _canonical_hash(payload: Mapping[str, Any]) -> str:
|
|||||||
return hashlib.sha256(encoded).hexdigest()
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _report_hash(report: Mapping[str, Any]) -> str:
|
||||||
|
payload = copy.deepcopy(dict(report))
|
||||||
|
payload.pop("report_hash", None)
|
||||||
|
payload.pop("report_hash_algorithm", None)
|
||||||
|
return _canonical_hash(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_entry_hash(entry: Mapping[str, Any]) -> str:
|
||||||
|
payload = copy.deepcopy(dict(entry))
|
||||||
|
payload.pop("manifest_entry_hash", None)
|
||||||
|
payload.pop("manifest_entry_hash_algorithm", None)
|
||||||
|
return _canonical_hash(payload)
|
||||||
|
|
||||||
|
|
||||||
def _atomic_write(path: Path, payload: Mapping[str, Any]) -> None:
|
def _atomic_write(path: Path, payload: Mapping[str, Any]) -> None:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
temporary = path.with_name(f".{path.name}.tmp")
|
temporary = path.with_name(f".{path.name}.tmp")
|
||||||
@@ -54,9 +71,13 @@ class ResearchRunStore:
|
|||||||
return {"schema_version": RUN_SCHEMA_VERSION, "runs": {}}
|
return {"schema_version": RUN_SCHEMA_VERSION, "runs": {}}
|
||||||
try:
|
try:
|
||||||
manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
|
manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||||
raise ResearchRunError("research run manifest is unreadable") from exc
|
raise ResearchRunError("research run manifest is unreadable") from exc
|
||||||
if manifest.get("schema_version") != RUN_SCHEMA_VERSION or not isinstance(manifest.get("runs"), dict):
|
if (
|
||||||
|
not isinstance(manifest, dict)
|
||||||
|
or manifest.get("schema_version") != RUN_SCHEMA_VERSION
|
||||||
|
or not isinstance(manifest.get("runs"), dict)
|
||||||
|
):
|
||||||
raise ResearchRunError("unsupported research run manifest schema")
|
raise ResearchRunError("unsupported research run manifest schema")
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
@@ -74,79 +95,235 @@ class ResearchRunStore:
|
|||||||
raise FileNotFoundError(run_id)
|
raise FileNotFoundError(run_id)
|
||||||
try:
|
try:
|
||||||
report = json.loads(path.read_text(encoding="utf-8"))
|
report = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||||
raise ResearchRunError("research report is unreadable") from exc
|
raise ResearchRunError("research report is unreadable") from exc
|
||||||
|
if not isinstance(report, dict):
|
||||||
|
raise ResearchRunError("research report is invalid")
|
||||||
if report.get("run_id") != run_id or report.get("schema_version") != RUN_SCHEMA_VERSION:
|
if report.get("run_id") != run_id or report.get("schema_version") != RUN_SCHEMA_VERSION:
|
||||||
raise ResearchRunError("research report identity mismatch")
|
raise ResearchRunError("research report identity mismatch")
|
||||||
|
declared_hash = report.get("report_hash")
|
||||||
|
if (
|
||||||
|
report.get("report_hash_algorithm") != REPORT_HASH_ALGORITHM
|
||||||
|
or not isinstance(declared_hash, str)
|
||||||
|
or _report_hash(report) != declared_hash
|
||||||
|
):
|
||||||
|
raise ResearchRunError("research report integrity hash mismatch")
|
||||||
|
manifest_entry = self.load_manifest().get("runs", {}).get(run_id)
|
||||||
|
declared_manifest_hash = manifest_entry.get("manifest_entry_hash") if isinstance(manifest_entry, dict) else None
|
||||||
|
if (
|
||||||
|
not isinstance(manifest_entry, dict)
|
||||||
|
or manifest_entry.get("run_id") != run_id
|
||||||
|
or manifest_entry.get("report_file") != path.name
|
||||||
|
or any(manifest_entry.get(field) != report.get(field) for field in ("status", "reason", "generated_at"))
|
||||||
|
or manifest_entry.get("report_hash_algorithm") != REPORT_HASH_ALGORITHM
|
||||||
|
or manifest_entry.get("report_hash") != declared_hash
|
||||||
|
or manifest_entry.get("manifest_entry_hash_algorithm") != REPORT_HASH_ALGORITHM
|
||||||
|
or not isinstance(declared_manifest_hash, str)
|
||||||
|
or _manifest_entry_hash(manifest_entry) != declared_manifest_hash
|
||||||
|
):
|
||||||
|
raise ResearchRunError("research report manifest integrity mismatch")
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
def migrate_legacy(self) -> int:
|
||||||
|
"""Add integrity metadata to explicitly migrated pre-hash reports."""
|
||||||
|
manifest = self.load_manifest()
|
||||||
|
migrated = 0
|
||||||
|
for raw_run_id, raw_entry in manifest["runs"].items():
|
||||||
|
run_id = str(raw_run_id)
|
||||||
|
if not _RUN_ID_RE.fullmatch(run_id) or not isinstance(raw_entry, dict):
|
||||||
|
raise ResearchRunError("invalid research run manifest entry")
|
||||||
|
path = self._report_path(run_id)
|
||||||
|
if raw_entry.get("run_id") != run_id or raw_entry.get("report_file") != path.name:
|
||||||
|
raise ResearchRunError("research run manifest entry identity mismatch")
|
||||||
|
if not path.is_file():
|
||||||
|
raise FileNotFoundError(run_id)
|
||||||
|
try:
|
||||||
|
report = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise ResearchRunError("research report is unreadable") from exc
|
||||||
|
if not isinstance(report, dict) or report.get("run_id") != run_id or report.get("schema_version") != RUN_SCHEMA_VERSION:
|
||||||
|
raise ResearchRunError("research report identity mismatch")
|
||||||
|
has_report_integrity = "report_hash" in report or "report_hash_algorithm" in report
|
||||||
|
has_manifest_integrity = "manifest_entry_hash" in raw_entry or "manifest_entry_hash_algorithm" in raw_entry
|
||||||
|
if has_manifest_integrity and not has_report_integrity:
|
||||||
|
raise ResearchRunError("research run integrity metadata is incomplete")
|
||||||
|
declared_report_hash = report.get("report_hash")
|
||||||
|
report_integrity_valid = (
|
||||||
|
report.get("report_hash_algorithm") == REPORT_HASH_ALGORITHM
|
||||||
|
and isinstance(declared_report_hash, str)
|
||||||
|
and _report_hash(report) == declared_report_hash
|
||||||
|
)
|
||||||
|
if has_report_integrity and not report_integrity_valid:
|
||||||
|
raise ResearchRunError("research run integrity metadata is invalid")
|
||||||
|
if not has_report_integrity or not has_manifest_integrity:
|
||||||
|
if any(raw_entry.get(field) != report.get(field) for field in ("status", "reason", "generated_at")):
|
||||||
|
raise ResearchRunError("research report manifest metadata mismatch")
|
||||||
|
if not has_report_integrity:
|
||||||
|
stored = copy.deepcopy(report)
|
||||||
|
stored["report_hash_algorithm"] = REPORT_HASH_ALGORITHM
|
||||||
|
stored["report_hash"] = _report_hash(stored)
|
||||||
|
declared_report_hash = stored["report_hash"]
|
||||||
|
_atomic_write(path, stored)
|
||||||
|
if not has_manifest_integrity:
|
||||||
|
migrated_entry = copy.deepcopy(raw_entry)
|
||||||
|
migrated_entry["report_hash_algorithm"] = REPORT_HASH_ALGORITHM
|
||||||
|
migrated_entry["report_hash"] = declared_report_hash
|
||||||
|
migrated_entry["manifest_entry_hash_algorithm"] = REPORT_HASH_ALGORITHM
|
||||||
|
migrated_entry["manifest_entry_hash"] = _manifest_entry_hash(migrated_entry)
|
||||||
|
manifest["runs"][run_id] = migrated_entry
|
||||||
|
migrated += 1
|
||||||
|
continue
|
||||||
|
declared_manifest_hash = raw_entry.get("manifest_entry_hash")
|
||||||
|
if (
|
||||||
|
not report_integrity_valid
|
||||||
|
or raw_entry.get("report_hash_algorithm") != REPORT_HASH_ALGORITHM
|
||||||
|
or raw_entry.get("report_hash") != declared_report_hash
|
||||||
|
or raw_entry.get("manifest_entry_hash_algorithm") != REPORT_HASH_ALGORITHM
|
||||||
|
or not isinstance(declared_manifest_hash, str)
|
||||||
|
or _manifest_entry_hash(raw_entry) != declared_manifest_hash
|
||||||
|
or any(raw_entry.get(field) != report.get(field) for field in ("status", "reason", "generated_at"))
|
||||||
|
):
|
||||||
|
raise ResearchRunError("research run integrity metadata is invalid")
|
||||||
|
if migrated:
|
||||||
|
_atomic_write(self.manifest_path, manifest)
|
||||||
|
return migrated
|
||||||
|
|
||||||
|
def _validated_manifest_entries(self) -> list[dict[str, Any]]:
|
||||||
|
manifest = self.load_manifest()
|
||||||
|
entries: list[dict[str, Any]] = []
|
||||||
|
for run_id, raw_entry in manifest["runs"].items():
|
||||||
|
if not _RUN_ID_RE.fullmatch(str(run_id)) or not isinstance(raw_entry, dict) or raw_entry.get("run_id") != run_id:
|
||||||
|
raise ResearchRunError("invalid research run manifest entry")
|
||||||
|
try:
|
||||||
|
self.load(run_id)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise ResearchRunError("research run manifest entry report is missing") from exc
|
||||||
|
entries.append(raw_entry)
|
||||||
|
return entries
|
||||||
|
|
||||||
def persist(self, report: dict[str, Any]) -> dict[str, Any]:
|
def persist(self, report: dict[str, Any]) -> dict[str, Any]:
|
||||||
run_id = str(report.get("run_id", ""))
|
run_id = str(report.get("run_id", ""))
|
||||||
path = self._report_path(run_id)
|
path = self._report_path(run_id)
|
||||||
manifest = self.load_manifest()
|
|
||||||
existing = manifest["runs"].get(run_id)
|
|
||||||
if existing:
|
|
||||||
stored = self.load(run_id)
|
|
||||||
if stored != report:
|
|
||||||
raise ResearchRunError("research run id already contains a different report")
|
|
||||||
return stored
|
|
||||||
stored = copy.deepcopy(report)
|
stored = copy.deepcopy(report)
|
||||||
|
stored["report_hash_algorithm"] = REPORT_HASH_ALGORITHM
|
||||||
|
stored["report_hash"] = _report_hash(stored)
|
||||||
|
manifest = self.load_manifest()
|
||||||
|
self._validated_manifest_entries()
|
||||||
|
if run_id in manifest["runs"]:
|
||||||
|
if not isinstance(manifest["runs"][run_id], dict):
|
||||||
|
raise ResearchRunError("invalid research run manifest entry")
|
||||||
|
existing_report = self.load(run_id)
|
||||||
|
if existing_report != stored:
|
||||||
|
raise ResearchRunError("research run id already contains a different report")
|
||||||
|
return existing_report
|
||||||
_atomic_write(path, stored)
|
_atomic_write(path, stored)
|
||||||
manifest["runs"][run_id] = {
|
manifest_entry = {
|
||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
"status": stored.get("status"),
|
"status": stored.get("status"),
|
||||||
"reason": stored.get("reason"),
|
"reason": stored.get("reason"),
|
||||||
"generated_at": stored.get("generated_at"),
|
"generated_at": stored.get("generated_at"),
|
||||||
"report_file": path.name,
|
"report_file": path.name,
|
||||||
|
"report_hash_algorithm": REPORT_HASH_ALGORITHM,
|
||||||
|
"report_hash": stored["report_hash"],
|
||||||
}
|
}
|
||||||
|
manifest_entry["manifest_entry_hash_algorithm"] = REPORT_HASH_ALGORITHM
|
||||||
|
manifest_entry["manifest_entry_hash"] = _manifest_entry_hash(manifest_entry)
|
||||||
|
manifest["runs"][run_id] = manifest_entry
|
||||||
_atomic_write(self.manifest_path, manifest)
|
_atomic_write(self.manifest_path, manifest)
|
||||||
return stored
|
return stored
|
||||||
|
|
||||||
def latest(self) -> dict[str, Any]:
|
def latest(self) -> dict[str, Any]:
|
||||||
entries = list(self.load_manifest().get("runs", {}).values())
|
entries = self._validated_manifest_entries()
|
||||||
if not entries:
|
if not entries:
|
||||||
raise FileNotFoundError("no research runs")
|
raise FileNotFoundError("no research runs")
|
||||||
latest = max(entries, key=lambda item: (str(item.get("generated_at", "")), str(item.get("run_id", ""))))
|
latest = max(entries, key=lambda item: (str(item.get("generated_at", "")), str(item.get("run_id", ""))))
|
||||||
return self.load(str(latest["run_id"]))
|
return self.load(str(latest["run_id"]))
|
||||||
|
|
||||||
def list_runs(self) -> list[dict[str, Any]]:
|
def list_runs(self) -> list[dict[str, Any]]:
|
||||||
entries = list(self.load_manifest().get("runs", {}).values())
|
entries = self._validated_manifest_entries()
|
||||||
return sorted(entries, key=lambda item: (str(item.get("generated_at", "")), str(item.get("run_id", ""))), reverse=True)
|
return sorted(entries, key=lambda item: (str(item.get("generated_at", "")), str(item.get("run_id", ""))), reverse=True)
|
||||||
|
|
||||||
|
|
||||||
def _validate_config(windows: Sequence[int], cost_bps: float, min_events: int, execution_lag_sessions: int) -> tuple[list[int], float, int, int]:
|
def _parse_config_integer(value: Any, field: str, minimum: int) -> int:
|
||||||
if not windows or any(isinstance(window, bool) or int(window) != window or int(window) <= 0 for window in windows):
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
raise ResearchRunError("windows must contain positive integers")
|
raise ResearchRunError(f"{field} must be an integer")
|
||||||
try:
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
numeric = float(value)
|
||||||
|
except (OverflowError, TypeError, ValueError) as exc:
|
||||||
|
raise ResearchRunError(f"{field} must be an integer") from exc
|
||||||
|
if not math.isfinite(numeric) or parsed != value or parsed < minimum:
|
||||||
|
qualifier = "positive" if minimum > 0 else "non-negative"
|
||||||
|
raise ResearchRunError(f"{field} must be a {qualifier} integer")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_config(windows: Sequence[int], cost_bps: float, min_events: int, execution_lag_sessions: int) -> tuple[list[int], float, int, int]:
|
||||||
|
if not isinstance(windows, (list, tuple)) or not windows:
|
||||||
|
raise ResearchRunError("windows must contain positive integers")
|
||||||
|
parsed_windows = [_parse_config_integer(window, "windows", 1) for window in windows]
|
||||||
|
try:
|
||||||
|
if isinstance(cost_bps, bool):
|
||||||
|
raise TypeError
|
||||||
parsed_cost = float(cost_bps)
|
parsed_cost = float(cost_bps)
|
||||||
except (TypeError, ValueError) as exc:
|
except (OverflowError, TypeError, ValueError) as exc:
|
||||||
raise ResearchRunError("cost_bps must be finite and non-negative") from exc
|
raise ResearchRunError("cost_bps must be finite and non-negative") from exc
|
||||||
if not math.isfinite(parsed_cost) or parsed_cost < 0:
|
if not math.isfinite(parsed_cost) or parsed_cost < 0:
|
||||||
raise ResearchRunError("cost_bps must be finite and non-negative")
|
raise ResearchRunError("cost_bps must be finite and non-negative")
|
||||||
if isinstance(min_events, bool) or int(min_events) != min_events or int(min_events) < 1:
|
parsed_min_events = _parse_config_integer(min_events, "min_events", 1)
|
||||||
raise ResearchRunError("min_events must be positive")
|
parsed_execution_lag = _parse_config_integer(execution_lag_sessions, "execution_lag_sessions", 0)
|
||||||
if isinstance(execution_lag_sessions, bool) or int(execution_lag_sessions) != execution_lag_sessions or int(execution_lag_sessions) < 0:
|
return parsed_windows, parsed_cost, parsed_min_events, parsed_execution_lag
|
||||||
raise ResearchRunError("execution_lag_sessions must be a non-negative integer")
|
|
||||||
return [int(window) for window in windows], parsed_cost, int(min_events), int(execution_lag_sessions)
|
|
||||||
|
|
||||||
|
|
||||||
def _price_gate(price_entry: Mapping[str, Any] | None) -> dict[str, Any]:
|
def _validate_mode(mode: str) -> str:
|
||||||
|
if not isinstance(mode, str) or mode not in RESEARCH_MODES:
|
||||||
|
raise ResearchRunError(f"mode must be one of: {', '.join(sorted(RESEARCH_MODES))}")
|
||||||
|
return mode
|
||||||
|
|
||||||
|
|
||||||
|
def _price_gate(price_entry: Mapping[str, Any] | None, mode: str = "validated") -> dict[str, Any]:
|
||||||
|
mode = _validate_mode(mode)
|
||||||
if not price_entry:
|
if not price_entry:
|
||||||
return {"status": "blocked", "reason": "price_snapshot_missing", "snapshot_count": 0}
|
return {"status": "blocked", "reason": "price_snapshot_missing", "snapshot_count": 0}
|
||||||
if price_entry.get("point_in_time") is not True:
|
if price_entry.get("point_in_time") is not True:
|
||||||
return {
|
return {
|
||||||
"status": "blocked",
|
"status": "descriptive_only" if mode == "exploratory" else "blocked",
|
||||||
"reason": "price_series_not_point_in_time",
|
"reason": "price_series_not_point_in_time",
|
||||||
"snapshot_id": price_entry.get("snapshot_id"),
|
"snapshot_id": price_entry.get("snapshot_id"),
|
||||||
"quality": price_entry.get("quality"),
|
"quality": price_entry.get("quality"),
|
||||||
"point_in_time": False,
|
"point_in_time": False,
|
||||||
|
"validated": False,
|
||||||
|
}
|
||||||
|
if price_entry.get("quality") != "point_in_time_archive" or price_entry.get("archive_contract") != PIT_ARCHIVE_CONTRACT:
|
||||||
|
return {
|
||||||
|
"status": "descriptive_only" if mode == "exploratory" else "blocked",
|
||||||
|
"reason": "price_archive_contract_missing",
|
||||||
|
"snapshot_id": price_entry.get("snapshot_id"),
|
||||||
|
"quality": price_entry.get("quality"),
|
||||||
|
"archive_contract": price_entry.get("archive_contract"),
|
||||||
|
"point_in_time": True,
|
||||||
|
"validated": False,
|
||||||
|
}
|
||||||
|
if mode == "exploratory":
|
||||||
|
return {
|
||||||
|
"status": "descriptive_only",
|
||||||
|
"reason": "exploratory_mode_non_validated",
|
||||||
|
"snapshot_id": price_entry.get("snapshot_id"),
|
||||||
|
"quality": price_entry.get("quality"),
|
||||||
|
"archive_contract": price_entry.get("archive_contract"),
|
||||||
|
"provider_release_id": price_entry.get("provider_release_id"),
|
||||||
|
"point_in_time": True,
|
||||||
|
"validated": False,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
"reason": None,
|
"reason": None,
|
||||||
"snapshot_id": price_entry.get("snapshot_id"),
|
"snapshot_id": price_entry.get("snapshot_id"),
|
||||||
"quality": price_entry.get("quality"),
|
"quality": price_entry.get("quality"),
|
||||||
|
"archive_contract": price_entry.get("archive_contract"),
|
||||||
|
"provider_release_id": price_entry.get("provider_release_id"),
|
||||||
"point_in_time": True,
|
"point_in_time": True,
|
||||||
|
"validated": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -164,11 +341,25 @@ def _select_independent_vintages(entries: Sequence[Mapping[str, Any]]) -> list[d
|
|||||||
entry = dict(raw_entry)
|
entry = dict(raw_entry)
|
||||||
key = _revision_key(entry)
|
key = _revision_key(entry)
|
||||||
current = selected.get(key)
|
current = selected.get(key)
|
||||||
candidate_rank = (str(entry.get("first_seen_at", "")), str(entry.get("last_seen_at", "")), str(entry.get("vintage_id", "")))
|
candidate_rank = (
|
||||||
current_rank = (str(current.get("first_seen_at", "")), str(current.get("last_seen_at", "")), str(current.get("vintage_id", ""))) if current else ("", "", "")
|
_canonical_timestamp(str(entry.get("first_seen_at") or "")),
|
||||||
|
_canonical_timestamp(str(entry.get("last_seen_at") or "")),
|
||||||
|
str(entry.get("vintage_id", "")),
|
||||||
|
)
|
||||||
|
current_rank = (
|
||||||
|
_canonical_timestamp(str(current.get("first_seen_at") or "")),
|
||||||
|
_canonical_timestamp(str(current.get("last_seen_at") or "")),
|
||||||
|
str(current.get("vintage_id", "")),
|
||||||
|
) if current else ("", "", "")
|
||||||
if current is None or candidate_rank > current_rank:
|
if current is None or candidate_rank > current_rank:
|
||||||
selected[key] = entry
|
selected[key] = entry
|
||||||
return sorted(selected.values(), key=lambda item: (str(item.get("published_at", "")), str(item.get("vintage_id", ""))))
|
return sorted(
|
||||||
|
selected.values(),
|
||||||
|
key=lambda item: (
|
||||||
|
_canonical_timestamp(str(item.get("published_at") or "")),
|
||||||
|
str(item.get("vintage_id", "")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _series_rows(series: Mapping[str, Any], price_field: str, symbol: str) -> list[dict[str, Any]]:
|
def _series_rows(series: Mapping[str, Any], price_field: str, symbol: str) -> list[dict[str, Any]]:
|
||||||
@@ -179,7 +370,15 @@ def _series_rows(series: Mapping[str, Any], price_field: str, symbol: str) -> li
|
|||||||
for bar in bars:
|
for bar in bars:
|
||||||
if not isinstance(bar, dict) or price_field not in bar:
|
if not isinstance(bar, dict) or price_field not in bar:
|
||||||
raise ResearchRunError(f"price field {price_field} missing for {symbol}")
|
raise ResearchRunError(f"price field {price_field} missing for {symbol}")
|
||||||
rows.append({"date": bar.get("date"), "close": bar.get(price_field)})
|
session_date = bar.get("session_date", bar.get("date"))
|
||||||
|
row = {"date": session_date, "close": bar.get(price_field)}
|
||||||
|
if "session_date" in bar:
|
||||||
|
row["session_date"] = session_date
|
||||||
|
if "known_at" in bar:
|
||||||
|
row["known_at"] = bar.get("known_at")
|
||||||
|
if "timezone" in series:
|
||||||
|
row["market_timezone"] = series.get("timezone")
|
||||||
|
rows.append(row)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@@ -192,13 +391,15 @@ def run_tourism_research(
|
|||||||
windows: Sequence[int] = (1, 3, 5, 20),
|
windows: Sequence[int] = (1, 3, 5, 20),
|
||||||
cost_bps: float = 20.0,
|
cost_bps: float = 20.0,
|
||||||
execution_lag_sessions: int = 1,
|
execution_lag_sessions: int = 1,
|
||||||
|
mode: str = "validated",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
mode = _validate_mode(mode)
|
||||||
windows, cost_bps, min_events, execution_lag_sessions = _validate_config(windows, cost_bps, min_events, execution_lag_sessions)
|
windows, cost_bps, min_events, execution_lag_sessions = _validate_config(windows, cost_bps, min_events, execution_lag_sessions)
|
||||||
try:
|
try:
|
||||||
vintages = _select_independent_vintages(vintage_store.list_vintages())
|
vintages = _select_independent_vintages(vintage_store.list_vintages())
|
||||||
price_entry = price_store.latest_snapshot_entry()
|
price_entry = price_store.latest_snapshot_entry()
|
||||||
except (VintageStoreError, PriceSourceError) as exc:
|
except (VintageStoreError, PriceSourceError) as exc:
|
||||||
raise ResearchRunError(str(exc)) from exc
|
raise ResearchRunError(f"research input integrity validation failed: {exc}") from exc
|
||||||
vintage_gate = assess_backtest_readiness(vintages, min_events=min_events)
|
vintage_gate = assess_backtest_readiness(vintages, min_events=min_events)
|
||||||
validated_vintage_snapshots: dict[str, dict[str, Any]] = {}
|
validated_vintage_snapshots: dict[str, dict[str, Any]] = {}
|
||||||
try:
|
try:
|
||||||
@@ -219,9 +420,11 @@ def run_tourism_research(
|
|||||||
**price_entry,
|
**price_entry,
|
||||||
"point_in_time": price_source.get("point_in_time"),
|
"point_in_time": price_source.get("point_in_time"),
|
||||||
"quality": price_source.get("quality"),
|
"quality": price_source.get("quality"),
|
||||||
|
"archive_contract": price_source.get("archive_contract"),
|
||||||
|
"provider_release_id": price_source.get("provider_release_id"),
|
||||||
"normalized_snapshot_hash": price_source.get("normalized_snapshot_hash"),
|
"normalized_snapshot_hash": price_source.get("normalized_snapshot_hash"),
|
||||||
}
|
}
|
||||||
price_gate = _price_gate(price_gate_entry)
|
price_gate = _price_gate(price_gate_entry, mode)
|
||||||
input_vintages = [
|
input_vintages = [
|
||||||
{
|
{
|
||||||
"vintage_id": entry.get("vintage_id"),
|
"vintage_id": entry.get("vintage_id"),
|
||||||
@@ -229,17 +432,28 @@ def run_tourism_research(
|
|||||||
"as_of": entry.get("as_of"),
|
"as_of": entry.get("as_of"),
|
||||||
"published_at": entry.get("published_at"),
|
"published_at": entry.get("published_at"),
|
||||||
"raw_payload_hash": entry.get("raw_payload_hash"),
|
"raw_payload_hash": entry.get("raw_payload_hash"),
|
||||||
|
"normalized_snapshot_hash": entry.get("normalized_snapshot_hash"),
|
||||||
|
"normalized_hash_algorithm": entry.get("normalized_hash_algorithm"),
|
||||||
"parser_version": entry.get("parser_version"),
|
"parser_version": entry.get("parser_version"),
|
||||||
"revision_status": entry.get("revision_status"),
|
"revision_status": entry.get("revision_status"),
|
||||||
}
|
}
|
||||||
for entry in vintages
|
for entry in vintages
|
||||||
]
|
]
|
||||||
input_price = copy.deepcopy(price_gate_entry) if price_gate_entry else None
|
input_price = (
|
||||||
|
{
|
||||||
|
key: copy.deepcopy(value)
|
||||||
|
for key, value in price_gate_entry.items()
|
||||||
|
if key not in _OBSERVATION_RUNTIME_FIELDS
|
||||||
|
}
|
||||||
|
if price_gate_entry
|
||||||
|
else None
|
||||||
|
)
|
||||||
config = {
|
config = {
|
||||||
"min_events": min_events,
|
"min_events": min_events,
|
||||||
"windows": windows,
|
"windows": windows,
|
||||||
"cost_bps": cost_bps,
|
"cost_bps": cost_bps,
|
||||||
"execution_lag_sessions": execution_lag_sessions,
|
"execution_lag_sessions": execution_lag_sessions,
|
||||||
|
"mode": mode,
|
||||||
}
|
}
|
||||||
input_fingerprint = _canonical_hash({"theme": "tourism", "config": config, "vintages": input_vintages, "price_snapshot": input_price})
|
input_fingerprint = _canonical_hash({"theme": "tourism", "config": config, "vintages": input_vintages, "price_snapshot": input_price})
|
||||||
run_id = f"tourism-run-{input_fingerprint[:16]}"
|
run_id = f"tourism-run-{input_fingerprint[:16]}"
|
||||||
@@ -255,6 +469,8 @@ def run_tourism_research(
|
|||||||
"status": "blocked",
|
"status": "blocked",
|
||||||
"reason": None,
|
"reason": None,
|
||||||
"generated_at": generated_at,
|
"generated_at": generated_at,
|
||||||
|
"research_mode": mode,
|
||||||
|
"result_scope": "blocked",
|
||||||
"config": config,
|
"config": config,
|
||||||
"gates": {"vintages": vintage_gate, "prices": price_gate},
|
"gates": {"vintages": vintage_gate, "prices": price_gate},
|
||||||
"inputs": {"vintages": input_vintages, "price_snapshot": input_price},
|
"inputs": {"vintages": input_vintages, "price_snapshot": input_price},
|
||||||
@@ -262,7 +478,7 @@ def run_tourism_research(
|
|||||||
if vintage_gate["status"] != "ready":
|
if vintage_gate["status"] != "ready":
|
||||||
report["reason"] = vintage_gate["reason"]
|
report["reason"] = vintage_gate["reason"]
|
||||||
return run_store.persist(report)
|
return run_store.persist(report)
|
||||||
if price_gate["status"] != "ready":
|
if price_gate["status"] == "blocked":
|
||||||
report["reason"] = price_gate["reason"]
|
report["reason"] = price_gate["reason"]
|
||||||
return run_store.persist(report)
|
return run_store.persist(report)
|
||||||
if not price_entry or price_snapshot is None:
|
if not price_entry or price_snapshot is None:
|
||||||
@@ -294,13 +510,21 @@ def run_tourism_research(
|
|||||||
cost_bps=cost_bps,
|
cost_bps=cost_bps,
|
||||||
min_events=min_events,
|
min_events=min_events,
|
||||||
execution_lag_sessions=execution_lag_sessions,
|
execution_lag_sessions=execution_lag_sessions,
|
||||||
|
require_price_known_at=mode == "validated",
|
||||||
)
|
)
|
||||||
except (FileNotFoundError, VintageStoreError, EventStudyError, ResearchRunError, KeyError, TypeError, ValueError) as exc:
|
except (FileNotFoundError, VintageStoreError, EventStudyError, ResearchRunError, KeyError, TypeError, ValueError) as exc:
|
||||||
report["reason"] = "research_inputs_invalid"
|
report["reason"] = "research_inputs_invalid"
|
||||||
report["error"] = str(exc)
|
report["error"] = str(exc)
|
||||||
return run_store.persist(report)
|
return run_store.persist(report)
|
||||||
report["status"] = "ready"
|
report["status"] = "descriptive_only" if mode == "exploratory" else "ready"
|
||||||
report["reason"] = None
|
report["reason"] = None
|
||||||
|
report["result_scope"] = "validated_pit_event_study" if mode == "validated" else "non_pit_descriptive_only"
|
||||||
|
if mode == "exploratory":
|
||||||
|
report["limitations"] = ["This result is descriptive only and must not be used as validated backtest evidence."]
|
||||||
|
if price_source.get("point_in_time") is not True:
|
||||||
|
report["limitations"].insert(0, "Historical price data may be revised vendor history and is not point-in-time validated.")
|
||||||
|
else:
|
||||||
|
report["limitations"].insert(0, "Point-in-time-capable inputs are intentionally not promoted by exploratory mode.")
|
||||||
report["result"] = result
|
report["result"] = result
|
||||||
report["input_summary"] = {
|
report["input_summary"] = {
|
||||||
"event_count": len(events),
|
"event_count": len(events),
|
||||||
|
|||||||
@@ -42,10 +42,12 @@ def _data_quality(snapshot: dict[str, Any], observations: list[dict[str, Any]])
|
|||||||
def compute_tourism_signal(snapshot: dict[str, Any]) -> dict[str, Any]:
|
def compute_tourism_signal(snapshot: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Compute a replayable Tourism Pulse signal from a frozen snapshot."""
|
"""Compute a replayable Tourism Pulse signal from a frozen snapshot."""
|
||||||
|
|
||||||
observations = list(snapshot.get("observations", []))
|
observations = snapshot.get("observations", [])
|
||||||
if not observations:
|
if not isinstance(observations, list) or not observations:
|
||||||
raise ValueError("tourism snapshot must contain observations")
|
raise ValueError("tourism snapshot must contain observations")
|
||||||
for observation in observations:
|
for observation in observations:
|
||||||
|
if not isinstance(observation, dict):
|
||||||
|
raise ValueError("tourism observation must be an object")
|
||||||
_validate_observation(observation)
|
_validate_observation(observation)
|
||||||
|
|
||||||
standardized = []
|
standardized = []
|
||||||
@@ -58,7 +60,12 @@ def compute_tourism_signal(snapshot: dict[str, Any]) -> dict[str, Any]:
|
|||||||
theme_surprise = round(fmean(standardized), 8)
|
theme_surprise = round(fmean(standardized), 8)
|
||||||
|
|
||||||
signal_rows = []
|
signal_rows = []
|
||||||
for exposure in snapshot.get("exposures", []):
|
exposures = snapshot.get("exposures", [])
|
||||||
|
if not isinstance(exposures, list):
|
||||||
|
raise ValueError("tourism snapshot exposures must be a list")
|
||||||
|
for exposure in exposures:
|
||||||
|
if not isinstance(exposure, dict):
|
||||||
|
raise ValueError("tourism exposure must be an object")
|
||||||
symbol = str(exposure.get("symbol", "")).strip().upper()
|
symbol = str(exposure.get("symbol", "")).strip().upper()
|
||||||
coefficient = float(exposure.get("coefficient", 0))
|
coefficient = float(exposure.get("coefficient", 0))
|
||||||
confidence = float(exposure.get("confidence", 1.0))
|
confidence = float(exposure.get("confidence", 1.0))
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class VintageStore:
|
|||||||
return {"schema_version": MANIFEST_SCHEMA_VERSION, "vintages": {}}
|
return {"schema_version": MANIFEST_SCHEMA_VERSION, "vintages": {}}
|
||||||
try:
|
try:
|
||||||
payload = json.loads(self.manifest_path.read_text(encoding="utf-8"))
|
payload = json.loads(self.manifest_path.read_text(encoding="utf-8"))
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||||
raise VintageStoreError("vintage manifest is unreadable") from exc
|
raise VintageStoreError("vintage manifest is unreadable") from exc
|
||||||
if not isinstance(payload, dict) or payload.get("schema_version") != MANIFEST_SCHEMA_VERSION:
|
if not isinstance(payload, dict) or payload.get("schema_version") != MANIFEST_SCHEMA_VERSION:
|
||||||
raise VintageStoreError("unsupported vintage manifest schema")
|
raise VintageStoreError("unsupported vintage manifest schema")
|
||||||
@@ -174,8 +174,10 @@ class VintageStore:
|
|||||||
raise FileNotFoundError(vintage_id)
|
raise FileNotFoundError(vintage_id)
|
||||||
try:
|
try:
|
||||||
snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
|
snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||||
raise VintageStoreError("vintage snapshot is unreadable") from exc
|
raise VintageStoreError("vintage snapshot is unreadable") from exc
|
||||||
|
if not isinstance(snapshot, dict):
|
||||||
|
raise VintageStoreError("vintage snapshot is invalid")
|
||||||
source = snapshot.get("source")
|
source = snapshot.get("source")
|
||||||
if not isinstance(source, dict) or source.get("vintage_id") != vintage_id:
|
if not isinstance(source, dict) or source.get("vintage_id") != vintage_id:
|
||||||
raise VintageStoreError("vintage snapshot identity mismatch")
|
raise VintageStoreError("vintage snapshot identity mismatch")
|
||||||
@@ -186,7 +188,11 @@ class VintageStore:
|
|||||||
raw_path = (self.raw_dir / raw_filename).resolve()
|
raw_path = (self.raw_dir / raw_filename).resolve()
|
||||||
if raw_path.parent != self.raw_dir.resolve() or not raw_path.is_file():
|
if raw_path.parent != self.raw_dir.resolve() or not raw_path.is_file():
|
||||||
raise VintageStoreError("vintage raw payload is missing")
|
raise VintageStoreError("vintage raw payload is missing")
|
||||||
if hashlib.sha256(raw_path.read_bytes()).hexdigest() != source.get("raw_payload_hash"):
|
try:
|
||||||
|
raw_payload_hash = hashlib.sha256(raw_path.read_bytes()).hexdigest()
|
||||||
|
except OSError as exc:
|
||||||
|
raise VintageStoreError("vintage raw payload is unreadable") from exc
|
||||||
|
if raw_payload_hash != source.get("raw_payload_hash"):
|
||||||
raise VintageStoreError("vintage raw payload hash mismatch")
|
raise VintageStoreError("vintage raw payload hash mismatch")
|
||||||
manifest_entry = self.load_manifest().get("vintages", {}).get(vintage_id)
|
manifest_entry = self.load_manifest().get("vintages", {}).get(vintage_id)
|
||||||
if not isinstance(manifest_entry, dict):
|
if not isinstance(manifest_entry, dict):
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import os
|
|||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
|
|
||||||
app = create_app()
|
HOST = os.getenv("HOST", os.getenv("PAPER_BIND_HOST", "127.0.0.1"))
|
||||||
|
app = create_app({"PAPER_BIND_HOST": HOST})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(
|
app.run(
|
||||||
host=os.getenv("HOST", "127.0.0.1"),
|
host=HOST,
|
||||||
port=int(os.getenv("PORT", "5000")),
|
port=int(os.getenv("PORT", "5000")),
|
||||||
debug=False,
|
debug=False,
|
||||||
)
|
)
|
||||||
|
|||||||
27
backend/scripts/migrate_research_report_hashes.py
Normal file
27
backend/scripts/migrate_research_report_hashes.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Migrate legacy research reports into the fail-closed hashed format."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.research import ResearchRunStore
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--root",
|
||||||
|
type=Path,
|
||||||
|
default=Path(__file__).resolve().parents[1] / "data" / "research",
|
||||||
|
help="research store root containing manifest.json and reports/",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
migrated = ResearchRunStore(args.root).migrate_legacy()
|
||||||
|
print(json.dumps({"migrated_reports": migrated}, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -94,6 +94,39 @@ class EventStudyTests(unittest.TestCase):
|
|||||||
result = run_event_study(events, prices, windows=(1,), min_events=1)
|
result = run_event_study(events, prices, windows=(1,), min_events=1)
|
||||||
self.assertAlmostEqual(result["windows"]["1"]["gross_return"], 101 / 100 - 1, places=8)
|
self.assertAlmostEqual(result["windows"]["1"]["gross_return"], 101 / 100 - 1, places=8)
|
||||||
|
|
||||||
|
def test_event_study_requires_known_at_for_point_in_time_prices(self):
|
||||||
|
events = [{"event_id": "pit", "published_at": "2026-01-01T08:00:00+07:00", "signals": [{"symbol": "AOT", "target_weight": 1.0}]}]
|
||||||
|
prices = {
|
||||||
|
"AOT": [
|
||||||
|
{"session_date": "2026-01-01", "close": 100},
|
||||||
|
{"session_date": "2026-01-02", "close": 105, "known_at": "2026-01-02T17:00:00+07:00"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
with self.assertRaisesRegex(EventStudyError, "known_at"):
|
||||||
|
run_event_study(events, prices, windows=(1,), min_events=1, require_price_known_at=True)
|
||||||
|
|
||||||
|
def test_event_study_rejects_price_known_after_session_as_lookahead(self):
|
||||||
|
events = [{"event_id": "lookahead", "published_at": "2026-01-01T08:00:00+07:00", "signals": [{"symbol": "AOT", "target_weight": 1.0}]}]
|
||||||
|
prices = {
|
||||||
|
"AOT": [
|
||||||
|
{"session_date": "2026-01-01", "close": 100, "known_at": "2026-01-01T17:00:00+07:00", "market_timezone": "Asia/Bangkok"},
|
||||||
|
{"session_date": "2026-01-02", "close": 105, "known_at": "2026-01-03T09:00:00+07:00", "market_timezone": "Asia/Bangkok"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
with self.assertRaisesRegex(EventStudyError, "known_at"):
|
||||||
|
run_event_study(events, prices, windows=(1,), min_events=1, require_price_known_at=True)
|
||||||
|
|
||||||
|
def test_event_study_checks_known_at_in_market_timezone(self):
|
||||||
|
events = [{"event_id": "timezone-lookahead", "published_at": "2026-01-01T08:00:00+07:00", "signals": [{"symbol": "AOT", "target_weight": 1.0}]}]
|
||||||
|
prices = {
|
||||||
|
"AOT": [
|
||||||
|
{"session_date": "2026-01-01", "close": 100, "known_at": "2026-01-01T18:30:00Z", "market_timezone": "Asia/Bangkok"},
|
||||||
|
{"session_date": "2026-01-02", "close": 105, "known_at": "2026-01-02T17:00:00+07:00", "market_timezone": "Asia/Bangkok"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
with self.assertRaisesRegex(EventStudyError, "known_at"):
|
||||||
|
run_event_study(events, prices, windows=(1,), min_events=1, require_price_known_at=True)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from app.prices import (
|
from app.prices import (
|
||||||
DEFAULT_SYMBOL_MAP,
|
DEFAULT_SYMBOL_MAP,
|
||||||
|
PIT_ARCHIVE_CONTRACT,
|
||||||
PriceSnapshotStore,
|
PriceSnapshotStore,
|
||||||
PriceSourceError,
|
PriceSourceError,
|
||||||
|
_normalized_snapshot_hash,
|
||||||
|
_observation_id,
|
||||||
collect_price_snapshot,
|
collect_price_snapshot,
|
||||||
normalize_yahoo_chart,
|
normalize_yahoo_chart,
|
||||||
trading_dates,
|
trading_dates,
|
||||||
@@ -19,6 +23,33 @@ FIXTURE = Path(__file__).parent / "fixtures" / "yahoo_chart_aot.json"
|
|||||||
PAYLOAD = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
PAYLOAD = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def valid_non_pit_snapshot(snapshot_id="prices-valid", retrieved_at="2026-02-01T00:00:00+00:00"):
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"source": {
|
||||||
|
"source_id": "test.prices",
|
||||||
|
"source_url": "https://example.test/prices",
|
||||||
|
"snapshot_id": snapshot_id,
|
||||||
|
"retrieved_at": retrieved_at,
|
||||||
|
"period_start": "2026-01-01",
|
||||||
|
"period_end": "2026-01-02",
|
||||||
|
"quality": "revised_vendor_history",
|
||||||
|
"point_in_time": False,
|
||||||
|
"parser_version": "test-v1",
|
||||||
|
"adjusted_prices": False,
|
||||||
|
},
|
||||||
|
"series": {
|
||||||
|
"AOT": {
|
||||||
|
"canonical_symbol": "AOT",
|
||||||
|
"provider_symbol": "AOT.BK",
|
||||||
|
"raw_payload_hash": hashlib.sha256(b"aot").hexdigest(),
|
||||||
|
"timezone": "Asia/Bangkok",
|
||||||
|
"bars": [{"date": "2026-01-01", "close": 100.0}],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class FakePriceProvider:
|
class FakePriceProvider:
|
||||||
def __init__(self, payload):
|
def __init__(self, payload):
|
||||||
self.payload = payload
|
self.payload = payload
|
||||||
@@ -75,6 +106,43 @@ class PriceSnapshotTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(PriceSourceError, "result"):
|
with self.assertRaisesRegex(PriceSourceError, "result"):
|
||||||
normalize_yahoo_chart(payload, canonical_symbol="AOT", provider_symbol="AOT.BK", retrieved_at="2026-08-23T06:00:00+00:00", raw_payload_hash="a" * 64)
|
normalize_yahoo_chart(payload, canonical_symbol="AOT", provider_symbol="AOT.BK", retrieved_at="2026-08-23T06:00:00+00:00", raw_payload_hash="a" * 64)
|
||||||
|
|
||||||
|
def test_normalize_yahoo_chart_rejects_malformed_provider_shapes_and_numbers(self):
|
||||||
|
cases = []
|
||||||
|
|
||||||
|
payload = copy.deepcopy(PAYLOAD)
|
||||||
|
payload["chart"]["result"][0]["meta"] = []
|
||||||
|
cases.append(("meta", payload))
|
||||||
|
|
||||||
|
payload = copy.deepcopy(PAYLOAD)
|
||||||
|
payload["chart"]["result"][0]["indicators"]["quote"][0] = []
|
||||||
|
cases.append(("quote", payload))
|
||||||
|
|
||||||
|
payload = copy.deepcopy(PAYLOAD)
|
||||||
|
payload["chart"]["result"][0]["timestamp"][0] = 10**100
|
||||||
|
cases.append(("timestamp overflow", payload))
|
||||||
|
|
||||||
|
for label, field, value in (
|
||||||
|
("boolean timestamp", "timestamp", True),
|
||||||
|
("boolean close", "close", True),
|
||||||
|
("boolean volume", "volume", True),
|
||||||
|
):
|
||||||
|
payload = copy.deepcopy(PAYLOAD)
|
||||||
|
if field == "timestamp":
|
||||||
|
payload["chart"]["result"][0][field][0] = value
|
||||||
|
else:
|
||||||
|
payload["chart"]["result"][0]["indicators"]["quote"][0][field][0] = value
|
||||||
|
cases.append((label, payload))
|
||||||
|
|
||||||
|
for label, payload in cases:
|
||||||
|
with self.subTest(label=label), self.assertRaises(PriceSourceError):
|
||||||
|
normalize_yahoo_chart(
|
||||||
|
payload,
|
||||||
|
canonical_symbol="AOT",
|
||||||
|
provider_symbol="AOT.BK",
|
||||||
|
retrieved_at="2026-08-23T06:00:00+00:00",
|
||||||
|
raw_payload_hash="a" * 64,
|
||||||
|
)
|
||||||
|
|
||||||
def test_collector_persists_revised_vendor_history_snapshot(self):
|
def test_collector_persists_revised_vendor_history_snapshot(self):
|
||||||
provider = FakePriceProvider(PAYLOAD)
|
provider = FakePriceProvider(PAYLOAD)
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
@@ -93,6 +161,231 @@ class PriceSnapshotTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(manifest["snapshots"]), 1)
|
self.assertEqual(len(manifest["snapshots"]), 1)
|
||||||
self.assertEqual(len(provider.calls), 2)
|
self.assertEqual(len(provider.calls), 2)
|
||||||
|
|
||||||
|
def test_collector_records_observation_history_for_unchanged_payload(self):
|
||||||
|
provider = FakePriceProvider(PAYLOAD)
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
collect_price_snapshot(
|
||||||
|
root,
|
||||||
|
start="2026-01-01",
|
||||||
|
end="2026-01-06",
|
||||||
|
symbol_map={"AOT": "AOT.BK", "SET50": "^SET.BK"},
|
||||||
|
provider=provider,
|
||||||
|
retrieved_at="2026-08-23T06:00:00+00:00",
|
||||||
|
)
|
||||||
|
collect_price_snapshot(
|
||||||
|
root,
|
||||||
|
start="2026-01-01",
|
||||||
|
end="2026-01-06",
|
||||||
|
symbol_map={"AOT": "AOT.BK", "SET50": "^SET.BK"},
|
||||||
|
provider=provider,
|
||||||
|
retrieved_at="2026-08-24T06:00:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
manifest = store.load_manifest()
|
||||||
|
observations = manifest["observations"]
|
||||||
|
self.assertEqual(len(observations), 2)
|
||||||
|
self.assertEqual(observations[0]["revision_status"], "initial")
|
||||||
|
self.assertEqual(observations[1]["revision_status"], "unchanged")
|
||||||
|
self.assertEqual(observations[1]["previous_raw_payload_hash"], observations[0]["raw_payload_hash"])
|
||||||
|
self.assertFalse(observations[1]["diff"]["normalized_content_changed"])
|
||||||
|
entry = store.latest_snapshot_entry()
|
||||||
|
self.assertEqual(entry["first_seen_at"], "2026-08-23T06:00:00+00:00")
|
||||||
|
self.assertEqual(entry["last_seen_at"], "2026-08-24T06:00:00+00:00")
|
||||||
|
self.assertEqual(entry["observation_count"], 2)
|
||||||
|
|
||||||
|
def test_collector_records_revision_diff_without_overwriting_previous_snapshot(self):
|
||||||
|
changed_payload = copy.deepcopy(PAYLOAD)
|
||||||
|
changed_payload["chart"]["result"][0]["indicators"]["quote"][0]["close"][0] = 61.0
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
collect_price_snapshot(
|
||||||
|
root,
|
||||||
|
start="2026-01-01",
|
||||||
|
end="2026-01-06",
|
||||||
|
symbol_map={"AOT": "AOT.BK", "SET50": "^SET.BK"},
|
||||||
|
provider=FakePriceProvider(PAYLOAD),
|
||||||
|
retrieved_at="2026-08-23T06:00:00+00:00",
|
||||||
|
)
|
||||||
|
revised = collect_price_snapshot(
|
||||||
|
root,
|
||||||
|
start="2026-01-01",
|
||||||
|
end="2026-01-06",
|
||||||
|
symbol_map={"AOT": "AOT.BK", "SET50": "^SET.BK"},
|
||||||
|
provider=FakePriceProvider(changed_payload),
|
||||||
|
retrieved_at="2026-08-24T06:00:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
manifest = store.load_manifest()
|
||||||
|
observations = manifest["observations"]
|
||||||
|
self.assertEqual(len(manifest["snapshots"]), 2)
|
||||||
|
self.assertEqual(len(observations), 2)
|
||||||
|
self.assertEqual(observations[1]["revision_status"], "revised")
|
||||||
|
self.assertTrue(observations[1]["diff"]["normalized_content_changed"])
|
||||||
|
self.assertIn("series.AOT.bars[0].close", observations[1]["diff"]["changed_paths"])
|
||||||
|
self.assertEqual(observations[1]["previous_snapshot_id"], observations[0]["snapshot_id"])
|
||||||
|
self.assertNotEqual(revised["source"]["snapshot_id"], observations[0]["snapshot_id"])
|
||||||
|
self.assertEqual(store.load_snapshot(observations[0]["snapshot_id"])["series"]["AOT"]["bars"][0]["close"], 61.5)
|
||||||
|
|
||||||
|
def test_price_store_rejects_tampered_observation_audit_entry(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
collect_price_snapshot(
|
||||||
|
root,
|
||||||
|
start="2026-01-01",
|
||||||
|
end="2026-01-06",
|
||||||
|
symbol_map={"AOT": "AOT.BK", "SET50": "^SET.BK"},
|
||||||
|
provider=FakePriceProvider(PAYLOAD),
|
||||||
|
retrieved_at="2026-08-23T06:00:00+00:00",
|
||||||
|
)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
manifest = store.load_manifest()
|
||||||
|
manifest["observations"][0]["diff"]["changed_paths"] = ["series.AOT.bars[0].close"]
|
||||||
|
store.manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaises(PriceSourceError):
|
||||||
|
store.list_observations()
|
||||||
|
|
||||||
|
def test_price_store_rejects_recomputed_observation_id_with_mismatched_predecessor(self):
|
||||||
|
changed_payload = copy.deepcopy(PAYLOAD)
|
||||||
|
changed_payload["chart"]["result"][0]["indicators"]["quote"][0]["close"][0] = 61.0
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
collect_price_snapshot(
|
||||||
|
root,
|
||||||
|
start="2026-01-01",
|
||||||
|
end="2026-01-06",
|
||||||
|
symbol_map={"AOT": "AOT.BK", "SET50": "^SET.BK"},
|
||||||
|
provider=FakePriceProvider(PAYLOAD),
|
||||||
|
retrieved_at="2026-08-23T06:00:00+00:00",
|
||||||
|
)
|
||||||
|
collect_price_snapshot(
|
||||||
|
root,
|
||||||
|
start="2026-01-01",
|
||||||
|
end="2026-01-06",
|
||||||
|
symbol_map={"AOT": "AOT.BK", "SET50": "^SET.BK"},
|
||||||
|
provider=FakePriceProvider(changed_payload),
|
||||||
|
retrieved_at="2026-08-24T06:00:00+00:00",
|
||||||
|
)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
manifest = store.load_manifest()
|
||||||
|
tampered = manifest["observations"][1]
|
||||||
|
tampered["previous_raw_payload_hash"] = "0" * 64
|
||||||
|
tampered["observation_id"] = _observation_id({key: value for key, value in tampered.items() if key != "observation_id"})
|
||||||
|
store.manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "previous.*hash|observation"):
|
||||||
|
store.list_observations()
|
||||||
|
|
||||||
|
def test_price_store_rejects_recomputed_observation_id_with_tampered_diff(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
collect_price_snapshot(
|
||||||
|
root,
|
||||||
|
start="2026-01-01",
|
||||||
|
end="2026-01-06",
|
||||||
|
symbol_map={"AOT": "AOT.BK", "SET50": "^SET.BK"},
|
||||||
|
provider=FakePriceProvider(PAYLOAD),
|
||||||
|
retrieved_at="2026-08-23T06:00:00+00:00",
|
||||||
|
)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
manifest = store.load_manifest()
|
||||||
|
tampered = manifest["observations"][0]
|
||||||
|
tampered["diff"]["changed_paths"] = ["series.AOT.bars[0].close"]
|
||||||
|
tampered["observation_id"] = _observation_id({key: value for key, value in tampered.items() if key != "observation_id"})
|
||||||
|
store.manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "diff|observation"):
|
||||||
|
store.list_observations()
|
||||||
|
|
||||||
|
def test_price_store_rejects_malformed_observation_id_types_without_type_error(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
collect_price_snapshot(
|
||||||
|
root,
|
||||||
|
start="2026-01-01",
|
||||||
|
end="2026-01-06",
|
||||||
|
symbol_map={"AOT": "AOT.BK", "SET50": "^SET.BK"},
|
||||||
|
provider=FakePriceProvider(PAYLOAD),
|
||||||
|
retrieved_at="2026-08-23T06:00:00+00:00",
|
||||||
|
)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
manifest = store.load_manifest()
|
||||||
|
tampered = manifest["observations"][0]
|
||||||
|
tampered["snapshot_id"] = []
|
||||||
|
tampered["observation_id"] = _observation_id({key: value for key, value in tampered.items() if key != "observation_id"})
|
||||||
|
store.manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "observation"):
|
||||||
|
store.list_observations()
|
||||||
|
|
||||||
|
def test_price_store_rejects_observation_history_truncation(self):
|
||||||
|
changed_payload = copy.deepcopy(PAYLOAD)
|
||||||
|
changed_payload["chart"]["result"][0]["indicators"]["quote"][0]["close"][0] = 61.0
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
collect_price_snapshot(
|
||||||
|
root,
|
||||||
|
start="2026-01-01",
|
||||||
|
end="2026-01-06",
|
||||||
|
symbol_map={"AOT": "AOT.BK", "SET50": "^SET.BK"},
|
||||||
|
provider=FakePriceProvider(PAYLOAD),
|
||||||
|
retrieved_at="2026-08-23T06:00:00+00:00",
|
||||||
|
)
|
||||||
|
collect_price_snapshot(
|
||||||
|
root,
|
||||||
|
start="2026-01-01",
|
||||||
|
end="2026-01-06",
|
||||||
|
symbol_map={"AOT": "AOT.BK", "SET50": "^SET.BK"},
|
||||||
|
provider=FakePriceProvider(changed_payload),
|
||||||
|
retrieved_at="2026-08-24T06:00:00+00:00",
|
||||||
|
)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
manifest = store.load_manifest()
|
||||||
|
manifest["observations"] = manifest["observations"][:-1]
|
||||||
|
store.manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "observation"):
|
||||||
|
store.list_observations()
|
||||||
|
|
||||||
|
def test_collector_rejects_out_of_order_observation_after_future_capture(self):
|
||||||
|
revised_payload = copy.deepcopy(PAYLOAD)
|
||||||
|
revised_payload["chart"]["result"][0]["indicators"]["quote"][0]["close"][0] = 61.0
|
||||||
|
late_payload = copy.deepcopy(PAYLOAD)
|
||||||
|
late_payload["chart"]["result"][0]["indicators"]["quote"][0]["close"][0] = 62.0
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
collect_price_snapshot(
|
||||||
|
root,
|
||||||
|
start="2026-01-01",
|
||||||
|
end="2026-01-06",
|
||||||
|
symbol_map={"AOT": "AOT.BK", "SET50": "^SET.BK"},
|
||||||
|
provider=FakePriceProvider(PAYLOAD),
|
||||||
|
retrieved_at="2026-08-23T06:00:00+00:00",
|
||||||
|
)
|
||||||
|
collect_price_snapshot(
|
||||||
|
root,
|
||||||
|
start="2026-01-01",
|
||||||
|
end="2026-01-06",
|
||||||
|
symbol_map={"AOT": "AOT.BK", "SET50": "^SET.BK"},
|
||||||
|
provider=FakePriceProvider(revised_payload),
|
||||||
|
retrieved_at="2026-08-24T06:00:00+00:00",
|
||||||
|
)
|
||||||
|
before = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "immutable predecessor"):
|
||||||
|
collect_price_snapshot(
|
||||||
|
root,
|
||||||
|
start="2026-01-01",
|
||||||
|
end="2026-01-06",
|
||||||
|
symbol_map={"AOT": "AOT.BK", "SET50": "^SET.BK"},
|
||||||
|
provider=FakePriceProvider(late_payload),
|
||||||
|
retrieved_at="2026-08-22T06:00:00+00:00",
|
||||||
|
)
|
||||||
|
after = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(after, before)
|
||||||
|
|
||||||
def test_price_store_rejects_snapshot_with_mismatched_raw_hash(self):
|
def test_price_store_rejects_snapshot_with_mismatched_raw_hash(self):
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
store = PriceSnapshotStore(Path(temp_dir))
|
store = PriceSnapshotStore(Path(temp_dir))
|
||||||
@@ -100,6 +393,469 @@ class PriceSnapshotTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(PriceSourceError, "raw payload hash"):
|
with self.assertRaisesRegex(PriceSourceError, "raw payload hash"):
|
||||||
store.persist(snapshot, {"AOT.BK": b"different"})
|
store.persist(snapshot, {"AOT.BK": b"different"})
|
||||||
|
|
||||||
|
def test_price_store_rejects_same_raw_hash_with_changed_stable_content(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
snapshot = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"source": {
|
||||||
|
"source_id": "test.prices",
|
||||||
|
"snapshot_id": "prices-same-raw",
|
||||||
|
"retrieved_at": "2026-02-01T00:00:00+00:00",
|
||||||
|
"period_start": "2026-01-01",
|
||||||
|
"period_end": "2026-01-01",
|
||||||
|
"quality": "revised_vendor_history",
|
||||||
|
"point_in_time": False,
|
||||||
|
},
|
||||||
|
"series": {
|
||||||
|
"AOT": {
|
||||||
|
"canonical_symbol": "AOT",
|
||||||
|
"provider_symbol": "AOT.BK",
|
||||||
|
"raw_payload_hash": hashlib.sha256(b"aot").hexdigest(),
|
||||||
|
"timezone": "Asia/Bangkok",
|
||||||
|
"bars": [{"date": "2026-01-01", "close": 100.0}],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
raw = {"AOT.BK": b"aot"}
|
||||||
|
store.persist(snapshot, raw)
|
||||||
|
changed = copy.deepcopy(snapshot)
|
||||||
|
changed["series"]["AOT"]["bars"][0]["close"] = 101.0
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "immutable"):
|
||||||
|
store.persist(changed, raw)
|
||||||
|
|
||||||
|
def test_price_store_rejects_same_raw_payload_with_changed_normalized_content(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
initial = valid_non_pit_snapshot("prices-same-raw-initial", "2026-02-01T00:00:00+00:00")
|
||||||
|
store.persist(initial, {"AOT.BK": b"aot"})
|
||||||
|
|
||||||
|
revised = valid_non_pit_snapshot("prices-same-raw-revised", "2026-02-02T00:00:00+00:00")
|
||||||
|
revised["series"]["AOT"]["bars"][0]["close"] = 101.0
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "normalized content"):
|
||||||
|
store.persist(revised, {"AOT.BK": b"aot"})
|
||||||
|
|
||||||
|
def test_price_store_rejects_point_in_time_flag_without_archive_contract(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
store = PriceSnapshotStore(Path(temp_dir))
|
||||||
|
snapshot = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"source": {
|
||||||
|
"source_id": "test.prices",
|
||||||
|
"snapshot_id": "prices-pit-missing-contract",
|
||||||
|
"retrieved_at": "2026-02-01T00:00:00+00:00",
|
||||||
|
"period_start": "2026-01-01",
|
||||||
|
"period_end": "2026-01-02",
|
||||||
|
"quality": "forward_market_archive",
|
||||||
|
"point_in_time": True,
|
||||||
|
},
|
||||||
|
"series": {"AOT": {"bars": [{"date": "2026-01-01", "close": 100.0}]}},
|
||||||
|
}
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "point-in-time archive contract"):
|
||||||
|
store.persist(snapshot, {"AOT.BK": b"aot"})
|
||||||
|
|
||||||
|
def test_price_store_requires_known_at_for_each_pit_bar(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
store = PriceSnapshotStore(Path(temp_dir))
|
||||||
|
snapshot = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"source": {
|
||||||
|
"source_id": "test.prices",
|
||||||
|
"source_url": "https://example.test/prices",
|
||||||
|
"snapshot_id": "prices-pit-missing-known-at",
|
||||||
|
"retrieved_at": "2026-02-01T00:00:00+00:00",
|
||||||
|
"period_start": "2026-01-01",
|
||||||
|
"period_end": "2026-01-01",
|
||||||
|
"quality": "point_in_time_archive",
|
||||||
|
"point_in_time": True,
|
||||||
|
"archive_contract": PIT_ARCHIVE_CONTRACT,
|
||||||
|
"provider_release_id": "release-2026-01-01",
|
||||||
|
"parser_version": "pit-test-v1",
|
||||||
|
"point_in_time_evidence": {
|
||||||
|
"known_at_field": "known_at",
|
||||||
|
"known_at_semantics": "provider_release_time",
|
||||||
|
"provider_release_id": "release-2026-01-01",
|
||||||
|
"release_published_at": "2026-01-01T17:00:00+07:00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"series": {"AOT": {"canonical_symbol": "AOT", "timezone": "Asia/Bangkok", "bars": [{"session_date": "2026-01-01", "close": 100.0}]}},
|
||||||
|
}
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "known_at"):
|
||||||
|
store.persist(snapshot, {"AOT.BK": b"aot"})
|
||||||
|
|
||||||
|
def test_price_store_persists_and_loads_explicit_pit_archive_contract(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
store = PriceSnapshotStore(Path(temp_dir))
|
||||||
|
snapshot = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"source": {
|
||||||
|
"source_id": "test.prices",
|
||||||
|
"source_url": "https://example.test/prices",
|
||||||
|
"snapshot_id": "prices-pit-valid",
|
||||||
|
"retrieved_at": "2026-02-01T00:00:00+00:00",
|
||||||
|
"period_start": "2026-01-01",
|
||||||
|
"period_end": "2026-01-01",
|
||||||
|
"quality": "point_in_time_archive",
|
||||||
|
"point_in_time": True,
|
||||||
|
"archive_contract": PIT_ARCHIVE_CONTRACT,
|
||||||
|
"provider_release_id": "release-2026-01-01",
|
||||||
|
"parser_version": "pit-test-v1",
|
||||||
|
"point_in_time_evidence": {
|
||||||
|
"known_at_field": "known_at",
|
||||||
|
"known_at_semantics": "provider_release_time",
|
||||||
|
"provider_release_id": "release-2026-01-01",
|
||||||
|
"release_published_at": "2026-01-01T17:00:00+07:00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"series": {
|
||||||
|
"AOT": {
|
||||||
|
"canonical_symbol": "AOT",
|
||||||
|
"timezone": "Asia/Bangkok",
|
||||||
|
"bars": [{
|
||||||
|
"session_date": "2026-01-01",
|
||||||
|
"open": 99.0,
|
||||||
|
"high": 101.0,
|
||||||
|
"low": 98.0,
|
||||||
|
"close": 100.0,
|
||||||
|
"adjusted_close": 100.0,
|
||||||
|
"volume": 1000,
|
||||||
|
"known_at": "2026-01-01T17:00:00+07:00",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
loaded = store.persist(snapshot, {"AOT.BK": b"aot"})
|
||||||
|
|
||||||
|
self.assertEqual(loaded["source"]["archive_contract"], PIT_ARCHIVE_CONTRACT)
|
||||||
|
self.assertEqual(store.load_snapshot("prices-pit-valid")["source"]["provider_release_id"], "release-2026-01-01")
|
||||||
|
manifest_entry = store.load_manifest()["snapshots"]["prices-pit-valid"]
|
||||||
|
self.assertEqual(manifest_entry["archive_contract"], PIT_ARCHIVE_CONTRACT)
|
||||||
|
self.assertTrue(manifest_entry["point_in_time"])
|
||||||
|
|
||||||
|
snapshot["series"]["AOT"]["bars"][0]["close"] = 101.0
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "immutable"):
|
||||||
|
store.persist(snapshot, {"AOT.BK": b"aot-revision"})
|
||||||
|
|
||||||
|
manifest = store.load_manifest()
|
||||||
|
manifest["snapshots"]["prices-pit-valid"]["period_end"] = "2026-01-02"
|
||||||
|
(Path(temp_dir) / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "point-in-time metadata"):
|
||||||
|
store.load_snapshot("prices-pit-valid")
|
||||||
|
|
||||||
|
def test_price_store_totalizes_extreme_pit_numeric_values(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
store = PriceSnapshotStore(Path(temp_dir))
|
||||||
|
snapshot = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"source": {
|
||||||
|
"source_id": "test.prices",
|
||||||
|
"source_url": "https://example.test/prices",
|
||||||
|
"snapshot_id": "prices-pit-extreme-number",
|
||||||
|
"retrieved_at": "2026-02-01T00:00:00+00:00",
|
||||||
|
"period_start": "2026-01-01",
|
||||||
|
"period_end": "2026-01-01",
|
||||||
|
"quality": "point_in_time_archive",
|
||||||
|
"point_in_time": True,
|
||||||
|
"archive_contract": PIT_ARCHIVE_CONTRACT,
|
||||||
|
"provider_release_id": "release-2026-01-01",
|
||||||
|
"parser_version": "pit-test-v1",
|
||||||
|
"point_in_time_evidence": {
|
||||||
|
"known_at_field": "known_at",
|
||||||
|
"known_at_semantics": "provider_release_time",
|
||||||
|
"provider_release_id": "release-2026-01-01",
|
||||||
|
"release_published_at": "2026-01-01T17:00:00+07:00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"series": {
|
||||||
|
"AOT": {
|
||||||
|
"canonical_symbol": "AOT",
|
||||||
|
"timezone": "Asia/Bangkok",
|
||||||
|
"bars": [{
|
||||||
|
"session_date": "2026-01-01",
|
||||||
|
"open": 99.0,
|
||||||
|
"high": 101.0,
|
||||||
|
"low": 98.0,
|
||||||
|
"close": 100.0,
|
||||||
|
"adjusted_close": 100.0,
|
||||||
|
"known_at": "2026-01-01T17:00:00+07:00",
|
||||||
|
"volume": 10**1000,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "point-in-time archive volume"):
|
||||||
|
store.persist(snapshot, {"AOT.BK": b"aot"})
|
||||||
|
|
||||||
|
def test_price_store_rejects_non_object_manifest_before_persist(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
(root / "manifest.json").write_text("[]", encoding="utf-8")
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
snapshot = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"source": {"source_id": "test.prices", "snapshot_id": "prices-malformed-manifest", "point_in_time": False},
|
||||||
|
"series": {},
|
||||||
|
}
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "manifest"):
|
||||||
|
store.persist(snapshot, {"AOT.BK": b"aot"})
|
||||||
|
|
||||||
|
def test_price_store_rejects_invalid_raw_payload_symbol(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
store = PriceSnapshotStore(Path(temp_dir))
|
||||||
|
snapshot = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"source": {"source_id": "test.prices", "snapshot_id": "prices-invalid-raw-symbol", "point_in_time": False},
|
||||||
|
"series": {},
|
||||||
|
}
|
||||||
|
raw_payloads: Any = {1: b"aot"}
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "raw payload"):
|
||||||
|
store.persist(snapshot, raw_payloads)
|
||||||
|
def test_price_store_rejects_series_provenance_mismatch_after_normalized_hash_refresh(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
snapshot = valid_non_pit_snapshot()
|
||||||
|
store.persist(snapshot, {"AOT.BK": b"aot"})
|
||||||
|
snapshot_path = root / "snapshots" / "prices-valid.json"
|
||||||
|
original = json.loads(snapshot_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
for field, value in (("provider_symbol", "PTT.BK"), ("raw_payload_hash", "0" * 64)):
|
||||||
|
tampered = copy.deepcopy(original)
|
||||||
|
tampered["series"]["AOT"][field] = value
|
||||||
|
tampered["source"]["normalized_snapshot_hash"] = _normalized_snapshot_hash(tampered)
|
||||||
|
snapshot_path.write_text(json.dumps(tampered), encoding="utf-8")
|
||||||
|
with self.subTest(field=field), self.assertRaisesRegex(PriceSourceError, "series"):
|
||||||
|
store.load_snapshot("prices-valid")
|
||||||
|
|
||||||
|
def test_price_store_rejects_missing_required_metadata_and_malformed_non_pit_series(self):
|
||||||
|
cases = []
|
||||||
|
snapshot = valid_non_pit_snapshot("prices-missing-schema")
|
||||||
|
snapshot.pop("schema_version")
|
||||||
|
cases.append(("schema_version", snapshot))
|
||||||
|
|
||||||
|
snapshot = valid_non_pit_snapshot("prices-missing-pit-flag")
|
||||||
|
snapshot["source"].pop("point_in_time")
|
||||||
|
cases.append(("point_in_time", snapshot))
|
||||||
|
|
||||||
|
snapshot = valid_non_pit_snapshot("prices-missing-source-id")
|
||||||
|
snapshot["source"].pop("source_id")
|
||||||
|
cases.append(("source identity", snapshot))
|
||||||
|
|
||||||
|
snapshot = valid_non_pit_snapshot("prices-malformed-series")
|
||||||
|
snapshot["series"]["AOT"]["bars"] = "not-a-list"
|
||||||
|
cases.append(("normalized series", snapshot))
|
||||||
|
|
||||||
|
snapshot = valid_non_pit_snapshot("prices-duplicate-bars")
|
||||||
|
snapshot["series"]["AOT"]["bars"].append(copy.deepcopy(snapshot["series"]["AOT"]["bars"][0]))
|
||||||
|
cases.append(("duplicate normalized dates", snapshot))
|
||||||
|
|
||||||
|
for label, snapshot in cases:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
with self.subTest(label=label), self.assertRaises(PriceSourceError):
|
||||||
|
PriceSnapshotStore(Path(temp_dir)).persist(snapshot, {"AOT.BK": b"aot"})
|
||||||
|
|
||||||
|
def test_price_store_rejects_non_pit_manifest_metadata_tampering(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
store.persist(valid_non_pit_snapshot(), {"AOT.BK": b"aot"})
|
||||||
|
manifest_path = root / "manifest.json"
|
||||||
|
original = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
for field, value in (("parser_version", "tampered-parser"), ("symbols", ["PTT"])):
|
||||||
|
manifest = copy.deepcopy(original)
|
||||||
|
manifest["snapshots"]["prices-valid"][field] = value
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
with self.subTest(field=field), self.assertRaisesRegex(PriceSourceError, "metadata"):
|
||||||
|
store.load_snapshot("prices-valid")
|
||||||
|
|
||||||
|
manifest = copy.deepcopy(original)
|
||||||
|
manifest["snapshots"]["prices-valid"].pop("source_url")
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "metadata"):
|
||||||
|
store.load_snapshot("prices-valid")
|
||||||
|
|
||||||
|
def test_price_store_rejects_out_of_order_insertion_without_changing_manifest(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
late = valid_non_pit_snapshot("prices-late", "2026-02-01T18:30:00-07:00")
|
||||||
|
early = valid_non_pit_snapshot("prices-early", "2026-02-02T00:00:00+00:00")
|
||||||
|
store.persist(late, {"AOT.BK": b"aot"})
|
||||||
|
before = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "immutable predecessor"):
|
||||||
|
store.persist(early, {"AOT.BK": b"aot"})
|
||||||
|
|
||||||
|
after = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(after, before)
|
||||||
|
self.assertFalse((root / "snapshots" / "prices-early.json").exists())
|
||||||
|
|
||||||
|
def test_price_store_uses_deterministic_predecessor_for_equal_timestamps(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
timestamp = "2026-02-01T00:00:00+00:00"
|
||||||
|
store.persist(valid_non_pit_snapshot("prices-a", timestamp), {"AOT.BK": b"aot"})
|
||||||
|
store.persist(valid_non_pit_snapshot("prices-b", timestamp), {"AOT.BK": b"aot"})
|
||||||
|
|
||||||
|
observation = next(item for item in store.load_manifest()["observations"] if item["snapshot_id"] == "prices-b")
|
||||||
|
self.assertEqual(observation["previous_snapshot_id"], "prices-a")
|
||||||
|
|
||||||
|
def test_price_store_rejects_reverse_arrival_for_equal_timestamp(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
timestamp = "2026-02-01T00:00:00+00:00"
|
||||||
|
store.persist(valid_non_pit_snapshot("prices-b", timestamp), {"AOT.BK": b"aot"})
|
||||||
|
before = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "immutable predecessor"):
|
||||||
|
store.persist(valid_non_pit_snapshot("prices-a", timestamp), {"AOT.BK": b"aot"})
|
||||||
|
|
||||||
|
after = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(after, before)
|
||||||
|
|
||||||
|
def test_price_store_treats_same_snapshot_same_timestamp_as_idempotent(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
snapshot = valid_non_pit_snapshot("prices-idempotent", "2026-02-01T00:00:00+00:00")
|
||||||
|
store.persist(snapshot, {"AOT.BK": b"aot"})
|
||||||
|
store.persist(copy.deepcopy(snapshot), {"AOT.BK": b"aot"})
|
||||||
|
|
||||||
|
self.assertEqual(len(store.list_observations()), 1)
|
||||||
|
|
||||||
|
def test_price_store_rejects_equal_timestamp_initial_node_with_canonical_predecessor(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
timestamp = "2026-02-01T00:00:00+00:00"
|
||||||
|
store.persist(valid_non_pit_snapshot("prices-a", timestamp), {"AOT.BK": b"aot"})
|
||||||
|
store.persist(valid_non_pit_snapshot("prices-b", timestamp), {"AOT.BK": b"aot"})
|
||||||
|
manifest_path = root / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
observation = next(item for item in manifest["observations"] if item["snapshot_id"] == "prices-b")
|
||||||
|
observation.pop("observation_id")
|
||||||
|
observation["previous_snapshot_id"] = None
|
||||||
|
observation["previous_raw_payload_hash"] = None
|
||||||
|
observation["revision_status"] = "initial"
|
||||||
|
observation["observation_id"] = _observation_id(observation)
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "predecessor chain"):
|
||||||
|
store.load_manifest()
|
||||||
|
|
||||||
|
def test_price_store_rejects_duplicate_equal_time_observations_for_one_snapshot(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
snapshot_id = "prices-duplicate-time"
|
||||||
|
store.persist(valid_non_pit_snapshot(snapshot_id, "2026-02-01T00:00:00+00:00"), {"AOT.BK": b"aot"})
|
||||||
|
manifest_path = root / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
original = manifest["observations"][0]
|
||||||
|
duplicate_diff = copy.deepcopy(original["diff"])
|
||||||
|
duplicate_diff["raw_payload_changed"] = False
|
||||||
|
observations = []
|
||||||
|
for retrieved_at in ("2026-02-01T00:00:00+00:00", "2026-02-01T00:00:00Z"):
|
||||||
|
observation = copy.deepcopy(original)
|
||||||
|
observation["retrieved_at"] = retrieved_at
|
||||||
|
observation["previous_snapshot_id"] = snapshot_id
|
||||||
|
observation["previous_raw_payload_hash"] = original["raw_payload_hash"]
|
||||||
|
observation["revision_status"] = "unchanged"
|
||||||
|
observation["diff"] = copy.deepcopy(duplicate_diff)
|
||||||
|
observation["observation_id"] = _observation_id(
|
||||||
|
{key: value for key, value in observation.items() if key != "observation_id"}
|
||||||
|
)
|
||||||
|
observations.append(observation)
|
||||||
|
manifest["observations"] = observations
|
||||||
|
entry = manifest["snapshots"][snapshot_id]
|
||||||
|
entry["observation_count"] = 2
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "ambiguous observation"):
|
||||||
|
store.load_manifest()
|
||||||
|
|
||||||
|
def test_price_store_orders_latest_snapshot_by_utc_time(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
store.persist(valid_non_pit_snapshot("prices-early", "2026-02-02T00:00:00+00:00"), {"AOT.BK": b"aot"})
|
||||||
|
store.persist(valid_non_pit_snapshot("prices-late", "2026-02-01T18:30:00-07:00"), {"AOT.BK": b"aot"})
|
||||||
|
|
||||||
|
self.assertEqual(store.latest_snapshot_entry()["snapshot_id"], "prices-late")
|
||||||
|
|
||||||
|
def test_price_store_validates_timing_metadata_when_observation_count_is_omitted(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
store.persist(valid_non_pit_snapshot(), {"AOT.BK": b"aot"})
|
||||||
|
manifest_path = root / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
entry = manifest["snapshots"]["prices-valid"]
|
||||||
|
entry.pop("observation_count")
|
||||||
|
entry["first_seen_at"] = "not-a-timestamp"
|
||||||
|
entry["last_seen_at"] = "not-a-timestamp"
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "timing"):
|
||||||
|
store.load_manifest()
|
||||||
|
|
||||||
|
def test_price_store_rejects_observation_before_snapshot_first_capture(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
store.persist(valid_non_pit_snapshot("prices-temporal", "2026-02-01T00:00:00+00:00"), {"AOT.BK": b"aot"})
|
||||||
|
manifest_path = root / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
observation = manifest["observations"][0]
|
||||||
|
observation["retrieved_at"] = "2026-01-31T00:00:00+00:00"
|
||||||
|
observation["observation_id"] = _observation_id({key: value for key, value in observation.items() if key != "observation_id"})
|
||||||
|
entry = manifest["snapshots"]["prices-temporal"]
|
||||||
|
entry["first_seen_at"] = "2026-01-31T00:00:00+00:00"
|
||||||
|
entry["last_seen_at"] = "2026-01-31T00:00:00+00:00"
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "before snapshot"):
|
||||||
|
store.load_manifest()
|
||||||
|
|
||||||
|
def test_price_store_rejects_unreferenced_contract_snapshot(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
store.persist(valid_non_pit_snapshot(), {"AOT.BK": b"aot"})
|
||||||
|
manifest_path = root / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
unreferenced = copy.deepcopy(manifest["snapshots"]["prices-valid"])
|
||||||
|
unreferenced["snapshot_id"] = "prices-unreferenced"
|
||||||
|
unreferenced["snapshot_file"] = "prices-unreferenced.json"
|
||||||
|
unreferenced.pop("observation_count")
|
||||||
|
unreferenced.pop("first_seen_at")
|
||||||
|
unreferenced.pop("last_seen_at")
|
||||||
|
manifest["snapshots"]["prices-unreferenced"] = unreferenced
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "observation count"):
|
||||||
|
store.load_manifest()
|
||||||
|
|
||||||
|
def test_price_store_rejects_malformed_snapshot_manifest_entry(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = PriceSnapshotStore(root)
|
||||||
|
store.persist(valid_non_pit_snapshot(), {"AOT.BK": b"aot"})
|
||||||
|
manifest_path = root / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
manifest["snapshots"]["prices-malformed-entry"] = []
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PriceSourceError, "snapshot manifest entry"):
|
||||||
|
store.load_manifest()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import copy
|
import copy
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
from app.prices import PriceSnapshotStore, PriceSourceError
|
from app.prices import PriceSnapshotStore, PriceSourceError
|
||||||
from app.research import ResearchRunError, ResearchRunStore, run_tourism_research
|
from app.research import ResearchRunError, ResearchRunStore, _manifest_entry_hash, _report_hash, _validate_config, run_tourism_research
|
||||||
from app.vintages import VintageStore
|
from app.vintages import VintageStore
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ def tourism_snapshot(vintage_id: str, published_at: str) -> dict:
|
|||||||
"source_url": "https://example.test/bot",
|
"source_url": "https://example.test/bot",
|
||||||
"vintage_id": vintage_id,
|
"vintage_id": vintage_id,
|
||||||
"published_at": published_at,
|
"published_at": published_at,
|
||||||
"retrieved_at": "2026-02-01T00:00:00+00:00",
|
"retrieved_at": "2026-02-02T00:00:00+00:00",
|
||||||
"release_status": "provisional",
|
"release_status": "provisional",
|
||||||
"parser_version": "test-v1",
|
"parser_version": "test-v1",
|
||||||
},
|
},
|
||||||
@@ -35,23 +37,51 @@ def tourism_snapshot(vintage_id: str, published_at: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def price_snapshot(point_in_time: bool) -> tuple[dict, dict[str, bytes]]:
|
def price_snapshot(point_in_time: bool) -> tuple[dict, dict[str, bytes]]:
|
||||||
|
def bar(session_date: str, close: float) -> dict:
|
||||||
|
if not point_in_time:
|
||||||
|
return {"date": session_date, "close": close}
|
||||||
|
return {
|
||||||
|
"date": session_date,
|
||||||
|
"session_date": session_date,
|
||||||
|
"open": close,
|
||||||
|
"high": close + 1,
|
||||||
|
"low": close - 1,
|
||||||
|
"close": close,
|
||||||
|
"adjusted_close": close,
|
||||||
|
"volume": 1000,
|
||||||
|
"known_at": f"{session_date}T17:00:00+07:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
pit_source = {
|
||||||
|
"source_url": "https://example.test/prices",
|
||||||
|
"parser_version": "pit-test-v1",
|
||||||
|
"archive_contract": "pit-daily-v1",
|
||||||
|
"provider_release_id": "release-2026-02-01",
|
||||||
|
"point_in_time_evidence": {
|
||||||
|
"known_at_field": "known_at",
|
||||||
|
"known_at_semantics": "provider_release_time",
|
||||||
|
"provider_release_id": "release-2026-02-01",
|
||||||
|
"release_published_at": "2026-02-01T17:00:00+07:00",
|
||||||
|
},
|
||||||
|
} if point_in_time else {}
|
||||||
return (
|
return (
|
||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"source": {
|
"source": {
|
||||||
"source_id": "test.prices",
|
"source_id": "test.prices",
|
||||||
"snapshot_id": "prices-test",
|
"snapshot_id": "prices-test",
|
||||||
"retrieved_at": "2026-02-01T00:00:00+00:00",
|
"retrieved_at": "2026-02-02T00:00:00+00:00",
|
||||||
"period_start": "2026-01-01",
|
"period_start": "2026-01-01",
|
||||||
"period_end": "2026-01-06",
|
"period_end": "2026-01-06",
|
||||||
"quality": "forward_market_archive" if point_in_time else "revised_vendor_history",
|
"quality": "point_in_time_archive" if point_in_time else "revised_vendor_history",
|
||||||
"point_in_time": point_in_time,
|
"point_in_time": point_in_time,
|
||||||
"adjusted_prices": False,
|
"adjusted_prices": False,
|
||||||
|
**pit_source,
|
||||||
},
|
},
|
||||||
"series": {
|
"series": {
|
||||||
"AOT": {"bars": [{"date": "2026-01-01", "close": 100.0}, {"date": "2026-01-02", "close": 102.0}, {"date": "2026-01-05", "close": 104.0}, {"date": "2026-01-06", "close": 106.0}]},
|
"AOT": {"canonical_symbol": "AOT", "provider_symbol": "AOT.BK", "raw_payload_hash": hashlib.sha256(b"aot").hexdigest(), "timezone": "Asia/Bangkok", "bars": [bar("2026-01-01", 100.0), bar("2026-01-02", 102.0), bar("2026-01-05", 104.0), bar("2026-01-06", 106.0)]},
|
||||||
"PTT": {"bars": [{"date": "2026-01-01", "close": 100.0}, {"date": "2026-01-02", "close": 99.0}, {"date": "2026-01-05", "close": 98.0}, {"date": "2026-01-06", "close": 97.0}]},
|
"PTT": {"canonical_symbol": "PTT", "provider_symbol": "PTT.BK", "raw_payload_hash": hashlib.sha256(b"ptt").hexdigest(), "timezone": "Asia/Bangkok", "bars": [bar("2026-01-01", 100.0), bar("2026-01-02", 99.0), bar("2026-01-05", 98.0), bar("2026-01-06", 97.0)]},
|
||||||
"SET50": {"bars": [{"date": "2026-01-01", "close": 100.0}, {"date": "2026-01-02", "close": 101.0}, {"date": "2026-01-05", "close": 102.0}, {"date": "2026-01-06", "close": 103.0}]},
|
"SET50": {"canonical_symbol": "SET50", "provider_symbol": "^SET.BK", "raw_payload_hash": hashlib.sha256(b"set").hexdigest(), "timezone": "Asia/Bangkok", "bars": [bar("2026-01-01", 100.0), bar("2026-01-02", 101.0), bar("2026-01-05", 102.0), bar("2026-01-06", 103.0)]},
|
||||||
},
|
},
|
||||||
"benchmark_symbol": "SET50",
|
"benchmark_symbol": "SET50",
|
||||||
},
|
},
|
||||||
@@ -60,6 +90,364 @@ def price_snapshot(point_in_time: bool) -> tuple[dict, dict[str, bytes]]:
|
|||||||
|
|
||||||
|
|
||||||
class ResearchRunTests(unittest.TestCase):
|
class ResearchRunTests(unittest.TestCase):
|
||||||
|
def test_research_config_rejects_malformed_values_with_domain_error(self):
|
||||||
|
cases = (
|
||||||
|
((None,), 20.0, 1, 1),
|
||||||
|
((float("nan"),), 20.0, 1, 1),
|
||||||
|
((1,), float("inf"), 1, 1),
|
||||||
|
((1,), 20.0, None, 1),
|
||||||
|
((1,), 20.0, 1, float("inf")),
|
||||||
|
)
|
||||||
|
for windows, cost_bps, min_events, execution_lag in cases:
|
||||||
|
with self.subTest(windows=windows, cost_bps=cost_bps, min_events=min_events, execution_lag=execution_lag):
|
||||||
|
with self.assertRaises(ResearchRunError):
|
||||||
|
_validate_config(
|
||||||
|
cast(Any, windows),
|
||||||
|
cast(Any, cost_bps),
|
||||||
|
cast(Any, min_events),
|
||||||
|
cast(Any, execution_lag),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_research_run_store_binds_report_hash_and_rejects_tampering(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = ResearchRunStore(root)
|
||||||
|
report = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"run_id": "tourism-run-test",
|
||||||
|
"theme": "tourism",
|
||||||
|
"status": "blocked",
|
||||||
|
"reason": "test",
|
||||||
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
||||||
|
"config": {},
|
||||||
|
"gates": {},
|
||||||
|
"inputs": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
stored = store.persist(report)
|
||||||
|
|
||||||
|
self.assertRegex(stored["report_hash"], r"^[a-f0-9]{64}$")
|
||||||
|
report_path = root / "reports" / "tourism-run-test.json"
|
||||||
|
tampered = json.loads(report_path.read_text(encoding="utf-8"))
|
||||||
|
tampered["reason"] = "tampered"
|
||||||
|
report_path.write_text(json.dumps(tampered), encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(ResearchRunError, "integrity"):
|
||||||
|
store.load("tourism-run-test")
|
||||||
|
|
||||||
|
def test_research_run_store_rejects_manifest_hash_tampering(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = ResearchRunStore(root)
|
||||||
|
report = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"run_id": "tourism-run-manifest-test",
|
||||||
|
"theme": "tourism",
|
||||||
|
"status": "blocked",
|
||||||
|
"reason": "test",
|
||||||
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
||||||
|
"config": {},
|
||||||
|
"gates": {},
|
||||||
|
"inputs": {},
|
||||||
|
}
|
||||||
|
store.persist(report)
|
||||||
|
|
||||||
|
manifest_path = root / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
manifest["runs"]["tourism-run-manifest-test"]["report_hash"] = "0" * 64
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(ResearchRunError, "integrity"):
|
||||||
|
store.load("tourism-run-manifest-test")
|
||||||
|
|
||||||
|
def test_research_run_store_migrates_legacy_reports_explicitly(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = ResearchRunStore(root)
|
||||||
|
report = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"run_id": "tourism-run-legacy-test",
|
||||||
|
"theme": "tourism",
|
||||||
|
"status": "blocked",
|
||||||
|
"reason": "test",
|
||||||
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
||||||
|
"config": {},
|
||||||
|
"gates": {},
|
||||||
|
"inputs": {},
|
||||||
|
}
|
||||||
|
legacy_manifest = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"runs": {
|
||||||
|
"tourism-run-legacy-test": {
|
||||||
|
"run_id": "tourism-run-legacy-test",
|
||||||
|
"status": "blocked",
|
||||||
|
"reason": "test",
|
||||||
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
||||||
|
"report_file": "tourism-run-legacy-test.json",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(root / "reports").mkdir(parents=True)
|
||||||
|
(root / "reports" / "tourism-run-legacy-test.json").write_text(json.dumps(report), encoding="utf-8")
|
||||||
|
(root / "manifest.json").write_text(json.dumps(legacy_manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ResearchRunError, "integrity"):
|
||||||
|
store.load("tourism-run-legacy-test")
|
||||||
|
|
||||||
|
self.assertEqual(store.migrate_legacy(), 1)
|
||||||
|
migrated = store.load("tourism-run-legacy-test")
|
||||||
|
manifest_entry = store.load_manifest()["runs"]["tourism-run-legacy-test"]
|
||||||
|
self.assertEqual(migrated["report_hash_algorithm"], "sha256-json-canonical-v1")
|
||||||
|
self.assertRegex(migrated["report_hash"], r"^[a-f0-9]{64}$")
|
||||||
|
self.assertRegex(manifest_entry["manifest_entry_hash"], r"^[a-f0-9]{64}$")
|
||||||
|
self.assertEqual(store.migrate_legacy(), 0)
|
||||||
|
|
||||||
|
def test_research_run_store_completes_interrupted_legacy_migration(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = ResearchRunStore(root)
|
||||||
|
report = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"run_id": "tourism-run-partial-migration-test",
|
||||||
|
"theme": "tourism",
|
||||||
|
"status": "blocked",
|
||||||
|
"reason": "test",
|
||||||
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
||||||
|
"config": {},
|
||||||
|
"gates": {},
|
||||||
|
"inputs": {},
|
||||||
|
}
|
||||||
|
partial_report = copy.deepcopy(report)
|
||||||
|
partial_report["report_hash_algorithm"] = "sha256-json-canonical-v1"
|
||||||
|
partial_report["report_hash"] = _report_hash(partial_report)
|
||||||
|
legacy_entry = {
|
||||||
|
"run_id": report["run_id"],
|
||||||
|
"status": report["status"],
|
||||||
|
"reason": report["reason"],
|
||||||
|
"generated_at": report["generated_at"],
|
||||||
|
"report_file": f"{report['run_id']}.json",
|
||||||
|
}
|
||||||
|
(root / "reports").mkdir(parents=True)
|
||||||
|
(root / "reports" / f"{report['run_id']}.json").write_text(json.dumps(partial_report), encoding="utf-8")
|
||||||
|
(root / "manifest.json").write_text(json.dumps({"schema_version": 1, "runs": {report["run_id"]: legacy_entry}}), encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertEqual(store.migrate_legacy(), 1)
|
||||||
|
self.assertEqual(store.load(report["run_id"])["report_hash"], partial_report["report_hash"])
|
||||||
|
self.assertEqual(store.migrate_legacy(), 0)
|
||||||
|
|
||||||
|
def test_research_run_store_rejects_manifest_identity_tampering(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = ResearchRunStore(root)
|
||||||
|
report = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"run_id": "tourism-run-identity-test",
|
||||||
|
"theme": "tourism",
|
||||||
|
"status": "blocked",
|
||||||
|
"reason": "test",
|
||||||
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
||||||
|
"config": {},
|
||||||
|
"gates": {},
|
||||||
|
"inputs": {},
|
||||||
|
}
|
||||||
|
store.persist(report)
|
||||||
|
|
||||||
|
manifest_path = root / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
entry = manifest["runs"]["tourism-run-identity-test"]
|
||||||
|
entry["run_id"] = "tourism-run-other"
|
||||||
|
entry["manifest_entry_hash"] = _manifest_entry_hash(entry)
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ResearchRunError, "integrity"):
|
||||||
|
store.load("tourism-run-identity-test")
|
||||||
|
|
||||||
|
def test_research_run_store_rejects_manifest_metadata_tampering(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = ResearchRunStore(root)
|
||||||
|
report = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"run_id": "tourism-run-metadata-test",
|
||||||
|
"theme": "tourism",
|
||||||
|
"status": "blocked",
|
||||||
|
"reason": "test",
|
||||||
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
||||||
|
"config": {},
|
||||||
|
"gates": {},
|
||||||
|
"inputs": {},
|
||||||
|
}
|
||||||
|
store.persist(report)
|
||||||
|
|
||||||
|
manifest_path = root / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
manifest["runs"]["tourism-run-metadata-test"]["generated_at"] = "2099-02-01T00:00:00+00:00"
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(ResearchRunError, "integrity"):
|
||||||
|
store.load("tourism-run-metadata-test")
|
||||||
|
|
||||||
|
def test_research_run_store_rejects_non_object_report(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = ResearchRunStore(root)
|
||||||
|
report = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"run_id": "tourism-run-shape-test",
|
||||||
|
"theme": "tourism",
|
||||||
|
"status": "blocked",
|
||||||
|
"reason": "test",
|
||||||
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
||||||
|
"config": {},
|
||||||
|
"gates": {},
|
||||||
|
"inputs": {},
|
||||||
|
}
|
||||||
|
store.persist(report)
|
||||||
|
report_path = root / "reports" / "tourism-run-shape-test.json"
|
||||||
|
report_path.write_text("[\"tampered\"]", encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(ResearchRunError, "invalid"):
|
||||||
|
store.load("tourism-run-shape-test")
|
||||||
|
|
||||||
|
def test_research_run_store_rejects_non_object_manifest(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = ResearchRunStore(root)
|
||||||
|
(root / "manifest.json").write_text("[]", encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ResearchRunError, "schema"):
|
||||||
|
store.load_manifest()
|
||||||
|
|
||||||
|
def test_research_run_store_rejects_unreadable_manifest_bytes(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = ResearchRunStore(root)
|
||||||
|
(root / "manifest.json").write_bytes(b"\xff")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ResearchRunError, "unreadable"):
|
||||||
|
store.load_manifest()
|
||||||
|
|
||||||
|
def test_research_run_store_rejects_unreadable_report_bytes(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = ResearchRunStore(root)
|
||||||
|
report = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"run_id": "tourism-run-encoding-test",
|
||||||
|
"theme": "tourism",
|
||||||
|
"status": "blocked",
|
||||||
|
"reason": "test",
|
||||||
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
||||||
|
"config": {},
|
||||||
|
"gates": {},
|
||||||
|
"inputs": {},
|
||||||
|
}
|
||||||
|
store.persist(report)
|
||||||
|
(root / "reports" / "tourism-run-encoding-test.json").write_bytes(b"\xff")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ResearchRunError, "unreadable"):
|
||||||
|
store.load("tourism-run-encoding-test")
|
||||||
|
|
||||||
|
def test_research_run_store_list_runs_rejects_manifest_metadata_tampering(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = ResearchRunStore(root)
|
||||||
|
report = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"run_id": "tourism-run-list-test",
|
||||||
|
"theme": "tourism",
|
||||||
|
"status": "blocked",
|
||||||
|
"reason": "test",
|
||||||
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
||||||
|
"config": {},
|
||||||
|
"gates": {},
|
||||||
|
"inputs": {},
|
||||||
|
}
|
||||||
|
store.persist(report)
|
||||||
|
manifest_path = root / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
manifest["runs"]["tourism-run-list-test"]["generated_at"] = "2099-02-01T00:00:00+00:00"
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ResearchRunError, "integrity"):
|
||||||
|
store.list_runs()
|
||||||
|
|
||||||
|
def test_research_run_store_latest_and_list_reject_malformed_manifest_entry(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = ResearchRunStore(root)
|
||||||
|
report = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"run_id": "tourism-run-entry-shape-test",
|
||||||
|
"theme": "tourism",
|
||||||
|
"status": "blocked",
|
||||||
|
"reason": "test",
|
||||||
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
||||||
|
"config": {},
|
||||||
|
"gates": {},
|
||||||
|
"inputs": {},
|
||||||
|
}
|
||||||
|
store.persist(report)
|
||||||
|
manifest_path = root / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
manifest["runs"]["tourism-run-entry-shape-test"] = []
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ResearchRunError, "entry"):
|
||||||
|
store.list_runs()
|
||||||
|
with self.assertRaisesRegex(ResearchRunError, "entry"):
|
||||||
|
store.latest()
|
||||||
|
|
||||||
|
def test_research_run_store_persist_rejects_malformed_existing_manifest_entry(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = ResearchRunStore(root)
|
||||||
|
report = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"run_id": "tourism-run-persist-entry-test",
|
||||||
|
"theme": "tourism",
|
||||||
|
"status": "blocked",
|
||||||
|
"reason": "test",
|
||||||
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
||||||
|
"config": {},
|
||||||
|
"gates": {},
|
||||||
|
"inputs": {},
|
||||||
|
}
|
||||||
|
store.persist(report)
|
||||||
|
manifest_path = root / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
manifest["runs"]["tourism-run-persist-entry-test"] = []
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ResearchRunError, "entry"):
|
||||||
|
store.persist(report)
|
||||||
|
|
||||||
|
def test_research_run_store_persist_validates_unrelated_manifest_entries_before_write(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = ResearchRunStore(root)
|
||||||
|
existing_report = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"run_id": "tourism-run-persist-unrelated-existing",
|
||||||
|
"theme": "tourism",
|
||||||
|
"status": "blocked",
|
||||||
|
"reason": "test",
|
||||||
|
"generated_at": "2026-02-01T00:00:00+00:00",
|
||||||
|
"config": {},
|
||||||
|
"gates": {},
|
||||||
|
"inputs": {},
|
||||||
|
}
|
||||||
|
new_report = {
|
||||||
|
**existing_report,
|
||||||
|
"run_id": "tourism-run-persist-unrelated-new",
|
||||||
|
}
|
||||||
|
store.persist(existing_report)
|
||||||
|
|
||||||
|
manifest_path = root / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
manifest["runs"]["tourism-run-persist-unrelated-malformed"] = []
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ResearchRunError, "entry"):
|
||||||
|
store.persist(new_report)
|
||||||
|
self.assertFalse((root / "reports" / "tourism-run-persist-unrelated-new.json").exists())
|
||||||
|
|
||||||
def test_price_store_loads_snapshot_and_verifies_raw_payload(self):
|
def test_price_store_loads_snapshot_and_verifies_raw_payload(self):
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
store = PriceSnapshotStore(Path(temp_dir))
|
store = PriceSnapshotStore(Path(temp_dir))
|
||||||
@@ -100,6 +488,78 @@ class ResearchRunTests(unittest.TestCase):
|
|||||||
self.assertNotIn("result", report)
|
self.assertNotIn("result", report)
|
||||||
self.assertEqual(run_store.latest()["run_id"], report["run_id"])
|
self.assertEqual(run_store.latest()["run_id"], report["run_id"])
|
||||||
|
|
||||||
|
def test_runner_allows_explicit_exploratory_mode_with_revised_prices(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
vintage_store = VintageStore(root / "tourism")
|
||||||
|
vintage_store.persist(b"tourism", tourism_snapshot("v1", "2026-01-01T08:00:00+07:00"))
|
||||||
|
price_store = PriceSnapshotStore(root / "prices")
|
||||||
|
snapshot, raw = price_snapshot(point_in_time=False)
|
||||||
|
price_store.persist(snapshot, raw)
|
||||||
|
run_store = ResearchRunStore(root / "runs")
|
||||||
|
|
||||||
|
report = run_tourism_research(
|
||||||
|
vintage_store,
|
||||||
|
price_store,
|
||||||
|
run_store,
|
||||||
|
mode="exploratory",
|
||||||
|
min_events=1,
|
||||||
|
windows=(1,),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(report["status"], "descriptive_only")
|
||||||
|
self.assertEqual(report["research_mode"], "exploratory")
|
||||||
|
self.assertEqual(report["result_scope"], "non_pit_descriptive_only")
|
||||||
|
self.assertEqual(report["gates"]["prices"]["status"], "descriptive_only")
|
||||||
|
self.assertEqual(report["gates"]["prices"]["reason"], "price_series_not_point_in_time")
|
||||||
|
self.assertEqual(report["result"]["event_count"], 1)
|
||||||
|
|
||||||
|
def test_runner_keeps_exploratory_pit_inputs_descriptive_only(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
vintage_store = VintageStore(root / "tourism")
|
||||||
|
vintage_store.persist(b"tourism", tourism_snapshot("v1", "2026-01-01T08:00:00+07:00"))
|
||||||
|
price_store = PriceSnapshotStore(root / "prices")
|
||||||
|
snapshot, raw = price_snapshot(point_in_time=True)
|
||||||
|
price_store.persist(snapshot, raw)
|
||||||
|
run_store = ResearchRunStore(root / "runs")
|
||||||
|
|
||||||
|
report = run_tourism_research(
|
||||||
|
vintage_store,
|
||||||
|
price_store,
|
||||||
|
run_store,
|
||||||
|
mode="exploratory",
|
||||||
|
min_events=1,
|
||||||
|
windows=(1,),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(report["status"], "descriptive_only")
|
||||||
|
self.assertEqual(report["result_scope"], "non_pit_descriptive_only")
|
||||||
|
self.assertEqual(report["gates"]["prices"]["status"], "descriptive_only")
|
||||||
|
self.assertFalse(report["gates"]["prices"]["validated"])
|
||||||
|
self.assertTrue(report["gates"]["prices"]["point_in_time"])
|
||||||
|
self.assertEqual(report["gates"]["prices"]["reason"], "exploratory_mode_non_validated")
|
||||||
|
|
||||||
|
def test_runner_reuses_same_run_when_only_observation_time_changes(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
vintage_store = VintageStore(root / "tourism")
|
||||||
|
vintage_store.persist(b"tourism", tourism_snapshot("v1", "2026-01-01T08:00:00+07:00"))
|
||||||
|
price_store = PriceSnapshotStore(root / "prices")
|
||||||
|
snapshot, raw = price_snapshot(point_in_time=False)
|
||||||
|
price_store.persist(snapshot, raw)
|
||||||
|
run_store = ResearchRunStore(root / "runs")
|
||||||
|
|
||||||
|
first = run_tourism_research(vintage_store, price_store, run_store, mode="exploratory", min_events=1, windows=(1,))
|
||||||
|
observed_again = copy.deepcopy(snapshot)
|
||||||
|
observed_again["source"]["retrieved_at"] = "2026-02-03T00:00:00+00:00"
|
||||||
|
price_store.persist(observed_again, raw)
|
||||||
|
second = run_tourism_research(vintage_store, price_store, run_store, mode="exploratory", min_events=1, windows=(1,))
|
||||||
|
|
||||||
|
self.assertEqual(second["run_id"], first["run_id"])
|
||||||
|
self.assertEqual(second, first)
|
||||||
|
self.assertEqual(len(price_store.list_observations()), 2)
|
||||||
|
|
||||||
def test_runner_computes_replayable_result_when_both_gates_pass(self):
|
def test_runner_computes_replayable_result_when_both_gates_pass(self):
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
root = Path(temp_dir)
|
root = Path(temp_dir)
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
from app.bot_tourism import BotTourismSource
|
from app.bot_tourism import BotTourismSource
|
||||||
from app.collector import collect_bot_vintage
|
from app.collector import collect_bot_vintage
|
||||||
from app.vintages import VintageStore, filter_vintages
|
from app.vintages import VintageStore, VintageStoreError, filter_vintages
|
||||||
|
|
||||||
FIXTURES = Path(__file__).parent / "fixtures"
|
FIXTURES = Path(__file__).parent / "fixtures"
|
||||||
REPORT_HTML = (FIXTURES / "bot_tourism_report.html").read_text(encoding="utf-8")
|
REPORT_HTML = (FIXTURES / "bot_tourism_report.html").read_text(encoding="utf-8")
|
||||||
@@ -128,6 +129,50 @@ class VintageStoreTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ValueError, "hash"):
|
with self.assertRaisesRegex(ValueError, "hash"):
|
||||||
store.load_snapshot(snapshot["source"]["vintage_id"])
|
store.load_snapshot(snapshot["source"]["vintage_id"])
|
||||||
|
|
||||||
|
def test_snapshot_load_rejects_non_object_json(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = VintageStore(root)
|
||||||
|
snapshot = sample_snapshot()
|
||||||
|
store.persist(REPORT_HTML.encode("utf-8"), snapshot)
|
||||||
|
snapshot_path = root / "snapshots" / f"{snapshot['source']['vintage_id']}.json"
|
||||||
|
snapshot_path.write_text(json.dumps(["not", "a", "snapshot"]), encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(VintageStoreError, "invalid"):
|
||||||
|
store.load_snapshot(snapshot["source"]["vintage_id"])
|
||||||
|
|
||||||
|
def test_snapshot_load_rejects_unreadable_utf8(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = VintageStore(root)
|
||||||
|
snapshot = sample_snapshot()
|
||||||
|
store.persist(REPORT_HTML.encode("utf-8"), snapshot)
|
||||||
|
snapshot_path = root / "snapshots" / f"{snapshot['source']['vintage_id']}.json"
|
||||||
|
snapshot_path.write_bytes(b"\xff")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(VintageStoreError, "unreadable"):
|
||||||
|
store.load_snapshot(snapshot["source"]["vintage_id"])
|
||||||
|
|
||||||
|
def test_snapshot_load_rejects_unreadable_raw_payload(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = VintageStore(root)
|
||||||
|
snapshot = sample_snapshot()
|
||||||
|
store.persist(REPORT_HTML.encode("utf-8"), snapshot)
|
||||||
|
|
||||||
|
with patch("pathlib.Path.read_bytes", side_effect=OSError("read failed")):
|
||||||
|
with self.assertRaisesRegex(VintageStoreError, "unreadable"):
|
||||||
|
store.load_snapshot(snapshot["source"]["vintage_id"])
|
||||||
|
|
||||||
|
def test_manifest_load_rejects_unreadable_utf8(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
store = VintageStore(root)
|
||||||
|
(root / "manifest.json").write_bytes(b"\xff")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(VintageStoreError, "unreadable"):
|
||||||
|
store.load_manifest()
|
||||||
|
|
||||||
def test_collector_returns_manifested_snapshot_summary(self):
|
def test_collector_returns_manifested_snapshot_summary(self):
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
result = collect_bot_vintage(
|
result = collect_bot_vintage(
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 2026-08-23 — replay and research-report integrity remediation
|
||||||
|
|
||||||
|
## Plan status
|
||||||
|
|
||||||
|
- Canonical replay validation: complete.
|
||||||
|
- Research report content-hash binding: complete.
|
||||||
|
- Research manifest-entry hash binding: complete.
|
||||||
|
- Malformed report fail-closed handling: complete.
|
||||||
|
- VintageStore snapshot/manifest loader fail-closed hardening: complete.
|
||||||
|
- Semantic replay-shape validation: complete; non-list/non-object observations and exposures now fail closed through controlled `ValueError` handling.
|
||||||
|
- Independent review gate: no valid approval for the current tree. `deleg_c227c439` found that `persist()` did not validate unrelated manifest entries before writing a new report; this was fixed with a regression test. `deleg_cf1da27d` then timed out after 600 seconds without a schema-valid JSON verdict; it is inconclusive, not an approval. `deleg_feb40653` was dispatched against the pre-semantic-fix tree, reported truncated cached diffs, and is stale/non-approving. `deleg_87a0f686` is the current bounded post-fix exact-diff review; no schema-complete current-tree approval has been surfaced yet.
|
||||||
|
- Backtest gate: still blocked; this work does not create additional evidence.
|
||||||
|
|
||||||
|
## Changed files
|
||||||
|
|
||||||
|
- `backend/app/__init__.py` — replay and replayability now use `VintageStore.load_snapshot()` rather than reading normalized JSON directly.
|
||||||
|
- `backend/app/research.py` — report hashes, manifest-entry hashes, strict object validation, explicit resumable legacy migration, and normalized snapshot lineage fields.
|
||||||
|
- `backend/app/vintages.py` — malformed snapshot/manifest shape, encoding, and raw-payload I/O failures now map to `VintageStoreError`.
|
||||||
|
- `backend/app/tourism.py` — malformed observation/exposure containers and entries now raise controlled semantic validation errors.
|
||||||
|
- `backend/scripts/migrate_research_report_hashes.py` — explicit migration command for trusted pre-hash research stores.
|
||||||
|
- `backend/tests/test_api.py` — canonical-store replay setup, tampered/semantically invalid snapshots including malformed exposure entries, and replay storage-I/O regressions.
|
||||||
|
- `backend/tests/test_research.py` — report-file, manifest-hash, manifest-metadata, legacy migration, malformed-report, and persist-wide manifest-validation regressions.
|
||||||
|
- `backend/tests/test_vintages.py` — non-object, invalid-UTF-8, and raw-payload I/O regression coverage.
|
||||||
|
|
||||||
|
## Root cause
|
||||||
|
|
||||||
|
The replay endpoint bypassed the canonical vintage loader, so a modified normalized snapshot could be recomputed as if it were trusted. Persisted research reports also had no self-hash, and the manifest metadata was not bound to its recorded entry. A changed report or `generated_at` value could therefore enter a replay path without integrity detection.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
- Replay loads only through `VintageStore.load_snapshot()`, which verifies raw payload, normalized payload, manifest metadata, and identity.
|
||||||
|
- Reports persist `report_hash_algorithm=sha256-json-canonical-v1` and a canonical `report_hash`.
|
||||||
|
- Each manifest entry persists and verifies its own canonical `manifest_entry_hash`, binding report filename, report hash, status, reason, and generated timestamp.
|
||||||
|
- JSON values that are not report objects are rejected with `ResearchRunError` instead of causing an attribute error.
|
||||||
|
- Non-object manifests and malformed manifest entries are rejected by `load_manifest()`, `list_runs()`, and `latest()`.
|
||||||
|
- `list_runs()` verifies each report and manifest-entry hash before returning metadata.
|
||||||
|
- `persist()` validates every existing manifest entry before returning or writing a new report, and refuses malformed entries without leaving an orphan report.
|
||||||
|
- Direct report loads bind the manifest entry identity to the requested `run_id`.
|
||||||
|
- Invalid UTF-8 in persisted manifest/report files is converted to `ResearchRunError` instead of escaping as a decode exception.
|
||||||
|
- VintageStore rejects non-object snapshots, invalid UTF-8 manifests/snapshots, and raw-payload read failures with `VintageStoreError` instead of leaking attribute/decode/I/O exceptions.
|
||||||
|
- Research input lineage now includes normalized snapshot hashes and their hash algorithm.
|
||||||
|
- Replay computation rejects non-list/non-object observation/exposure shapes as controlled semantic failures, so the replay endpoint returns HTTP 422 rather than leaking `AttributeError`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```text
|
||||||
|
PYTHONPATH=backend .venv/bin/python -W error -m unittest discover -s backend/tests -v
|
||||||
|
Ran 79 tests ... OK
|
||||||
|
|
||||||
|
PYTHONPATH=backend .venv/bin/python -m compileall -q backend/app backend/scripts backend/tests
|
||||||
|
npm run build
|
||||||
|
npm audit --omit=dev --audit-level=high
|
||||||
|
found 0 vulnerabilities
|
||||||
|
git diff --check
|
||||||
|
static scan of added lines: no findings
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtime smoke:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/research/tourism/latest → HTTP 200; report_hash present
|
||||||
|
GET /api/v1/backtest/tourism?min_events=12 → HTTP 409; reason=insufficient_vintages
|
||||||
|
```
|
||||||
|
|
||||||
|
## Boundary and remaining work
|
||||||
|
|
||||||
|
These hashes detect local artifact corruption and accidental tampering; they are not signatures against a hostile operator who can rewrite the application, data, and runtime. The validated backtest remains blocked until at least 12 independent BOT releases and a point-in-time price archive exist.
|
||||||
|
|
||||||
|
The post-fix BOT collector run fetched the same release (`source_id=bot.ec_ei_028_s2`, `published_at=2026-07-31T14:30:00+07:00`), increasing observation count to 14 without increasing independent releases. The gate correctly remained `status=blocked`, `reason=insufficient_vintages`.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# M2.9 — Forward price observations and explicit research modes
|
||||||
|
|
||||||
|
## Plan status
|
||||||
|
|
||||||
|
- Status: **complete/blocked**
|
||||||
|
- Complete: the system can record forward observations, detect local revisions, and run an explicitly labelled exploratory descriptive study.
|
||||||
|
- Blocked: validated historical PIT backtest remains closed until a provider passes `pit-daily-v1` and BOT has at least 12 independent releases.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The strict PIT requirement is a gate for validated historical backtests, not a launch blocker for exploratory or forward paper work. Revised vendor history remains usable only when the result is labelled `non_pit_descriptive_only`.
|
||||||
|
|
||||||
|
## Root cause addressed
|
||||||
|
|
||||||
|
The price store already preserved raw payloads and immutable normalized snapshots, but repeated retrievals had no durable observation history. The research runner also treated the PIT requirement as a universal gate, which prevented descriptive research from using an available revised history.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- `backend/app/prices.py`
|
||||||
|
- Added an optional, backward-compatible manifest `observations` list.
|
||||||
|
- Records one observation per distinct retrieval identity with source/period scope, retrieval time, raw hash, prior snapshot ID/hash, revision status, and structured diff paths.
|
||||||
|
- Preserves the original raw and normalized snapshot on unchanged retrievals; an actual content change creates a new snapshot/vintage instead of overwriting the prior one.
|
||||||
|
- Tracks `first_seen_at`, `last_seen_at`, and `observation_count` on snapshot manifest entries.
|
||||||
|
- Binds observation IDs to their content and rejects tampered or unknown-snapshot observation records at load time.
|
||||||
|
- Keeps revised Yahoo history at `quality=revised_vendor_history` and `point_in_time=false`.
|
||||||
|
- `backend/app/research.py`
|
||||||
|
- Added explicit `mode=validated|exploratory`.
|
||||||
|
- `validated` remains fail-closed on missing PIT archive evidence and requires per-bar known-at validation.
|
||||||
|
- `exploratory` may proceed with revised history only as `result_scope=non_pit_descriptive_only`; it disables PIT known-at enforcement and emits limitations.
|
||||||
|
- Runtime observation timestamps are excluded from the research input fingerprint, so an unchanged feed reuses the same frozen research run.
|
||||||
|
- `backend/app/__init__.py`
|
||||||
|
- Price health now exposes observation count, last observation time, and revision status.
|
||||||
|
- Added `GET /api/v1/prices/observations` for the local audit trail.
|
||||||
|
- The research endpoint accepts the explicit mode and defaults exploratory requests to one event unless overridden; validated requests retain the 12-event default.
|
||||||
|
- `frontend/src/App.vue`
|
||||||
|
- The research action explicitly requests exploratory mode with `min_events=1`.
|
||||||
|
- UI copy and result metadata distinguish a descriptive study from a validated backtest.
|
||||||
|
- Tests cover unchanged retrievals, revision diffs, immutable prior snapshots, observation tampering, exploratory execution, validated regression behavior, API mode validation, and stable run reuse.
|
||||||
|
|
||||||
|
## Verification evidence
|
||||||
|
|
||||||
|
- `PYTHONPATH=backend .venv/bin/python -W error -m unittest discover -s backend/tests -v`
|
||||||
|
- Baseline before this slice: 93 tests passed.
|
||||||
|
- First full run after the slice: 98 tests passed.
|
||||||
|
- Focused run after observation-integrity hardening: 67 tests passed.
|
||||||
|
- Final full run: 121 tests passed with `-W error`.
|
||||||
|
- `PYTHONPATH=backend .venv/bin/python -m compileall -q backend` passed.
|
||||||
|
- `npm run build` from `frontend/` passed.
|
||||||
|
- `npm audit --omit=dev --audit-level=high` reported 0 vulnerabilities.
|
||||||
|
- `git diff --check` passed.
|
||||||
|
- Static scan over the changed code found zero hardcoded-secret assignments, shell execution, `eval`/`exec`, `pickle`, or SQL-f-string patterns.
|
||||||
|
- Final bounded independent reviewer `deleg_e5407553`: schema-valid `passed=true`; `security_concerns=[]` and `logic_errors=[]`. Non-blocking gaps: concurrent persistence/atomic manifest-update coverage, same-raw normalized-content mismatch coverage, and invalid/missing/mismatched predecessor-reference coverage. Suggestions are deferred because the store remains single-process and validated PIT deployment is still blocked.
|
||||||
|
|
||||||
|
## Risks and limits
|
||||||
|
|
||||||
|
- Observation history proves only what this system observed after capture began. It does not prove provider release time, historical knowledge state, or absence of revisions before first capture.
|
||||||
|
- The local filesystem store remains single-process; concurrent-writer safety and shared database migration are deferred.
|
||||||
|
- No provider has passed the complete PIT evidence packet. Do not set `point_in_time=true` for revised vendor history.
|
||||||
|
- No live execution, webhook, broker, MT5, or external order path was added.
|
||||||
|
|
||||||
|
## Exact next action
|
||||||
|
|
||||||
|
Keep collecting independent BOT releases until 12 genuinely distinct `(source_id, published_at)` releases exist. In parallel, request one complete market-data provider evidence packet with SET coverage, provider release identity/time, explicit known-at semantics, correction/revision replay, immutable raw/archive/manifest evidence, and timezone/session rules. Only then implement or promote a PIT adapter.
|
||||||
|
|
||||||
|
NEVER include API keys, tokens, passwords, secrets, credentials, or connection strings in the summary — replace any that appear with [REDACTED].
|
||||||
30
docs/engineering-log/2026-08-24-integrity-review.md
Normal file
30
docs/engineering-log/2026-08-24-integrity-review.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Integrity Remediation Review — 2026-08-24
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Complete for the staged integrity-remediation scope under the stated local single-user threat model. No commit or push was performed.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
The review covered canonical report-content hashing, manifest-entry identity/hash binding, replay through canonical loaders, manifest-wide validation before writes, raw/normalized snapshot binding, legacy migration boundaries, strict UTF-8/object/path/raw-hash validation, and tourism replay semantic validation.
|
||||||
|
|
||||||
|
## Independent Review Gate
|
||||||
|
|
||||||
|
- Original independent review `deleg_43348a3b` returned `passed=true` with empty `findings`, but its payload did not use the required `security_concerns` and `logic_errors` keys.
|
||||||
|
- Schema-correction review `deleg_a1c721d2` returned exactly:
|
||||||
|
`{"passed":true,"security_concerns":[],"logic_errors":[],"suggestions":[]}`
|
||||||
|
- The corrected payload was parsed and verified to contain exactly the four required keys, with `passed=true` and all arrays empty.
|
||||||
|
- Parallel review `deleg_e2d54c30` ended interrupted and contributes no evidence.
|
||||||
|
|
||||||
|
The corrected verdict closes the integrity-review gate for the stated threat model. It does not cover hostile machine owners, multi-user production deployment, live execution, or the separate point-in-time price-data readiness gate.
|
||||||
|
|
||||||
|
## Existing Verification Evidence
|
||||||
|
|
||||||
|
The parent verification already recorded 85 backend tests passing, compileall passing, Vite build passing, high-severity npm audit with zero vulnerabilities, and both working-tree and staged `git diff --check` passing. No new code was changed while processing this verdict.
|
||||||
|
|
||||||
|
## Remaining Work
|
||||||
|
|
||||||
|
- Revised vendor price history remains explicitly `point_in_time=false`.
|
||||||
|
- Independent BOT releases remain below the 12-event research threshold.
|
||||||
|
- PIT price archive design and evidence are still required before event-study/backtest readiness can be raised.
|
||||||
|
- No commit or push has been performed.
|
||||||
55
docs/engineering-log/2026-08-24-m29-final-review.md
Normal file
55
docs/engineering-log/2026-08-24-m29-final-review.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# M2.9 Final Independent Review — 2026-08-24
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- Review gate: **passed** for the bounded M2.9 remediation scope under the declared local single-process threat model.
|
||||||
|
- Milestone: **complete/blocked** — exploratory and forward paper work is available; validated historical PIT research remains blocked.
|
||||||
|
- Commit/push: none performed.
|
||||||
|
|
||||||
|
## Independent verdict
|
||||||
|
|
||||||
|
Reviewer `deleg_e5407553` returned the required schema-valid verdict:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"security_concerns": [],
|
||||||
|
"logic_errors": [],
|
||||||
|
"test_gaps": [
|
||||||
|
"concurrent read-modify-write persistence and atomic manifest updates",
|
||||||
|
"same-raw-payload observation with differing normalized content",
|
||||||
|
"invalid, missing, or mismatched predecessor references"
|
||||||
|
],
|
||||||
|
"suggestions": [
|
||||||
|
"add process-level locking or transactional manifest updates before multi-worker deployment",
|
||||||
|
"add malformed-manifest, equal-time, lineage, and predecessor adversarial fixtures",
|
||||||
|
"add focused helper and frontend mapping tests"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The security and logic blocker arrays are empty. The listed gaps and suggestions are non-blocking for the current single-process local store and are deferred until the deployment scope expands or focused coverage is prioritized.
|
||||||
|
|
||||||
|
## Verification basis
|
||||||
|
|
||||||
|
- Backend: 121 tests passed with `-W error`.
|
||||||
|
- Backend compileall: passed.
|
||||||
|
- Frontend Vite build: passed.
|
||||||
|
- Production dependency audit: 0 vulnerabilities.
|
||||||
|
- `git diff --check`: passed.
|
||||||
|
- Static dangerous-pattern scan: no hardcoded-secret assignments, shell execution, `eval`/`exec`, `pickle`, or SQL-f-string matches.
|
||||||
|
- Live smoke: health and observations returned HTTP 200; validated backtest remained HTTP 409 and fail-closed.
|
||||||
|
|
||||||
|
## Remaining gates
|
||||||
|
|
||||||
|
- Revised vendor price history remains `point_in_time=false`.
|
||||||
|
- BOT has only 1/12 independent releases.
|
||||||
|
- No provider has supplied the evidence required by `pit-daily-v1`.
|
||||||
|
- Do not claim a validated historical backtest or alpha result.
|
||||||
|
- Add locking/transactional persistence before multi-worker or shared-filesystem deployment.
|
||||||
|
|
||||||
|
## Next action
|
||||||
|
|
||||||
|
Continue collecting genuinely independent BOT releases and request a complete PIT provider evidence packet. Keep exploratory output explicitly `status=descriptive_only` with `result_scope=non_pit_descriptive_only`.
|
||||||
|
|
||||||
|
NEVER include API keys, tokens, passwords, secrets, credentials, or connection strings in the summary — replace any that appear with [REDACTED].
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# M2.9 Observation Integrity Follow-up — 2026-08-24
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- Milestone: **complete/blocked** for the local single-process observation-integrity scope.
|
||||||
|
- Commit/push: none performed.
|
||||||
|
- Validated PIT research remains blocked; revised vendor history remains `point_in_time=false`.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Closed three non-blocking local integrity gaps identified by the prior M2.9 review:
|
||||||
|
|
||||||
|
1. Reject a new snapshot that reuses the same raw payload hash while changing normalized content.
|
||||||
|
2. Reject malformed snapshot manifest entries instead of silently skipping them.
|
||||||
|
3. Harden observation predecessor chronology: observations cannot precede the manifest snapshot's immutable first capture, and equal-UTC duplicate observations for one snapshot are rejected.
|
||||||
|
|
||||||
|
The existing valid repeated-observation path remains supported: later observations of an unchanged immutable snapshot continue to append without creating a new snapshot or research run.
|
||||||
|
|
||||||
|
## TDD evidence
|
||||||
|
|
||||||
|
Each new behavior followed RED → GREEN:
|
||||||
|
|
||||||
|
- `test_price_store_rejects_same_raw_payload_with_changed_normalized_content` failed before the guard, then passed after the minimal raw/normalized consistency check.
|
||||||
|
- `test_price_store_rejects_malformed_snapshot_manifest_entry` failed before manifest-entry type validation, then passed after fail-closed validation.
|
||||||
|
- `test_price_store_rejects_observation_before_snapshot_first_capture` failed before first-capture temporal binding, then passed after binding to manifest `retrieved_at`.
|
||||||
|
- `test_price_store_rejects_duplicate_equal_time_observations_for_one_snapshot` failed before equal-time ambiguity detection, then passed after strict predecessor ordering.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Price tests: 37 passed.
|
||||||
|
- Full backend suite: 125 passed with `PYTHONPATH=backend .venv/bin/python -W error -m unittest discover -s backend/tests -v`.
|
||||||
|
- Backend compileall: passed.
|
||||||
|
- Frontend `npm run build`: passed.
|
||||||
|
- `npm audit --omit=dev --audit-level=high`: `found 0 vulnerabilities`.
|
||||||
|
- `git diff --check`: passed.
|
||||||
|
- Added-line static scan: no hardcoded-secret assignments, shell execution, `eval`/`exec`, unsafe pickle, or SQL-f-string matches.
|
||||||
|
- `ruff`, `mypy`, `eslint`, and `tsc` are not installed in this environment.
|
||||||
|
- Fresh focused independent review `deleg_0e2282c8` returned schema-valid `passed=true` with `security_concerns=[]` and `logic_errors=[]`. Its only suggestion is to retain regression coverage for later repeated observations and deterministic equal-time ordering across distinct snapshot IDs.
|
||||||
|
|
||||||
|
## Remaining work
|
||||||
|
|
||||||
|
- Process-level locking/transactional manifest updates remain deferred until multi-worker or shared-filesystem deployment is in scope.
|
||||||
|
- Focused helper/UI mapping coverage remains non-blocking follow-up work.
|
||||||
|
- Continue collecting independent BOT releases and obtain a provider evidence packet satisfying `pit-daily-v1` before enabling validated PIT research.
|
||||||
|
|
||||||
|
NEVER include API keys, tokens, passwords, secrets, credentials, or connection strings in the summary — replace any that appear with [REDACTED].
|
||||||
47
docs/engineering-log/2026-08-24-paper-auth-policy.md
Normal file
47
docs/engineering-log/2026-08-24-paper-auth-policy.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Paper Auth Policy — 2026-08-24
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Complete for the paper-auth scope. The separate staged integrity-remediation review remains open. No commit or push was performed.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make the paper-only write path explicit and safe for the local single-user demo while preserving fail-closed protected mode for shared/network deployments.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added explicit `demo` and `token` paper-auth modes.
|
||||||
|
- Restricted demo mode to a loopback bind; unknown and non-loopback bind hosts fail closed.
|
||||||
|
- Passed the actual `HOST`/bind value from `backend/run.py` into the app factory.
|
||||||
|
- Kept protected token mode as the default and fail-closed when its configured credential is missing or malformed.
|
||||||
|
- Added truthful API/UI state for “configured but not yet authenticated” versus “paper writes disabled by server configuration.”
|
||||||
|
- Added regression coverage for demo writes, non-loopback rejection, unknown bind rejection, missing credentials, malformed credential configuration, and pre-session enabled state.
|
||||||
|
- Updated README and handoff documentation with the two operating modes.
|
||||||
|
|
||||||
|
## Review Finding and Root-Cause Fix
|
||||||
|
|
||||||
|
The first independent review found that coercing a non-string configured credential to `str(...)` could turn malformed configuration into an accepted value. It also found that GET auth status conflated an unauthenticated browser session with a disabled server configuration. The fix validates configured credentials without coercion, reports an explicit `enabled` state, and prevents the UI from presenting a dead unlock/write path.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
- `PYTHONPATH=backend .venv/bin/python -W error -m unittest discover -s backend/tests -v` — 85 tests passed.
|
||||||
|
- `PYTHONPATH=backend .venv/bin/python -m compileall -q backend` — passed.
|
||||||
|
- `npm run build` — passed.
|
||||||
|
- `npm audit --omit=dev --audit-level=high` — 0 vulnerabilities.
|
||||||
|
- `git diff --check` and `git diff --cached --check` — passed.
|
||||||
|
- Static diff scan for credential-shaped literals and dangerous patterns — no findings.
|
||||||
|
- Environment smoke checks — loopback demo allowed; unknown and non-loopback demo binds rejected.
|
||||||
|
- Fresh static-server verification on port 5175 — HTTP 200; served bundle contained the disabled-state copy and auth warning marker. Temporary server was stopped afterward.
|
||||||
|
- `ruff`, `mypy`, and `eslint` are unavailable in this environment; no project lint script is configured for them.
|
||||||
|
|
||||||
|
## Data Safety
|
||||||
|
|
||||||
|
The smoke test created only synthetic paper rows in the runtime ledger; those exact test rows were removed afterward. The ledger was verified to contain no remaining smoke-test entries. No credentials, tokens, secrets, or connection strings were recorded.
|
||||||
|
|
||||||
|
## Independent Review
|
||||||
|
|
||||||
|
Fresh bounded review returned schema-valid `passed=true` with empty security-concern and logic-error arrays. Non-blocking suggestions: add one request-token type regression, add frontend integration coverage for both auth states, and add a startup precedence regression for `HOST`/`PAPER_BIND_HOST`.
|
||||||
|
|
||||||
|
## Remaining Gate
|
||||||
|
|
||||||
|
The separate staged integrity-remediation review is now closed for this scope: schema correction `deleg_a1c721d2` returned the exact required four-key verdict with `passed=true` and empty security/logic-error arrays. Earlier schema-invalid, timeout, and interrupted responses remain non-approving historical artifacts.
|
||||||
52
docs/engineering-log/2026-08-24-pit-price-contract.md
Normal file
52
docs/engineering-log/2026-08-24-pit-price-contract.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# PIT Daily Price Archive Contract — 2026-08-24
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- Implementation: complete for the local research/paper vertical slice.
|
||||||
|
- Provider evidence: not established yet.
|
||||||
|
- Backtest/event-study readiness: blocked until a real archive satisfies this contract and the BOT source reaches the independent-vintage threshold.
|
||||||
|
- Existing revised vendor history remains `point_in_time=false`.
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
`point_in_time=true` is accepted only when all of the following are present and bound into the immutable price manifest:
|
||||||
|
|
||||||
|
- `quality=point_in_time_archive`.
|
||||||
|
- `archive_contract=pit-daily-v1`.
|
||||||
|
- Non-empty `provider_release_id`, `source_url`, `parser_version`, and timezone-aware `retrieved_at`.
|
||||||
|
- `point_in_time_evidence` with `known_at_field=known_at`, `known_at_semantics=provider_release_time`, a matching provider release ID, and timezone-aware `release_published_at`.
|
||||||
|
- Every series has a matching `canonical_symbol`, an IANA `timezone`, and ordered unique bars.
|
||||||
|
- Every bar has `session_date`, timezone-aware `known_at`, OHLC, `adjusted_close`, and `volume` values. `known_at` must not be after the session date in the market timezone or after retrieval.
|
||||||
|
- Snapshot raw-payload hashes, normalized hash, manifest identity, and contract metadata must agree on reload.
|
||||||
|
|
||||||
|
The generic Yahoo collector does not produce this evidence and continues to persist `revised_vendor_history` with `point_in_time=false`.
|
||||||
|
|
||||||
|
## No-lookahead behavior
|
||||||
|
|
||||||
|
The event-study runner has an explicit `require_price_known_at=True` mode. The research runner enables it for PIT snapshots. It rejects missing timestamps and rejects any price whose `known_at` falls on a later market-local session date, including UTC timestamps that cross the market-timezone boundary.
|
||||||
|
|
||||||
|
## Changed files
|
||||||
|
|
||||||
|
- `backend/app/prices.py` — PIT contract validation, per-bar known-at checks, timezone validation, manifest binding.
|
||||||
|
- `backend/app/event_study.py` — known-at-aware price joining and market-timezone no-lookahead checks.
|
||||||
|
- `backend/app/research.py` — carries session date, known-at, and market timezone into the event-study join; enables the PIT check.
|
||||||
|
- `backend/app/__init__.py` — exposes contract metadata in price health and keeps backtest readiness fail-closed.
|
||||||
|
- `backend/tests/test_prices.py`, `backend/tests/test_event_study.py`, `backend/tests/test_research.py` — RED/GREEN regression coverage for missing contract evidence, missing known-at, manifest binding, revised-history preservation, and timezone lookahead.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `PYTHONPATH=backend .venv/bin/python -W error -m unittest discover -s backend/tests -v` — **93 tests passed**.
|
||||||
|
- `PYTHONPATH=backend .venv/bin/python -m compileall -q backend` — passed.
|
||||||
|
- `npm run build` — passed.
|
||||||
|
- `npm audit --omit=dev --audit-level=high` — 0 vulnerabilities.
|
||||||
|
- `git diff HEAD --check` and `git diff --cached --check` — passed.
|
||||||
|
- Static credential-pattern scan over `git diff HEAD` — 0 findings.
|
||||||
|
- Final bounded independent review `deleg_c08f6dd8` — exact schema returned `passed=true` with empty security-concern, logic-error, and suggestion arrays. Two earlier reviewer attempts timed out and were not counted.
|
||||||
|
- App smoke check: `/api/v1/prices/health` returned HTTP 200 for revised history; `/api/v1/backtest/tourism?min_events=1` returned HTTP 409 with `blocked` / `price_series_not_point_in_time`.
|
||||||
|
|
||||||
|
## Remaining work
|
||||||
|
|
||||||
|
- Obtain an actual provider archive/release contract and evidence for PIT semantics; do not flip the live Yahoo flag by configuration alone.
|
||||||
|
- Collect at least 12 independent BOT releases; repeated observations of one release do not count.
|
||||||
|
- Only after both gates pass should a frozen event-study/backtest result be treated as available. No alpha or investment claim is established here.
|
||||||
|
- No commit or push was performed in this session.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Price Provider Feasibility — 2026-08-24
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**No candidate passes `pit-daily-v1` from public evidence currently available.**
|
||||||
|
|
||||||
|
The repository must not add a provider adapter that emits `point_in_time=true` yet. Existing Yahoo/revised vendor history remains `quality=revised_vendor_history` and `point_in_time=false`.
|
||||||
|
|
||||||
|
This is a source-evidence decision, not a statement that the candidates cannot provide the required semantics under a commercial contract.
|
||||||
|
|
||||||
|
## Gate used
|
||||||
|
|
||||||
|
A provider must be able to supply an evidence-bearing archive with all of the following:
|
||||||
|
|
||||||
|
- stable `provider_release_id` for each release;
|
||||||
|
- `release_published_at` or equivalent provider release time;
|
||||||
|
- explicit `known_at` semantics that can be mapped to the provider release time;
|
||||||
|
- daily bars with session date, OHLC, adjusted close, volume, and market timezone;
|
||||||
|
- raw payload and normalized snapshot retained immutably;
|
||||||
|
- a manifest binding release identity, period, symbols, raw hash, normalized hash, parser version, and source URL;
|
||||||
|
- correction/revision behavior documented well enough to replay what was knowable at the decision time;
|
||||||
|
- evidence that the SET symbols and SET50 benchmark requested by this project are covered.
|
||||||
|
|
||||||
|
The local implementation enforces this as `archive_contract=pit-daily-v1`; the implementation itself is not provider evidence.
|
||||||
|
|
||||||
|
## Candidate matrix
|
||||||
|
|
||||||
|
| Candidate | Publicly verified capability | PIT evidence gap | Decision |
|
||||||
|
|---|---|---|---|
|
||||||
|
| SET Historical Data Request | SET offers historical trading/statistics data as a one-time service, with data sent by email after payment. [1] | No public release ID per historical vintage, release publication time, known-at semantics, immutable archive contract, or correction replay semantics. | **Reject for PIT claim**. Useful as a historical data content source only until SET supplies a release/archive contract. |
|
||||||
|
| SETSMART | SETSMART contains real-time and historical trading information for securities, derivatives, and indices. [2] | Public page does not establish timestamped historical snapshots, release identity, known-at semantics, or immutable archive replay. | **Not sufficient**. Do not build a PIT adapter from the web UI description alone. |
|
||||||
|
| SMART Marketplace | SET describes an API platform with historical intraday data and equity EOD trading price/statistics. [3] | Public page does not establish per-release identity, release publication time, historical corrections/revisions, or immutable PIT archive semantics. | **Promising but unproven**. Request the API/archive data contract before implementation. |
|
||||||
|
| ICE SET data | ICE advertises historical and end-of-day SET data, native exchange timestamps plus ICE UTC system timestamps, and an ICE historical archive. [4] [7] | The public pages do not prove that the SET daily archive exposes provider release IDs, release publication times, or point-in-time correction/replay semantics. Coverage and fields for this project also require confirmation. | **Best commercial candidate; conditional**. Ask ICE for a concrete sample archive and contract. |
|
||||||
|
| LSEG Tick History / S3 Direct | LSEG explicitly advertises full historical market data and point-in-time updates through S3 Direct, with persistent access in the customer's S3 environment. [5] [6] | Public material still does not prove that SET is covered for the requested symbols or expose the release/update metadata needed to map each daily bar to `known_at=provider_release_time`. The point-in-time claim is strong, but the SET-specific evidence packet is missing. | **Strongest PIT candidate; not yet accepted**. Request SET venue coverage, sample files, release/update metadata, and revision replay evidence before writing an adapter. |
|
||||||
|
| Databento Historical / Corporate Actions | Databento's venue page lists the Stock Exchange of Thailand, and its market-data pages describe aggregates through day-level bars, point-in-time instrument definitions, and nanosecond event timestamps. [8] [10] [11] | Databento's explicit PIT/replay contract is for corporate-action records: records are retained by `ts_record` and described as known at points in time. [9] The public market-data material does not establish price-bar release IDs, release publication times, client availability/`known_at`, or correction-vintage replay for SET prices. | **Reject for PIT price claim**. Retain as a possible SET coverage/reference-data lead; do not build a price adapter or promote its event timestamps to `known_at`. |
|
||||||
|
| Exchange Data International (EDI) | EDI advertises daily EOD pricing across 170+ exchanges, OHLCV-style closing data, API/S3/SFTP delivery, and Thailand Stock Exchange coverage identified as `XBKK`; its July 2025 ETA sheet lists confirmed Thailand equity delivery at 14:30 GMT. [12] [14] | EDI documents unconfirmed-to-confirmed republish and correction files in `Repush1/2/3`, but its FAQ says there is currently no time-series database for EOD prices. [12] [13] Public material does not establish stable provider release IDs, actual release-published timestamps per file, immutable retention, or retrievable vintage manifests. | **Closest conditional candidate; not accepted**. Request a customer evidence packet before writing an adapter. |
|
||||||
|
|
||||||
|
## Required provider evidence packet
|
||||||
|
|
||||||
|
Before writing a production adapter, obtain one complete historical sample covering at least one SET symbol and the SET50 benchmark. The sample should include:
|
||||||
|
|
||||||
|
1. provider documentation defining `provider_release_id`, `release_published_at`, and the meaning of `known_at`;
|
||||||
|
2. the exact raw response/file as delivered by the provider;
|
||||||
|
3. the provider's release manifest or metadata file;
|
||||||
|
4. a correction/revision example showing whether a later revision changes a prior bar and how both vintages are retrieved;
|
||||||
|
5. the provider timezone and session-calendar definition;
|
||||||
|
6. a symbol mapping file for the requested SET instruments;
|
||||||
|
7. delivery metadata proving when the archive was available to the client, not only when the market session occurred;
|
||||||
|
8. licensing/retention terms that permit immutable local replay for internal research and paper testing.
|
||||||
|
|
||||||
|
The fixture is accepted only if the complete packet can be normalized into the existing contract and replayed after changing neither the raw payload nor its manifest.
|
||||||
|
|
||||||
|
## Implementation decision
|
||||||
|
|
||||||
|
No files under `backend/app/` or `backend/scripts/` were changed for this feasibility pass. This avoids creating a fake adapter around a source whose PIT semantics are still unverified.
|
||||||
|
|
||||||
|
The next implementation action is one of:
|
||||||
|
|
||||||
|
- obtain a **market-data provider evidence packet** from EDI first, because its public docs expose SET coverage and correction/re-push behavior; this means provider documentation plus a representative market-data sample, not customer/vendor business data; or
|
||||||
|
- build the smallest ICE adapter if ICE supplies a valid evidence packet; or
|
||||||
|
- build the smallest LSEG adapter if LSEG supplies a valid evidence packet; or
|
||||||
|
- keep the gate blocked and document the provider rejection if neither does.
|
||||||
|
|
||||||
|
Databento was checked as an additional candidate. Its public documentation is useful evidence for SET venue presence and PIT corporate-action history, but it does not close the PIT **price-vintage** gate. No Databento price adapter is justified from the public material currently available. [8] [9] [11]
|
||||||
|
|
||||||
|
EDI is currently the closest public price-feed lead: its correction/re-push workflow and confirmed Thailand ETA are relevant to vintage capture, but an ETA is not an actual per-release publication timestamp and the public contract still lacks the immutable release metadata required by `pit-daily-v1`. Treat EDI as a request-for-evidence path, not as a qualified provider. [12] [13] [14]
|
||||||
|
|
||||||
|
In parallel, continue collecting BOT **independent** releases. Repeated observations of one published release still do not count toward the 12-release threshold.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
[1] https://www.set.or.th/en/services/connectivity-and-data/data/historical
|
||||||
|
[2] https://www.set.or.th/en/services/connectivity-and-data/data/web-based
|
||||||
|
[3] https://www.set.or.th/en/services/connectivity-and-data/data/smart-marketplace
|
||||||
|
[4] https://developer.ice.com/fixed-income-data-services/catalog/stock-exchange-thailand-set
|
||||||
|
[5] https://www.lseg.com/en/data-analytics/market-data/data-feeds/tick-history
|
||||||
|
[6] https://www.lseg.com/en/data-analytics/market-data/data-feeds/tick-history/s3-direct
|
||||||
|
[7] https://www.ice.com/fixed-income-data-services/access-and-delivery/connectivity-and-feeds/ice-consolidated-history
|
||||||
|
[8] https://databento.com/venues
|
||||||
|
[9] https://databento.com/docs/venues-and-datasets/corporate-actions
|
||||||
|
[10] https://databento.com/historical
|
||||||
|
[11] https://databento.com/equities
|
||||||
|
[12] https://www.exchange-data.com/product/end-day-pricing-data
|
||||||
|
[13] https://www.exchange-data.com/faqs-end-of-day-prices
|
||||||
|
[14] https://www.exchange-data.com/wp-content/uploads/2025/09/ETA-Coverage-17-July-2025.xls
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Test evidence — 2026-08-23 replay and research-report integrity remediation
|
||||||
|
|
||||||
|
## Automated
|
||||||
|
|
||||||
|
```text
|
||||||
|
PYTHONPATH=backend .venv/bin/python -W error -m unittest discover -s backend/tests -v
|
||||||
|
Ran 79 tests ... OK
|
||||||
|
|
||||||
|
PYTHONPATH=backend .venv/bin/python -m compileall -q backend/app backend/scripts backend/tests
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
Vite build completed successfully.
|
||||||
|
|
||||||
|
npm audit --omit=dev --audit-level=high
|
||||||
|
found 0 vulnerabilities
|
||||||
|
|
||||||
|
git diff --check
|
||||||
|
static scan of added lines: no findings
|
||||||
|
```
|
||||||
|
|
||||||
|
Ruff, mypy, and ESLint were not installed/configured in this checkout and were skipped without masking an exit code.
|
||||||
|
|
||||||
|
## Regression coverage
|
||||||
|
|
||||||
|
- Replay reads through `VintageStore.load_snapshot()`.
|
||||||
|
- Tampered normalized vintage snapshots return HTTP 422 from the replay endpoint.
|
||||||
|
- Research report content tampering is rejected by the report hash.
|
||||||
|
- Research manifest report-hash tampering is rejected.
|
||||||
|
- Research manifest metadata tampering is rejected by the manifest-entry hash.
|
||||||
|
- Non-object report JSON is rejected as invalid rather than raising an uncaught attribute error.
|
||||||
|
- Non-object manifests and malformed manifest entries are rejected by `load_manifest()`, `list_runs()`, and `latest()`.
|
||||||
|
- `list_runs()` verifies each report and manifest-entry hash before returning metadata.
|
||||||
|
- `persist()` validates every existing manifest entry before returning or writing a new report, and refuses malformed entries without leaving an orphan report.
|
||||||
|
- Source replayability uses the same verified load-and-compute helper as the replay endpoint, so semantically invalid snapshots are marked unreplayable.
|
||||||
|
- Tourism replay computation rejects non-list and non-object observation/exposure shapes as controlled semantic validation failures instead of leaking `AttributeError`.
|
||||||
|
- Replay storage I/O failures return HTTP 422 instead of leaking an uncontrolled 500.
|
||||||
|
- Explicit legacy report migration validates identity/metadata, is resumable after an interrupted report write, and is not automatic.
|
||||||
|
- Direct report loads bind the manifest entry identity to the requested `run_id`.
|
||||||
|
- Invalid UTF-8 in persisted manifest/report files is converted to `ResearchRunError` instead of escaping as a decode exception.
|
||||||
|
- VintageStore rejects non-object snapshots with `VintageStoreError`.
|
||||||
|
- Invalid UTF-8 in vintage manifests/snapshots is converted to `VintageStoreError`.
|
||||||
|
- Raw vintage payload read failures are converted to `VintageStoreError`.
|
||||||
|
- Research lineage includes normalized snapshot hashes and algorithms.
|
||||||
|
|
||||||
|
## Runtime smoke
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/research/tourism/latest → HTTP 200; report_hash present
|
||||||
|
GET /api/v1/backtest/tourism?min_events=12 → HTTP 409; reason=insufficient_vintages
|
||||||
|
|
||||||
|
BOT collector rerun → same published release, `seen_count=14`, independent release count remains 1, gate remains blocked.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Independent review
|
||||||
|
|
||||||
|
No valid approval for the current tree. `deleg_c227c439` found a persist-wide manifest-validation gap in the prior staged tree; the gap is now covered by a regression test and fixed before writing new reports. `deleg_cf1da27d` timed out after 600 seconds with no schema-valid JSON verdict, so it is inconclusive and does not approve the tree. `deleg_feb40653` was dispatched against the pre-semantic-fix tree, reported truncated cached diffs, and is stale/non-approving. `deleg_87a0f686` is the current bounded post-fix exact-diff review; no schema-complete current-tree approval has been surfaced yet.
|
||||||
|
|
||||||
|
## Scope boundary
|
||||||
|
|
||||||
|
This remediation improves local artifact integrity only. It does not unlock the backtest: the current evidence remains below 12 independent releases and price data remains `point_in_time=false`.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Test Evidence — Forward Price Observations and Research Modes
|
||||||
|
|
||||||
|
- Date: 2026-08-24
|
||||||
|
- Scope: M2.9 forward price observation archive, exploratory research mode, validated PIT regression
|
||||||
|
- Branch: `main`
|
||||||
|
- Commit: pending; no commit or push performed
|
||||||
|
|
||||||
|
## Commands and results
|
||||||
|
|
||||||
|
```text
|
||||||
|
PYTHONPATH=backend .venv/bin/python -W error -m unittest discover -s backend/tests -v
|
||||||
|
Ran 125 tests in 0.351s
|
||||||
|
OK
|
||||||
|
|
||||||
|
PYTHONPATH=backend .venv/bin/python -m compileall -q backend
|
||||||
|
exit 0
|
||||||
|
|
||||||
|
(cd frontend && npm run build)
|
||||||
|
Vite production build completed successfully
|
||||||
|
|
||||||
|
(cd frontend && npm audit --omit=dev --audit-level=high)
|
||||||
|
found 0 vulnerabilities
|
||||||
|
|
||||||
|
git diff --check
|
||||||
|
exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Static scan over the changed code reported zero matches for:
|
||||||
|
|
||||||
|
```text
|
||||||
|
secret_assignment=0
|
||||||
|
shell=0
|
||||||
|
eval=0
|
||||||
|
pickle=0
|
||||||
|
sql_fstring=0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Focused behavior verified
|
||||||
|
|
||||||
|
- Identical provider payloads reuse the immutable snapshot and append an `unchanged` observation.
|
||||||
|
- Changed provider payloads create a new snapshot and append a `revised` observation with prior raw hash and structured diff paths.
|
||||||
|
- Original raw/snapshot files remain readable after a revision.
|
||||||
|
- Tampered observation audit entries fail closed on manifest load.
|
||||||
|
- Contract-backed snapshot entries reject missing observation references, count/timing metadata tampering, and out-of-order/equal-time predecessor insertion without creating artifacts.
|
||||||
|
- Non-PIT normalized series require canonical identity, strictly increasing sessions, and provider-symbol/raw-payload lineage.
|
||||||
|
- Legacy pre-observation price manifests remain readable, while new contract-backed entries require complete manifest metadata cross-checks.
|
||||||
|
- Observation time changes do not create a new research run when the price snapshot content is unchanged.
|
||||||
|
- Same raw payload bytes with changed normalized content across a new snapshot ID are rejected.
|
||||||
|
- Malformed snapshot manifest entries are rejected instead of silently skipped.
|
||||||
|
- Observation timestamps before a snapshot's immutable first capture are rejected.
|
||||||
|
- Duplicate equal-UTC observations for one snapshot are rejected while later repeated observations remain valid.
|
||||||
|
- Explicit exploratory mode can run on revised history only with `status=descriptive_only`, `result_scope=non_pit_descriptive_only`, and limitations.
|
||||||
|
- Validated mode still rejects non-PIT price history and requires the existing PIT/known-at contract.
|
||||||
|
- API exposes `/api/v1/prices/observations`; invalid research modes return HTTP 400.
|
||||||
|
|
||||||
|
## Independent review status
|
||||||
|
|
||||||
|
The final bounded independent reviewer `deleg_e5407553` returned a schema-valid `passed=true` verdict with `security_concerns=[]` and `logic_errors=[]`. This follow-up closes the same-raw, malformed-manifest, first-capture timing, and equal-time duplicate gaps with RED/GREEN regressions. Fresh focused independent review `deleg_0e2282c8` returned schema-valid `passed=true` with empty blocker arrays; its only suggestion is to retain regression coverage for later repeated observations and deterministic equal-time ordering across distinct snapshot IDs. Process-level locking/transactional persistence and direct helper/UI tests remain deferred. No commit or push has been performed.
|
||||||
|
|
||||||
|
## Known limits
|
||||||
|
|
||||||
|
- Cache/diff evidence begins at first capture and does not prove provider release time or historical knowledge state before capture.
|
||||||
|
- The live revised Yahoo history remains `quality=revised_vendor_history` and `point_in_time=false`.
|
||||||
|
- No validated historical event study/backtest or alpha claim is enabled.
|
||||||
|
- Local filesystem writes remain single-process; concurrent-writer and shared database verification are deferred.
|
||||||
|
|
||||||
|
NEVER include API keys, tokens, passwords, secrets, credentials, or connection strings in the summary — replace any that appear with [REDACTED].
|
||||||
78
docs/test-evidence/2026-08-24-pit-price-contract.md
Normal file
78
docs/test-evidence/2026-08-24-pit-price-contract.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Test Evidence — PIT Daily Price Contract — 2026-08-24
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
PIT daily-price archive validation and no-lookahead event-study joining. This evidence covers the implementation contract only; it does not claim that the live revised vendor source is PIT.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```text
|
||||||
|
PYTHONPATH=backend .venv/bin/python -W error -m unittest discover -s backend/tests -v
|
||||||
|
Ran 93 tests in 0.214s — OK
|
||||||
|
|
||||||
|
PYTHONPATH=backend .venv/bin/python -m compileall -q backend
|
||||||
|
passed
|
||||||
|
|
||||||
|
cd frontend && npm run build
|
||||||
|
exit 0
|
||||||
|
|
||||||
|
cd frontend && npm audit --omit=dev --audit-level=high
|
||||||
|
found 0 vulnerabilities
|
||||||
|
|
||||||
|
git diff HEAD --check && git diff --cached --check
|
||||||
|
passed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Focused evidence
|
||||||
|
|
||||||
|
- A `point_in_time=true` snapshot without `pit-daily-v1` and provider-release evidence is rejected.
|
||||||
|
- A PIT bar without `known_at` is rejected.
|
||||||
|
- A valid PIT archive persists and reloads only when raw hash, normalized hash, manifest identity, release metadata, and contract metadata agree.
|
||||||
|
- Existing snapshot IDs are immutable; changed raw or normalized content cannot overwrite an already manifested archive.
|
||||||
|
- Malformed manifests, snapshots, and raw payload symbols fail as controlled `PriceSourceError` values before persistence.
|
||||||
|
- Revised vendor history remains accepted only as `quality=revised_vendor_history` and `point_in_time=false`.
|
||||||
|
- Event-study PIT mode rejects missing `known_at` and a timestamp after the session date.
|
||||||
|
- Event-study PIT mode checks the session date in the declared market timezone, not only the UTC calendar date.
|
||||||
|
- The research runner enables the known-at check when the PIT gate is active.
|
||||||
|
|
||||||
|
## Live app check
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/prices/health
|
||||||
|
HTTP 200; current source is revised vendor history, point_in_time=false.
|
||||||
|
|
||||||
|
GET /api/v1/backtest/tourism?min_events=1
|
||||||
|
HTTP 409; status=blocked; reason=price_series_not_point_in_time.
|
||||||
|
```
|
||||||
|
|
||||||
|
- Final bounded independent review `deleg_c08f6dd8` returned `passed=true` with empty `security_concerns`, `logic_errors`, and `suggestions` arrays.
|
||||||
|
|
||||||
|
## Provider evidence gate
|
||||||
|
|
||||||
|
- Databento public material confirms SET venue presence and point-in-time corporate-action records, but does not establish point-in-time SET price-bar release IDs, client availability/`known_at`, or correction-vintage replay. [8][9][11]
|
||||||
|
- Exchange Data International (EDI) public material confirms SET/XBKK equity coverage, a July 2025 confirmed ETA of 14:30 GMT, and correction/re-push behavior. Its public contract still does not establish actual per-file release timestamps, immutable retention, release IDs, or retrievable vintage manifests; its FAQ also states that it has no EOD time-series database. [12][13][14]
|
||||||
|
- ICE and LSEG remain conditional commercial candidates; no candidate passed `pit-daily-v1`, so no provider adapter was added and the live API remains fail-closed. [4][5][6]
|
||||||
|
- The broader matrix retains public references for SET Historical Data, SETSMART, and SMART Marketplace; none supplies the missing PIT release/vintage contract. [1][2][3]
|
||||||
|
- ICE SET and Databento historical timestamp materials remain non-qualifying without SET price-vintage evidence. [7][10]
|
||||||
|
- Full feasibility evidence and citations: `docs/engineering-log/2026-08-24-price-provider-feasibility.md`.
|
||||||
|
|
||||||
|
## Remaining gate
|
||||||
|
|
||||||
|
A real archive provider and release-time evidence are still required. The contract implementation must not be treated as evidence that any current vendor history is PIT. BOT remains at one independent release out of the required twelve.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
[1] https://www.set.or.th/en/services/connectivity-and-data/data/historical
|
||||||
|
[2] https://www.set.or.th/en/services/connectivity-and-data/data/web-based
|
||||||
|
[3] https://www.set.or.th/en/services/connectivity-and-data/data/smart-marketplace
|
||||||
|
[4] https://developer.ice.com/fixed-income-data-services/catalog/stock-exchange-thailand-set
|
||||||
|
[5] https://www.lseg.com/en/data-analytics/market-data/data-feeds/tick-history
|
||||||
|
[6] https://www.lseg.com/en/data-analytics/market-data/data-feeds/tick-history/s3-direct
|
||||||
|
[7] https://www.ice.com/fixed-income-data-services/access-and-delivery/connectivity-and-feeds/ice-consolidated-history
|
||||||
|
[8] https://databento.com/venues
|
||||||
|
[9] https://databento.com/docs/venues-and-datasets/corporate-actions
|
||||||
|
[10] https://databento.com/historical
|
||||||
|
[11] https://databento.com/equities
|
||||||
|
[12] https://www.exchange-data.com/product/end-day-pricing-data
|
||||||
|
[13] https://www.exchange-data.com/faqs-end-of-day-prices
|
||||||
|
[14] https://www.exchange-data.com/wp-content/uploads/2025/09/ETA-Coverage-17-July-2025.xls
|
||||||
Reference in New Issue
Block a user