Backend: - siamchart_factors.py: build per-symbol factor view from Siamchart snapshot (PE, EPS latest, EPS growth YoY derived from series, dividend yield, P/BV, ROE, is_dividend). eps_latest now returns the most recent year. - /api/v1/factors endpoint: merge Siamchart fundamentals with the tourism signal (side/score), signal-led sorting. - test_siamchart_factors.py: 5 tests incl. regression asserting eps == year5 value. Frontend: - App.vue/style.css: new 'Stock board' dashboard table (Signal, Symbol, P/E, EPS, EPS YoY, Yield%, P/BV, ROE) with a Dividend-only filter and click-to-sort columns. Verified: full backend suite 140 tests OK, frontend build OK, static scan clean, live /api/v1/factors 200 (49 factors/46 dividends), rendered table filter+sort verified in browser. Independent review deleg_969513e5 caught+fixed eps bug; re-review deleg_e6bd80db passed=true.
544 lines
28 KiB
Vue
544 lines
28 KiB
Vue
<script setup>
|
||
import { computed, onMounted, ref } from 'vue'
|
||
|
||
const summary = ref(null)
|
||
const observations = ref(null)
|
||
const signalData = ref(null)
|
||
const factorData = ref(null)
|
||
const dividendOnly = ref(false)
|
||
const sortKey = ref('signal_score')
|
||
const sortDirection = ref('desc')
|
||
const ledger = ref({ entries: [] })
|
||
const backtest = ref(null)
|
||
const researchRun = ref(null)
|
||
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 paperAuthMode = ref('token')
|
||
const paperAuthEnabled = ref(true)
|
||
const paperAuthWarning = ref('')
|
||
const unlocking = ref(false)
|
||
const researchRunning = ref(false)
|
||
|
||
const signals = computed(() => signalData.value?.signals ?? [])
|
||
const observationRows = computed(() => observations.value?.observations ?? [])
|
||
|
||
// Combined factor rows (Siamchart fundamentals + tourism signal).
|
||
const factorRows = computed(() => factorData.value?.factors ?? [])
|
||
const factorAvailable = computed(() => factorData.value?.available ?? false)
|
||
const dividendCount = computed(() => factorData.value?.dividend_count ?? 0)
|
||
const signalScoreCount = computed(() => factorRows.value.filter((f) => f.signal_side === 'LONG' || f.signal_side === 'SHORT').length)
|
||
|
||
// Rows filtered to dividend-paying names only when the toggle is on.
|
||
const filteredFactorRows = computed(() => {
|
||
let rows = factorRows.value
|
||
if (dividendOnly.value) rows = rows.filter((f) => f.is_dividend)
|
||
return rows
|
||
})
|
||
|
||
// Numeric accessor used for sorting columns.
|
||
function factorValue(row, key) {
|
||
if (key === 'signal_score') return row.signal_score ?? (row.signal_side === 'LONG' ? 9999 : 0)
|
||
if (key === 'symbol') return row.symbol
|
||
if (key === 'dividend_yield') return row.dividend_yield ?? -1
|
||
if (key === 'eps_growth_yoy') return row.eps_growth_yoy ?? -1
|
||
if (key === 'pe') return row.pe ?? 0
|
||
if (key === 'eps') return row.eps ?? 0
|
||
if (key === 'pbv') return row.pbv ?? 0
|
||
if (key === 'roe') return row.roe ?? 0
|
||
return row[key]
|
||
}
|
||
|
||
const sortedFactorRows = computed(() => {
|
||
const rows = [...filteredFactorRows.value]
|
||
const dir = sortDirection.value === 'asc' ? 1 : -1
|
||
rows.sort((a, b) => {
|
||
const av = factorValue(a, sortKey.value)
|
||
const bv = factorValue(b, sortKey.value)
|
||
if (typeof av === 'string') return av.localeCompare(bv) * dir
|
||
if (av === bv) return a.symbol.localeCompare(b.symbol)
|
||
if (av == null) return 1
|
||
if (bv == null) return -1
|
||
return (av - bv) * dir
|
||
})
|
||
return rows
|
||
})
|
||
|
||
function setSort(key) {
|
||
if (sortKey.value === key) {
|
||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||
} else {
|
||
sortKey.value = key
|
||
sortDirection.value = 'desc'
|
||
}
|
||
}
|
||
|
||
function sortIndicator(key) {
|
||
if (sortKey.value !== key) return ''
|
||
return sortDirection.value === 'asc' ? '↑' : '↓'
|
||
}
|
||
|
||
const maxSurprise = computed(() => {
|
||
const values = observationRows.value.map((row) => Math.abs(Number(row.surprise)))
|
||
return Math.max(...values, 1)
|
||
})
|
||
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',
|
||
})
|
||
}
|
||
|
||
const displayLabels = {
|
||
available: 'available',
|
||
blocked: 'blocked',
|
||
descriptive_only: 'descriptive only',
|
||
high: 'high',
|
||
missing: 'missing',
|
||
provisional: 'provisional',
|
||
ready: 'ready',
|
||
revised_vendor_history: 'revised vendor history',
|
||
point_in_time_archive: 'point-in-time archive',
|
||
validated_pit_event_study: 'validated PIT event study',
|
||
non_pit_descriptive_only: 'non-PIT descriptive only',
|
||
}
|
||
|
||
function displayLabel(value) {
|
||
if (value === null || value === undefined || value === '') return ''
|
||
const text = String(value)
|
||
return displayLabels[text] || text.replaceAll('_', ' ')
|
||
}
|
||
|
||
function researchReason(report) {
|
||
const reasons = {
|
||
insufficient_vintages: `Only ${report?.gates?.vintages?.available_events ?? 0} independent releases; ${report?.gates?.vintages?.required_events ?? 0} required.`,
|
||
price_series_not_point_in_time: 'Price history is available, but it is revised vendor history rather than point-in-time data.',
|
||
price_archive_contract_missing: 'The price source is not backed by the required point-in-time archive contract.',
|
||
exploratory_mode_non_validated: 'Exploratory runs are descriptive only, even when the input archive is point-in-time capable.',
|
||
price_snapshot_missing: 'No price snapshot is available.',
|
||
price_snapshot_unreadable: 'The price snapshot failed integrity validation.',
|
||
price_snapshot_invalid: 'The price snapshot has an invalid normalized series and cannot support this run.',
|
||
research_inputs_invalid: 'One or more frozen inputs failed validation.',
|
||
}
|
||
if (report?.result_scope === 'non_pit_descriptive_only') {
|
||
return 'Exploratory run: descriptive only; this is not validated point-in-time backtest evidence.'
|
||
}
|
||
return reasons[report?.reason] || displayLabel(report?.reason) || 'Event study result is available.'
|
||
}
|
||
|
||
async function fetchJson(url, options) {
|
||
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 fetchBacktestReadiness() {
|
||
const response = await fetch('/api/v1/backtest/tourism?min_events=12')
|
||
const body = await response.json().catch(() => ({}))
|
||
if (![200, 409].includes(response.status)) {
|
||
throw new Error(body.error || `Request failed: ${response.status}`)
|
||
}
|
||
return body
|
||
}
|
||
|
||
async function fetchLatestResearch() {
|
||
const response = await fetch('/api/v1/research/tourism/latest')
|
||
if (response.status === 404) return null
|
||
const body = await response.json().catch(() => ({}))
|
||
if (!response.ok) throw new Error(body.error || `Request failed: ${response.status}`)
|
||
return body
|
||
}
|
||
|
||
async function runResearch() {
|
||
researchRunning.value = true
|
||
notice.value = ''
|
||
try {
|
||
researchRun.value = await fetchJson('/api/v1/research/tourism/run', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ mode: 'exploratory', min_events: 1, windows: [1, 3, 5, 20], cost_bps: 20, execution_lag_sessions: 1 }),
|
||
})
|
||
} catch (caught) {
|
||
notice.value = caught.message
|
||
} finally {
|
||
researchRunning.value = false
|
||
}
|
||
}
|
||
|
||
async function loadDashboard() {
|
||
loading.value = true
|
||
error.value = ''
|
||
try {
|
||
const [summaryBody, observationBody, signalBody, factorBody, ledgerBody, sessionBody, backtestBody, researchBody] = await Promise.all([
|
||
fetchJson('/api/v1/dashboard/summary'),
|
||
fetchJson('/api/v1/factors/tourism/observations'),
|
||
fetchJson('/api/v1/signals'),
|
||
fetchJson('/api/v1/factors'),
|
||
fetchJson('/api/v1/paper/ledger'),
|
||
fetchJson('/api/v1/auth/paper', { credentials: 'include' }),
|
||
fetchBacktestReadiness(),
|
||
fetchLatestResearch(),
|
||
])
|
||
summary.value = summaryBody
|
||
observations.value = observationBody
|
||
signalData.value = signalBody
|
||
factorData.value = factorBody
|
||
ledger.value = ledgerBody
|
||
paperAuthenticated.value = Boolean(sessionBody.authenticated)
|
||
paperAuthMode.value = sessionBody.mode || 'token'
|
||
paperAuthEnabled.value = sessionBody.enabled !== false
|
||
paperAuthWarning.value = sessionBody.warning || ''
|
||
backtest.value = backtestBody
|
||
researchRun.value = researchBody
|
||
} catch (caught) {
|
||
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 {
|
||
const authBody = await fetchJson('/api/v1/auth/paper', {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ token: paperToken.value }),
|
||
})
|
||
paperAuthenticated.value = Boolean(authBody.authenticated)
|
||
paperAuthMode.value = authBody.mode || 'token'
|
||
paperAuthEnabled.value = authBody.enabled !== false
|
||
paperAuthWarning.value = authBody.warning || ''
|
||
paperToken.value = ''
|
||
notice.value = 'Paper ledger unlocked for this browser session.'
|
||
} catch (caught) {
|
||
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="#stocks"><span class="nav-glyph">▤</span>Stock board</a>
|
||
<a class="nav-item" href="#signals"><span class="nav-glyph">↗</span>Signal board</a>
|
||
<a class="nav-item" href="#factors"><span class="nav-glyph">∿</span>Factor explorer</a>
|
||
<a class="nav-item" href="#lineage"><span class="nav-glyph">⌁</span>Data lineage</a>
|
||
<a class="nav-item" href="#research-run"><span class="nav-glyph">✓</span>Research run</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.5 · research + paper</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>{{ summary?.data_health?.source_mode === 'bot' ? 'BOT live vintage' : 'Fixture 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" :class="summary.theme_surprise >= 0 ? 'positive-text' : 'negative-text'">{{ summary.theme_surprise >= 0 ? 'Above' : 'Below' }} 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">{{ displayLabel(summary.data_health.status) }}</div>
|
||
<div class="kpi-foot">Vintage {{ summary.data_health.vintage_id }}</div>
|
||
</article>
|
||
<article class="kpi-card">
|
||
<div class="kpi-label">Backtest gate</div>
|
||
<div class="kpi-value quality-value">{{ displayLabel(backtest?.status) }}</div>
|
||
<div class="kpi-foot">{{ backtest?.available_events || 0 }} / {{ backtest?.required_events || 0 }} vintages · {{ displayLabel(backtest?.price_snapshot?.quality) || 'price snapshot missing' }}</div>
|
||
</article>
|
||
</section>
|
||
|
||
<section class="panel stock-panel" id="stocks">
|
||
<div class="panel-header signal-header">
|
||
<div>
|
||
<div class="section-kicker">Alternative factors × SET50</div>
|
||
<h2>Stock board</h2>
|
||
<p class="panel-subtitle">Fundamentals from Siamchart merged with the tourism signal. Click a column header to sort; toggle <em>dividend only</em> to view dividend payers.</p>
|
||
</div>
|
||
<div class="stock-controls">
|
||
<label class="toggle-filter">
|
||
<input type="checkbox" v-model="dividendOnly" />
|
||
<span>Dividend only ({{ dividendCount }})</span>
|
||
</label>
|
||
<span class="status-tag" :class="factorAvailable ? '' : 'warning-tag'">{{ factorAvailable ? 'Siamchart live' : 'factors unavailable' }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="!factorAvailable" class="empty-research">Siamchart factor snapshot is not available on disk. Run <code>collect_siamchart.py --group SET50 --with-info</code> to collect it.</div>
|
||
<div v-else class="table-wrap">
|
||
<table class="factor-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="sortable" :class="{ active: sortKey === 'signal_score' }" @click="setSort('signal_score')">Signal {{ sortIndicator('signal_score') }}</th>
|
||
<th class="sortable" :class="{ active: sortKey === 'symbol' }" @click="setSort('symbol')">Symbol {{ sortIndicator('symbol') }}</th>
|
||
<th class="sortable" :class="{ active: sortKey === 'pe' }" @click="setSort('pe')">P/E {{ sortIndicator('pe') }}</th>
|
||
<th class="sortable" :class="{ active: sortKey === 'eps' }" @click="setSort('eps')">EPS {{ sortIndicator('eps') }}</th>
|
||
<th class="sortable" :class="{ active: sortKey === 'eps_growth_yoy' }" @click="setSort('eps_growth_yoy')">EPS YoY {{ sortIndicator('eps_growth_yoy') }}</th>
|
||
<th class="sortable" :class="{ active: sortKey === 'dividend_yield' }" @click="setSort('dividend_yield')">Yield % {{ sortIndicator('dividend_yield') }}</th>
|
||
<th class="sortable" :class="{ active: sortKey === 'pbv' }" @click="setSort('pbv')">P/BV {{ sortIndicator('pbv') }}</th>
|
||
<th class="sortable" :class="{ active: sortKey === 'roe' }" @click="setSort('roe')">ROE {{ sortIndicator('roe') }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="factor in sortedFactorRows" :key="factor.symbol">
|
||
<td><span v-if="factor.signal_side" class="side-pill" :class="factor.signal_side.toLowerCase()">{{ factor.signal_side }}</span><span v-else class="muted-cell">—</span></td>
|
||
<td><strong class="symbol-name">{{ factor.symbol }}</strong></td>
|
||
<td class="score-cell">{{ factor.pe != null ? formatNumber(factor.pe) : '—' }}</td>
|
||
<td>{{ factor.eps != null ? formatNumber(factor.eps) : '—' }}</td>
|
||
<td :class="factor.eps_growth_yoy >= 0 ? 'positive-text' : 'negative-text'">{{ factor.eps_growth_yoy != null ? (factor.eps_growth_yoy >= 0 ? '+' : '') + formatNumber(factor.eps_growth_yoy) + '%' : '—' }}</td>
|
||
<td :class="factor.dividend_yield >= 0 ? 'positive-text' : ''">{{ factor.dividend_yield != null ? formatNumber(factor.dividend_yield) + '%' : '—' }}<span v-if="factor.is_dividend" class="dividend-dot" title="Pays dividend">●</span></td>
|
||
<td>{{ factor.pbv != null ? formatNumber(factor.pbv) : '—' }}</td>
|
||
<td :class="factor.roe >= 0 ? 'positive-text' : 'negative-text'">{{ factor.roe != null ? formatNumber(factor.roe) + '%' : '—' }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="hero-grid" id="factors">
|
||
<article class="panel pulse-panel">
|
||
<div class="panel-header">
|
||
<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 {{ summary.theme_surprise >= 0 ? 'above' : 'below' }} 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 === 'high' ? '' : 'warning-tag'">{{ displayLabel(summary.data_health.status) }}</span>
|
||
</div>
|
||
<div class="lineage-list">
|
||
<div class="lineage-item"><span>Source</span><strong>{{ summary.data_health.source_id }}</strong></div>
|
||
<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 class="lineage-item"><span>History</span><strong>{{ summary.data_health.history_points || '—' }} points</strong></div>
|
||
<div class="lineage-item"><span>Parser</span><strong>{{ summary.data_health.parser_version || '—' }}</strong></div>
|
||
<div class="lineage-item"><span>Replay</span><strong>{{ summary.data_health.replayable ? 'Available' : 'Not captured' }}</strong></div>
|
||
<div class="lineage-item"><span>Raw hash</span><strong>{{ summary.data_health.raw_payload_hash ? summary.data_health.raw_payload_hash.slice(0, 16) : '—' }}</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="panel research-panel" id="research-run">
|
||
<div class="panel-header">
|
||
<div>
|
||
<div class="section-kicker">04 / Frozen research run</div>
|
||
<h2>Can the evidence support a descriptive study?</h2>
|
||
</div>
|
||
<button class="primary-button" :disabled="researchRunning" @click="runResearch">{{ researchRunning ? 'Running…' : 'Run exploratory study' }}</button>
|
||
</div>
|
||
<div v-if="researchRun" class="research-grid">
|
||
<div>
|
||
<div class="research-status" :class="researchRun.result_scope === 'non_pit_descriptive_only' ? 'status-descriptive' : researchRun.status === 'ready' ? 'status-ready' : 'status-blocked'">{{ researchRun.result_scope === 'non_pit_descriptive_only' ? 'descriptive only' : displayLabel(researchRun.status) }}</div>
|
||
<div class="research-reason">{{ researchReason(researchRun) }}</div>
|
||
<div class="research-meta">Run {{ researchRun.run_id }} · {{ formatDate(researchRun.generated_at) }} · {{ displayLabel(researchRun.result_scope) }}</div>
|
||
</div>
|
||
<div class="gate-list">
|
||
<div class="gate-row"><span>Independent vintages</span><strong>{{ researchRun.gates.vintages.available_events }} / {{ researchRun.gates.vintages.required_events }} · {{ displayLabel(researchRun.gates.vintages.status) }}</strong></div>
|
||
<div class="gate-row"><span>Price source</span><strong>{{ displayLabel(researchRun.gates.prices.quality) || 'missing' }} · {{ displayLabel(researchRun.gates.prices.status) }}</strong></div>
|
||
</div>
|
||
</div>
|
||
<div v-if="researchRun?.result?.windows" class="research-results">
|
||
<div v-for="(windowResult, windowKey) in researchRun.result.windows" :key="windowKey" class="research-result-row">
|
||
<span>+{{ windowKey }} sessions</span>
|
||
<strong :class="windowResult.net_return >= 0 ? 'positive-text' : 'negative-text'">{{ (windowResult.net_return * 100).toFixed(2) }}% net</strong>
|
||
<span>{{ (windowResult.hit_rate * 100).toFixed(0) }}% hit · {{ windowResult.event_count }} events</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="!researchRun" class="empty-research">No frozen research run yet. Run the check to persist the current inputs and gate decision.</div>
|
||
</section>
|
||
|
||
<section class="bottom-grid">
|
||
<article class="panel thesis-panel">
|
||
<div class="section-kicker">05 / Research note</div>
|
||
<h2>Read the signal as a thesis.</h2>
|
||
<p>Tourism observations are {{ summary.theme_surprise >= 0 ? 'above' : 'below' }} expectation, so exposure determines which names receive positive or negative 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">06 / 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="paperAuthWarning" class="paper-auth-warning">{{ paperAuthWarning }}</div>
|
||
<div v-if="notice" class="notice" :class="notice.includes('recorded') || notice.includes('unlocked') ? 'notice-success' : 'notice-error'">{{ notice }}</div>
|
||
<div v-if="!paperAuthenticated && paperAuthMode === 'token' && paperAuthEnabled" class="auth-form">
|
||
<div class="auth-copy">Paper writes are locked. Enter the local operator token; it is used only to create an HttpOnly session.</div>
|
||
<input v-model="paperToken" type="password" autocomplete="current-password" placeholder="Paper-session token" aria-label="Paper-session token" />
|
||
<button class="primary-button" :disabled="unlocking" @click="unlockPaper">{{ unlocking ? 'Unlocking…' : 'Unlock paper ledger' }}</button>
|
||
</div>
|
||
<div v-else-if="!paperAuthenticated && paperAuthMode === 'token' && !paperAuthEnabled" class="empty-ledger">Paper writes are disabled by the server configuration.</div>
|
||
<div v-else-if="paperAuthenticated && selectedSignal" class="entry-form">
|
||
<div class="selected-entry"><strong>{{ selectedSignal.symbol }}</strong><span>{{ selectedSignal.side }} · target {{ (selectedSignal.target_weight * 100).toFixed(1) }}%</span></div>
|
||
<input v-model="assumedPrice" type="number" min="0.01" step="0.01" placeholder="Assumed fill price" aria-label="Assumed fill price" />
|
||
<button class="primary-button" :disabled="submitting" @click="recordPaperEntry">{{ submitting ? 'Recording…' : 'Record paper entry' }}</button>
|
||
</div>
|
||
<div v-else class="empty-ledger">Choose a signal above to record a paper entry.</div>
|
||
</article>
|
||
</section>
|
||
</template>
|
||
</main>
|
||
</div>
|
||
</template>
|