1833 lines
65 KiB
JavaScript
1833 lines
65 KiB
JavaScript
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');
|
||
}
|
||
});
|