[verified] build tourism signal dashboard
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal 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
67
README.md
Normal 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
182
backend/app/__init__.py
Normal 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
41
backend/app/paper.py
Normal 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
96
backend/app/tourism.py
Normal 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"),
|
||||
}
|
||||
44
backend/fixtures/tourism_snapshot.json
Normal file
44
backend/fixtures/tourism_snapshot.json
Normal 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
1
backend/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
Flask>=3.1,<4
|
||||
17
backend/run.py
Normal file
17
backend/run.py
Normal 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
87
backend/tests/test_api.py
Normal 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()
|
||||
65
backend/tests/test_tourism.py
Normal file
65
backend/tests/test_tourism.py
Normal 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
47
docs/HANDOFF.md
Normal 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
32
docs/engineering-log.md
Normal 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
13
frontend/index.html
Normal 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
1382
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
frontend/package.json
Normal file
18
frontend/package.json
Normal 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
307
frontend/src/App.vue
Normal 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
5
frontend/src/main.js
Normal 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
172
frontend/src/style.css
Normal 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
12
frontend/vite.config.js
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user