- define missing design tokens (--card/--foreground/--accent/--font/--text-2) that theme/modal components referenced but :root never declared (they rendered transparent/wrong color) - zero section-panel padding so .signal-header is the single top-spacing source (was double 22px+22px on theme/lineage/health/sim/backtest panels) - theme cards now surface the new source reads (banks rate, retail sales YoY, nonbank household debt, property prices, telecom business confidence)
921 lines
53 KiB
Vue
921 lines
53 KiB
Vue
<script setup>
|
||
import { computed, onMounted, ref } from 'vue'
|
||
|
||
const summary = ref(null)
|
||
const observations = ref(null)
|
||
const signalData = ref(null)
|
||
const factorData = ref(null)
|
||
const 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('')
|
||
const btEnd = ref('')
|
||
const btCapital = ref(1000000)
|
||
const btFreq = ref('event')
|
||
const btLoading = ref(false)
|
||
const btResult = ref(null)
|
||
const btRuns = ref([])
|
||
// strict PIT backtest readiness (Task 1)
|
||
const btReadiness = ref(null)
|
||
const btUseLedger = ref(true)
|
||
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 ?? [])
|
||
// data scheduler source-health log (refresh cadence + failure diagnosis)
|
||
const sourceHealth = ref([])
|
||
const sourceHealthLoaded = ref(false)
|
||
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 {
|
||
simResult.value = await fetchJson('/api/v1/suggestion', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ capital: Number(simCapital.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 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 loadBacktestReadiness() {
|
||
try {
|
||
const body = await fetchJson('/api/v1/backtest/readiness')
|
||
btReadiness.value = body
|
||
// apply recommended defaults only when the user hasn't already picked dates
|
||
if (!btStart.value && body.recommended_start) btStart.value = body.recommended_start
|
||
if (!btEnd.value && body.recommended_end) btEnd.value = body.recommended_end
|
||
} catch { btReadiness.value = null }
|
||
}
|
||
|
||
async function loadSourceHealth() {
|
||
try {
|
||
const body = await fetchJson('/api/v1/scheduler/sources')
|
||
sourceHealth.value = body.sources || []
|
||
} catch { sourceHealth.value = [] }
|
||
sourceHealthLoaded.value = true
|
||
}
|
||
|
||
const healthCategoryLabel = (cat) => ({
|
||
ok: 'ปกติ', network: 'เครือข่ายขัดข้อง', timeout: 'หมดเวลา',
|
||
http: 'HTTP error', parse: 'รูปแบบข้อมูลผิด', structure: 'หน้าเว็บเปลี่ยนโครงสร้าง',
|
||
auth: 'สิทธิ์/ยืนยันตัวตน', other: 'อื่น ๆ',
|
||
})[cat] || cat
|
||
|
||
async function copyHealthLine(entry) {
|
||
const line = `[${entry.at}] ${entry.label} (${entry.key}) — ${entry.ok ? 'OK' : 'FAIL: ' + healthCategoryLabel(entry.category)} ${entry.detail ? '| ' + entry.detail : ''}`
|
||
try {
|
||
await navigator.clipboard.writeText(line)
|
||
notice.value = `คัดลอกสาเหตุของ ${entry.key} แล้ว`
|
||
} catch {
|
||
notice.value = line // fallback: show raw text as a notice
|
||
}
|
||
}
|
||
|
||
function appendHealthRationale(entry) {
|
||
return entry.ok ? '' : ` (สาเหตุน่าจะ: ${healthCategoryLabel(entry.category)})`
|
||
}
|
||
|
||
async function runBacktest() {
|
||
btLoading.value = true
|
||
btResult.value = null
|
||
try {
|
||
btResult.value = await fetchJson('/api/v1/backtest/run', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
start: btStart.value, end: btEnd.value,
|
||
capital: Number(btCapital.value), use_ledger: btUseLedger.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/run')).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 Promise.all([loadBacktestRuns(), loadBacktestReadiness(), loadSourceHealth()]) })
|
||
</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="#health"><span class="nav-glyph">⛨</span>สถานะข้อมูล</a>
|
||
<a class="nav-item" href="#research-run"><span class="nav-glyph">✓</span>งานวิจัย</a>
|
||
<a class="nav-item" href="#suggestion"><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 v-else-if="theme.id === 'banks' && theme.read.interest_rate_pct != null" class="theme-read-value">ดอกเบี้ย {{ formatNumber(theme.read.interest_rate_pct) }}%</div>
|
||
<div v-else-if="theme.id === 'banks' && theme.read.bank_npl_pct != null" class="theme-read-value">NPL ภาคการเงิน {{ formatNumber(theme.read.bank_npl_pct) }}%</div>
|
||
<div v-else-if="(theme.id === 'retail' || theme.id === 'consumer_staples') && theme.read.retail_sales_yoy != null" class="theme-read-value">ยอดขายปลีก {{ formatNumber(theme.read.retail_sales_yoy) }}% YoY</div>
|
||
<div v-else-if="(theme.id === 'retail' || theme.id === 'consumer_staples') && theme.read.consumer_confidence != null" class="theme-read-value">เชื่อมั่นผู้บริโภค {{ formatNumber(theme.read.consumer_confidence, 1) }}</div>
|
||
<div v-else-if="theme.id === 'nonbank_finance' && theme.read.consumer_credit != null" class="theme-read-value">สินเชื่อผู้บริโภค {{ formatNumber(theme.read.consumer_credit / 1e6, 2) }} ล้านลบ.</div>
|
||
<div v-else-if="theme.id === 'nonbank_finance' && theme.read.household_debt_gdp != null" class="theme-read-value">หนี้ครัวเรือน {{ formatNumber(theme.read.household_debt_gdp) }}% GDP</div>
|
||
<div v-else-if="theme.id === 'property' && theme.read.property_prices_yoy != null" class="theme-read-value">ราคาอสังหา {{ formatNumber(theme.read.property_prices_yoy) }}% YoY</div>
|
||
<div v-else-if="theme.id === 'telecom_it' && theme.read.business_confidence != null" class="theme-read-value">เชื่อมั่นธุรกิจ {{ formatNumber(theme.read.business_confidence, 1) }}</div>
|
||
<div v-else-if="theme.id === 'healthcare' && theme.read.consumption_yoy != null" class="theme-read-value">บริโภค {{ formatNumber(theme.read.consumption_yoy) }}% YoY</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>
|
||
|
||
<section class="panel health-panel" id="health">
|
||
<div class="panel-header signal-header">
|
||
<div>
|
||
<div class="section-kicker">สถานะการดึงข้อมูล</div>
|
||
<h2>Log — สถานะแหล่งข้อมูล</h2>
|
||
<p class="panel-subtitle">ผลการดึงข้อมูลครั้งล่าสุดของแต่ละแหล่ง โดยระบบวิเคราะห์สาเหตุให้อัตโนมัติ (เครือข่าย / หมดเวลา / หน้าเว็บเปลี่ยนโครงสร้าง / รูปแบบข้อมูล เป็นต้น) — กดปุ่ม "คัดลอก" เพื่อ copy สาเหตุไปแจ้ง/ตรวจสอบได้ทันที.</p>
|
||
</div>
|
||
</div>
|
||
<div v-if="sourceHealth.length === 0" class="empty-research muted-cell">ยังไม่มี log — รอรอบ refresh ถัดไป (ปกติ ~ทุกวันสำหรับราคา, ~รายเดือน/ไตรมาสสำหรับปัจจัย).</div>
|
||
<div v-else>
|
||
<table class="source-table">
|
||
<thead><tr><th>แหล่ง</th><th>ผลลัพธ์</th><th>สาเหตุ</th><th>เวลา</th><th></th></tr></thead>
|
||
<tbody>
|
||
<tr v-for="(e, i) in sourceHealth.slice(0, 20)" :key="i">
|
||
<td class="source-name">{{ e.label }}<div class="muted-cell" style="font-size:11px">{{ e.key }}</div></td>
|
||
<td>
|
||
<span v-if="e.ok" class="status-tag" style="background:#1a7f37;color:#fff">OK</span>
|
||
<span v-else class="status-tag warning-tag">FAIL</span>
|
||
</td>
|
||
<td>
|
||
<template v-if="e.ok">—</template>
|
||
<template v-else>
|
||
<div>{{ healthCategoryLabel(e.category) }}{{ appendHealthRationale(e) }}</div>
|
||
<div v-if="e.detail" class="muted-cell" style="font-size:11px;word-break:break-word">{{ e.detail.slice(0, 160) }}</div>
|
||
</template>
|
||
</td>
|
||
<td class="muted-cell">{{ e.at ? formatDate(e.at) : '—' }}</td>
|
||
<td>
|
||
<button v-if="!e.ok" class="primary-btn" style="padding:2px 8px" @click="copyHealthLine(e)">คัดลอกสาเหตุ</button>
|
||
</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="suggestion">
|
||
<div class="panel-header signal-header">
|
||
<div>
|
||
<div class="section-kicker">คำแนะนำการลงทุน</div>
|
||
<h2>จัดสรรทุน (Suggestion)</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>
|
||
<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="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 ณ วันที่เริ่ม ลงทุน และปรับพอร์ตตามข้อมูลที่เผยแพร่ใหม่ (event-driven) จนถึงวันสิ้นสุด — สรุปกำไร/ขาดทุนจากราคา + เงินปันผล. มีค่าธรรมเนียม 0.3% ต่อรายการ และปันผลเข้าบัญชีใน 30 วันหลัง ex-date.</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 class="checkbox-label" style="display:flex;align-items:center;gap:6px">
|
||
<input type="checkbox" v-model="btUseLedger" /> ใช้ ledger ปันผลตามวันที่จริง
|
||
</label>
|
||
<button class="primary-btn" :disabled="btLoading || (btReadiness && !btReadiness.ready)" @click="runBacktest">{{ btLoading ? 'กำลังย้อนทดสอบ…' : 'รัน Backtest' }}</button>
|
||
</div>
|
||
|
||
<div v-if="btReadiness && !btReadiness.ready" class="state-card warning-state">
|
||
<strong>ยังรันย้อนทดสอบแบบ strict PIT ไม่ได้ — ขาดข้อมูล coverage:</strong>
|
||
<div class="muted-cell" style="margin-top:4px">{{ (btReadiness.missing || []).slice(0, 8).join(', ') }}{{ (btReadiness.missing || []).length > 8 ? '…' : '' }}</div>
|
||
<div class="muted-cell" style="margin-top:2px">วันเริ่มที่แนะนำ: {{ btReadiness.recommended_start || '—' }} · วันสิ้นสุด: {{ btReadiness.recommended_end || '—' }}</div>
|
||
</div>
|
||
|
||
<div v-if="btResult?.error" class="state-card error-state">{{ btResult.error }}</div>
|
||
<div v-else-if="btResult && !btResult.error" class="backtest-results">
|
||
<div class="bt-kpi-grid">
|
||
<div class="bt-kpi"><span>กำไรจากราคา (realized)</span><strong :class="pnlClass(btResult.realized_trading_pnl)">{{ formatNumber(btResult.realized_trading_pnl) }} บาท</strong></div>
|
||
<div class="bt-kpi"><span>กำไรจากราคา (unrealized)</span><strong :class="pnlClass(btResult.unrealized_trading_pnl)">{{ formatNumber(btResult.unrealized_trading_pnl) }} บาท</strong></div>
|
||
<div class="bt-kpi"><span>เงินปันผลที่ได้รับ</span><strong class="positive-text">{{ formatNumber(btResult.dividend_cash_received) }} บาท</strong></div>
|
||
<div class="bt-kpi"><span>ค่าธรรมเนียม (0.3%)</span><strong class="negative-text">–{{ formatNumber(btResult.transaction_costs) }} บาท</strong></div>
|
||
<div class="bt-kpi"><span>เงินปันผลค้างรับ</span><strong>{{ formatNumber(btResult.dividend_receivable) }} บาท</strong></div>
|
||
<div class="bt-kpi"><span>มูลค่าสุดท้าย (equity)</span><strong>{{ formatNumber(btResult.final_equity) }} บาท</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">Rebalances: {{ btResult.rebalances }} · ปันผลตาม: {{ btResult.dividend_timing }} · ช่วง {{ btResult.start }} → {{ btResult.end }}</div>
|
||
<div v-if="btResult.leakage_guard" class="bt-meta">✅ strict PIT (leakage guard active)</div>
|
||
<div v-else class="bt-meta muted-cell">คำเตือน: ไม่ได้พิสูจน์ point-in-time (non-PIT)</div>
|
||
<div v-if="btResult.holdings && btResult.holdings.length" class="bt-holdings">
|
||
<strong>พอร์ตสุดท้าย:</strong>
|
||
<table class="source-table" style="margin-top:6px">
|
||
<thead><tr><th>หุ้น</th><th>จำนวน</th><th>ต้นทุนเฉลี่ย</th><th>ราคาล่าสุด</th><th>มูลค่า</th><th>กำไร unrealized</th></tr></thead>
|
||
<tbody>
|
||
<tr v-for="h in btResult.holdings" :key="h.symbol">
|
||
<td class="muted-cell">{{ h.symbol }}</td>
|
||
<td>{{ h.qty }}</td>
|
||
<td>{{ formatNumber(h.average_cost, 2) }}</td>
|
||
<td>{{ formatNumber(h.last_price, 2) }}</td>
|
||
<td>{{ formatNumber(h.market_value) }}</td>
|
||
<td :class="pnlClass(h.unrealized_pnl)">{{ formatNumber(h.unrealized_pnl) }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</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>
|