fix(stats): 예상지출 — 비정기 큰 지출(대출상환 등)을 모두 일평균에서 제외
Deploy / deploy (push) Failing after 14m58s

가장 큰 하루 1건만 제외하던 것을, 중앙값 3배(또는 10만원) 초과 '모든' 날 제외로 변경.
대출상환·다건 일회성이 일평균에 남아 남은 날에 곱해져 예상지출이 과다 추정되던 문제 해결.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-06 02:01:33 +09:00
parent a52ac6532c
commit 5b46ba93f3
+21 -10
View File
@@ -72,16 +72,27 @@ const expenseDelta = computed(() => {
일별 지출을 받아 '평소 하루 지출'을 구하되, 일회성 큰 지출(노트북 등)이 있는 날은 일별 지출을 받아 '평소 하루 지출'을 구하되, 일회성 큰 지출(노트북 등)이 있는 날은
일평균을 왜곡하므로 제외한다. 추정 = 실제 지출(일회성 포함) + 남은 날 × 평소 하루 지출. */ 일평균을 왜곡하므로 제외한다. 추정 = 실제 지출(일회성 포함) + 남은 날 × 평소 하루 지출. */
function robustDailyRate(days) { function robustDailyRate(days) {
if (!days.length) return { rate: 0, dropped: 0 } if (!days.length) return { rate: 0, dropped: 0, droppedCount: 0 }
const sorted = [...days].sort((a, b) => a - b) const sorted = [...days].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2) const mid = Math.floor(sorted.length / 2)
const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2 const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
const max = sorted[sorted.length - 1] // 비정기 큰 지출(대출상환·가전·여행 등)은 남은 날에 반복되지 않으므로,
// 최대 지출일이 중앙값의 3배(그리고 10만원)를 넘으면 일회성으로 보고 하루평균 산정에서 제외 // 중앙값의 3배(또는 10만원)를 넘는 '모든' 날을 하루평균 산정에서 제외(가장 큰 1건만이 아님).
const dropOutlier = sorted.length >= 4 && max > Math.max(median * 3, 100000) const threshold = Math.max(median * 3, 100000)
const base = dropOutlier ? sorted.slice(0, -1) : sorted const canTrim = sorted.length >= 4
const rate = base.reduce((a, b) => a + b, 0) / base.length const kept = []
return { rate, dropped: dropOutlier ? max : 0 } let dropped = 0
let droppedCount = 0
for (const d of sorted) {
if (canTrim && d > threshold) {
dropped += d
droppedCount++
} else {
kept.push(d)
}
}
const rate = kept.length ? kept.reduce((a, b) => a + b, 0) / kept.length : 0
return { rate, dropped, droppedCount }
} }
async function loadForecast() { async function loadForecast() {
forecast.value = null forecast.value = null
@@ -98,12 +109,12 @@ async function loadForecast() {
;(daily || []).forEach((d) => (map[String(d.bucket)] = d.expense || 0)) ;(daily || []).forEach((d) => (map[String(d.bucket)] = d.expense || 0))
const days = [] const days = []
for (let d = 1; d <= elapsed; d++) days.push(map[String(d)] || 0) for (let d = 1; d <= elapsed; d++) days.push(map[String(d)] || 0)
const { rate, dropped } = robustDailyRate(days) const { rate, dropped, droppedCount } = robustDailyRate(days)
const remaining = totalDays - elapsed const remaining = totalDays - elapsed
forecast.value = Math.round(monthExpense.value + rate * remaining) forecast.value = Math.round(monthExpense.value + rate * remaining)
forecastNote.value = forecastNote.value =
dropped > 0 droppedCount > 0
? `가장 큰 하루 지출(${won(dropped)})은 일회성으로 보고 남은 ${remaining}일 추정에서 제외` ? `대출상환 등 비정기 큰 지출 ${droppedCount}건(합계 ${won(dropped)})은 남은 ${remaining}일 추정에서 제외`
: `최근 하루 평균 지출로 남은 ${remaining}일을 더한 추정` : `최근 하루 평균 지출로 남은 ${remaining}일을 더한 추정`
} catch { } catch {
forecast.value = null forecast.value = null