[verified] Fix dashboard: real-data badge + sortable คะแนนรวม + modal calc steps

- badge now driven by /api/v1/dashboard availability (ข้อมูลจริงจากแหล่งไทย), not stale fixture summary (#request)
- factorValue handles 'combined' -> คะแนนรวม column now sorts + toggles asc/desc
- symbol modal shows full calculation: combined_formula, per-step calc (ธีม/พื้นฐาน + weights), z-score derivation with population mean/stdev/universe size
- panel-header layout fixes (full-width subtitle, header breathing room)
- Verified: badge real, sort 0.11->1.43, modal calc steps render
This commit is contained in:
Kunthawat Greethong
2026-08-26 06:22:52 +07:00
parent 225ef2cb9c
commit 6c26bf99dc
3 changed files with 59 additions and 11 deletions

View File

@@ -17,6 +17,7 @@ were the same timestamp — see `frequency` on each scored theme.
from __future__ import annotations from __future__ import annotations
import statistics
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
@@ -200,8 +201,22 @@ def symbol_breakdown(
d = fac.get("dividend_yield") or 0.0 d = fac.get("dividend_yield") or 0.0
g = float(g) if g is not None else 0.0 g = float(g) if g is not None else 0.0
raw_siamchart = g + d * 2.0 raw_siamchart = g + d * 2.0
# z-score against the full universe (same as build_siamchart_score)
siamchart_score = build_siamchart_score(factor_view).get(symbol, 0.0) # z-score against the full universe (same as build_siamchart_score); capture
# the population stats so the view can show HOW -2.8 became -0.588.
siamchart_map = build_siamchart_score(factor_view)
siamchart_score = siamchart_map.get(symbol, 0.0)
# recompute the population of raw scores to expose mean / stdev
raw_values = []
for f in factor_view.get("factors", []):
if not f.get("symbol"):
continue
gg = f.get("eps_growth_yoy")
dd = f.get("dividend_yield") or 0.0
gg = float(gg) if gg is not None else 0.0
raw_values.append(gg + dd * 2.0)
pop_mean = statistics.mean(raw_values) if raw_values else 0.0
pop_stdev = statistics.pstdev(raw_values) if raw_values else 0.0
combined = round(weight_theme * theme_score + weight_siamchart * siamchart_score, 3) combined = round(weight_theme * theme_score + weight_siamchart * siamchart_score, 3)
@@ -217,7 +232,22 @@ def symbol_breakdown(
"dividend_yield": fac.get("dividend_yield"), "dividend_yield": fac.get("dividend_yield"),
"raw_growth_plus_yield_2x": round(raw_siamchart, 3), "raw_growth_plus_yield_2x": round(raw_siamchart, 3),
}, },
"siamchart_z_note": {
"formula": "z = (raw_i - mean) / stdev",
"raw_i": round(raw_siamchart, 3),
"population_mean": round(pop_mean, 3),
"population_stdev": round(pop_stdev, 3),
"universe_size": len(raw_values),
},
"combined_calc": [
{"label": "คะแนนธีม", "value": round(theme_score, 3), "weight": weight_theme,
"note": "ค่าเฉลี่ยของ theme surprise ที่หุ้นนี้อยู่ใน", "term": f"{theme_score:.3f} × {weight_theme}"},
{"label": "คะแนนพื้นฐาน", "value": round(siamchart_score, 3), "weight": weight_siamchart,
"note": f"z-score ของ ((EPS growth {g:+.1f}%) + ปันผล {d:.1f}%×2) เมื่อเทียบทั้ง universe",
"term": f"{siamchart_score:.3f} × {weight_siamchart}"},
],
"combined_score": combined, "combined_score": combined,
"combined_formula": f"({theme_score:.3f} × {weight_theme}) + ({siamchart_score:.3f} × {weight_siamchart}) = {combined}",
"weights": {"theme": weight_theme, "siamchart": weight_siamchart}, "weights": {"theme": weight_theme, "siamchart": weight_siamchart},
"fundamentals": { "fundamentals": {
"pe": fac.get("pe"), "pe": fac.get("pe"),

View File

@@ -93,6 +93,7 @@ const filteredFactorRows = computed(() => {
// Numeric accessor used for sorting columns. // Numeric accessor used for sorting columns.
function factorValue(row, key) { function factorValue(row, key) {
if (key === 'signal_score') return row.signal_score ?? (row.signal_side === 'LONG' ? 9999 : 0) 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 === 'symbol') return row.symbol
if (key === 'dividend_yield') return row.dividend_yield ?? -1 if (key === 'dividend_yield') return row.dividend_yield ?? -1
if (key === 'eps_growth_yoy') return row.eps_growth_yoy ?? -1 if (key === 'eps_growth_yoy') return row.eps_growth_yoy ?? -1
@@ -418,7 +419,7 @@ onMounted(loadDashboard)
<p class="subtitle">ภาพรวม alternative factors ไทย ไปจนถงสญญาณลงทนทอธบายได research + paper only</p> <p class="subtitle">ภาพรวม alternative factors ไทย ไปจนถงสญญาณลงทนทอธบายได research + paper only</p>
</div> </div>
<div class="topbar-meta"> <div class="topbar-meta">
<div class="freshness-pill"><span class="freshness-dot"></span>{{ summary?.data_health?.source_mode === 'bot' ? 'ข้อมูลจริง BOT' : 'ข้อมูลจำลอง (fixture)' }}</div> <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 class="as-of">อม {{ summary?.as_of || '—' }}</div>
</div> </div>
</header> </header>
@@ -679,14 +680,17 @@ onMounted(loadDashboard)
</div> </div>
<div class="modal-section"> <div class="modal-section">
<div class="modal-section-title">มาของคะแนนรวม (โปรงใส)</div> <div class="modal-section-title">นตอนการคำนวณคะแนนรวม</div>
<table class="score-table"> <div class="calc-box">
<tbody> <div class="calc-line">{{ symbolDetail.combined_formula }}</div>
<tr><td>คะแนนธ ( {{ symbolDetail.themes?.length || 0 }} )</td><td>{{ formatNumber(symbolDetail.theme_score) }}</td><td>× {{ symbolDetail.weights?.theme }}</td></tr> <div v-for="item in symbolDetail.combined_calc" :key="item.label" class="calc-step">
<tr><td>คะแนนพนฐาน (EPS + yield)</td><td>{{ formatNumber(symbolDetail.siamchart_score) }}</td><td>× {{ symbolDetail.weights?.siamchart }}</td></tr> <div class="calc-step-head"><span>{{ item.label }}</span><strong>{{ formatNumber(item.value) }} × {{ item.weight }}</strong></div>
<tr class="score-total"><td>คะแนนรวม = 60%× + 40%×นฐาน</td><td>{{ formatNumber(symbolDetail.combined_score) }}</td><td></td></tr> <div class="calc-step-note">{{ item.note }}</div>
</tbody> </div>
</table> <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 class="modal-sub">ราคาลาส: {{ symbolDetail.price?.latest != null ? formatNumber(symbolDetail.price.latest) : '—' }} ({{ symbolDetail.price?.date || '—' }})</div>
</div> </div>
</div> </div>

View File

@@ -56,6 +56,8 @@ h2 { margin-top: 7px; font-size: 17px; letter-spacing: -.025em; }
.subtitle { max-width: 560px; margin-top: 12px; color: var(--muted); font-size: 13px; line-height: 1.7; } .subtitle { max-width: 560px; margin-top: 12px; color: var(--muted); font-size: 13px; line-height: 1.7; }
.topbar-meta { text-align: right; color: var(--muted); font: 10px 'DM Mono', monospace; } .topbar-meta { text-align: right; color: var(--muted); font: 10px 'DM Mono', monospace; }
.freshness-pill { display: inline-flex; align-items: center; gap: 8px; padding: 7px 10px; border: 1px solid rgba(82,214,189,.25); border-radius: 99px; color: var(--mint); background: var(--mint-soft); } .freshness-pill { display: inline-flex; align-items: center; gap: 8px; padding: 7px 10px; border: 1px solid rgba(82,214,189,.25); border-radius: 99px; color: var(--mint); background: var(--mint-soft); }
.freshness-pill.pill-fixture { border-color: rgba(230,185,108,.35); color: var(--amber); background: var(--amber-soft); }
.freshness-pill.pill-fixture .freshness-dot { background: var(--amber); box-shadow: 0 0 12px var(--amber); }
.as-of { margin-top: 10px; } .as-of { margin-top: 10px; }
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 12px; } .kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 12px; }
@@ -93,6 +95,9 @@ h2 { margin-top: 7px; font-size: 17px; letter-spacing: -.025em; }
.signal-panel { padding: 0; overflow: hidden; } .signal-panel { padding: 0; overflow: hidden; }
.signal-header { padding: 22px; border-bottom: 1px solid var(--line); } .signal-header { padding: 22px; border-bottom: 1px solid var(--line); }
.signal-header > div:first-child { flex: 1 1 auto; min-width: 0; }
.stock-panel .signal-header { align-items: center; flex-wrap: wrap; }
.stock-panel .panel-subtitle { max-width: 620px; }
.strategy-meta { color: var(--faint); font: 10px 'DM Mono', monospace; } .strategy-meta { color: var(--faint); font: 10px 'DM Mono', monospace; }
.strategy-meta span { color: var(--line-bright); padding: 0 5px; } .strategy-meta span { color: var(--line-bright); padding: 0 5px; }
.table-wrap { overflow-x: auto; } .table-wrap { overflow-x: auto; }
@@ -172,6 +177,15 @@ tbody tr:hover { background: rgba(255,255,255,.025); }
.score-table td { padding: 7px 6px; border-bottom: 1px solid var(--line-weak, rgba(255,255,255,.05)); } .score-table td { padding: 7px 6px; border-bottom: 1px solid var(--line-weak, rgba(255,255,255,.05)); }
.score-table tr.score-total td { border-top: 1px solid var(--line-bright); font-weight: 700; color: var(--mint); } .score-table tr.score-total td { border-top: 1px solid var(--line-bright); font-weight: 700; color: var(--mint); }
/* calculation steps in the symbol modal */
.calc-box { background: rgba(0,0,0,.25); border: 1px solid var(--line-bright); border-radius: 8px; padding: 12px; }
.calc-line { font: 700 14px 'DM Mono', monospace; color: var(--mint); padding: 6px 0 10px; border-bottom: 1px dashed var(--line-bright); margin-bottom: 8px; }
.calc-step { padding: 6px 0; border-bottom: 1px solid var(--line-weak, rgba(255,255,255,.05)); }
.calc-step-head { display: flex; justify-content: space-between; font-size: 13px; }
.calc-step-head strong { color: var(--text); font-family: 'DM Mono', monospace; }
.calc-step-note { font-size: 11px; color: var(--faint); margin-top: 3px; line-height: 1.5; }
.calc-z { font-size: 11px; color: var(--faint); margin-top: 8px; line-height: 1.6; }
.thesis-list { display: flex; flex-direction: column; gap: 10px; margin: 12px 0; } .thesis-list { display: flex; flex-direction: column; gap: 10px; margin: 12px 0; }
.thesis-row { display: flex; gap: 10px; align-items: baseline; padding-bottom: 8px; border-bottom: 1px solid var(--line-weak, rgba(255,255,255,.05)); } .thesis-row { display: flex; gap: 10px; align-items: baseline; padding-bottom: 8px; border-bottom: 1px solid var(--line-weak, rgba(255,255,255,.05)); }
.thesis-theme { min-width: 130px; color: var(--accent); } .thesis-theme { min-width: 130px; color: var(--accent); }