Skip to content

Commit 94c62bb

Browse files
authored
Merge pull request #181 from choigod1023/codex/restore-live-pick-probability
fix: 경기 중 사전 픽·확률 및 현재 추정 표시 복구
2 parents ced56e6 + a98a601 commit 94c62bb

4 files changed

Lines changed: 185 additions & 29 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { scheduledAt } from "./match-status.js";
2+
import { estimateLiveProbability } from "./bet-ledger.js";
3+
4+
const numeric = (value) => typeof value === "number" || (typeof value === "string" && value.trim())
5+
? Number(value) : NaN;
6+
const probability = (value) => {
7+
const n = numeric(value);
8+
return Number.isFinite(n) && n > 0 && n < 1 ? n : null;
9+
};
10+
11+
/** Historical display only: never reconstruct a missing pregame pick from live odds. */
12+
export function savedLivePrediction(game, live = null, now = Date.now()) {
13+
const record = game?.prediction_record;
14+
const kickoff = scheduledAt(game);
15+
const captured = Date.parse(record?.captured_at || "");
16+
if (!record?.selection_id || !record.market || !record.selection
17+
|| kickoff == null || !Number.isFinite(captured) || captured >= kickoff || captured > now) return null;
18+
const option = {
19+
selection_id: record.selection_id, offer_id: record.offer_id,
20+
market: record.market, label: record.label || "", 선택: record.selection,
21+
배당: Number.isFinite(numeric(record.odds)) ? numeric(record.odds) : null,
22+
};
23+
const openingProbability = probability(record.probability);
24+
const result = { option, openingProbability, capturedAt: record.captured_at, estimate: null,
25+
estimateStatus: openingProbability === null ? "missing_opening" : "waiting_live" };
26+
if (openingProbability === null || !live || live.status === "BEFORE") return result;
27+
if (live.finished || live.cancelled || live.postponed) return { ...result, estimateStatus: "closed" };
28+
const observed = Date.parse(game._liveFeedAt || live.observed_at || "");
29+
if (!Number.isFinite(observed) || now - observed > 10 * 60 * 1000 || observed > now + 5000) {
30+
return { ...result, estimateStatus: "stale_live" };
31+
}
32+
if (![live.home_score, live.away_score].every((v) => Number.isInteger(numeric(v)) && numeric(v) >= 0)) {
33+
return { ...result, estimateStatus: "missing_score" };
34+
}
35+
// The existing estimator has no handicap/period/margin model. Do not present
36+
// its generic win/loss fallback as a probability for those distinct contracts.
37+
const supportedChoice = {
38+
"승패": ["홈", "원정", "승", "패"],
39+
"승무패": ["홈", "원정", "승", "패", "무", "무승부"],
40+
"언더오버": ["언더", "오버"],
41+
}[record.market]?.includes(record.selection);
42+
if (!supportedChoice || !["sc", "bs", "bk"].includes(game.sport)
43+
|| (record.market === "언더오버" && !/\d/.test(option.label))) {
44+
return { ...result, estimateStatus: "unsupported_market" };
45+
}
46+
const estimate = estimateLiveProbability({ openingProbability, game: { sport: game.sport },
47+
selection: { market: option.market, label: option.label, choice: option.선택 } }, live);
48+
return { ...result, estimate, estimateStatus: "available" };
49+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import { savedLivePrediction } from "./saved-live-prediction.js";
4+
5+
const now = Date.parse("2026-09-05T11:00:00Z");
6+
const record = { selection_id: "saved", offer_id: "old-offer", market: "승패", label: "",
7+
selection: "홈", odds: 1.8, probability: .57, captured_at: "2026-09-05T08:00:00Z" };
8+
const game = { year: 2026, date: "09.05(토) 18:00", sport: "bs", home: "홈팀", away: "원정팀",
9+
prediction_record: record, options: [], _liveFeedAt: new Date(now).toISOString() };
10+
const live = { status: "STARTED", status_text: "6회초", home_score: 4, away_score: 1 };
11+
12+
test("empty options retain the recorded pick and probability for live estimates", () => {
13+
const result = savedLivePrediction(game, live, now);
14+
assert.equal(result.option.선택, "홈");
15+
assert.equal(result.openingProbability, .57);
16+
assert.ok(result.estimate.probability > .57);
17+
assert.equal(record.probability, .57);
18+
});
19+
test("changed live odds, selection identity and probabilities never overwrite the prior", () => {
20+
const result = savedLivePrediction({ ...game, _liveOddsChanged: true,
21+
options: [{ selection_id: "new", 선택: "원정", 시장확률: .9, 배당: 1.1 }] }, live, now);
22+
assert.equal(result.option.selection_id, "saved");
23+
assert.equal(result.option.배당, 1.8);
24+
assert.equal(result.openingProbability, .57);
25+
});
26+
for (const value of [null, undefined, "", " ", true, 0, 1, -1, NaN, Infinity]) {
27+
test(`missing/invalid saved probability (${String(value)}) keeps the pick but no fabricated estimate`, () => {
28+
const result = savedLivePrediction({ ...game, prediction_record: { ...record, probability: value } }, live, now);
29+
assert.equal(result.option.선택, "홈");
30+
assert.equal(result.openingProbability, null);
31+
assert.equal(result.estimate, null);
32+
});
33+
}
34+
test("no record or post-kickoff record never creates a retrospective pick", () => {
35+
assert.equal(savedLivePrediction({ ...game, prediction_record: null }, live, now), null);
36+
for (const captured_at of ["2026-09-05T09:00:00Z", "2026-09-05T10:00:00Z", "bad", null]) {
37+
assert.equal(savedLivePrediction({ ...game, prediction_record: { ...record, captured_at } }, live, now), null);
38+
}
39+
});
40+
test("missing/stale scores preserve the fixed prior, not a current estimate", () => {
41+
for (const value of [null, undefined, "", true, -1]) {
42+
assert.equal(savedLivePrediction(game, { ...live, home_score: value }, now).estimateStatus, "missing_score");
43+
}
44+
const stale = savedLivePrediction({ ...game, _liveFeedAt: "2026-09-05T10:00:00Z" }, live, now);
45+
assert.equal(stale.openingProbability, .57);
46+
assert.equal(stale.estimate, null);
47+
assert.equal(stale.estimateStatus, "stale_live");
48+
});
49+
test("totals always estimate against the saved line, never current lines", () => {
50+
const result = savedLivePrediction({ ...game, prediction_record: { ...record, market: "언더오버", label: "U/O 7.5", selection: "언더" },
51+
options: [{ market: "언더오버", label: "U/O 9.5", line: 9.5 }] }, live, now);
52+
assert.equal(result.option.label, "U/O 7.5");
53+
assert.equal(result.estimateStatus, "available");
54+
});
55+
test("unsupported contracts and finished games retain the prior without generic win estimates", () => {
56+
for (const market of ["핸디캡", "전반승패", "승①패", "승⑤패"]) {
57+
const result = savedLivePrediction({ ...game, prediction_record: { ...record, market } }, live, now);
58+
assert.equal(result.openingProbability, .57);
59+
assert.equal(result.estimateStatus, "unsupported_market");
60+
}
61+
assert.equal(savedLivePrediction(game, { ...live, finished: true }, now).estimateStatus, "closed");
62+
});

web/src/pages/Markets.jsx

Lines changed: 25 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ import { alignTodayRecommendations, buildTodayMemberships,
1616
import { usePolledData } from "../lib/poll.js";
1717
import { availableToday, nextTodayRefreshDelay } from "../lib/today-plan.js";
1818
import { freshnessStatus, waitingLabel } from "../lib/data-freshness.js";
19-
import { decisionFrozen, gamePhase, PHASE_LABEL, recommendationOutcome } from "../lib/match-status.js";
19+
import { decisionFrozen, gamePhase, PHASE_LABEL, recommendationOutcome, scheduledAt } from "../lib/match-status.js";
2020
import { predictionForGame } from "../lib/game-prediction.js";
21-
import { estimateLiveProbability } from "../lib/bet-ledger.js";
21+
import { savedLivePrediction } from "../lib/saved-live-prediction.js";
2222
import { commentaryMethod, directPickReason } from "../lib/recommendation.js";
2323
import { compactTeamPlayerLine } from "../lib/team-preview.js";
2424
import { deduplicateGameCards } from "../lib/game-dedup.js";
@@ -609,7 +609,7 @@ function MarketHistory({ rows }) {
609609
</details>;
610610
}
611611

612-
function Game({ g, opts, wait, grades, lv, stale, generatedAt, year, todayMembership,
612+
export function Game({ g, opts, wait, grades, lv, stale, generatedAt, year, todayMembership,
613613
todayOption, highlightedToday, onSaveBet }) {
614614
// 같은 마켓의 두 선택지가 같은 등급이면 '=' — 어느 쪽을 사도 같아 고를 근거가 없다
615615
const tie = useMemo(() => {
@@ -626,25 +626,23 @@ function Game({ g, opts, wait, grades, lv, stale, generatedAt, year, todayMember
626626
// 모델 최대확률을 골라 새 추천을 만들지 않는다.
627627
const done = g.status === "정산";
628628
const liveClosed = g._liveStarted === true;
629+
const phase = gamePhase(g, lv);
630+
const kickoff = scheduledAt(g);
631+
const started = liveClosed || phase !== "upcoming" || (kickoff != null && kickoff <= Date.now());
632+
const locked = started || decisionFrozen(g);
633+
const saved = savedLivePrediction(g, lv);
629634
const predictionUnavailable = !g.prediction_record
630635
&& (done || g.prediction_status === "prediction_ledger_required");
631636
const prediction = wait || stale || predictionUnavailable || g._liveOddsChanged
632637
? null : predictionForGame(opts);
633-
const savedPick = g.prediction_record;
634-
const savedOption = savedPick && {
635-
...(opts.find((o) => o.selection_id === savedPick.selection_id) || {}),
636-
selection_id: savedPick.selection_id, market: savedPick.market,
637-
label: savedPick.label || "", 선택: savedPick.selection, 배당: savedPick.odds,
638-
};
639-
const displayedOption = (decisionFrozen(g) && savedOption)
640-
|| todayOption || prediction?.option || null;
638+
const displayedOption = (locked && saved?.option)
639+
|| (!started ? todayOption || prediction?.option : null) || null;
641640
const pick = displayedOption ? {
642641
o: displayedOption,
643642
g: gradeOf(grades, displayedOption["배당"]),
644643
tie: false,
645644
} : null;
646645
// 프로토 정산은 경기가 끝나고도 한참 뒤다. 그 사이를 실시간 점수가 메운다.
647-
const phase = gamePhase(g, lv);
648646
// 필터 집계와 카드 본문이 반드시 같은 상태 판정을 사용해야 한다. 단순히
649647
// finished=false만 보면 갱신이 끊긴 중계 스냅샷이 카드에 영원히 LIVE로 남는다.
650648
const playing = phase === "live";
@@ -663,11 +661,11 @@ function Game({ g, opts, wait, grades, lv, stale, generatedAt, year, todayMember
663661
|| (outcome.source === "score" && g.sport === "sc" && lv?.regular_time_score)
664662
|| (lv && lv.home_score != null && lv.away_score != null
665663
? [lv.home_score, lv.away_score] : null);
666-
const analysis = wait || stale || predictionUnavailable || liveClosed
664+
const analysis = wait || stale || predictionUnavailable || started
667665
? null : performanceAnalysis(g, pick?.o || null, displayCommentary(g));
668666
const decision = analysis?.decision || buildDecisionViewModel(g, pick?.o || null);
669667
const forecast = analysis?.prediction;
670-
const fallbackForecast = disruption || (g._liveOddsChanged
668+
const fallbackForecast = disruption || (started && !saved ? "사전 예측 기록 없음" : g._liveOddsChanged
671669
? "배당 변경 · 재계산 대기"
672670
: stale
673671
? "최신 데이터 확인 필요"
@@ -678,20 +676,8 @@ function Game({ g, opts, wait, grades, lv, stale, generatedAt, year, todayMember
678676
: wait
679677
? "배당 발표 전"
680678
: "분석 자료 확인 중");
681-
const openingProbability = Number(
682-
decision?.probability?.final ?? pick?.o?.["예상적중확률"] ?? pick?.o?.["시장확률"],
683-
);
684-
const liveProbability = playing && pick && Number.isFinite(openingProbability)
685-
? estimateLiveProbability({
686-
openingProbability,
687-
game: { sport: g.sport },
688-
selection: {
689-
market: pick.o.market,
690-
label: pick.o.label || "",
691-
choice: pick.o["선택"],
692-
},
693-
}, lv)
694-
: null;
679+
const openingProbability = saved?.openingProbability ?? null;
680+
const liveProbability = playing ? saved?.estimate : null;
695681
const compactPlayers = compactTeamPlayerLine(analysis?.teamPreviews);
696682
const pendingLabel = g._liveOddsChanged ? "재계산" : stale ? "중단" : "보류";
697683
// 시작 전 산출물이 options=[]였던 경기는 해설에도 "배당 미발표"가 박혀 있다.
@@ -740,8 +726,10 @@ function Game({ g, opts, wait, grades, lv, stale, generatedAt, year, todayMember
740726
<span className="flex gap-1.5">
741727
{playing ? <>
742728
<span className="live-score-badge"><i />LIVE <b>{lv.status_text || "진행 중"}</b></span>
729+
{Number.isFinite(openingProbability) &&
730+
<OddsChip label="사전 확률" value={pct(openingProbability)} title="경기 시작 전에 저장된 픽의 고정 확률" />}
743731
{Number.isFinite(liveProbability?.probability) &&
744-
<OddsChip label="현재 적중" value={pct(liveProbability.probability)}
732+
<OddsChip label="현재 추정" value={pct(liveProbability.probability)}
745733
title="사전 확률에 현재 점수와 남은 시간을 반영한 상황 추정치" />}
746734
</>
747735
: disruption ? <OddsChip label="상태" value={disruption.replace("경기 ", "")} />
@@ -764,6 +752,14 @@ function Game({ g, opts, wait, grades, lv, stale, generatedAt, year, todayMember
764752
</span>
765753
</summary>
766754
<div className="match-detail">
755+
{locked && saved && !finished && (
756+
<div className="mb-3 rounded border border-rule2 bg-panel px-3 py-2 text-[12px]" aria-label="저장된 사전 예측">
757+
<b>경기 전 예측 픽 · {saved.option.market} {saved.option.label} {saved.option.선택}</b>
758+
<p>사전 확률 {Number.isFinite(openingProbability) ? pct(openingProbability) : "기록 없음"} · 당시 배당 {odds(saved.option.배당)}</p>
759+
{playing && !liveProbability && <small>현재 추정 확률은 {saved.estimateStatus === "unsupported_market"
760+
? "이 마켓에서 제공하지 않습니다." : "사전 확률과 최신 점수 자료가 모두 확인되어야 표시됩니다."}</small>}
761+
</div>
762+
)}
767763
<MarketHistory rows={g._marketHistory} />
768764
{todayMembership && recommendationReason && (
769765
<div className={`today-pick-signals ${highlightedToday ? "is-recommended" : "is-excluded"}`} role="note">
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import { fileURLToPath } from "node:url";
4+
import { createElement } from "react";
5+
import { renderToStaticMarkup } from "react-dom/server";
6+
import { createServer } from "vite";
7+
8+
test("live card renders the saved pick and prior even with no current options", async () => {
9+
const now = Date.parse("2026-09-05T11:00:00Z");
10+
const originalNow = Date.now;
11+
Date.now = () => now;
12+
const server = await createServer({ configFile: false,
13+
root: fileURLToPath(new URL("../../", import.meta.url)),
14+
esbuild: { jsx: "automatic" }, optimizeDeps: { noDiscovery: true, include: [] },
15+
server: { middlewareMode: true, watch: null }, appType: "custom" });
16+
try {
17+
const { Game } = await server.ssrLoadModule("/src/pages/Markets.jsx");
18+
const lv = { status: "STARTED", status_text: "6회초", home_score: 4, away_score: 1 };
19+
const g = { year: 2026, round: 105, date: "09.05(토) 18:00", sport: "bs",
20+
home: "홈팀", away: "원정팀", status: "경기전", options: [],
21+
_liveStarted: true, _liveFeedAt: new Date(now).toISOString(),
22+
prediction_record: { selection_id: "saved", market: "승패", selection: "홈", label: "",
23+
odds: 1.8, probability: .57, captured_at: "2026-09-05T08:00:00Z" } };
24+
const props = { g, opts: [], lv, wait: true, stale: true, grades: { odds_bins: [] }, year: 2026 };
25+
const html = renderToStaticMarkup(createElement(Game, props));
26+
assert.match(html, / /);
27+
assert.match(html, /57.0%/);
28+
assert.match(html, / /);
29+
assert.match(html, / /);
30+
assert.doesNotMatch(html, /NaN/);
31+
const missingProbability = renderToStaticMarkup(createElement(Game, { ...props,
32+
g: { ...g, prediction_record: { ...g.prediction_record, probability: null } } }));
33+
assert.match(missingProbability, / /);
34+
assert.match(missingProbability, / /);
35+
assert.doesNotMatch(missingProbability, /0.0%/);
36+
const unrecorded = renderToStaticMarkup(createElement(Game, { ...props,
37+
g: { ...g, prediction_record: null },
38+
todayOption: { market: "승패", 선택: "원정", 시장확률: .9 } }));
39+
assert.match(unrecorded, / /);
40+
assert.doesNotMatch(unrecorded, / |90.0%/);
41+
const stale = renderToStaticMarkup(createElement(Game, { ...props,
42+
g: { ...g, _liveFeedAt: "2026-09-05T10:00:00Z" } }));
43+
assert.match(stale, /57.0%/);
44+
assert.doesNotMatch(stale, / /);
45+
} finally {
46+
await server.close();
47+
Date.now = originalNow;
48+
}
49+
});

0 commit comments

Comments
 (0)