Files
set50-system/frontend/src/App.vue
Kunthawat Greethong 8a6991b7dd [verified] Add Siamchart factor view + redesigned SET50 dashboard stock board
Backend:
- siamchart_factors.py: build per-symbol factor view from Siamchart snapshot (PE, EPS latest, EPS growth YoY derived from series, dividend yield, P/BV, ROE, is_dividend). eps_latest now returns the most recent year.
- /api/v1/factors endpoint: merge Siamchart fundamentals with the tourism signal (side/score), signal-led sorting.
- test_siamchart_factors.py: 5 tests incl. regression asserting eps == year5 value.

Frontend:
- App.vue/style.css: new 'Stock board' dashboard table (Signal, Symbol, P/E, EPS, EPS YoY, Yield%, P/BV, ROE) with a Dividend-only filter and click-to-sort columns.

Verified: full backend suite 140 tests OK, frontend build OK, static scan clean, live /api/v1/factors 200 (49 factors/46 dividends), rendered table filter+sort verified in browser. Independent review deleg_969513e5 caught+fixed eps bug; re-review deleg_e6bd80db passed=true.
2026-08-25 09:03:28 +07:00

544 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>