Skip to content

Commit 0ebc228

Browse files
committed
місяць і темп поповнень окремою фазою
Сьома фаза розбиття. Коміт-ПЕРЕНЕСЕННЯ: golden документа стану не зрушив ні на байт. У state_month.go зібрані три різні відповіді на схоже питання «скільки я вклав», які доти стояли за пʼятсот рядків одна від одної й через це легко плутались: ВКЛАДЕНО цього місяця — покупки, облігації й сертифікати. Рух грошей із рахунку в папери. ВНЕСЕНО цього місяця — поповнення НЕТТО зі зняттями. Нові гроші, яких у портфелі не було. ТЕМП — скільки нових грошей заходить на місяць у середньому за останні півроку. План міряється ВНЕСЕНИМ, а не вкладеним, і в шапці файлу сказано чому: купівля лише переносить гроші з рахунку в папери, тож порівняння плану з купівлями показувало б 100% виконання за папір, куплений на накопичені купони. Два майже однакові цикли — по поповненнях і по резерву — звелись до одного addMove. Вони й були однакові дослівно, просто списані двічі. actualWindowDays переїхав із тіла функції в константу файлу разом із поясненням, чому саме 183 дні. Сторожі. Три мутації валять golden: «внесено» стало брутто — month_deposited_uah 65 000 → 80 000; темп без поправки +1 місяць — with_reinvest_actual 1 615 807 → 1 764 071; вікно темпу на всю історію — with_reinvest_actual → 2 223 349. Четверта, «резерв більше не рахується внеском», golden НЕ завалила: рухи резерву в фікстурі стоять у травні-червні, а документ будується на 15 липня, тож ця гілка там не виконується взагалі. Правити фікстуру в комітi-перенесенні не можна, тож перевірка додана на саму фазу — і не «резерв рахується», а той інваріант, який описує коментар: переміщення гаманець → матрац записане ДВОМА ногами й разом дає нуль внеску, хоч зняття при цьому чесне. Два тести дзеркальні, і мутації валять їх у протилежні боки: без другої ноги внесок стає −100 000, без першої +100 000. Ціна помилки справді двостороння — показати втрату капіталу, якої не було, або показати внесок там, де гроші переклали з кишені в кишеню. buildState: 1449 → 1312 рядків.
1 parent 6342c67 commit 0ebc228

3 files changed

Lines changed: 267 additions & 144 deletions

File tree

internal/api/state_builder.go

Lines changed: 7 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -161,100 +161,19 @@ func (s *Server) buildState(ctx context.Context, now time.Time) (*state.Doc, err
161161
}
162162
}
163163

164-
// внески місяця: покупки поточного місяця в грн-еквіваленті
165-
monthInv := money.New(0, money.UAH)
166-
for _, l := range hold.Lots {
167-
// Уся куплена кількість, а не залишок: питання «скільки я вклав
168-
// цього місяця», і продаж наступного дня факту покупки не скасовує.
169-
if l.BuyDate.Year() == now.Year() && l.BuyDate.Month() == now.Month() {
170-
cost := domain.MulQty(l.PricePerBond, l.Qty)
171-
if l.Fee != nil && !l.Fee.IsZero() {
172-
if cost, err = cost.Add(l.Fee); err != nil {
173-
return nil, err
174-
}
175-
}
176-
uahAmt, err := fx.ToUAH(cost, rates)
177-
if err != nil {
178-
return nil, err
179-
}
180-
monthInv, err = monthInv.Add(uahAmt)
181-
if err != nil {
182-
return nil, err
183-
}
184-
}
185-
}
186-
187-
// Сертифікати фондів — теж купівля паперів, тож у «вкладено цього
188-
// місяця» вони входять нарівні з облігаціями. Досі не входили лише
189-
// тому, що фонди прибудовувались до моделі пізніше.
190-
for _, op := range fundOps {
191-
if op.Kind != domain.FundBuy ||
192-
op.Date.Year() != now.Year() || op.Date.Month() != now.Month() {
193-
continue
194-
}
195-
if u, cerr := fx.ToUAH(money.New(op.Amount, op.Currency), rates); cerr == nil {
196-
if sum, aerr := monthInv.Add(u); aerr == nil {
197-
monthInv = sum
198-
}
199-
}
164+
// Рухи поточного місяця й фактичний темп (state_month.go).
165+
mth, err := buildMonth(src, hold, rates, now, today)
166+
if err != nil {
167+
return nil, err
200168
}
169+
monthInv := mth.InvestedUAH
170+
monthDep, monthOut := mth.DepositedUAH, mth.WithdrawnUAH
171+
actualMonthly, actualMonths := mth.ActualMonthlyUAH, mth.ActualMonths
201172

202173
// target — місячний план. Не читається з налаштувань: виводиться з
203174
// цілі й дедлайну нижче, коли вже зібрані валютні рукави.
204175
target := money.New(0, money.UAH)
205176

206-
// Поповнення за поточний місяць. Саме поповнення, а не купівлі:
207-
// план тепер означає «скільки НОВИХ грошей треба вносити до цілі»,
208-
// а купівля лише переносить гроші з рахунку в папери. Порівнювати
209-
// план із купівлями означало б показувати 100% виконання за папір,
210-
// куплений на накопичені купони, — до цілі це не додає нічого.
211-
monthDep := money.New(0, money.UAH)
212-
monthOut := money.New(0, money.UAH) // зняття цього місяця, додатнім числом
213-
for _, d := range src.deposits {
214-
if d.Date.Year() != now.Year() || int(d.Date.Month()) != int(now.Month()) {
215-
continue
216-
}
217-
if d.Amount < 0 {
218-
if u, cerr := fx.ToUAH(money.New(-d.Amount, d.Currency), rates); cerr == nil {
219-
if sum, aerr := monthOut.Add(u); aerr == nil {
220-
monthOut = sum
221-
}
222-
}
223-
}
224-
// Нетто, а не сума поповнень: зняття зменшує капітал так само,
225-
// як поповнення його збільшує. Без цього переказ між брокерами
226-
// (він записується як зняття + поповнення, бо окремої сутності
227-
// переказу немає) роздував би «внесено» на свою суму, не
228-
// додавши жодної нової копійки.
229-
if u, cerr := fx.ToUAH(money.New(d.Amount, d.Currency), rates); cerr == nil {
230-
if sum, aerr := monthDep.Add(u); aerr == nil {
231-
monthDep = sum
232-
}
233-
}
234-
}
235-
// Резерв рахується в тому самому нетто, і саме тому, що переміщення
236-
// гаманець → матрац записується ДВОМА ногами (мінус у deposits, плюс
237-
// тут): порізно перша нога виглядала б як втрата капіталу, а разом
238-
// вони дають нуль, як і має бути. Відкладені зовні гроші, які на
239-
// рахунок брокера не заходили, це й далі чесний внесок.
240-
for _, op := range reserveOps {
241-
if op.Date.Year() != now.Year() || int(op.Date.Month()) != int(now.Month()) {
242-
continue
243-
}
244-
if op.Amount < 0 {
245-
if u, cerr := fx.ToUAH(money.New(-op.Amount, op.Currency), rates); cerr == nil {
246-
if sum, aerr := monthOut.Add(u); aerr == nil {
247-
monthOut = sum
248-
}
249-
}
250-
}
251-
if u, cerr := fx.ToUAH(money.New(op.Amount, op.Currency), rates); cerr == nil {
252-
if sum, aerr := monthDep.Add(u); aerr == nil {
253-
monthDep = sum
254-
}
255-
}
256-
}
257-
258177
// Неперевкладені: надійшлі виплати без статусу reinvested. Рахуються по
259178
// ВСІХ інструментах із розкладом — купони й погашення ОВДП тут, відсотки
260179
// й тіло вкладів нижче, у їхньому циклі. Правило одне: запланована
@@ -733,62 +652,6 @@ func (s *Server) buildState(ctx context.Context, now time.Time) (*state.Doc, err
733652
portfolioYieldByCur := bnd.YieldByCur
734653
portfolioYieldRealByCur := bnd.YieldRealByCur
735654

736-
// --- фактичний темп поповнень ---
737-
// План може розходитись із реальністю, тож рахуємо ще й середній темп
738-
// НОВИХ грошей. Саме поповнень, а не покупок: покупка лише переносить
739-
// гроші з рахунку в папери й нового капіталу не додає (а купони вже
740-
// враховані окремо).
741-
//
742-
// Знаменник — це +1 місяць до проміжку «перше поповнення … сьогодні», і
743-
// це не косметика. Поповнення фінансують ПЕРІОДИ, а не проміжок між
744-
// собою: три щомісячні внески покривають три місяці, тоді як від
745-
// першого до сьогодні минуло лише два. Ділення на проміжок завищувало
746-
// темп у півтора раза (15 000 за 60 днів давали 7 610 ₴/міс замість
747-
// 5 000). Та сама поправка знімає й вибух на старті: одне поповнення
748-
// сьогодні дає знаменник 1, а не 0.1, тож окремий поріг більше не
749-
// потрібен — темп показуємо одразу, а поруч пишемо, на якій довжині
750-
// історії він порахований, щоб було видно, наскільки йому вірити.
751-
var actualMonthly float64
752-
var actualMonths int
753-
// Вікно — останні півроку, а не вся історія.
754-
//
755-
// Усереднення за весь час міряє не темп, а біографію: якщо портфель
756-
// колись виходив у нуль і починався заново, внески «до» і виведення
757-
// «під час» гасять одне одного, і сьогоднішні 7 500 ₴/міс виглядають
758-
// як 430. На реальних даних саме так і сталось — 29 місяців історії з
759-
// повним виходом посередині дали 0% від потрібного при живих внесках.
760-
//
761-
// Півроку — компроміс: досить довго, щоб пропущений місяць не обвалив
762-
// оцінку, і досить коротко, щоб показник відповідав на «як я вкладаю
763-
// ЗАРАЗ», а саме це питання йому й ставлять.
764-
const actualWindowDays = 183
765-
if len(src.deposits) > 0 {
766-
first := today
767-
var totalUAH int64
768-
for _, d := range src.deposits {
769-
if n := domain.DaysBetween(d.Date, today); n < 0 || n > actualWindowDays {
770-
continue
771-
}
772-
if d.Date.Before(first) {
773-
first = d.Date
774-
}
775-
// Нетто: зняття теж рух капіталу. Інакше переказ між брокерами
776-
// (зняття + поповнення) завищував би темп на свою суму, а
777-
// прогноз «За фактом» через це малював би дисципліну, якої немає.
778-
if u, cerr := fx.ToUAH(money.New(d.Amount, d.Currency), rates); cerr == nil {
779-
totalUAH += u.Amount()
780-
}
781-
}
782-
if totalUAH > 0 {
783-
months := float64(domain.DaysBetween(first, today))/30.44 + 1
784-
if months < 1 {
785-
months = 1
786-
}
787-
actualMonths = int(months + 0.5)
788-
actualMonthly = round2(float64(totalUAH) / 100 / months)
789-
}
790-
}
791-
792655
// --- проєкція капіталу: помісячна симуляція РЕАЛЬНИХ потоків ---
793656
// (купони/погашення наявних паперів) + внески; реінвест під дохідність
794657
// портфеля. Готівка не працює, поки не реінвестована. Це замість сухої

internal/api/state_month.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Поточний місяць і фактичний темп поповнень.
2+
//
3+
// Сьома фаза розбиття buildState. Тут три різні відповіді на схоже
4+
// питання «скільки я вклав», і плутати їх не можна:
5+
//
6+
// - ВКЛАДЕНО цього місяця — покупки: облігації й сертифікати. Це рух
7+
// грошей із рахунку в папери.
8+
// - ВНЕСЕНО цього місяця — поповнення, нетто зі зняттями. Це НОВІ
9+
// гроші, яких у портфелі не було.
10+
// - ТЕМП — скільки нових грошей заходить на місяць у середньому за
11+
// останні півроку.
12+
//
13+
// План міряється ВНЕСЕНИМ, а не вкладеним, і це не дрібниця. План означає
14+
// «скільки нових грошей треба принести до цілі»; купівля ж лише переносить
15+
// гроші з рахунку в папери й до цілі не додає нічого. Порівнювати план із
16+
// купівлями означало б показувати 100% виконання за папір, куплений на
17+
// накопичені купони.
18+
package api
19+
20+
import (
21+
"time"
22+
23+
"github.com/ODDsama/oddinvest/internal/domain"
24+
"github.com/ODDsama/oddinvest/internal/fx"
25+
26+
money "github.com/Rhymond/go-money"
27+
)
28+
29+
// actualWindowDays — вікно, за яким міряється темп: останні півроку, а не
30+
// вся історія.
31+
//
32+
// Усереднення за весь час міряє не темп, а біографію: якщо портфель колись
33+
// виходив у нуль і починався заново, внески «до» і виведення «під час»
34+
// гасять одне одного. На реальних даних 29 місяців історії з повним
35+
// виходом посередині дали 0% від потрібного при живих внесках — сьогоднішні
36+
// 7 500 ₴/міс виглядали як 430.
37+
//
38+
// Півроку — компроміс: досить довго, щоб пропущений місяць не обвалив
39+
// оцінку, і досить коротко, щоб показник відповідав на «як я вкладаю
40+
// ЗАРАЗ», а саме це питання йому й ставлять.
41+
const actualWindowDays = 183
42+
43+
// monthPhase — рухи поточного місяця й темп поповнень.
44+
type monthPhase struct {
45+
// InvestedUAH — куплено цього місяця (папери + сертифікати), грн-екв.
46+
InvestedUAH *money.Money
47+
// DepositedUAH — внесено НЕТТО (поповнення мінус зняття);
48+
// WithdrawnUAH — самі зняття, додатнім числом.
49+
DepositedUAH *money.Money
50+
WithdrawnUAH *money.Money
51+
// ActualMonthlyUAH — темп нових грошей, ₴/міс; ActualMonths — на якій
52+
// довжині історії він порахований (щоб було видно, наскільки вірити).
53+
ActualMonthlyUAH float64
54+
ActualMonths int
55+
}
56+
57+
// buildMonth зводить рухи місяця й темп.
58+
func buildMonth(src *sources, hold domain.Holdings, rates fx.Rates,
59+
now time.Time, today domain.Date) (monthPhase, error) {
60+
out := monthPhase{
61+
InvestedUAH: money.New(0, money.UAH),
62+
DepositedUAH: money.New(0, money.UAH),
63+
WithdrawnUAH: money.New(0, money.UAH),
64+
}
65+
66+
for _, l := range hold.Lots {
67+
// Уся куплена кількість, а не залишок: питання «скільки я вклав
68+
// цього місяця», і продаж наступного дня факту покупки не скасовує.
69+
if l.BuyDate.Year() != now.Year() || l.BuyDate.Month() != now.Month() {
70+
continue
71+
}
72+
cost := domain.MulQty(l.PricePerBond, l.Qty)
73+
if l.Fee != nil && !l.Fee.IsZero() {
74+
c2, err := cost.Add(l.Fee)
75+
if err != nil {
76+
return out, err
77+
}
78+
cost = c2
79+
}
80+
uahAmt, err := fx.ToUAH(cost, rates)
81+
if err != nil {
82+
return out, err
83+
}
84+
sum, err := out.InvestedUAH.Add(uahAmt)
85+
if err != nil {
86+
return out, err
87+
}
88+
out.InvestedUAH = sum
89+
}
90+
// Сертифікати фондів — теж купівля паперів, тож у «вкладено цього
91+
// місяця» вони входять нарівні з облігаціями. Досі не входили лише
92+
// тому, що фонди прибудовувались до моделі пізніше.
93+
for _, op := range src.fundOps {
94+
if op.Kind != domain.FundBuy ||
95+
op.Date.Year() != now.Year() || op.Date.Month() != now.Month() {
96+
continue
97+
}
98+
if u, cerr := fx.ToUAH(money.New(op.Amount, op.Currency), rates); cerr == nil {
99+
if sum, aerr := out.InvestedUAH.Add(u); aerr == nil {
100+
out.InvestedUAH = sum
101+
}
102+
}
103+
}
104+
105+
// Внесено — нетто, а не сума поповнень: зняття зменшує капітал так
106+
// само, як поповнення його збільшує. Без цього переказ між брокерами
107+
// (він записується як зняття + поповнення, бо окремої сутності переказу
108+
// немає) роздував би «внесено» на свою суму, не додавши жодної нової
109+
// копійки.
110+
addMove := func(amount int64, cur string) {
111+
if amount < 0 {
112+
if u, cerr := fx.ToUAH(money.New(-amount, cur), rates); cerr == nil {
113+
if sum, aerr := out.WithdrawnUAH.Add(u); aerr == nil {
114+
out.WithdrawnUAH = sum
115+
}
116+
}
117+
}
118+
if u, cerr := fx.ToUAH(money.New(amount, cur), rates); cerr == nil {
119+
if sum, aerr := out.DepositedUAH.Add(u); aerr == nil {
120+
out.DepositedUAH = sum
121+
}
122+
}
123+
}
124+
for _, d := range src.deposits {
125+
if d.Date.Year() != now.Year() || int(d.Date.Month()) != int(now.Month()) {
126+
continue
127+
}
128+
addMove(d.Amount, d.Currency)
129+
}
130+
// Резерв рахується в тому самому нетто, і саме тому, що переміщення
131+
// гаманець → матрац записується ДВОМА ногами (мінус у deposits, плюс
132+
// тут): порізно перша нога виглядала б як втрата капіталу, а разом
133+
// вони дають нуль, як і має бути. Відкладені зовні гроші, які на
134+
// рахунок брокера не заходили, це й далі чесний внесок.
135+
for _, op := range src.reserveOps {
136+
if op.Date.Year() != now.Year() || int(op.Date.Month()) != int(now.Month()) {
137+
continue
138+
}
139+
addMove(op.Amount, op.Currency)
140+
}
141+
142+
// --- фактичний темп поповнень ---
143+
// Саме поповнень, а не покупок: покупка лише переносить гроші з рахунку
144+
// в папери й нового капіталу не додає (а купони враховані окремо).
145+
//
146+
// Знаменник — це +1 місяць до проміжку «перше поповнення … сьогодні», і
147+
// це не косметика. Поповнення фінансують ПЕРІОДИ, а не проміжок між
148+
// собою: три щомісячні внески покривають три місяці, тоді як від
149+
// першого до сьогодні минуло лише два. Ділення на проміжок завищувало
150+
// темп у півтора раза (15 000 за 60 днів давали 7 610 ₴/міс замість
151+
// 5 000). Та сама поправка знімає й вибух на старті: одне поповнення
152+
// сьогодні дає знаменник 1, а не 0.1, тож окремий поріг не потрібен.
153+
if len(src.deposits) > 0 {
154+
first := today
155+
var totalUAH int64
156+
for _, d := range src.deposits {
157+
if n := domain.DaysBetween(d.Date, today); n < 0 || n > actualWindowDays {
158+
continue
159+
}
160+
if d.Date.Before(first) {
161+
first = d.Date
162+
}
163+
// Нетто: зняття теж рух капіталу. Інакше переказ між брокерами
164+
// (зняття + поповнення) завищував би темп на свою суму, а
165+
// прогноз «За фактом» через це малював би дисципліну, якої немає.
166+
if u, cerr := fx.ToUAH(money.New(d.Amount, d.Currency), rates); cerr == nil {
167+
totalUAH += u.Amount()
168+
}
169+
}
170+
if totalUAH > 0 {
171+
months := float64(domain.DaysBetween(first, today))/30.44 + 1
172+
if months < 1 {
173+
months = 1
174+
}
175+
out.ActualMonths = int(months + 0.5)
176+
out.ActualMonthlyUAH = round2(float64(totalUAH) / 100 / months)
177+
}
178+
}
179+
return out, nil
180+
}

0 commit comments

Comments
 (0)