Skip to content

Commit 0db8056

Browse files
committed
декумуляція: на скільки вистачить без внесків
Питання, зворотне до всієї решти вкладки. Модель накопичення відповідає на «скільки я накопичу»; ця — на «на скільки цього вистачить, якщо перестати вносити й почати знімати». Власна функція домену, а не від'ємний внесок у наявну: projState.step поняття зняття не має взагалі, і від'ємний contrib зламав би логіку порога — floor(cash/threshold) від'ємної готівки перекидав би в роботу гроші, яких немає. ЧОТИРИ ПРИПУЩЕННЯ, і кожне можна оскаржити. Названі в коментарі domain/drawdown.go і повторені в довідці картки, бо число одне, а стоїть за ним ось це: 1. знімаємо спершу готівку, потім працюючу частину — продаємо лише те, що продається без втрат; 2. ЗАМКНЕНЕ (номінал ОВДП, тіло вкладів, сертифікати) достроково не продається, лише віддає купони й погашення за графіком. Найконсерва- тивніша з розумних угод: ОВДП на вторинці продати можна, але за ціною, якої застосунок не знає, а вклад — із втратою відсотків; 3. зняття задане в СЬОГОДНІШНІХ гривнях і дорожчає зі знеціненням; 4. між валютами ділиться пропорційно їхнім ЛІКВІДНИМ часткам. Четверте припущення — виправлення помилки, яку я зробив у першій версії й упіймав на власній фікстурі. Зняття ділилось пропорційно ПОВНІЙ вартості рукава, а платити може лише ліквідна частина. Доларовий рукав із трьома тисячами номіналу отримував свою «частку», покрити її не міг, і недобір нікуди не переносився — портфель «вичерпувався» першого ж місяця, маючи пів мільйона готівки в гривневому рукаві. Golden показував months=1 при капіталі 922 660 ₴ і знятті 20 000. Тепер поділ іде по ліквідному, і на тій самій фікстурі виходить 45 місяців. covered_pct — яку частку зняття покриває сьогоднішній дохід. Саме вона пояснює, ЧОМУ число таке: 100% означає, що портфель живе з потоку й тіла не чіпає взагалі. Картка розрізняє три відповіді, і жодну не можна показати замість іншої: «не вичерпується» (потоки покривають), «не вистачить і на місяць» (ліквідного менше за одне зняття) і сам строк. На живих даних це рівно другий випадок — капітал 19 078 ₴ проти 50 000 ₴/міс витрат, — і картка каже це словами, а не малює «0 місяців». ЗМІНА GOLDEN — доданий блок drawdown (7 рядків). Жодне наявне число не зрушило. Фікстури контракту не змінились. Сторожі. У домені сім: готівка без дохідності тане рівно на суму зняття (120 000 по 10 000 — тринадцятий місяць); дохід, що покриває зняття, дає −1, а не 0 (плутати їх не можна, це протилежності); замкнене не продається; погашення поповнює готівку й подовжує життя; поділ іде по ліквідному, а не повному; зняття індексується знеціненням. У фазі три: явна сума перемагає витрати; більше зняття вичерпує раніше (−1 тут означає НАЙДОВШЕ, і порівнювати його як число не можна); покриття рахується від того самого доходу, що в сусідніх картках. Мутаційно перевірено чотири злами: замкнене продається достроково, зняття не індексується, «не вистачає» перетворене на «не вичерпується», погашення не поповнює готівку.
1 parent 863de08 commit 0db8056

12 files changed

Lines changed: 439 additions & 3 deletions

File tree

contract/oddinvest-state.schema.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,17 @@
656656
"unlock_date": { "type": "string" }
657657
}
658658
},
659+
"drawdown": {
660+
"type": "object",
661+
"description": "На скільки вистачить, якщо перестати вносити й почати знімати. Припущення: замкнене (номінал ОВДП, тіло вкладів, сертифікати) достроково НЕ продається — воно лише віддає купони й погашення за графіком (адитивне поле)",
662+
"properties": {
663+
"withdraw_uah": { "type": "number", "description": "Скільки знімати щомісяця, ₴ у сьогоднішніх грошах" },
664+
"withdraw_from": { "type": "string", "enum": ["setting", "expenses"], "description": "Сума задана явно чи взята з місячних витрат" },
665+
"months": { "type": "integer", "description": "На скільки місяців вистачить; -1 = не вичерпується за 60 років, бо потоки покривають зняття" },
666+
"until": { "type": "string", "format": "date" },
667+
"covered_pct": { "type": "number", "description": "Яку частку зняття покриває сьогоднішній дохід. Саме вона пояснює, чому число таке: 100% означає, що портфель живе з потоку й тіла не чіпає" }
668+
}
669+
},
659670
"independence": {
660671
"type": "object",
661672
"description": "Коли пасивний дохід покриє життя. Дві дати: за плановим внеском і за фактичним темпом — різниця між ними це ціна дисципліни, а не ринку (адитивне поле)",

contract/ui-manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@
1414
{ "path": "js/format.js", "sha256": "ebf4800d3d52b3154cbd937b228bd2f4f16975e83620b9ed096ee29380592491" },
1515
{ "path": "js/forms.js", "sha256": "971f334403306920b57241636faec6e25121a127f838282333cac9c0d7d28efd" },
1616
{ "path": "js/fund-ops.js", "sha256": "7efc8885a39a054655824315840bba89c6a60ed6ce0c1c597902fe100858c79b" },
17-
{ "path": "js/info.js", "sha256": "9583bc6c6d29ad57799b54ca037943533a71f65b8e9e93ad7c89809dddd2efec" },
17+
{ "path": "js/info.js", "sha256": "a48d3fafae85d1d186f89907b8bb89c4a3e20d78678e80b7fa4e3fca4be664b6" },
1818
{ "path": "js/store.js", "sha256": "58f81bbb8262d47856790206066f557b871169e25eea268c2bc34598ba03e120" },
1919
{ "path": "js/styles.js", "sha256": "7f623a4506b4336538ffce25d56eeab347afb912ceacadf76b8cdc05028f943f" },
2020
{ "path": "js/transport.js", "sha256": "7be0b7f7ea8317446ce769fc30c95d8bd281adfdcd67d8dc2350a8b6c96b52ae" },
2121
{ "path": "js/views/bonds.js", "sha256": "cbf6d03bc97ed22f21b58bad7dc52cfd072a844c9c139a154b4784452c43d32a" },
2222
{ "path": "js/views/deposits.js", "sha256": "91b3d789c53293885b994ea44e63bba6f726591d08db09177d28b2b4b75e4bca" },
2323
{ "path": "js/views/forecast.js", "sha256": "684c87ac6ea0e92f82fd0cd45cdb1cd88f329d23c967c4eced00dae8e0f6bdd7" },
24-
{ "path": "js/views/future.js", "sha256": "431b72f6c3db1ee602db88d5addd89ae6b0239c15c1e7d2ecebe38bf638db2c7" },
24+
{ "path": "js/views/future.js", "sha256": "efc66c4a89dcce601852fd4f3955d86ca0413818b2513c39fbf4bcdc7d44068e" },
2525
{ "path": "js/views/history.js", "sha256": "6e6efb9e2221e78cbd2274990c8039348c29cd35ddc3204fead0969cdbc08545" },
2626
{ "path": "js/views/money.js", "sha256": "63a25e448969cf90472f81cbe36e01b58fbfcb4d68842f1c9575faedc98eec7e" },
2727
{ "path": "js/views/overview.js", "sha256": "38799ae16da85dcf1c243c462c485039c90b7eb7f1b830f0602e507ce01327e6" },

internal/api/state_builder.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,7 @@ func (s *Server) buildState(ctx context.Context, now time.Time) (*state.Doc, err
750750

751751
Projection: projection, ProjectionRatePct: capRate, Forecast: forecast,
752752
Sensitivity: prj.Sensitivity, Independence: prj.Independence,
753+
Drawdown: prj.Drawdown,
753754
Rebalance: rebalance, Concentration: concentration,
754755
RateRisk: rateRisk, Liquidity: liquidity,
755756
AccruedUAH: round2(float64(accruedUAH) / 100), NBURefreshedAt: nbuAt,

internal/api/state_independence.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
package api
1717

1818
import (
19+
"math"
20+
1921
"github.com/ODDsama/oddinvest/internal/domain"
2022
"github.com/ODDsama/oddinvest/internal/state"
2123
)
@@ -68,3 +70,42 @@ func buildIndependence(in independenceInput) *state.Independence {
6870
}
6971
return out
7072
}
73+
74+
// drawdownInput — понад те, що вже порахувала проєкція.
75+
type drawdownInput struct {
76+
Factory sleeveFactory
77+
Deval float64
78+
// WithdrawUAH — скільки знімати щомісяця, у СЬОГОДНІШНІХ гривнях;
79+
// WithdrawFrom — "setting" чи "expenses".
80+
WithdrawUAH float64
81+
WithdrawFrom string
82+
// IncomeNowUAH — сьогоднішній дохід. Потрібен, щоб сказати, яку
83+
// частку зняття портфель покриває вже зараз: саме це пояснює, чому
84+
// число вийшло таким, а не інакшим.
85+
IncomeNowUAH float64
86+
Today domain.Date
87+
}
88+
89+
// buildDrawdown рахує, на скільки вистачить без внесків.
90+
func buildDrawdown(in drawdownInput) *state.Drawdown {
91+
if in.WithdrawUAH <= 0 {
92+
return nil
93+
}
94+
// Рукави БЕЗ внеску: це й є декумуляція.
95+
sl := in.Factory.build(0, 0)
96+
months := domain.DrawdownMonths(sl, in.Deval, in.WithdrawUAH, goalHorizonMonths)
97+
out := &state.Drawdown{
98+
WithdrawUAH: round2(in.WithdrawUAH),
99+
WithdrawFrom: in.WithdrawFrom,
100+
Months: months,
101+
CoveredPct: round1(in.IncomeNowUAH / in.WithdrawUAH * 100),
102+
}
103+
if months > 0 {
104+
out.Until = string(domain.NewDate(in.Today.Time().AddDate(0, months, 0)))
105+
}
106+
return out
107+
}
108+
109+
// round1 — одна десята. Той самий крок, що й у goal_pct: два різні
110+
// заокруглення для однорідних відсотків читались би як розбіжність.
111+
func round1(v float64) float64 { return math.Round(v*10) / 10 }

internal/api/state_independence_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,66 @@ func indepInput(t *testing.T, expenses, target float64) projectionInput {
1717
return in
1818
}
1919

20+
// TestDrawdownWithdrawPrefersSetting — «скільки знімати» окреме питання
21+
// від «який дохід достатній»: жити можна й скромніше за ціль.
22+
func TestDrawdownWithdrawPrefersSetting(t *testing.T) {
23+
in := indepInput(t, 50_000, 30_000)
24+
w := 12_000.0
25+
in.Settings.WithdrawMonthlyUAH = &w
26+
out := buildProjection(in).Drawdown
27+
if out == nil {
28+
t.Fatal("декумуляції немає")
29+
}
30+
if out.WithdrawUAH != 12_000 || out.WithdrawFrom != "setting" {
31+
t.Errorf("зняття %v (%s); очікували 12000 із налаштування",
32+
out.WithdrawUAH, out.WithdrawFrom)
33+
}
34+
fallback := buildProjection(indepInput(t, 50_000, 30_000)).Drawdown
35+
if fallback.WithdrawUAH != 50_000 || fallback.WithdrawFrom != "expenses" {
36+
t.Errorf("спад дав %v (%s); очікували 50000 із витрат",
37+
fallback.WithdrawUAH, fallback.WithdrawFrom)
38+
}
39+
}
40+
41+
// TestDrawdownBiggerWithdrawalLastsLess — більше зняття вичерпує портфель
42+
// раніше.
43+
//
44+
// Нуль тут неможливий (перший непокритий місяць це вже 1), а −1 означає
45+
// «не вичерпується», тобто НАЙДОВШЕ. Порівнювати його як число не можна.
46+
func TestDrawdownBiggerWithdrawalLastsLess(t *testing.T) {
47+
small := 3_000.0
48+
in := indepInput(t, 50_000, 30_000)
49+
in.Settings.WithdrawMonthlyUAH = &small
50+
a := buildProjection(in).Drawdown.Months
51+
52+
big := 30_000.0
53+
in2 := indepInput(t, 50_000, 30_000)
54+
in2.Settings.WithdrawMonthlyUAH = &big
55+
b := buildProjection(in2).Drawdown.Months
56+
57+
rank := func(m int) int {
58+
if m == -1 {
59+
return 1 << 30 // не вичерпується — найдовше з можливого
60+
}
61+
return m
62+
}
63+
if rank(b) >= rank(a) {
64+
t.Errorf("зняття 30 000 вистачило на %d, а 3 000 — на %d", b, a)
65+
}
66+
}
67+
68+
// TestDrawdownCoveredPctExplainsTheNumber — покриття рахується від того
69+
// самого доходу, що в сусідніх картках.
70+
func TestDrawdownCoveredPctExplainsTheNumber(t *testing.T) {
71+
in := indepInput(t, 50_000, 30_000) // IncomeMonthlyNow = 1234.56
72+
w := 12_345.6
73+
in.Settings.WithdrawMonthlyUAH = &w
74+
out := buildProjection(in).Drawdown
75+
if out.CoveredPct != 10 {
76+
t.Errorf("покриття %v%%, очікували 10 (1234.56 з 12345.60)", out.CoveredPct)
77+
}
78+
}
79+
2080
// TestIndependenceTargetPrefersSetting — явна ціль доходу перемагає
2181
// місячні витрати.
2282
//

internal/api/state_projection.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ type projectionPhase struct {
114114
// Independence — коли дохід покриє життя (state_independence.go).
115115
// Теж тут і з тієї самої причини: потрібна та сама фабрика.
116116
Independence *state.Independence
117+
// Drawdown — на скільки вистачить без внесків (там само).
118+
Drawdown *state.Drawdown
117119
}
118120

119121
// sleeveFactory — усе, що потрібно, щоб зібрати валютні рукави під
@@ -356,6 +358,21 @@ func buildProjection(in projectionInput) projectionPhase {
356358
IncomeNowUAH: in.IncomeMonthlyNow, Today: today,
357359
})
358360

361+
// Скільки знімати — окреме питання від того, який дохід достатній:
362+
// жити можна й скромніше за ціль. Спад той самий, на витрати.
363+
withdraw, withdrawFrom := 0.0, "expenses"
364+
if in.Settings.MonthlyExpensesUAH != nil {
365+
withdraw = *in.Settings.MonthlyExpensesUAH
366+
}
367+
if in.Settings.WithdrawMonthlyUAH != nil && *in.Settings.WithdrawMonthlyUAH > 0 {
368+
withdraw, withdrawFrom = *in.Settings.WithdrawMonthlyUAH, "setting"
369+
}
370+
out.Drawdown = buildDrawdown(drawdownInput{
371+
Factory: factory, Deval: in.Deval,
372+
WithdrawUAH: withdraw, WithdrawFrom: withdrawFrom,
373+
IncomeNowUAH: in.IncomeMonthlyNow, Today: today,
374+
})
375+
359376
// Старт проєкції — капітал БЕЗ резерву. Решта входить уся, разом із
360377
// сертифікатами й вкладами: інакше крива починалась би нижче за
361378
// плитку «Капітал» на ту саму суму (доти так і було — вклади сюди не

internal/api/testdata/state_rich.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,13 @@
911911
"actual_date": "2032-07-15",
912912
"capital_uah": 5194491.3
913913
},
914+
"drawdown": {
915+
"withdraw_uah": 20000,
916+
"withdraw_from": "setting",
917+
"months": 45,
918+
"until": "2030-04-15",
919+
"covered_pct": 7.7
920+
},
914921
"rebalance": [
915922
{
916923
"dimension": "currency",

0 commit comments

Comments
 (0)