366 lines
16 KiB
Python
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
|