Files
set50-system/backend/app/__init__.py
2026-08-23 14:42:02 +07:00

366 lines
16 KiB
Python

"""Flask application factory for the SET50 alternative-data platform."""
from __future__ import annotations
import hmac
import json
import os
import re
import secrets
import time
from pathlib import Path
from typing import Any
from flask import Flask, jsonify, request
from .bot_tourism import BotTourismSource, TourismSourceError
from .event_study import EventStudyError, assess_backtest_readiness
from .paper import PaperLedger
from .prices import PriceSnapshotStore, PriceSourceError
from .research import ResearchRunError, ResearchRunStore, run_tourism_research
from .tourism import compute_tourism_signal
from .vintages import VintageStore, VintageStoreError
APP_VERSION = "0.5.0"
def _load_default_snapshot() -> dict[str, Any]:
fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "tourism_snapshot.json"
return json.loads(fixture_path.read_text(encoding="utf-8"))
def _signal_summary(result: dict[str, Any]) -> dict[str, int]:
signals = result["signals"]
return {
"total": len(signals),
"long": sum(item["side"] == "LONG" for item in signals),
"short": sum(item["side"] == "SHORT" for item in signals),
"neutral": sum(item["side"] == "NEUTRAL" for item in signals),
}
def create_app(config: dict[str, Any] | None = None) -> Flask:
app = Flask(__name__)
data_root = Path(__file__).resolve().parents[1] / "data"
app.config.from_mapping(
TESTING=False,
MODE=os.getenv("APP_MODE", "research"),
PAPER_WRITE_TOKEN=os.getenv("PAPER_WRITE_TOKEN", ""),
PAPER_COOKIE_SECURE=os.getenv("PAPER_COOKIE_SECURE", "0") == "1",
PAPER_SESSION_SECONDS=int(os.getenv("PAPER_SESSION_SECONDS", "3600")),
PAPER_LEDGER_PATH=Path(os.getenv("PAPER_LEDGER_PATH", str(data_root / "paper" / "ledger.json"))),
TOURISM_SOURCE=os.getenv("TOURISM_SOURCE", "fixture"),
TOURISM_ADAPTER=None,
TOURISM_DATA_ROOT=Path(os.getenv("TOURISM_DATA_ROOT", str(data_root))),
TOURISM_RAW_DIR=Path(os.getenv("TOURISM_RAW_DIR", str(data_root / "raw" / "tourism"))),
SNAPSHOT_DIR=Path(os.getenv("TOURISM_SNAPSHOT_DIR", str(data_root / "snapshots"))),
VINTAGE_STORE=None,
PRICE_DATA_ROOT=Path(os.getenv("PRICE_DATA_ROOT", str(data_root / "prices"))),
PRICE_STORE=None,
RESEARCH_DATA_ROOT=Path(os.getenv("RESEARCH_DATA_ROOT", str(data_root / "research"))),
RESEARCH_RUN_STORE=None,
SNAPSHOT=_load_default_snapshot(),
)
if config:
app.config.update(config)
vintage_store = app.config.get("VINTAGE_STORE") or VintageStore(app.config["TOURISM_DATA_ROOT"])
app.extensions["vintage_store"] = vintage_store
price_store = app.config.get("PRICE_STORE") or PriceSnapshotStore(app.config["PRICE_DATA_ROOT"])
app.extensions["price_store"] = price_store
research_run_store = app.config.get("RESEARCH_RUN_STORE") or ResearchRunStore(app.config["RESEARCH_DATA_ROOT"])
app.extensions["research_run_store"] = research_run_store
source_snapshot = app.config["SNAPSHOT"]
source_mode = str(app.config["TOURISM_SOURCE"]).lower()
if source_mode == "bot":
adapter = app.config.get("TOURISM_ADAPTER") or BotTourismSource(vintage_store=vintage_store)
try:
source_snapshot = adapter.fetch(exposures=source_snapshot.get("exposures", []))
except TourismSourceError as exc:
raise RuntimeError(f"Tourism source startup failed: {exc}") from exc
elif source_mode != "fixture":
raise ValueError(f"unsupported TOURISM_SOURCE: {source_mode}")
result = compute_tourism_signal(source_snapshot)
ledger_path = app.config["PAPER_LEDGER_PATH"]
if app.config["TESTING"] and (not config or "PAPER_LEDGER_PATH" not in config):
ledger_path = None
ledger = PaperLedger(ledger_path)
paper_sessions: dict[str, float] = {}
allowed_symbols = {item["symbol"] for item in result["signals"]}
app.extensions["tourism_result"] = result
app.extensions["paper_ledger"] = ledger
app.extensions["paper_sessions"] = paper_sessions
app.extensions["allowed_symbols"] = allowed_symbols
@app.after_request
def add_cors_headers(response):
origin = request.headers.get("Origin")
allowed_origin = os.getenv("CORS_ORIGIN", "http://localhost:5173")
if origin == allowed_origin:
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Vary"] = "Origin"
response.headers["Access-Control-Allow-Headers"] = "Content-Type"
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
return response
def _paper_write_authorized() -> tuple[bool, tuple[dict[str, str], int] | None]:
configured = str(app.config.get("PAPER_WRITE_TOKEN", ""))
if not configured:
return False, ({"error": "paper writes disabled: PAPER_WRITE_TOKEN is not configured"}, 503)
sessions: dict[str, float] = app.extensions["paper_sessions"]
now = time.time()
expired = [session_id for session_id, expiry in sessions.items() if expiry <= now]
for session_id in expired:
sessions.pop(session_id, None)
session_id = request.cookies.get("paper_session", "")
if session_id and session_id in sessions and sessions[session_id] > now:
return True, None
return False, ({"error": "paper session required"}, 401)
@app.post("/api/v1/auth/paper")
def authenticate_paper():
configured = str(app.config.get("PAPER_WRITE_TOKEN", ""))
if not configured:
return jsonify({"error": "paper writes disabled: PAPER_WRITE_TOKEN is not configured"}), 503
payload = request.get_json(silent=True)
candidate = str(payload.get("token", "")) if isinstance(payload, dict) else ""
if not hmac.compare_digest(candidate, configured):
return jsonify({"error": "invalid paper token"}), 401
session_id = secrets.token_urlsafe(32)
app.extensions["paper_sessions"][session_id] = time.time() + int(app.config["PAPER_SESSION_SECONDS"])
response = jsonify({"authenticated": True, "mode": "paper"})
response.set_cookie(
"paper_session",
session_id,
max_age=int(app.config["PAPER_SESSION_SECONDS"]),
httponly=True,
secure=bool(app.config["PAPER_COOKIE_SECURE"]),
samesite="Strict",
)
return response
@app.get("/api/v1/auth/paper")
def paper_session_status():
authorized, _ = _paper_write_authorized()
return jsonify({"authenticated": authorized})
def _snapshot_file_for(vintage_id: str) -> Path | None:
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", vintage_id):
return None
root = Path(app.config["SNAPSHOT_DIR"]).resolve()
candidate = (root / f"{vintage_id}.json").resolve()
if candidate.parent != root:
return None
return candidate
def _source_replayable(source: dict[str, Any]) -> bool:
vintage_id = str(source.get("vintage_id", ""))
snapshot_path = _snapshot_file_for(vintage_id) if vintage_id else None
return snapshot_path is not None and snapshot_path.is_file()
def _price_health_payload() -> dict[str, Any]:
try:
snapshots = list(app.extensions["price_store"].load_manifest().get("snapshots", {}).values())
except PriceSourceError as exc:
return {"available": False, "status": "error", "error": str(exc), "snapshot_count": 0}
if not snapshots:
return {"available": False, "status": "missing", "point_in_time": False, "snapshot_count": 0}
latest = max(snapshots, key=lambda item: str(item.get("retrieved_at", "")))
return {
"available": True,
"status": "available",
"snapshot_count": len(snapshots),
"snapshot_id": latest.get("snapshot_id"),
"retrieved_at": latest.get("retrieved_at"),
"period_start": latest.get("period_start"),
"period_end": latest.get("period_end"),
"quality": latest.get("quality"),
"point_in_time": bool(latest.get("point_in_time")),
"symbols": latest.get("symbols", []),
}
@app.get("/api/v1/prices/health")
def prices_health():
return jsonify(_price_health_payload())
@app.post("/api/v1/research/tourism/run")
def run_tourism_research_endpoint():
payload = request.get_json(silent=True) or {}
if not isinstance(payload, dict):
return jsonify({"error": "JSON object required"}), 400
try:
report = run_tourism_research(
app.extensions["vintage_store"],
app.extensions["price_store"],
app.extensions["research_run_store"],
min_events=payload.get("min_events", 12),
windows=payload.get("windows", (1, 3, 5, 20)),
cost_bps=payload.get("cost_bps", 20.0),
execution_lag_sessions=payload.get("execution_lag_sessions", 1),
)
except (ResearchRunError, TypeError, ValueError) as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(report)
@app.get("/api/v1/research/tourism/latest")
def latest_tourism_research():
try:
report = app.extensions["research_run_store"].latest()
except FileNotFoundError:
return jsonify({"error": "no research runs"}), 404
except ResearchRunError as exc:
return jsonify({"error": str(exc)}), 422
return jsonify(report)
@app.get("/api/v1/data-health")
def data_health():
current = app.extensions["tourism_result"]
source = current["source"]
return jsonify(
{
"status": current["data_quality"],
"source_id": source.get("source_id"),
"source_url": source.get("source_url"),
"published_at": source.get("published_at"),
"retrieved_at": source.get("retrieved_at"),
"vintage_id": source.get("vintage_id"),
"raw_payload_hash": source.get("raw_payload_hash"),
"parser_version": source.get("parser_version"),
"available_periods": source.get("available_periods"),
"history_points": source.get("history_points"),
"replayable": _source_replayable(source),
"source_mode": app.config["TOURISM_SOURCE"],
}
)
@app.get("/api/v1/vintages")
def vintages():
as_of = request.args.get("as_of")
try:
entries = app.extensions["vintage_store"].list_vintages(as_of)
except VintageStoreError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"as_of": as_of, "count": len(entries), "vintages": entries})
@app.get("/api/v1/backtest/tourism")
def tourism_backtest_readiness():
raw_min_events = request.args.get("min_events", "12")
try:
min_events = int(raw_min_events)
readiness = assess_backtest_readiness(app.extensions["vintage_store"].list_vintages(), min_events)
except (ValueError, TypeError, EventStudyError) as exc:
return jsonify({"error": str(exc)}), 400
price_snapshot = _price_health_payload()
if readiness["status"] == "ready" and (not price_snapshot.get("available") or not price_snapshot.get("point_in_time")):
readiness = {
**readiness,
"status": "blocked",
"reason": "price_series_not_point_in_time",
}
body = {
**readiness,
"theme": "tourism",
"price_series_required": True,
"price_snapshot": price_snapshot,
"next_action": "collect independent published vintages before running event study" if readiness["reason"] == "insufficient_vintages" else "provide point-in-time daily price series" if readiness["reason"] == "price_series_not_point_in_time" else "run event study",
}
return jsonify(body), 409 if readiness["status"] == "blocked" else 200
@app.get("/api/v1/replay/tourism")
def replay_tourism():
vintage_id = request.args.get("vintage_id", "")
snapshot_path = _snapshot_file_for(vintage_id)
if snapshot_path is None:
return jsonify({"error": "invalid vintage_id"}), 400
if not snapshot_path.is_file():
return jsonify({"error": "vintage snapshot not found"}), 404
try:
frozen_snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
if frozen_snapshot.get("source", {}).get("vintage_id") != vintage_id:
return jsonify({"error": "vintage snapshot identity mismatch"}), 409
replayed = compute_tourism_signal(frozen_snapshot)
except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc:
return jsonify({"error": f"vintage snapshot is invalid: {exc.__class__.__name__}"}), 422
return jsonify({"vintage_id": vintage_id, "result": replayed})
@app.get("/api/v1/health")
def health():
return jsonify({"status": "ok", "mode": app.config["MODE"], "version": APP_VERSION})
@app.get("/api/v1/dashboard/summary")
def dashboard_summary():
current = app.extensions["tourism_result"]
entries = app.extensions["paper_ledger"].entries()
return jsonify(
{
"as_of": current["as_of"],
"theme": current["theme"],
"strategy_version": current["strategy_version"],
"theme_surprise": current["theme_surprise"],
"data_health": {
"status": current["data_quality"],
"source_id": current["source"].get("source_id"),
"source_url": current["source"].get("source_url"),
"published_at": current["source"].get("published_at"),
"retrieved_at": current["source"].get("retrieved_at"),
"vintage_id": current["source"].get("vintage_id"),
"raw_payload_hash": current["source"].get("raw_payload_hash"),
"parser_version": current["source"].get("parser_version"),
"available_periods": current["source"].get("available_periods"),
"history_points": current["source"].get("history_points"),
"source_mode": app.config["TOURISM_SOURCE"],
"replayable": _source_replayable(current["source"]),
},
"signal_summary": _signal_summary(current),
"top_signals": current["signals"][:5],
"paper_ledger": {"entries": len(entries), "mode": "paper"},
}
)
@app.get("/api/v1/factors/tourism/observations")
def tourism_observations():
current = app.extensions["tourism_result"]
return jsonify(
{
"theme": current["theme"],
"as_of": current["as_of"],
"theme_surprise": current["theme_surprise"],
"source": current["source"],
"observations": current["observations"],
}
)
@app.get("/api/v1/signals")
def signals():
current = app.extensions["tourism_result"]
return jsonify(
{
"theme": current["theme"],
"as_of": current["as_of"],
"strategy_version": current["strategy_version"],
"signals": current["signals"],
}
)
@app.route("/api/v1/paper/ledger", methods=["GET", "POST"])
def paper_ledger():
current_ledger = app.extensions["paper_ledger"]
if request.method == "GET":
return jsonify({"mode": "paper", "entries": current_ledger.entries()})
authorized, error = _paper_write_authorized()
if not authorized:
body, status = error
return jsonify(body), status
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return jsonify({"error": "JSON object required"}), 400
try:
entry = current_ledger.record(payload, app.extensions["allowed_symbols"])
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"mode": "paper", "entry": entry}), 201
return app