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.
892 lines
48 KiB
Vue
892 lines
48 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('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>
|