Files
set50-system/frontend/src/App.vue
Kunthawat Greethong 4b6ce2c09e [verified] Remove tourism-only sections (01 surprise pulse, 02 provenance, 03 signal table, 04 research run); thesis across 3 themes; sources show fetch date
- Removed 'เชื่อถือข้อมูลได้ไหม' (02), 'ตารางสัญญาณ' dup (03), 'หลักฐานพอจะรองรับ study' (04), tourism surprise pulse — superseded by 3-theme panel + sources table
- 05 บันทึกการวิเคราะห์ now lists thesis per theme (tourism/auto/energy) with surprise
- Sources table shows fetched-at date (ข้อ 2)
- All dashboard analysis is real (no mock); automation note: no cron yet (data fetched on-demand + daily cache)
- Verified: build + browser 8 checks pass
2026-08-25 20:53:24 +07:00

616 lines
31 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 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 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
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 === '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
}
}
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 {
simResult.value = await fetchJson('/api/v1/simulation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ capital: Number(simCapital.value), mode: simMode.value }),
})
} catch (caught) {
notice.value = caught.message
} finally {
simLoading.value = false
}
}
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
} 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>ภาพรวม</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"><span class="freshness-dot"></span>{{ summary?.data_health?.source_mode === 'bot' ? 'ข้อมูลจริง BOT' : 'ข้อมูลจำลอง (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">{{ 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">Alternative factors · อมลไทย</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.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">
<div class="panel-header signal-header">
<div>
<div class="section-kicker">Alternative factors × SET50</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')">รวม {{ 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>
<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">
<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>
<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">{{ 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ึงมาเมื่อ'] ? 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">07 / การจำลองการลงท</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>
</section>
<section class="bottom-grid">
<article class="panel thesis-panel">
<div class="section-kicker">05 / นทกการวเคราะห</div>
<h2>านสญญาณเปนสมมตฐาน (3 ).</h2>
<div class="thesis-list">
<div v-for="t in dashboardThemes" :key="t.id" class="thesis-row">
<strong class="thesis-theme">{{ t.label_th }}</strong>
<span class="thesis-text">{{ t.thesis || '—' }}</span>
<span class="thesis-surprise">{{ t.surprise != null ? formatNumber(t.surprise,2) + 'σ' : '—' }}</span>
</div>
</div>
<div class="thesis-rule"><span></span>ความตาง (surprise) × การเขาถงธ × ความเชอม</div>
</article>
</section>
</template>
</main>
</div>
</template>