Files
set50-system/backend/app/__init__.py
Kunthawat Greethong c3a1461932 [verified] Add capital-allocation simulation engine + MT5 bridge (both dry-run/gated)
- simulation.py: price-series loader (Yahoo snapshot) + allocate_capital 50/20/30 with min-100 shares, bucket3 excludes bucket1, cash fallback
- /api/v1/simulation POST: combines theme 60/40 score + Siamchart dividend + Yahoo price; labels output paper/backtest non-PIT (never validated)
- mt5_bridge.py: MT5 order interface, dry-run default; live dispatch needs MT5_SEND_ORDERS=1 AND approval (Windows-only MetaTrader5)
- 12 new tests (simulation/mt5/api); full suite 184 OK; live verified (1M -> buckets)
2026-08-25 16:24:22 +07:00

738 lines
32 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 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 _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
@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()
current = app.extensions["tourism_result"]
signal_by_symbol = {s["symbol"]: s for s in current["signals"]}
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"),
"signal_target_weight": sig.get("target_weight"),
"reason_codes": sig.get("reason_codes", []),
}
)
# 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"}.
Combines per-symbol combined score (themes 60/40) with dividend status
(Siamchart) and latest price (Yahoo snapshot), then allocates across
50/20/30 buckets. Output is labeled paper/backtest on revised history
(non-PIT) — not validated evidence.
"""
from app import simulation as sim
from app import siamchart_factors
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
# combined score from the multi-theme board
factor_view = siamchart_factors.build_factor_view()
# recompute theme+combined by importing the same scoring path
from app import themes as themes_mod
from app import auto_credit, daily_cache, energy_thai
current = app.extensions["tourism_result"]
cache = app.extensions.setdefault("daily_cache", daily_cache.DailyCache())
tourism_scores = themes_mod.build_theme_scores("tourism", current.get("signals", []))
try:
auto_d = cache.fetch_or_stale(
f"auto_credit/{current.get('as_of','')}",
lambda: auto_credit.fetch_auto_credit().to_dict(),
)
auto_sign = 1 if (auto_d.get("new_car_sales_yoy") or 0) > 0 else -1
except Exception:
auto_sign = 0
try:
en_d = cache.fetch_or_stale(
"energy_thai", lambda: energy_thai.fetch_energy_thai().to_dict())
qmap = en_d.get("quarterly", {})
latest = next(iter(qmap.values()), {})
en_sign = 1 if (latest.get("net_profit") or 0) > 0 else -1
except Exception:
en_sign = 0
theme_scores = {
"tourism": tourism_scores,
"auto_credit": {s: auto_sign for s in themes_mod.THEME_SYMBOLS["auto_credit"]},
"refining_energy": {s: en_sign for s in themes_mod.THEME_SYMBOLS["refining_energy"]},
}
siamchart_score = themes_mod.build_siamchart_score(factor_view)
combined = themes_mod.combine_score(
[theme_scores["tourism"], theme_scores["auto_credit"], theme_scores["refining_energy"]],
siamchart_score,
)
# factors for dividend status + yield
factor_by_symbol = {f["symbol"]: f for f in factor_view.get("factors", [])}
candidates = []
for sym, meta in combined.items():
price = prices.get(sym)
if price is None:
continue
f = factor_by_symbol.get(sym, {})
candidates.append(
sim.Candidate(
symbol=sym,
price=price,
combined_score=meta["combined"],
is_dividend=bool(f.get("is_dividend")),
dividend_yield=float(f.get("dividend_yield") or 0.0),
)
)
try:
result = sim.allocate_capital(capital, candidates)
except sim.SimulationError as exc:
return jsonify({"error": str(exc)}), 400
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.
Aggregates the 3 Thai alternative-factor themes (tourism, auto_credit,
refining_energy) and the Siamchart fundamental provider into a per-symbol
combined score (60% theme / 40% Siamchart), with the theme list and each
theme's factor read. Frequency of each theme is reported so different-
cadence factors are not treated as same-timestamp.
"""
from app import auto_credit, daily_cache, energy_thai
from app import siamchart_factors, themes as themes_mod
cache = app.extensions.setdefault(
"daily_cache",
daily_cache.DailyCache(),
)
current = app.extensions["tourism_result"]
tourism_signals = current.get("signals", [])
# ---- per-theme macro factor reads (cached daily) ----
theme_reads = {
"tourism": {
"source": current.get("source"),
"as_of": current.get("as_of"),
"surprise": current.get("theme_surprise"),
"frequency": "monthly",
},
}
# auto_credit: Trading Economics Thailand car sales
try:
auto = cache.fetch_or_stale(
f"auto_credit/{current.get('as_of','')}",
lambda: auto_credit.fetch_auto_credit().to_dict(),
)
auto_d = auto["data"] if isinstance(auto, dict) and "data" in auto else auto
theme_reads["auto_credit"] = {
"source": "tradingeconomics",
"as_of": auto_d.get("as_of", ""),
"total_vehicle_sales": auto_d.get("total_vehicle_sales"),
"new_car_sales_yoy": auto_d.get("new_car_sales_yoy"),
"frequency": "monthly",
}
except Exception as exc:
theme_reads["auto_credit"] = {"source": "tradingeconomics", "error": str(exc), "frequency": "monthly"}
# refining_energy: Thai Oil (TOP) quarterly financials
try:
en = cache.fetch_or_stale(
"energy_thai",
lambda: energy_thai.fetch_energy_thai().to_dict(),
)
en_d = en["data"] if isinstance(en, dict) and "data" in en else en
qmap = en_d.get("quarterly", {})
periods = list(qmap.keys())
if periods:
latest = qmap[periods[0]]
else:
latest = {}
theme_reads["refining_energy"] = {
"source": "thaioil",
"as_of": periods[0] if periods else "",
"net_profit": latest.get("net_profit"),
"ebitda": latest.get("ebitda"),
"sales": latest.get("sales"),
"frequency": "quarterly",
}
except Exception as exc:
theme_reads["refining_energy"] = {"source": "thaioil", "error": str(exc), "frequency": "quarterly"}
# ---- per-symbol theme scores ----
# tourism: use the real per-symbol tourism signals.
tourism_scores = themes_mod.build_theme_scores("tourism", tourism_signals)
theme_scores = {"tourism": tourism_scores}
# auto_credit / energy: score the theme's exposed symbols from the macro
# factor direction (positive YoY / positive net profit = bullish theme).
auto_read = theme_reads.get("auto_credit", {})
auto_yoy = auto_read.get("new_car_sales_yoy")
auto_sign = (1 if (auto_yoy or 0) > 0 else -1) if auto_yoy is not None else 0
theme_scores["auto_credit"] = {
sym: auto_sign for sym in themes_mod.THEME_SYMBOLS["auto_credit"]
}
en_read = theme_reads.get("refining_energy", {})
en_np = en_read.get("net_profit")
en_sign = (1 if (en_np or 0) > 0 else -1) if en_np is not None else 0
theme_scores["refining_energy"] = {
sym: en_sign for sym in themes_mod.THEME_SYMBOLS["refining_energy"]
}
# ---- Siamchart fundamental score (40%) ----
factor_view = siamchart_factors.build_factor_view()
siamchart_score = themes_mod.build_siamchart_score(factor_view)
# ---- combine 60/40 ----
combined = themes_mod.combine_score(
[theme_scores["tourism"], theme_scores["auto_credit"], theme_scores["refining_energy"]],
siamchart_score,
weight_theme=0.6, weight_siamchart=0.4,
)
board = [
{
"symbol": sym,
"theme_score": m["theme_score"],
"siamchart_score": m["siamchart_score"],
"combined_score": m["combined"],
**({"themes": m["themes"]} if m["themes"] else {}),
}
for sym, m in combined.items()
]
board.sort(key=lambda b: -b["combined_score"])
return jsonify(
{
"themes": [
{
"id": t.id,
"label_en": t.label_en,
"label_th": t.label_th,
"frequency": t.frequency,
"source": t.source,
"enabled": t.enabled,
"read": theme_reads.get(t.id),
}
for t in themes_mod.list_themes()
],
"as_of": current.get("as_of"),
"combined_count": len(board),
"board": board,
}
)
@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