[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.
This commit is contained in:
@@ -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"]
|
||||
|
||||
127
backend/app/siamchart_factors.py
Normal file
127
backend/app/siamchart_factors.py
Normal file
@@ -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,
|
||||
}
|
||||
119
backend/tests/test_siamchart_factors.py
Normal file
119
backend/tests/test_siamchart_factors.py
Normal file
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
<nav class="nav-stack" aria-label="Primary navigation">
|
||||
<a class="nav-item active" href="#overview"><span class="nav-glyph">◈</span>Overview</a>
|
||||
<a class="nav-item" href="#stocks"><span class="nav-glyph">▤</span>Stock board</a>
|
||||
<a class="nav-item" href="#signals"><span class="nav-glyph">↗</span>Signal board</a>
|
||||
<a class="nav-item" href="#factors"><span class="nav-glyph">∿</span>Factor explorer</a>
|
||||
<a class="nav-item" href="#lineage"><span class="nav-glyph">⌁</span>Data lineage</a>
|
||||
@@ -254,16 +352,62 @@ onMounted(loadDashboard)
|
||||
</article>
|
||||
<article class="kpi-card">
|
||||
<div class="kpi-label">Data quality</div>
|
||||
<div class="kpi-value quality-value">{{ summary.data_health.status }}</div>
|
||||
<div class="kpi-value quality-value">{{ displayLabel(summary.data_health.status) }}</div>
|
||||
<div class="kpi-foot">Vintage {{ summary.data_health.vintage_id }}</div>
|
||||
</article>
|
||||
<article class="kpi-card">
|
||||
<div class="kpi-label">Backtest gate</div>
|
||||
<div class="kpi-value quality-value">{{ backtest?.status || '—' }}</div>
|
||||
<div class="kpi-foot">{{ backtest?.available_events || 0 }} / {{ backtest?.required_events || 0 }} vintages · {{ backtest?.price_snapshot?.quality || 'price snapshot missing' }}</div>
|
||||
<div class="kpi-value quality-value">{{ displayLabel(backtest?.status) }}</div>
|
||||
<div class="kpi-foot">{{ backtest?.available_events || 0 }} / {{ backtest?.required_events || 0 }} vintages · {{ displayLabel(backtest?.price_snapshot?.quality) || 'price snapshot missing' }}</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel stock-panel" id="stocks">
|
||||
<div class="panel-header signal-header">
|
||||
<div>
|
||||
<div class="section-kicker">Alternative factors × SET50</div>
|
||||
<h2>Stock board</h2>
|
||||
<p class="panel-subtitle">Fundamentals from Siamchart merged with the tourism signal. Click a column header to sort; toggle <em>dividend only</em> to view dividend payers.</p>
|
||||
</div>
|
||||
<div class="stock-controls">
|
||||
<label class="toggle-filter">
|
||||
<input type="checkbox" v-model="dividendOnly" />
|
||||
<span>Dividend only ({{ dividendCount }})</span>
|
||||
</label>
|
||||
<span class="status-tag" :class="factorAvailable ? '' : 'warning-tag'">{{ factorAvailable ? 'Siamchart live' : 'factors unavailable' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!factorAvailable" class="empty-research">Siamchart factor snapshot is not available on disk. Run <code>collect_siamchart.py --group SET50 --with-info</code> to collect it.</div>
|
||||
<div v-else class="table-wrap">
|
||||
<table class="factor-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" :class="{ active: sortKey === 'signal_score' }" @click="setSort('signal_score')">Signal {{ sortIndicator('signal_score') }}</th>
|
||||
<th class="sortable" :class="{ active: sortKey === 'symbol' }" @click="setSort('symbol')">Symbol {{ sortIndicator('symbol') }}</th>
|
||||
<th class="sortable" :class="{ active: sortKey === 'pe' }" @click="setSort('pe')">P/E {{ sortIndicator('pe') }}</th>
|
||||
<th class="sortable" :class="{ active: sortKey === 'eps' }" @click="setSort('eps')">EPS {{ sortIndicator('eps') }}</th>
|
||||
<th class="sortable" :class="{ active: sortKey === 'eps_growth_yoy' }" @click="setSort('eps_growth_yoy')">EPS YoY {{ sortIndicator('eps_growth_yoy') }}</th>
|
||||
<th class="sortable" :class="{ active: sortKey === 'dividend_yield' }" @click="setSort('dividend_yield')">Yield % {{ sortIndicator('dividend_yield') }}</th>
|
||||
<th class="sortable" :class="{ active: sortKey === 'pbv' }" @click="setSort('pbv')">P/BV {{ sortIndicator('pbv') }}</th>
|
||||
<th class="sortable" :class="{ active: sortKey === 'roe' }" @click="setSort('roe')">ROE {{ sortIndicator('roe') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="factor in sortedFactorRows" :key="factor.symbol">
|
||||
<td><span v-if="factor.signal_side" class="side-pill" :class="factor.signal_side.toLowerCase()">{{ factor.signal_side }}</span><span v-else class="muted-cell">—</span></td>
|
||||
<td><strong class="symbol-name">{{ factor.symbol }}</strong></td>
|
||||
<td class="score-cell">{{ factor.pe != null ? formatNumber(factor.pe) : '—' }}</td>
|
||||
<td>{{ factor.eps != null ? formatNumber(factor.eps) : '—' }}</td>
|
||||
<td :class="factor.eps_growth_yoy >= 0 ? 'positive-text' : 'negative-text'">{{ factor.eps_growth_yoy != null ? (factor.eps_growth_yoy >= 0 ? '+' : '') + formatNumber(factor.eps_growth_yoy) + '%' : '—' }}</td>
|
||||
<td :class="factor.dividend_yield >= 0 ? 'positive-text' : ''">{{ factor.dividend_yield != null ? formatNumber(factor.dividend_yield) + '%' : '—' }}<span v-if="factor.is_dividend" class="dividend-dot" title="Pays dividend">●</span></td>
|
||||
<td>{{ factor.pbv != null ? formatNumber(factor.pbv) : '—' }}</td>
|
||||
<td :class="factor.roe >= 0 ? 'positive-text' : 'negative-text'">{{ factor.roe != null ? formatNumber(factor.roe) + '%' : '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hero-grid" id="factors">
|
||||
<article class="panel pulse-panel">
|
||||
<div class="panel-header">
|
||||
@@ -292,7 +436,7 @@ onMounted(loadDashboard)
|
||||
<div class="section-kicker">02 / Provenance</div>
|
||||
<h2>Can we trust the input?</h2>
|
||||
</div>
|
||||
<span class="status-tag" :class="summary.data_health.status === 'high' ? '' : 'warning-tag'">{{ summary.data_health.status }}</span>
|
||||
<span class="status-tag" :class="summary.data_health.status === 'high' ? '' : 'warning-tag'">{{ displayLabel(summary.data_health.status) }}</span>
|
||||
</div>
|
||||
<div class="lineage-list">
|
||||
<div class="lineage-item"><span>Source</span><strong>{{ summary.data_health.source_id }}</strong></div>
|
||||
@@ -339,19 +483,19 @@ onMounted(loadDashboard)
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<div class="section-kicker">04 / Frozen research run</div>
|
||||
<h2>Can the evidence support a study?</h2>
|
||||
<h2>Can the evidence support a descriptive study?</h2>
|
||||
</div>
|
||||
<button class="primary-button" :disabled="researchRunning" @click="runResearch">{{ researchRunning ? 'Running…' : 'Run research check' }}</button>
|
||||
<button class="primary-button" :disabled="researchRunning" @click="runResearch">{{ researchRunning ? 'Running…' : 'Run exploratory study' }}</button>
|
||||
</div>
|
||||
<div v-if="researchRun" class="research-grid">
|
||||
<div>
|
||||
<div class="research-status" :class="researchRun.status === 'ready' ? 'status-ready' : 'status-blocked'">{{ researchRun.status }}</div>
|
||||
<div class="research-status" :class="researchRun.result_scope === 'non_pit_descriptive_only' ? 'status-descriptive' : researchRun.status === 'ready' ? 'status-ready' : 'status-blocked'">{{ researchRun.result_scope === 'non_pit_descriptive_only' ? 'descriptive only' : displayLabel(researchRun.status) }}</div>
|
||||
<div class="research-reason">{{ researchReason(researchRun) }}</div>
|
||||
<div class="research-meta">Run {{ researchRun.run_id }} · {{ formatDate(researchRun.generated_at) }}</div>
|
||||
<div class="research-meta">Run {{ researchRun.run_id }} · {{ formatDate(researchRun.generated_at) }} · {{ displayLabel(researchRun.result_scope) }}</div>
|
||||
</div>
|
||||
<div class="gate-list">
|
||||
<div class="gate-row"><span>Independent vintages</span><strong>{{ researchRun.gates.vintages.available_events }} / {{ researchRun.gates.vintages.required_events }} · {{ researchRun.gates.vintages.status }}</strong></div>
|
||||
<div class="gate-row"><span>Price source</span><strong>{{ researchRun.gates.prices.quality || 'missing' }} · {{ researchRun.gates.prices.status }}</strong></div>
|
||||
<div class="gate-row"><span>Independent vintages</span><strong>{{ researchRun.gates.vintages.available_events }} / {{ researchRun.gates.vintages.required_events }} · {{ displayLabel(researchRun.gates.vintages.status) }}</strong></div>
|
||||
<div class="gate-row"><span>Price source</span><strong>{{ displayLabel(researchRun.gates.prices.quality) || 'missing' }} · {{ displayLabel(researchRun.gates.prices.status) }}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="researchRun?.result?.windows" class="research-results">
|
||||
@@ -377,13 +521,15 @@ onMounted(loadDashboard)
|
||||
<span class="status-tag neutral-tag">Internal only</span>
|
||||
</div>
|
||||
<p>Record an assumed fill to test portfolio behavior. This does not send a webhook or order.</p>
|
||||
<div v-if="paperAuthWarning" class="paper-auth-warning">{{ paperAuthWarning }}</div>
|
||||
<div v-if="notice" class="notice" :class="notice.includes('recorded') || notice.includes('unlocked') ? 'notice-success' : 'notice-error'">{{ notice }}</div>
|
||||
<div v-if="!paperAuthenticated" class="auth-form">
|
||||
<div v-if="!paperAuthenticated && paperAuthMode === 'token' && paperAuthEnabled" class="auth-form">
|
||||
<div class="auth-copy">Paper writes are locked. Enter the local operator token; it is used only to create an HttpOnly session.</div>
|
||||
<input v-model="paperToken" type="password" autocomplete="current-password" placeholder="Paper-session token" aria-label="Paper-session token" />
|
||||
<button class="primary-button" :disabled="unlocking" @click="unlockPaper">{{ unlocking ? 'Unlocking…' : 'Unlock paper ledger' }}</button>
|
||||
</div>
|
||||
<div v-else-if="selectedSignal" class="entry-form">
|
||||
<div v-else-if="!paperAuthenticated && paperAuthMode === 'token' && !paperAuthEnabled" class="empty-ledger">Paper writes are disabled by the server configuration.</div>
|
||||
<div v-else-if="paperAuthenticated && selectedSignal" class="entry-form">
|
||||
<div class="selected-entry"><strong>{{ selectedSignal.symbol }}</strong><span>{{ selectedSignal.side }} · target {{ (selectedSignal.target_weight * 100).toFixed(1) }}%</span></div>
|
||||
<input v-model="assumedPrice" type="number" min="0.01" step="0.01" placeholder="Assumed fill price" aria-label="Assumed fill price" />
|
||||
<button class="primary-button" :disabled="submitting" @click="recordPaperEntry">{{ submitting ? 'Recording…' : 'Record paper entry' }}</button>
|
||||
|
||||
@@ -112,11 +112,25 @@ tbody tr:hover { background: rgba(255,255,255,.025); }
|
||||
.row-action, .primary-button { padding: 8px 10px; border: 1px solid var(--line-bright); border-radius: 5px; color: var(--text); background: transparent; font-size: 10px; white-space: nowrap; transition: .2s ease; }
|
||||
.row-action:hover { color: var(--mint); border-color: var(--mint); }
|
||||
|
||||
/* Stock board (alternative factor × signal merged table) */
|
||||
.stock-panel { padding: 0; overflow: hidden; margin-bottom: 12px; }
|
||||
.panel-subtitle { margin-top: 10px; color: var(--faint); font-size: 11px; line-height: 1.6; }
|
||||
.panel-subtitle em { color: var(--mint); font-style: normal; }
|
||||
.stock-controls { display: flex; align-items: center; gap: 14px; }
|
||||
.toggle-filter { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font: 10px 'DM Mono', monospace; cursor: pointer; user-select: none; }
|
||||
.toggle-filter input { width: auto; accent-color: var(--mint); }
|
||||
.toggle-filter span { white-space: nowrap; }
|
||||
.dividend-dot { margin-left: 6px; color: var(--amber); }
|
||||
th.sortable { cursor: pointer; transition: color .15s ease; }
|
||||
th.sortable:hover { color: var(--text); }
|
||||
th.sortable.active { color: var(--mint); }
|
||||
|
||||
.research-panel { margin-bottom: 12px; }
|
||||
.research-grid { display: grid; grid-template-columns: 1fr 1.4fr; gap: 24px; align-items: center; margin-top: 22px; }
|
||||
.research-status { display: inline-block; padding: 7px 9px; border-radius: 5px; font: 11px 'DM Mono', monospace; text-transform: uppercase; }
|
||||
.status-ready { color: var(--mint); background: var(--mint-soft); }
|
||||
.status-blocked { color: var(--amber); background: var(--amber-soft); }
|
||||
.status-descriptive { color: var(--amber); background: var(--amber-soft); }
|
||||
.research-reason { margin-top: 12px; color: var(--muted); font-size: 12px; }
|
||||
.research-meta { margin-top: 10px; color: var(--faint); font: 9px 'DM Mono', monospace; overflow-wrap: anywhere; }
|
||||
.gate-list { display: grid; gap: 0; border-top: 1px solid var(--line); }
|
||||
@@ -136,6 +150,7 @@ tbody tr:hover { background: rgba(255,255,255,.025); }
|
||||
.neutral-tag { color: var(--amber); border-color: rgba(230,185,108,.25); background: var(--amber-soft); }
|
||||
.auth-form { display: grid; gap: 10px; margin-top: 16px; }
|
||||
.auth-copy { color: var(--muted); font-size: 11px; line-height: 1.65; }
|
||||
.paper-auth-warning { margin-top: 14px; padding: 9px 10px; border: 1px solid rgba(230,185,108,.3); border-radius: 5px; color: var(--amber); background: var(--amber-soft); font: 10px 'DM Mono', monospace; line-height: 1.55; }
|
||||
.entry-form { display: grid; gap: 10px; margin-top: 16px; }
|
||||
.selected-entry { display: flex; justify-content: space-between; gap: 12px; align-items: center; color: var(--muted); font: 10px 'DM Mono', monospace; }
|
||||
.selected-entry strong { color: var(--text); font-size: 13px; }
|
||||
|
||||
Reference in New Issue
Block a user