Close the last deferred PIT milestone by collecting REAL per-stock dated
dividend cash-flow history from Siamchart, upgrading the dividend ledger
from DPS estimates to dated_ledger.
- backend/app/siamchart.py: parse_dividend_history(html) extracts the
'ประวัติการปันผล' dividend table (ex_date + per-share DPS) from each
stock-info page; fetch_dividend_history(symbol) fetches it live.
- backend/app/dividend_ledger.py: populate_dated_dividends(ledger,
symbols, fetcher) registers every dated payment as a real row
(estimate=False, source=siamchart_dated); one symbol failing never
aborts the rest.
- backend/app/__init__.py: DividendLedger persisted at
data/dividends/ledger.json; POST /api/v1/dividends/update fetches all
symbols and saves it; use_ledger backtests prefer the dated ledger when
populated (dividend_method=dated_ledger) and fall back to DPS estimates
otherwise.
- tests: parser (4) + populate (2) — full backend 292 passed.
Live (real network): update fetched 49/49 symbols, 1410 dated payments;
use_ledger backtest then reports dividend_method=dated_ledger.
The 'eval(' static-scan hit is ast.literal_eval (safe literal parse, no
code execution), not eval().
1035 lines
47 KiB
Python
1035 lines
47 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 statistics
|
|
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 PIT_ARCHIVE_CONTRACT, 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"
|
|
PAPER_AUTH_MODES = {"demo", "token"}
|
|
LOOPBACK_BIND_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
PAPER_DEMO_WARNING = (
|
|
"DEMO MODE: unauthenticated local paper writes; loopback-only and paper-only. No live orders."
|
|
)
|
|
|
|
|
|
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 _load_siamchart_snapshot() -> dict[str, Any]:
|
|
"""Load the latest Siamchart SET50 fundamental snapshot (raw dict with
|
|
``rows`` + ``details`` + ``retrieved_at``) or {} if absent/malformed.
|
|
|
|
Used by the PIT scorer: its EPS 5-year series and per-symbol ratios feed
|
|
the fundamental (40%) dimension. Absent snapshot -> {} (the PIT provider
|
|
then falls back to the current board for fundamental, flagged partial)."""
|
|
path = Path(__file__).resolve().parents[1] / "data" / "siamchart" / "set50_master.json"
|
|
if not path.is_file():
|
|
return {}
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return {}
|
|
if not isinstance(data, dict):
|
|
return {}
|
|
return data
|
|
|
|
|
|
def _seed_siamchart_vintage(store) -> None:
|
|
"""Persist the current on-disk siamchart snapshot as the first vintage, if
|
|
the store is empty. Intentionally idempotent (store.deduplicates by
|
|
retrieved_at + body)."""
|
|
try:
|
|
from .siamchart_vintages import SiamchartVintageStore
|
|
if isinstance(store, SiamchartVintageStore) and not store.list_ids():
|
|
snap = _load_siamchart_snapshot()
|
|
if snap:
|
|
store.persist(snap)
|
|
except Exception: # noqa: BLE001 — seeding must never break the route
|
|
pass
|
|
|
|
|
|
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_AUTH_MODE=os.getenv("PAPER_AUTH_MODE", "token"),
|
|
PAPER_BIND_HOST=os.getenv("PAPER_BIND_HOST", os.getenv("HOST", "unknown")),
|
|
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)
|
|
|
|
paper_auth_mode = str(app.config.get("PAPER_AUTH_MODE", "token")).strip().lower()
|
|
if paper_auth_mode not in PAPER_AUTH_MODES:
|
|
raise ValueError(f"unsupported PAPER_AUTH_MODE: {paper_auth_mode}")
|
|
paper_bind_host = str(app.config.get("PAPER_BIND_HOST", "127.0.0.1")).strip().lower()
|
|
if paper_auth_mode == "demo" and paper_bind_host not in LOOPBACK_BIND_HOSTS:
|
|
raise ValueError("PAPER_AUTH_MODE=demo requires a loopback PAPER_BIND_HOST")
|
|
app.config["PAPER_AUTH_MODE"] = paper_auth_mode
|
|
app.config["PAPER_BIND_HOST"] = paper_bind_host
|
|
|
|
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
|
|
from .forward_test import ForwardTestStore
|
|
app.extensions["forward_store"] = ForwardTestStore(
|
|
app.config.get("FORWARD_STORE_PATH") or (data_root / "forward" / "runs.json")
|
|
)
|
|
from .dividend_ledger import DividendLedger
|
|
app.extensions["dividend_ledger"] = DividendLedger(
|
|
app.config.get("DIVIDEND_LEDGER_PATH") or (data_root / "dividends" / "ledger.json")
|
|
)
|
|
|
|
# shared in-process daily cache + app-internal data scheduler (independent of
|
|
# Hermes — this app runs on its own server).
|
|
from .daily_cache import DailyCache
|
|
from .scheduler import AppDataScheduler
|
|
cache = DailyCache()
|
|
app.extensions["daily_cache"] = cache
|
|
if not app.config.get("TESTING"):
|
|
interval_s = int(os.getenv("REFRESH_INTERVAL_SECONDS", "3600"))
|
|
scheduler = AppDataScheduler(cache, Path(__file__).resolve().parents[1] / "data", interval_seconds=interval_s)
|
|
scheduler.start()
|
|
app.extensions["data_scheduler"] = scheduler
|
|
|
|
@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 _configured_paper_token() -> str | None:
|
|
configured = app.config.get("PAPER_WRITE_TOKEN", "")
|
|
if not isinstance(configured, str) or not configured:
|
|
return None
|
|
return configured
|
|
|
|
def _paper_write_authorized() -> tuple[bool, tuple[dict[str, str], int] | None]:
|
|
if app.config["PAPER_AUTH_MODE"] == "demo":
|
|
return True, None
|
|
configured = _configured_paper_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)
|
|
|
|
def _paper_auth_status(authorized: bool, enabled: bool = True) -> dict[str, Any]:
|
|
if app.config["PAPER_AUTH_MODE"] == "demo":
|
|
return {"authenticated": True, "enabled": True, "mode": "demo", "warning": PAPER_DEMO_WARNING}
|
|
return {
|
|
"authenticated": authorized,
|
|
"enabled": enabled,
|
|
"mode": "token",
|
|
"warning": (
|
|
"Protected mode: paper writes are disabled until a server-side token is configured."
|
|
if not enabled
|
|
else "Protected mode: paper writes require a server-side token session."
|
|
),
|
|
}
|
|
|
|
@app.post("/api/v1/auth/paper")
|
|
def authenticate_paper():
|
|
if app.config["PAPER_AUTH_MODE"] == "demo":
|
|
return jsonify(_paper_auth_status(True))
|
|
configured = _configured_paper_token()
|
|
if not configured:
|
|
return jsonify({"error": "paper writes disabled: PAPER_WRITE_TOKEN is not configured"}), 503
|
|
payload = request.get_json(silent=True)
|
|
candidate = payload.get("token", "") if isinstance(payload, dict) else ""
|
|
if not isinstance(candidate, str):
|
|
candidate = ""
|
|
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(_paper_auth_status(True))
|
|
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()
|
|
enabled = app.config["PAPER_AUTH_MODE"] == "demo" or _configured_paper_token() is not None
|
|
return jsonify(_paper_auth_status(authorized, enabled=enabled))
|
|
|
|
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 _load_replayed_tourism(vintage_id: str) -> dict[str, Any]:
|
|
frozen_snapshot = app.extensions["vintage_store"].load_snapshot(vintage_id)
|
|
return compute_tourism_signal(frozen_snapshot)
|
|
|
|
def _source_replayable(source: dict[str, Any]) -> bool:
|
|
vintage_id = str(source.get("vintage_id", ""))
|
|
if not vintage_id or _snapshot_file_for(vintage_id) is None:
|
|
return False
|
|
try:
|
|
_load_replayed_tourism(vintage_id)
|
|
except (FileNotFoundError, VintageStoreError, OSError, KeyError, TypeError, ValueError):
|
|
return False
|
|
return True
|
|
|
|
def _price_health_payload() -> dict[str, Any]:
|
|
try:
|
|
price_store = app.extensions["price_store"]
|
|
snapshots = price_store.list_snapshots()
|
|
observations = price_store.list_observations()
|
|
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,
|
|
"observation_count": len(observations),
|
|
}
|
|
latest = snapshots[-1]
|
|
latest_observation = observations[-1] if observations else None
|
|
try:
|
|
snapshot = price_store.load_snapshot(str(latest["snapshot_id"]))
|
|
except (FileNotFoundError, PriceSourceError, KeyError) as exc:
|
|
return {"available": False, "status": "error", "error": f"price snapshot integrity validation failed: {exc}", "snapshot_count": len(snapshots)}
|
|
source = snapshot.get("source", {})
|
|
return {
|
|
"available": True,
|
|
"status": "available",
|
|
"snapshot_count": len(snapshots),
|
|
"observation_count": len(observations),
|
|
"snapshot_id": latest.get("snapshot_id"),
|
|
"retrieved_at": latest.get("retrieved_at"),
|
|
"last_seen_at": latest.get("last_seen_at", latest.get("retrieved_at")),
|
|
"last_observation_at": latest_observation.get("retrieved_at") if latest_observation else None,
|
|
"last_revision_status": latest_observation.get("revision_status") if latest_observation else None,
|
|
"period_start": latest.get("period_start"),
|
|
"period_end": latest.get("period_end"),
|
|
"quality": source.get("quality"),
|
|
"point_in_time": source.get("point_in_time"),
|
|
"archive_contract": source.get("archive_contract"),
|
|
"provider_release_id": source.get("provider_release_id"),
|
|
"parser_version": source.get("parser_version"),
|
|
"symbols": latest.get("symbols", []),
|
|
}
|
|
|
|
@app.get("/api/v1/prices/health")
|
|
def prices_health():
|
|
return jsonify(_price_health_payload())
|
|
|
|
@app.get("/api/v1/prices/observations")
|
|
def prices_observations():
|
|
try:
|
|
observations = app.extensions["price_store"].list_observations()
|
|
except PriceSourceError as exc:
|
|
return jsonify({"error": str(exc)}), 422
|
|
return jsonify({"count": len(observations), "observations": observations})
|
|
|
|
@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", 1 if payload.get("mode") == "exploratory" else 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),
|
|
mode=payload.get("mode", "validated"),
|
|
)
|
|
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()
|
|
price_is_pit_archive = (
|
|
price_snapshot.get("available")
|
|
and price_snapshot.get("point_in_time") is True
|
|
and price_snapshot.get("quality") == "point_in_time_archive"
|
|
and price_snapshot.get("archive_contract") == PIT_ARCHIVE_CONTRACT
|
|
)
|
|
if readiness["status"] == "ready" and not price_is_pit_archive:
|
|
reason = "price_series_not_point_in_time" if price_snapshot.get("point_in_time") is not True else "price_archive_contract_missing"
|
|
readiness = {
|
|
**readiness,
|
|
"status": "blocked",
|
|
"reason": reason,
|
|
}
|
|
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 a verified point-in-time daily price archive contract" if readiness["reason"] in {"price_series_not_point_in_time", "price_archive_contract_missing"} 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", "")
|
|
if _snapshot_file_for(vintage_id) is None:
|
|
return jsonify({"error": "invalid vintage_id"}), 400
|
|
try:
|
|
replayed = _load_replayed_tourism(vintage_id)
|
|
except FileNotFoundError:
|
|
return jsonify({"error": "vintage snapshot not found"}), 404
|
|
except (VintageStoreError, OSError):
|
|
return jsonify({"error": "vintage snapshot integrity validation failed"}), 422
|
|
except (TypeError, ValueError, KeyError) 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.get("/api/v1/factors")
|
|
def factors():
|
|
"""Return the Siamchart fundamental factor view merged with tourism signals.
|
|
|
|
Each entry pairs the per-symbol fundamental factors (PE, EPS, dividend
|
|
yield, P/BV, ROE, is_dividend) with the tourism signal (side/score) so
|
|
the dashboard can show both the alternative-factor read and the signal
|
|
recommendation in one table, and can filter to dividend payers.
|
|
"""
|
|
from app import siamchart_factors
|
|
|
|
factor_view = siamchart_factors.build_factor_view()
|
|
# signal = derived from the NEW theme-engine combined score (60/40 with
|
|
# per-symbol quality), NOT the old tourism_result. One source of truth.
|
|
from app import daily_cache
|
|
from app.dashboard import RealDashboard
|
|
cache = app.extensions.setdefault("daily_cache", daily_cache.DailyCache())
|
|
current = app.extensions.get("tourism_result") or {}
|
|
signal_by_symbol = {}
|
|
try:
|
|
dash = RealDashboard(current.get("signals", []), cache).build()
|
|
board = dash.get("board", [])
|
|
combos = [b.get("combined") for b in board if b.get("combined") is not None]
|
|
if combos:
|
|
q1, q3 = statistics.quantiles(combos, n=4)[0], statistics.quantiles(combos, n=4)[2]
|
|
else:
|
|
q1 = q3 = 0.0
|
|
|
|
# market-regime gate (R3): how many themes are in distress. Instead
|
|
# of a hard binary cliff, use a continuous stress = (negative themes
|
|
# / total) in [0,1] and shift the LONG bar / SHORT threshold
|
|
# smoothly with it. In a broad-down market we tighten LONG and pull
|
|
# more names into SHORT/avoid, so 'best of a falling board' isn't LONG.
|
|
theme_surprises = [t.get("surprise") for t in dash.get("themes", [])
|
|
if t.get("surprise") is not None]
|
|
n_themes = max(len(theme_surprises), 1)
|
|
n_neg = sum(1 for s in theme_surprises if s < 0)
|
|
stress = n_neg / n_themes # 0..1 continuous regime gauge
|
|
# gate offset grows with stress (at stress=1 => +0.15 to go LONG)
|
|
long_bar = q3 + 0.15 * stress
|
|
# SHORT threshold widens as stress rises (pull more into avoid)
|
|
short_bar = q1 - 0.05 - 0.08 * stress
|
|
|
|
fmap = {b.get("symbol"): b for b in board}
|
|
for row in board:
|
|
comb = row.get("combined")
|
|
sym = row.get("symbol")
|
|
if comb is None:
|
|
signal_by_symbol[sym] = {"side": None, "score": None}
|
|
continue
|
|
fac = fmap.get(sym, {})
|
|
# R5 (dividend screen): a name that pays no dividend (or has cut
|
|
# its yield to a negative/zero level) never goes LONG — dividend
|
|
# is our core value assumption; literature treats a cut as a
|
|
# screen-off signal. Downgrade to NEUTRAL/SHORT accordingly.
|
|
is_div = bool(fac.get("is_dividend")) or (fac.get("dividend_yield") or 0) > 0
|
|
if comb >= long_bar:
|
|
if is_div:
|
|
side, score = "LONG", round(min(abs(comb) * 3.0, 1.0) * 0.9 + 0.1, 3)
|
|
else:
|
|
# high score but no dividend -> strong growth but our
|
|
# thesis is dividend-anchored; cap at NEUTRAL.
|
|
side, score = "NEUTRAL", round((comb - q1) / max(q3 - q1, 1e-9), 3)
|
|
elif comb < short_bar:
|
|
side, score = "SHORT", round(min(abs(comb) / max(abs(short_bar), 1e-6), 1.0) * 0.5, 3)
|
|
else:
|
|
median = combos and statistics.median(combos) or 0.0
|
|
side = "SHORT" if (comb < median and stress > 0.5) else "NEUTRAL"
|
|
score = round(abs(comb) / max(abs(q1), 1e-9) * 0.5, 3) if stress > 0.5 \
|
|
else round((comb - q1) / max(q3 - q1, 1e-9), 3)
|
|
signal_by_symbol[sym] = {
|
|
"side": side, "score": score, "confidence": "medium",
|
|
"combined_score": comb,
|
|
"regime": "risk-off" if stress > 0.4 else "normal",
|
|
"regime_stress": round(stress, 3),
|
|
}
|
|
except Exception:
|
|
# no dashboard -> fall back to neutral for all
|
|
pass
|
|
|
|
merged = []
|
|
for factor in factor_view.get("factors", []):
|
|
sig = signal_by_symbol.get(factor["symbol"], {})
|
|
merged.append(
|
|
{
|
|
**factor,
|
|
"signal_side": sig.get("side"),
|
|
"signal_score": sig.get("score"),
|
|
"signal_confidence": sig.get("confidence"),
|
|
"combined_score": sig.get("combined_score"),
|
|
"regime": sig.get("regime"),
|
|
"signal_target_weight": None,
|
|
"reason_codes": ([
|
|
f"quartile({'LONG' if sig.get('side')=='LONG' else 'SHORT'} 25%), regime={sig.get('regime') or 'normal'}"
|
|
] if sig.get("side") in ("LONG", "SHORT") else ["NEUTRAL quartile band"]),
|
|
}
|
|
)
|
|
# Put symbols that carry a non-neutral signal first, then by signal score.
|
|
merged.sort(
|
|
key=lambda f: (
|
|
0 if f.get("signal_side") in ("LONG", "SHORT") else 1,
|
|
-(abs(f.get("signal_score") or 0)),
|
|
f["symbol"],
|
|
)
|
|
)
|
|
return jsonify(
|
|
{
|
|
"available": factor_view.get("available", False),
|
|
"as_of": factor_view.get("as_of"),
|
|
"source": factor_view.get("source"),
|
|
"factor_count": factor_view.get("factor_count", 0),
|
|
"dividend_count": factor_view.get("dividend_count", 0),
|
|
"signal_as_of": current["as_of"],
|
|
"signal_theme": current["theme"],
|
|
"signal_theme_surprise": current["theme_surprise"],
|
|
"factors": merged,
|
|
}
|
|
)
|
|
|
|
|
|
@app.route("/api/v1/simulation", methods=["POST"])
|
|
def simulation():
|
|
"""Capital-allocation simulation (paper/backtest; never a real order).
|
|
|
|
Body: {capital: float, mode: "backtest"|"forward"}.
|
|
Uses the SAME canonical combined score as /api/v1/dashboard (via
|
|
`default_scores`) — not a separate 3-theme recompute — so the "จำลอง"
|
|
allocation can never disagree with the board on which names rank highest.
|
|
Prices come from the latest Yahoo snapshot; allocation uses the 50/20/30
|
|
dividend buckets. Output is paper/backtest on revised history (non-PIT).
|
|
"""
|
|
from app import simulation as sim
|
|
|
|
payload = request.get_json(silent=True) or {}
|
|
try:
|
|
capital = float(payload.get("capital", 0))
|
|
except (TypeError, ValueError):
|
|
return jsonify({"error": "capital must be a number"}), 400
|
|
mode = payload.get("mode", "backtest") in ("backtest", "forward") and payload.get("mode", "backtest")
|
|
if capital <= 0:
|
|
return jsonify({"error": "capital must be > 0"}), 400
|
|
|
|
try:
|
|
series = sim.load_price_snapshot()
|
|
prices = sim.latest_prices(series)
|
|
except (sim.SimulationError, OSError) as exc:
|
|
return jsonify({"error": f"price snapshot: {exc}"}), 503
|
|
|
|
# Single source of truth: the live multi-theme board (combined 60/40 +
|
|
# quality + momentum + dividend screen), identical to /api/v1/dashboard.
|
|
from app.dashboard import default_scores
|
|
try:
|
|
score_by_symbol = default_scores(None)
|
|
except Exception:
|
|
# No collector detail in the response (avoid leaking internal state).
|
|
return jsonify({"error": "dashboard scores unavailable"}), 503
|
|
|
|
candidates = []
|
|
for sym, meta in score_by_symbol.items():
|
|
price = prices.get(sym)
|
|
if price is None:
|
|
continue
|
|
candidates.append(
|
|
sim.Candidate(
|
|
symbol=sym,
|
|
price=price,
|
|
combined_score=meta.get("combined", 0.0),
|
|
is_dividend=bool(meta.get("is_dividend")),
|
|
dividend_yield=float(meta.get("dividend_yield") or 0.0),
|
|
)
|
|
)
|
|
|
|
try:
|
|
result = sim.allocate_capital(capital, candidates)
|
|
except sim.SimulationError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
|
|
from app import daily_cache
|
|
current = app.extensions.get("tourism_result") or {}
|
|
return jsonify(
|
|
{
|
|
"mode": mode,
|
|
"capital": capital,
|
|
"as_of": current.get("as_of"),
|
|
"data_note": "paper/backtest on revised vendor history (non-PIT) — not validated evidence",
|
|
**result.to_dict(),
|
|
}
|
|
)
|
|
|
|
@app.get("/api/v1/themes")
|
|
def themes():
|
|
"""Multi-theme combined board (delegates to the canonical dashboard).
|
|
|
|
Single source: RealDashboard.build() so /api/v1/themes returns the SAME
|
|
13-theme set, labels, surprises, and per-symbol combined board as
|
|
/api/v1/dashboard. Removes the old 3-theme duplicated logic.
|
|
"""
|
|
from app.dashboard import RealDashboard, DashboardError
|
|
from app import daily_cache
|
|
cache = app.extensions.setdefault("daily_cache", daily_cache.DailyCache())
|
|
current = app.extensions.get("tourism_result") or {}
|
|
try:
|
|
dash = RealDashboard(current.get("signals", []), cache).build()
|
|
except DashboardError as exc:
|
|
return jsonify({"error": str(exc)}), 503
|
|
return jsonify({
|
|
"themes": dash["themes"],
|
|
"as_of": dash.get("as_of", ""),
|
|
"combined_count": len(dash["board"]),
|
|
"board": dash["board"],
|
|
"macro": dash.get("macro", {}),
|
|
})
|
|
|
|
|
|
|
|
@app.get("/api/v1/symbols/<symbol>")
|
|
def symbol_detail(symbol: str):
|
|
"""Transparent per-symbol analysis breakdown (themes -> weights -> combined)."""
|
|
from app import themes as themes_mod
|
|
from app import siamchart_factors, simulation
|
|
symbol = symbol.upper()
|
|
factor_view = siamchart_factors.build_factor_view()
|
|
if symbol not in {f.get("symbol") for f in factor_view.get("factors", [])}:
|
|
return jsonify({"error": f"unknown symbol {symbol}", "symbol": symbol}), 404
|
|
# per-theme surprise from the real dashboard
|
|
from app.dashboard import RealDashboard
|
|
from app import daily_cache
|
|
cache = app.extensions.setdefault("daily_cache", daily_cache.DailyCache())
|
|
current = app.extensions.get("tourism_result")
|
|
try:
|
|
dash = RealDashboard((current or {}).get("signals", []), cache).build()
|
|
theme_surprises = {t["id"]: t.get("surprise") for t in dash.get("themes", [])}
|
|
except Exception:
|
|
theme_surprises = {}
|
|
# latest price from the Yahoo snapshot
|
|
price = None
|
|
price_date = ""
|
|
try:
|
|
series = simulation.load_price_snapshot()
|
|
bars = series.get(symbol, {}).get("bars", [])
|
|
if bars:
|
|
price = float(bars[-1]["adjusted_close"])
|
|
price_date = bars[-1].get("date", "")
|
|
except Exception:
|
|
pass
|
|
detail = themes_mod.symbol_breakdown(
|
|
symbol, factor_view=factor_view, theme_surprises=theme_surprises,
|
|
latest_price=price, price_date=price_date,
|
|
momentum=themes_mod._load_momentum(),
|
|
)
|
|
return jsonify(detail)
|
|
|
|
@app.post("/api/v1/backtest")
|
|
def run_backtest_endpoint():
|
|
"""Run a real backtest over [start, end] with capital; persist result."""
|
|
from app.backtest import run_backtest, BacktestError
|
|
from app import daily_cache
|
|
body = request.get_json(silent=True) or {}
|
|
start = body.get("start") or "2024-06-01"
|
|
end = body.get("end") or "2026-06-01"
|
|
capital = float(body.get("capital") or 1_000_000)
|
|
freq = body.get("freq") or "monthly"
|
|
use_pit = bool(body.get("use_pit"))
|
|
use_ledger = bool(body.get("use_ledger"))
|
|
try:
|
|
ledger = None
|
|
if use_ledger:
|
|
from .dividend_ledger import build_dps_ledger
|
|
# prefer the persisted dated ledger (real ex-date history, if
|
|
# populated via /api/v1/dividends/update); otherwise fall back
|
|
# to DPS estimates built from the current snapshot.
|
|
stored = app.extensions.get("dividend_ledger")
|
|
if stored is not None and stored.symbols():
|
|
ledger = stored
|
|
else:
|
|
ledger = build_dps_ledger(_load_siamchart_snapshot())
|
|
if use_pit:
|
|
from pathlib import Path as _Path
|
|
from .factor_vintages import FactorVintageStore
|
|
from .siamchart_vintages import SiamchartVintageStore
|
|
from .pit_scorer import PitScoreProvider, make_pit_score_fn
|
|
froot = _Path(__file__).resolve().parents[1] / "data"
|
|
store = FactorVintageStore(froot)
|
|
sstore = SiamchartVintageStore(froot)
|
|
# seed the first vintage from the current snapshot so the store
|
|
# has a known baseline (no-op if already stored).
|
|
_seed_siamchart_vintage(sstore)
|
|
provider = PitScoreProvider(store, _load_siamchart_snapshot(),
|
|
siamchart_store=sstore)
|
|
score_fn = make_pit_score_fn(provider)
|
|
res = run_backtest(start, end, capital=capital,
|
|
rebalance_freq=freq, score_fn=score_fn,
|
|
dividend_ledger=ledger)
|
|
else:
|
|
res = run_backtest(start, end, capital=capital,
|
|
rebalance_freq=freq, dividend_ledger=ledger)
|
|
except BacktestError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
runs = app.extensions.setdefault("backtest_runs", [])
|
|
record = res.to_dict()
|
|
record["id"] = len(runs) + 1
|
|
record["ran_at"] = __import__("datetime").datetime.now(
|
|
__import__("datetime").timezone.utc).isoformat(timespec="minutes")
|
|
runs.append(record)
|
|
return jsonify(record)
|
|
|
|
@app.get("/api/v1/backtest/runs")
|
|
def backtest_runs():
|
|
runs = app.extensions.get("backtest_runs", [])
|
|
return jsonify({"runs": runs})
|
|
|
|
@app.get("/api/v1/forward")
|
|
def forward_list():
|
|
"""List all durable forward-test runs (real lifecycle, not cosmetic)."""
|
|
store = app.extensions["forward_store"]
|
|
return jsonify({"runs": store.list()})
|
|
|
|
@app.get("/api/v1/forward/<run_id>")
|
|
def forward_get(run_id: str):
|
|
store = app.extensions["forward_store"]
|
|
run = store.get(run_id)
|
|
if run is None:
|
|
return jsonify({"error": "unknown forward run"}), 404
|
|
return jsonify(run)
|
|
|
|
@app.post("/api/v1/forward")
|
|
def forward_create():
|
|
"""CREATE + EXECUTE a forward run with frozen signals.
|
|
|
|
Body: {capital, use_pit: bool, as_of: "YYYY-MM-DD"}.
|
|
Freezes the current (or PIT-as-of) combined per-symbol scores and fills
|
|
the 50/20/30 buckets at post-freeze prices. The score set is immutable
|
|
from this point (a real frozen-signal lifecycle).
|
|
"""
|
|
from .forward_test import ForwardError
|
|
store = app.extensions["forward_store"]
|
|
body = request.get_json(silent=True) or {}
|
|
try:
|
|
capital = float(body.get("capital", 0))
|
|
except (TypeError, ValueError):
|
|
return jsonify({"error": "capital must be a number"}), 400
|
|
if capital <= 0:
|
|
return jsonify({"error": "capital must be > 0"}), 400
|
|
use_pit = bool(body.get("use_pit"))
|
|
as_of = body.get("as_of")
|
|
try:
|
|
from app import simulation as sim
|
|
series = sim.load_price_snapshot()
|
|
prices = sim.latest_prices(series)
|
|
if use_pit:
|
|
from .pit_scorer import PitScoreProvider, make_pit_score_fn
|
|
from .factor_vintages import FactorVintageStore
|
|
from .siamchart_vintages import SiamchartVintageStore
|
|
froot = Path(__file__).resolve().parents[1] / "data"
|
|
sstore = SiamchartVintageStore(froot)
|
|
_seed_siamchart_vintage(sstore)
|
|
provider = PitScoreProvider(FactorVintageStore(froot),
|
|
_load_siamchart_snapshot(),
|
|
siamchart_store=sstore)
|
|
score_fn = make_pit_score_fn(provider)
|
|
score_by_symbol = score_fn(symbols=[], as_of=as_of) or {}
|
|
non_pit = not any(
|
|
isinstance(m, dict) and isinstance(m.get("pit_meta"), dict)
|
|
and bool(m.get("pit_meta", {}).get("pit"))
|
|
for m in score_by_symbol.values()
|
|
)
|
|
else:
|
|
from .dashboard import default_scores
|
|
score_by_symbol = default_scores(None) or {}
|
|
non_pit = True
|
|
frozen = {}
|
|
for sym, meta in (score_by_symbol or {}).items():
|
|
if sym in prices:
|
|
frozen[sym] = {
|
|
"combined": float(meta.get("combined", 0.0)),
|
|
"is_dividend": bool(meta.get("is_dividend")),
|
|
"dividend_yield": float(meta.get("dividend_yield") or 0.0),
|
|
}
|
|
run = store.create(capital, frozen, as_of=as_of or "now", non_pit=non_pit)
|
|
executed = store.execute(run["id"], prices)
|
|
return jsonify(executed), 201
|
|
except (ForwardError, sim.SimulationError, OSError) as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
|
|
@app.post("/api/v1/forward/<run_id>/mark")
|
|
def forward_mark(run_id: str):
|
|
from .forward_test import ForwardError
|
|
from app import simulation as sim
|
|
store = app.extensions["forward_store"]
|
|
try:
|
|
series = sim.load_price_snapshot()
|
|
prices = sim.latest_prices(series)
|
|
return jsonify(store.mark(run_id, prices))
|
|
except (ForwardError, sim.SimulationError, OSError) as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
|
|
@app.post("/api/v1/forward/<run_id>/mature")
|
|
def forward_mature(run_id: str):
|
|
from .forward_test import ForwardError
|
|
from app import simulation as sim
|
|
store = app.extensions["forward_store"]
|
|
try:
|
|
series = sim.load_price_snapshot()
|
|
prices = sim.latest_prices(series)
|
|
return jsonify(store.mature(run_id, prices))
|
|
except (ForwardError, sim.SimulationError, OSError) as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
|
|
@app.post("/api/v1/dividends/update")
|
|
def dividends_update():
|
|
"""Fetch real dated dividend history for every snapshot symbol and
|
|
persist a dated dividend ledger.
|
|
|
|
Uses ``siamchart.fetch_dividend_history`` for each symbol (ex-date +
|
|
per-share DPS). Populates the on-disk ledger so ``use_ledger`` backtests
|
|
credit REAL dated cash flows (dividend_method=dated_ledger) instead of
|
|
DPS estimates. Returns per-symbol row counts.
|
|
"""
|
|
from .siamchart import fetch_dividend_history
|
|
from .dividend_ledger import populate_dated_dividends
|
|
from .siamchart_factors import build_factor_view
|
|
ledger = app.extensions["dividend_ledger"]
|
|
fv = build_factor_view()
|
|
symbols = [f["symbol"] for f in fv.get("factors", [])]
|
|
counts = populate_dated_dividends(ledger, symbols, fetch_dividend_history)
|
|
ledger.save()
|
|
total = sum(counts.values())
|
|
return jsonify({
|
|
"updated_symbols": len([s for s, n in counts.items() if n > 0]),
|
|
"symbols": len(symbols),
|
|
"total_payments": total,
|
|
"counts": counts,
|
|
"note": "dated_ledger now used by use_ledger backtests",
|
|
})
|
|
|
|
@app.get("/api/v1/data/last-refresh")
|
|
def last_refresh():
|
|
"""Status of the in-app automatic data refresh (independent of Hermes)."""
|
|
interval = os.getenv("REFRESH_INTERVAL_SECONDS", "3600")
|
|
marker_path = Path(__file__).resolve().parents[1] / "data" / "scheduler" / "last_refresh.json"
|
|
last = None
|
|
if marker_path.exists():
|
|
try:
|
|
last = json.loads(marker_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
last = None
|
|
return jsonify({
|
|
"automatic_refresh": True,
|
|
"interval_seconds": int(interval),
|
|
"interval_label": f"ทุก {int(interval)//3600} ชั่วโมง" if int(interval) >= 3600 else f"ทุก {int(interval)//60} นาที",
|
|
"last_refresh": last,
|
|
})
|
|
|
|
@app.get("/api/v1/dashboard")
|
|
def dashboard():
|
|
"""Real multi-theme dashboard (3 themes + macro + board + sources)."""
|
|
from app.dashboard import RealDashboard, DashboardError
|
|
from app import daily_cache
|
|
cache = app.extensions.setdefault("daily_cache", daily_cache.DailyCache())
|
|
current = app.extensions.get("tourism_result")
|
|
tourism_signals = (current or {}).get("signals", [])
|
|
try:
|
|
dash = RealDashboard(tourism_signals, cache).build()
|
|
except DashboardError as exc:
|
|
return jsonify({"error": str(exc), "available": False}), 503
|
|
return jsonify({"available": True, **dash})
|
|
|
|
@app.get("/api/v1/learning/momentum")
|
|
def learning_momentum():
|
|
"""P4 factor-weight learning report for the 12-1 momentum factor.
|
|
|
|
Runs a strictly point-in-time IC analysis over the price history:
|
|
does 12-1 momentum at month t predict 3m forward returns across the
|
|
cross-section? Applies a split-sample holdout validation gate —
|
|
reports mean/train/holdout IC, t-stat, n periods, `validated`, why not
|
|
(gate_notes), and a suggested weight. The learned weight is NEVER
|
|
auto-applied: `validated=false` keeps the weight unchanged. Honest:
|
|
503 when no price snapshot exists. Macro/demographic factors are not
|
|
yet attributable (no vintages).
|
|
"""
|
|
from app import weight_learning as wl
|
|
from app import simulation as sim
|
|
start = request.args.get("start") or "2024-06-01"
|
|
end = request.args.get("end") or "2026-06-01"
|
|
try:
|
|
series = sim.load_price_snapshot()
|
|
except (sim.SimulationError, OSError) as exc:
|
|
return jsonify({"error": f"price snapshot: {exc}"}), 503
|
|
symbols = sorted(series.keys())
|
|
try:
|
|
learning = wl.learn_momentum_gated(series, symbols, start, end)
|
|
except (wl.WeightLearningError, ValueError) as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
return jsonify({"factor": learning.to_dict(), "window": {"start": start, "end": end}})
|
|
|
|
@app.get("/api/v1/learning/factors")
|
|
def learning_factors():
|
|
"""P4 dataset-readiness: how many historical points each factor has.
|
|
|
|
Factors with >= `min_points` historical observations are learnable
|
|
(they accumulate automatically each scheduler run, starting now).
|
|
Macro/demographic factors start at 0 and become learnable over time.
|
|
"""
|
|
from app import factors as factors_mod
|
|
from app.factor_history import FactorHistory
|
|
hist_dir = app.config.get("FACTOR_HISTORY_DIR") or (
|
|
Path(__file__).resolve().parents[1] / "data" / "factor_history")
|
|
fh = FactorHistory(hist_dir)
|
|
try:
|
|
min_points = max(int(request.args.get("min_points", "12")), 0)
|
|
except (TypeError, ValueError):
|
|
return jsonify({"error": "min_points must be a non-negative integer"}), 400
|
|
rows = []
|
|
for fkey, fact in factors_mod.FACTORS.items():
|
|
series = fh.series(fkey)
|
|
rows.append({
|
|
"factor_key": fkey,
|
|
"name_th": fact.get("name_th", fkey),
|
|
"source": fact.get("source"),
|
|
"frequency": fact.get("frequency"),
|
|
"n_points": len(series),
|
|
"learnable": len(series) >= min_points,
|
|
"last_value": series[-1]["value"] if series else None,
|
|
"last_as_of": series[-1].get("as_of", "") if series else "",
|
|
})
|
|
rows.sort(key=lambda r: (-r["n_points"], r["factor_key"]))
|
|
return jsonify({"min_points": min_points, "factors": rows})
|
|
|
|
@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
|