Skip to content

Repository files navigation

ODD Invest

Домашній сервіс обліку інвестиційного портфеля: власний веб-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.Numberbig.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, але це не «один імпорт»: разом із ним міняється назва драйвера (sqlite3sqlite) і синтаксис DSN (_journal_mode/_busy_timeout/_foreign_keys_pragma=).

Конфігурація (env)

Змінна Типово Опис
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 НБУ (для тестів)

Деплой (LXC + systemd)

# у контейнері
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 oddinvestd

MQTT-креденшели розкоментувати в unit-файлі. StateDirectory=oddinvestd створить /var/lib/oddinvestd автоматично.

Контракт з інтеграцією HA

  • 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 ганяє свої парсери проти цих фікстур.

Що перевірити на живому API НБУ

Реєстр ОВДП і курси — два різні ендпойнти:

# довідник паперів (саме 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 / YYYYMMDDT…-суфіксом включно), а правиться це в одному місці — 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.

Розділи UI

П'ять вкладок, кожна відповідає рівно на одне питання: Огляд — що робити зараз, Портфель — що в мене є, Гроші — де вони, Майбутнє — куди це йде, Налаштування — як воно налаштоване. Розділ, який не відповідає на своє питання одним поглядом, — привід рознести його, а не дописати шосту вкладку.

Чого тут свідомо немає

  • Ринкової ціни облігацій. Продати на вторинному ринку можна, але застосунок ціни не моделює, і вигадане число було б гіршим за чесну відсутність. Тому ж у «замкненому» є вклади, але немає ОВДП.
  • Збірки фронтенду. Сервіс ставиться в LXC без Node; замість бандлера синтаксис і граф імпортів перевіряє джоба ui.
  • Спільної таблиці інструментів. У сертифіката немає номіналу й графіка купонів, у вкладу — вторинного ринку; зведення їх в одну сутність зламало б і драбину, і дюрацію.
  • Резерву серед брокерів. «Матрац» міг би бути ще одним рахунком у brokers — і тоді fitsFor порахував би його купівельною спроможністю. Тому в нього власний журнал: у капітал і валютні частки входить, у «чи вистачає на папір» — ні.
  • Порад щодо стратегії. Конфігуратор пропонує ІМЕНОВАНІ НАБОРИ тих самих налаштувань — з підписом, що кожен дає і чим за це платить, — а питання про обмеження лише позначають, який набір їм не суперечить. Логіка збігу показана під кожним рядком; слова «рекомендований» і сортування «найкращий зверху» немає навмисно. Застосунок не знає ні ваших зобовʼязань, ні доходу, ні того, що буде з ринком.
  • Окремої сутності «стратегія» в бекенді. Набори — константи фронтенду, які заповнюють ті самі поля, що й форми поруч. Інакше зʼявився б другий спосіб задати ті самі числа, і питання «яке з них справжнє» не мало б відповіді.
  • Дефолтів у лімітах концентрації. «Не більше 20% в один папір» — це порада, а застосунок їх не дає. Порожній ліміт означає, що вимір не показується, а не що там стоїть чиєсь уявлення про норму.
  • Заборон. Перевищений ліміт нічого не ховає в «Що купити» й нічого не блокує: це спостереження. Ліміт міг бути порушений із причин, яких застосунок не знає.
  • Автоматичних місячних витрат. Достатність резерву рахується від суми, яку вводять руками: зняття з рахунку це і покупка холодильника, і переказ у той самий резерв, тож виводити з них «скільки коштує місяць життя» означало б міряти запас від випадкового числа.
  • Оптимістичних оновлень у HA. Команда йде в REST, підтверджене значення повертається з MQTT — на екрані те, що реально збережено.

About

ODD Invest backend service (oddinvestd)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages