183 lines
7.2 KiB
Python
183 lines
7.2 KiB
Python
"""Flask application factory for the SET50 alternative-data platform."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import json
|
|
import os
|
|
import secrets
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from flask import Flask, jsonify, request
|
|
|
|
from .paper import PaperLedger
|
|
from .tourism import compute_tourism_signal
|
|
|
|
APP_VERSION = "0.1.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__)
|
|
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")),
|
|
SNAPSHOT=_load_default_snapshot(),
|
|
)
|
|
if config:
|
|
app.config.update(config)
|
|
|
|
result = compute_tourism_signal(app.config["SNAPSHOT"])
|
|
ledger = PaperLedger()
|
|
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})
|
|
|
|
@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"),
|
|
},
|
|
"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
|