Домашній сервіс обліку інвестиційного портфеля: власний веб-UI, REST API,
довідник паперів з відкритого API НБУ і публікація стану портфеля в
MQTT для інтеграції з Home Assistant
(окремий репозиторій — ha-oddinvest, кастомна інтеграція).
Підтримуються три класи: ОВДП, сертифікати фондів і банківські строкові вклади. Кожен живе власною таблицею й моделлю — у сертифіката немає ні номіналу, ні графіка купонів, у вкладу немає вторинного ринку, і зведення їх в одну сутність зламало б і драбину, і дюрацію.
Зафіксовані архітектурні рішення лежать у самому коді — коментарями над тим, чого вони стосуються.
Облік. Лоти ОВДП і продажі на вторинному ринку з валідацією залишків, НКД (ACT/ACT) і реалізованим результатом. Журнал операцій із сертифікатами фондів (позиція — сальдо журналу, собівартість середньозважена). Строкові вклади з графіком, поповненнями, податком на відсотки й достроковим розірванням. Гаманець у розрізі «брокер × валюта», конвертації, імпорт виписки Inzhur (.xlsx) із дедуплікацією й водяним знаком. Резерв («матрац») — журнал грошей на чорний день: він у капіталі й валютних частках, але навмисно поза купівельною спроможністю, інакше помічник запропонував би купити папір за аварійні гроші.
Довідник. Кеш повного реєстру ЦП НБУ з графіками виплат, автокомпліт по ISIN, добове оновлення курсів і backfill історії.
Аналітика. Календар майбутніх виплат з урахуванням продажів (виплата в день продажу — продавцю, строго після — покупцю). Драбина погашень за роками й валютами. XIRR окремо по кожній валюті. Дохідність до погашення, номінальна й реальна — остання з поправкою на знецінення гривні, яке міряється з десятирічного вікна курсів НБУ. Процентний ризик двох різних природ: ціновий (лише ОВДП — переоцінюються тільки вони) і ризик перевкладення (ОВДП і вклади разом). Ліквідність, податковий звіт, виписка руху коштів, бенчмарк «а якби я просто тримав долари».
Рішення. Помічник реінвесту: порівнює облігації, фонди й вклади між собою за реальною дохідністю після податку — саме вона, а не ціна, вирішує порядок. Диверсифікація як політика портфеля: цільові частки за валютою й за видом інструмента, ліміти концентрації на один папір, одну установу й один рік погашень, і конфігуратор готових наборів цих налаштувань. Прогноз капіталу помісячною симуляцією, у якій кожна валюта живе власним рукавом і власною ставкою.
Інтеграція. Retained-стан у MQTT ({prefix}/state + LWT
{prefix}/availability), добові знімки агрегатів і графік «як росте»,
експорт CSV за рік, повний бекап/відновлення.
Rhymond/go-money: суми — цілі мінорні одиниці + валюта; операції між різними валютами падають помилкою, а не рахуються тихо.internal/fx— єдина точка конвертації: курси цілими ×10⁴,big.Rat, банківське заокруглення (half-to-even).- JSON НБУ парситься через
json.Number→big.Rat→ мінорні одиниці.float64з'являється лише на межі відображення (документoddinvest/state, суми в мажорних одиницях).
make # список цілей
make check # те саме, що ганяє CI: формат, vet, лінтер, манiфест
make test # усі тести під -race
make buildЛінтер ставиться окремо, версією з CI — щоб локально й на сервері він казав одне й те саме:
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2Без make — те саме руками:
go mod tidy # перший раз: підтягне залежності і згенерує go.sum
go test ./... -race
go build -o oddinvestd ./cmd/oddinvestdФормат і статичний аналіз обовʼязкові: CI падає на gofmt -l і на
golangci-lint (конфіг .golangci.yml, формат v2). У ньому ввімкнено
errcheck із check-blank, тож свідомо проковтнута помилка мусить нести
//nolint:errcheck із причиною — інакше вона не відрізняється від
забутої, а забута тут означає стан із хибними числами, опублікований у
MQTT як істина.
Потрібен CGO (SQLite-драйвер mattn/go-sqlite3): gcc має бути в
системі. Без нього не збираються internal/store і internal/api — тобто
й їхні тести, тож на машині без компілятора локально бігають лише чисті
пакети (internal/domain, internal/state, internal/fx, internal/nbu,
internal/imports), а решту перевіряє CI.
CGO-free збірка можлива через modernc.org/sqlite, але це не «один
імпорт»: разом із ним міняється назва драйвера (sqlite3 → sqlite) і
синтаксис DSN (_journal_mode/_busy_timeout/_foreign_keys → _pragma=).
| Змінна | Типово | Опис |
|---|---|---|
ODDINVEST_HTTP_ADDR |
:8080 |
адреса HTTP |
ODDINVEST_DB_PATH |
/var/lib/oddinvestd/oddinvest.db |
шлях до SQLite |
ODDINVEST_MQTT_ADDR |
— | tcp://host:1883; порожньо = MQTT вимкнено |
ODDINVEST_MQTT_USER / ODDINVEST_MQTT_PASS |
— | облікові дані брокера |
ODDINVEST_MQTT_PREFIX |
oddinvest |
префікс топіків |
ODDINVEST_NBU_BASE |
https://bank.gov.ua |
база API НБУ (для тестів) |
# у контейнері
install -o root -g root -m 755 oddinvestd /usr/local/bin/
useradd -r oddinvestd
install -m 644 deploy/systemd/oddinvestd.service /etc/systemd/system/
systemctl enable --now oddinvestdMQTT-креденшели розкоментувати в unit-файлі. StateDirectory=oddinvestd
створить /var/lib/oddinvestd автоматично.
contract/oddinvest-state.schema.json— JSON Schema (draft 2020-12) retained-повідомлення{prefix}/state.contract/fixtures/*.json— фікстури, згенеровані кодом (go test ./internal/state -update); тестTestFixtureUpToDateне дає їм розійтися з кодом.TestSchemaMatchesDoc/TestSchemaMatchesSettingsтримають саму схему чесною: вона має описувати рівно те, що віддаєstate.Doc— ні більше, ні менше. Валідації фікстури для цього замало (зайве поле вона просто пропускає), і схема встигла розійтися з кодом в обидва боки, поки її не перевіряв ніхто.- Еволюція: тільки додавання полів; зміна семантики поля = інкремент
schema. CI репозиторіюha-oddinvestганяє свої парсери проти цих фікстур.
Реєстр ОВДП і курси — два різні ендпойнти:
# довідник паперів (саме depo_securities, не statdirectory/securities)
curl 'https://bank.gov.ua/depo_securities?json' | head -c 2000
# курс валюти на сьогодні
curl 'https://bank.gov.ua/NBUStatService/v1/statdirectory/exchange?valcode=USD&json'Фікстура internal/nbu/testdata/securities_sample.json складена за
документацією; якщо жива відповідь від неї відрізняється, парсер дат
приймає ISO / DD.MM.YYYY / YYYYMMDD (з T…-суфіксом включно), а
правиться це в одному місці — internal/nbu/client.go: parseNBUDate.
cmd/oddinvestd/ — wiring, graceful shutdown
internal/domain/ — чиста доменна логіка + тести (календар, НКД,
результат продажу, драбина, позиції, вклади,
фонди, XIRR, рукави й проєкція)
internal/fx/ — конвертація валют (єдина точка)
internal/nbu/ — клієнт API НБУ (json.Number, без float64)
internal/store/ — SQLite: STRICT-таблиці, вбудовані міграції
internal/state/ — збірка документа oddinvest/state (контракт)
internal/imports/ — читання .xlsx і розбір виписки Inzhur
internal/api/ — REST + вбудований веб-UI (go:embed)
internal/api/web/ — фронтенд: ESM-модулі без збірки (js/app.js —
компонент, js/views/ — розділи, css/ — токени
й дві теми)
internal/mqtt/ — paho-паблішер з LWT
internal/jobs/ — добове оновлення НБУ, знімки, бекап, публікація
contract/ — те, що споживає репо ha-oddinvest: JSON Schema,
фікстури і ui-manifest.json
deploy/ — systemd unit і скрипти для Proxmox LXC
scripts/ — генератор ui-manifest.json
Пакет internal/api розкладений по файлах за доменами: server.go —
тільки тип, роутер і мідлвари; state_builder.go — збирання документа
стану; cashflow.go — той самий рух грошей, але розкладений на події;
решта — handlers_*.go за темою (лоти, продажі, вклади, фонди, довідники,
налаштування, реінвест, звіти, імпорт).
Двоє з них рахують ті самі величини різними способами й мусять сходитись:
buildState зводить за весь час, cashEvents — по подіях. Єдиний захист
від їх розходження — TestCashflowStatementReconciles; про це сказано на
початку обох файлів.
Фронтенд один на дві поверхні: internal/api/web/js/app.js — це
<odd-invest-app>, який монтують і index.html (тут), і бічна панель
Home Assistant. Різниця між ними зводиться до транспорту й теми, які
передаються властивостями; репозиторій ha-oddinvest вендорить ці
модулі скриптом за contract/ui-manifest.json, а його CI падає на
розбіжності. Збірки немає навмисно — сервіс ставиться в LXC без Node,
модулі віддаються браузеру як є; замість бандлера синтаксис і граф
імпортів перевіряє джоба ui.
П'ять вкладок, кожна відповідає рівно на одне питання: Огляд — що робити зараз, Портфель — що в мене є, Гроші — де вони, Майбутнє — куди це йде, Налаштування — як воно налаштоване. Розділ, який не відповідає на своє питання одним поглядом, — привід рознести його, а не дописати шосту вкладку.
- Ринкової ціни облігацій. Продати на вторинному ринку можна, але застосунок ціни не моделює, і вигадане число було б гіршим за чесну відсутність. Тому ж у «замкненому» є вклади, але немає ОВДП.
- Збірки фронтенду. Сервіс ставиться в LXC без Node; замість бандлера
синтаксис і граф імпортів перевіряє джоба
ui. - Спільної таблиці інструментів. У сертифіката немає номіналу й графіка купонів, у вкладу — вторинного ринку; зведення їх в одну сутність зламало б і драбину, і дюрацію.
- Резерву серед брокерів. «Матрац» міг би бути ще одним рахунком у
brokers— і тодіfitsForпорахував би його купівельною спроможністю. Тому в нього власний журнал: у капітал і валютні частки входить, у «чи вистачає на папір» — ні. - Порад щодо стратегії. Конфігуратор пропонує ІМЕНОВАНІ НАБОРИ тих самих налаштувань — з підписом, що кожен дає і чим за це платить, — а питання про обмеження лише позначають, який набір їм не суперечить. Логіка збігу показана під кожним рядком; слова «рекомендований» і сортування «найкращий зверху» немає навмисно. Застосунок не знає ні ваших зобовʼязань, ні доходу, ні того, що буде з ринком.
- Окремої сутності «стратегія» в бекенді. Набори — константи фронтенду, які заповнюють ті самі поля, що й форми поруч. Інакше зʼявився б другий спосіб задати ті самі числа, і питання «яке з них справжнє» не мало б відповіді.
- Дефолтів у лімітах концентрації. «Не більше 20% в один папір» — це порада, а застосунок їх не дає. Порожній ліміт означає, що вимір не показується, а не що там стоїть чиєсь уявлення про норму.
- Заборон. Перевищений ліміт нічого не ховає в «Що купити» й нічого не блокує: це спостереження. Ліміт міг бути порушений із причин, яких застосунок не знає.
- Автоматичних місячних витрат. Достатність резерву рахується від суми, яку вводять руками: зняття з рахунку це і покупка холодильника, і переказ у той самий резерв, тож виводити з них «скільки коштує місяць життя» означало б міряти запас від випадкового числа.
- Оптимістичних оновлень у HA. Команда йде в REST, підтверджене значення повертається з MQTT — на екрані те, що реально збережено.