[verified] Rebuild dashboard: real multi-theme (3 themes + macro + sources table) — user 10-point review

- Remove obsolete KPIs (Theme surprise card, Paper ledger, คุณภาพข้อมูล, ประตู backtest) -> now 2 cards: สัญญาณ (long/short/neutral) + แหล่งข้อมูล
- Theme panel: 3 themes with uniform surprise (0.57/0.81/1.62σ) + per-theme thesis (#8,#10)
- Add macro backdrop panel (การบริโภค/การลงทุน/เงินเฟ้อ/การว่างงาน/นักท่องเที่ยว) from BOT Thai Economy (#6)
- Add sources lineage table (ที่มาข้อมูล: ข้อมูล/แหล่ง/ช่วง/อัปเดต) (#9)
- Merge signal+fundamental into one table with combined column (#7)
- Live verified via /api/v1/dashboard (real data, 49-symbol board)
This commit is contained in:
Kunthawat Greethong
2026-08-25 20:39:43 +07:00
parent 9739849f68
commit 489920b3c9
2 changed files with 103 additions and 41 deletions

View File

@@ -6,6 +6,7 @@ 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 simMode = ref('backtest')
const simLoading = ref(false)
@@ -35,6 +36,24 @@ 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 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 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)
@@ -218,12 +237,13 @@ async function loadDashboard() {
loading.value = true
error.value = ''
try {
const [summaryBody, observationBody, signalBody, factorBody, themeBody, ledgerBody, sessionBody, backtestBody, researchBody] = await Promise.all([
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(),
@@ -234,6 +254,7 @@ async function loadDashboard() {
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'
@@ -371,29 +392,18 @@ onMounted(loadDashboard)
<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 ? 'สูงกว่า' : 'ต่ำกว่า' }} าตามฤดกาล</div>
</article>
<article class="kpi-card">
<div class="kpi-label">ญญาณทใชงาน</div>
<div class="kpi-value">{{ summary.signal_summary.total }}</div>
<div class="kpi-foot"><span class="long-count">{{ summary.signal_summary.long }} </span> · <span class="short-count">{{ summary.signal_summary.short }} ขาย</span></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">สมดบ paper</div>
<div class="kpi-value">{{ ledger.entries.length }}</div>
<div class="kpi-foot">ไมการเชอมต external</div>
</article>
<article class="kpi-card">
<div class="kpi-label">ณภาพขอม</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' }}</div>
<div class="kpi-label">แหลงขอมใช</div>
<div class="kpi-value">{{ sourceCount }}</div>
<div class="kpi-foot">ลจรงจากแหลงไทย {{ realData? '(จริง)' : '—' }}</div>
</article>
</section>
@@ -407,32 +417,35 @@ onMounted(loadDashboard)
<span class="status-tag">รวม {{ combinedCount }} symbols</span>
</div>
<div class="theme-grid">
<article v-for="theme in themes" :key="theme.id" class="theme-card">
<article v-for="theme in dashboardThemes" :key="theme.id" class="theme-card">
<div class="theme-card-head">
<span class="theme-chip">{{ theme.frequency }}</span>
<span class="theme-label-en">{{ theme.label_en }}</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-label-th">{{ theme.label_th }}</div>
<div class="theme-source">{{ theme.source }}</div>
<div class="theme-read">
<template v-if="theme.read && !theme.read.error">
<div v-if="theme.id === 'tourism'">
<div class="theme-read-value">{{ theme.read.surprise != null ? '+' + formatNumber(theme.read.surprise, 2) + 'σ' : '—' }}</div>
<div class="theme-read-meta">theme surprise · as of {{ theme.read.as_of }}</div>
</div>
<div v-else-if="theme.id === 'auto_credit'">
<div class="theme-read-value">{{ theme.read.new_car_sales_yoy != null ? (theme.read.new_car_sales_yoy >= 0 ? '+' : '') + formatNumber(theme.read.new_car_sales_yoy) + '%' : '—' }}</div>
<div class="theme-read-meta">ยอดขายรถ YoY · {{ theme.read.total_vehicle_sales ? formatNumber(theme.read.total_vehicle_sales, 0) + ' คัน' : '' }}</div>
</div>
<div v-else-if="theme.id === 'refining_energy'">
<div class="theme-read-value">{{ theme.read.net_profit != null ? formatNumber(theme.read.net_profit / 1000, 1) + ' พันล้าน' : '—' }}</div>
<div class="theme-read-meta">TOP กำไรสทธ (านบาท) · {{ theme.read.as_of }}</div>
</div>
</template>
<div v-else class="theme-read-error">{{ theme.read?.error || 'ไม่พบข้อมูล' }}</div>
<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.thesis" class="theme-thesis">{{ theme.thesis }}</div>
</article>
</div>
<div v-if="Object.keys(dashboardMacro).length" class="macro-panel">
<div class="section-kicker">ภาพรวมประเทศไทย (macro)</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">
@@ -440,7 +453,7 @@ onMounted(loadDashboard)
<div>
<div class="section-kicker">Alternative factors × SET50</div>
<h2>ตารางห</h2>
<p class="panel-subtitle">ลคาพนฐานจาก Siamchart รวมกบสญญาณ tourism คลกหวตารางเพอเร; เป <em>เฉพาะหนปนผล</em> เพอกรองหนทายปนผล.</p>
<p class="panel-subtitle">ตารางเดยวรวมทกธ ญญาณ + คะแนนรวม (60% / 40% นฐาน) + ลคาพนฐานจาก Siamchart. เรยงไดโดยคลกหวตารา; เป <em>เฉพาะหนปนผล</em> เพอกรองหนทายปนผล.</p>
</div>
<div class="stock-controls">
<label class="toggle-filter">
@@ -456,6 +469,7 @@ onMounted(loadDashboard)
<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')">รวม {{ sortIndicator('combined') }}</th>
<th class="sortable" :class="{ active: sortKey === 'symbol' }" @click="setSort('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>
@@ -468,6 +482,7 @@ onMounted(loadDashboard)
<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 class="combined-cell">{{ boardBySymbol[factor.symbol]?.combined != null ? formatNumber(boardBySymbol[factor.symbol].combined) : '—' }}</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>
@@ -481,6 +496,34 @@ onMounted(loadDashboard)
</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">{{ sourceCount }} แหล</span>
</div>
<div class="table-wrap">
<table class="source-table">
<thead>
<tr>
<th>อม</th><th>แหล</th><th>วงขอม</th><th>ปเดต</th>
</tr>
</thead>
<tbody>
<tr v-for="(s, i) in dashboardSources" :key="i">
<td>{{ s['จาก'] }}</td>
<td class="source-name">{{ s['แหล่ง'] }}</td>
<td class="muted-cell">{{ s['ข้อมูล'] }}</td>
<td class="muted-cell">{{ s['dึงมาเมื่อ'] }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="hero-grid" id="factors">
<article class="panel pulse-panel">
<div class="panel-header">

View File

@@ -125,6 +125,25 @@ tbody tr:hover { background: rgba(255,255,255,.025); }
@media (max-width: 720px) { .theme-grid { grid-template-columns: 1fr; } }
/* ---- Rebuilt theme panel: surprise + thesis ---- */
.theme-surprise { display: flex; flex-direction: column; gap: 2px; margin: 8px 0 6px; }
.theme-surprise-label { font-size: 10px; color: var(--faint); }
.theme-surprise-value { font-size: 22px; font-weight: 700; color: var(--accent); }
.theme-thesis { font-size: 11px; color: var(--text-2, #99a); border-top: 1px dashed var(--line-bright); padding-top: 6px; margin-top: 6px; }
.macro-panel { margin-top: 14px; border-top: 1px solid var(--line-bright); padding-top: 12px; }
.macro-chips { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.macro-chip { background: var(--card); border: 1px solid var(--line-bright); border-radius: 6px; padding: 6px 9px; font-size: 11px; }
.macro-chip strong { color: var(--mint); }
/* ---- Lineage / sources table ---- */
.lineage-panel { margin-top: 18px; }
.source-table { width: 100%; border-collapse: collapse; }
.source-table th { text-align: left; font: 700 10px 'DM Mono', monospace; color: var(--faint); padding: 6px 8px; border-bottom: 1px solid var(--line-bright); }
.source-table td { padding: 7px 8px; font-size: 12px; border-bottom: 1px solid var(--line-weak, rgba(255,255,255,.04)); }
.source-name { color: var(--accent); }
.combined-cell { color: var(--mint); font-weight: 600; font-family: 'DM Mono', monospace; }
/* ---- Simulation panel ---- */
.sim-panel { margin-top: 18px; }
.sim-controls { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 14px; }