Files
set50-system/frontend/src/App.vue
Kunthawat Greethong f3fad16183 feat(ui): surface dated_ledger vs proxy synthesis in backtest results
Backtest result card and saved-run history now show an explicit dividend
badge per run: green 'ตามวันจริง' for dated_ledger (real ex-date x qty cash
flow), amber 'Proxy (ต่อหุ้น)'/'Proxy' for estimates, plus a footnote that
matches the actual dividend_method. Frontend build passes (bundle
index-DDItjwYy.js); served bundle contains the new strings.
2026-08-27 12:54:44 +07:00

892 lines
48 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 themeData = ref(null)
const dashData = ref(null)
const simCapital = ref(1000000)
const selectedSymbol = ref(null)
const symbolDetail = ref(null)
const symbolLoading = ref(false)
// backtest
const btStart = ref('2024-06-01')
const btEnd = ref('2026-06-01')
const btCapital = ref(1000000)
const btFreq = ref('monthly')
const btLoading = ref(false)
const btResult = ref(null)
const btRuns = ref([])
const simMode = ref('backtest')
const simLoading = ref(false)
const simResult = 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 ?? [])
// real multi-theme dashboard (3 themes + macro + sources)
const dashboardThemes = computed(() => dashData.value?.themes ?? [])
const dashboardSources = computed(() => dashData.value?.sources ?? [])
const dashboardMacro = computed(() => dashData.value?.macro ?? {})
const sourceCount = computed(() => dashboardSources.value.length)
const factorCount = computed(() => dashData.value?.source_summary?.factor_keys ?? sourceCount.value)
const realData = computed(() => dashData.value?.available ?? false)
// merged stock board: from /api/v1/dashboard board (combined real) + siamchart
const combinedRows = computed(() => dashData.value?.board ?? factorRows.value)
const boardBySymbol = computed(() => {
const m = {}
for (const row of combinedRows.value) m[row.symbol] = row
return m
})
// per-theme surprise labels for theme cards + thesis
const signalSummary = computed(() => {
const s = summary.value?.signal_summary
return { long: s?.long ?? 0, short: s?.short ?? 0, neutral: s?.neutral ?? 0, total: s?.total ?? 0 }
})
const freqLabel = (f) => ({ monthly: 'รายเดือน', quarterly: 'รายไตรมาส', annual: 'รายปี', daily: 'รายวัน' })[f] || f
// theme labels come from the API (dashData.themes[].label_th) — no hardcode.
const themeLabelById = computed(() => {
const m = {}
for (const t of dashboardThemes.value) m[t.id] = t.label_th
return m
})
// which themes a symbol belongs to — from the dashboard board (API), so editing
// themes.py propagates to the UI with zero frontend change.
function symbolThemes(symbol) {
const row = boardBySymbol.value[symbol]
const ids = row?.themes ?? []
return ids.map((id) => themeLabelById.value[id] || id)
}
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)
// Multi-theme combined data from /api/v1/themes.
const themes = computed(() => themeData.value?.themes ?? [])
const combinedBoard = computed(() => themeData.value?.board ?? [])
const combinedCount = computed(() => themeData.value?.combined_count ?? 0)
// 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 === 'combined') return boardBySymbol.value[row.symbol]?.combined ?? -9999
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)
}
// map the backend's dividend_method to an honest Thai label/flag
function dividendMethodLabel(method) {
if (method === 'dated_ledger') return 'ปันผล (ตามวันจริง)'
if (method === 'dps_annual_proxy') return 'ประมาณการปันผล/หุ้น (DPS)'
return 'ประมาณการปันผล (Proxy)' // final_holdings_yield_proxy or unknown
}
function dividendIsEstimate(method) {
// only dated_ledger rows are real dated cash flows; everything else is an estimate/proxy
return method !== 'dated_ledger'
}
function dividendIsDated(method) {
// real dated cash-flow ledger (per-share x qty on actual ex/pay dates)
return method === 'dated_ledger'
}
function dividendBadge(method) {
// cls: use classes known to exist in this project (status-tag/neutral-tag/
// warning-tag); dated gets an inline green style so "real cash flow" reads
// as clearly better than "estimate".
if (dividendIsDated(method)) return { label: 'ตามวันจริง', cls: 'status-tag', style: 'background:#1a7f37;color:#fff' }
if (method === 'dps_annual_proxy') return { label: 'Proxy (ต่อหุ้น)', cls: 'status-tag warning-tag' }
return { label: 'Proxy', cls: 'status-tag warning-tag' }
}
function dividendFootnote(method) {
if (dividendIsDated(method)) return 'ปันผลตามวันจริงจาก ledger (ex-date × จำนวนหุ้น) — กระแสเงินสดจริง'
if (method === 'dps_annual_proxy') return 'ประมาณการปันผลต่อหุ้น (DPS ล่าสุด × จำนวนหุ้น) ไม่ใช่กระแสเงินสดตามวันจริง'
return 'ประมาณจาก dividend yield ของพอร์ตสุดท้าย ไม่ใช่กระแสเงินสดปันผลจริง'
}
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
}
}
const simOrders = computed(() => simResult.value?.orders ?? [])
const simInvested = computed(() => simResult.value?.invested ?? 0)
const simUnallocated = computed(() => simResult.value?.unallocated_cash ?? 0)
const bucketOrders = (n) => simOrders.value.filter((o) => o.bucket === n)
async function runSimulation() {
simLoading.value = true
simResult.value = null
try {
if (simMode.value === 'forward') {
// REAL forward lifecycle (not cosmetic): freeze + execute a paper run.
simResult.value = await fetchJson('/api/v1/forward', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ capital: Number(simCapital.value), use_pit: false }),
})
await loadForward()
} else {
simResult.value = await fetchJson('/api/v1/simulation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ capital: Number(simCapital.value), mode: 'backtest' }),
})
}
} catch (caught) {
notice.value = caught.message
} finally {
simLoading.value = false
}
}
// ---- real forward-test lifecycle (durable paper runs) ----
const fwdRuns = ref([])
const fwdLoading = ref(false)
async function loadForward() {
fwdLoading.value = true
try {
const body = await fetchJson('/api/v1/forward')
fwdRuns.value = body.runs ?? []
} catch (caught) {
notice.value = caught.message
} finally {
fwdLoading.value = false
}
}
async function markForward(id) {
try {
await fetchJson(`/api/v1/forward/${id}/mark`, { method: 'POST' })
await loadForward()
} catch (caught) {
notice.value = caught.message
}
}
async function matureForward(id) {
try {
await fetchJson(`/api/v1/forward/${id}/mature`, { method: 'POST' })
await loadForward()
} catch (caught) {
notice.value = caught.message
}
}
async function loadDashboard() {
loading.value = true
error.value = ''
try {
const [summaryBody, observationBody, signalBody, factorBody, themeBody, dashBody, 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/themes'),
fetchJson('/api/v1/dashboard'),
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
themeData.value = themeBody
dashData.value = dashBody
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
await loadForward() // load durable forward-test runs (not cosmetic)
} catch (caught) {
error.value = caught.message
} finally {
loading.value = false
}
}
function chooseSignal(signal) {
selectedSignal.value = signal
assumedPrice.value = ''
notice.value = ''
}
async function openSymbolDetail(symbol) {
selectedSymbol.value = symbol
symbolDetail.value = null
symbolLoading.value = true
try {
symbolDetail.value = await fetchJson(`/api/v1/symbols/${symbol}`)
} catch (caught) {
symbolDetail.value = { error: caught.message, symbol }
} finally {
symbolLoading.value = false
}
}
function closeSymbolDetail() {
selectedSymbol.value = null
symbolDetail.value = null
}
async function runBacktest() {
btLoading.value = true
btResult.value = null
try {
btResult.value = await fetchJson('/api/v1/backtest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
start: btStart.value, end: btEnd.value,
capital: Number(btCapital.value), freq: btFreq.value,
}),
})
await loadBacktestRuns()
} catch (caught) {
btResult.value = { error: caught.message }
} finally {
btLoading.value = false
}
}
async function loadBacktestRuns() {
try { btRuns.value = (await fetchJson('/api/v1/backtest/runs')).runs || [] }
catch { btRuns.value = [] }
}
const pnlClass = (net) => net != null ? (net >= 0 ? 'positive-text' : 'negative-text') : ''
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(async () => { await loadDashboard(); await loadBacktestRuns() })
</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>ภาพรวม</a>
<a class="nav-item" href="#themes"><span class="nav-glyph"></span></a>
<a class="nav-item" href="#stocks"><span class="nav-glyph"></span>ตารางห</a>
<a class="nav-item" href="#signals"><span class="nav-glyph"></span>ญญาณ</a>
<a class="nav-item" href="#factors"><span class="nav-glyph"></span>จจ</a>
<a class="nav-item" href="#lineage"><span class="nav-glyph"></span>มาขอม</a>
<a class="nav-item" href="#research-run"><span class="nav-glyph"></span>งานว</a>
<a class="nav-item" href="#simulation"><span class="nav-glyph"></span>จำลอง</a>
</nav>
<div class="sidebar-footer">
<div class="mode-card">
<div class="mode-dot"></div>
<div>
<div class="mode-label">โหมด Research</div>
<div class="mode-detail">Paper execution only</div>
</div>
</div>
<div class="version-line">Multi-theme · research + paper</div>
</div>
</aside>
<main class="content" id="overview">
<header class="topbar">
<div>
<div class="eyebrow">Alternative data · SET50</div>
<h1>SET50 Signal Lab</h1>
<p class="subtitle">ภาพรวม alternative factors ไทย ไปจนถงสญญาณลงทนทอธบายได research + paper only</p>
</div>
<div class="topbar-meta">
<div class="freshness-pill" :class="realData ? 'pill-live' : 'pill-fixture'"><span class="freshness-dot"></span>{{ realData ? 'ข้อมูลจริงจากแหล่งไทย' : 'ข้อมูลจำลอง (fixture)' }}</div>
<div class="as-of">อม {{ summary?.as_of || '—' }}</div>
</div>
</header>
<div v-if="loading" class="state-card">กำลังโหลดข้อมูล</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">ญญาณทใชงาน</div>
<div class="kpi-value">{{ signalSummary.long }}</div>
<div class="kpi-foot">
<span class="long-count">{{ signalSummary.long }} </span> ·
<span class="short-count">{{ signalSummary.short }} ขาย</span> ·
<span class="neutral-count">{{ signalSummary.neutral }} เปนกลาง</span>
</div>
</article>
<article class="kpi-card">
<div class="kpi-label">แหลงขอมลทใช</div>
<div class="kpi-value">{{ factorCount }} จจ · {{ sourceCount }} แหล</div>
<div class="kpi-foot">อมลจรงจากแหลงไทย {{ realData? '(จริง)' : '—' }}</div>
</article>
</section>
<section class="panel theme-panel" id="themes">
<div class="panel-header signal-header">
<div>
<div class="section-kicker"></div>
<h2> (Themes)</h2>
<p class="panel-subtitle">ภาพรวม alternative factors ของไทย แตละธมมความถอมลตางก (monthly / quarterly) งนนอยาเทยบเปนจดเวลาเดยวก.</p>
</div>
<span class="status-tag">รวม {{ combinedCount }} symbols</span>
</div>
<div class="theme-grid">
<article v-for="theme in dashboardThemes" :key="theme.id" class="theme-card">
<div class="theme-card-head">
<span class="theme-chip">{{ freqLabel(theme.frequency) }}</span>
<span class="theme-label-th">{{ theme.label_th }}</span>
</div>
<div class="theme-surprise">
<span class="theme-surprise-label">ความตาง (surprise)</span>
<span class="theme-surprise-value">{{ theme.surprise != null ? formatNumber(theme.surprise, 2) + 'σ' : '—' }}</span>
</div>
<div class="theme-read">
<div v-if="theme.id === 'auto_credit' && theme.read.new_car_sales_yoy != null" class="theme-read-value">{{ formatNumber(theme.read.new_car_sales_yoy) }}% YoY ยอดขายรถ</div>
<div v-else-if="theme.id === 'auto_credit' && theme.read.auto_npl_pct != null" class="theme-read-value">NPL {{ formatNumber(theme.read.auto_npl_pct) }}%</div>
<div v-else-if="theme.id === 'refining_energy' && (theme.read.quarterly || theme.read.net_profit)" class="theme-read-value">กำไรสุทธิ TOP (รายไตรมาส)</div>
<div v-else-if="theme.id === 'tourism'" class="theme-read-value">signal tourism {{ theme.surprise != null ? formatNumber(theme.surprise,2) : '' }}σ</div>
</div>
<div v-if="theme.narrative" class="theme-narrative">{{ theme.narrative }}</div>
</article>
</div>
<div v-if="Object.keys(dashboardMacro).length" class="macro-panel">
<div class="section-kicker">ภาพรวมประเทศไทย</div>
<div class="macro-chips">
<span class="macro-chip">การบรโภคภาคเอกชน <strong>{{ dashboardMacro.private_consumption_yoy }}%</strong></span>
<span class="macro-chip">การลงทนเอกชน <strong>{{ dashboardMacro.private_investment_yoy }}%</strong></span>
<span class="macro-chip">เงนเฟ <strong>{{ dashboardMacro.headline_inflation_yoy }}%</strong></span>
<span class="macro-chip">การวางงาน <strong>{{ dashboardMacro.unemployment_pct }}%</strong></span>
<span class="macro-chip">กทองเทยว YTD <strong>{{ dashboardMacro.tourists_ytd_mn }} าน</strong></span>
</div>
</div>
</section>
<section class="panel stock-panel" id="stocks">
<div class="panel-header signal-header">
<div>
<div class="section-kicker">ตารางห</div>
<h2>ตารางห</h2>
<p class="panel-subtitle">ตารางเดยวรวมทกธ ญญาณ + คะแนนรวม (60% / 40% นฐาน) + ลคาพนฐานจาก Siamchart. เรยงไดโดยคลกหวตาราง; เป <em>เฉพาะหนปนผล</em> เพอกรองหนทายปนผล.</p>
</div>
<div class="stock-controls">
<label class="toggle-filter">
<input type="checkbox" v-model="dividendOnly" />
<span>เฉพาะหนปนผล ({{ dividendCount }})</span>
</label>
<span class="status-tag" :class="factorAvailable ? '' : 'warning-tag'">{{ factorAvailable ? 'Siamchart ใช้งานได้' : 'ไม่มี factor' }}</span>
</div>
</div>
<div v-if="!factorAvailable" class="empty-research">Siamchart snapshot ไม่อยู่บน disk. รัน <code>collect_siamchart.py --group SET50 --with-info</code> เพอเกบขอม.</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')">สัญญาณ {{ sortIndicator('signal_score') }}</th>
<th class="sortable" :class="{ active: sortKey === 'combined' }" @click="setSort('combined')" title="60% ธีม + 40% พื้นฐาน">คะแนนรวม (60/40) {{ sortIndicator('combined') }}</th>
<th class="sortable" :class="{ active: sortKey === 'symbol' }" @click="setSort('symbol')">หุ้น {{ sortIndicator('symbol') }}</th>
<th></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')">ปันผล % {{ 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" class="clickable-row" @click="openSymbolDetail(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 class="combined-cell">{{ boardBySymbol[factor.symbol]?.combined != null ? formatNumber(boardBySymbol[factor.symbol].combined) : '—' }}</td>
<td><strong class="symbol-name">{{ factor.symbol }}</strong></td>
<td>
<span v-for="th in symbolThemes(factor.symbol)" :key="th" class="theme-tag">{{ th }}</span>
<span v-if="!symbolThemes(factor.symbol).length" class="muted-cell"></span>
</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="จ่ายปันผล"></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="panel lineage-panel" id="lineage">
<div class="panel-header signal-header">
<div>
<div class="section-kicker">มาของขอม</div>
<h2>แหลงขอมลทงหมด</h2>
<p class="panel-subtitle">รายการแหลงขอมลจรงทใช งมาเมอใด และขอมลชดไหน อมลทงหมดจากแหลงไทย.</p>
</div>
<span class="status-tag">{{ factorCount }} จจ · {{ sourceCount }} แหล</span>
</div>
<div class="table-wrap">
<table class="source-table">
<thead>
<tr>
<th>อม</th><th>แหล</th><th>วงขอม</th><th>ความถ</th><th>ปเดตครงตอไป</th><th>ปเดตลาส</th>
</tr>
</thead>
<tbody>
<tr v-for="(s, i) in dashboardSources" :key="i">
<td>{{ s['จาก'] || s['ขอบเขต'] }}</td>
<td class="source-name">{{ s['แหล่ง'] }}</td>
<td class="muted-cell">{{ s['ข้อมูล'] }}</td>
<td class="muted-cell">{{ s['ความถี่'] || '—' }}</td>
<td class="muted-cell">{{ s['อัปเดตครั้งต่อไป'] ? formatDate(s['อัปเดตครั้งต่อไป']) : '—' }}</td>
<td class="muted-cell">{{ s['dึงมาเมื่อ'] ? formatDate(s['dึงมาเมื่อ']) : '—' }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Sections 01-04 (tourism-only surprise, provenance, signal table, frozen research) removed per user: superseded by the 3-theme panel + sources table above. -->
<section class="panel sim-panel" id="simulation">
<div class="panel-header signal-header">
<div>
<div class="section-kicker">การจำลองการลงท</div>
<h2>ดสรรท (Simulation)</h2>
<p class="panel-subtitle">กรอกท และระบบจดสรรตามสดสวน 50 / 20 / 30 นททำกำไรไดมากสดแลวจายปนผล, นทำกำไรแตไมนผล, และหนปนผลสงส (ไม) นต 100 นตอต.</p>
</div>
<span class="status-tag neutral-tag">{{ simResult ? 'ใช้ได้' : 'รอใส่ทุน' }}</span>
</div>
<div class="sim-controls">
<div class="sim-field">
<label> (บาท)</label>
<input v-model="simCapital" type="number" min="1000" step="1000" />
</div>
<div class="sim-field">
<label>โหมด</label>
<select v-model="simMode">
<option value="backtest">Backtest</option>
<option value="forward">Forward test</option>
</select>
</div>
<button class="primary-button" :disabled="simLoading" @click="runSimulation">{{ simLoading ? 'กำลังคำนวณ' : 'คำนวณการจัดสรร' }}</button>
</div>
<div v-if="simResult" class="sim-result">
<div class="sim-sums">
<div class="sim-sum"><span>ลงทนรวม</span><strong>{{ formatNumber(simInvested, 0) }} บาท</strong></div>
<div class="sim-sum"><span>เงนสดเหล</span><strong>{{ formatNumber(simUnallocated, 0) }} บาท</strong></div>
</div>
<div class="sim-note">{{ simResult.data_note }}</div>
<div class="sim-buckets">
<div class="sim-bucket">
<div class="sim-bucket-head"><span class="sim-bucket-tag b1">50%</span><strong>ทำกำไร + ายปนผล</strong></div>
<table class="sim-order-table">
<tbody v-if="bucketOrders(1).length">
<tr v-for="o in bucketOrders(1)" :key="'b1'+o.symbol">
<td>{{ o.symbol }}</td><td class="muted-cell">qty {{ o.qty }}</td><td class="score-cell">@ {{ formatNumber(o.price) }}</td><td class="score-cell">{{ formatNumber(o.notional, 0) }}</td>
</tr>
</tbody>
<tbody v-else><tr><td class="muted-cell">ไมนทเขาเกณฑ</td></tr></tbody>
</table>
</div>
<div class="sim-bucket">
<div class="sim-bucket-head"><span class="sim-bucket-tag b2">20%</span><strong>ทำกำไร ไมนผล</strong></div>
<table class="sim-order-table">
<tbody v-if="bucketOrders(2).length">
<tr v-for="o in bucketOrders(2)" :key="'b2'+o.symbol">
<td>{{ o.symbol }}</td><td class="muted-cell">qty {{ o.qty }}</td><td class="score-cell">@ {{ formatNumber(o.price) }}</td><td class="score-cell">{{ formatNumber(o.notional, 0) }}</td>
</tr>
</tbody>
<tbody v-else><tr><td class="muted-cell">ไมนทเขาเกณฑ</td></tr></tbody>
</table>
</div>
<div class="sim-bucket">
<div class="sim-bucket-head"><span class="sim-bucket-tag b3">30%</span><strong>นผลสงส (ไม)</strong></div>
<table class="sim-order-table">
<tbody v-if="bucketOrders(3).length">
<tr v-for="o in bucketOrders(3)" :key="'b3'+o.symbol">
<td>{{ o.symbol }}</td><td class="muted-cell">qty {{ o.qty }}</td><td class="score-cell">@ {{ formatNumber(o.price) }}</td><td class="score-cell">{{ formatNumber(o.notional, 0) }}</td>
</tr>
</tbody>
<tbody v-else><tr><td class="muted-cell">ไมนทเขาเกณฑ</td></tr></tbody>
</table>
</div>
</div>
</div>
<div v-if="!simResult" class="empty-research">กด 'คำนวณการจัดสรร' เพื่อดูว่า 50/20/30 จัดสรรทุนของคุณไปที่หุ้นไหนบ้าง</div>
<!-- REAL forward-test lifecycle panel (not cosmetic) -->
<div v-if="simMode === 'forward'" class="fwd-panel">
<div class="section-kicker">Forward Test (Paper) ญญาณถกตร เวลาสราง</div>
<p class="panel-subtitle">สราง forward run ญญาณ (คะแนน) freeze นทสราง แล execute วยราคาหล freeze. กด Mark ตามราคาลาส, Mature เพอป run. เป Paper เทาน.</p>
<div v-if="fwdRuns.length === 0" class="empty-research muted-cell">ยังไม่มี forward run กด 'คำนวณการจัดสรร' ข้างบนเพื่อสร้าง</div>
<table v-else class="source-table">
<thead><tr><th>#</th><th>สถานะ</th><th></th><th>ลงท</th><th></th><th>ผลตอบแทน</th><th>ตรวจ</th></tr></thead>
<tbody>
<tr v-for="run in fwdRuns.slice().reverse()" :key="run.id">
<td class="muted-cell">{{ run.id.slice(0, 12) }}</td>
<td>
<span class="status-tag" :class="run.status === 'matured' ? 'warning-tag' : (run.status === 'frozen' ? 'neutral-tag' : 'warning-tag')">{{ run.status }}</span>
<span v-if="run.non_pit" class="status-tag warning-tag" title="ใช้คะแนนปัจจุบัน ไม่ใช่ PIT">non-PIT</span>
</td>
<td>{{ formatNumber(run.capital, 0) }}</td>
<td class="positive-text">{{ formatNumber(run.invested, 0) }}</td>
<td class="muted-cell">{{ Object.keys(run.holdings || {}).join(', ') || '—' }}</td>
<td :class="pnlClass(run.net_return)">{{ run.net_return != null ? (run.net_return * 100).toFixed(2) + '%' : '—' }}</td>
<td>
<button v-if="run.status !== 'matured'" class="primary-btn" style="padding:2px 8px;margin-right:4px" @click="markForward(run.id)">Mark</button>
<button v-if="run.status !== 'matured'" class="primary-btn" style="padding:2px 8px" @click="matureForward(run.id)">Mature</button>
<span v-else class="muted-cell">ดแล</span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="panel backtest-panel" id="backtest">
<div class="panel-header signal-header">
<div>
<div class="section-kicker">การยอนทดสอบ</div>
<h2>Backtest (อนทดสอบ)</h2>
<p class="panel-subtitle">กำหนดชวงว แลวระบบจดสรร 50/20/30 นทเร ลงทนและถอจนถงวนสนส สรปกำไร/ขาดทนจากราคา + เงนปนผล.</p>
</div>
</div>
<div class="backtest-controls">
<label>งแต <input type="date" v-model="btStart" /></label>
<label> <input type="date" v-model="btEnd" /></label>
<label> <input type="number" v-model.number="btCapital" step="100000" /></label>
<label>ความถ
<select v-model="btFreq">
<option value="monthly">รายเดอน</option>
<option value="quarterly">รายไตรมาส</option>
</select>
</label>
<button class="primary-btn" :disabled="btLoading" @click="runBacktest">{{ btLoading ? 'กำลังย้อนทดสอบ' : 'รัน Backtest' }}</button>
</div>
<div v-if="btResult?.error" class="state-card error-state">{{ btResult.error }}</div>
<div v-else-if="btResult" class="backtest-results">
<div class="bt-kpi-grid">
<div class="bt-kpi"><span>กำไรจากราคา</span><strong :class="pnlClass(btResult.price_pnl)">{{ formatNumber(btResult.price_pnl) }} บาท</strong></div>
<div class="bt-kpi"><span>{{ dividendMethodLabel(btResult.dividend_method) }}<span class="status-tag" :class="dividendBadge(btResult.dividend_method).cls" :style="dividendBadge(btResult.dividend_method).style || undefined" style="margin-left:6px">{{ dividendBadge(btResult.dividend_method).label }}</span></span><strong class="positive-text">{{ formatNumber(btResult.dividend_income) }} บาท</strong></div>
<div class="bt-kpi"><span>ลคาสดทาย</span><strong>{{ formatNumber(btResult.final_value) }} บาท</strong></div>
<div class="bt-kpi"><span>ผลตอบแทนสทธ</span><strong :class="pnlClass(btResult.net_return)">{{ (btResult.net_return * 100).toFixed(2) }}%</strong></div>
</div>
<div class="bt-meta muted-cell">Trades: {{ btResult.trades }} · วง {{ btResult.start }} {{ btResult.end }}</div>
<div class="bt-meta muted-cell">*{{ dividendFootnote(btResult.dividend_method) }}</div>
<div v-if="!btResult.leakage_guard" class="bt-meta muted-cell">คำเตือน: ผลนี้ใช้คะแนนปัจจุบันย้อนหลัง จึงเป็น descriptive non-PIT และไม่ใช่หลักฐานประสิทธิภาพกลยุทธ์</div>
<div v-if="Object.keys(btResult.holdings || {}).length" class="bt-holdings">
<strong>พอรตสดทาย:</strong>
<span v-for="(qty, sym) in btResult.holdings" :key="sym" class="theme-tag">{{ sym }} {{ qty }} หุ้น</span>
</div>
</div>
<div v-else class="empty-research">กำหนดชวงวนแลวกด 'รัน Backtest' เพอดผล (กำไร/ขาดทนจากราคา + นผล)</div>
<div v-if="btRuns.length" class="bt-history">
<div class="section-kicker">ประวการยอนทดสอบ</div>
<table class="source-table">
<thead><tr><th>#</th><th>วง</th><th></th><th>กำไรราคา</th><th>นผล</th><th>ผลตอบแทน</th><th>นเม</th></tr></thead>
<tbody>
<tr v-for="r in btRuns.slice().reverse()" :key="r.id">
<td>{{ r.id }}</td><td>{{ r.start }} {{ r.end }} <span v-if="r.leakage_guard === false" class="status-tag warning-tag" title="ใช้คะแนนปัจจุบันย้อนหลัง ไม่ใช่ point-in-time">descriptive non-PIT</span></td>
<td>{{ formatNumber(r.capital) }}</td>
<td :class="pnlClass(r.price_pnl)">{{ formatNumber(r.price_pnl) }}</td>
<td class="positive-text">{{ formatNumber(r.dividend_income) }}<span class="status-tag" :class="dividendBadge(r.dividend_method).cls" :style="dividendBadge(r.dividend_method).style || undefined" style="margin-left:4px" :title="dividendFootnote(r.dividend_method)">{{ dividendBadge(r.dividend_method).label }}</span></td>
<td :class="pnlClass(r.net_return)">{{ (r.net_return * 100).toFixed(2) }}%</td>
<td class="muted-cell">{{ r.ran_at ? formatDate(r.ran_at) : '—' }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- 05 / นทกการวเคราะห removed per user (redundant with theme-panel narratives) -->
</template>
</main>
<!-- Per-symbol analysis detail modal -->
<div v-if="selectedSymbol" class="modal-overlay" @click.self="closeSymbolDetail">
<div class="modal-card">
<div class="modal-head">
<div>
<div class="modal-kicker">การวเคราะหรายห</div>
<h3>{{ selectedSymbol }}</h3>
</div>
<button class="modal-close" @click="closeSymbolDetail"></button>
</div>
<div v-if="symbolLoading" class="empty-research">กำลังโหลดการวิเคราะห์</div>
<div v-else-if="symbolDetail?.error" class="state-card error-state">{{ symbolDetail.error }}</div>
<div v-else-if="symbolDetail" class="modal-body">
<div class="modal-section">
<div class="modal-section-title">มทเกยวของ (คะแนนตอธ)</div>
<div v-if="symbolDetail.themes?.length" class="modal-themes">
<div v-for="c in symbolDetail.theme_contributions" :key="c.theme" class="contrib-line">
<span class="contrib-name">{{ c.label_th || themeLabelById[c.theme] || c.theme }}</span>
<span v-if="c.surprise != null" class="contrib-calc">
<em>{{ formatNumber(c.surprise) }}σ</em> × ณภาพ <em>{{ c.quality }}</em> = <strong>{{ formatNumber(c.theme_score) }}σ</strong>
</span>
<strong v-else class="muted-cell">งไมอม</strong>
</div>
<div class="modal-sub">คะแนนธ = าเฉลยของ (surprise × ณภาพห) นนอยใน</div>
</div>
<div v-else class="muted-cell">นนงไมไดดอยในธมใด (จะอปเดตเมอเพมธ)</div>
</div>
<div class="modal-section">
<div class="modal-section-title">ลคาพนฐาน (Siamchart)</div>
<div class="fund-grid">
<span>P/E <strong>{{ symbolDetail.fundamentals?.pe ?? '—' }}</strong></span>
<span>EPS <strong>{{ symbolDetail.fundamentals?.eps ?? '—' }}</strong></span>
<span>P/BV <strong>{{ symbolDetail.fundamentals?.pbv ?? '—' }}</strong></span>
<span>ROE <strong>{{ symbolDetail.fundamentals?.roe ?? '—' }}</strong></span>
<span>นผล <strong>{{ symbolDetail.fundamentals?.is_dividend ? 'จ่าย' : '—' }}</strong></span>
</div>
<div class="modal-sub">ภาพรวม: {{ symbolDetail.company_name || selectedSymbol }}</div>
</div>
<div class="modal-section">
<div class="modal-section-title">นตอนการคำนวณคะแนนรวม</div>
<div class="calc-box">
<div class="calc-line">{{ symbolDetail.combined_formula }}</div>
<div v-for="item in symbolDetail.combined_calc" :key="item.label" class="calc-step">
<div class="calc-step-head"><span>{{ item.label }}</span><strong>{{ formatNumber(item.value) }} × {{ item.weight }}</strong></div>
<div class="calc-step-note">{{ item.note }}</div>
</div>
<div v-if="symbolDetail.siamchart_z_note" class="calc-z">
คะแนนพื้นฐานได้จาก z-score: z = (ค่า{{ symbolDetail.siamchart_z_note.raw_i }} ค่าเฉลี่ย {{ symbolDetail.siamchart_z_note.population_mean }}) / ค่าเบี่ยงเบน {{ symbolDetail.siamchart_z_note.population_stdev }}<br/>เทยบก {{ symbolDetail.siamchart_z_note.universe_size }} นใน SET50
</div>
</div>
<div class="modal-sub">ราคาลาส: {{ symbolDetail.price?.latest != null ? formatNumber(symbolDetail.price.latest) : '—' }} ({{ symbolDetail.price?.date || '—' }})</div>
</div>
</div>
</div>
</div>
</div>
</template>