Skip to content

Commit 863de08

Browse files
committed
крива до дедлайну замість чотирьох точок
«Крива капіталу» малювала лінію по тих самих 1/3/5/10 роках, що й таблиця під нею. Вона казала, ЧИМ усе скінчиться, і мовчала про те, коли саме траєкторія розходиться з планом. А розходиться вона нерівномірно: перші роки будуються з фактичного календаря виплат, тож мають форму, якої в сухому складному відсотку немає. Нова domain.ProjectSleevesSeries — та сама симуляція, але з відліком на кожному кроці. Рукави крокують у лок-степі, як у MonthsToReachSleeves і з тієї самої причини: підсумок береться по всіх валютах разом, і кожна мусить бути на тому самому місяці. Одна симуляція на сценарій — чотири разом, а не перезапуск на кожну точку. Дві угоди записані в коді як умови, а не як побажання. Точка на нулі є завжди (крива, що починається з першого кроку, візуально стрибає від нуля, і читач бачить приріст, якого не було). Остання точка завжди дорівнює горизонту, навіть якщо крок туди не влучає — інакше кінець кривої розходився б із сумою, яку та сама модель називає в картці прогнозу. Крок росте з горизонтом, щоб точок лишалось близько дюжини. Помісячна крива на десяти роках це 120 точок на серію, тобто півтисячі чисел у документі заради лінії, у якій сусідні місяці візуально не відрізняються; документ читає ще й Home Assistant. Заразом scenarioDef піднято на рівень пакета: набори допущень віяла тепер проганяє ще й крива, і перелічувати їх удруге означало б завести другий шанс розійтись — той самий, який уже коштував нам двох визначень капіталу. У charts.js новий svgBandLine. Окремо від svgLine навмисно: той малює рівноправні серії, а тут у серій РІЗНІ ролі — дві задають межі коридору, третя йде посередині, четверта показує відхилення, а ціль це взагалі не серія. Запхати ролі в svgLine означало б додати йому чотири прапорці й зламати два наявні виклики. Ціль входить у масштаб: інакше її лінія вилітала б за полотно, і «не дотягуємо» виглядало б як «дотягуємо». Картка лишає запасний вигляд на чотирьох точках: старі бекенди кривої не надсилають, і панель не мусить показувати порожньо. ЗМІНА GOLDEN — доданий forecast.curve: 14 точок кроком 3 місяці на 39-місячному горизонті. Жодне наявне число не зрушило. Сторожі. У домені пʼять, головний — кінець кривої дорівнює тому, що на тому ж горизонті дає ProjectSleeves, і перевіряється на пʼятьох різних горизонтах. Це дві реалізації однієї моделі, і на екрані вони стоять поруч: крива веде до числа, яке картка називає словами. У фазі два: кінець кривої збігається з УСІМА чотирма рядками прогнозу (збіг одного буває випадковим — сценарії відрізняються лише допущеннями, і легко зібрати криву для одного набору, а підпис узяти з іншого), і розмір документа не росте помісячно. Мутаційно перевірено чотири злами: кінець не додається, коли крок не влучає; крива починається з першого кроку; всі сценарії рахуються від реалістичних допущень; крок завжди 1. Наживо на копії: картку відмальовано тим самим модулем, що й у браузері (node імпортує views/future.js і отримує справжній документ) — коридор, пунктир факту, лінія цілі й підписи років на місці.
1 parent aa273aa commit 863de08

11 files changed

Lines changed: 518 additions & 14 deletions

File tree

contract/oddinvest-state.schema.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,28 @@
712712
"contrib_plan": { "type": "number" },
713713
"rate0_usd": { "type": "number" },
714714
"glide_years": { "type": "number" },
715+
"curve": {
716+
"type": "object",
717+
"description": "Та сама модель помісячно: рядки кажуть, ЧИМ усе скінчиться, крива — коли саме траєкторія розходиться з планом (адитивне поле)",
718+
"properties": {
719+
"step_months": { "type": "integer", "minimum": 1, "description": "Крок між точками; росте з горизонтом, щоб точок лишалось близько дюжини" },
720+
"goal_uah": { "type": "number", "description": "Лінія цілі, щоб UI не діставав її з іншого місця" },
721+
"points": {
722+
"type": "array",
723+
"items": {
724+
"type": "object",
725+
"properties": {
726+
"month": { "type": "integer", "minimum": 0, "description": "Місяць від сьогодні; 0 — сьогоднішній капітал" },
727+
"plan": { "type": "number", "description": "Реалістичний сценарій, у сьогоднішніх ₴" },
728+
"optimistic": { "type": "number" },
729+
"pessimistic": { "type": "number" },
730+
"actual": { "type": "number", "description": "За фактичним темпом поповнень; відсутнє, поки темп невідомий" }
731+
},
732+
"required": ["month", "plan"]
733+
}
734+
}
735+
}
736+
},
715737
"rows": {
716738
"type": "array",
717739
"items": { "type": "object" }

contract/ui-manifest.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,21 @@
77
{ "path": "css/theme-web.css", "sha256": "c715fb6ccbd354b01a8d55b052a4c161b49c8f46756ecef2e97dd9e6a0e43b99" },
88
{ "path": "css/tokens.css", "sha256": "7f40aebc154d1e549f8d12afd17269dbe0a15dfa1367e1205eacfb05120d7a8c" },
99
{ "path": "js/app.js", "sha256": "0f8fa1b03887ffe3148ac4993795fe3661f263fb2a09f741a214592d56286a01" },
10-
{ "path": "js/charts.js", "sha256": "f7ac8dbca1ce57b3dfcfcf940a53f218fae4da1b0dbf5afe9d0cc3e8c1e5ad11" },
10+
{ "path": "js/charts.js", "sha256": "7c511649c89975b263af293d63a0657481b3c0203f08c3f135f215933fc519ea" },
1111
{ "path": "js/components.js", "sha256": "2116cd17793ed795998bde2bf274b57fc63f5a7c8f2666a70a7ccf52454389e9" },
1212
{ "path": "js/constants.js", "sha256": "6e1e88eeaed650d6f8db8c1edda6f95180afad53ce8fb1b04c862d39b7ecf28f" },
1313
{ "path": "js/disclosure.js", "sha256": "28a4313a71fd15a0b12b45695928f4edd16a314afb69ff731ff22a7b4c98dfef" },
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": "e6da3f6bff4afccd1cceb864050b93fbe1cc490d3613135ead3b8b7742865524" },
17+
{ "path": "js/info.js", "sha256": "9583bc6c6d29ad57799b54ca037943533a71f65b8e9e93ad7c89809dddd2efec" },
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": "a2b2a3a03090e85d43953183daef486f79fdb277272049d6d173efdd6ddc0538" },
24+
{ "path": "js/views/future.js", "sha256": "431b72f6c3db1ee602db88d5addd89ae6b0239c15c1e7d2ecebe38bf638db2c7" },
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_projection.go

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ const (
3939
defaultDevalSpreadPP = 4.0
4040
)
4141

42+
// scenarioDef — один набір допущень віяла: що вносимо і який ринок.
43+
//
44+
// Тип рівня пакета, а не локальний, бо ті самі набори проганяє ще й
45+
// крива (buildForecastCurve). Доти він жив усередині функції, і крива
46+
// мусила б їх перелічити вдруге — з тим самим шансом розійтись, який уже
47+
// коштував нам двох визначень капіталу.
48+
type scenarioDef struct {
49+
key, label string
50+
contrib, ratePP, deval float64
51+
}
52+
4253
// projectionInput — усе, від чого залежить проєкція.
4354
//
4455
// Перелік довгий, і це не вада, а те, заради чого фаза виділена: він
@@ -395,17 +406,13 @@ func buildProjection(in projectionInput) projectionPhase {
395406
if deadlineMonths <= 0 {
396407
return out
397408
}
398-
type scenario struct {
399-
key, label string
400-
contrib, ratePP, deval float64
401-
}
402-
defs := []scenario{
409+
defs := []scenarioDef{
403410
{"optimistic", "Оптимістично", out.ContribM, rateSpreadPP, math.Max(0, in.Deval-devalSpreadPP)},
404411
{"realistic", "Реалістично", out.ContribM, 0, in.Deval},
405412
{"pessimistic", "Песимістично", out.ContribM, -rateSpreadPP, in.Deval + devalSpreadPP},
406413
}
407414
if in.ActualMonthly > 0 {
408-
defs = append(defs, scenario{"actual", "За фактом", in.ActualMonthly, 0, in.Deval})
415+
defs = append(defs, scenarioDef{"actual", "За фактом", in.ActualMonthly, 0, in.Deval})
409416
}
410417
f := &state.Forecast{
411418
Date: string(domain.NewDate(today.Time().AddDate(0, deadlineMonths, 0))),
@@ -452,6 +459,56 @@ func buildProjection(in projectionInput) projectionPhase {
452459
}
453460
f.Rows = append(f.Rows, row)
454461
}
462+
f.Curve = buildForecastCurve(factory, defs, in.Deval, goalAmount, deadlineMonths)
455463
out.Forecast = f
456464
return out
457465
}
466+
467+
// buildForecastCurve — помісячна траєкторія кожного сценарію.
468+
//
469+
// Одна симуляція на сценарій, тобто чотири разом: ProjectSleevesSeries
470+
// проходить місяці один раз і віддає зрізи, а не перезапускається на
471+
// кожну точку.
472+
//
473+
// Крок підбирається так, щоб точок було близько дюжини. Помісячна крива
474+
// на десятирічному горизонті — це 120 точок на серію, тобто півтисячі
475+
// чисел у документі заради лінії, у якій сусідні місяці візуально не
476+
// відрізняються.
477+
func buildForecastCurve(factory sleeveFactory, defs []scenarioDef,
478+
deval, goal float64, months int) *state.ForecastCurve {
479+
if months <= 0 {
480+
return nil
481+
}
482+
step := months / 12
483+
if step < 1 {
484+
step = 1
485+
}
486+
// Ключ сценарію → зрізи. Місяці в усіх однакові, бо крок і горизонт
487+
// одні; збирати їх у точки можна за індексом.
488+
series := map[string][]domain.SeriesPoint{}
489+
for _, d := range defs {
490+
series[d.key] = domain.ProjectSleevesSeries(
491+
factory.build(d.contrib, d.ratePP), d.deval, months, step)
492+
}
493+
plan := series["realistic"]
494+
if len(plan) == 0 {
495+
return nil
496+
}
497+
at := func(key string, i int) float64 {
498+
s := series[key]
499+
if i >= len(s) {
500+
return 0
501+
}
502+
return round2(s[i].UAH)
503+
}
504+
out := &state.ForecastCurve{StepMonths: step, GoalUAH: goal}
505+
for i, p := range plan {
506+
out.Points = append(out.Points, state.ForecastCurvePoint{
507+
Month: p.Month, Plan: round2(p.UAH),
508+
Optimistic: at("optimistic", i),
509+
Pessimistic: at("pessimistic", i),
510+
Actual: at("actual", i),
511+
})
512+
}
513+
return out
514+
}

internal/api/state_projection_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,68 @@ func TestForecastSpreadDefaultsMatchOldConstants(t *testing.T) {
9494
}
9595
}
9696

97+
// TestForecastCurveEndsAtScenarioAmounts — кінець кривої дорівнює сумі
98+
// того самого сценарію в рядках прогнозу.
99+
//
100+
// Це два числа про одне й те саме на одному екрані: рядок каже «на
101+
// дедлайн буде X», крива веде туди лінією. Розійтись їм не можна —
102+
// читач побачив би лінію, яка закінчується не там, де написано.
103+
//
104+
// Перевіряються ВСІ чотири сценарії. Збіг одного буває випадковим:
105+
// сценарії відрізняються лише допущеннями, і легко зібрати криву для
106+
// одного набору, а підпис узяти з іншого.
107+
func TestForecastCurveEndsAtScenarioAmounts(t *testing.T) {
108+
in := forecastInput(t, goalSettings("", "2030-01-01"))
109+
in.ActualMonthly = 12_000
110+
f := buildProjection(in).Forecast
111+
if f == nil || f.Curve == nil || len(f.Curve.Points) == 0 {
112+
t.Fatal("кривої немає")
113+
}
114+
last := f.Curve.Points[len(f.Curve.Points)-1]
115+
if last.Month != f.Months {
116+
t.Errorf("крива обривається на місяці %d, а дедлайн на %d", last.Month, f.Months)
117+
}
118+
byKey := map[string]float64{}
119+
for _, r := range f.Rows {
120+
byKey[r.Key] = r.Amount
121+
}
122+
for _, c := range []struct {
123+
key string
124+
curv float64
125+
}{
126+
{"realistic", last.Plan},
127+
{"optimistic", last.Optimistic},
128+
{"pessimistic", last.Pessimistic},
129+
{"actual", last.Actual},
130+
} {
131+
if want, ok := byKey[c.key]; ok && want != c.curv {
132+
t.Errorf("%s: рядок каже %v, крива веде до %v", c.key, want, c.curv)
133+
}
134+
}
135+
// Ціль лежить у самій кривій, щоб UI не діставав її з іншого місця й
136+
// не малював лінію проти числа, якого в цьому ж обʼєкті немає.
137+
if f.Curve.GoalUAH != f.GoalAmount {
138+
t.Errorf("ціль у кривій %v, а в прогнозі %v", f.Curve.GoalUAH, f.GoalAmount)
139+
}
140+
}
141+
142+
// TestForecastCurveStaysSmall — точок близько дюжини, а не помісячно.
143+
//
144+
// Помісячна крива на десятирічному горизонті це 120 точок на серію,
145+
// тобто півтисячі чисел у документі заради лінії, у якій сусідні місяці
146+
// візуально не відрізняються. Документ читає ще й Home Assistant.
147+
func TestForecastCurveStaysSmall(t *testing.T) {
148+
in := forecastInput(t, goalSettings("", "2036-07-01")) // ~10 років
149+
f := buildProjection(in).Forecast
150+
if f.Curve == nil {
151+
t.Fatal("кривої немає")
152+
}
153+
if n := len(f.Curve.Points); n > 16 {
154+
t.Errorf("точок %d за %d місяців — крок мусить рости з горизонтом",
155+
n, f.Months)
156+
}
157+
}
158+
97159
// TestForecastWiderSpreadWidensFan — більший розкид розводить сценарії
98160
// далі один від одного. Напрямок, а не конкретні числа: конкретні
99161
// стереже golden.

internal/api/testdata/state_rich.json

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -684,7 +684,111 @@
684684
}
685685
]
686686
}
687-
]
687+
],
688+
"curve": {
689+
"step_months": 3,
690+
"goal_uah": 4000000,
691+
"points": [
692+
{
693+
"month": 0,
694+
"plan": 874996.99,
695+
"optimistic": 874996.99,
696+
"pessimistic": 874996.99,
697+
"actual": 874996.99
698+
},
699+
{
700+
"month": 3,
701+
"plan": 1132780.51,
702+
"optimistic": 1140845.85,
703+
"pessimistic": 1125088.63,
704+
"actual": 1062250.65
705+
},
706+
{
707+
"month": 6,
708+
"plan": 1391825.24,
709+
"optimistic": 1412479.28,
710+
"pessimistic": 1372356.62,
711+
"actual": 1251880.5
712+
},
713+
{
714+
"month": 9,
715+
"plan": 1643396.9,
716+
"optimistic": 1681274.51,
717+
"pessimistic": 1608117.47,
718+
"actual": 1435083.04
719+
},
720+
{
721+
"month": 12,
722+
"plan": 1891506.46,
723+
"optimistic": 1951516.84,
724+
"pessimistic": 1836309.56,
725+
"actual": 1615807.66
726+
},
727+
{
728+
"month": 15,
729+
"plan": 2134979.75,
730+
"optimistic": 2222111.09,
731+
"pessimistic": 2055841.75,
732+
"actual": 1792822.59
733+
},
734+
{
735+
"month": 18,
736+
"plan": 2375580.7,
737+
"optimistic": 2495014.11,
738+
"pessimistic": 2268481.56,
739+
"actual": 1967838.09
740+
},
741+
{
742+
"month": 21,
743+
"plan": 2613598.4,
744+
"optimistic": 2770733.65,
745+
"pessimistic": 2474507.01,
746+
"actual": 2141077.63
747+
},
748+
{
749+
"month": 24,
750+
"plan": 2849306.38,
751+
"optimistic": 3049776.54,
752+
"pessimistic": 2674172.81,
753+
"actual": 2312764.49
754+
},
755+
{
756+
"month": 27,
757+
"plan": 3082872.39,
758+
"optimistic": 3332670.56,
759+
"pessimistic": 2867621.47,
760+
"actual": 2482987.19
761+
},
762+
{
763+
"month": 30,
764+
"plan": 3314628.3,
765+
"optimistic": 3620292.07,
766+
"pessimistic": 3055107.06,
767+
"actual": 2652052.12
768+
},
769+
{
770+
"month": 33,
771+
"plan": 3544650.27,
772+
"optimistic": 3912824.32,
773+
"pessimistic": 3236729.64,
774+
"actual": 2819959.32
775+
},
776+
{
777+
"month": 36,
778+
"plan": 3773042.08,
779+
"optimistic": 4210451.13,
780+
"pessimistic": 3412626.23,
781+
"actual": 2986771.46
782+
},
783+
{
784+
"month": 39,
785+
"plan": 3999999.87,
786+
"optimistic": 4513659.35,
787+
"pessimistic": 3582970.23,
788+
"actual": 3152638.68
789+
}
790+
]
791+
}
688792
},
689793
"sensitivity": {
690794
"base_contrib_uah": 62987.08,

internal/api/web/js/charts.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,49 @@ export function svgLine(xlabels, series) {
7676
return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto">${lines}${xl}</svg>`;
7777
}
7878

79+
/** Крива прогнозу: коридор між двома серіями, лінії всередині нього і
80+
* горизонтальна лінія цілі.
81+
*
82+
* Окремо від svgLine навмисно. Той малює рівноправні серії по спільних
83+
* мітках; тут же в серій РІЗНІ ролі — дві задають межі коридору, третя
84+
* йде посередині, четверта показує відхилення від неї, а ціль це взагалі
85+
* не серія, а орієнтир. Запхати ролі в svgLine означало б додати йому
86+
* чотири прапорці й зламати два наявні виклики.
87+
*
88+
* bands: {lo, hi} — значення нижньої й верхньої межі коридору (може
89+
* бути порожньо). lines: [{color, values, dash}]. goal — число або 0.
90+
* xlabels — підписи, порожній рядок = мітки немає. */
91+
export function svgBandLine(xlabels, bands, lines, goal) {
92+
const W = 320, H = 170, Pl = 8, Pr = 8, Pt = 14, Pb = 28;
93+
const iw = W - Pl - Pr, ih = H - Pt - Pb;
94+
const n = Math.max(1, xlabels.length);
95+
// Ціль входить у масштаб: інакше лінія цілі вилітала б за полотно, і
96+
// «не дотягуємо» виглядало б як «дотягуємо».
97+
const all = lines.flatMap((s) => s.values).concat(bands.hi || [], [goal || 0]);
98+
const max = Math.max(1, ...all);
99+
const X = (i) => Pl + (n <= 1 ? iw / 2 : (iw * i) / (n - 1));
100+
const Y = (v) => Pt + ih - (v / max) * ih;
101+
let out = "";
102+
if ((bands.lo || []).length && (bands.hi || []).length) {
103+
const up = bands.hi.map((v, i) => `${X(i).toFixed(1)},${Y(v).toFixed(1)}`);
104+
const down = bands.lo.map((v, i) => `${X(i).toFixed(1)},${Y(v).toFixed(1)}`).reverse();
105+
out += `<polygon points="${up.concat(down).join(" ")}" fill="var(--oi-series-invested)" opacity="0.12"/>`;
106+
}
107+
if (goal > 0) {
108+
const y = Y(goal).toFixed(1);
109+
out += `<line x1="${Pl}" y1="${y}" x2="${W - Pr}" y2="${y}" stroke="${AXIS}"`
110+
+ ` stroke-width="1" stroke-dasharray="4 3"/>`;
111+
}
112+
out += lines.map((s) =>
113+
`<polyline points="${s.values.map((v, i) => `${X(i).toFixed(1)},${Y(v).toFixed(1)}`).join(" ")}"`
114+
+ ` fill="none" stroke="${s.color}" stroke-width="2.5" stroke-linejoin="round"`
115+
+ `${s.dash ? ` stroke-dasharray="${s.dash}"` : ""}/>`).join("");
116+
out += xlabels.map((l, i) => l
117+
? `<text x="${X(i).toFixed(1)}" y="${H - 10}" text-anchor="middle" font-size="10" fill="${AXIS}">${esc(l)}</text>`
118+
: "").join("");
119+
return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto">${out}</svg>`;
120+
}
121+
79122
/** Кільце часток. parts: [{label, value}] — уже відсортовані.
80123
* Повертає {svg, colors}: легенду малює той, хто кличе, бо підпис
81124
* частки в різних місцях різний (сума, відсоток, і те й те). */

0 commit comments

Comments
 (0)