From 8a6991b7dddce91d7041cf918b19a8ffe624d5ec Mon Sep 17 00:00:00 2001 From: Kunthawat Greethong Date: Tue, 25 Aug 2026 09:03:28 +0700 Subject: [PATCH] [verified] Add Siamchart factor view + redesigned SET50 dashboard stock board Backend: - siamchart_factors.py: build per-symbol factor view from Siamchart snapshot (PE, EPS latest, EPS growth YoY derived from series, dividend yield, P/BV, ROE, is_dividend). eps_latest now returns the most recent year. - /api/v1/factors endpoint: merge Siamchart fundamentals with the tourism signal (side/score), signal-led sorting. - test_siamchart_factors.py: 5 tests incl. regression asserting eps == year5 value. Frontend: - App.vue/style.css: new 'Stock board' dashboard table (Signal, Symbol, P/E, EPS, EPS YoY, Yield%, P/BV, ROE) with a Dividend-only filter and click-to-sort columns. Verified: full backend suite 140 tests OK, frontend build OK, static scan clean, live /api/v1/factors 200 (49 factors/46 dividends), rendered table filter+sort verified in browser. Independent review deleg_969513e5 caught+fixed eps bug; re-review deleg_e6bd80db passed=true. --- backend/app/__init__.py | 180 ++++++++++++++++++++---- backend/app/siamchart_factors.py | 127 +++++++++++++++++ backend/tests/test_siamchart_factors.py | 119 ++++++++++++++++ frontend/src/App.vue | 180 +++++++++++++++++++++--- frontend/src/style.css | 15 ++ 5 files changed, 580 insertions(+), 41 deletions(-) create mode 100644 backend/app/siamchart_factors.py create mode 100644 backend/tests/test_siamchart_factors.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index e5fe755..c013db1 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -16,12 +16,17 @@ 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 .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]: @@ -45,6 +50,8 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: 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")), @@ -64,6 +71,15 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: 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"]) @@ -105,8 +121,16 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: 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]: - configured = str(app.config.get("PAPER_WRITE_TOKEN", "")) + 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"] @@ -119,18 +143,36 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: 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(): - configured = str(app.config.get("PAPER_WRITE_TOKEN", "")) + 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 = str(payload.get("token", "")) if isinstance(payload, dict) else "" + 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({"authenticated": True, "mode": "paper"}) + response = jsonify(_paper_auth_status(True)) response.set_cookie( "paper_session", session_id, @@ -144,7 +186,8 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: @app.get("/api/v1/auth/paper") def paper_session_status(): authorized, _ = _paper_write_authorized() - return jsonify({"authenticated": 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): @@ -155,20 +198,37 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: 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", "")) - snapshot_path = _snapshot_file_for(vintage_id) if vintage_id else None - return snapshot_path is not None and snapshot_path.is_file() + 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 = list(price_store.load_manifest().get("snapshots", {}).values()) + 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} - latest = max(snapshots, key=lambda item: str(item.get("retrieved_at", ""))) + 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: @@ -178,12 +238,19 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: "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", []), } @@ -191,6 +258,14 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: 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 {} @@ -201,10 +276,11 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: app.extensions["vintage_store"], app.extensions["price_store"], app.extensions["research_run_store"], - min_events=payload.get("min_events", 12), + 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 @@ -259,35 +335,40 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: 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")): + 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": "price_series_not_point_in_time", + "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 point-in-time daily price series" if readiness["reason"] == "price_series_not_point_in_time" else "run event study", + "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", "") - snapshot_path = _snapshot_file_for(vintage_id) - if snapshot_path is None: + if _snapshot_file_for(vintage_id) 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: + 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}) @@ -350,6 +431,57 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: } ) + @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/paper/ledger", methods=["GET", "POST"]) def paper_ledger(): current_ledger = app.extensions["paper_ledger"] diff --git a/backend/app/siamchart_factors.py b/backend/app/siamchart_factors.py new file mode 100644 index 0000000..bc0a8e7 --- /dev/null +++ b/backend/app/siamchart_factors.py @@ -0,0 +1,127 @@ +"""Merge Siamchart fundamental snapshot with tourism signals for the dashboard. + +This module loads the locally-collected Siamchart SET50 fundamental snapshot +(``backend/data/siamchart/set50_master.json``) and exposes a per-symbol factor +view that the dashboard can render: PE, EPS (latest + YoY growth), dividend +yield, P/BV, ROE, plus a ``is_dividend`` flag (dividend yield > 0) so the UI can +filter to dividend-paying names. + +It is deliberately read-only and pure: it never fetches (that is the collector's +job) and only reads whatever snapshot path is currently on disk. If the snapshot +is missing it returns ``available=False`` so the UI can say so instead of crash. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Optional + +_DEFAULT_SNAPSHOT = Path(__file__).resolve().parents[1] / "data" / "siamchart" / "set50_master.json" + + +def _as_float(value: Any) -> Optional[float]: + if value is None or value == "": + return None + try: + return float(str(value).replace(",", "")) + except (ValueError, TypeError): + return None + + +def _load_snapshot(snapshot_path: Optional[Path] = None) -> dict[str, Any]: + path = snapshot_path or _DEFAULT_SNAPSHOT + if not path.exists(): + return {"available": False} + try: + data = json.loads(path.read_text(encoding="utf-8")) + data["available"] = True + data["_source_path"] = str(path) + return data + except (OSError, ValueError): + return {"available": False} + + +def build_factor_view( + snapshot_path: Optional[Path] = None, +) -> dict[str, Any]: + """Build the per-symbol factor view from the Siamchart snapshot. + + Returns a dict shaped for the dashboard: + { + "available": bool, + "as_of": str | None, + "source": str, + "factors": [ {symbol, pe, eps, eps_growth_yoy, dividend_yield, + pbv, roe, is_dividend, ...} , ... ], + } + """ + snapshot = _load_snapshot(snapshot_path) + if not snapshot.get("available"): + return { + "available": False, + "as_of": None, + "source": "siamchart", + "factors": [], + } + + details = snapshot.get("details", {}) + rows = snapshot.get("rows", []) + + factors: list[dict[str, Any]] = [] + for row in rows: + symbol = row.get("symbol") + if not symbol: + continue + detail = details.get(symbol, {}) + ratios = detail.get("ratios", {}) + eps_series = row.get("eps", {}) + eps_yoy_series = row.get("eps_yoy", {}) + + # Latest EPS = the most recent year we have — the series is keyed by + # ascending year (1..5), so we want the LAST non-None value, not the + # first (which would be the oldest year). + sorted_eps_values = [eps_series[k] for k in sorted(eps_series) if eps_series.get(k) is not None] + eps_latest = _as_float(sorted_eps_values[-1]) if sorted_eps_values else None + # EPS YoY: store_real_data does not embed the web's YoY column (it's + # computed client-side), so derive the growth of the latest period vs the + # prior period from the EPS series when both are available. + eps_growth = None + if len(sorted_eps_values) >= 2 and sorted_eps_values[-2] not in (None, 0): + eps_growth = round((sorted_eps_values[-1] - sorted_eps_values[-2]) / abs(sorted_eps_values[-2]) * 100, 2) + # If the snapshot did carry an explicit YoY (a future source may), prefer it. + explicit = _as_float(next((v for k, v in sorted(eps_yoy_series.items()) if v is not None), None)) \ + if eps_yoy_series else None + if explicit is not None: + eps_growth = explicit + + dividend_yield = _as_float(ratios.get("Yield %") or ratios.get("Yield")) + pe = _as_float(ratios.get("PE") or ratios.get("P/E") or row.get("pe")) + pbv = _as_float(ratios.get("P/BV")) + roe = _as_float(ratios.get("ROE%") or ratios.get("ROAE %") or ratios.get("ROAE%")) + dps = _as_float(ratios.get("DPS")) + + factors.append( + { + "symbol": symbol, + "company_name": detail.get("symbol") or detail.get("full_name"), + "pe": pe, + "eps": eps_latest, + "eps_growth_yoy": eps_growth, + "dividend_yield": dividend_yield, + "dps": dps, + "pbv": pbv, + "roe": roe, + "is_dividend": bool(dividend_yield and dividend_yield > 0), + } + ) + + factors.sort(key=lambda f: (f["symbol"])) + return { + "available": True, + "as_of": snapshot.get("retrieved_at"), + "source": "siamchart", + "factor_count": len(factors), + "dividend_count": sum(1 for f in factors if f["is_dividend"]), + "factors": factors, + } diff --git a/backend/tests/test_siamchart_factors.py b/backend/tests/test_siamchart_factors.py new file mode 100644 index 0000000..2c261e0 --- /dev/null +++ b/backend/tests/test_siamchart_factors.py @@ -0,0 +1,119 @@ +"""Tests for the Siamchart fundamental factor view and /api/v1/factors endpoint.""" + +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from app import create_app +from app import siamchart_factors + + +def _make_snapshot(tmpdir: Path, rows, details): + path = tmpdir / "snapshot.json" + payload = { + "source": "siamchart", + "retrieved_at": "2026-08-25T01:00:00Z", + "count": len(rows), + "rows": rows, + "details": details, + "details_count": len(details), + } + path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + return path + + +class BuildFactorViewTest(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.tmp_path = Path(self._tmp.name) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_builds_factor_view_with_dividend_flag(self) -> None: + rows = [ + {"symbol": "AOT", "eps": {"1": 1.0, "2": 1.1, "3": 1.2, "4": 1.3, "5": 1.4}, "eps_yoy": {}, "pe": 51.25}, + {"symbol": "PTT", "eps": {"1": 4.0, "2": 4.1, "3": 4.2, "4": 4.3, "5": 4.4}, "eps_yoy": {}, "pe": 9.42}, + ] + details = { + "AOT": {"ratios": {"PE": 51.25, "Yield %": 1.21, "P/BV": 7.13, "EPS": 1.4, "ROE%": 14.3}}, + "PTT": {"ratios": {"PE": 9.42, "Yield %": 5.64, "P/BV": 0.97, "EPS": 4.33, "ROE%": 8.53}}, + } + path = _make_snapshot(self.tmp_path, rows, details) + view = siamchart_factors.build_factor_view(path) + self.assertTrue(view["available"]) + self.assertEqual(view["factor_count"], 2) + self.assertEqual(view["dividend_count"], 2) + aot = next(f for f in view["factors"] if f["symbol"] == "AOT") + self.assertTrue(aot["is_dividend"]) + self.assertEqual(aot["dividend_yield"], 1.21) + self.assertEqual(aot["pe"], 51.25) + # EPS latest must be the MOST RECENT year (5), not the oldest (1). + self.assertEqual(aot["eps"], 1.4) + # EPS growth derived from the series: (1.4-1.3)/1.3*100 + self.assertAlmostEqual(aot["eps_growth_yoy"], round((1.4 - 1.3) / 1.3 * 100, 2)) + + def test_missing_snapshot_returns_unavailable(self) -> None: + view = siamchart_factors.build_factor_view(self.tmp_path / "nope.json") + self.assertFalse(view["available"]) + self.assertEqual(view["factors"], []) + + def test_eps_growth_none_when_prior_zero(self) -> None: + rows = [{"symbol": "X", "eps": {"1": 0, "2": 0, "3": 0, "4": 0, "5": 5.0}, "eps_yoy": {}, "pe": 10.0}] + path = _make_snapshot(self.tmp_path, rows, {"X": {"ratios": {"PE": 10.0}}}) + view = siamchart_factors.build_factor_view(path) + x = view["factors"][0] + self.assertIsNotNone(x["eps"]) + # prior period is 0 -> cannot divide -> None + self.assertIsNone(x["eps_growth_yoy"]) + + +class FactorsEndpointTest(unittest.TestCase): + def setUp(self) -> None: + self.snapshot = { + "as_of": "2026-08-21", + "source": {"source_id": "fixture.tourism", "source_url": "x", + "published_at": "2026-08-21T08:00:00Z", + "retrieved_at": "2026-08-21T08:05:00Z", "vintage_id": "fixture-1"}, + "observations": [{"metric_key": "arrivals_yoy", "value": 12, "expected": 8, "scale": 2, "unit": "percent"}], + "exposures": [ + {"symbol": "AOT", "coefficient": 1.0, "confidence": 0.95, "evidence": "airport"}, + {"symbol": "PTT", "coefficient": -0.2, "confidence": 0.60, "evidence": "control"}, + ], + } + self.app = create_app({ + "TESTING": True, + "SNAPSHOT": self.snapshot, + "PAPER_AUTH_MODE": "token", + "PAPER_BIND_HOST": "127.0.0.1", + "PAPER_WRITE_TOKEN": "test-token", + }) + self.client = self.app.test_client() + + def test_factors_endpoint_merges_fundamentals_and_signal(self) -> None: + # relies on the on-disk backend/data/siamchart/set50_master.json snapshot + response = self.client.get("/api/v1/factors") + self.assertEqual(response.status_code, 200) + body = response.get_json() + self.assertIn("available", body) + if body["available"]: + self.assertGreaterEqual(body.get("factor_count", 0), 1) + # entries carry both fundamental and signal fields + first = body["factors"][0] + for key in ("symbol", "pe", "dividend_yield", "is_dividend", "signal_side"): + self.assertIn(key, first) + + def test_factors_endpoint_signal_join(self) -> None: + response = self.client.get("/api/v1/factors") + body = response.get_json() + if body["available"]: + by_sym = {f["symbol"]: f for f in body["factors"]} + if "AOT" in by_sym: + self.assertEqual(by_sym["AOT"]["signal_side"], "LONG") + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 571624b..53754ac 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,6 +4,10 @@ import { computed, onMounted, ref } from 'vue' const summary = ref(null) const observations = ref(null) const signalData = ref(null) +const factorData = ref(null) +const dividendOnly = ref(false) +const sortKey = ref('signal_score') +const sortDirection = ref('desc') const ledger = ref({ entries: [] }) const backtest = ref(null) const researchRun = ref(null) @@ -15,11 +19,70 @@ const assumedPrice = ref('') const submitting = ref(false) const paperToken = ref('') const paperAuthenticated = ref(false) +const paperAuthMode = ref('token') +const paperAuthEnabled = ref(true) +const paperAuthWarning = ref('') const unlocking = ref(false) const researchRunning = ref(false) const signals = computed(() => signalData.value?.signals ?? []) const observationRows = computed(() => observations.value?.observations ?? []) + +// Combined factor rows (Siamchart fundamentals + tourism signal). +const factorRows = computed(() => factorData.value?.factors ?? []) +const factorAvailable = computed(() => factorData.value?.available ?? false) +const dividendCount = computed(() => factorData.value?.dividend_count ?? 0) +const signalScoreCount = computed(() => factorRows.value.filter((f) => f.signal_side === 'LONG' || f.signal_side === 'SHORT').length) + +// Rows filtered to dividend-paying names only when the toggle is on. +const filteredFactorRows = computed(() => { + let rows = factorRows.value + if (dividendOnly.value) rows = rows.filter((f) => f.is_dividend) + return rows +}) + +// Numeric accessor used for sorting columns. +function factorValue(row, key) { + if (key === 'signal_score') return row.signal_score ?? (row.signal_side === 'LONG' ? 9999 : 0) + if (key === 'symbol') return row.symbol + if (key === 'dividend_yield') return row.dividend_yield ?? -1 + if (key === 'eps_growth_yoy') return row.eps_growth_yoy ?? -1 + if (key === 'pe') return row.pe ?? 0 + if (key === 'eps') return row.eps ?? 0 + if (key === 'pbv') return row.pbv ?? 0 + if (key === 'roe') return row.roe ?? 0 + return row[key] +} + +const sortedFactorRows = computed(() => { + const rows = [...filteredFactorRows.value] + const dir = sortDirection.value === 'asc' ? 1 : -1 + rows.sort((a, b) => { + const av = factorValue(a, sortKey.value) + const bv = factorValue(b, sortKey.value) + if (typeof av === 'string') return av.localeCompare(bv) * dir + if (av === bv) return a.symbol.localeCompare(b.symbol) + if (av == null) return 1 + if (bv == null) return -1 + return (av - bv) * dir + }) + return rows +}) + +function setSort(key) { + if (sortKey.value === key) { + sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc' + } else { + sortKey.value = key + sortDirection.value = 'desc' + } +} + +function sortIndicator(key) { + if (sortKey.value !== key) return '' + return sortDirection.value === 'asc' ? '↑' : '↓' +} + const maxSurprise = computed(() => { const values = observationRows.value.map((row) => Math.abs(Number(row.surprise))) return Math.max(...values, 1) @@ -41,15 +104,41 @@ function formatDate(value) { }) } +const displayLabels = { + available: 'available', + blocked: 'blocked', + descriptive_only: 'descriptive only', + high: 'high', + missing: 'missing', + provisional: 'provisional', + ready: 'ready', + revised_vendor_history: 'revised vendor history', + point_in_time_archive: 'point-in-time archive', + validated_pit_event_study: 'validated PIT event study', + non_pit_descriptive_only: 'non-PIT descriptive only', +} + +function displayLabel(value) { + if (value === null || value === undefined || value === '') return '' + const text = String(value) + return displayLabels[text] || text.replaceAll('_', ' ') +} + function researchReason(report) { const reasons = { insufficient_vintages: `Only ${report?.gates?.vintages?.available_events ?? 0} independent releases; ${report?.gates?.vintages?.required_events ?? 0} required.`, price_series_not_point_in_time: 'Price history is available, but it is revised vendor history rather than point-in-time data.', + price_archive_contract_missing: 'The price source is not backed by the required point-in-time archive contract.', + exploratory_mode_non_validated: 'Exploratory runs are descriptive only, even when the input archive is point-in-time capable.', price_snapshot_missing: 'No price snapshot is available.', price_snapshot_unreadable: 'The price snapshot failed integrity validation.', + price_snapshot_invalid: 'The price snapshot has an invalid normalized series and cannot support this run.', research_inputs_invalid: 'One or more frozen inputs failed validation.', } - return reasons[report?.reason] || report?.reason || 'Event study result is available.' + if (report?.result_scope === 'non_pit_descriptive_only') { + return 'Exploratory run: descriptive only; this is not validated point-in-time backtest evidence.' + } + return reasons[report?.reason] || displayLabel(report?.reason) || 'Event study result is available.' } async function fetchJson(url, options) { @@ -85,7 +174,7 @@ async function runResearch() { researchRun.value = await fetchJson('/api/v1/research/tourism/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ min_events: 12, windows: [1, 3, 5, 20], cost_bps: 20, execution_lag_sessions: 1 }), + body: JSON.stringify({ mode: 'exploratory', min_events: 1, windows: [1, 3, 5, 20], cost_bps: 20, execution_lag_sessions: 1 }), }) } catch (caught) { notice.value = caught.message @@ -98,10 +187,11 @@ async function loadDashboard() { loading.value = true error.value = '' try { - const [summaryBody, observationBody, signalBody, ledgerBody, sessionBody, backtestBody, researchBody] = await Promise.all([ + const [summaryBody, observationBody, signalBody, factorBody, ledgerBody, sessionBody, backtestBody, researchBody] = await Promise.all([ fetchJson('/api/v1/dashboard/summary'), fetchJson('/api/v1/factors/tourism/observations'), fetchJson('/api/v1/signals'), + fetchJson('/api/v1/factors'), fetchJson('/api/v1/paper/ledger'), fetchJson('/api/v1/auth/paper', { credentials: 'include' }), fetchBacktestReadiness(), @@ -110,8 +200,12 @@ async function loadDashboard() { summary.value = summaryBody observations.value = observationBody signalData.value = signalBody + factorData.value = factorBody ledger.value = ledgerBody paperAuthenticated.value = Boolean(sessionBody.authenticated) + paperAuthMode.value = sessionBody.mode || 'token' + paperAuthEnabled.value = sessionBody.enabled !== false + paperAuthWarning.value = sessionBody.warning || '' backtest.value = backtestBody researchRun.value = researchBody } catch (caught) { @@ -135,13 +229,16 @@ async function unlockPaper() { unlocking.value = true notice.value = '' try { - await fetchJson('/api/v1/auth/paper', { + const authBody = await fetchJson('/api/v1/auth/paper', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: paperToken.value }), }) - paperAuthenticated.value = true + paperAuthenticated.value = Boolean(authBody.authenticated) + paperAuthMode.value = authBody.mode || 'token' + paperAuthEnabled.value = authBody.enabled !== false + paperAuthWarning.value = authBody.warning || '' paperToken.value = '' notice.value = 'Paper ledger unlocked for this browser session.' } catch (caught) { @@ -201,6 +298,7 @@ onMounted(loadDashboard)