Files
2026FIFAWorldCup/public/app.js

1833 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const $ = (sel) => document.querySelector(sel);
const state = {
matches: [],
analysis: null,
schedule: null,
sourceRegistry: [],
marketMatrix: null,
sharpMoney: null,
quantitative: null,
portfolio: null,
liveCenter: null,
lineMovement: null,
todayInsights: null,
quantitativeMatchId: '',
filter: 'all',
};
const CURRENT_PAGE = (document.body.dataset.page || 'home').toLowerCase();
const APP_TZ = 'Asia/Taipei';
function fmtTime(raw) {
if (!raw) return '-';
return new Date(raw).toLocaleString('zh-Hant-TW', {
timeZone: APP_TZ,
hour12: false,
});
}
function fmtDate(raw) {
if (!raw) return '-';
return new Date(raw).toLocaleDateString('zh-Hant-TW', {
timeZone: APP_TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
}
function fmtDateLabel(raw) {
if (!raw) return '';
return new Intl.DateTimeFormat('en-CA', {
timeZone: APP_TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date(raw));
}
function pct(v) {
if (!Number.isFinite(v)) return '-';
return `${(v * 100).toFixed(1)}%`;
}
function pctText(v) {
if (!Number.isFinite(v)) return '-';
return `${(v * 100).toFixed(2)}%`;
}
function ratio(v, decimals = 2) {
if (!Number.isFinite(v)) return '-';
return Number(v).toFixed(decimals);
}
function pctFromRate(v, decimals = 2) {
if (!Number.isFinite(v)) return '-';
return `${(v * 100).toFixed(decimals)}%`;
}
function classifyRecommendationTier(probability = 0, confidence = 0) {
const p = clamp(Number(probability), 0, 1);
const c = clamp(Number(confidence), 0, 1);
const score = (0.68 * p) + (0.32 * c);
if (score >= 0.72) return 'high';
if (score >= 0.54) return 'medium';
return 'low';
}
function clamp(v, min = 0, max = 1) {
if (!Number.isFinite(v)) return min;
return Math.max(min, Math.min(max, v));
}
function money(v) {
if (!Number.isFinite(v)) return '-';
return `${Number(v).toFixed(2)}`;
}
function toFixed(v, decimals = 2) {
if (!Number.isFinite(v)) return '-';
return Number(v).toFixed(decimals);
}
function safeText(v) {
if (v === undefined || v === null || v === '') return '-';
return String(v);
}
function newsSignalText(v, asPercent = false) {
if (!Number.isFinite(v) || v === 0) return '中性';
const abs = Math.abs(v);
const base = abs <= 1 ? v * 100 : v;
const direction = v > 0 ? '偏利好' : '偏保守';
return asPercent ? `${direction} ${base.toFixed(2)}%` : `${direction} ${base.toFixed(2)}`;
}
function asArray(v) {
return Array.isArray(v) ? v : [];
}
function renderStatus(text, type = 'ok') {
const status = $('#status');
if (!status) return;
status.textContent = text || '';
status.className = `status ${type}`;
}
function filterMarkets(match, filter) {
if (filter === 'all') return true;
if (!Array.isArray(match.marketSummary)) return false;
return match.marketSummary.some((s) => s.market === filter);
}
function renderSummaryCards(totalMatches) {
const el = $('#summaryCards');
if (!el) return;
const highCount = state.analysis?.highProbabilitySingles ? state.analysis.highProbabilitySingles.length : 0;
const mediumCount = state.analysis?.mediumProbabilitySingles ? state.analysis.mediumProbabilitySingles.length : 0;
const lowCount = state.analysis?.lowProbabilitySingles ? state.analysis.lowProbabilitySingles.length : 0;
const upsetCount = state.analysis?.upsetSignals ? state.analysis.upsetSignals.length : 0;
el.innerHTML = `
<article class="card">
<h3>賽事總場數</h3>
<p>${totalMatches}</p>
</article>
<article class="card">
<h3>最後更新</h3>
<p>${state.analysis ? (state.analysis.generatedAtTaipei || state.analysis.generatedAt || '-') : '-'}</p>
</article>
<article class="card">
<h3>高勝率單場</h3>
<p>${highCount}</p>
</article>
<article class="card">
<h3>中勝率單場</h3>
<p>${mediumCount}</p>
</article>
<article class="card">
<h3>低勝率單場</h3>
<p>${lowCount}</p>
</article>
<article class="card">
<h3>爆冷候選</h3>
<p>${upsetCount}</p>
</article>
<article class="card">
<h3>新聞熱點場次48h</h3>
<p>${state.schedule && state.schedule.hotNewsWithin48h ? state.schedule.hotNewsWithin48h.length : '-'}</p>
</article>
`;
}
function renderMiniBars(containerId, rows = [], maxLabelLen = 18) {
const container = $(`#${containerId}`);
if (!container) return;
if (!rows.length) {
container.innerHTML = '<p>尚無資料</p>';
return;
}
const maxValue = rows.reduce((acc, r) => Math.max(acc, Number(r.value) || 0), 0);
const safeMax = Math.max(maxValue, 1);
container.innerHTML = rows
.map((row) => {
const safeValue = Number(row.value) || 0;
const width = `${((safeValue / safeMax) * 100).toFixed(1)}%`;
const label = String(row.label || '').slice(0, maxLabelLen);
const color = row.color || 'var(--accent)';
return `
<div class="mini-bar-row">
<div class="mini-bar-label" title="${row.label || ''}">${label}</div>
<div class="mini-bar-track">
<span class="mini-bar-fill" style="width: ${width}; --bar:${color};"></span>
</div>
<div class="mini-bar-value">${safeValue}</div>
</div>
`;
})
.join('');
}
function renderRecommendRows(items = [], max = 3) {
if (!items.length) return '<li>目前無建議</li>';
return items
.slice(0, max)
.map(
(r) => `
<li>
<strong>${r.market}</strong> - ${r.selection}
<div class="muted">賠率: ${money(r.odds)} | 勝率: ${pct(r.probability)} | 信心: ${(Number(r.confidence) * 100).toFixed(0)}% | EV: ${money(r.expectedValue)} | Kelly: ${ratio(r.kellyFraction, 2)} | ${r.recommendationTier ? r.recommendationTier : 'low'}</div>
<div class="muted">模型: ${r.modelGrade || '-'} | 價值邊際: ${pctText(r.valueEdge)}</div>
<div class="muted">${r.rationale}</div>
</li>`,
)
.join('');
}
function renderUpsetRows(items = [], max = 3) {
if (!items.length) return '<li>目前無明顯爆冷信號</li>';
return items
.slice(0, max)
.map((r) => {
const risk = r.riskLabel || 'low';
const riskClass = risk === 'high' ? 'high' : risk === 'medium' ? 'medium' : 'low';
return `
<li>
<strong>${r.selection}</strong>
<span class="tier-badge ${riskClass}">${risk === 'high' ? '高風險' : risk === 'medium' ? '中風險' : '低風險'}</span>
<div class="muted">賠率: ${money(r.odds)} | 爆冷機率: ${pct(r.probability)} | 主流偏好: ${pct(r.favoriteProbability)} (${r.competitorOutcome})</div>
<div class="muted">EV: ${money(r.expectedValue)} | Kelly: ${ratio(r.kellyFraction, 2)}</div>
<div class="muted">${r.rationale}</div>
</li>`;
})
.join('');
}
function marketGroup(market = '') {
const key = String(market).toLowerCase();
if (key.includes('1x2')) return 'oneX2';
if (key.includes('double chance')) return 'doubleChance';
if (key.includes('handicap')) return 'handicap';
if (key.includes('totals')) return 'totals';
if (key.includes('both teams') || key.includes('btts')) return 'btts';
return 'other';
}
function probabilityBand(p) {
const value = Number.isFinite(p) ? p : 0;
if (value >= 0.70) return { key: 'high', label: '穩健', cls: 'risk-high' };
if (value >= 0.56) return { key: 'medium', label: '平衡', cls: 'risk-medium' };
return { key: 'low', label: '高波動', cls: 'risk-low' };
}
function safePlaybookRows(value) {
return Array.isArray(value) ? value : [];
}
function buildSinglePlaybookPool() {
const perMatch = state.analysis?.perMatch || [];
const buckets = {
all: [],
oneX2: [],
handicap: [],
totals: [],
btts: [],
doubleChance: [],
};
for (const match of perMatch) {
const source = [
match.topRecommendation ? { ...match.topRecommendation } : null,
...(match.recommendationBuckets?.high || []),
...(match.recommendationBuckets?.medium || []),
...(match.recommendationBuckets?.low || []),
].filter(Boolean);
const seen = new Set();
for (const rec of source) {
const market = rec.market || '';
const key = `${match.matchId}-${market}-${rec.selection}`;
if (seen.has(key)) continue;
seen.add(key);
const payload = {
...rec,
matchId: match.matchId,
teams: match.teams,
kickoffAt: match.kickoffAt,
recommendationTier:
rec.recommendationTier || classifyRecommendationTier(Number.isFinite(rec.probability) ? rec.probability : 0, Number.isFinite(rec.confidence) ? rec.confidence : 0),
};
buckets.all.push(payload);
const group = marketGroup(market);
if (group !== 'other' && buckets[group]) {
buckets[group].push(payload);
}
}
}
const sortByValue = (a, b) => {
const av = Number.isFinite(a.expectedRoiPercent) ? a.expectedRoiPercent : (Number.isFinite(a.expectedValue) ? a.expectedValue * 100 : -999);
const bv = Number.isFinite(b.expectedRoiPercent) ? b.expectedRoiPercent : (Number.isFinite(b.expectedValue) ? b.expectedValue * 100 : -999);
if (bv !== av) return bv - av;
return (b.confidence || 0) - (a.confidence || 0);
};
for (const k of Object.keys(buckets)) {
buckets[k] = (buckets[k] || []).sort(sortByValue);
}
return buckets;
}
function renderSingleUniverse(containerId, recs = [], max = 8) {
const el = $(`#${containerId}`);
if (!el) return;
if (!recs.length) {
el.innerHTML = '<p>目前無可用單場建議</p>';
return;
}
el.innerHTML = recs
.slice(0, max)
.map((r, idx) => {
const band = probabilityBand(Number(r.probability));
return `
<li>
<p class="playbook-row">#${idx + 1} ${r.market} ${r.selection}</p>
<p class="playbook-meta">
場次:${r.teams} · ${r.kickoffAtTaipei || fmtTime(r.kickoffAt)} ·
賠率 ${money(r.odds)} · 勝率 ${pct(r.probability)} · EV ${(Number.isFinite(r.expectedRoiPercent) ? `${r.expectedRoiPercent.toFixed(2)}%` : '-')}
<span class="tier-badge ${r.recommendationTier === 'high' ? 'high' : r.recommendationTier === 'medium' ? 'medium' : 'low'}">${r.recommendationTier || '-'}</span>
</p>
<p class="playbook-meta"><span class="risk-pill ${band.cls}">${band.label}</span> Kelly ${ratio(r.kellyFraction, 2)} · 價值邊際 ${pctText(r.valueEdge)}</p>
<p class="muted">${r.rationale || ''}</p>
</li>
`;
})
.join('');
}
function renderSinglePlaybook() {
const pb = state.analysis?.professionalPlaybook?.singles;
const pool = pb
? {
all: safePlaybookRows(pb.all),
oneX2: safePlaybookRows(pb.oneX2),
handicap: safePlaybookRows(pb.handicap),
totals: safePlaybookRows(pb.totals),
btts: safePlaybookRows(pb.btts),
doubleChance: safePlaybookRows(pb.doubleChance),
}
: buildSinglePlaybookPool();
renderSingleUniverse('singleUniverse', pool.all, 14);
renderSingleUniverse('singleOneX2', pool.oneX2, 10);
renderSingleUniverse('singleHandicap', pool.handicap, 10);
renderSingleUniverse('singleTotals', pool.totals, 10);
renderSingleUniverse('singleBtts', pool.btts, 10);
renderSingleUniverse('singleDoubleChance', pool.doubleChance, 10);
}
function rankComboByStyle(rows = [], mode = 'balanced') {
const safeRows = Array.isArray(rows) ? rows : [];
const base = [...safeRows];
if (mode === 'conservative') {
return base
.filter((row) => {
const legs = Array.isArray(row.legs) ? row.legs : [];
if (!legs.length) return false;
const minConf = Math.min(...legs.map((l) => (Number.isFinite(l.confidence) ? l.confidence : 0)));
return Number.isFinite(row.hitProbability) && row.hitProbability >= 0.04 && minConf >= 0.72 && row.expectedRoi > 0;
})
.sort((a, b) => b.hitProbability - a.hitProbability)
.slice(0, 8);
}
if (mode === 'value') {
return base
.filter((row) => Number.isFinite(row.expectedRoi) && row.expectedRoi >= 0.02 && Number.isFinite(row.hitProbability))
.sort((a, b) => (b.expectedRoi || 0) - (a.expectedRoi || 0))
.slice(0, 8);
}
return base
.filter((row) => Number.isFinite(row.hitProbability))
.sort((a, b) => (b.expectedRoi || 0) - (a.expectedRoi || 0))
.slice(0, 8);
}
function renderComboRows(rows = [], elId, max = 8) {
const el = $(`#${elId}`);
if (!el) return;
if (!rows.length) {
el.innerHTML = '<li>目前無可用組合</li>';
return;
}
el.innerHTML = rows.slice(0, max)
.map((row) => {
const legs = Array.isArray(row.legs)
? row.legs.map((leg, legIdx) => `${legIdx + 1}) ${leg.market || '-'} ${leg.selection || '-'}`).join(' + ')
: '-';
const expectedRoi = Number.isFinite(row.expectedRoi) ? row.expectedRoi * 100 : null;
const hit = Number.isFinite(row.hitProbability) ? row.hitProbability : 0;
const band = probabilityBand(hit);
return `
<li>
<p class="playbook-row">${legs}</p>
<p class="playbook-meta">
賠率 ${money(row.odds)} · 命中 ${pct(hit)} · ROI ${(Number.isFinite(expectedRoi) ? `${expectedRoi.toFixed(1)}%` : '-')}
<span class="risk-pill ${band.cls}">${band.label}</span>
</p>
<p class="muted">${row.notes || '-'}</p>
</li>
`;
})
.join('');
}
function renderParlayStrategy() {
const pb = state.analysis?.professionalPlaybook?.parlay;
const doubles = pb?.double || {};
const triples = pb?.triple || {};
const fallbackDoubles = state.analysis?.doublePlay || [];
const fallbackTriples = state.analysis?.triplePlay || [];
renderComboRows(
safePlaybookRows(doubles.conservative).length
? doubles.conservative
: rankComboByStyle(fallbackDoubles, 'conservative'),
'doubleConservative',
);
renderComboRows(
safePlaybookRows(doubles.balanced).length
? doubles.balanced
: rankComboByStyle(fallbackDoubles, 'balanced'),
'doubleBalanced',
);
renderComboRows(
safePlaybookRows(doubles.value).length ? doubles.value : rankComboByStyle(fallbackDoubles, 'value'),
'doubleValue',
);
renderComboRows(
safePlaybookRows(triples.conservative).length
? triples.conservative
: rankComboByStyle(fallbackTriples, 'conservative'),
'tripleConservative',
);
renderComboRows(
safePlaybookRows(triples.balanced).length ? triples.balanced : rankComboByStyle(fallbackTriples, 'balanced'),
'tripleBalanced',
);
renderComboRows(
safePlaybookRows(triples.value).length ? triples.value : rankComboByStyle(fallbackTriples, 'value'),
'tripleValue',
);
}
function renderPortfolioSummary() {
const el = $('#portfolioSummary');
if (!el) return;
const summary = state.analysis?.professionalPlaybook?.overall?.portfolio || {};
const risk = summary.riskProfile || {};
const markets = Array.isArray(summary.marketCoverage) ? summary.marketCoverage : [];
const marketList = markets.length
? markets.map((m) => `<li>${m.market}${m.count} 筆</li>`).join('')
: '<li>尚無市場分布</li>';
el.innerHTML = `
<article class="card">
<h3>總和評估(全局)</h3>
<p class="playbook-meta">${summary.summaryLine || '目前尚無可用總體評估資料'}</p>
<p class="muted">平均信心:${summary.avgConfidence !== undefined ? ratio(summary.avgConfidence, 4) : '-'}</p>
<ul>
<li>高勝率建議:${summary.topHighProbabilities || 0}</li>
<li>總爆冷機率信號:${summary.totalUpsetSignals || 0}</li>
<li>新聞訊號樣本:${summary.totalNewsSignals || 0}</li>
</ul>
<p class="playbook-meta">市場分布</p>
<ul>${marketList}</ul>
<p class="playbook-meta">風險占比|高 ${ratio(risk.high, 2) || 0} / 中 ${ratio(risk.medium, 2) || 0} / 低 ${ratio(risk.low, 2) || 0}</p>
</article>
`;
}
function renderComboRowsHtml(rows = []) {
if (!rows.length) return '<li>目前無可用組合</li>';
return rows
.slice(0, 6)
.map((row) => {
const legs = Array.isArray(row.legs)
? row.legs.map((leg, legIdx) => `${legIdx + 1}) ${leg.market || '-'} ${leg.selection || '-'}`).join(' + ')
: '-';
const expectedRoi = Number.isFinite(row.expectedRoi) ? row.expectedRoi * 100 : null;
const hit = Number.isFinite(row.hitProbability) ? row.hitProbability : 0;
const band = probabilityBand(hit);
return `
<li>
<p class="playbook-row">${legs}</p>
<p class="playbook-meta">
賠率 ${money(row.odds)} · 命中 ${pct(hit)} · ROI ${(Number.isFinite(expectedRoi) ? `${expectedRoi.toFixed(1)}%` : '-')}
<span class="risk-pill ${band.cls}">${band.label}</span>
</p>
</li>
`;
})
.join('');
}
function renderMultiLegParlay() {
const container = $('#multiLegParlay');
if (!container) return;
const payload = state.analysis?.professionalPlaybook?.overall?.multiLegParlay || {};
const sections = [
{ title: '2 串', data: payload.twoLeg },
{ title: '3 串', data: payload.threeLeg },
{ title: '4 串', data: payload.fourLeg },
{ title: '5 串', data: payload.fiveLeg },
];
container.innerHTML = sections
.map((sec) => {
const conservative = safePlaybookRows(sec.data?.conservative);
const balanced = safePlaybookRows(sec.data?.balanced);
const value = safePlaybookRows(sec.data?.value);
return `
<article class="card">
<h3>${sec.title}|穩健型</h3>
<ul>${renderComboRowsHtml(conservative)}</ul>
</article>
<article class="card">
<h3>${sec.title}|平衡型</h3>
<ul>${renderComboRowsHtml(balanced)}</ul>
</article>
<article class="card">
<h3>${sec.title}|高報酬型</h3>
<ul>${renderComboRowsHtml(value)}</ul>
</article>
`;
})
.join('');
}
function pickTopLegsForSystem(size = 4) {
const fromSingles = (state.analysis?.topSingles || [])
.filter((row) => Number.isFinite(row.probability) && Number.isFinite(row.odds) && row.odds > 1)
.filter((row) => Number.isFinite(row.confidence) ? row.confidence >= 0.55 : true)
.slice(0, Math.max(6, size))
.filter((row, idx, arr) => arr.findIndex((x) => x.matchId === row.matchId) === idx);
if (!fromSingles.length) return [];
return fromSingles;
}
function combinationProducts(rows, size, output, current = [], start = 0, limit = 160) {
if (output.length >= limit) return;
if (current.length === size) {
output.push([...current]);
return;
}
for (let i = start; i < rows.length; i += 1) {
if (output.length >= limit) return;
current.push(rows[i]);
combinationProducts(rows, size, output, current, i + 1, limit);
current.pop();
}
}
function buildSystemPatternTemplate(rows = [], totalLegs = 4, includedLegs = 2) {
const legs = rows.slice(0, totalLegs);
if (legs.length < totalLegs || includedLegs <= 0 || includedLegs > totalLegs) return null;
const combos = [];
combinationProducts(legs, includedLegs, combos, []);
if (!combos.length) return null;
let totalRoi = 0;
let avgHit = 0;
const slipSamples = [];
for (const combo of combos) {
const odds = combo.reduce((acc, r) => acc * (Number(r.odds) || 1), 1);
const hit = combo.reduce((acc, r) => acc * (Number(r.probability) || 0), 1);
const roi = odds * hit - 1;
totalRoi += roi;
avgHit += hit;
slipSamples.push({
odds: Number.isFinite(odds) ? Number(odds.toFixed(2)) : odds,
hitProbability: hit,
roi,
});
}
const n = combos.length;
return {
pattern: `${totalLegs}${includedLegs}`,
slips: n,
avgHitProbability: avgHit / (n || 1),
expectedRoi: (totalRoi / (n || 1)),
sample: slipSamples.slice(0, 3).map((s) => `命中 ${(s.hitProbability * 100).toFixed(1)}% / 賠率 ${money(s.odds)} / ROI ${(s.roi * 100).toFixed(1)}%`),
legs,
};
}
function renderSystemPlaybook() {
const el = $('#systemPlaybook');
if (!el) return;
const system = state.analysis?.professionalPlaybook?.system;
if (Array.isArray(system) && system.length) {
const topSamples = system[0]?.legs
? system[0].legs.slice(0, 5).map((r) => `${r.market} ${r.selection}`).join('')
: '尚未形成完整樣本';
el.innerHTML = `
<p class="playbook-meta">核心樣本:${topSamples}</p>
<ul>
${system
.map((row) => {
const band = probabilityBand(row.avgHitProbability);
return `
<li>
<p class="playbook-row">${row.pattern}(共 ${row.slips} 注)</p>
<p class="playbook-meta">平均命中 ${(row.avgHitProbability * 100).toFixed(1)}% / 期望ROI ${(row.expectedRoi * 100).toFixed(1)}%
<span class="risk-pill ${band.cls}">${band.label}</span>
</p>
<p class="muted">${(row.sample || []).join('')}</p>
</li>
`;
})
.join('')}
</ul>
`;
return;
}
const topLegs = pickTopLegsForSystem(6);
if (!topLegs.length) {
el.innerHTML = '<p>目前資料不足,尚無系統式參考</p>';
return;
}
const patterns = [];
for (const [n, k] of [
[4, 2],
[4, 3],
[5, 2],
[5, 3],
]) {
const p = buildSystemPatternTemplate(topLegs, n, k);
if (p) patterns.push(p);
}
if (!patterns.length) {
el.innerHTML = '<p>目前無法組出完整系統式玩法</p>';
return;
}
const topSelection = topLegs.slice(0, 5).map((r) => `${r.market} ${r.selection}`).join('');
el.innerHTML = `
<p class="playbook-meta">核心樣本:${topSelection}</p>
<ul>
${patterns
.map((row) => {
const band = probabilityBand(row.avgHitProbability);
return `
<li>
<p class="playbook-row">${row.pattern}(共 ${row.slips} 注)</p>
<p class="playbook-meta">平均命中 ${(row.avgHitProbability * 100).toFixed(1)}% / 期望ROI ${(row.expectedRoi * 100).toFixed(1)}%
<span class="risk-pill ${band.cls}">${band.label}</span>
</p>
<p class="muted">${row.sample.join('')}</p>
</li>
`;
})
.join('')}
</ul>
`;
}
function renderCrossMarketPairs() {
const el = $('#crossMarketPairs');
if (!el) return;
const pb = state.analysis?.professionalPlaybook?.crossMarket;
if (Array.isArray(pb) && pb.length) {
el.innerHTML = pb
.slice(0, 8)
.map((row) => {
const lines = row.candidates
.map((r) => `${r.market} ${r.selection}(賠率 ${money(r.odds)} / 勝率 ${pct(r.probability)}`)
.join('<br/>');
return `
<article class="card">
<h4>${row.teams}</h4>
<p class="muted">開賽 ${row.kickoffAtTaipei || fmtTime(row.kickoffAt)}</p>
<p>${lines}</p>
</article>
`;
})
.join('');
return;
}
const list = [];
for (const match of state.analysis?.perMatch || []) {
const marketCandidates = [];
for (const pool of [match.recommendationBuckets?.high || [], match.recommendationBuckets?.medium || []]) {
for (const rec of pool) {
const group = marketGroup(rec.market);
if (group === 'other') continue;
const key = `${group}::${rec.selection}`;
if (!marketCandidates.find((x) => x.key === key)) {
marketCandidates.push({
key,
market: rec.market,
selection: rec.selection,
odds: rec.odds,
probability: rec.probability,
rationale: rec.rationale || '',
});
}
}
}
const uniqGroups = new Set(marketCandidates.map((r) => r.market));
if (uniqGroups.size >= 2) {
list.push({
match: match.teams,
time: match.kickoffAtTaipei || fmtTime(match.kickoffAt),
candidates: marketCandidates.slice(0, 4),
});
}
}
if (!list.length) {
el.innerHTML = '<p>目前無法提供同場雙盤進階參考</p>';
return;
}
el.innerHTML = list
.slice(0, 8)
.map((row) => {
const lines = row.candidates
.map((r) => `${r.market} ${r.selection}(賠率 ${money(r.odds)} / 勝率 ${pct(r.probability)}`)
.join('<br/>');
return `
<article class="card">
<h4>${row.match}</h4>
<p class="muted">開賽 ${row.time}</p>
<p>${lines}</p>
</article>
`;
})
.join('');
}
function renderSourceRegistry(sources = []) {
const el = $('#sourceRegistry');
if (!el) return;
const sorted = [...sources].sort((a, b) => {
const intOrder = {
active: 0,
conditional: 1,
planned: 2,
reference_only: 3,
};
const aw = intOrder[a.integration] ?? 4;
const bw = intOrder[b.integration] ?? 4;
if (aw !== bw) return aw - bw;
return (b.weight || 0) - (a.weight || 0);
});
if (!sources.length) {
el.innerHTML = '<p>尚未取得外部來源台帳,將在下次更新時補齊</p>';
return;
}
const statusRows = sorted
.map((item) => {
const runtime = item.runtime || {};
const badgeClass =
runtime.status === 'ok'
? 'ok'
: runtime.status === 'error'
? 'danger'
: runtime.status === 'checking'
? 'loading'
: 'pending';
const integrationClass =
item.integration === 'active'
? 'ok'
: item.integration === 'conditional'
? 'loading'
: item.integration === 'planned'
? 'reference'
: 'pending';
const runtimeStatus = runtime.status || 'pending';
const latency = Number.isFinite(Number(runtime.latencyMs))
? `${Number(runtime.latencyMs)}ms`
: '-';
const msg = runtime.message || '待啟動';
const lastCheck = runtime.checkedAt ? fmtTime(runtime.checkedAt) : '-';
const lastSuccessAt = runtime.lastSuccessAt ? fmtTime(runtime.lastSuccessAt) : '-';
const errorMsg = runtime.lastError ? `<span class="muted">錯誤: ${runtime.lastError}</span><br />` : '';
return `
<article class="card source-card">
<div class="source-head">
<div>
<h3>${item.name || '-'}</h3>
<p class="muted source-meta">類別 ${item.sourceType || '-'} / 權重 ${Number(item.weight || 0).toFixed(2)}</p>
</div>
<span class="source-badge ${badgeClass}">${runtimeStatus}</span>
</div>
<div class="source-meta">
<p>整合:<span class="source-badge ${integrationClass}">${item.integration || '-'}</span></p>
<p>策略:${item.category || '-'} / ${item.name ? '已納入主流矩陣' : '-'}</p>
</div>
<p>${item.description || '-'}</p>
<p class="muted">
服務狀態:${msg}<br />
最新檢測:${lastCheck}<br />
最近成功:${lastSuccessAt}<br />
延遲:${latency}<br />
${errorMsg || ''}
</p>
<p><a href="${item.url || '#'}" target="_blank" rel="noopener">${item.url || '-'}</a></p>
</article>
`;
})
.join('');
el.innerHTML = statusRows || '<p>尚未取得外部來源台帳,將在下次更新時補齊</p>';
}
function renderMarketBuckets(rows = []) {
const normalized = Array.isArray(rows) ? rows : [];
const groups = {};
for (const rec of normalized) {
const key = String(rec.market || '未知市場').trim() || '未知市場';
(groups[key] || (groups[key] = [])).push(rec);
}
const order = ['1X2', 'Handicap', 'Totals', 'Double Chance', 'Both Teams To Score', 'BTTS Trend', '讓球', '大小球', '雙重機率'];
const sortedKeys = [
...order,
...Object.keys(groups).filter((k) => !order.includes(k)),
];
const uniqKeys = [];
const seen = new Set();
for (const key of sortedKeys) {
if (!seen.has(key) && groups[key]?.length) {
uniqKeys.push(key);
seen.add(key);
}
}
return uniqKeys.map((market) => {
const list = groups[market].slice().sort((a, b) => (b.confidence || 0) - (a.confidence || 0));
return {
market,
rows: list,
};
});
}
function renderMatchContextLine(context = {}) {
if (!context || typeof context !== 'object') return '';
const venue = context.venue || {};
const home = context.home || {};
const away = context.away || {};
const venueText = venue.venue ? `場館 ${venue.venue}${Number.isFinite(venue.altitude) ? `${venue.altitude}m` : ''}` : '';
const homeText = Number.isFinite(home.restDays) ? `主隊休整 ${home.restDays}天(${home.fatigueLabel || '資料不足'}` : '';
const awayText = Number.isFinite(away.restDays) ? `客隊休整 ${away.restDays}天(${away.fatigueLabel || '資料不足'}` : '';
const risks = context.venueRisk?.label ? `環境風險 ${context.venueRisk.label}` : '';
const parts = [venueText, risks, homeText, awayText].filter(Boolean);
if (!parts.length) return '';
return `<span class="muted">${parts.join('')}</span>`;
}
function renderTodayInsightCards() {
const container = $('#todayInsightCards');
if (!container) return;
const payload = state.todayInsights || {};
const matches = Array.isArray(payload.matches) ? payload.matches : [];
const high = Array.isArray(payload.topSinglesByTier?.high) ? payload.topSinglesByTier.high : [];
const medium = Array.isArray(payload.topSinglesByTier?.medium) ? payload.topSinglesByTier.medium : [];
const low = Array.isArray(payload.topSinglesByTier?.low) ? payload.topSinglesByTier.low : [];
const upsets = Array.isArray(payload.upsetSignals) ? payload.upsetSignals : [];
const dateLabel = payload.dateLabel || `今天(台北時間)`;
const summary = payload.summary || {};
const breakdown = payload.upsetBreakdown || {};
const list = (items = [], limit = 3) =>
items.length
? items
.slice(0, limit)
.map((row) => `<li>${safeText(row.market || '-')} ${safeText(row.selection || '-')}(賠率 ${money(row.odds)}/勝率 ${pct(row.probability)}</li>`)
.join('')
: '<li>本級別目前無可用訊號</li>';
container.innerHTML = `
<article class="card">
<h3>今日總覽:${dateLabel}</h3>
<p class="muted">共 ${matches.length} 場比賽可用</p>
<ul>
<li>高勝率建議數:${ratio(summary.highSingles, 0)} </li>
<li>中勝率建議數:${ratio(summary.mediumSingles, 0)} </li>
<li>低勝率建議數:${ratio(summary.lowSingles, 0)} </li>
<li>爆冷訊號數:${ratio(summary.upsetSignals, 0)}(高 ${ratio(breakdown.high, 0)} / 中 ${ratio(breakdown.medium, 0)} / 低 ${ratio(breakdown.low, 0)}</li>
</ul>
</article>
<article class="card">
<h3>高勝率 Top 3</h3>
<ul>${list(high, 3)}</ul>
</article>
<article class="card">
<h3>中勝率 Top 3</h3>
<ul>${list(medium, 3)}</ul>
</article>
<article class="card">
<h3>低勝率 Top 3</h3>
<ul>${list(low, 3)}</ul>
</article>
<article class="card">
<h3>爆冷 Top 5</h3>
<ul>${list(upsets, 5)}</ul>
</article>
`;
}
function renderTodayMatchCards() {
const el = $('#todayMatches');
const title = $('#todayMatchesTitle');
if (!el) return;
const today = fmtDateLabel(new Date());
const all = state.analysis?.perMatch || [];
const todayMatches = all
.filter((m) => fmtDateLabel(m.kickoffAt) === today)
.sort((a, b) => new Date(a.kickoffAt).getTime() - new Date(b.kickoffAt).getTime());
if (title) {
const dateText = today || '-';
title.textContent = `今天(台北時間 ${dateText})賽事投注建議`;
}
if (!todayMatches.length) {
el.innerHTML = '<article class="card"><p>今天目前尚無賽事資料</p><p class="muted">更新完成後會即時顯示今日可下的投注建議。</p></article>';
return;
}
el.innerHTML = todayMatches
.map((match) => {
const allRows = [
...(match.recommendationBuckets?.high || []),
...(match.recommendationBuckets?.medium || []),
...(match.recommendationBuckets?.low || []),
].sort((a, b) => (b.confidence || 0) - (a.confidence || 0));
const marketBuckets = renderMarketBuckets(allRows);
const top = match.topRecommendation
? `
<li><strong>主建議:</strong>${match.topRecommendation.market} ${match.topRecommendation.selection}
(賠率 ${money(match.topRecommendation.odds)} / 機率 ${pct(match.topRecommendation.probability)} / EV ${money(match.topRecommendation.expectedValue)} / Kelly ${ratio(match.topRecommendation.kellyFraction, 2)}
</li>`
: '<li>目前無可用主建議</li>';
const highs = (match.recommendationBuckets?.high || []).map((r) => `<li>${r.market} ${r.selection}|賠率 ${money(r.odds)}|勝率 ${pct(r.probability)}EV ${money(r.expectedValue)}</li>`);
const mediums = (match.recommendationBuckets?.medium || []).map((r) => `<li>${r.market} ${r.selection}|賠率 ${money(r.odds)}|勝率 ${pct(r.probability)}EV ${money(r.expectedValue)}</li>`);
const lows = (match.recommendationBuckets?.low || []).map((r) => `<li>${r.market} ${r.selection}|賠率 ${money(r.odds)}|勝率 ${pct(r.probability)}EV ${money(r.expectedValue)}</li>`);
const upsets = (match.upsetSignals || []).map((u) => `<li>${u.selection}${u.riskLabel || 'low'})-賠率 ${money(u.odds)} / 爆冷機率 ${pct(u.probability)}</li>`);
const contextLine = renderMatchContextLine(match.matchContext);
const allRowsHtml = allRows.length
? allRows
.map((r) => `<li>${r.market} ${r.selection}|賠率 ${money(r.odds)}|勝率 ${pct(r.probability)}EV ${money(r.expectedValue)}|分層 ${r.recommendationTier || classifyRecommendationTier(r.probability, r.confidence)}</li>`)
.join('')
: '<li>無可用單關建議</li>';
const marketDetails = marketBuckets.length
? marketBuckets
.map(
(bucket) => `
<p class="playbook-meta">[${bucket.market}]</p>
<ul>${bucket.rows
.map((r) => `<li>${r.selection}(賠率 ${money(r.odds)}|勝率 ${pct(r.probability)}EV ${money(r.expectedValue)}|信心 ${ratio(r.confidence, 2)}</li>`)
.join('')}</ul>
`,
)
.join('')
: '';
return `
<article class="card match-mini-card">
<div class="match-time">${match.kickoffAtTaipei || fmtTime(match.kickoffAt)} · ${match.teams}</div>
${contextLine ? `<p>${contextLine}</p>` : ''}
<ul class="today-list">
${top}
</ul>
<h4>高勝率候選(高)</h4>
<ul>${highs.length ? highs.join('') : '<li>無</li>'}</ul>
<h4>中勝率候選(中)</h4>
<ul>${mediums.length ? mediums.join('') : '<li>無</li>'}</ul>
<h4>低勝率候選(高報酬)</h4>
<ul>${lows.length ? lows.join('') : '<li>無</li>'}</ul>
<h4>單關完整建議(高/中/低)</h4>
<ul>${allRowsHtml}</ul>
<h4>市場維度完整建議</h4>
${marketDetails || '<p class="muted">尚無市場維度明細</p>'}
<h4>爆冷訊號</h4>
<ul>${upsets.length ? upsets.join('') : '<li>無</li>'}</ul>
</article>
`;
})
.join('');
}
function renderMatchCard(match) {
const newsItems = Array.isArray(match.news) ? match.news : [];
const top = match.topRecommendation
? `
<li>
<strong>${match.topRecommendation.market}</strong>
${match.topRecommendation.selection}
(賠率 <b>${match.topRecommendation.odds}</b>,機率 ${pct(match.topRecommendation.probability)}EV ${money(match.topRecommendation.expectedValue)}Kelly ${ratio(match.topRecommendation.kellyFraction, 2)},模型 ${match.topRecommendation.modelGrade || '-'}
</li>
`
: '<li>暫無可用主建議</li>';
const marketSummary = Array.isArray(match.marketSummary) ? match.marketSummary : [];
const markets = marketSummary.map(
(m) =>
`<li>[${m.market}] ${m.bestOutcome}|賠率 ${money(m.bestOdds)}|勝率 ${pct(m.fairProbability)}</li>`,
).join('');
const news = newsItems
.slice(0, 3)
.map(
(item) =>
`<li><a href="${item.link}" target="_blank" rel="noopener">${item.title}</a><span class="muted">${item.source || '-'}${fmtTime(item.publishedAt)}</span></li>`,
)
.join('');
const contextLine = renderMatchContextLine(match.matchContext);
return `
<article class="match-card card">
<h3>${match.teams}</h3>
<p class="muted">${match.source} · ${match.kickoffAtTaipei || fmtTime(match.kickoffAt)}</p>
${contextLine ? `<p class="muted">環境條件:${contextLine}</p>` : ''}
<h4>高勝率推薦</h4>
<ul>${top}</ul>
<h4>市場摘要</h4>
<ul>${markets || '<li>暫無市場數據</li>'}</ul>
<h4>高勝率(高機率)</h4>
<ul>${renderRecommendRows(match.recommendationBuckets?.high || [])}</ul>
<h4>中勝率(平衡風險)</h4>
<ul>${renderRecommendRows(match.recommendationBuckets?.medium || [], 2)}</ul>
<h4>低勝率(博取機會)</h4>
<ul>${renderRecommendRows(match.recommendationBuckets?.low || [], 2)}</ul>
<h4>爆冷機會</h4>
<ul>${renderUpsetRows(match.upsetSignals || [], 3)}</ul>
<h4>近期新聞</h4>
<ul>${news || '<li>暫無近期新聞</li>'}</ul>
</article>
`;
}
function renderMatches() {
const el = $('#matchContainer');
if (!el) return;
const list = (state.analysis?.perMatch || []).filter((m) => filterMarkets(m, state.filter));
if (!list.length) {
el.innerHTML = '<p>目前無可顯示資料,請先更新</p>';
return;
}
el.innerHTML = list.map(renderMatchCard).join('');
}
function renderCombos() {
const doubleEl = $('#doubleCombo');
const tripleEl = $('#tripleCombo');
if (!doubleEl || !tripleEl) return;
const doubles = state.analysis?.doublePlay || [];
const triples = state.analysis?.triplePlay || [];
doubleEl.innerHTML = doubles.length
? doubles
.slice(0, 8)
.map((row) => {
const legs = row.legs
.map((leg) => `${leg.market} ${leg.selection}`)
.join(' + ');
return `<article class="card"><div>${legs}</div><div class="muted">勝率 ${pct(row.hitProbability)} / 賠率 ${money(row.odds)} / 預估ROI ${(row.expectedRoi * 100).toFixed(1)}%</div><div class="muted">${row.notes}</div></article>`;
})
.join('')
: '<p>尚未產生可組合項目</p>';
tripleEl.innerHTML = triples.length
? triples
.slice(0, 8)
.map((row) => {
const legs = row.legs
.map((leg) => `${leg.market} ${leg.selection}`)
.join(' + ');
return `<article class="card"><div>${legs}</div><div class="muted">勝率 ${pct(row.hitProbability)} / 賠率 ${money(row.odds)} / 預估ROI ${(row.expectedRoi * 100).toFixed(1)}%</div><div class="muted">${row.notes}</div></article>`;
})
.join('')
: '<p>尚未產生可組合項目</p>';
}
function renderSchedule() {
const dateEl = $('#scheduleByDate');
const newsEl = $('#newsHeat');
if (!dateEl || !newsEl) return;
const byDate = state.schedule?.byDate || [];
const hotNews = state.schedule?.hotNewsWithin48h || [];
dateEl.innerHTML = byDate
.map(
(row) =>
`<article class="card"><h4>${row.date || '-'}</h4><p>場數 ${row.count}</p><ul>${(Array.isArray(row.events) ? row.events : [])
.map((e) => `<li>${e.teams}</li>`)
.join('')}</ul></article>`,
)
.join('');
newsEl.innerHTML = hotNews.length
? hotNews.map((item) => {
const headlines = item.sampleHeadlines.map((h) => `<li>${h}</li>`).join('');
return `<article class="card"><h4>${item.teams}</h4><p class="muted">距離最新報導 ${item.ageHours} 小時</p><ul>${headlines}</ul></article>`;
}).join('')
: '<p>48 小時內無高熱度新聞</p>';
}
function renderSingleBuckets() {
const highEl = $('#highSingles');
const mediumEl = $('#mediumSingles');
const lowEl = $('#lowSingles');
if (!highEl || !mediumEl || !lowEl) return;
const high = state.analysis?.highProbabilitySingles || [];
const medium = state.analysis?.mediumProbabilitySingles || [];
const low = state.analysis?.lowProbabilitySingles || [];
highEl.innerHTML = renderRecommendRows(high, 8);
mediumEl.innerHTML = renderRecommendRows(medium, 6);
lowEl.innerHTML = renderRecommendRows(low, 6);
}
function renderUpsetSignalsOverview() {
const el = $('#upsetContainer');
if (!el) return;
const rows = state.analysis?.upsetSignals || [];
if (!rows.length) {
el.innerHTML = '<p>目前無明顯爆冷信號</p>';
return;
}
el.innerHTML = rows
.map((row) => {
const signals = Array.isArray(row.signals) ? row.signals : [];
return `
<article class="card">
<h3>${row.teams}</h3>
<p class="muted">開賽:${fmtTime(row.kickoffAt)} · ${signals.length} 個候選結果</p>
<ul>${renderUpsetRows(signals, 3)}</ul>
</article>
`;
})
.join('');
}
function renderAnalyticBars() {
const highCount = state.analysis?.highProbabilitySingles ? state.analysis.highProbabilitySingles.length : 0;
const mediumCount = state.analysis?.mediumProbabilitySingles ? state.analysis.mediumProbabilitySingles.length : 0;
const lowCount = state.analysis?.lowProbabilitySingles ? state.analysis.lowProbabilitySingles.length : 0;
renderMiniBars('tierDistributionChart', [
{ label: '高勝率', value: highCount, color: 'var(--ok)' },
{ label: '中勝率', value: mediumCount, color: '#60a5fa' },
{ label: '低勝率', value: lowCount, color: 'var(--accent)' },
]);
const upsetRows = state.analysis?.upsetSignals || [];
let high = 0;
let medium = 0;
let low = 0;
for (const row of upsetRows) {
const signals = Array.isArray(row.signals) ? row.signals : [];
for (const s of signals) {
const risk = s?.riskLabel || 'low';
if (risk === 'high') high += 1;
else if (risk === 'medium') medium += 1;
else low += 1;
}
}
renderMiniBars('upsetRiskChart', [
{ label: '高風險', value: high, color: 'var(--danger)' },
{ label: '中風險', value: medium, color: '#fbbf24' },
{ label: '低風險', value: low, color: 'var(--ok)' },
]);
const sourceStats = {};
for (const src of state.sourceRegistry || []) {
const key = src?.integration || 'reference_only';
sourceStats[key] = (sourceStats[key] || 0) + 1;
}
renderMiniBars('sourceHealthChart', [
{ label: 'active', value: sourceStats.active || 0, color: 'var(--ok)' },
{ label: 'conditional', value: sourceStats.conditional || 0, color: '#60a5fa' },
{ label: 'planned', value: sourceStats.planned || 0, color: '#7dd3fc' },
{ label: 'reference_only', value: sourceStats.reference_only || 0, color: 'var(--muted)' },
]);
const topSingles = (state.analysis?.topSingles || []).slice(0, 8);
renderMiniBars(
'topValueChart',
topSingles.map((row) => ({
label: `${row.teams || row.matchId || '-'} ${row.market || '-'}`,
value: Number.isFinite(row.expectedRoiPercent) ? row.expectedRoiPercent : 0,
color: row.recommendationTier === 'high' ? 'var(--ok)' : row.recommendationTier === 'medium' ? '#60a5fa' : 'var(--accent)',
})),
16,
);
const scheduleRows = (state.schedule?.byDate || []).map((row) => ({
label: row.date || '-',
value: Number(row.count) || 0,
color: 'var(--accent)',
}));
renderMiniBars('scheduleDensityChart', scheduleRows);
}
function renderBankrollGuide() {
const el = $('#bankrollGuide');
if (!el) return;
const guide = state.analysis?.bankrollGuide || {};
const principles = Array.isArray(guide.principle) ? guide.principle : [];
const body = principles.length
? `<ul>${principles.map((line) => `<li>${line}</li>`).join('')}</ul>`
: '<p>尚未載入資金配置建議</p>';
const model = guide.suggestedModel ? `<p class="muted">模型建議:${guide.suggestedModel}</p>` : '';
const warning = guide.warning ? `<p class="note">風險提醒:${guide.warning}</p>` : '';
el.innerHTML = `${body}${model ? `<p class="muted">${model}</p>` : ''}${warning}`;
}
function renderMarketRowBadge(list = [], max = 4) {
return list
.slice(0, max)
.map((item) => `<li>${item.option || item.selection} @ ${money(item.price)}${ratio(item.implied, 2)}</li>`)
.join('') || '<li>暫無可比價選項</li>';
}
function renderDashboardMarketMatrix() {
const summaryContainer = $('#marketMatrixSummary');
const rowsContainer = $('#marketMatrixRows');
if (!summaryContainer || !rowsContainer) return;
const payload = state.marketMatrix || {};
const rows = Array.isArray(payload.rows) ? payload.rows : [];
const opportunities = Array.isArray(payload.topOpportunities) ? payload.topOpportunities : [];
const topOps = opportunities.slice(0, 10);
summaryContainer.innerHTML = `
<article class="card">
<h3>賠率矩陣快照</h3>
<p class="muted">更新時間:${payload.updatedAt ? fmtTime(payload.updatedAt) : '-'}</p>
<ul>
<li>場次:${rows.length}</li>
<li>套利候選:${payload.opportunityCount || opportunities.length || 0}</li>
<li>最大輸出場次:${safeText(payload.matchCount)}</li>
</ul>
</article>
<article class="card">
<h3>最高套利信號</h3>
<ul>
${topOps.length ? topOps.map((row) => `<li>${safeText(row.market)} · ${safeText(row.type)}${safeText(Number.isFinite(row.edgePercent) ? `${row.edgePercent}%` : '')}</li>`).join('') : '<li>目前未偵測到可套利組合</li>'}
</ul>
</article>
`;
if (!rows.length) {
rowsContainer.innerHTML = '<p>尚未取得賠率矩陣資料</p>';
return;
}
rowsContainer.innerHTML = rows
.slice(0, 8)
.map((row) => {
const h2h = renderMarketRowBadge(row.marketWinners?.h2h || []);
const spread = renderMarketRowBadge(row.marketWinners?.spreads || []);
const totals = renderMarketRowBadge(row.marketWinners?.totals || []);
const btts = renderMarketRowBadge(row.marketWinners?.btts || []);
return `
<article class="card">
<h3>${safeText(row.teams)}</h3>
<p class="muted">開賽:${safeText(row.kickoffAtTaipei || '-')}</p>
<div class="market-grid">
<div>
<p class="playbook-meta">1X2</p>
<ul>${h2h}</ul>
</div>
<div>
<p class="playbook-meta">讓球</p>
<ul>${spread}</ul>
</div>
<div>
<p class="playbook-meta">大小球</p>
<ul>${totals}</ul>
</div>
<div>
<p class="playbook-meta">BTTS</p>
<ul>${btts}</ul>
</div>
</div>
<p class="muted">場次 ID${safeText(row.matchId)}</p>
</article>
`;
})
.join('');
}
function renderDashboardSharpMoney() {
const container = $('#sharpMoneyBoard');
if (!container) return;
const payload = state.sharpMoney || {};
const signals = Array.isArray(payload.signals) ? payload.signals : [];
if (!signals.length) {
container.innerHTML = '<p>目前尚無 Public / Sharp 監控訊號</p>';
return;
}
container.innerHTML = signals
.slice(0, 12)
.map((row) => {
const biasClass = row.sharpBias === 'sharp-heavy' ? 'risk-high' : row.sharpBias === 'public-heavy' ? 'risk-low' : 'risk-medium';
const status = row.status === 'extreme' ? '極端偏差' : row.status === 'notice' ? '明顯偏差' : '一般';
return `
<article class="card">
<h3>${safeText(row.market)} ${safeText(row.option)}</h3>
<p class="muted">${safeText(row.match)} · 點位 ${safeText(row.point ?? '-')}</p>
<p class="playbook-meta">
票量 ${ratio(row.ticketsPercent)}% 資金 ${ratio(row.handlePercent)}%
偏離 ${ratio(row.deltaSignal)}%
<span class="risk-pill ${biasClass}">${status}</span>
</p>
<p class="muted">${safeText(row.rationale || '-')}</p>
</article>
`;
})
.join('');
}
function renderDashboardLiveCenter() {
const container = $('#liveCenterBoard');
const movement = $('#lineMovementBoard');
if (!container && !movement) return;
const livePayload = state.liveCenter || {};
const live = Array.isArray(livePayload.data) ? livePayload.data : [];
if (container) {
if (!live.length) {
container.innerHTML = '<p>目前尚無進行中場次快照</p>';
} else {
container.innerHTML = live
.slice(0, 8)
.map((item) => {
const timeline = Array.isArray(item.timeline) ? item.timeline.slice(-1)[0] : null;
return `
<article class="card">
<h3>${safeText(item.teams)}</h3>
<p class="muted">已比賽 ${ratio(item.elapsedMinutes)} 分鐘</p>
<div class="mini-grid">
<p class="playbook-meta">控球率|主 ${ratio(item.possession?.home, 2)} ${ratio(item.possession?.away, 2)}%</p>
<p class="playbook-meta">xG${money(item.xgProjection?.homeXg)} ${money(item.xgProjection?.awayXg)}</p>
</div>
<p class="muted">最新事件 ${timeline ? `${safeText(timeline.minute)}${safeText(timeline.team)} ${safeText(timeline.type)}` : '尚無事件推播'}</p>
</article>
`;
})
.join('');
}
}
const move = state.lineMovement;
if (!movement) return;
const movementTrails = Array.isArray(move?.trails) ? move.trails : [];
const moveMatchName = safeText(move?.teams || `${move?.match || ''}`) || safeText(move?.matchId) || '-';
if (!movementTrails.length) {
movement.innerHTML = '<p>尚未取得盤口走勢</p>';
return;
}
movement.innerHTML = movementTrails
.slice(0, 3)
.map((trail) => {
const path = Array.isArray(trail.series) ? trail.series : [];
const last = path[path.length - 1] || {};
const first = path[0] || {};
return `
<article class="card">
<h3>${moveMatchName} - ${safeText(trail.market || '-')} ${safeText(trail.outcome || '-')}</h3>
<p class="muted">首 ${safeText(first.price)} / 尾 ${safeText(last.price)} ${safeText(trail.sampleCount)} 點</p>
<p class="playbook-meta">最小 ${money(trail.minPrice)} 最大 ${money(trail.maxPrice)}</p>
</article>
`;
})
.join('');
}
function renderQuantitativePage() {
const summary = $('#quantSummary');
const matchSelect = $('#quantMatchSelect');
const matchPanel = $('#quantMatchPanel');
const valuePanel = $('#quantValuePanel');
const payload = state.quantitative || {};
const items = Array.isArray(payload.items) ? payload.items : [];
if (!matchSelect) {
return;
}
const availableMatchOptions = items.map((item) => {
const label = `${safeText(item.teams)}${safeText(item.matchId)}`;
return `<option value="${safeText(item.matchId)}">${label}</option>`;
});
matchSelect.innerHTML = availableMatchOptions.length
? availableMatchOptions.join('')
: '<option value="">尚無量化資料</option>';
if (!state.quantitativeMatchId || !items.find((i) => i.matchId === state.quantitativeMatchId)) {
state.quantitativeMatchId = items[0]?.matchId || '';
matchSelect.value = state.quantitativeMatchId;
} else {
matchSelect.value = state.quantitativeMatchId;
}
if (summary) {
const topBetCount = items.reduce((acc, item) => acc + (Array.isArray(item.evSignals?.valueBets) ? item.evSignals.valueBets.length : 0), 0);
summary.innerHTML = `
<article class="card">
<h3>量化模型總覽</h3>
<p>場次:${items.length}</p>
<p class="muted">更新時間:${safeText(payload.generatedAtTaipei || payload.generatedAt)}</p>
</article>
<article class="card">
<h3>今日可用正向 EV</h3>
<p>${topBetCount} 筆</p>
</article>
`;
}
if (valuePanel) {
const allValueBets = items.flatMap((item) => (Array.isArray(item.evSignals?.valueBets) ? item.evSignals.valueBets.map((row) => ({ ...row, matchId: item.matchId, teams: item.teams })) : []));
valuePanel.innerHTML = allValueBets.length
? allValueBets
.slice(0, 16)
.map((row) => `<article class="card"><h3>${safeText(row.market)} ${safeText(row.selection)}</h3><p class="muted">${safeText(row.teams)} | ${money(row.odds)} | EV ${money(row.expectedValue)} | Edge ${safeText(row.edgePercent)}%</p><p class="muted">${safeText(row.rationale || '')}</p></article>`)
.join('')
: '<p>目前無 Value Bet 訊號</p>';
}
if (!matchPanel) return;
const selectedId = state.quantitativeMatchId;
const current = items.find((item) => item.matchId === selectedId) || items[0];
if (!current) {
matchPanel.innerHTML = '<p>尚無可量化的場次</p>';
return;
}
const poisson = current.poisson || {};
const mc = current.monteCarlo || {};
const topScores = Array.isArray(poisson.topScores) ? poisson.topScores : [];
const scenario = mc.scenarioProbability || {};
matchPanel.innerHTML = `
<article class="card">
<h3>${safeText(current.teams)}(場次 ${safeText(current.matchId)}</h3>
<p class="muted">EV 訊號:${safeText(current.evSignals?.count)},正向 ${safeText(current.evSignals?.countValue)}Poisson 總體 λ=${ratio(poisson.lambdas?.total)};蒙地卡羅=${mc.simulations || 0} 次</p>
<div class="metric-grid">
<div class="card">
<h4>Poisson 高概率比分</h4>
<ul>${topScores.length ? topScores.slice(0, 10).map((row) => `<li>${safeText(row.score)}${pctFromRate(row.probability, 2)}</li>`).join('') : '<li>無</li>'}</ul>
</div>
<div class="card">
<h4>Monte Carlo 情境</h4>
<ul>
<li>主勝 ${pctFromRate(scenario.homeWin, 2)}</li>
<li>和局 ${pctFromRate(scenario.draw, 2)}</li>
<li>客勝 ${pctFromRate(scenario.awayWin, 2)}</li>
<li>主隊淨勝2球+ ${pctFromRate(scenario.homeWinBy2OrMore, 2)}</li>
<li>客隊淨勝2球+ ${pctFromRate(scenario.awayWinBy2OrMore, 2)}</li>
</ul>
<p class="muted">${safeText(mc.insight?.message || '')}</p>
</div>
</div>
</article>
`;
}
function renderPortfolioTrackerPage() {
const summary = $('#portfolioSummaryCards');
const rowsContainer = $('#portfolioRows');
const matchSelect = $('#portfolioMatchId');
const payload = state.portfolio || {};
const bets = Array.isArray(payload.bets) ? payload.bets : [];
if (summary) {
summary.innerHTML = `
<article class="card"><h3>投注總額</h3><p>${ratio(payload.totalStake)} 元</p></article>
<article class="card"><h3>已結算損益</h3><p>${ratio(payload.totalPnl)} 元</p></article>
<article class="card"><h3>ROI</h3><p>${ratio(payload.roiPercent)}%</p></article>
<article class="card"><h3>累計 CLV</h3><p>${ratio(payload.avgClvPercent)}%</p></article>
<article class="card"><h3>勝/負/推</h3><p>${safeText(payload.byResult?.win)} / ${safeText(payload.byResult?.loss)} / ${safeText(payload.byResult?.push)}</p></article>
<article class="card"><h3>結算/未結</h3><p>${safeText(payload.settledCount)} / ${safeText(payload.openCount)}</p></article>
`;
}
if (matchSelect) {
const options = state.matches
.map((m) => `<option value="${safeText(m.id)}">${safeText(m.homeTeam)} vs ${safeText(m.awayTeam)} · ${fmtTime(m.commenceTimeTaipei || m.commenceTime)}</option>`)
.join('');
if (!matchSelect.innerHTML || matchSelect.dataset.optionsReady !== '1') {
matchSelect.innerHTML = options || '<option value="">請先載入賽事</option>';
matchSelect.dataset.optionsReady = '1';
}
}
if (!rowsContainer) return;
if (!bets.length) {
rowsContainer.innerHTML = '<p>目前尚未記錄下注</p>';
return;
}
rowsContainer.innerHTML = `
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>場次</th><th>市場</th><th>選項</th><th>賠率</th><th>本金</th><th>結果</th><th>CLV%</th><th>開倉時間</th>
</tr>
</thead>
<tbody>
${bets
.map((row) => `
<tr>
<td>${safeText(row.meta?.teams || row.matchId)}</td>
<td>${safeText(row.market)}</td>
<td>${safeText(row.selection)}</td>
<td>${money(row.odds)}</td>
<td>${ratio(row.stake)}</td>
<td>${safeText(row.result || 'open')}</td>
<td>${row.clv === null || row.clv === undefined ? '-' : ratio(row.clv)}</td>
<td>${fmtTime(row.placedAt)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
async function loadPortfolioData() {
const result = await fetchJson('/api/portfolio');
state.portfolio = result;
}
async function loadDashboardData() {
const [marketPayload, sharpPayload, livePayload] = await Promise.all([
fetchJson('/api/market-matrix'),
fetchJson('/api/sharp-money'),
fetchJson('/api/live-center'),
]);
state.marketMatrix = marketPayload;
state.sharpMoney = sharpPayload;
state.liveCenter = livePayload;
const firstMatchId = marketPayload?.rows?.[0]?.matchId || safeText(state.matches?.[0]?.id);
if (firstMatchId) {
try {
state.lineMovement = await fetchJson(`/api/line-movement?matchId=${encodeURIComponent(firstMatchId)}&market=h2h`);
} catch (e) {
state.lineMovement = null;
}
} else {
state.lineMovement = null;
}
}
async function loadQuantitativeCoreData(force = false) {
const payload = await fetchJson(`/api/quantitative${force ? '?force=1' : ''}`);
state.quantitative = payload;
if (payload?.items?.length) {
const currentId = state.quantitativeMatchId;
if (!currentId || !payload.items.find((item) => item.matchId === currentId)) {
state.quantitativeMatchId = payload.items[0]?.matchId || '';
}
}
}
function syncPortfolioMarketSuggestion() {
const matchSelect = $('#portfolioMatchId');
const marketInput = $('#portfolioMarket');
const selectionInput = $('#portfolioSelection');
if (!matchSelect || !marketInput || !selectionInput) return;
const selectedMatchId = matchSelect.value;
const target = (state.analysis?.perMatch || []).find((m) => m.matchId === selectedMatchId);
if (!target) return;
const top = target.topRecommendation || null;
if (top && top.market && top.selection) {
marketInput.value = top.market;
selectionInput.value = top.selection;
}
}
async function postJson(path, payload) {
const resp = await fetch(path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`${path} 發送失敗: ${resp.status} ${text}`);
}
return resp.json();
}
async function refreshQuantitativeData(force = false) {
renderStatus('重算量化模型...', 'loading');
await loadQuantitativeCoreData(force);
renderPage();
renderStatus('量化模型已更新', 'ok');
}
function renderPage() {
renderSummaryCards(state.matches.length);
renderSourceRegistry(state.sourceRegistry);
renderMatches();
renderCombos();
renderSinglePlaybook();
renderParlayStrategy();
renderPortfolioSummary();
renderMultiLegParlay();
renderSchedule();
renderSingleBuckets();
renderUpsetSignalsOverview();
renderSystemPlaybook();
renderCrossMarketPairs();
renderBankrollGuide();
renderTodayInsightCards();
renderTodayMatchCards();
renderAnalyticBars();
renderDashboardMarketMatrix();
renderDashboardSharpMoney();
renderDashboardLiveCenter();
renderQuantitativePage();
renderPortfolioTrackerPage();
}
async function fetchJson(path) {
const resp = await fetch(path);
if (!resp.ok) {
const text = await resp.text();
throw new Error(`${path} 呼叫失敗: ${resp.status} ${text}`);
}
return resp.json();
}
async function loadData() {
renderStatus('更新資料中...', 'loading');
const matchesPayload = await fetchJson('/api/matches?force=1');
state.matches = matchesPayload.matches || [];
renderStatus(`已抓取 ${state.matches.length} 場比賽`, 'ok');
const analysisPayload = await fetchJson('/api/analyze');
state.analysis = analysisPayload;
const schedulePayload = await fetchJson('/api/schedule-comparison');
state.schedule = schedulePayload;
const todayPayload = await fetchJson('/api/today-insights');
state.todayInsights = todayPayload;
const sourcePayload = await fetchJson('/api/source-registry');
state.sourceRegistry = sourcePayload.sources || [];
const page = CURRENT_PAGE;
const tasks = [];
if (page === 'dashboard') tasks.push(loadDashboardData());
if (page === 'quantitative') tasks.push(loadQuantitativeCoreData());
if (page === 'portfolio') tasks.push(loadPortfolioData());
if (tasks.length) {
await Promise.all(tasks);
}
renderPage();
}
async function refreshAnalysis() {
renderStatus('重新計算下注策略...', 'loading');
const result = await fetchJson('/api/analyze');
state.analysis = result;
const todayPayload = await fetchJson('/api/today-insights');
state.todayInsights = todayPayload;
renderPage();
renderStatus('策略已更新', 'ok');
}
async function loadSchedule() {
renderStatus('更新賽程比對...', 'loading');
const result = await fetchJson('/api/schedule-comparison');
state.schedule = result;
renderPage();
renderStatus('賽程比對完成', 'ok');
}
document.addEventListener('DOMContentLoaded', async () => {
const btnRefresh = $('#btnRefresh');
const btnAnalyze = $('#btnAnalyze');
const btnSchedule = $('#btnSchedule');
const marketFilter = $('#marketFilter');
const btnRefreshQuant = $('#btnRefreshQuant');
const btnRefreshPortfolio = $('#btnRefreshPortfolio');
const portfolioForm = $('#portfolioForm');
const portfolioMatchSelect = $('#portfolioMatchId');
const quantMatchSelect = $('#quantMatchSelect');
if (btnRefresh) {
btnRefresh.addEventListener('click', async () => {
try {
await loadData();
} catch (e) {
renderStatus(`更新失敗:${e.message}`, 'error');
}
});
}
if (btnAnalyze) {
btnAnalyze.addEventListener('click', async () => {
try {
await refreshAnalysis();
} catch (e) {
renderStatus(`策略更新失敗:${e.message}`, 'error');
}
});
}
if (btnSchedule) {
btnSchedule.addEventListener('click', async () => {
try {
await loadSchedule();
} catch (e) {
renderStatus(`賽程比對失敗:${e.message}`, 'error');
}
});
}
if (marketFilter) {
marketFilter.addEventListener('change', (e) => {
state.filter = e.target.value;
renderMatches();
});
}
if (quantMatchSelect) {
quantMatchSelect.addEventListener('change', () => {
state.quantitativeMatchId = quantMatchSelect.value;
renderQuantitativePage();
});
}
if (btnRefreshQuant) {
btnRefreshQuant.addEventListener('click', async () => {
try {
await refreshQuantitativeData(true);
} catch (e) {
renderStatus(`量化更新失敗:${e.message}`, 'error');
}
});
}
if (portfolioMatchSelect) {
portfolioMatchSelect.addEventListener('change', syncPortfolioMarketSuggestion);
}
if (btnRefreshPortfolio) {
btnRefreshPortfolio.addEventListener('click', async () => {
try {
await loadPortfolioData();
renderPage();
renderStatus('投注紀錄已刷新', 'ok');
} catch (e) {
renderStatus(`投注紀錄刷新失敗:${e.message}`, 'error');
}
});
}
if (portfolioForm) {
portfolioForm.addEventListener('submit', async (e) => {
e.preventDefault();
const marketInput = $('#portfolioMarket');
const selectionInput = $('#portfolioSelection');
const matchInput = $('#portfolioMatchId');
const oddsInput = $('#portfolioOdds');
const stakeInput = $('#portfolioStake');
const pointInput = $('#portfolioPoint');
const resultInput = $('#portfolioResult');
const payload = {
matchId: matchInput ? matchInput.value : '',
market: marketInput ? marketInput.value : '',
selection: selectionInput ? selectionInput.value : '',
odds: Number(oddsInput ? oddsInput.value : NaN),
stake: Number(stakeInput ? stakeInput.value : NaN),
point: pointInput && pointInput.value !== '' ? Number(pointInput.value) : null,
result: resultInput ? resultInput.value || 'open' : 'open',
};
try {
await postJson('/api/portfolio', payload);
await loadPortfolioData();
syncPortfolioMarketSuggestion();
renderPortfolioTrackerPage();
renderStatus('投注紀錄已更新', 'ok');
if (oddsInput) oddsInput.value = '';
if (stakeInput) stakeInput.value = '';
if (selectionInput) selectionInput.value = '';
} catch (err) {
renderStatus(`提交失敗:${err.message}`, 'error');
}
});
}
const navLinks = document.querySelectorAll('.top-nav a');
navLinks.forEach((link) => {
const href = (link.getAttribute('href') || '').replace('./', '');
if ((CURRENT_PAGE === 'home' && (href === '' || href === 'index.html')) || href.includes(CURRENT_PAGE)) {
link.classList.add('active');
}
});
syncPortfolioMarketSuggestion();
try {
await loadData();
} catch (e) {
renderStatus(`載入失敗:${e.message}`, 'error');
}
});