[verified] build tourism signal dashboard

This commit is contained in:
Kunthawat Greethong
2026-08-23 07:41:31 +07:00
commit 3e978c5948
19 changed files with 2597 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
.venv/
.env
.DS_Store
backend/.pytest_cache/
frontend/node_modules/
frontend/dist/
reports/

67
README.md Normal file
View File

@@ -0,0 +1,67 @@
# SET50 Alternative Data Platform
Tourism-first vertical slice for a deterministic SET50 alternative-data research system.
Current scope:
```text
fixture observation
→ Tourism Pulse surprise
→ versioned exposure score
→ ranked target weights
→ English dashboard
→ internal paper ledger
```
No external webhook receiver and no live MT5 execution are enabled.
## Run the backend
```bash
python -m venv .venv
.venv/bin/pip install -r backend/requirements.txt
PAPER_WRITE_TOKEN=local-paper-token PYTHONPATH=backend .venv/bin/python backend/run.py
```
Health check:
```bash
curl http://127.0.0.1:5000/api/v1/health
```
## Run the dashboard
In a second terminal:
```bash
cd frontend
npm install
npm run dev -- --host 127.0.0.1
```
Open `http://127.0.0.1:5173`.
The frontend reads the live API through Vite's `/api` proxy. Paper writes require the operator to unlock an HttpOnly browser session using the backend `PAPER_WRITE_TOKEN`; the token is never embedded in the frontend bundle. The paper-entry action records an assumed fill in the in-memory paper ledger only.
For HTTPS/non-local deployment, set `PAPER_COOKIE_SECURE=1`. The M0 session store is intentionally in-memory and single-process; use a shared session store before running multiple workers or replicas.
## Tests and build
```bash
PYTHONPATH=backend .venv/bin/python -m unittest discover -s backend/tests -v
cd frontend && npm run build
```
## Current M0 boundary
- English UI and analysis vocabulary
- Research mode and paper mode only
- Tourism Pulse fixture adapter
- Data lineage: source, publication time, retrieval time, vintage
- Deterministic surprise × exposure × confidence score
- Paper ledger endpoint
- No LLM call yet; the deterministic result is the source of truth
- No webhook receiver yet
- No MT5 bridge yet
The next implementation step is replacing the fixture with one replayable Tourism source adapter while preserving the same snapshot contract.

182
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,182 @@
"""Flask application factory for the SET50 alternative-data platform."""
from __future__ import annotations
import hmac
import json
import os
import secrets
import time
from pathlib import Path
from typing import Any
from flask import Flask, jsonify, request
from .paper import PaperLedger
from .tourism import compute_tourism_signal
APP_VERSION = "0.1.0"
def _load_default_snapshot() -> dict[str, Any]:
fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "tourism_snapshot.json"
return json.loads(fixture_path.read_text(encoding="utf-8"))
def _signal_summary(result: dict[str, Any]) -> dict[str, int]:
signals = result["signals"]
return {
"total": len(signals),
"long": sum(item["side"] == "LONG" for item in signals),
"short": sum(item["side"] == "SHORT" for item in signals),
"neutral": sum(item["side"] == "NEUTRAL" for item in signals),
}
def create_app(config: dict[str, Any] | None = None) -> Flask:
app = Flask(__name__)
app.config.from_mapping(
TESTING=False,
MODE=os.getenv("APP_MODE", "research"),
PAPER_WRITE_TOKEN=os.getenv("PAPER_WRITE_TOKEN", ""),
PAPER_COOKIE_SECURE=os.getenv("PAPER_COOKIE_SECURE", "0") == "1",
PAPER_SESSION_SECONDS=int(os.getenv("PAPER_SESSION_SECONDS", "3600")),
SNAPSHOT=_load_default_snapshot(),
)
if config:
app.config.update(config)
result = compute_tourism_signal(app.config["SNAPSHOT"])
ledger = PaperLedger()
paper_sessions: dict[str, float] = {}
allowed_symbols = {item["symbol"] for item in result["signals"]}
app.extensions["tourism_result"] = result
app.extensions["paper_ledger"] = ledger
app.extensions["paper_sessions"] = paper_sessions
app.extensions["allowed_symbols"] = allowed_symbols
@app.after_request
def add_cors_headers(response):
origin = request.headers.get("Origin")
allowed_origin = os.getenv("CORS_ORIGIN", "http://localhost:5173")
if origin == allowed_origin:
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Vary"] = "Origin"
response.headers["Access-Control-Allow-Headers"] = "Content-Type"
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
return response
def _paper_write_authorized() -> tuple[bool, tuple[dict[str, str], int] | None]:
configured = str(app.config.get("PAPER_WRITE_TOKEN", ""))
if not configured:
return False, ({"error": "paper writes disabled: PAPER_WRITE_TOKEN is not configured"}, 503)
sessions: dict[str, float] = app.extensions["paper_sessions"]
now = time.time()
expired = [session_id for session_id, expiry in sessions.items() if expiry <= now]
for session_id in expired:
sessions.pop(session_id, None)
session_id = request.cookies.get("paper_session", "")
if session_id and session_id in sessions and sessions[session_id] > now:
return True, None
return False, ({"error": "paper session required"}, 401)
@app.post("/api/v1/auth/paper")
def authenticate_paper():
configured = str(app.config.get("PAPER_WRITE_TOKEN", ""))
if not configured:
return jsonify({"error": "paper writes disabled: PAPER_WRITE_TOKEN is not configured"}), 503
payload = request.get_json(silent=True)
candidate = str(payload.get("token", "")) if isinstance(payload, dict) else ""
if not hmac.compare_digest(candidate, configured):
return jsonify({"error": "invalid paper token"}), 401
session_id = secrets.token_urlsafe(32)
app.extensions["paper_sessions"][session_id] = time.time() + int(app.config["PAPER_SESSION_SECONDS"])
response = jsonify({"authenticated": True, "mode": "paper"})
response.set_cookie(
"paper_session",
session_id,
max_age=int(app.config["PAPER_SESSION_SECONDS"]),
httponly=True,
secure=bool(app.config["PAPER_COOKIE_SECURE"]),
samesite="Strict",
)
return response
@app.get("/api/v1/auth/paper")
def paper_session_status():
authorized, _ = _paper_write_authorized()
return jsonify({"authenticated": authorized})
@app.get("/api/v1/health")
def health():
return jsonify({"status": "ok", "mode": app.config["MODE"], "version": APP_VERSION})
@app.get("/api/v1/dashboard/summary")
def dashboard_summary():
current = app.extensions["tourism_result"]
entries = app.extensions["paper_ledger"].entries()
return jsonify(
{
"as_of": current["as_of"],
"theme": current["theme"],
"strategy_version": current["strategy_version"],
"theme_surprise": current["theme_surprise"],
"data_health": {
"status": current["data_quality"],
"source_id": current["source"].get("source_id"),
"source_url": current["source"].get("source_url"),
"published_at": current["source"].get("published_at"),
"retrieved_at": current["source"].get("retrieved_at"),
"vintage_id": current["source"].get("vintage_id"),
},
"signal_summary": _signal_summary(current),
"top_signals": current["signals"][:5],
"paper_ledger": {"entries": len(entries), "mode": "paper"},
}
)
@app.get("/api/v1/factors/tourism/observations")
def tourism_observations():
current = app.extensions["tourism_result"]
return jsonify(
{
"theme": current["theme"],
"as_of": current["as_of"],
"theme_surprise": current["theme_surprise"],
"source": current["source"],
"observations": current["observations"],
}
)
@app.get("/api/v1/signals")
def signals():
current = app.extensions["tourism_result"]
return jsonify(
{
"theme": current["theme"],
"as_of": current["as_of"],
"strategy_version": current["strategy_version"],
"signals": current["signals"],
}
)
@app.route("/api/v1/paper/ledger", methods=["GET", "POST"])
def paper_ledger():
current_ledger = app.extensions["paper_ledger"]
if request.method == "GET":
return jsonify({"mode": "paper", "entries": current_ledger.entries()})
authorized, error = _paper_write_authorized()
if not authorized:
body, status = error
return jsonify(body), status
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return jsonify({"error": "JSON object required"}), 400
try:
entry = current_ledger.record(payload, app.extensions["allowed_symbols"])
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"mode": "paper", "entry": entry}), 201
return app

41
backend/app/paper.py Normal file
View File

@@ -0,0 +1,41 @@
"""Paper portfolio ledger for the first vertical slice."""
from __future__ import annotations
from datetime import datetime, timezone
from math import isfinite
from uuid import uuid4
class PaperLedger:
def __init__(self) -> None:
self._entries: list[dict] = []
def record(self, payload: dict, allowed_symbols: set[str]) -> dict:
symbol = str(payload.get("symbol", "")).strip().upper()
if symbol not in allowed_symbols:
raise ValueError(f"unknown signal symbol: {symbol}")
try:
target_weight = float(payload["target_weight"])
assumed_price = float(payload["assumed_price"])
except (KeyError, TypeError, ValueError) as exc:
raise ValueError("target_weight and assumed_price must be numeric") from exc
if not isfinite(target_weight) or not isfinite(assumed_price):
raise ValueError("target_weight and assumed_price must be finite")
if not -1.0 <= target_weight <= 1.0:
raise ValueError("target_weight must be between -1 and 1")
if assumed_price <= 0:
raise ValueError("assumed_price must be positive")
entry = {
"entry_id": f"paper_{uuid4().hex}",
"created_at": datetime.now(timezone.utc).isoformat(),
"symbol": symbol,
"target_weight": target_weight,
"assumed_price": assumed_price,
"status": "PAPER_RECORDED",
}
self._entries.append(entry)
return entry
def entries(self) -> list[dict]:
return list(self._entries)

96
backend/app/tourism.py Normal file
View File

@@ -0,0 +1,96 @@
"""Tourism Pulse deterministic signal engine."""
from __future__ import annotations
import math
from statistics import fmean
from typing import Any
def _validate_observation(observation: dict[str, Any]) -> None:
required = {"metric_key", "value", "expected", "scale", "unit"}
missing = required.difference(observation)
if missing:
raise ValueError(f"observation missing fields: {sorted(missing)}")
scale = float(observation["scale"])
if not math.isfinite(scale) or scale <= 0:
raise ValueError(f"observation scale must be positive: {observation['metric_key']}")
for key in ("value", "expected"):
value = float(observation[key])
if not math.isfinite(value):
raise ValueError(f"observation {key} must be finite: {observation['metric_key']}")
def _data_quality(snapshot: dict[str, Any], observations: list[dict[str, Any]]) -> str:
source = snapshot.get("source", {})
source_id = str(source.get("source_id", ""))
if source_id.startswith("fixture"):
return "fixture"
required_source = {"source_id", "source_url", "published_at", "retrieved_at", "vintage_id"}
if not required_source.issubset(source):
return "low"
if not observations:
return "low"
return "high"
def compute_tourism_signal(snapshot: dict[str, Any]) -> dict[str, Any]:
"""Compute a replayable Tourism Pulse signal from a frozen snapshot."""
observations = list(snapshot.get("observations", []))
if not observations:
raise ValueError("tourism snapshot must contain observations")
for observation in observations:
_validate_observation(observation)
standardized = []
observation_output = []
for observation in observations:
surprise = (float(observation["value"]) - float(observation["expected"])) / float(observation["scale"])
surprise = round(surprise, 8)
standardized.append(surprise)
observation_output.append({**observation, "surprise": surprise})
theme_surprise = round(fmean(standardized), 8)
signal_rows = []
for exposure in snapshot.get("exposures", []):
symbol = str(exposure.get("symbol", "")).strip().upper()
coefficient = float(exposure.get("coefficient", 0))
confidence = float(exposure.get("confidence", 1.0))
if not symbol:
raise ValueError("exposure symbol must not be empty")
if not math.isfinite(coefficient) or not math.isfinite(confidence):
raise ValueError(f"exposure must be finite: {symbol}")
if confidence < 0 or confidence > 1:
raise ValueError(f"exposure confidence must be between 0 and 1: {symbol}")
score = round(theme_surprise * coefficient * confidence, 8)
evidence = str(exposure.get("evidence", "exposure")).strip().upper().replace(" ", "_")
side = "LONG" if score > 0.15 else "SHORT" if score < -0.15 else "NEUTRAL"
signal_rows.append(
{
"symbol": symbol,
"score": score,
"coefficient": coefficient,
"confidence": confidence,
"side": side,
"reason_codes": ["TOURISM_SURPRISE", evidence],
"evidence": exposure.get("evidence", ""),
}
)
signal_rows.sort(key=lambda row: (-row["score"], row["symbol"]))
total_abs = sum(abs(row["score"]) for row in signal_rows)
for rank, row in enumerate(signal_rows, start=1):
row["rank"] = rank
row["target_weight"] = round((row["score"] / total_abs) * 0.5, 8) if total_abs else 0.0
return {
"theme": "tourism",
"as_of": snapshot.get("as_of"),
"theme_surprise": theme_surprise,
"data_quality": _data_quality(snapshot, observations),
"source": snapshot.get("source", {}),
"observations": observation_output,
"signals": signal_rows,
"strategy_version": snapshot.get("strategy_version", "tourism-v0.1"),
}

View File

@@ -0,0 +1,44 @@
{
"as_of": "2026-08-21",
"strategy_version": "tourism-v0.1",
"source": {
"source_id": "fixture.tourism_pulse",
"source_url": "https://example.invalid/fixture/tourism-pulse",
"published_at": "2026-08-21T08:00:00Z",
"retrieved_at": "2026-08-21T08:05:00Z",
"vintage_id": "fixture-tourism-2026-08-21-v1"
},
"observations": [
{
"metric_key": "foreign_arrivals_yoy",
"value": 12.5,
"expected": 8.0,
"scale": 2.5,
"unit": "percent"
},
{
"metric_key": "airport_passengers_yoy",
"value": 9.0,
"expected": 6.0,
"scale": 2.0,
"unit": "percent"
},
{
"metric_key": "hotel_occupancy_change",
"value": 3.2,
"expected": 1.0,
"scale": 1.5,
"unit": "percentage_points"
}
],
"exposures": [
{"symbol": "AOT", "coefficient": 1.00, "confidence": 0.95, "evidence": "airport"},
{"symbol": "MINT", "coefficient": 0.80, "confidence": 0.80, "evidence": "hotel"},
{"symbol": "AWC", "coefficient": 0.70, "confidence": 0.75, "evidence": "hotel"},
{"symbol": "CPN", "coefficient": 0.45, "confidence": 0.65, "evidence": "retail"},
{"symbol": "CPALL", "coefficient": 0.35, "confidence": 0.60, "evidence": "consumer"},
{"symbol": "CRC", "coefficient": 0.35, "confidence": 0.60, "evidence": "consumer"},
{"symbol": "BEM", "coefficient": 0.20, "confidence": 0.50, "evidence": "transit"},
{"symbol": "PTT", "coefficient": -0.15, "confidence": 0.35, "evidence": "control"}
]
}

1
backend/requirements.txt Normal file
View File

@@ -0,0 +1 @@
Flask>=3.1,<4

17
backend/run.py Normal file
View File

@@ -0,0 +1,17 @@
"""Run the local Flask API."""
from __future__ import annotations
import os
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run(
host=os.getenv("HOST", "127.0.0.1"),
port=int(os.getenv("PORT", "5000")),
debug=False,
)

87
backend/tests/test_api.py Normal file
View File

@@ -0,0 +1,87 @@
import unittest
from app import create_app
class ApiTests(unittest.TestCase):
def setUp(self):
self.snapshot = {
"as_of": "2026-08-21",
"source": {
"source_id": "fixture.tourism",
"source_url": "https://example.invalid/tourism",
"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_WRITE_TOKEN": "test-token"})
self.client = self.app.test_client()
def _login_paper(self):
response = self.client.post("/api/v1/auth/paper", json={"token": "test-token"})
self.assertEqual(response.status_code, 200)
def test_health_reports_research_mode(self):
response = self.client.get("/api/v1/health")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_json()["mode"], "research")
def test_summary_contains_lineage_and_signal_counts(self):
response = self.client.get("/api/v1/dashboard/summary")
body = response.get_json()
self.assertEqual(response.status_code, 200)
self.assertEqual(body["data_health"]["vintage_id"], "fixture-1")
self.assertEqual(body["data_health"]["status"], "fixture")
self.assertEqual(body["signal_summary"]["total"], 2)
self.assertEqual(body["signal_summary"]["long"], 1)
self.assertEqual(body["signal_summary"]["short"], 1)
def test_paper_ledger_requires_token(self):
response = self.client.post(
"/api/v1/paper/ledger",
json={"symbol": "AOT", "target_weight": 0.1, "assumed_price": 10},
)
self.assertEqual(response.status_code, 401)
def test_paper_ledger_rejects_invalid_paper_token(self):
response = self.client.post("/api/v1/auth/paper", json={"token": "wrong-token"})
self.assertEqual(response.status_code, 401)
def test_paper_ledger_rejects_non_finite_price(self):
self._login_paper()
response = self.client.post(
"/api/v1/paper/ledger",
json={"symbol": "AOT", "target_weight": 0.1, "assumed_price": "NaN"},
)
self.assertEqual(response.status_code, 400)
def test_paper_ledger_rejects_unknown_symbol(self):
self._login_paper()
response = self.client.post(
"/api/v1/paper/ledger",
json={"symbol": "UNKNOWN", "target_weight": 0.1, "assumed_price": 10},
)
self.assertEqual(response.status_code, 400)
def test_paper_ledger_records_valid_entry(self):
self._login_paper()
response = self.client.post(
"/api/v1/paper/ledger",
json={"symbol": "AOT", "target_weight": 0.1, "assumed_price": 60},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.get_json()["entry"]["symbol"], "AOT")
ledger = self.client.get("/api/v1/paper/ledger").get_json()["entries"]
self.assertEqual(len(ledger), 1)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,65 @@
import unittest
from app.tourism import compute_tourism_signal
class TourismSignalTests(unittest.TestCase):
def test_theme_surprise_is_mean_of_standardized_observations(self):
snapshot = {
"as_of": "2026-08-21",
"source": {
"source_id": "fixture.tourism",
"source_url": "https://example.invalid/tourism",
"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"},
{"metric_key": "airport_passengers_yoy", "value": 8, "expected": 6, "scale": 2, "unit": "percent"},
],
"exposures": [],
}
result = compute_tourism_signal(snapshot)
self.assertAlmostEqual(result["theme_surprise"], 1.5)
self.assertEqual(result["data_quality"], "fixture")
def test_exposure_score_and_rank_are_deterministic(self):
snapshot = {
"as_of": "2026-08-21",
"source": {
"source_id": "fixture.tourism",
"source_url": "https://example.invalid/tourism",
"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": "MINT", "coefficient": 0.6, "confidence": 0.80, "evidence": "hotel"},
{"symbol": "PTT", "coefficient": -0.2, "confidence": 0.60, "evidence": "control"},
],
}
result = compute_tourism_signal(snapshot)
self.assertEqual([item["symbol"] for item in result["signals"]], ["AOT", "MINT", "PTT"])
self.assertEqual(result["signals"][0]["side"], "LONG")
self.assertEqual(result["signals"][-1]["side"], "SHORT")
self.assertGreater(result["signals"][0]["score"], result["signals"][1]["score"])
self.assertEqual(result["signals"][0]["reason_codes"], ["TOURISM_SURPRISE", "AIRPORT"])
def test_missing_scale_is_rejected(self):
snapshot = {
"as_of": "2026-08-21",
"source": {},
"observations": [{"metric_key": "arrivals_yoy", "value": 10, "expected": 8}],
"exposures": [],
}
with self.assertRaises(ValueError):
compute_tourism_signal(snapshot)
if __name__ == "__main__":
unittest.main()

47
docs/HANDOFF.md Normal file
View File

@@ -0,0 +1,47 @@
# Handoff — Tourism Vertical Slice
## Project
- Path: `/Users/kunthawat/Gitea/set50-alternative-data-platform`
- Mode: research + paper only
- Frontend: Vue 3 + Vite
- Backend: Flask
- Current data: deterministic fixture with lineage metadata
## Completed
- Tourism snapshot schema with source, publication, retrieval and vintage fields.
- Deterministic Tourism Pulse score: standardized surprise × exposure × confidence.
- Ranked target weights and LONG/SHORT/NEUTRAL classification.
- Flask endpoints for health, summary, observations, signals and paper ledger.
- English dashboard with live API data, lineage panel, signal table and paper-entry form.
- No external webhook or MT5 integration.
## Verified commands
```text
PYTHONPATH=backend .venv/bin/python -m unittest discover -s backend/tests -v
Ran 10 tests ... OK
Paper writes use a server-side token exchange and HttpOnly `paper_session` cookie; the token is not embedded in the frontend bundle.
npm run build
Vite build completed successfully.
GET /api/v1/health
{"mode":"research","status":"ok","version":"0.1.0"}
POST /api/v1/paper/ledger + GET /api/v1/paper/ledger
PAPER_RECORDED and readback verified.
Independent review
PASSED — no concrete security or logic blockers.
```
## Known limitation
The data adapter is a fixture. It demonstrates the contract and calculation, not live tourism data quality. Browser visual screenshot verification was blocked by a Chrome remote-debugging permission prompt; served HTML, API response and frontend build were verified instead.
## Next action
Implement one replayable Tourism source adapter without changing the snapshot contract. Add parser fixture tests, publication timestamp handling, raw snapshot hash, and a data-health failure state before adding LLM analysis.

32
docs/engineering-log.md Normal file
View File

@@ -0,0 +1,32 @@
# Engineering Log — SET50 Alternative Data Platform
## Current status
| Milestone | Status | Evidence | Next action |
|---|---|---|---|
| M0 repo foundation | complete | Flask API, Vue/Vite shell | replace fixture with source adapter |
| Tourism deterministic signal | complete | 7 backend tests pass | add replayable real source |
| Internal paper ledger | complete | POST/readback through live API | persist in PostgreSQL later |
| Dashboard | complete | Vite build + live HTML/API checks | visual browser capture after permission is available |
| LLM analysis | deferred | intentionally no LLM dependency in M0 | add after signal lineage is stable |
| Webhook receiver | deferred | contract only, no external receiver | choose after core app is usable |
| MT5 bridge | deferred | not started | paper bridge after webhook decision |
## Guardrails
- Research and paper modes only.
- No live orders, external webhook receiver, broker credentials, or MT5 connection.
- Deterministic signal is authoritative; LLM will remain downstream.
- Fixture source is clearly marked and must be replaced before investment use.
- `target_weight` is recorded in the internal paper ledger; it is not an order.
## Verification
- Backend: 10 unittest tests pass.
- Independent review: **PASSED**; no concrete security or logic blockers.
- Reviewer suggestions: set `PAPER_COOKIE_SECURE=1` outside local HTTP; replace in-memory sessions before multi-worker deployment.
- Frontend: `npm run build` passes with Vite.
- Backend health endpoint returns HTTP 200 JSON.
- Dashboard served HTML contains the current title, Vue mount point and Vite entry.
- Paper ledger POST and readback work through the live API.
- Browser visual capture was blocked by Chrome remote-debugging permission; no permission dialog was clicked.

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0b1018" />
<title>SET50 Signal Lab</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1382
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
frontend/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "set50-alternative-data-dashboard",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"vite": "^6.2.0"
}
}

307
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,307 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
const summary = ref(null)
const observations = ref(null)
const signalData = ref(null)
const ledger = ref({ entries: [] })
const loading = ref(true)
const error = ref('')
const notice = ref('')
const selectedSignal = ref(null)
const assumedPrice = ref('')
const submitting = ref(false)
const paperToken = ref('')
const paperAuthenticated = ref(false)
const unlocking = ref(false)
const signals = computed(() => signalData.value?.signals ?? [])
const observationRows = computed(() => observations.value?.observations ?? [])
const maxSurprise = computed(() => {
const values = observationRows.value.map((row) => Math.abs(Number(row.surprise)))
return Math.max(...values, 1)
})
const positiveObservations = computed(() => observationRows.value.filter((row) => Number(row.surprise) >= 0).length)
function formatNumber(value, digits = 2) {
return Number(value ?? 0).toFixed(digits)
}
function formatDate(value) {
if (!value) return '—'
return new Date(value).toLocaleString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
async function fetchJson(url, options) {
const response = await fetch(url, options)
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body.error || `Request failed: ${response.status}`)
}
return response.json()
}
async function loadDashboard() {
loading.value = true
error.value = ''
try {
const [summaryBody, observationBody, signalBody, ledgerBody, sessionBody] = await Promise.all([
fetchJson('/api/v1/dashboard/summary'),
fetchJson('/api/v1/factors/tourism/observations'),
fetchJson('/api/v1/signals'),
fetchJson('/api/v1/paper/ledger'),
fetchJson('/api/v1/auth/paper', { credentials: 'include' }),
])
summary.value = summaryBody
observations.value = observationBody
signalData.value = signalBody
ledger.value = ledgerBody
paperAuthenticated.value = Boolean(sessionBody.authenticated)
} catch (caught) {
error.value = caught.message
} finally {
loading.value = false
}
}
function chooseSignal(signal) {
selectedSignal.value = signal
assumedPrice.value = ''
notice.value = ''
}
async function unlockPaper() {
if (!paperToken.value) {
notice.value = 'Enter the paper-session token to unlock paper recording.'
return
}
unlocking.value = true
notice.value = ''
try {
await fetchJson('/api/v1/auth/paper', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: paperToken.value }),
})
paperAuthenticated.value = true
paperToken.value = ''
notice.value = 'Paper ledger unlocked for this browser session.'
} catch (caught) {
paperAuthenticated.value = false
notice.value = caught.message
} finally {
unlocking.value = false
}
}
async function recordPaperEntry() {
if (!paperAuthenticated.value) {
notice.value = 'Unlock the paper ledger before recording an entry.'
return
}
const price = Number(assumedPrice.value)
if (!selectedSignal.value || !Number.isFinite(price) || price <= 0) {
notice.value = 'Enter a valid assumed price before recording the paper entry.'
return
}
submitting.value = true
notice.value = ''
try {
await fetchJson('/api/v1/paper/ledger', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
symbol: selectedSignal.value.symbol,
target_weight: selectedSignal.value.target_weight,
assumed_price: price,
}),
})
notice.value = `${selectedSignal.value.symbol} recorded in the paper ledger.`
selectedSignal.value = null
await loadDashboard()
} catch (caught) {
notice.value = caught.message
} finally {
submitting.value = false
}
}
onMounted(loadDashboard)
</script>
<template>
<div class="app-shell">
<aside class="sidebar">
<div class="brand-lockup">
<div class="brand-mark">SL</div>
<div>
<div class="brand-name">Signal Lab</div>
<div class="brand-caption">SET50 alternative data</div>
</div>
</div>
<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="#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>
</nav>
<div class="sidebar-footer">
<div class="mode-card">
<div class="mode-dot"></div>
<div>
<div class="mode-label">Research mode</div>
<div class="mode-detail">Paper execution only</div>
</div>
</div>
<div class="version-line">Tourism v0.1 · API online</div>
</div>
</aside>
<main class="content" id="overview">
<header class="topbar">
<div>
<div class="eyebrow">Alternative data / SET50</div>
<h1>Tourism Pulse</h1>
<p class="subtitle">A first vertical slice from economic observation to explainable portfolio signal.</p>
</div>
<div class="topbar-meta">
<div class="freshness-pill"><span class="freshness-dot"></span>Live dataset</div>
<div class="as-of">As of {{ summary?.as_of || '—' }}</div>
</div>
</header>
<div v-if="loading" class="state-card">Loading the signal snapshot</div>
<div v-else-if="error" class="state-card error-state">{{ error }}</div>
<template v-else>
<section class="kpi-grid" aria-label="Signal summary">
<article class="kpi-card accent-card">
<div class="kpi-label">Theme surprise</div>
<div class="kpi-value">{{ summary.theme_surprise > 0 ? '+' : '' }}{{ formatNumber(summary.theme_surprise) }}<span class="kpi-unit">σ</span></div>
<div class="kpi-foot positive-text">Above seasonal expectation</div>
</article>
<article class="kpi-card">
<div class="kpi-label">Active signals</div>
<div class="kpi-value">{{ summary.signal_summary.total }}</div>
<div class="kpi-foot"><span class="long-count">{{ summary.signal_summary.long }} long</span> · <span class="short-count">{{ summary.signal_summary.short }} short</span></div>
</article>
<article class="kpi-card">
<div class="kpi-label">Paper ledger</div>
<div class="kpi-value">{{ ledger.entries.length }}</div>
<div class="kpi-foot">No external receiver connected</div>
</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-foot">Vintage {{ summary.data_health.vintage_id }}</div>
</article>
</section>
<section class="hero-grid" id="factors">
<article class="panel pulse-panel">
<div class="panel-header">
<div>
<div class="section-kicker">01 / Economic pulse</div>
<h2>What changed?</h2>
</div>
<span class="confidence-tag">{{ positiveObservations }}/{{ observationRows.length }} positive</span>
</div>
<div class="pulse-lead">
<span class="pulse-number">{{ formatNumber(summary.theme_surprise) }}σ</span>
<span class="pulse-copy">The tourism basket is running above its seasonal expectation. The score is an input, not an order.</span>
</div>
<div class="observation-list">
<div v-for="observation in observationRows" :key="observation.metric_key" class="observation-row">
<div class="observation-name">{{ observation.metric_key.replaceAll('_', ' ') }}</div>
<div class="observation-track"><div class="observation-bar" :class="Number(observation.surprise) >= 0 ? 'bar-positive' : 'bar-negative'" :style="{ width: `${Math.min(Math.abs(Number(observation.surprise)) / maxSurprise * 100, 100)}%` }"></div></div>
<div class="observation-value" :class="Number(observation.surprise) >= 0 ? 'positive-text' : 'negative-text'">{{ Number(observation.surprise) >= 0 ? '+' : '' }}{{ formatNumber(observation.surprise) }}σ</div>
</div>
</div>
</article>
<article class="panel lineage-panel" id="lineage">
<div class="panel-header">
<div>
<div class="section-kicker">02 / Provenance</div>
<h2>Can we trust the input?</h2>
</div>
<span class="status-tag" :class="summary.data_health.status === 'fixture' ? 'fixture-tag' : ''">{{ 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>
<div class="lineage-item"><span>Published</span><strong>{{ formatDate(summary.data_health.published_at) }}</strong></div>
<div class="lineage-item"><span>Retrieved</span><strong>{{ formatDate(summary.data_health.retrieved_at) }}</strong></div>
<div class="lineage-item"><span>Vintage</span><strong>{{ summary.data_health.vintage_id }}</strong></div>
</div>
<div class="lineage-note">Every signal will carry its source, release timestamp and vintage. Revised data never silently rewrites the past.</div>
</article>
</section>
<section class="panel signal-panel" id="signals">
<div class="panel-header signal-header">
<div>
<div class="section-kicker">03 / Deterministic output</div>
<h2>Signal board</h2>
</div>
<div class="strategy-meta">{{ signalData.strategy_version }} <span>·</span> target weights, not orders</div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Rank</th><th>Symbol</th><th>Side</th><th>Score</th><th>Exposure</th><th>Reason</th><th>Target</th><th></th></tr></thead>
<tbody>
<tr v-for="signal in signals" :key="signal.symbol">
<td class="muted-cell">{{ String(signal.rank).padStart(2, '0') }}</td>
<td><strong class="symbol-name">{{ signal.symbol }}</strong><span class="confidence-cell">{{ Math.round(signal.confidence * 100) }}% confidence</span></td>
<td><span class="side-pill" :class="signal.side.toLowerCase()">{{ signal.side }}</span></td>
<td class="score-cell" :class="signal.score >= 0 ? 'positive-text' : 'negative-text'">{{ signal.score >= 0 ? '+' : '' }}{{ formatNumber(signal.score) }}</td>
<td>{{ signal.coefficient >= 0 ? '+' : '' }}{{ formatNumber(signal.coefficient) }}</td>
<td><span class="reason-code" v-for="code in signal.reason_codes" :key="code">{{ code }}</span></td>
<td class="target-cell">{{ signal.target_weight >= 0 ? '+' : '' }}{{ (signal.target_weight * 100).toFixed(1) }}%</td>
<td><button class="row-action" @click="chooseSignal(signal)">Paper entry</button></td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="bottom-grid">
<article class="panel thesis-panel">
<div class="section-kicker">04 / Research note</div>
<h2>Read the signal as a thesis.</h2>
<p>Tourism observations are above expectation, so high-exposure names receive positive scores. The system deliberately stops before execution: a human still needs to review valuation, price-in, liquidity and risk.</p>
<div class="thesis-rule"><span></span>Surprise × Exposure × Confidence</div>
</article>
<article class="panel ledger-panel">
<div class="panel-header">
<div><div class="section-kicker">05 / Simulation</div><h2>Paper ledger</h2></div>
<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="notice" class="notice" :class="notice.includes('recorded') || notice.includes('unlocked') ? 'notice-success' : 'notice-error'">{{ notice }}</div>
<div v-if="!paperAuthenticated" 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 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>
</div>
<div v-else class="empty-ledger">Choose a signal above to record a paper entry.</div>
</article>
</section>
</template>
</main>
</div>
</template>

5
frontend/src/main.js Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')

172
frontend/src/style.css Normal file
View File

@@ -0,0 +1,172 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap');
:root {
color-scheme: dark;
font-family: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
color: #edf2f7;
background: #0b1018;
font-synthesis: none;
text-rendering: optimizeLegibility;
--bg: #0b1018;
--panel: #111925;
--panel-soft: #151f2d;
--line: #263344;
--line-bright: #35465c;
--text: #edf2f7;
--muted: #8794a6;
--faint: #5b6a7e;
--mint: #52d6bd;
--mint-soft: rgba(82, 214, 189, 0.12);
--amber: #e6b96c;
--amber-soft: rgba(230, 185, 108, 0.12);
--red: #ef8b8b;
--red-soft: rgba(239, 139, 139, 0.12);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; background: var(--bg); }
button, input { font: inherit; }
button { cursor: pointer; }
.app-shell { min-height: 100vh; display: flex; background: radial-gradient(circle at 85% -10%, rgba(82, 214, 189, 0.08), transparent 32rem), var(--bg); }
.sidebar { width: 248px; flex: 0 0 248px; min-height: 100vh; padding: 28px 18px 22px; border-right: 1px solid var(--line); display: flex; flex-direction: column; background: rgba(11, 16, 24, 0.76); }
.brand-lockup { display: flex; align-items: center; gap: 11px; padding: 0 9px 33px; }
.brand-mark { width: 32px; height: 32px; display: grid; place-items: center; border: 1px solid rgba(82, 214, 189, .7); border-radius: 9px; color: var(--mint); font: 500 11px 'DM Mono', monospace; letter-spacing: -.08em; box-shadow: 0 0 24px rgba(82, 214, 189, .12); }
.brand-name { font-size: 13px; font-weight: 800; letter-spacing: .01em; }
.brand-caption { margin-top: 2px; color: var(--faint); font: 10px 'DM Mono', monospace; }
.nav-stack { display: grid; gap: 5px; }
.nav-item { display: flex; align-items: center; gap: 11px; padding: 11px 12px; border: 1px solid transparent; border-radius: 8px; color: var(--muted); text-decoration: none; font-size: 12px; font-weight: 600; transition: .2s ease; }
.nav-item:hover { color: var(--text); background: rgba(255,255,255,.025); }
.nav-item.active { color: var(--mint); background: var(--mint-soft); border-color: rgba(82,214,189,.18); }
.nav-glyph { width: 16px; color: currentColor; font-size: 15px; text-align: center; }
.sidebar-footer { margin-top: auto; padding: 12px 8px 0; }
.mode-card { display: flex; gap: 10px; align-items: center; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: rgba(255,255,255,.02); }
.mode-dot, .freshness-dot { width: 7px; height: 7px; flex: 0 0 7px; border-radius: 99px; background: var(--mint); box-shadow: 0 0 12px var(--mint); }
.mode-label { font-size: 11px; font-weight: 700; }
.mode-detail, .version-line { margin-top: 3px; color: var(--faint); font: 10px 'DM Mono', monospace; }
.version-line { padding: 14px 3px 0; }
.content { width: min(100%, 1440px); margin: 0 auto; padding: 42px clamp(22px, 4vw, 64px) 70px; }
.topbar { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; padding-bottom: 35px; }
.eyebrow, .section-kicker { color: var(--mint); font: 500 10px 'DM Mono', monospace; letter-spacing: .14em; text-transform: uppercase; }
h1, h2, p { margin: 0; }
h1 { margin-top: 10px; font-size: clamp(28px, 4vw, 46px); line-height: 1.04; letter-spacing: -.055em; }
h2 { margin-top: 7px; font-size: 17px; letter-spacing: -.025em; }
.subtitle { max-width: 560px; margin-top: 12px; color: var(--muted); font-size: 13px; line-height: 1.7; }
.topbar-meta { text-align: right; color: var(--muted); font: 10px 'DM Mono', monospace; }
.freshness-pill { display: inline-flex; align-items: center; gap: 8px; padding: 7px 10px; border: 1px solid rgba(82,214,189,.25); border-radius: 99px; color: var(--mint); background: var(--mint-soft); }
.as-of { margin-top: 10px; }
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 12px; }
.kpi-card, .panel, .state-card { border: 1px solid var(--line); background: linear-gradient(145deg, rgba(21,31,45,.86), rgba(14,21,31,.94)); box-shadow: 0 18px 42px rgba(0,0,0,.12); }
.kpi-card { min-height: 132px; padding: 19px 19px 16px; border-radius: 10px; }
.accent-card { border-color: rgba(82,214,189,.3); background: linear-gradient(145deg, rgba(25,56,59,.8), rgba(14,31,38,.92)); }
.kpi-label { color: var(--muted); font: 10px 'DM Mono', monospace; text-transform: uppercase; letter-spacing: .08em; }
.kpi-value { margin-top: 12px; color: var(--text); font-size: 29px; font-weight: 700; letter-spacing: -.055em; }
.kpi-unit { margin-left: 4px; color: var(--mint); font: 14px 'DM Mono', monospace; }
.quality-value { font-size: 22px; text-transform: capitalize; }
.kpi-foot { margin-top: 10px; color: var(--faint); font: 10px 'DM Mono', monospace; }
.long-count, .positive-text { color: var(--mint); }
.short-count, .negative-text { color: var(--red); }
.hero-grid, .bottom-grid { display: grid; grid-template-columns: 1.45fr 1fr; gap: 12px; margin-bottom: 12px; }
.panel { border-radius: 10px; padding: 22px; }
.panel-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; }
.confidence-tag, .status-tag { padding: 6px 8px; color: var(--mint); border: 1px solid rgba(82,214,189,.22); border-radius: 6px; background: var(--mint-soft); font: 10px 'DM Mono', monospace; white-space: nowrap; }
.pulse-lead { display: flex; gap: 15px; align-items: baseline; margin: 26px 0 25px; }
.pulse-number { color: var(--mint); font-size: 32px; font-weight: 700; letter-spacing: -.06em; }
.pulse-copy { max-width: 380px; color: var(--muted); font-size: 12px; line-height: 1.6; }
.observation-list { display: grid; gap: 16px; }
.observation-row { display: grid; grid-template-columns: minmax(145px, 1fr) 1.3fr 58px; align-items: center; gap: 14px; }
.observation-name { color: var(--muted); font: 10px 'DM Mono', monospace; text-transform: uppercase; }
.observation-track { height: 6px; overflow: hidden; border-radius: 99px; background: #253140; }
.observation-bar { height: 100%; border-radius: inherit; }
.bar-positive { background: var(--mint); box-shadow: 0 0 16px rgba(82,214,189,.42); }
.bar-negative { background: var(--red); }
.observation-value { text-align: right; font: 11px 'DM Mono', monospace; }
.lineage-panel { display: flex; flex-direction: column; }
.lineage-list { display: grid; gap: 0; margin-top: 24px; border-top: 1px solid var(--line); }
.lineage-item { display: flex; justify-content: space-between; gap: 15px; padding: 13px 0; border-bottom: 1px solid var(--line); color: var(--muted); font-size: 11px; }
.lineage-item strong { color: var(--text); font: 10px 'DM Mono', monospace; text-align: right; }
.lineage-note { margin-top: auto; padding-top: 22px; color: var(--faint); font-size: 11px; line-height: 1.7; }
.signal-panel { padding: 0; overflow: hidden; }
.signal-header { padding: 22px; border-bottom: 1px solid var(--line); }
.strategy-meta { color: var(--faint); font: 10px 'DM Mono', monospace; }
.strategy-meta span { color: var(--line-bright); padding: 0 5px; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 850px; }
th { padding: 12px 16px; color: var(--faint); border-bottom: 1px solid var(--line); font: 10px 'DM Mono', monospace; font-weight: 400; text-align: left; text-transform: uppercase; letter-spacing: .06em; }
td { padding: 14px 16px; border-bottom: 1px solid rgba(38,51,68,.72); color: var(--muted); font-size: 11px; vertical-align: middle; }
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: rgba(255,255,255,.025); }
.muted-cell, .score-cell, .target-cell { font-family: 'DM Mono', monospace; }
.symbol-name { display: block; color: var(--text); font-size: 12px; }
.confidence-cell { display: block; margin-top: 4px; color: var(--faint); font: 9px 'DM Mono', monospace; }
.side-pill { display: inline-block; min-width: 52px; padding: 5px 7px; border-radius: 5px; font: 10px 'DM Mono', monospace; text-align: center; }
.side-pill.long { color: var(--mint); background: var(--mint-soft); }
.side-pill.short { color: var(--red); background: var(--red-soft); }
.side-pill.neutral { color: var(--amber); background: var(--amber-soft); }
.reason-code { display: inline-block; margin: 2px 3px 2px 0; padding: 4px 5px; border: 1px solid var(--line-bright); border-radius: 4px; color: var(--faint); font: 9px 'DM Mono', monospace; }
.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); }
.bottom-grid { grid-template-columns: 1fr 1fr; }
.thesis-panel { background: linear-gradient(145deg, rgba(36,48,64,.9), rgba(15,23,34,.95)); }
.thesis-panel p, .ledger-panel p { margin-top: 14px; color: var(--muted); font-size: 12px; line-height: 1.8; }
.thesis-rule { display: flex; align-items: center; gap: 9px; margin-top: 24px; color: var(--mint); font: 10px 'DM Mono', monospace; }
.thesis-rule span { width: 22px; height: 1px; background: var(--mint); }
.fixture-tag { color: var(--amber); border-color: rgba(230,185,108,.3); background: var(--amber-soft); }
.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; }
.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; }
input { width: 100%; padding: 10px 11px; outline: none; border: 1px solid var(--line-bright); border-radius: 6px; color: var(--text); background: rgba(11,16,24,.7); font: 11px 'DM Mono', monospace; }
input:focus { border-color: var(--mint); box-shadow: 0 0 0 3px rgba(82,214,189,.09); }
.primary-button { color: #071411; border-color: var(--mint); background: var(--mint); font-weight: 700; }
.primary-button:disabled { opacity: .5; cursor: wait; }
.empty-ledger { margin-top: 18px; padding: 14px; border: 1px dashed var(--line-bright); border-radius: 6px; color: var(--faint); font: 10px 'DM Mono', monospace; text-align: center; }
.notice { margin-top: 14px; padding: 9px 10px; border-radius: 5px; font: 10px 'DM Mono', monospace; }
.notice-success { color: var(--mint); background: var(--mint-soft); }
.notice-error { color: var(--red); background: var(--red-soft); }
.state-card { margin-top: 12px; padding: 28px; border-radius: 10px; color: var(--muted); font: 12px 'DM Mono', monospace; }
.error-state { color: var(--red); border-color: rgba(239,139,139,.3); }
@media (max-width: 1040px) {
.sidebar { width: 208px; flex-basis: 208px; }
.kpi-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 760px) {
.app-shell { display: block; }
.sidebar { width: 100%; min-height: auto; padding: 15px 18px; border-right: 0; border-bottom: 1px solid var(--line); }
.brand-lockup { padding: 0; }
.nav-stack { display: flex; overflow-x: auto; margin-top: 14px; gap: 5px; }
.nav-item { flex: 0 0 auto; padding: 8px 10px; font-size: 10px; }
.nav-glyph { display: none; }
.sidebar-footer { display: none; }
.content { padding: 30px 16px 44px; }
.topbar { display: block; padding-bottom: 25px; }
.topbar-meta { display: flex; justify-content: space-between; align-items: center; margin-top: 18px; text-align: left; }
.hero-grid, .bottom-grid { grid-template-columns: 1fr; }
.panel { padding: 18px; }
}
@media (max-width: 500px) {
.kpi-grid { grid-template-columns: 1fr 1fr; gap: 7px; }
.kpi-card { min-height: 112px; padding: 14px; }
.kpi-value { font-size: 22px; }
.kpi-foot { font-size: 9px; }
.pulse-lead { display: block; margin: 20px 0; }
.pulse-copy { display: block; margin-top: 8px; }
.observation-row { grid-template-columns: 1fr 55px; gap: 8px; }
.observation-track { grid-column: 1 / -1; grid-row: 2; }
.observation-value { grid-column: 2; grid-row: 1; }
.lineage-item { display: block; }
.lineage-item strong { display: block; margin-top: 5px; text-align: left; }
h1 { font-size: 31px; }
.subtitle { font-size: 11px; }
}

12
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': 'http://127.0.0.1:5000',
},
},
})