├── data └── .gitkeep ├── volumes └── .gitkeep ├── .python-version ├── backend ├── markets │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ └── factory.py │ ├── migrations │ │ └── __init__.py │ ├── apps.py │ ├── admin.py │ ├── urls.py │ ├── templates │ │ └── admin │ │ │ └── markets │ │ │ └── market │ │ │ └── change_list.html │ ├── serializers.py │ ├── views.py │ └── models.py ├── sectors │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ └── factory.py │ ├── migrations │ │ └── __init__.py │ ├── apps.py │ ├── urls.py │ ├── templates │ │ └── admin │ │ │ └── sectors │ │ │ └── sector │ │ │ └── change_list.html │ ├── admin.py │ ├── serializers.py │ ├── models.py │ └── views.py ├── settings │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── factory.py │ │ └── test_views.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0003_remove_usersettings_backend_hostname.py │ │ ├── 0004_usersettings_display_welcome.py │ │ └── 0002_usersettings_backend_hostname_and_more.py │ ├── apps.py │ ├── urls.py │ ├── serializers.py │ └── models.py ├── stats │ ├── __init__.py │ ├── models │ │ └── __init__.py │ ├── tests │ │ ├── __init__.py │ │ └── factory.py │ ├── views │ │ └── __init__.py │ ├── calculators │ │ └── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── serializers │ │ ├── __init__.py │ │ └── portfolio_stats.py │ ├── apps.py │ ├── admin.py │ └── urls.py ├── benchmarks │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── tests.py │ ├── apps.py │ ├── urls.py │ ├── admin.py │ └── serializers.py ├── companies │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ └── factory.py │ ├── urls │ │ ├── __init__.py │ │ ├── companies.py │ │ └── companies_portfolio.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0002_alter_company_broker.py │ ├── apps.py │ ├── filters.py │ ├── utils.py │ ├── admin.py │ └── serializers_lite.py ├── currencies │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── factory.py │ │ └── test_views.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── apps.py │ ├── urls.py │ ├── serializers.py │ ├── admin.py │ ├── templates │ │ └── admin │ │ │ └── currencies │ │ │ └── currency │ │ │ └── change_list.html │ ├── views.py │ └── models.py ├── initialize_data │ ├── admin.py │ ├── models.py │ ├── tests.py │ ├── __init__.py │ ├── initializers │ │ └── __init__.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── initialize_markets.py │ │ │ └── initialize_benchmarks.py │ ├── migrations │ │ └── __init__.py │ ├── data │ │ ├── benchmarks.json │ │ └── currencies.json │ ├── apps.py │ └── urls.py ├── log_messages │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ └── factory.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── apps.py │ ├── admin.py │ ├── urls.py │ ├── serializers.py │ └── views.py ├── portfolios │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ └── factory.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── apps.py │ ├── admin.py │ ├── serializers_lite.py │ ├── calculators.py │ ├── urls.py │ └── models.py ├── stock_prices │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── mocks │ │ │ ├── __init__.py │ │ │ └── mock_yfinance.py │ │ ├── services │ │ │ ├── __init__.py │ │ │ └── test_yfinance_api_client.py │ │ ├── factory.py │ │ └── test_views.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ ├── task_run.py │ │ │ ├── get_tasks.py │ │ │ └── get_currency.py │ ├── migrations │ │ └── __init__.py │ ├── apps.py │ ├── services │ │ └── types.py │ ├── urls.py │ ├── admin.py │ ├── serializers.py │ ├── models.py │ └── views.py ├── buho_backend │ ├── tests │ │ ├── __init__.py │ │ └── base_test_case.py │ ├── utils │ │ └── __init__.py │ ├── __init__.py │ ├── transaction_types.py │ ├── signals.py │ ├── path_converters.py │ ├── wsgi.py │ ├── admin.py │ ├── views.py │ ├── asgi.py │ └── celery_app.py ├── exchange_rates │ ├── __init__.py │ ├── services │ │ └── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── services │ │ │ └── __init__.py │ │ └── factory.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── get_exchange.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_alter_exchangerate_options.py │ │ └── 0001_initial.py │ ├── apps.py │ ├── admin.py │ ├── urls.py │ ├── serializers.py │ └── models.py ├── dividends_transactions │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ └── factory.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ └── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0005_alter_dividendstransaction_notes.py │ │ └── 0004_alter_dividendstransaction_notes.py │ ├── admin.py │ ├── apps.py │ ├── urls.py │ ├── models.py │ └── serializers.py ├── rights_transactions │ ├── __init__.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── set_rights_total.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0005_alter_rightstransaction_notes.py │ │ └── 0004_alter_rightstransaction_notes.py │ ├── admin.py │ ├── apps.py │ ├── urls.py │ ├── models.py │ └── tests │ │ └── factory.py ├── shares_transactions │ ├── __init__.py │ ├── calculators │ │ └── __init__.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── set_shares_total.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0004_alter_sharestransaction_notes.py │ │ └── 0005_alter_sharestransaction_notes.py │ ├── new_utils │ │ └── __init__.py │ ├── admin.py │ ├── apps.py │ ├── urls.py │ └── tests │ │ └── factory.py ├── .coveragerc └── manage.py ├── client ├── .prettierignore ├── src │ ├── vite.env.d.ts │ ├── svg.d.ts │ ├── types │ │ ├── config.ts │ │ ├── location.ts │ │ ├── country.ts │ │ ├── portfolio-lite.ts │ │ ├── routes.ts │ │ ├── currency.ts │ │ ├── log-messages.ts │ │ ├── sector.ts │ │ ├── transaction.ts │ │ ├── task-result.ts │ │ ├── market.ts │ │ ├── stock-prices.ts │ │ ├── settings.ts │ │ ├── dividends-transaction.ts │ │ ├── exchange-rate.ts │ │ ├── benchmark.ts │ │ ├── portfolio-year-stats.ts │ │ ├── rights-transaction.ts │ │ ├── portfolio.ts │ │ ├── company-year-stats.ts │ │ ├── shares-transaction.ts │ │ └── csv.ts │ ├── version.ts │ ├── theme.tsx │ ├── utils │ │ ├── numbers.ts │ │ ├── dates.ts │ │ ├── grouping.ts │ │ └── countries.ts │ ├── test-utils │ │ ├── index.tsx │ │ └── render.tsx │ ├── App.css │ ├── mocks │ │ ├── server.ts │ │ └── responses │ │ │ ├── settings.ts │ │ │ └── currencies.ts │ ├── components │ │ ├── NotesRow │ │ │ └── NotesRow.tsx │ │ ├── Logo │ │ │ ├── Logo.test.tsx │ │ │ └── Logo.tsx │ │ ├── PageFooter │ │ │ └── PageFooter.tsx │ │ ├── BuySellLabel │ │ │ ├── BuySellLabel.tsx │ │ │ └── BuySellLabel.test.tsx │ │ ├── CountryFlag │ │ │ ├── CountryFlag.tsx │ │ │ └── CountryFlag.test.tsx │ │ ├── LoadingSpin │ │ │ ├── LoadingSpin.tsx │ │ │ └── LoadingSpin.test.tsx │ │ ├── NavigationLinks │ │ │ └── NavigationLink.tsx │ │ ├── ToggleThemeButton │ │ │ └── ToggleThemeButton.tsx │ │ ├── ChartPortfolioReturns │ │ │ └── BenchmarkSelect.tsx │ │ ├── CountrySelector │ │ │ └── CountrySelector.test.tsx │ │ └── ListLanguageProvider │ │ │ └── ListLanguageProvider.tsx │ ├── pages │ │ ├── import │ │ │ └── components │ │ │ │ ├── ImportSteps │ │ │ │ └── components │ │ │ │ │ ├── TradesImportStep │ │ │ │ │ ├── components │ │ │ │ │ │ └── TradesImportForm │ │ │ │ │ │ │ ├── TradesImportForm.module.css │ │ │ │ │ │ │ └── TradesImportFormProvider.tsx │ │ │ │ │ └── TradesImportStep.tsx │ │ │ │ │ ├── DividendsImportStep │ │ │ │ │ ├── components │ │ │ │ │ │ └── DividendsImportForm │ │ │ │ │ │ │ ├── DividendsImportForm.module.css │ │ │ │ │ │ │ └── DividendsImportFormProvider.tsx │ │ │ │ │ └── DividendsImportStep.tsx │ │ │ │ │ ├── DragAndDropCsvParser │ │ │ │ │ └── utils │ │ │ │ │ │ ├── csv-parsing-utils.ts │ │ │ │ │ │ └── info-parsing.ts │ │ │ │ │ ├── CorporateActionsImportStep │ │ │ │ │ └── components │ │ │ │ │ │ └── CorporateActionsImportForm │ │ │ │ │ │ ├── CorporateActionsImportForm.module.css │ │ │ │ │ │ └── CorportateActionsImportFormProvider.tsx │ │ │ │ │ └── UpdatePortfolioStep │ │ │ │ │ └── UpdatePortfolioStep.tsx │ │ │ │ └── ImportFromBrokerPageHeader │ │ │ │ └── ImportFromBrokerPageHeader.tsx │ │ ├── settings │ │ │ └── SettingsPage │ │ │ │ └── components │ │ │ │ ├── SettingsHeader │ │ │ │ └── SettingsHeader.tsx │ │ │ │ └── InfoMessageAddManually │ │ │ │ └── InfoMessageAddManually.tsx │ │ ├── companies │ │ │ └── CompanyDetailsPage │ │ │ │ └── components │ │ │ │ ├── CompanyExtraInfo │ │ │ │ └── CompanyExtraInfo.tsx │ │ │ │ ├── CompanyStats │ │ │ │ └── components │ │ │ │ │ └── YearSelector │ │ │ │ │ └── YearSelector.tsx │ │ │ │ └── Charts │ │ │ │ └── Charts.tsx │ │ ├── markets │ │ │ └── MarketsListPage │ │ │ │ ├── components │ │ │ │ └── MarketsPageHeader │ │ │ │ │ └── MarketsPageHeader.test.tsx │ │ │ │ └── MarketsListPage.tsx │ │ ├── home │ │ │ ├── HomePage.tsx │ │ │ └── components │ │ │ │ └── HomePageHeader │ │ │ │ └── HomePageHeader.tsx │ │ ├── portfolios │ │ │ ├── PortfolioDetailPage │ │ │ │ └── components │ │ │ │ │ └── PortfolioCharts │ │ │ │ │ └── PortfolioCharts.tsx │ │ │ └── PorfolioChartsPage │ │ │ │ ├── PortfolioChartsPage.tsx │ │ │ │ └── components │ │ │ │ └── ChartsList │ │ │ │ └── components │ │ │ │ ├── ChartInvestedByCompany │ │ │ │ └── ChartInvestedByCompanyProvider.tsx │ │ │ │ ├── ChartSectorsByCompany │ │ │ │ └── ChartSectorsByCompanyProvider.tsx │ │ │ │ ├── ChartMarketByCompany │ │ │ │ └── ChartMarketsByCompanyProvider.tsx │ │ │ │ ├── ChartInvestedByCompanyYearly │ │ │ │ └── ChartInvestedByCompanyYearlyProvider.tsx │ │ │ │ ├── ChartBrokerByCompany │ │ │ │ └── ChartBrokerByCompanyProvider.tsx │ │ │ │ └── ChartValueByCompany │ │ │ │ └── ChartValueByCompanyProvider.tsx │ │ ├── benchmarks │ │ │ └── BenchmarksListPage │ │ │ │ ├── components │ │ │ │ └── BenchmarkYearForm │ │ │ │ │ └── BenchmarkYearFormProvider.tsx │ │ │ │ └── BenchmarksListPage.tsx │ │ ├── sectors │ │ │ └── SectorsListPage │ │ │ │ └── SectorsListPage.tsx │ │ └── currencies │ │ │ └── CurrenciesListPage │ │ │ └── CurrenciesListPage.tsx │ ├── api │ │ ├── query-client.ts │ │ ├── api-interceptors.ts │ │ └── api-client.ts │ ├── make_version.ts │ ├── hooks │ │ ├── use-query-params │ │ │ └── use-query-params.ts │ │ ├── use-companies │ │ │ └── use-companies-search.ts │ │ ├── use-currencies │ │ │ └── use-currencies.test.tsx │ │ ├── use-settings │ │ │ └── use-mutation-settings.test.ts │ │ └── use-task-results │ │ │ └── use-task-results.ts │ ├── locales │ │ └── en │ │ │ └── translation.json │ ├── config.ts │ ├── routes.ts │ ├── index.css │ ├── i18n.ts │ └── index.tsx ├── .eslintignore ├── public │ ├── favicon.ico │ ├── robots.txt │ ├── logo-96x96.png │ ├── logo-144x144.png │ ├── logo-192x192.png │ ├── logo-512x512.png │ ├── apple-touch-icon.png │ └── manifest.json ├── .husky │ └── pre-commit ├── .prettierrc.json ├── .env.local ├── .env.test ├── .gitignore ├── postcss.config.cjs ├── tsconfig.json └── vite.config.mjs ├── logo.png ├── poetry.toml ├── docs ├── development │ ├── requirements.md │ ├── index.md │ └── database-select.md ├── user-guides │ ├── index.md │ ├── create-portfolio.md │ ├── initialize-app-data.md │ ├── create-company.md │ └── deploy-docker-compose.md └── _config.yml ├── .flake8 ├── .dockerignore ├── mypy.ini ├── .pre-commit-config.yaml ├── etc └── entrypoint.sh ├── docker.client.Dockerfile ├── .github └── workflows │ ├── react.yml │ ├── docker-build-publish-backend-latest.yml │ ├── docker-build-publish-backend-tag.yml │ ├── docker-build-publish-client-latest.yml │ ├── docker-build-publish-client-tag.yml │ └── django.yml └── .vscode └── settings.json /data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /volumes/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /backend/markets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/sectors/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/stats/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/benchmarks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/companies/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/currencies/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/initialize_data/admin.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/initialize_data/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/initialize_data/tests.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/log_messages/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/markets/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/portfolios/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/sectors/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/stats/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/stats/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/stats/tests/factory.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/stats/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/stock_prices/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/buho_backend/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/buho_backend/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/companies/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/companies/urls/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/currencies/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/exchange_rates/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/initialize_data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/log_messages/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/markets/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/portfolios/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/sectors/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/settings/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/stats/calculators/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/stats/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/stats/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/stock_prices/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/.prettierignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ -------------------------------------------------------------------------------- /backend/benchmarks/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/companies/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/currencies/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/dividends_transactions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/exchange_rates/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/exchange_rates/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/log_messages/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/portfolios/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/rights_transactions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/settings/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/shares_transactions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/stock_prices/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/stock_prices/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/dividends_transactions/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/exchange_rates/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/exchange_rates/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/initialize_data/initializers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/initialize_data/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/initialize_data/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/stock_prices/tests/mocks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/stock_prices/tests/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/dividends_transactions/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/dividends_transactions/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/exchange_rates/tests/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/rights_transactions/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/rights_transactions/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/shares_transactions/calculators/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/shares_transactions/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/shares_transactions/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/shares_transactions/new_utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/stock_prices/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/exchange_rates/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/dividends_transactions/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/rights_transactions/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/shares_transactions/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/vite.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/src/svg.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renefs/buho-stocks/HEAD/logo.png -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | create = true 3 | in-project = true 4 | -------------------------------------------------------------------------------- /client/.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules/** 2 | **/build/** 3 | .eslintrc.js 4 | -------------------------------------------------------------------------------- /client/src/types/config.ts: -------------------------------------------------------------------------------- 1 | export interface IConfig { 2 | SENTRY_ENV: string; 3 | } 4 | -------------------------------------------------------------------------------- /backend/benchmarks/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renefs/buho-stocks/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: / 4 | -------------------------------------------------------------------------------- /client/src/version.ts: -------------------------------------------------------------------------------- 1 | export const PACKAGE_VERSION = "1.0.5"; 2 | export default { PACKAGE_VERSION }; 3 | -------------------------------------------------------------------------------- /backend/buho_backend/__init__.py: -------------------------------------------------------------------------------- 1 | from .celery_app import app as celery_app 2 | 3 | __all__ = ("celery_app",) 4 | -------------------------------------------------------------------------------- /backend/rights_transactions/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /backend/shares_transactions/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /client/public/logo-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renefs/buho-stocks/HEAD/client/public/logo-96x96.png -------------------------------------------------------------------------------- /backend/dividends_transactions/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /client/public/logo-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renefs/buho-stocks/HEAD/client/public/logo-144x144.png -------------------------------------------------------------------------------- /client/public/logo-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renefs/buho-stocks/HEAD/client/public/logo-192x192.png -------------------------------------------------------------------------------- /client/public/logo-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renefs/buho-stocks/HEAD/client/public/logo-512x512.png -------------------------------------------------------------------------------- /client/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renefs/buho-stocks/HEAD/client/public/apple-touch-icon.png -------------------------------------------------------------------------------- /client/src/types/location.ts: -------------------------------------------------------------------------------- 1 | export interface LocationState { 2 | from: { 3 | pathname: string; 4 | }; 5 | } 6 | -------------------------------------------------------------------------------- /client/.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | . ./.venv/bin/activate 5 | 6 | pre-commit run -------------------------------------------------------------------------------- /client/src/types/country.ts: -------------------------------------------------------------------------------- 1 | export interface ICountry { 2 | key: string; 3 | name: string; 4 | code: string; 5 | } 6 | -------------------------------------------------------------------------------- /client/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": false 6 | } 7 | -------------------------------------------------------------------------------- /backend/initialize_data/data/benchmarks.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "name": "MSCI World", "id": 1 }, 3 | { "name": "S&P 500", "id": 2 } 4 | ] 5 | -------------------------------------------------------------------------------- /docs/development/requirements.md: -------------------------------------------------------------------------------- 1 | # 1. Requirements 2 | 3 | ## Technologies 4 | 5 | - Python 3.11 6 | - Node 20 7 | - Django 5 8 | - Mantine UI -------------------------------------------------------------------------------- /backend/buho_backend/transaction_types.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class TransactionType(models.TextChoices): 5 | BUY = "BUY" 6 | SELL = "SELL" 7 | -------------------------------------------------------------------------------- /client/src/theme.tsx: -------------------------------------------------------------------------------- 1 | import { createTheme } from "@mantine/core"; 2 | 3 | export const theme = createTheme({ 4 | /** Put your mantine theme override here */ 5 | }); 6 | -------------------------------------------------------------------------------- /client/src/types/portfolio-lite.ts: -------------------------------------------------------------------------------- 1 | export interface IPortfolioLite { 2 | id: number; 3 | baseCurrency: string; 4 | name: string; 5 | countryCode: string; 6 | } 7 | -------------------------------------------------------------------------------- /client/src/utils/numbers.ts: -------------------------------------------------------------------------------- 1 | export function maxDecimalPlaces(value: number, decimals: number): number { 2 | return Number(value.toFixed(decimals + 1).slice(0, -1)); 3 | } 4 | -------------------------------------------------------------------------------- /backend/markets/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MarketsConfig(AppConfig): 5 | """Configuration for Markets app""" 6 | 7 | name = "markets" 8 | -------------------------------------------------------------------------------- /backend/stats/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class StatsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "stats" 7 | -------------------------------------------------------------------------------- /backend/sectors/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SectorsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "sectors" 7 | -------------------------------------------------------------------------------- /backend/settings/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SettingsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "settings" 7 | -------------------------------------------------------------------------------- /client/.env.local: -------------------------------------------------------------------------------- 1 | VITE_ENV = local 2 | VITE_PORT = 3000 3 | VITE_API_URL = http://127.0.0.1:8001 4 | VITE_SENTRY_DSN = 5 | VITE_SENTRY_ENV = development 6 | VITE_WEBSOCKETS_URL = 127.0.0.1:8001 7 | -------------------------------------------------------------------------------- /docs/development/index.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | 1. [Requirements](requirements.md) 4 | 2. [Select a database](database-select.md) 5 | 3. [Install and run the project locally](run-project-locally.md) -------------------------------------------------------------------------------- /backend/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | omit = */tests/*, */migrations/*, */wsgi.py, manage.py, fabfile.py, */__init__.py 4 | source = . 5 | 6 | # .coveragerc 7 | [report] 8 | show_missing = True -------------------------------------------------------------------------------- /backend/companies/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CompaniesConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "companies" 7 | -------------------------------------------------------------------------------- /backend/currencies/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CurrenciesConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "currencies" 7 | -------------------------------------------------------------------------------- /backend/portfolios/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PortfoliosConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "portfolios" 7 | -------------------------------------------------------------------------------- /client/src/types/routes.ts: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | 3 | export interface RoutePathProps { 4 | key: string; 5 | path: string; 6 | text: string; 7 | icon: ReactElement; 8 | } 9 | -------------------------------------------------------------------------------- /backend/log_messages/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class LogMessagesConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "log_messages" 7 | -------------------------------------------------------------------------------- /backend/stock_prices/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class StockPricesConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "stock_prices" 7 | -------------------------------------------------------------------------------- /backend/benchmarks/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class StockMarketsIndexesConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "benchmarks" 7 | -------------------------------------------------------------------------------- /backend/exchange_rates/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ExchangeRatesConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "exchange_rates" 7 | -------------------------------------------------------------------------------- /backend/initialize_data/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class InitializeDataConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "initialize_data" 7 | -------------------------------------------------------------------------------- /backend/stock_prices/services/types.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | 3 | 4 | class TypedStockPrice(TypedDict): 5 | price: float 6 | price_currency: str 7 | ticker: str 8 | transaction_date: str 9 | -------------------------------------------------------------------------------- /backend/rights_transactions/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class RightsTransactionsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "rights_transactions" 7 | -------------------------------------------------------------------------------- /backend/shares_transactions/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SharesTransactionsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "shares_transactions" 7 | -------------------------------------------------------------------------------- /client/.env.test: -------------------------------------------------------------------------------- 1 | VITE_ENV = test 2 | VITE_PORT = 3000 3 | VITE_API_URL = http://127.0.0.1:8001 4 | VITE_SENTRY_DSN = https://1234.ingest.sentry.io/test 5 | VITE_SENTRY_ENV = development 6 | VITE_WEBSOCKETS_URL = 127.0.0.1:8001 -------------------------------------------------------------------------------- /docs/user-guides/index.md: -------------------------------------------------------------------------------- 1 | # User guides 2 | 3 | 1. [Deploy the application using Docker Compose](deploy-docker-compose.md) 4 | 2. [Initialize the app data](initialize-app-data.md) 5 | 3. [Create a portfolio](create-portfolio.md) -------------------------------------------------------------------------------- /client/src/test-utils/index.tsx: -------------------------------------------------------------------------------- 1 | import { userEvent } from "@testing-library/user-event"; 2 | 3 | export * from "@testing-library/react"; 4 | export { render as customRender, wrapper } from "./render"; 5 | export { userEvent }; 6 | -------------------------------------------------------------------------------- /backend/currencies/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import DefaultRouter 2 | 3 | from currencies import views 4 | 5 | router = DefaultRouter() 6 | 7 | router.register(r"currencies", views.CurrencyViewSet, basename="currencies") 8 | -------------------------------------------------------------------------------- /backend/dividends_transactions/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DividendsTransactionsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "dividends_transactions" 7 | -------------------------------------------------------------------------------- /backend/buho_backend/signals.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import pre_save 2 | 3 | 4 | def validate_model(sender, **kwargs): 5 | kwargs["instance"].clean() 6 | 7 | 8 | pre_save.connect(validate_model, dispatch_uid="validate_models") 9 | -------------------------------------------------------------------------------- /backend/rights_transactions/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import DefaultRouter 2 | 3 | from rights_transactions import views 4 | 5 | router = DefaultRouter() 6 | 7 | router.register(r"rights", views.RightsViewSet, basename="rights") 8 | -------------------------------------------------------------------------------- /backend/shares_transactions/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import DefaultRouter 2 | 3 | from shares_transactions import views 4 | 5 | router = DefaultRouter() 6 | 7 | router.register(r"shares", views.SharesViewSet, basename="shares") 8 | -------------------------------------------------------------------------------- /client/src/App.css: -------------------------------------------------------------------------------- 1 | .site-layout .site-layout-background { 2 | background: #fff; 3 | } 4 | 5 | .content { 6 | padding: 1rem; 7 | } 8 | 9 | .ant-layout-sider-children { 10 | display: flex; 11 | flex-direction: column; 12 | } 13 | -------------------------------------------------------------------------------- /backend/stock_prices/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import DefaultRouter 2 | 3 | from stock_prices import views 4 | 5 | router = DefaultRouter() 6 | 7 | router.register(r"stock-prices", views.ExchangeRateViewSet, basename="stock-prices") 8 | -------------------------------------------------------------------------------- /backend/dividends_transactions/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import DefaultRouter 2 | 3 | from dividends_transactions import views 4 | 5 | router = DefaultRouter() 6 | 7 | router.register(r"dividends", views.DividendsViewSet, basename="dividends") 8 | -------------------------------------------------------------------------------- /backend/stock_prices/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from stock_prices.models import StockPrice 4 | 5 | 6 | @admin.register(StockPrice) 7 | class StockPriceAdmin(admin.ModelAdmin): 8 | list_display = ["ticker", "price", "transaction_date"] 9 | -------------------------------------------------------------------------------- /client/src/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from "msw/node"; 2 | import { handlers } from "./handlers"; 3 | 4 | // This configures a request mocking server with the given request handlers. 5 | export const server = setupServer(...handlers); 6 | 7 | export default server; 8 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # black compatibility 3 | max-line-length = 88 4 | extend-exclude = 5 | client 6 | docs 7 | docs_old 8 | media 9 | venv 10 | env 11 | migrations 12 | 13 | select = C,E,F,W,DJ,DJ10 14 | # black compatibility 15 | extend-ignore = E203,W503 -------------------------------------------------------------------------------- /backend/currencies/tests/factory.py: -------------------------------------------------------------------------------- 1 | from factory import Factory, Faker 2 | 3 | 4 | class CurrencyFactory(Factory): 5 | name = Faker("currency_name") 6 | code = Faker("currency_code") 7 | symbol = Faker("currency_symbol") 8 | countries = [Faker("country") for _ in range(3)] 9 | -------------------------------------------------------------------------------- /client/src/types/currency.ts: -------------------------------------------------------------------------------- 1 | export interface ICurrencyFormFields { 2 | name: string; 3 | code: string; 4 | symbol: string; 5 | } 6 | 7 | export interface ICurrency extends ICurrencyFormFields { 8 | dateCreated: string; 9 | lastUpdated: string; 10 | id: number; 11 | } 12 | -------------------------------------------------------------------------------- /backend/settings/tests/factory.py: -------------------------------------------------------------------------------- 1 | from factory import Faker, django 2 | 3 | from settings.models import UserSettings 4 | 5 | 6 | class UserSettingsFactory(django.DjangoModelFactory): 7 | class Meta: 8 | model = UserSettings 9 | 10 | language = Faker("language_code") 11 | -------------------------------------------------------------------------------- /backend/settings/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from settings import views 4 | 5 | urlpatterns = [ 6 | path("", views.UserSettingsDetailAPIView.as_view(), name="user-settings-detail"), 7 | # path("delete//",views.DeleteTodoAPIView.as_view(),name="delete_todo") 8 | ] 9 | -------------------------------------------------------------------------------- /client/src/components/NotesRow/NotesRow.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | 3 | interface NotesRowProps { 4 | notes: string; 5 | } 6 | 7 | export default function NotesRow({ notes }: NotesRowProps): ReactElement { 8 | return

{notes}

; 9 | } 10 | -------------------------------------------------------------------------------- /backend/sectors/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import DefaultRouter 2 | 3 | from sectors import views 4 | 5 | router = DefaultRouter() 6 | 7 | router.register(r"super-sectors", views.SuperSectorViewSet, basename="super_sectors") 8 | router.register(r"sectors", views.SectorViewSet, basename="sectors") 9 | -------------------------------------------------------------------------------- /client/src/pages/import/components/ImportSteps/components/TradesImportStep/components/TradesImportForm/TradesImportForm.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | position: relative; 3 | } 4 | 5 | .error { 6 | position: absolute; 7 | bottom: rem(-25px); 8 | } 9 | 10 | .wrapper { 11 | margin-bottom: 0; 12 | } 13 | -------------------------------------------------------------------------------- /backend/companies/urls/companies.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | from django.urls import path 3 | 4 | from companies import views 5 | 6 | urlpatterns = [ 7 | path( 8 | "search//", 9 | views.CompanySearchAPIView.as_view(), 10 | name="companies-search", 11 | ), 12 | ] 13 | -------------------------------------------------------------------------------- /backend/companies/filters.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | 4 | class FilteredCompanySerializer(serializers.ListSerializer): 5 | def to_representation(self, data): 6 | data = data.filter(is_closed=False) 7 | return super(FilteredCompanySerializer, self).to_representation(data) 8 | -------------------------------------------------------------------------------- /client/src/pages/import/components/ImportSteps/components/DividendsImportStep/components/DividendsImportForm/DividendsImportForm.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | position: relative; 3 | } 4 | 5 | .error { 6 | position: absolute; 7 | bottom: rem(-25px); 8 | } 9 | 10 | .wrapper { 11 | margin-bottom: 0; 12 | } 13 | -------------------------------------------------------------------------------- /client/src/types/log-messages.ts: -------------------------------------------------------------------------------- 1 | export interface ILogMessageFormFields { 2 | messageText: string; 3 | messageType: string; 4 | portfolio: number; 5 | } 6 | 7 | export interface ILogMessage extends ILogMessageFormFields { 8 | id: number; 9 | dateCreated: string; 10 | lastUpdated: string; 11 | } 12 | -------------------------------------------------------------------------------- /backend/currencies/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from currencies.models import Currency 4 | 5 | 6 | class CurrencySerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = Currency 9 | fields = ["name", "code", "symbol", "id", "date_created", "last_updated"] 10 | -------------------------------------------------------------------------------- /client/src/pages/import/components/ImportSteps/components/DragAndDropCsvParser/utils/csv-parsing-utils.ts: -------------------------------------------------------------------------------- 1 | export function convertDataLinesToList(data: string[][]) { 2 | console.log("Converting data to list..."); 3 | // Create a list with the data attribute of each object 4 | const dataRows = data; 5 | return dataRows; 6 | } 7 | -------------------------------------------------------------------------------- /client/src/pages/import/components/ImportSteps/components/CorporateActionsImportStep/components/CorporateActionsImportForm/CorporateActionsImportForm.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | position: relative; 3 | } 4 | 5 | .error { 6 | position: absolute; 7 | bottom: rem(-25px); 8 | } 9 | 10 | .wrapper { 11 | margin-bottom: 0; 12 | } 13 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | -------------------------------------------------------------------------------- /client/src/utils/dates.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | 3 | export const convertToTimezone = ( 4 | date: string, 5 | dateFormat = "HH:mm:ss", 6 | fromTz = "Europe/Zurich", 7 | toTz = "Europe/Zurich", 8 | ) => { 9 | return dayjs.tz(date, dateFormat, fromTz).tz(toTz).toDate(); 10 | }; 11 | 12 | export default convertToTimezone; 13 | -------------------------------------------------------------------------------- /backend/benchmarks/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import DefaultRouter 2 | 3 | from benchmarks import views 4 | 5 | router = DefaultRouter() 6 | 7 | router.register(r"benchmarks", views.BenchmarkViewSet, basename="benchmarks") 8 | router.register( 9 | r"benchmarks-years", views.BenchmarkYearViewSet, basename="benchmarks_years" 10 | ) 11 | -------------------------------------------------------------------------------- /client/src/api/query-client.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | 3 | const queryClient = new QueryClient({ 4 | defaultOptions: { 5 | queries: { 6 | retry: false, 7 | refetchOnWindowFocus: false, 8 | staleTime: 1000 * 60, // 60 seconds 9 | }, 10 | }, 11 | }); 12 | 13 | export default queryClient; 14 | -------------------------------------------------------------------------------- /client/src/make_version.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-require-imports 2 | const PACKAGE_VERSION = require("../package.json").version; 3 | 4 | console.log(`export const PACKAGE_VERSION = "${PACKAGE_VERSION}";`); 5 | 6 | console.log(`export default { PACKAGE_VERSION };`); 7 | 8 | console.error("package.json version:", PACKAGE_VERSION); 9 | -------------------------------------------------------------------------------- /backend/buho_backend/path_converters.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | 3 | 4 | class DateConverter: 5 | regex = "[0-9]{4}-[0-9]{2}-[0-9]{2}" 6 | 7 | def to_python(self, value: str): 8 | return datetime.strptime(value, "%Y-%m-%d").date() 9 | 10 | def to_url(self, value: date): 11 | return value.strftime("%Y-%m-%d") 12 | -------------------------------------------------------------------------------- /backend/markets/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from buho_backend.admin import BaseAdmin 4 | from markets.models import Market 5 | 6 | 7 | # Register your models here. 8 | @admin.register(Market) 9 | class MarketAdmin(BaseAdmin): 10 | list_display = ["id", "name", "last_updated", "date_created"] 11 | search_fields = ["name"] 12 | -------------------------------------------------------------------------------- /backend/currencies/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from buho_backend.admin import BaseAdmin 4 | from currencies.models import Currency 5 | 6 | 7 | # Register your models here. 8 | @admin.register(Currency) 9 | class CurrencyAdmin(BaseAdmin): 10 | list_display = ["id", "code", "symbol", "name"] 11 | search_fields = ["code", "name"] 12 | -------------------------------------------------------------------------------- /backend/markets/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from rest_framework.routers import DefaultRouter 3 | 4 | from markets import views 5 | 6 | router = DefaultRouter() 7 | 8 | router.register(r"markets", views.MarketViewSet, basename="markets") 9 | 10 | urlpatterns = [ 11 | path("timezones/", views.TimezoneList.as_view(), name="timezone-list"), 12 | ] 13 | -------------------------------------------------------------------------------- /client/src/components/Logo/Logo.test.tsx: -------------------------------------------------------------------------------- 1 | import Logo from "./Logo"; 2 | import { customRender, screen } from "test-utils"; 3 | 4 | describe("Logo tests", () => { 5 | it("renders the logo", async () => { 6 | customRender(); 7 | 8 | const element = screen.getByText(/Buho Stocks/i); 9 | expect(element).toBeInTheDocument(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /backend/portfolios/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from buho_backend.admin import BaseAdmin 4 | from portfolios.models import Portfolio 5 | 6 | 7 | # Register your models here. 8 | @admin.register(Portfolio) 9 | class PortfolioAdmin(BaseAdmin): 10 | list_display = ["id", "name", "last_updated", "date_created"] 11 | search_fields = ["name"] 12 | -------------------------------------------------------------------------------- /client/src/components/PageFooter/PageFooter.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@mantine/core"; 2 | import { PACKAGE_VERSION } from "version"; 3 | 4 | export default function PageFooter() { 5 | return ( 6 | 7 | Buho Stocks {PACKAGE_VERSION} - Bocabitlabs ©2021 -{" "} 8 | {new Date().getFullYear()} 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /client/src/hooks/use-query-params/use-query-params.ts: -------------------------------------------------------------------------------- 1 | import { useLocation } from "react-router-dom"; 2 | 3 | // A custom hook that builds on useLocation to parse 4 | // the query string for you. 5 | export function useQueryParameters() { 6 | const currentSearch = useLocation().search; 7 | return new URLSearchParams(currentSearch); 8 | } 9 | 10 | export default useQueryParameters; 11 | -------------------------------------------------------------------------------- /client/src/types/sector.ts: -------------------------------------------------------------------------------- 1 | export interface ISectorBase { 2 | name: string; 3 | isSuperSector?: boolean; 4 | } 5 | 6 | export interface ISectorFormFields extends ISectorBase { 7 | superSector?: number | string; 8 | } 9 | 10 | export interface ISector extends ISectorBase { 11 | id: number; 12 | superSector?: ISector; 13 | dateCreated: string; 14 | lastUpdated: string; 15 | } 16 | -------------------------------------------------------------------------------- /backend/markets/tests/factory.py: -------------------------------------------------------------------------------- 1 | from factory import Faker, django 2 | 3 | from markets.models import Market 4 | 5 | 6 | class MarketFactory(django.DjangoModelFactory): 7 | class Meta: 8 | model = Market 9 | 10 | name = Faker("company") 11 | description = Faker("paragraph") 12 | region = Faker("country") 13 | open_time = Faker("time") 14 | close_time = Faker("time") 15 | -------------------------------------------------------------------------------- /backend/portfolios/serializers_lite.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from portfolios.models import Portfolio 4 | 5 | 6 | class PortfolioSerializerLite(serializers.ModelSerializer): 7 | class Meta: 8 | model = Portfolio 9 | fields = [ 10 | "id", 11 | "name", 12 | "country_code", 13 | "base_currency", 14 | ] 15 | -------------------------------------------------------------------------------- /backend/log_messages/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from buho_backend.admin import BaseAdmin 4 | from log_messages.models import LogMessage 5 | 6 | 7 | # Register your models here. 8 | @admin.register(LogMessage) 9 | class LogMessageAdmin(BaseAdmin): 10 | list_display = ["id", "message_type", "portfolio_link", "date_created"] 11 | search_fields = ["message_type", "portfolio"] 12 | -------------------------------------------------------------------------------- /backend/markets/templates/admin/markets/market/change_list.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_list.html" %} 2 | {% load i18n admin_urls %} 3 | {% block object-tools-items %} 4 |
  • 5 | Create initial markets 7 |
  • 8 | {{ block.super }} 9 | {% endblock object-tools-items %} 10 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | *.pyo 4 | *.pyd 5 | .Python 6 | pip-log.txt 7 | .tox 8 | .coverage 9 | .coverage.* 10 | .coveragerc 11 | .cache 12 | nosetests.xml 13 | coverage.xml 14 | *.cover 15 | *.log 16 | *mypy.ini 17 | .mypy_cache 18 | .pytest_cache 19 | .pylintrc_old 20 | .hypothesis 21 | node_modules 22 | client/node_modules 23 | client/build 24 | client/dist 25 | .venv 26 | .git 27 | .gitingore 28 | -------------------------------------------------------------------------------- /backend/sectors/templates/admin/sectors/sector/change_list.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_list.html" %} 2 | {% load i18n admin_urls admin_list %} 3 | {% block object-tools-items %} 4 |
  • 5 | Create initial sectors 7 |
  • 8 | {{ block.super }} 9 | {% endblock object-tools-items %} 10 | -------------------------------------------------------------------------------- /client/src/types/transaction.ts: -------------------------------------------------------------------------------- 1 | export interface Transaction { 2 | count: number; 3 | grossPricePerShare: number; 4 | grossPricePerShareCurrency: string; 5 | totalCommission: number; 6 | totalCommissionCurrency: string; 7 | exchangeRate: number; 8 | transactionDate: string; 9 | company: number; 10 | color: string; 11 | notes: string; 12 | } 13 | 14 | export type TransactionType = "BUY" | "SELL"; 15 | -------------------------------------------------------------------------------- /backend/currencies/templates/admin/currencies/currency/change_list.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_list.html" %} 2 | {% load i18n admin_urls %} 3 | {% block object-tools-items %} 4 |
  • 5 | Create initial currencies 7 |
  • 8 | {{ block.super }} 9 | {% endblock object-tools-items %} 10 | -------------------------------------------------------------------------------- /client/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-preset-mantine": {}, 4 | "postcss-simple-vars": { 5 | variables: { 6 | "mantine-breakpoint-xs": "36em", 7 | "mantine-breakpoint-sm": "48em", 8 | "mantine-breakpoint-md": "62em", 9 | "mantine-breakpoint-lg": "75em", 10 | "mantine-breakpoint-xl": "88em", 11 | }, 12 | }, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /client/src/types/task-result.ts: -------------------------------------------------------------------------------- 1 | export interface ITaskDetails { 2 | task_description: string; 3 | company: string; 4 | year: string; 5 | } 6 | 7 | export interface ITaskResult { 8 | task_id: string; 9 | status: string; 10 | progress: number; 11 | task_name: string; 12 | details: ITaskDetails; 13 | notificationId: string; 14 | } 15 | 16 | export interface ITaskResultWrapper { 17 | status: ITaskResult; 18 | } 19 | -------------------------------------------------------------------------------- /client/src/utils/grouping.ts: -------------------------------------------------------------------------------- 1 | export const groupByName = (arr: T[], key: string): Record => { 2 | const initialValue: Record = {}; 3 | return arr.reduce((acc, cval) => { 4 | const myAttribute = String((cval as Record)[key]); 5 | acc[myAttribute] = [...(acc[myAttribute] || []), cval]; 6 | return acc; 7 | }, initialValue); 8 | }; 9 | 10 | export default { groupByName }; 11 | -------------------------------------------------------------------------------- /client/src/types/market.ts: -------------------------------------------------------------------------------- 1 | export interface ITimezone { 2 | name: string; 3 | } 4 | 5 | export interface IMarketFormFields { 6 | name: string; 7 | description: string; 8 | region: string; 9 | openTime: Date | string; 10 | closeTime: Date | string; 11 | timezone: string; 12 | } 13 | 14 | export interface IMarket extends IMarketFormFields { 15 | id: number; 16 | dateCreated: string; 17 | lastUpdated: string; 18 | } 19 | -------------------------------------------------------------------------------- /backend/stock_prices/management/commands/task_run.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from stats.tasks import debug_task 6 | 7 | logger = logging.getLogger("buho_backend") 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Runs a sample task" 12 | 13 | def handle(self, *args, **options): 14 | self.stdout.write("Running sample task") 15 | debug_task.delay() 16 | -------------------------------------------------------------------------------- /client/src/locales/en/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "WELCOME_MESSAGE1": "Welcome to Buho Stocks. In this application you can add several portfolios, companies, stocks, dividends, etc.", 3 | "WELCOME_MESSAGE2": "If it's your first time here, you can automatically create some data in the application to have an easier landing.", 4 | "WELCOME_MESSAGE3": "If you have any questions, you can check the application documentation. Enjoy the application!" 5 | } 6 | -------------------------------------------------------------------------------- /backend/companies/utils.py: -------------------------------------------------------------------------------- 1 | from shares_transactions.models import SharesTransaction 2 | 3 | 4 | def get_company_first_year(company_id: int) -> int | None: 5 | query = SharesTransaction.objects.filter(company_id=company_id).order_by( 6 | "transaction_date" 7 | ) 8 | if query.exists(): 9 | first_transaction = query[0] 10 | year: int = first_transaction.transaction_date.year 11 | return year 12 | return None 13 | -------------------------------------------------------------------------------- /backend/log_messages/tests/factory.py: -------------------------------------------------------------------------------- 1 | from factory import Faker, SubFactory, django 2 | 3 | from log_messages.models import LogMessage 4 | from portfolios.tests.factory import PortfolioFactory 5 | 6 | 7 | class LogMessageFactory(django.DjangoModelFactory): 8 | class Meta: 9 | model = LogMessage 10 | 11 | message_type = Faker("company") 12 | message_text = Faker("paragraph") 13 | 14 | portfolio = SubFactory(PortfolioFactory) 15 | -------------------------------------------------------------------------------- /backend/portfolios/calculators.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from shares_transactions.models import SharesTransaction 4 | 5 | logger = logging.getLogger("buho_backend") 6 | 7 | 8 | def get_portfolio_first_year(portfolio_id): 9 | query = SharesTransaction.objects.filter(company__portfolio=portfolio_id).order_by( 10 | "transaction_date" 11 | ) 12 | if query.exists(): 13 | return query[0].transaction_date.year 14 | return None 15 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | mypy_path = backend 3 | 4 | namespace_packages = True 5 | explicit_package_bases = True 6 | ignore_missing_imports = True 7 | no_implicit_optional = True 8 | warn_return_any = True 9 | warn_unused_configs = True 10 | 11 | exclude = ^.+?/(migrations)/ 12 | 13 | plugins = 14 | mypy_django_plugin.main, 15 | mypy_drf_plugin.main 16 | 17 | [mypy.plugins.django-stubs] 18 | django_settings_module = "buho_backend.settings" 19 | 20 | -------------------------------------------------------------------------------- /client/src/components/Logo/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { Group, Image, Title } from "@mantine/core"; 2 | 3 | export default function Logo() { 4 | return ( 5 | 6 | 13 | 14 | Buho Stocks 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /backend/portfolios/tests/factory.py: -------------------------------------------------------------------------------- 1 | from factory import Faker, django 2 | 3 | from portfolios.models import Portfolio 4 | 5 | 6 | class PortfolioFactory(django.DjangoModelFactory): 7 | class Meta: 8 | model = Portfolio 9 | 10 | name = Faker("company") 11 | color = Faker("color") 12 | description = Faker("paragraph") 13 | base_currency = "EUR" 14 | country_code = Faker("country_code") 15 | hide_closed_companies = Faker("boolean") 16 | -------------------------------------------------------------------------------- /backend/stock_prices/tests/factory.py: -------------------------------------------------------------------------------- 1 | from factory import Faker, django 2 | 3 | from stock_prices.models import StockPrice 4 | 5 | 6 | class StockPriceTransactionFactory(django.DjangoModelFactory): 7 | class Meta: 8 | model = StockPrice 9 | 10 | company_name = Faker("company") 11 | price = Faker("pydecimal", left_digits=4, right_digits=3, positive=True) 12 | transaction_date = Faker("date_object") 13 | ticker = Faker("pystr", max_chars=4) 14 | -------------------------------------------------------------------------------- /backend/exchange_rates/tests/factory.py: -------------------------------------------------------------------------------- 1 | from factory import Faker, django 2 | 3 | from exchange_rates.models import ExchangeRate 4 | 5 | 6 | class ExchangeRateFactory(django.DjangoModelFactory): 7 | class Meta: 8 | model = ExchangeRate 9 | 10 | exchange_from = Faker("currency_code") 11 | exchange_to = Faker("currency_code") 12 | exchange_rate = Faker("pydecimal", left_digits=1, right_digits=3, positive=True) 13 | exchange_date = Faker("date_object") 14 | -------------------------------------------------------------------------------- /client/src/types/stock-prices.ts: -------------------------------------------------------------------------------- 1 | export interface IStockPriceFormFields { 2 | ticker: string; 3 | transactionDate: Date; 4 | price: number; 5 | priceCurrency: string; 6 | } 7 | 8 | export interface IStockPrice extends IStockPriceFormFields { 9 | id: number; 10 | dateCreated: string; 11 | lastUpdated: string; 12 | } 13 | 14 | export interface IStockPriceListResponse { 15 | count: number; 16 | next: string; 17 | previous: number; 18 | results: IStockPrice[]; 19 | } 20 | -------------------------------------------------------------------------------- /client/src/pages/settings/SettingsPage/components/SettingsHeader/SettingsHeader.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { Group, Title } from "@mantine/core"; 3 | 4 | function SettingsPageHeader() { 5 | const { t } = useTranslation(); 6 | 7 | return ( 8 | 9 | 10 | {t("Settings")} 11 | 12 | 13 | ); 14 | } 15 | 16 | export default SettingsPageHeader; 17 | -------------------------------------------------------------------------------- /backend/buho_backend/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for buho_backend project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "buho_backend.settings") 15 | 16 | 17 | application = get_wsgi_application() 18 | -------------------------------------------------------------------------------- /client/src/types/settings.ts: -------------------------------------------------------------------------------- 1 | export interface ISettingsFormFields { 2 | companySortBy?: string; 3 | companyDisplayMode?: string; 4 | displayWelcome?: boolean; 5 | language?: string; 6 | mainPortfolio?: string; 7 | portfolioSortBy?: string; 8 | portfolioDisplayMode?: string; 9 | sentryDsn?: string; 10 | sentryEnabled?: boolean; 11 | timezone?: string; 12 | } 13 | 14 | export interface ISettings extends ISettingsFormFields { 15 | id: number; 16 | lastUpdated: string; 17 | } 18 | -------------------------------------------------------------------------------- /backend/settings/migrations/0003_remove_usersettings_backend_hostname.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-08-11 14:52 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("settings", "0002_usersettings_backend_hostname_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name="usersettings", 15 | name="backend_hostname", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /client/src/types/dividends-transaction.ts: -------------------------------------------------------------------------------- 1 | export interface IDividendsTransactionFormFields { 2 | totalAmount: number; 3 | totalAmountCurrency: string; 4 | totalCommission: number; 5 | totalCommissionCurrency: string; 6 | exchangeRate: number; 7 | transactionDate: Date; 8 | company: number; 9 | notes: string; 10 | } 11 | 12 | export interface IDividendsTransaction extends IDividendsTransactionFormFields { 13 | id: number; 14 | dateCreated: string; 15 | lastUpdated: string; 16 | } 17 | -------------------------------------------------------------------------------- /client/src/types/exchange-rate.ts: -------------------------------------------------------------------------------- 1 | export interface IExchangeRateFormFields { 2 | exchangeDate: Date; 3 | exchangeRate: number; 4 | exchangeFrom: string; 5 | exchangeTo: string; 6 | } 7 | 8 | export interface IExchangeRate extends IExchangeRateFormFields { 9 | id: number; 10 | dateCreated: string; 11 | lastUpdated: string; 12 | } 13 | 14 | export interface IExchangeRateListResponse { 15 | count: number; 16 | next: string; 17 | previous: number; 18 | results: IExchangeRate[]; 19 | } 20 | -------------------------------------------------------------------------------- /backend/companies/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from buho_backend.admin import BaseAdmin 4 | from companies.models import Company 5 | 6 | 7 | # Register your models here. 8 | @admin.register(Company) 9 | class CompanyAdmin(BaseAdmin): 10 | list_display = [ 11 | "id", 12 | "name", 13 | "ticker", 14 | "portfolio_link", 15 | "last_updated", 16 | "date_created", 17 | ] 18 | search_fields = ["name", "ticker", "portfolio", "user"] 19 | -------------------------------------------------------------------------------- /backend/exchange_rates/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from buho_backend.admin import BaseAdmin 4 | from exchange_rates.models import ExchangeRate 5 | 6 | 7 | # Register your models here. 8 | @admin.register(ExchangeRate) 9 | class ExchangeRateAdmin(BaseAdmin): 10 | list_display = [ 11 | "id", 12 | "exchange_from", 13 | "exchange_to", 14 | "exchange_date", 15 | "last_updated", 16 | ] 17 | search_fields = ["exchange_from", "exchange_to"] 18 | -------------------------------------------------------------------------------- /backend/exchange_rates/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from rest_framework.routers import DefaultRouter 3 | 4 | from exchange_rates import views 5 | 6 | router = DefaultRouter() 7 | 8 | router.register(r"exchange-rates", views.ExchangeRateViewSet) 9 | 10 | urlpatterns = [ 11 | path( 12 | "///", 13 | views.ExchangeRateDetailAPIView.as_view(), # type: ignore 14 | name="exchange-rates-details", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /client/src/mocks/responses/settings.ts: -------------------------------------------------------------------------------- 1 | import { ISettings } from "types/settings"; 2 | 3 | const settingsMock: ISettings = { 4 | id: 1, 5 | language: "en", 6 | timezone: "UTC", 7 | portfolioSortBy: "date", 8 | companySortBy: "date", 9 | mainPortfolio: "1", 10 | portfolioDisplayMode: "grid", 11 | companyDisplayMode: "grid", 12 | sentryDsn: "https://sentry.local/123456", 13 | sentryEnabled: true, 14 | lastUpdated: "2020-01-01T00:00:00.000Z", 15 | }; 16 | 17 | export default settingsMock; 18 | -------------------------------------------------------------------------------- /backend/companies/migrations/0002_alter_company_broker.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.10 on 2023-10-06 07:27 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("companies", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="company", 14 | name="broker", 15 | field=models.CharField(blank=True, max_length=200), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /client/src/pages/companies/CompanyDetailsPage/components/CompanyExtraInfo/CompanyExtraInfo.tsx: -------------------------------------------------------------------------------- 1 | import { Paper } from "@mantine/core"; 2 | 3 | interface Props { 4 | companyDescription: string; 5 | } 6 | 7 | export default function CompanyInfo({ companyDescription }: Props) { 8 | if (companyDescription !== null && companyDescription !== "") { 9 | return ( 10 | 11 |
    12 | 13 | ); 14 | } 15 | return null; 16 | } 17 | -------------------------------------------------------------------------------- /backend/sectors/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from buho_backend.admin import BaseAdmin 4 | from sectors.models import Sector, SuperSector 5 | 6 | 7 | # Register your models here. 8 | @admin.register(Sector) 9 | class SectorAdmin(BaseAdmin): 10 | list_display = ["id", "name", "last_updated", "date_created"] 11 | search_fields = ["name"] 12 | 13 | 14 | @admin.register(SuperSector) 15 | class SuperSectorAdmin(BaseAdmin): 16 | list_display = ["id", "name", "last_updated", "date_created"] 17 | search_fields = ["name"] 18 | -------------------------------------------------------------------------------- /backend/settings/migrations/0004_usersettings_display_welcome.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-08-11 15:39 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("settings", "0003_remove_usersettings_backend_hostname"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="usersettings", 15 | name="display_welcome", 16 | field=models.BooleanField(default=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /backend/stock_prices/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from stock_prices.models import StockPrice 4 | 5 | 6 | class StockPriceSerializer(serializers.ModelSerializer): 7 | price_currency = serializers.CharField(max_length=50) 8 | 9 | class Meta: 10 | model = StockPrice 11 | fields = [ 12 | "id", 13 | "transaction_date", 14 | "price", 15 | "price_currency", 16 | "ticker", 17 | "date_created", 18 | "last_updated", 19 | ] 20 | -------------------------------------------------------------------------------- /backend/exchange_rates/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from exchange_rates.models import ExchangeRate 4 | 5 | 6 | class ExchangeRateSerializer(serializers.ModelSerializer): 7 | exchange_date = serializers.DateField() 8 | 9 | class Meta: 10 | model = ExchangeRate 11 | fields = [ 12 | "exchange_from", 13 | "exchange_to", 14 | "exchange_date", 15 | "exchange_rate", 16 | "date_created", 17 | "last_updated", 18 | "id", 19 | ] 20 | -------------------------------------------------------------------------------- /client/src/api/api-interceptors.ts: -------------------------------------------------------------------------------- 1 | import { apiClient, getAxiosHeadersWithAuth } from "./api-client"; 2 | 3 | const setupInterceptors = () => { 4 | apiClient.interceptors.request.use( 5 | (config) => { 6 | // Do something before request is sent 7 | const newConfig = config; 8 | newConfig.headers = getAxiosHeadersWithAuth(); 9 | return newConfig; 10 | }, 11 | (error) => { 12 | // Do something with request error 13 | return Promise.reject(error); 14 | }, 15 | ); 16 | }; 17 | 18 | export default setupInterceptors; 19 | -------------------------------------------------------------------------------- /backend/rights_transactions/migrations/0005_alter_rightstransaction_notes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1 on 2024-08-18 05:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("rights_transactions", "0004_alter_rightstransaction_notes"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="rightstransaction", 15 | name="notes", 16 | field=models.TextField(blank=True, default=""), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /backend/shares_transactions/migrations/0004_alter_sharestransaction_notes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1 on 2024-08-18 05:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("shares_transactions", "0003_alter_sharestransaction_options_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="sharestransaction", 15 | name="notes", 16 | field=models.TextField(blank=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /backend/shares_transactions/migrations/0005_alter_sharestransaction_notes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1 on 2024-08-18 05:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("shares_transactions", "0004_alter_sharestransaction_notes"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="sharestransaction", 15 | name="notes", 16 | field=models.TextField(blank=True, default=""), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /client/src/config.ts: -------------------------------------------------------------------------------- 1 | import { IConfig } from "types/config"; 2 | 3 | const dev: IConfig = { 4 | SENTRY_ENV: import.meta.env.VITE_SENTRY_ENV, 5 | }; 6 | 7 | const prod: IConfig = { 8 | SENTRY_ENV: import.meta.env.VITE_SENTRY_ENV, 9 | }; 10 | 11 | const test: IConfig = { 12 | SENTRY_ENV: "", 13 | }; 14 | 15 | let tempConfig = dev; 16 | 17 | if (process.env.NODE_ENV === "production") { 18 | tempConfig = prod; 19 | } 20 | 21 | if (process.env.NODE_ENV === "test") { 22 | tempConfig = test; 23 | } 24 | const config = tempConfig; 25 | 26 | export default config; 27 | -------------------------------------------------------------------------------- /client/src/pages/markets/MarketsListPage/components/MarketsPageHeader/MarketsPageHeader.test.tsx: -------------------------------------------------------------------------------- 1 | import MarketsPageHeader from "./MarketsPageHeader"; 2 | import { customRender, screen } from "test-utils"; 3 | 4 | describe("CurrenciesPageHeader tests", () => { 5 | it("renders expected texts", async () => { 6 | customRender(); 7 | 8 | const element = screen.getAllByText(/Markets/i); 9 | expect(element).toHaveLength(1); 10 | 11 | const el1 = screen.getByText(/Add Market/i); 12 | expect(el1).toBeInTheDocument(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /backend/log_messages/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from rest_framework.routers import DefaultRouter 3 | 4 | from log_messages import views 5 | 6 | router = DefaultRouter() 7 | 8 | urlpatterns = [ 9 | path( 10 | "", 11 | views.LogMessageViewSet.as_view({"get": "list"}), 12 | name="message-list", 13 | ), 14 | path( 15 | "/", 16 | views.LogMessageViewSet.as_view( 17 | { 18 | "delete": "destroy", 19 | } 20 | ), 21 | name="message-detail", 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /backend/dividends_transactions/migrations/0005_alter_dividendstransaction_notes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1 on 2024-08-18 05:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("dividends_transactions", "0004_alter_dividendstransaction_notes"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="dividendstransaction", 15 | name="notes", 16 | field=models.TextField(blank=True, default=""), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /client/src/mocks/responses/currencies.ts: -------------------------------------------------------------------------------- 1 | const currenciesList = [ 2 | { 3 | name: "Australian dollar", 4 | code: "AUD", 5 | symbol: "$", 6 | countries: [], 7 | }, 8 | { 9 | name: "Bulgarian lev", 10 | code: "BGN", 11 | symbol: "BGN", 12 | countries: [], 13 | }, 14 | { 15 | name: "Brazilian real", 16 | code: "BRL", 17 | symbol: "R$", 18 | countries: [], 19 | }, 20 | { 21 | name: "Canadian dollar", 22 | code: "CAD", 23 | symbol: "$", 24 | countries: [], 25 | }, 26 | ]; 27 | 28 | export default currenciesList; 29 | -------------------------------------------------------------------------------- /backend/exchange_rates/migrations/0002_alter_exchangerate_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2024-08-11 14:52 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("exchange_rates", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name="exchangerate", 15 | options={ 16 | "verbose_name": "Exchange Rate", 17 | "verbose_name_plural": "Exchange Rates", 18 | }, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /client/src/components/BuySellLabel/BuySellLabel.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { Badge } from "@mantine/core"; 3 | 4 | export type LabelType = "SELL" | "BUY"; 5 | 6 | interface Props { 7 | value: LabelType; 8 | } 9 | 10 | export function BuySellLabel({ value }: Props) { 11 | const { t } = useTranslation(); 12 | 13 | let color = "green"; 14 | if (value === "SELL") { 15 | color = "red"; 16 | } 17 | return ( 18 | 19 | {t(value)} 20 | 21 | ); 22 | } 23 | 24 | export default BuySellLabel; 25 | -------------------------------------------------------------------------------- /backend/currencies/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from rest_framework import viewsets 4 | from rest_framework.pagination import LimitOffsetPagination 5 | 6 | from currencies.models import Currency 7 | from currencies.serializers import CurrencySerializer 8 | 9 | logger = logging.getLogger("buho_backend") 10 | 11 | 12 | class CurrencyViewSet(viewsets.ModelViewSet): 13 | """ 14 | A viewset for viewing and editing currency instances. 15 | """ 16 | 17 | serializer_class = CurrencySerializer 18 | pagination_class = LimitOffsetPagination 19 | queryset = Currency.objects.all() 20 | -------------------------------------------------------------------------------- /client/src/pages/home/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { Grid } from "@mantine/core"; 3 | import PortfoliosPageHeader from "./components/HomePageHeader/HomePageHeader"; 4 | import PortfolioList from "./components/PortfolioList/PortfolioList"; 5 | 6 | export function HomePage(): ReactElement { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | export default HomePage; 20 | -------------------------------------------------------------------------------- /client/src/pages/import/components/ImportSteps/components/UpdatePortfolioStep/UpdatePortfolioStep.tsx: -------------------------------------------------------------------------------- 1 | import UpdatePortfolioForm from "./components/UpdatePortfolioForm/UpdatePortfolioForm"; 2 | 3 | interface Props { 4 | portfolioId: number | undefined; 5 | onPortfolioUpdated: () => void; 6 | } 7 | 8 | export default function UpdatePortfolioStep({ 9 | portfolioId, 10 | onPortfolioUpdated, 11 | }: Props) { 12 | return ( 13 |
    14 | 18 |
    19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /client/src/types/benchmark.ts: -------------------------------------------------------------------------------- 1 | export interface IBenchmarkFormFields { 2 | name: string; 3 | } 4 | 5 | export interface IBenchmarkYearFormFields { 6 | year: number; 7 | returnPercentage: number; 8 | value: number; 9 | valueCurrency: string; 10 | benchmark: number; 11 | } 12 | 13 | export interface IBenchmarkYear extends IBenchmarkYearFormFields { 14 | id: number; 15 | dateCreated: string; 16 | lastUpdated: string; 17 | } 18 | 19 | export interface IBenchmark extends IBenchmarkFormFields { 20 | dateCreated: string; 21 | lastUpdated: string; 22 | id: number; 23 | years: IBenchmarkYear[]; 24 | } 25 | -------------------------------------------------------------------------------- /backend/stock_prices/management/commands/get_tasks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.core.management.base import BaseCommand 4 | from django_celery_results.models import TaskResult 5 | 6 | logger = logging.getLogger("buho_backend") 7 | 8 | 9 | class Command(BaseCommand): 10 | help = "Gets all the tasks" 11 | 12 | def handle(self, *args, **options): 13 | self.stdout.write("Running sample task") 14 | results = TaskResult.objects.all() 15 | for result in results: 16 | self.stdout.write( 17 | f"{result.task_id} - {result.status} - {result.date_done}" 18 | ) 19 | -------------------------------------------------------------------------------- /client/src/components/BuySellLabel/BuySellLabel.test.tsx: -------------------------------------------------------------------------------- 1 | import { BuySellLabel } from "./BuySellLabel"; 2 | import { customRender, screen } from "test-utils"; 3 | 4 | describe("BuySellLabel tests", () => { 5 | it("renders buy label", async () => { 6 | customRender(); 7 | 8 | const element = screen.getByText(/BUY/i); 9 | expect(element).toBeInTheDocument(); 10 | }); 11 | 12 | it("renders sell label", async () => { 13 | customRender(); 14 | 15 | const element = screen.getByText(/SELL/i); 16 | expect(element).toBeInTheDocument(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 24.3.0 4 | hooks: 5 | - id: black 6 | 7 | - repo: https://github.com/PyCQA/bandit 8 | rev: "1.7.4" 9 | hooks: 10 | - id: bandit 11 | - repo: https://github.com/pycqa/isort 12 | rev: 5.12.0 13 | hooks: 14 | - id: isort 15 | name: isort (python) 16 | args: [--profile, black, --skip, migrations] 17 | - repo: https://github.com/pycqa/flake8 18 | rev: "6.0.0" # pick a git hash / tag to point to 19 | hooks: 20 | - id: flake8 21 | additional_dependencies: [flake8-django==1.4.0] 22 | -------------------------------------------------------------------------------- /backend/portfolios/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from portfolios import views 4 | 5 | urlpatterns = [ 6 | path( 7 | "", 8 | views.PortfolioViewSet.as_view({"get": "list", "post": "create"}), 9 | name="portfolio-list", 10 | ), 11 | path( 12 | "/", 13 | views.PortfolioViewSet.as_view( 14 | { 15 | "get": "retrieve", 16 | "put": "update", 17 | "patch": "partial_update", 18 | "delete": "destroy", 19 | } 20 | ), 21 | name="portfolio-detail", 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /backend/rights_transactions/migrations/0004_alter_rightstransaction_notes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1 on 2024-08-18 05:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ( 10 | "rights_transactions", 11 | "0003_alter_rightstransaction_gross_price_per_share_currency_and_more", 12 | ), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="rightstransaction", 18 | name="notes", 19 | field=models.TextField(blank=True), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /client/src/types/portfolio-year-stats.ts: -------------------------------------------------------------------------------- 1 | export interface IPortfolioYearStats { 2 | company: { 3 | id: number; 4 | name: string; 5 | ticker: string; 6 | baseCurrency: string; 7 | dividendsCurrency: string; 8 | }; 9 | year: number; 10 | invested: number; 11 | dividends: number; 12 | dividendsYield: number; 13 | sharesCount: number; 14 | portfolioCurrency: string; 15 | accumulatedInvestment: number; 16 | accumulatedDividends: number; 17 | portfolioValue: number; 18 | returnValue: number; 19 | returnPercent: number; 20 | returnWithDividends: number; 21 | returnWithDividendsPercent: number; 22 | } 23 | -------------------------------------------------------------------------------- /backend/markets/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from markets.models import Market 4 | 5 | 6 | class MarketSerializer(serializers.ModelSerializer[Market]): 7 | class Meta: 8 | model = Market 9 | fields = [ 10 | "name", 11 | "description", 12 | "region", 13 | "open_time", 14 | "close_time", 15 | "timezone", 16 | "date_created", 17 | "last_updated", 18 | "id", 19 | ] 20 | 21 | 22 | class TimezoneSerializer(serializers.Serializer): 23 | name = serializers.CharField(max_length=200) 24 | -------------------------------------------------------------------------------- /backend/dividends_transactions/migrations/0004_alter_dividendstransaction_notes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1 on 2024-08-18 05:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ( 10 | "dividends_transactions", 11 | "0003_alter_dividendstransaction_gross_price_per_share_currency_and_more", 12 | ), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="dividendstransaction", 18 | name="notes", 19 | field=models.TextField(blank=True), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /backend/initialize_data/urls.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | # type: ignore 4 | from django.urls import path 5 | 6 | from initialize_data import views 7 | 8 | urlpatterns = [ 9 | path("markets/", views.InitializeMarketsView.as_view(), name="initialize_markets"), 10 | path( 11 | "benchmarks/", 12 | views.InitializeBenchmarksView.as_view(), 13 | name="initialize_benchmarks", 14 | ), 15 | path("sectors/", views.InitializeSectorsView.as_view(), name="initialize_sectors"), 16 | path( 17 | "currencies/", 18 | views.InitializeCurrenciesView.as_view(), 19 | name="initialize_currencies", 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /client/src/pages/settings/SettingsPage/components/InfoMessageAddManually/InfoMessageAddManually.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { Alert } from "@mantine/core"; 3 | import { IconInfoCircle } from "@tabler/icons-react"; 4 | 5 | export default function InfoMessageAddManually() { 6 | const { t } = useTranslation(); 7 | const infoIcon = ; 8 | 9 | return ( 10 | 11 | {t( 12 | "Usually, you won't need to add exchange rates manually. They are fetched from the API automatically.", 13 | )} 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /backend/companies/urls/companies_portfolio.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | from django.urls import path 3 | 4 | from companies import views 5 | 6 | urlpatterns = [ 7 | path( 8 | "", 9 | views.CompanyViewSet.as_view({"get": "list", "post": "create"}), 10 | name="company-list", 11 | ), 12 | path( 13 | "/", 14 | views.CompanyViewSet.as_view( 15 | { 16 | "get": "retrieve", 17 | "put": "update", 18 | "patch": "partial_update", 19 | "delete": "destroy", 20 | } 21 | ), 22 | name="company-detail", 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /backend/sectors/tests/factory.py: -------------------------------------------------------------------------------- 1 | from factory import Faker, SubFactory, django 2 | 3 | from sectors.models import Sector, SuperSector 4 | 5 | 6 | class SectorFactory(django.DjangoModelFactory): 7 | class Meta: 8 | model = Sector 9 | 10 | name = Faker("company") 11 | 12 | 13 | class SectorWithSuperSectorFactory(django.DjangoModelFactory): 14 | class Meta: 15 | model = Sector 16 | 17 | name = Faker("company") 18 | super_sector = SubFactory("sectors.tests.factory.SuperSectorFactory") 19 | 20 | 21 | class SuperSectorFactory(django.DjangoModelFactory): 22 | class Meta: 23 | model = SuperSector 24 | 25 | name = Faker("company") 26 | -------------------------------------------------------------------------------- /backend/settings/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from settings.models import UserSettings 4 | 5 | 6 | class UserSettingsSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = UserSettings 9 | fields = [ 10 | "company_display_mode", 11 | "company_sort_by", 12 | "display_welcome", 13 | "language", 14 | "id", 15 | "last_updated", 16 | "main_portfolio", 17 | "portfolio_sort_by", 18 | "portfolio_display_mode", 19 | "timezone", 20 | "sentry_dsn", 21 | "sentry_enabled", 22 | ] 23 | -------------------------------------------------------------------------------- /backend/log_messages/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework.fields import Field 3 | 4 | from log_messages.models import LogMessage 5 | from portfolios.models import Portfolio 6 | 7 | 8 | class LogMessageSerializer(serializers.ModelSerializer): 9 | portfolio: Field = serializers.PrimaryKeyRelatedField( 10 | queryset=Portfolio.objects, many=False, read_only=False 11 | ) 12 | 13 | class Meta: 14 | model = LogMessage 15 | fields = [ 16 | "id", 17 | "message_type", 18 | "message_text", 19 | "portfolio", 20 | "date_created", 21 | "last_updated", 22 | ] 23 | -------------------------------------------------------------------------------- /client/src/types/rights-transaction.ts: -------------------------------------------------------------------------------- 1 | import { TransactionType } from "types/transaction"; 2 | 3 | export interface IRightsTransactionFormFields { 4 | count: number; 5 | totalAmount: number; 6 | totalAmountCurrency: string; 7 | grossPricePerShare: number; 8 | grossPricePerShareCurrency: string; 9 | totalCommission: number; 10 | totalCommissionCurrency: string; 11 | exchangeRate: number; 12 | transactionDate: Date; 13 | company: number; 14 | notes: string; 15 | type: TransactionType; 16 | transactionType?: string; 17 | } 18 | 19 | export interface IRightsTransaction extends IRightsTransactionFormFields { 20 | id: number; 21 | dateCreated: string; 22 | lastUpdated: string; 23 | } 24 | -------------------------------------------------------------------------------- /backend/stats/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from buho_backend.admin import BaseAdmin 4 | from stats.models.company_stats import CompanyStatsForYear 5 | from stats.models.portfolio_stats import PortfolioStatsForYear 6 | 7 | 8 | # Register your models here. 9 | @admin.register(CompanyStatsForYear) 10 | class CompanyStatsForYearAdmin(BaseAdmin): 11 | list_display = ["id", "year", "company_link", "last_updated", "date_created"] 12 | search_fields = ["year", "company"] 13 | 14 | 15 | @admin.register(PortfolioStatsForYear) 16 | class PortfoliotatsForYearAdmin(BaseAdmin): 17 | list_display = ["id", "year", "portfolio_link", "last_updated", "date_created"] 18 | search_fields = ["year", "portfolio"] 19 | -------------------------------------------------------------------------------- /backend/initialize_data/management/commands/initialize_markets.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from initialize_data.initializers.markets import create_initial_markets 6 | 7 | logger = logging.getLogger("buho_backend") 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Add the initial markets on the database" 12 | 13 | def handle(self, *args, **options): 14 | self.stdout.write("Adding initial markets to the database") 15 | 16 | markets = create_initial_markets() 17 | 18 | self.stdout.write( 19 | self.style.SUCCESS( 20 | f"Created {len(markets)}{[market.name for market in markets]}" 21 | ) 22 | ) 23 | -------------------------------------------------------------------------------- /client/src/components/CountryFlag/CountryFlag.tsx: -------------------------------------------------------------------------------- 1 | import { hasFlag } from "country-flag-icons"; 2 | import Flags from "country-flag-icons/react/3x2"; 3 | 4 | interface Props { 5 | code: string; 6 | width?: number; 7 | } 8 | 9 | export default function CountryFlag({ 10 | code, 11 | width = undefined, 12 | }: Readonly) { 13 | let countryCode = code; 14 | if (code === "EUR") { 15 | countryCode = "EU"; 16 | } 17 | if (!countryCode || !hasFlag(countryCode.toUpperCase())) return null; 18 | const FlagComponent = Flags[countryCode.toUpperCase() as keyof typeof Flags]; 19 | return ( 20 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /backend/companies/serializers_lite.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from companies.models import Company 4 | from markets.serializers import MarketSerializer 5 | from sectors.serializers import SectorSerializerGet 6 | 7 | 8 | class CompanySerializerLite(serializers.ModelSerializer): 9 | market = MarketSerializer(many=False, read_only=True) 10 | sector = SectorSerializerGet(many=False, read_only=True) 11 | 12 | class Meta: 13 | model = Company 14 | fields = [ 15 | "id", 16 | "name", 17 | "ticker", 18 | "base_currency", 19 | "dividends_currency", 20 | "market", 21 | "sector", 22 | "is_closed", 23 | ] 24 | -------------------------------------------------------------------------------- /client/src/components/LoadingSpin/LoadingSpin.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { Box, Loader } from "@mantine/core"; 3 | 4 | interface Props { 5 | text?: string; 6 | } 7 | 8 | export default function LoadingSpin({ text = "Loading..." }: Props) { 9 | const { t } = useTranslation(); 10 | 11 | return ( 12 | 20 | 21 |
    26 | {t(text)} 27 |
    28 |
    29 |
    30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["node", "@testing-library/jest-dom", "vitest/globals"], 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 7 | "allowJs": false, 8 | "skipLibCheck": true, 9 | "esModuleInterop": false, 10 | "allowSyntheticDefaultImports": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "module": "ESNext", 14 | "moduleResolution": "Node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | "baseUrl": "src", 20 | }, 21 | "include": ["src",".eslintrc.js", "vitest.config.js","vitest.setup.mjs"], 22 | } -------------------------------------------------------------------------------- /docs/development/database-select.md: -------------------------------------------------------------------------------- 1 | # 2. Choosing a Database 2 | 3 | - `sqlite`: Default configuration. 4 | - `mysql`: Best configuration to prevent data loss. You will need to create the database manually and point the configuration files to it. 5 | 6 | ## MariaDB on Mac 7 | 8 | The following commands can be used on Mac to install MariaDB. 9 | 10 | ``` 11 | brew install mariadb 12 | sudo mysql_secure_installation 13 | ``` 14 | 15 | ``` 16 | brew services start mariadb 17 | ``` 18 | 19 | ``` 20 | brew services stop mariadb 21 | ``` 22 | 23 | ### Related Links 24 | 25 | - https://mariadb.com/resources/blog/installing-mariadb-10-1-16-on-mac-os-x-with-homebrew/ 26 | 27 | ## MySQL 28 | 29 | Just go to the MySQL website and download the installer for your platform. -------------------------------------------------------------------------------- /backend/stats/serializers/portfolio_stats.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from stats.models.portfolio_stats import PortfolioStatsForYear 4 | 5 | 6 | class PortfolioStatsForYearSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = PortfolioStatsForYear 9 | fields = [ 10 | "year", 11 | "invested", 12 | "dividends", 13 | "dividends_yield", 14 | "portfolio_currency", 15 | "accumulated_investment", 16 | "accumulated_dividends", 17 | "portfolio_value", 18 | "return_value", 19 | "return_percent", 20 | "return_with_dividends", 21 | "return_with_dividends_percent", 22 | ] 23 | -------------------------------------------------------------------------------- /backend/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "buho_backend.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /backend/stock_prices/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from buho_backend.tests.base_test_case import BaseApiTestCase 4 | from companies.tests.factory import CompanyFactory 5 | from stock_prices.tests.factory import StockPriceTransactionFactory 6 | 7 | logger = logging.getLogger("buho_backend") 8 | 9 | 10 | class StockPricesTransactionsDetailTestCase(BaseApiTestCase): 11 | def setUp(self): 12 | super().setUp() 13 | self.company = CompanyFactory.create() 14 | instances = [] 15 | for _ in range(0, 4): 16 | instance = StockPriceTransactionFactory.create( 17 | price_currency=self.company.base_currency 18 | ) 19 | instances.append(instance) 20 | self.instances = instances 21 | -------------------------------------------------------------------------------- /client/src/components/LoadingSpin/LoadingSpin.test.tsx: -------------------------------------------------------------------------------- 1 | import LoadingSpin from "./LoadingSpin"; 2 | import { customRender, screen } from "test-utils"; 3 | 4 | describe("LoadingSpin component", () => { 5 | test("renders with default text 'Loading...'", () => { 6 | customRender(); 7 | expect(screen.getByText("Loading...")).toBeInTheDocument(); 8 | }); 9 | 10 | test("renders with custom text", () => { 11 | customRender(); 12 | expect(screen.getByText("Custom text")).toBeInTheDocument(); 13 | }); 14 | 15 | test("renders with translated text", () => { 16 | customRender(); 17 | expect(screen.getByText("common.loading")).toBeInTheDocument(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /client/src/hooks/use-companies/use-companies-search.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { apiClient } from "api/api-client"; 3 | import { ICompanySearchResult } from "types/company"; 4 | 5 | export const fetchCompanySearch = async (ticker: string | undefined) => { 6 | const fetchURL = new URL( 7 | `companies/search/${ticker}/`, 8 | apiClient.defaults.baseURL, 9 | ); 10 | 11 | const { data } = await apiClient.get(fetchURL.href); 12 | return data; 13 | }; 14 | 15 | export function useCompanySearch(ticker: string | undefined) { 16 | return useQuery({ 17 | enabled: !!ticker, 18 | queryKey: ["companySearch", ticker], 19 | queryFn: () => fetchCompanySearch(ticker), 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /client/src/types/portfolio.ts: -------------------------------------------------------------------------------- 1 | import { ICompanyListItem } from "./company"; 2 | import { ICurrency } from "./currency"; 3 | 4 | interface IPortfolioBase { 5 | name: string; 6 | description: string; 7 | color: string; 8 | hideClosedCompanies: boolean; 9 | countryCode: string; 10 | } 11 | 12 | export interface IPortfolioFormFields extends IPortfolioBase { 13 | baseCurrency: number | null; 14 | } 15 | 16 | export interface IPortfolio extends IPortfolioBase { 17 | id: number; 18 | baseCurrency: ICurrency; 19 | firstYear: number; 20 | companies: ICompanyListItem[]; 21 | dateCreated: string; 22 | lastUpdated: string; 23 | } 24 | 25 | export interface IPortfolioRouteParams { 26 | computedMatch: { 27 | params: { 28 | id: string; 29 | }; 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /backend/currencies/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | # Create your models here. 5 | class Currency(models.Model): 6 | id = models.AutoField(primary_key=True) 7 | code: models.CharField = models.CharField(max_length=200, unique=True) 8 | symbol: models.CharField = models.CharField(max_length=200) 9 | name: models.CharField = models.CharField(max_length=200) 10 | 11 | date_created: models.DateTimeField = models.DateTimeField(auto_now_add=True) 12 | last_updated: models.DateTimeField = models.DateTimeField(auto_now=True) 13 | 14 | class Meta: 15 | ordering = ["code"] 16 | verbose_name = "Currency" 17 | verbose_name_plural = "currencies" 18 | 19 | def __str__(self) -> str: 20 | return f"{self.code} - {self.name}" 21 | -------------------------------------------------------------------------------- /backend/stock_prices/management/commands/get_currency.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from stock_prices.services.yfinance_api_client import YFinanceApiClient 6 | 7 | logger = logging.getLogger("buho_backend") 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Gets the currency of a given ticker" 12 | 13 | def add_arguments(self, parser): 14 | parser.add_argument("ticker", type=str) 15 | 16 | def handle(self, *args, **options): 17 | ticker = options["ticker"] 18 | 19 | self.stdout.write(f"Getting currency for {ticker}") 20 | 21 | api_client = YFinanceApiClient() 22 | currency = api_client.get_company_currency(ticker) 23 | 24 | self.stdout.write(self.style.SUCCESS(f"{ticker} currency: {currency}")) 25 | -------------------------------------------------------------------------------- /backend/benchmarks/admin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.contrib import admin 4 | 5 | from benchmarks.models import Benchmark, BenchmarkYear 6 | from buho_backend.admin import BaseAdmin 7 | 8 | logger = logging.getLogger("buho_backend") 9 | 10 | 11 | # Register your models here. 12 | @admin.register(Benchmark) 13 | class BenchmarkAdmin(BaseAdmin): 14 | list_display = ["id", "name", "last_updated", "date_created"] 15 | search_fields = ["name", "id"] 16 | 17 | 18 | @admin.register(BenchmarkYear) 19 | class StockMarketIndexYearAdmin(BaseAdmin): 20 | list_display = [ 21 | "id", 22 | "value", 23 | "year", 24 | "return_percentage", 25 | "benchmark", 26 | "last_updated", 27 | "date_created", 28 | ] 29 | search_fields = ["year", "id", "benchmark"] 30 | -------------------------------------------------------------------------------- /backend/log_messages/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from rest_framework import viewsets 4 | from rest_framework.pagination import LimitOffsetPagination 5 | 6 | from log_messages.models import LogMessage 7 | from log_messages.serializers import LogMessageSerializer 8 | 9 | logger = logging.getLogger("buho_backend") 10 | 11 | 12 | class LogMessageViewSet(viewsets.ModelViewSet): 13 | 14 | pagination_class = LimitOffsetPagination 15 | serializer_class = LogMessageSerializer 16 | queryset = LogMessage.objects.all() 17 | lookup_field = "id" 18 | 19 | def get_queryset(self): 20 | portfolio_id = self.kwargs.get("portfolio_id") 21 | recent_messages = LogMessage.objects.filter(portfolio=portfolio_id).order_by( 22 | "-date_created" 23 | ) 24 | return recent_messages 25 | -------------------------------------------------------------------------------- /client/src/components/CountryFlag/CountryFlag.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import CountryFlag from "./CountryFlag"; 3 | 4 | describe("CountryFlag component", () => { 5 | test("renders null when an invalid code prop is provided", () => { 6 | render(); 7 | expect(screen.queryByTestId("country-flag")).toBeNull(); 8 | }); 9 | 10 | test("renders flag when a valid code prop is provided", () => { 11 | render(); 12 | expect(screen.getByTestId("country-flag")).toBeInTheDocument(); 13 | }); 14 | 15 | test("renders flag with maxHeight style of 20", () => { 16 | render(); 17 | expect(screen.getByTestId("country-flag")).toHaveStyle({ 18 | maxHeight: "20px", 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /client/src/test-utils/render.tsx: -------------------------------------------------------------------------------- 1 | import { I18nextProvider } from "react-i18next"; 2 | import { MantineProvider } from "@mantine/core"; 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 | import { render as testingLibraryRender } from "@testing-library/react"; 5 | import i18n from "i18n"; 6 | import { theme } from "theme"; 7 | 8 | const queryClient = new QueryClient(); 9 | 10 | export const wrapper = ({ children }: { children: React.ReactNode }) => ( 11 | 12 | 13 | {children} 14 | 15 | 16 | ); 17 | 18 | export function render(ui: React.ReactNode) { 19 | return testingLibraryRender(<>{ui}, { 20 | wrapper, 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /client/src/pages/portfolios/PortfolioDetailPage/components/PortfolioCharts/PortfolioCharts.tsx: -------------------------------------------------------------------------------- 1 | import { Grid, Paper } from "@mantine/core"; 2 | import ChartPortfolioDividendsProvider from "components/ChartPortfolioDividends/ChartPortfolioDividendsProvider"; 3 | import ChartPortfolioReturnsProvider from "components/ChartPortfolioReturns/ChartPortfolioReturnsProvider"; 4 | 5 | export default function Charts() { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /backend/stock_prices/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models.query import QuerySet 3 | from djmoney.models.fields import MoneyField 4 | 5 | 6 | class StockPrice(models.Model): 7 | price = MoneyField(max_digits=12, decimal_places=3) 8 | transaction_date = models.DateField() 9 | ticker = models.CharField(max_length=100) 10 | date_created = models.DateTimeField(auto_now_add=True) 11 | last_updated = models.DateTimeField(auto_now=True) 12 | 13 | objects: QuerySet["StockPrice"] # To solve issue django-manager-missing 14 | 15 | class Meta: 16 | unique_together = ("ticker", "transaction_date") 17 | verbose_name = "Stock Price" 18 | verbose_name_plural = "Stock Prices" 19 | 20 | def __str__(self): 21 | return f"{self.ticker} - {self.price} ({self.transaction_date})" 22 | -------------------------------------------------------------------------------- /backend/exchange_rates/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class ExchangeRate(models.Model): 5 | exchange_from = models.CharField(max_length=200) 6 | exchange_to = models.CharField(max_length=200) 7 | exchange_rate = models.DecimalField(max_digits=12, decimal_places=3) 8 | exchange_date = models.DateField() 9 | 10 | date_created = models.DateTimeField(auto_now_add=True) 11 | last_updated = models.DateTimeField(auto_now=True) 12 | 13 | class Meta: 14 | verbose_name = "Exchange Rate" 15 | verbose_name_plural = "Exchange Rates" 16 | unique_together = ("exchange_from", "exchange_to", "exchange_date") 17 | 18 | def __str___(self): 19 | return ( 20 | f"{self.exchange_from}{self.exchange_to} -" 21 | f"{self.exchange_rate} ({self.exchange_date})" 22 | ) 23 | -------------------------------------------------------------------------------- /backend/buho_backend/admin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.contrib import admin 4 | from django.urls import reverse 5 | from django.utils.safestring import mark_safe 6 | 7 | logger = logging.getLogger("buho_backend") 8 | 9 | 10 | # Register your models here. 11 | class BaseAdmin(admin.ModelAdmin): 12 | def company_link(self, obj): 13 | url = reverse("admin:companies_company_change", args=(obj.company.pk,)) 14 | return mark_safe(f"{obj.company}") # nosec 15 | 16 | company_link.short_description = "company" # type: ignore 17 | 18 | def portfolio_link(self, obj): 19 | url = reverse("admin:portfolios_portfolio_change", args=(obj.portfolio.pk,)) 20 | return mark_safe(f"{obj.portfolio}") # nosec 21 | 22 | portfolio_link.short_description = "portfolio" # type: ignore 23 | -------------------------------------------------------------------------------- /client/src/components/NavigationLinks/NavigationLink.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { NavLink as RouterNavLink } from "react-router-dom"; 4 | import { ThemeIcon, NavLink } from "@mantine/core"; 5 | 6 | interface NavigationLinkProps { 7 | icon: React.ReactNode; 8 | color: string; 9 | label: string; 10 | to: string; 11 | } 12 | 13 | export default function NavigationLink({ 14 | icon, 15 | color, 16 | label, 17 | to, 18 | }: Readonly) { 19 | const { t } = useTranslation(); 20 | 21 | return ( 22 | (label)} 24 | leftSection={ 25 | 26 | {icon} 27 | 28 | } 29 | to={to} 30 | component={RouterNavLink} 31 | /> 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /backend/portfolios/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | # Create your models here. 5 | class Portfolio(models.Model): 6 | id = models.AutoField(primary_key=True) 7 | name = models.CharField(max_length=200) 8 | description = models.TextField(blank=True, default="") 9 | color = models.CharField(max_length=200) 10 | hide_closed_companies = models.BooleanField(default=False) 11 | 12 | date_created = models.DateTimeField(auto_now_add=True) 13 | last_updated = models.DateTimeField(auto_now=True) 14 | 15 | base_currency = models.CharField(max_length=50) 16 | country_code = models.CharField(max_length=200) 17 | 18 | class Meta: 19 | ordering = ["name"] 20 | verbose_name = "Portfolio" 21 | verbose_name_plural = "Portfolios" 22 | 23 | def __str__(self): 24 | return f"{self.name} ({self.base_currency})" 25 | -------------------------------------------------------------------------------- /client/src/components/ToggleThemeButton/ToggleThemeButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ActionIcon, 3 | useComputedColorScheme, 4 | useMantineColorScheme, 5 | } from "@mantine/core"; 6 | import { IconMoonStars, IconSun } from "@tabler/icons-react"; 7 | 8 | export default function ToggleThemeButton() { 9 | const { setColorScheme } = useMantineColorScheme(); 10 | const computedColorScheme = useComputedColorScheme("light", { 11 | getInitialValueInEffect: true, 12 | }); 13 | 14 | const toggleColorScheme = () => { 15 | setColorScheme(computedColorScheme === "dark" ? "light" : "dark"); 16 | }; 17 | return ( 18 | toggleColorScheme()} size={30}> 19 | {computedColorScheme === "dark" ? ( 20 | 21 | ) : ( 22 | 23 | )} 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /backend/stock_prices/tests/services/test_yfinance_api_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from buho_backend.tests.base_test_case import BaseApiTestCase 4 | from stock_prices.services.yfinance_api_client import YFinanceApiClient 5 | 6 | logger = logging.getLogger("buho_backend") 7 | 8 | 9 | class CustomYServiceTestCase(BaseApiTestCase): 10 | def setUp(self): 11 | super().setUp() 12 | 13 | def test_fetch_currency(self): 14 | service = YFinanceApiClient(wait_time=0) 15 | currency = service.get_company_currency("CSCO") 16 | self.assertEqual(currency, "USD") 17 | 18 | def test_fetch_stock_prices(self): 19 | service = YFinanceApiClient(wait_time=0) 20 | results, currency = service.get_company_data_between_dates( 21 | "CSCO", "2022-01-16", "2022-01-31" 22 | ) 23 | self.assertEqual(currency, "USD") 24 | self.assertEqual(len(results), 3) 25 | -------------------------------------------------------------------------------- /backend/sectors/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework.fields import Field 3 | 4 | from sectors.models import Sector, SuperSector 5 | 6 | 7 | class SectorSerializer(serializers.ModelSerializer): 8 | super_sector: Field = serializers.PrimaryKeyRelatedField( 9 | queryset=SuperSector.objects, 10 | many=False, 11 | read_only=False, 12 | allow_null=True, 13 | required=False, 14 | ) 15 | 16 | class Meta: 17 | model = Sector 18 | fields = ["name", "date_created", "last_updated", "id", "super_sector"] 19 | 20 | 21 | class SuperSectorSerializer(serializers.ModelSerializer): 22 | class Meta: 23 | model = SuperSector 24 | fields = ["name", "date_created", "last_updated", "id"] 25 | 26 | 27 | class SectorSerializerGet(SectorSerializer): 28 | super_sector = SuperSectorSerializer(many=False, read_only=True) 29 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Buho Stocks", 3 | "name": "Buho Stocks: Portfolio Manager", 4 | "description": "Stocks and portfolio manager", 5 | "icons": [ 6 | { 7 | "src": "favicon.ico", 8 | "sizes": "64x64 32x32 24x24 16x16", 9 | "type": "image/x-icon" 10 | }, 11 | { 12 | "src": "logo-96x96.png", 13 | "type": "image/png", 14 | "sizes": "96x96" 15 | }, 16 | { 17 | "src": "logo-144x144.png", 18 | "type": "image/png", 19 | "sizes": "144x144" 20 | }, 21 | { 22 | "src": "logo-192x192.png", 23 | "type": "image/png", 24 | "sizes": "192x192" 25 | }, 26 | { 27 | "src": "logo-512x512.png", 28 | "type": "image/png", 29 | "sizes": "512x512" 30 | } 31 | ], 32 | "start_url": ".", 33 | "display": "standalone", 34 | "theme_color": "#ffffff", 35 | "background_color": "#ffffff" 36 | } 37 | -------------------------------------------------------------------------------- /client/src/pages/import/components/ImportFromBrokerPageHeader/ImportFromBrokerPageHeader.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { Anchor, Breadcrumbs, Stack, Title } from "@mantine/core"; 3 | 4 | function ImportFromBrokerPageHeader() { 5 | const { t } = useTranslation(); 6 | const routes = [ 7 | { 8 | href: "/", 9 | title: t("Home"), 10 | id: "home", 11 | }, 12 | { 13 | href: `/import`, 14 | title: t("Import from CSV"), 15 | id: "import", 16 | }, 17 | ].map((item) => ( 18 | 19 | {item.title} 20 | 21 | )); 22 | return ( 23 | 24 | {routes} 25 | 26 | {t("Import from Interactive Brokers")} 27 | 28 | 29 | ); 30 | } 31 | 32 | export default ImportFromBrokerPageHeader; 33 | -------------------------------------------------------------------------------- /etc/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Function to check if MariaDB is reachable 4 | check_db_connection() { 5 | nc -zv "$DB_HOSTNAME" "$DB_PORT" &> /dev/null 6 | } 7 | 8 | if [ "$DB_TYPE" = "mysql" ] || [ "$DB_TYPE" = "postgresql" ]; then 9 | echo 'Waiting for database...' 10 | while ! check_db_connection; do 11 | echo "Database is not reachable yet. Waiting 1 second..." 12 | sleep 1 13 | done 14 | echo 'Database started!' 15 | fi 16 | 17 | echo 'Load virtual env...' 18 | . /usr/src/.venv/bin/activate 19 | echo 'Virtual env loaded!' 20 | 21 | echo 'Go to app dir...' 22 | cd /usr/src/app 23 | echo 'on app dir!' 24 | 25 | echo 'Running migrations...' 26 | python manage.py migrate 27 | echo 'Migrations finished!' 28 | 29 | echo 'Collecting static files...' 30 | python manage.py collectstatic --no-input 31 | echo 'Static files collected!' 32 | 33 | echo 'Starting application now...' 34 | 35 | exec "$@" 36 | -------------------------------------------------------------------------------- /backend/initialize_data/data/currencies.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "code": "AUD", "symbol": "$", "name": "Australian dollar" }, 3 | { "code": "CAD", "symbol": "$", "name": "Canadian dollar" }, 4 | { "code": "CHF", "symbol": "Fr.", "name": "Swiss franc" }, 5 | { "code": "CNY", "symbol": "\u00a5", "name": "Chinese/Yuan renminbi" }, 6 | { "code": "EUR", "symbol": "\u20ac", "name": "European Euro" }, 7 | { "code": "GBP", "symbol": "\u00a3", "name": "British pound" }, 8 | { "code": "HKD", "symbol": "HK$", "name": "Hong Kong dollar" }, 9 | { "code": "JPY", "symbol": "\u00a5", "name": "Japanese yen" }, 10 | { "code": "KRW", "symbol": "W", "name": "South Korean won" }, 11 | { "code": "NZD", "symbol": "NZ$", "name": "New Zealand dollar" }, 12 | { "code": "SEK", "symbol": "kr", "name": "Swedish krona" }, 13 | { "code": "SGD", "symbol": "S$", "name": "Singapore dollar" }, 14 | { "code": "USD", "symbol": "US$", "name": "United States dollar" } 15 | ] 16 | -------------------------------------------------------------------------------- /backend/sectors/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class SectorBase(models.Model): 5 | """ 6 | Base class for the sectors models 7 | """ 8 | 9 | id = models.AutoField(primary_key=True) 10 | name = models.CharField(max_length=200, unique=True) 11 | 12 | date_created = models.DateTimeField(auto_now_add=True) 13 | last_updated = models.DateTimeField(auto_now=True) 14 | 15 | class Meta: 16 | abstract = True 17 | 18 | def __str__(self): 19 | return self.name 20 | 21 | 22 | class SuperSector(SectorBase): 23 | """Super sector model class""" 24 | 25 | class Meta: 26 | ordering = ["name"] 27 | 28 | 29 | class Sector(SectorBase): 30 | """Sector model class""" 31 | 32 | super_sector = models.ForeignKey( 33 | SuperSector, on_delete=models.SET_NULL, related_name="sectors", null=True 34 | ) 35 | 36 | class Meta: 37 | ordering = ["name"] 38 | -------------------------------------------------------------------------------- /backend/settings/migrations/0002_usersettings_backend_hostname_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.10 on 2023-10-25 16:30 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("settings", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="usersettings", 14 | name="backend_hostname", 15 | field=models.CharField(blank=True, default="localhost:8081", max_length=200), 16 | ), 17 | migrations.AddField( 18 | model_name="usersettings", 19 | name="sentry_dsn", 20 | field=models.CharField(blank=True, default="", max_length=200), 21 | ), 22 | migrations.AddField( 23 | model_name="usersettings", 24 | name="sentry_enabled", 25 | field=models.BooleanField(default=False), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /client/src/types/company-year-stats.ts: -------------------------------------------------------------------------------- 1 | export interface CompanyForYearStats { 2 | id: number; 3 | name: string; 4 | ticker: string; 5 | baseCurrency: string; 6 | dividendsCurrency: string; 7 | } 8 | 9 | export interface CompanyYearStats { 10 | year: number; 11 | company: CompanyForYearStats; 12 | sectorName: string; 13 | superSectorName: string; 14 | currencyCode: string; 15 | marketName: string; 16 | broker: string; 17 | sharesCount: number; 18 | invested: string; 19 | dividends: string; 20 | dividendsYield: string; 21 | portfolioCurrency: string; 22 | accumulatedInvestment: string; 23 | accumulatedDividends: string; 24 | stockPriceValue: string; 25 | stockPriceCurrency: string; 26 | stockPriceTransactionDate: string; 27 | portfolioValue: string; 28 | portfolioValueIsDown: boolean; 29 | returnValue: string; 30 | returnPercent: string; 31 | returnWithDividends: string; 32 | returnWithDividendsPercent: string; 33 | } 34 | -------------------------------------------------------------------------------- /client/src/pages/companies/CompanyDetailsPage/components/CompanyStats/components/YearSelector/YearSelector.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { Select } from "@mantine/core"; 3 | 4 | interface Props { 5 | onYearChange: (year: string | null) => void; 6 | years: number[]; 7 | selectedYear: string | null; 8 | } 9 | 10 | export default function YearSelector({ 11 | years, 12 | selectedYear, 13 | onYearChange, 14 | }: Props) { 15 | const { t } = useTranslation(); 16 | 17 | const yearsOptions = years?.map((yearItem: number) => ({ 18 | value: yearItem.toString(), 19 | label: yearItem.toString(), 20 | })); 21 | 22 | yearsOptions?.unshift({ value: "all", label: t("All") }); 23 | 24 | return ( 25 | ("Select an index")} 30 | onChange={onChange} 31 | style={{ marginTop: 20, minWidth: 200 }} 32 | data={selectOptions} 33 | /> 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /client/src/pages/portfolios/PorfolioChartsPage/PortfolioChartsPage.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from "react-router-dom"; 2 | import { Grid, Loader } from "@mantine/core"; 3 | import ChartsList from "./components/ChartsList/ChartsList"; 4 | import PortfolioChartsPageHeader from "./components/PortfolioChartsPageHeader/PortfolioChartsPageHeader"; 5 | import { usePortfolio } from "hooks/use-portfolios/use-portfolios"; 6 | 7 | export function PortfolioChartsPage() { 8 | const { id } = useParams(); 9 | const { data: portfolio } = usePortfolio(+id!); 10 | 11 | if (!portfolio) { 12 | return ; 13 | } 14 | return ( 15 | 16 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | export default PortfolioChartsPage; 30 | -------------------------------------------------------------------------------- /docs/user-guides/initialize-app-data.md: -------------------------------------------------------------------------------- 1 | # 1. Initialize the app data 2 | 3 | The first time you run the application, it won't contain any data. However, it provides several elements that can be useful to start with (You can add your own data later on or modify the existing one). 4 | 5 | Initialize Data in Buho Stocks 6 | 7 | 1. **Benchmarks**: MSCI and SP500 values and returns from 2016. 8 | 2. **Currencies**: Some of the most used currencies in the world. 9 | 3. **Markets**: Some of the most common markets in the world. 10 | 4. **Sectors and Super sectors**: Several sectors and super sectors. 11 | 12 | In order to initialize each of these items, just select them and click the "Submit" button. 13 | 14 | If you don't want to see this window again, check the corresponding checkbox. 15 | 16 | Next: [Create your first portfolio](create-portfolio.md) 17 | 18 | Previous: [Deploy the application](deploy-docker-compose.md) -------------------------------------------------------------------------------- /backend/buho_backend/tests/base_test_case.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from rest_framework.test import APITestCase 4 | 5 | from stock_prices.tests.mocks.mock_yfinance import ( 6 | create_download_mock, 7 | create_ticker_mock, 8 | ) 9 | 10 | 11 | class BaseApiTestCase(APITestCase): 12 | def setUp(self): 13 | self.patch_ticker = patch("yfinance.Ticker") 14 | self.patch_download = patch("yfinance.download") 15 | 16 | self.mock_ticker = self.patch_ticker.start() 17 | self.mock_download = self.patch_download.start() 18 | 19 | self.set_mock_ticker_result(create_ticker_mock()) 20 | self.set_mock_download_result(create_download_mock()) 21 | 22 | def tearDown(self): 23 | self.patch_ticker.stop() 24 | self.patch_download.stop() 25 | 26 | def set_mock_ticker_result(self, result): 27 | self.mock_ticker.return_value = result 28 | 29 | def set_mock_download_result(self, result): 30 | self.mock_download.return_value = result 31 | -------------------------------------------------------------------------------- /backend/dividends_transactions/tests/factory.py: -------------------------------------------------------------------------------- 1 | from factory import Faker, SubFactory, django 2 | 3 | from companies.tests.factory import CompanyFactory 4 | from dividends_transactions.models import DividendsTransaction 5 | 6 | 7 | class DividendsTransactionFactory(django.DjangoModelFactory): 8 | class Meta: 9 | model = DividendsTransaction 10 | 11 | count = Faker("pyint") 12 | total_amount = Faker("pydecimal", left_digits=4, right_digits=3, positive=True) 13 | 14 | total_commission = Faker("pydecimal", left_digits=4, right_digits=3, positive=True) 15 | exchange_rate = Faker("pydecimal", left_digits=1, right_digits=3, positive=True) 16 | transaction_date = Faker("date_object") 17 | notes = Faker("paragraph") 18 | 19 | company = SubFactory(CompanyFactory) 20 | 21 | # @post_generation 22 | # def set_currencies(self, create, extracted, base_currency, **kwargs): 23 | # self.gross_price_per_share_currency = base_currency 24 | # self.total_commission_currency = base_currency 25 | -------------------------------------------------------------------------------- /client/src/types/shares-transaction.ts: -------------------------------------------------------------------------------- 1 | import { TransactionType } from "types/transaction"; 2 | 3 | export interface ISharesTransactionFormFields { 4 | count: number; 5 | totalAmount: number; 6 | totalAmountCurrency: string; 7 | grossPricePerShare: number; 8 | grossPricePerShareCurrency: string; 9 | totalCommission: number; 10 | totalCommissionCurrency: string; 11 | exchangeRate: number; 12 | transactionDate: Date; 13 | company: number; 14 | notes: string; 15 | type: TransactionType; 16 | transactionType?: string; 17 | } 18 | 19 | export interface ISharesTransaction extends ISharesTransactionFormFields { 20 | id: number; 21 | dateCreated: string; 22 | lastUpdated: string; 23 | } 24 | 25 | export interface ITradeCsv { 26 | date: string; 27 | count: number; 28 | price: number; 29 | currency: string; 30 | totalWithCommission: number; 31 | commission: number; 32 | description: string; 33 | ticker: string; 34 | companyName: string; 35 | companyISIN: string; 36 | market: string; 37 | } 38 | -------------------------------------------------------------------------------- /backend/currencies/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-04-07 09:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | dependencies = [] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="Currency", 14 | fields=[ 15 | ("id", models.AutoField(primary_key=True, serialize=False)), 16 | ("code", models.CharField(max_length=200, unique=True)), 17 | ("symbol", models.CharField(max_length=200)), 18 | ("name", models.CharField(max_length=200)), 19 | ("date_created", models.DateTimeField(auto_now_add=True)), 20 | ("last_updated", models.DateTimeField(auto_now=True)), 21 | ], 22 | options={ 23 | "verbose_name": "Currency", 24 | "verbose_name_plural": "currencies", 25 | "ordering": ["code"], 26 | }, 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /docker.client.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine as builder 2 | WORKDIR /app 3 | COPY ./client/package*.json ./ 4 | RUN npm ci 5 | 6 | ENV VITE_ENV=production 7 | ENV VITE_PORT= 8 | ENV VITE_API_URL= 9 | 10 | LABEL org.opencontainers.image.authors='renefernandez@duck.com' \ 11 | org.opencontainers.image.url='https://github.com/bocabitlabs/buho-stocks/pkgs/container/buho-stocks-client' \ 12 | org.opencontainers.image.documentation='https://bocabitlabs.github.io/buho-stocks/' \ 13 | org.opencontainers.image.source="https://github.com/bocabitlabs/buho-stocks" \ 14 | org.opencontainers.image.vendor='Bocabitlabs (Rene Fernandez)' \ 15 | org.opencontainers.image.licenses='GPL-3.0-or-later' 16 | 17 | RUN ls -la 18 | COPY ./client ./ 19 | RUN npm run build 20 | 21 | FROM nginx:stable-alpine 22 | 23 | WORKDIR /usr/share/nginx/html 24 | RUN rm -rf ./* 25 | 26 | RUN rm /etc/nginx/conf.d/default.conf 27 | COPY ./nginx/nginx.conf /etc/nginx/conf.d 28 | 29 | COPY --from=builder /app/dist . 30 | ENTRYPOINT [ "nginx", "-g", "daemon off;" ] -------------------------------------------------------------------------------- /client/src/pages/import/components/ImportSteps/components/DragAndDropCsvParser/utils/info-parsing.ts: -------------------------------------------------------------------------------- 1 | import { convertDataLinesToList } from "./csv-parsing-utils"; 2 | 3 | const getCompanyInfoHeaders = () => { 4 | return [ 5 | "Información de instrumento financiero", 6 | "Financial Instrument Information", 7 | ]; 8 | }; 9 | 10 | export default function extractInfoRows(data: string[][]) { 11 | const rows: string[][] = []; 12 | const headers = getCompanyInfoHeaders(); 13 | const dataRows = convertDataLinesToList(data); 14 | 15 | console.log("Searching for company information in the CSV file..."); 16 | 17 | const isCompanyInfoLine = (line: string[]) => { 18 | return ( 19 | headers.includes(line[0]) && 20 | line[1] !== "Header" && 21 | line[1] === "Data" && 22 | line[3] 23 | ); 24 | }; 25 | 26 | dataRows.map((line: string[]) => { 27 | if (isCompanyInfoLine(line)) { 28 | rows.push(line); 29 | } 30 | }); 31 | console.log(`Found ${rows.length} company info`); 32 | return rows; 33 | } 34 | -------------------------------------------------------------------------------- /backend/dividends_transactions/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models.query import QuerySet 3 | from djmoney.models.fields import MoneyField 4 | 5 | from companies.models import Company 6 | from shares_transactions.models import Transaction 7 | 8 | 9 | class DividendsTransaction(Transaction): 10 | company = models.ForeignKey( 11 | Company, on_delete=models.CASCADE, related_name="dividends_transactions" 12 | ) 13 | total_amount = MoneyField( 14 | max_digits=12, decimal_places=3, default=0, default_currency="EUR" 15 | ) 16 | 17 | objects: QuerySet["DividendsTransaction"] # To solve issue django-manager-missing 18 | 19 | class Meta: 20 | ordering = ["-transaction_date"] 21 | verbose_name = "Dividends Transaction" 22 | verbose_name_plural = "Dividends Transactions" 23 | 24 | def __str__(self): 25 | return ( 26 | f"Amount: {self.total_amount} - PPS: {self.gross_price_per_share} " 27 | f"- Commission: {self.total_commission}" 28 | ) 29 | -------------------------------------------------------------------------------- /backend/initialize_data/management/commands/initialize_benchmarks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from initialize_data.initializers.benchmarks import ( 6 | create_initial_benchmark_years, 7 | create_initial_benchmarks, 8 | ) 9 | 10 | logger = logging.getLogger("buho_backend") 11 | 12 | 13 | class Command(BaseCommand): 14 | help = "Add the initial benchmarks on the database" 15 | 16 | def handle(self, *args, **options): 17 | self.stdout.write("Adding initial benchmarks to the database") 18 | 19 | benchmarks = create_initial_benchmarks() 20 | benchmarks_years = create_initial_benchmark_years() 21 | 22 | self.stdout.write( 23 | self.style.SUCCESS( 24 | f"Created {len(benchmarks)}{[market.name for market in benchmarks]}" 25 | ) 26 | ) 27 | self.stdout.write( 28 | self.style.SUCCESS( 29 | f"Added {len(benchmarks_years)} years of data to the benchmarks" 30 | ) 31 | ) 32 | -------------------------------------------------------------------------------- /client/src/api/api-client.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export interface HttpRequestHeader { 4 | [key: string]: string; 5 | } 6 | 7 | export interface IRegistrationData { 8 | username: string; 9 | password: string; 10 | password2: string; 11 | firstName: string; 12 | lastName: string; 13 | email: string; 14 | } 15 | 16 | export interface ILoginData { 17 | username: string; 18 | password: string; 19 | } 20 | 21 | export interface IApiResponse { 22 | [key: string]: string; 23 | } 24 | 25 | const getAxiosOptionsWithAuth = () => ({ 26 | headers: { 27 | Accept: "application/json", 28 | }, 29 | }); 30 | 31 | export const getAxiosHeadersWithAuth = () => ({ 32 | Accept: "application/json", 33 | }); 34 | 35 | // URL must be set in .env file or if it is empty, get the current URL base 36 | // Env variables are injected at build time 37 | const url = import.meta.env.VITE_API_URL || window.location.origin; 38 | 39 | const apiClient = axios.create({ 40 | baseURL: `${url}/api/v1/`, 41 | }); 42 | 43 | export { apiClient, getAxiosOptionsWithAuth }; 44 | -------------------------------------------------------------------------------- /client/src/pages/import/components/ImportSteps/components/TradesImportStep/components/TradesImportForm/TradesImportFormProvider.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "@mantine/core"; 2 | import TradesImportForm from "./TradesImportForm"; 3 | import { usePortfolio } from "hooks/use-portfolios/use-portfolios"; 4 | import { ICsvTradesRow } from "types/csv"; 5 | 6 | interface Props { 7 | portfolioId: number; 8 | trade: ICsvTradesRow; 9 | onImportedCallback: () => void; 10 | } 11 | 12 | export default function TradesImportFormProvider({ 13 | trade, 14 | portfolioId, 15 | onImportedCallback, 16 | }: Props) { 17 | const { data: portfolio, isLoading } = usePortfolio(portfolioId); 18 | 19 | const onSubmitCallback = () => { 20 | onImportedCallback(); 21 | }; 22 | 23 | if (isLoading) { 24 | return ; 25 | } 26 | 27 | if (portfolio && trade) { 28 | return ( 29 | 34 | ); 35 | } 36 | return null; 37 | } 38 | -------------------------------------------------------------------------------- /client/src/hooks/use-currencies/use-currencies.test.tsx: -------------------------------------------------------------------------------- 1 | import { useAllCurrencies } from "./use-currencies"; 2 | import currenciesList from "mocks/responses/currencies"; 3 | import { renderHook, waitFor } from "test-utils"; 4 | import { wrapper } from "test-utils/render"; 5 | 6 | describe("useCurrencies Hook tests", () => { 7 | afterEach(() => { 8 | vi.clearAllMocks(); 9 | }); 10 | 11 | it("Gets a list of currencies", async () => { 12 | const { result } = renderHook(() => useAllCurrencies(), { wrapper }); 13 | await waitFor(() => result.current.isSuccess); 14 | 15 | await waitFor(() => { 16 | const currencies = result.current.data; 17 | expect(currencies?.length).toEqual(currenciesList.length); 18 | }); 19 | await waitFor(() => { 20 | const currencies = result.current.data; 21 | expect(currencies).toEqual( 22 | expect.arrayContaining([ 23 | expect.objectContaining({ name: "Canadian dollar" }), 24 | expect.objectContaining({ symbol: "$" }), 25 | ]), 26 | ); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /backend/buho_backend/views.py: -------------------------------------------------------------------------------- 1 | from django_celery_results.models import TaskResult 2 | from rest_framework import status 3 | from rest_framework.decorators import api_view 4 | from rest_framework.response import Response 5 | from rest_framework.views import APIView 6 | 7 | from stats.tasks import debug_task 8 | 9 | 10 | @api_view(["POST"]) 11 | def start_task_view(request): 12 | task = debug_task.delay() 13 | return Response({"task_id": task.id}, status=status.HTTP_202_ACCEPTED) 14 | 15 | 16 | class TaskResultList(APIView): 17 | def get(self, request): 18 | task_results = TaskResult.objects.filter(status="STARTED").all() 19 | data = [] 20 | for task_result in task_results: 21 | data.append( 22 | { 23 | "task_id": task_result.task_id, 24 | "status": task_result.status, 25 | "result": task_result.result, 26 | "date_done": task_result.date_done, 27 | } 28 | ) 29 | return Response(data, status=status.HTTP_200_OK) 30 | -------------------------------------------------------------------------------- /client/src/pages/markets/MarketsListPage/MarketsListPage.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { Grid, Loader } from "@mantine/core"; 3 | import MarketsListTable from "./components/MarketsListTable/MarketsListTable"; 4 | import MarketsPageHeader from "./components/MarketsPageHeader/MarketsPageHeader"; 5 | import { 6 | LanguageContext, 7 | LanguageProvider, 8 | } from "components/ListLanguageProvider/ListLanguageProvider"; 9 | 10 | function MarketsListContent() { 11 | const mrtLocalization = useContext(LanguageContext); 12 | 13 | return mrtLocalization ? ( 14 | 15 | ) : ( 16 | 17 | ); 18 | } 19 | 20 | export function MarketsListPage() { 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | } 34 | 35 | export default MarketsListPage; 36 | -------------------------------------------------------------------------------- /backend/stock_prices/tests/mocks/mock_yfinance.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pandas as pd 4 | 5 | 6 | class MockTicker(object): 7 | fast_info = {"currency": "USD"} 8 | sort_values = mock.Mock(spec=pd.DataFrame) 9 | 10 | 11 | def create_ticker_mock() -> MockTicker: 12 | return MockTicker() 13 | 14 | 15 | def create_download_mock_df() -> pd.DataFrame: 16 | """Create a mock dataframe with some dummy data for AAPL 17 | 18 | Returns: 19 | DataFrame: A dataframe with some dummy data 20 | """ 21 | mock_df = pd.DataFrame( 22 | {"Date": [200, 210, 220], "AAPL": [100, 110, 120], "Close": [100, 110, 120]} 23 | ) 24 | return mock_df 25 | 26 | 27 | def create_empty_download_mock_df() -> pd.DataFrame: 28 | """Create a mock dataframe with some dummy data for AAPL 29 | 30 | Returns: 31 | DataFrame: A dataframe with some dummy data 32 | """ 33 | mock_df = pd.DataFrame({"Date": [], "AAPL": [], "Close": []}) 34 | return mock_df 35 | 36 | 37 | def create_download_mock(): 38 | return create_download_mock_df() 39 | -------------------------------------------------------------------------------- /client/src/pages/sectors/SectorsListPage/SectorsListPage.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { Grid, Loader } from "@mantine/core"; 3 | import SectorsListTable from "./components/SectorsListTable/SectorsListTable"; 4 | import SectorsPageHeader from "./components/SectorsPageHeader/SectorsPageHeader"; 5 | import { 6 | LanguageContext, 7 | LanguageProvider, 8 | } from "components/ListLanguageProvider/ListLanguageProvider"; 9 | 10 | function SectorsListTableContent() { 11 | const mrtLocalization = useContext(LanguageContext); 12 | 13 | return mrtLocalization ? ( 14 | 15 | ) : ( 16 | 17 | ); 18 | } 19 | 20 | export function SectorsListPage() { 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | } 34 | 35 | export default SectorsListPage; 36 | -------------------------------------------------------------------------------- /client/src/components/CountrySelector/CountrySelector.test.tsx: -------------------------------------------------------------------------------- 1 | import CountrySelector from "./CountrySelector"; 2 | import { customRender, userEvent, screen } from "test-utils"; 3 | 4 | describe("CountrySelector tests", () => { 5 | afterEach(() => { 6 | vi.clearAllMocks(); 7 | }); 8 | 9 | it("renders expected texts when loading", async () => { 10 | const user = userEvent.setup({ pointerEventsCheck: 0 }); 11 | 12 | // Mock function for onCreate 13 | const onChangeMock = vi.fn(); 14 | 15 | customRender( 16 | , 21 | ); 22 | const element = screen.getByTestId("country-selector"); 23 | if (!element) throw new Error("Element not found"); 24 | await user.click(element); 25 | 26 | const option = screen.getByText("European Union"); 27 | 28 | expect(screen.getByText("European Union")).toBeInTheDocument(); 29 | await user.click(option); 30 | expect(onChangeMock).toHaveBeenCalledWith("eu", expect.anything()); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /client/src/hooks/use-settings/use-mutation-settings.test.ts: -------------------------------------------------------------------------------- 1 | import { useUpdateSettings } from "./use-settings"; 2 | import { renderHook, waitFor } from "test-utils"; 3 | // https://github.com/TanStack/query/discussions/1650 4 | vi.mock("./use-settings", async () => { 5 | return { 6 | useSettings: vi.fn(), 7 | useUpdateSettings: vi.fn(() => ({ 8 | mutate: vi.fn(), 9 | isLoading: false, 10 | })), 11 | }; 12 | }); 13 | 14 | describe("useUpdateSettings", () => { 15 | it("should call useUpdateSettings and return expected response", async () => { 16 | renderHook(() => useUpdateSettings()); 17 | const mutateMock = vi.fn(); 18 | vi.mocked(useUpdateSettings).mockReturnValue({ 19 | mutate: mutateMock, 20 | } as any); 21 | 22 | const { mutate } = useUpdateSettings(); 23 | mutate({ 24 | newSettings: { 25 | language: "en", 26 | }, 27 | }); 28 | 29 | await waitFor(() => { 30 | expect(mutateMock).toHaveBeenCalledWith({ 31 | newSettings: { 32 | language: "en", 33 | }, 34 | }); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /client/src/pages/import/components/ImportSteps/components/DividendsImportStep/components/DividendsImportForm/DividendsImportFormProvider.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "@mantine/core"; 2 | import DividendsImportForm from "./DividendsImportForm"; 3 | import { usePortfolio } from "hooks/use-portfolios/use-portfolios"; 4 | import { ICsvDividendRow } from "types/csv"; 5 | 6 | interface Props { 7 | portfolioId: number; 8 | dividend: ICsvDividendRow; 9 | onImportedCallback: () => void; 10 | } 11 | 12 | export default function DividendsImportFormProvider({ 13 | dividend, 14 | portfolioId, 15 | onImportedCallback, 16 | }: Props) { 17 | const { data: portfolio, isLoading } = usePortfolio(portfolioId); 18 | 19 | const onSubmitCallback = () => { 20 | onImportedCallback(); 21 | }; 22 | 23 | if (isLoading) { 24 | return ; 25 | } 26 | 27 | if (portfolio && dividend) { 28 | return ( 29 | 34 | ); 35 | } 36 | return null; 37 | } 38 | -------------------------------------------------------------------------------- /backend/buho_backend/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for buho_backend project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from channels.auth import AuthMiddlewareStack 13 | from channels.routing import ProtocolTypeRouter, URLRouter 14 | from channels.security.websocket import AllowedHostsOriginValidator 15 | from django.core.asgi import get_asgi_application 16 | from django.urls import re_path 17 | 18 | from buho_backend import consumers 19 | 20 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "buho_backend.settings") 21 | 22 | 23 | django_asgi_app = get_asgi_application() 24 | 25 | websocket_urlpatterns = [ 26 | re_path(r"ws/tasks/$", consumers.TaskConsumer.as_asgi()), 27 | ] 28 | 29 | application = ProtocolTypeRouter( 30 | { 31 | "http": django_asgi_app, 32 | "websocket": AllowedHostsOriginValidator( 33 | AuthMiddlewareStack(URLRouter(websocket_urlpatterns)) 34 | ), 35 | } 36 | ) 37 | -------------------------------------------------------------------------------- /client/src/pages/home/components/HomePageHeader/HomePageHeader.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { Button, Group, Title } from "@mantine/core"; 4 | import { IconPlus } from "@tabler/icons-react"; 5 | import PortfolioFormProvider from "../PortfolioForm/PortfolioFormProvider"; 6 | 7 | function HomePageHeader() { 8 | const { t } = useTranslation(); 9 | const [isModalVisible, setIsModalVisible] = useState(false); 10 | 11 | const showModal = () => { 12 | setIsModalVisible(true); 13 | }; 14 | 15 | const onClose = () => { 16 | setIsModalVisible(false); 17 | }; 18 | 19 | return ( 20 | 21 | 22 | {t("Portfolios")} 23 | 24 | 27 | 31 | 32 | ); 33 | } 34 | 35 | export default HomePageHeader; 36 | -------------------------------------------------------------------------------- /backend/exchange_rates/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-04-07 09:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | dependencies = [] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="ExchangeRate", 14 | fields=[ 15 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 16 | ("exchange_from", models.CharField(max_length=200)), 17 | ("exchange_to", models.CharField(max_length=200)), 18 | ("exchange_rate", models.DecimalField(decimal_places=3, max_digits=12)), 19 | ("exchange_date", models.DateField()), 20 | ("date_created", models.DateTimeField(auto_now_add=True)), 21 | ("last_updated", models.DateTimeField(auto_now=True)), 22 | ], 23 | options={ 24 | "unique_together": {("exchange_from", "exchange_to", "exchange_date")}, 25 | }, 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /backend/settings/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from rest_framework import status 3 | 4 | from buho_backend.tests.base_test_case import BaseApiTestCase 5 | 6 | 7 | class UserSettingsDetailTestCase(BaseApiTestCase): 8 | def setUp(self): 9 | super().setUp() 10 | 11 | def test_update_settings(self): 12 | temp_data = { 13 | "language": "en", 14 | "timezone": "Europe/Paris", 15 | "backend_hostname": "", 16 | "sentry_enabled": False, 17 | "sentry_dsn": "", 18 | } 19 | url = reverse("user-settings-detail") 20 | response = self.client.put(url, temp_data) 21 | # Check status response 22 | print("-----------------------------------------------") 23 | print(response.data) 24 | self.assertEqual(response.status_code, status.HTTP_200_OK) 25 | self.assertEqual( 26 | response.data["language"], 27 | temp_data["language"], 28 | ) 29 | self.assertEqual( 30 | response.data["timezone"], 31 | temp_data["timezone"], 32 | ) 33 | -------------------------------------------------------------------------------- /client/src/pages/companies/CompanyDetailsPage/components/Charts/Charts.tsx: -------------------------------------------------------------------------------- 1 | import { Grid, Paper } from "@mantine/core"; 2 | import ChartCompanyDividends from "./components/ChartCompanyDividends/ChartCompanyDividends"; 3 | import ChartCompanyReturns from "./components/ChartCompanyReturns/ChartCompanyReturns"; 4 | import { CompanyYearStats } from "types/company-year-stats"; 5 | 6 | interface Props { 7 | stats: CompanyYearStats[]; 8 | portfolioCurrency: string; 9 | } 10 | 11 | export default function Charts({ stats, portfolioCurrency }: Props) { 12 | if (stats.length === 0) { 13 | return null; 14 | } 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /client/src/pages/currencies/CurrenciesListPage/CurrenciesListPage.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { Grid, Loader } from "@mantine/core"; 3 | import CurrenciesListTable from "./components/CurrenciesListTable/CurrenciesListTable"; 4 | import CurrenciesPageHeader from "./components/CurrenciesPageHeader/CurrenciesPageHeader"; 5 | import { 6 | LanguageContext, 7 | LanguageProvider, 8 | } from "components/ListLanguageProvider/ListLanguageProvider"; 9 | 10 | function CurrenciesListTableContent() { 11 | const mrtLocalization = useContext(LanguageContext); 12 | 13 | return mrtLocalization ? ( 14 | 15 | ) : ( 16 | 17 | ); 18 | } 19 | 20 | export function CurrenciesListPage() { 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | } 34 | 35 | export default CurrenciesListPage; 36 | -------------------------------------------------------------------------------- /client/src/pages/benchmarks/BenchmarksListPage/BenchmarksListPage.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { Grid, Loader } from "@mantine/core"; 3 | import BenchmarksListPageHeader from "./components/BenchmarksListPageHeader/BenchmarksListPageHeader"; 4 | import BenchmarksListTable from "./components/BenchmarksListTable/BenchmarksListTable"; 5 | import { 6 | LanguageContext, 7 | LanguageProvider, 8 | } from "components/ListLanguageProvider/ListLanguageProvider"; 9 | 10 | function BenchmarksListContent() { 11 | const mrtLocalization = useContext(LanguageContext); 12 | 13 | return mrtLocalization ? ( 14 | 15 | ) : ( 16 | 17 | ); 18 | } 19 | 20 | export function BenchmarksListPage() { 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | } 34 | 35 | export default BenchmarksListPage; 36 | -------------------------------------------------------------------------------- /backend/markets/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.utils.decorators import method_decorator 4 | from drf_yasg.utils import swagger_auto_schema 5 | from rest_framework import generics, viewsets 6 | from rest_framework.pagination import LimitOffsetPagination 7 | 8 | from markets.models import Market, get_all_timezones 9 | from markets.serializers import MarketSerializer, TimezoneSerializer 10 | 11 | logger = logging.getLogger("buho_backend") 12 | 13 | 14 | class MarketViewSet(viewsets.ModelViewSet): 15 | """ 16 | A viewset for viewing and editing market instances. 17 | """ 18 | 19 | serializer_class = MarketSerializer 20 | pagination_class = LimitOffsetPagination 21 | queryset = Market.objects.all() 22 | 23 | 24 | @method_decorator( 25 | name="get", 26 | decorator=swagger_auto_schema( 27 | operation_id="timezones_list", 28 | operation_description="Get all the available timezones", 29 | tags=["timezones"], 30 | ), 31 | ) 32 | class TimezoneList(generics.ListAPIView): 33 | serializer_class = TimezoneSerializer 34 | 35 | def get_queryset(self): 36 | return get_all_timezones() 37 | -------------------------------------------------------------------------------- /backend/sectors/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from rest_framework import viewsets 4 | from rest_framework.pagination import LimitOffsetPagination 5 | 6 | from sectors.models import Sector, SuperSector 7 | from sectors.serializers import ( 8 | SectorSerializer, 9 | SectorSerializerGet, 10 | SuperSectorSerializer, 11 | ) 12 | 13 | logger = logging.getLogger("buho_backend") 14 | 15 | 16 | class SectorViewSet(viewsets.ModelViewSet): 17 | """ 18 | A viewset for viewing and editing sector instances. 19 | """ 20 | 21 | serializer_class = SectorSerializer 22 | pagination_class = LimitOffsetPagination 23 | queryset = Sector.objects.all() 24 | 25 | def get_serializer_class(self): 26 | if self.action == "list" or self.action == "retrieve": 27 | return SectorSerializerGet 28 | return super().get_serializer_class() 29 | 30 | 31 | class SuperSectorViewSet(viewsets.ModelViewSet): 32 | """ 33 | A viewset for viewing and editing sector instances. 34 | """ 35 | 36 | serializer_class = SuperSectorSerializer 37 | pagination_class = LimitOffsetPagination 38 | queryset = SuperSector.objects.all() 39 | -------------------------------------------------------------------------------- /backend/currencies/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from rest_framework import status 3 | 4 | from buho_backend.tests.base_test_case import BaseApiTestCase 5 | from initialize_data.initializers.currencies import create_initial_currencies 6 | 7 | 8 | class CurrenciesViewsTestCase(BaseApiTestCase): 9 | def setUp(self): 10 | super().setUp() 11 | self.available_currencies_count = 13 12 | create_initial_currencies() 13 | 14 | def test_get_currencies(self): 15 | url = reverse("currencies-list") 16 | response = self.client.get(url) 17 | # Check status response 18 | self.assertEqual(response.status_code, status.HTTP_200_OK) 19 | self.assertEqual(len(response.data), self.available_currencies_count) 20 | 21 | self.assertEqual( 22 | response.data[0]["name"], 23 | "Australian dollar", 24 | ) 25 | self.assertEqual( 26 | response.data[-1]["code"], 27 | "USD", 28 | ) 29 | self.assertEqual( 30 | response.data[round(self.available_currencies_count / 2)]["symbol"], 31 | "HK$", 32 | ) 33 | -------------------------------------------------------------------------------- /client/src/utils/countries.ts: -------------------------------------------------------------------------------- 1 | import { ICountry } from "types/country"; 2 | 3 | const countries: ICountry[] = [ 4 | { 5 | key: "ca", 6 | name: "Canada", 7 | code: "ca", 8 | }, 9 | { 10 | key: "cn", 11 | name: "China", 12 | code: "cn", 13 | }, 14 | { 15 | key: "fr", 16 | name: "France", 17 | code: "fr", 18 | }, 19 | { 20 | key: "de", 21 | name: "Germany", 22 | code: "de", 23 | }, 24 | { 25 | key: "eu", 26 | name: "European Union", 27 | code: "eu", 28 | }, 29 | { 30 | key: "it", 31 | name: "Italy", 32 | code: "it", 33 | }, 34 | { 35 | key: "jp", 36 | name: "Japan", 37 | code: "jp", 38 | }, 39 | { 40 | key: "nl", 41 | name: "Netherlands", 42 | code: "nl", 43 | }, 44 | { 45 | key: "es", 46 | name: "Spain", 47 | code: "es", 48 | }, 49 | { 50 | key: "ch", 51 | name: "Switzerland", 52 | code: "ch", 53 | }, 54 | { 55 | key: "gb", 56 | name: "United Kingdom", 57 | code: "gb", 58 | }, 59 | { 60 | key: "us", 61 | name: "United States", 62 | code: "us", 63 | }, 64 | ]; 65 | 66 | export default countries; 67 | -------------------------------------------------------------------------------- /backend/shares_transactions/tests/factory.py: -------------------------------------------------------------------------------- 1 | from factory import Faker, SubFactory, django 2 | 3 | from buho_backend.transaction_types import TransactionType 4 | from companies.tests.factory import CompanyFactory 5 | from shares_transactions.models import SharesTransaction 6 | 7 | 8 | class SharesTransactionFactory(django.DjangoModelFactory): 9 | class Meta: 10 | model = SharesTransaction 11 | 12 | count = Faker("pyint") 13 | gross_price_per_share = Faker("pydecimal", left_digits=4, right_digits=3, positive=True) 14 | 15 | total_commission = Faker("pydecimal", left_digits=4, right_digits=3, positive=True) 16 | exchange_rate = Faker("pydecimal", left_digits=1, right_digits=3, positive=True) 17 | transaction_date = Faker("date_between") 18 | notes = Faker("paragraph") 19 | 20 | type = Faker("random_element", elements=[x[0] for x in TransactionType.choices]) 21 | 22 | company = SubFactory(CompanyFactory) 23 | 24 | # @post_generation 25 | # def set_currencies(self, create, extracted, base_currency, **kwargs): 26 | # self.gross_price_per_share_currency = base_currency 27 | # self.total_commission_currency = base_currency 28 | -------------------------------------------------------------------------------- /backend/buho_backend/celery_app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from celery import Celery 5 | from celery.app.control import Control 6 | 7 | # Set the default Django settings module for the 'celery' program. 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "buho_backend.settings") 9 | 10 | app = Celery("buho_celery") 11 | control = Control(app=app) 12 | # Using a string here means the worker doesn't have to serialize 13 | # the configuration object to child processes. 14 | # - namespace='CELERY' means all celery-related configuration keys 15 | # should have a `CELERY_` prefix. 16 | app.config_from_object("django.conf:settings", namespace="CELERY") 17 | # Load task modules from all registered Django apps. 18 | app.autodiscover_tasks() 19 | 20 | logger = logging.getLogger("buho_backend") 21 | 22 | 23 | def revoke_scheduled_tasks_by_name(task_name): 24 | # Get all active tasks 25 | scheduled_tasks = control.inspect().scheduled() 26 | 27 | for queue, tasks in scheduled_tasks.items(): 28 | for task in tasks: 29 | # If the task name matches, revoke the task 30 | if task["name"] == task_name: 31 | control.revoke(task["id"], terminate=True) 32 | -------------------------------------------------------------------------------- /backend/markets/models.py: -------------------------------------------------------------------------------- 1 | import pytz 2 | from django.db import models 3 | 4 | TIMEZONES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) 5 | 6 | 7 | # Create your models here. 8 | class Market(models.Model): 9 | id = models.AutoField(primary_key=True) 10 | name: models.CharField = models.CharField(max_length=200, unique=True) 11 | description: models.TextField = models.TextField() 12 | region: models.CharField = models.CharField(max_length=200) 13 | open_time: models.TimeField = models.TimeField() 14 | close_time: models.TimeField = models.TimeField() 15 | timezone: models.CharField = models.CharField( 16 | max_length=200, choices=TIMEZONES, default="UTC" 17 | ) 18 | 19 | date_created: models.DateTimeField = models.DateTimeField(auto_now_add=True) 20 | last_updated: models.DateTimeField = models.DateTimeField(auto_now=True) 21 | 22 | class Meta: 23 | ordering = ["name"] 24 | verbose_name = "Market" 25 | verbose_name_plural = "Markets" 26 | 27 | def __str__(self) -> str: 28 | return f"{self.name} ({self.region})" 29 | 30 | 31 | def get_all_timezones(): 32 | return [{"name": name} for name in pytz.all_timezones] 33 | -------------------------------------------------------------------------------- /backend/rights_transactions/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models.query import QuerySet 3 | from djmoney.models.fields import MoneyField 4 | 5 | from buho_backend.transaction_types import TransactionType 6 | from companies.models import Company 7 | from shares_transactions.models import Transaction 8 | 9 | 10 | class RightsTransaction(Transaction): 11 | type: models.CharField = models.CharField( 12 | choices=TransactionType.choices, 13 | default=TransactionType.BUY, 14 | max_length=10, 15 | verbose_name="Type", 16 | ) 17 | total_amount = MoneyField( 18 | max_digits=12, decimal_places=3, default=0, default_currency="EUR" 19 | ) 20 | 21 | company: models.ForeignKey = models.ForeignKey( 22 | Company, on_delete=models.CASCADE, related_name="rights_transactions" 23 | ) 24 | objects: QuerySet["RightsTransaction"] # To solve issue django-manager-missing 25 | 26 | def __str___(self): 27 | return ( 28 | f"{self.type} - {self.count} - " 29 | f"{self.gross_price_per_share} ({self.total_commission}" 30 | ) 31 | 32 | class Meta: 33 | ordering = ["-transaction_date"] 34 | -------------------------------------------------------------------------------- /client/src/components/ListLanguageProvider/ListLanguageProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useMemo } from "react"; 2 | import { MRT_Localization } from "mantine-react-table"; 3 | import { MRT_Localization_EN } from "mantine-react-table/locales/en/index.esm.mjs"; 4 | import { MRT_Localization_ES } from "mantine-react-table/locales/es/index.esm.mjs"; 5 | import { useSettings } from "hooks/use-settings/use-settings"; 6 | 7 | type Props = { 8 | children: React.ReactNode; 9 | }; 10 | 11 | export const LanguageContext = 12 | createContext(MRT_Localization_EN); 13 | 14 | export function LanguageProvider({ children }: Props) { 15 | const { data: settings } = useSettings(); 16 | 17 | const localization = useMemo(() => { 18 | if (!settings) { 19 | return MRT_Localization_EN; 20 | } 21 | if (settings.language === "en") { 22 | return MRT_Localization_EN; 23 | } 24 | if (settings.language === "es") { 25 | return MRT_Localization_ES; 26 | } 27 | return MRT_Localization_EN; 28 | }, [settings]); 29 | 30 | return ( 31 | 32 | {children} 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /client/src/pages/import/components/ImportSteps/components/CorporateActionsImportStep/components/CorporateActionsImportForm/CorportateActionsImportFormProvider.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "@mantine/core"; 2 | import CorporateActionsImportForm from "./CorporateActionsImporForm"; 3 | import { usePortfolio } from "hooks/use-portfolios/use-portfolios"; 4 | import { ICsvCorporateActionsRow } from "types/csv"; 5 | 6 | interface Props { 7 | portfolioId: number; 8 | corporateAction: ICsvCorporateActionsRow; 9 | onImportedCallback: () => void; 10 | } 11 | 12 | export default function CorporateActionsImportFormProvider({ 13 | corporateAction, 14 | portfolioId, 15 | onImportedCallback, 16 | }: Props) { 17 | const { data: portfolio, isLoading } = usePortfolio(portfolioId); 18 | 19 | const onSubmitCallback = () => { 20 | onImportedCallback(); 21 | }; 22 | 23 | if (isLoading) { 24 | return ; 25 | } 26 | 27 | if (portfolio && corporateAction) { 28 | return ( 29 | 34 | ); 35 | } 36 | return null; 37 | } 38 | -------------------------------------------------------------------------------- /client/src/types/csv.ts: -------------------------------------------------------------------------------- 1 | export interface ICsvDataObject { 2 | data: string[][]; 3 | } 4 | 5 | export interface ICsvDividendRow { 6 | id: string; 7 | date: string; 8 | amount: number; 9 | currency: string; 10 | commissions: number; 11 | description: string; 12 | ticker: string; 13 | companyName: string; 14 | isin: string; 15 | market: string; 16 | } 17 | 18 | export interface ICsvTradesRow { 19 | id: string; 20 | transactionType: string; 21 | date: string; 22 | ticker: string; 23 | companyName: string; 24 | companyISIN: string; 25 | market: string; 26 | currency: string; 27 | count: number; 28 | price: number; 29 | total: number; 30 | commission: number; 31 | totalWithCommission: number; 32 | category: string; 33 | description: string; 34 | } 35 | 36 | export interface ICsvCorporateActionsRow { 37 | id: string; 38 | transactionType: string; 39 | date: string; 40 | ticker: string; 41 | companyName: string; 42 | isin: string; 43 | market: string; 44 | currency: string; 45 | count: number; 46 | price: number; 47 | total: number; 48 | commission: number; 49 | totalWithCommission: number; 50 | category: string; 51 | description: string; 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/react.yml: -------------------------------------------------------------------------------- 1 | name: React CI 2 | 3 | env: 4 | VITE_ENV: test 5 | VITE_API_URL: http://127.0.0.1:8001 6 | VITE_PORT: 3000 7 | 8 | on: 9 | push: 10 | branches: [main, develop] 11 | pull_request: 12 | branches: [main, develop] 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | max-parallel: 4 19 | matrix: 20 | node-version: [20] 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Set up Node ${{ matrix.node-version }} 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: "npm" 29 | cache-dependency-path: client/package-lock.json 30 | - name: Install Dependencies 31 | run: | 32 | cd client && npm ci 33 | - name: Lint 34 | run: | 35 | cd client && npm run lint 36 | - name: Run Tests 37 | run: | 38 | cd client && npm run coverage 39 | - name: Upload coverage to Codecov 40 | uses: codecov/codecov-action@v4 41 | with: 42 | token: ${{ secrets.CODECOV_TOKEN }} # required 43 | verbose: true # optional (default = false) 44 | -------------------------------------------------------------------------------- /backend/dividends_transactions/serializers.py: -------------------------------------------------------------------------------- 1 | from djmoney.contrib.django_rest_framework import MoneyField 2 | from rest_framework import serializers 3 | from rest_framework.fields import Field 4 | 5 | from companies.models import Company 6 | from dividends_transactions.models import DividendsTransaction 7 | 8 | 9 | class DividendsTransactionSerializer(serializers.ModelSerializer): 10 | company: Field = serializers.PrimaryKeyRelatedField( 11 | queryset=Company.objects, many=False, read_only=False 12 | ) 13 | 14 | total_commission = MoneyField(max_digits=12, decimal_places=3) 15 | total_commission_currency = serializers.CharField(max_length=50) 16 | 17 | notes = serializers.CharField(allow_blank=True, required=False) 18 | 19 | class Meta: 20 | model = DividendsTransaction 21 | fields = [ 22 | "id", 23 | "count", 24 | "exchange_rate", 25 | "transaction_date", 26 | "total_commission", 27 | "total_commission_currency", 28 | "total_amount", 29 | "total_amount_currency", 30 | "company", 31 | "notes", 32 | "date_created", 33 | "last_updated", 34 | ] 35 | -------------------------------------------------------------------------------- /.github/workflows/docker-build-publish-backend-latest.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build & Publish latest 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | build-and-publish: 9 | name: Build and Publish Docker Image (latest) 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up QEMU 16 | uses: docker/setup-qemu-action@v3 17 | 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v3 20 | 21 | - name: Login to GitHub Container Registry 22 | uses: docker/login-action@v3 23 | with: 24 | registry: ghcr.io 25 | username: ${{ github.actor }} 26 | password: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | - name: Build and Push backend Docker Image 29 | uses: docker/build-push-action@v5 30 | with: 31 | context: . # Path to your Dockerfile and other build context files 32 | platforms: linux/amd64,linux/arm64 33 | push: true 34 | tags: | 35 | ghcr.io/${{ github.repository }}:latest 36 | labels: | 37 | org.opencontainers.image.source=https://github.com/bocabitlabs/${{ github.repository }} 38 | -------------------------------------------------------------------------------- /backend/rights_transactions/tests/factory.py: -------------------------------------------------------------------------------- 1 | from factory import Faker, SubFactory, django 2 | 3 | from buho_backend.transaction_types import TransactionType 4 | from companies.tests.factory import CompanyFactory 5 | from rights_transactions.models import RightsTransaction 6 | 7 | 8 | class RightsTransactionFactory(django.DjangoModelFactory): 9 | class Meta: 10 | model = RightsTransaction 11 | 12 | count = Faker("pyint") 13 | gross_price_per_share = Faker("pydecimal", left_digits=4, right_digits=3, positive=True) 14 | 15 | total_amount = Faker("pydecimal", left_digits=4, right_digits=3, positive=True) 16 | total_commission = Faker("pydecimal", left_digits=4, right_digits=3, positive=True) 17 | exchange_rate = Faker("pydecimal", left_digits=1, right_digits=3, positive=True) 18 | transaction_date = Faker("date_object") 19 | notes = Faker("paragraph") 20 | 21 | type = Faker("random_element", elements=[x[0] for x in TransactionType.choices]) 22 | 23 | company = SubFactory(CompanyFactory) 24 | 25 | # @post_generation 26 | # def set_currencies(self, create, extracted, base_currency, **kwargs): 27 | # self.gross_price_per_share_currency = base_currency 28 | # self.total_commission_currency = base_currency 29 | -------------------------------------------------------------------------------- /client/vite.config.mjs: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from "vite"; 3 | 4 | import react from "@vitejs/plugin-react"; 5 | import viteTsconfigPaths from "vite-tsconfig-paths"; 6 | import svgr from "vite-plugin-svgr"; 7 | import eslint from "vite-plugin-eslint"; 8 | 9 | export default ({ command, mode }) => { 10 | return defineConfig({ 11 | plugins: [ 12 | react(), 13 | viteTsconfigPaths(), 14 | svgr(), 15 | eslint(), 16 | ], 17 | test: { 18 | globals: true, 19 | environment: "jsdom", 20 | setupFiles: "./vitest.setup.mjs", 21 | deps: { 22 | // inline: ["vitest-canvas-mock"], 23 | optimizer: { 24 | web: { 25 | include: ['vitest-canvas-mock'], 26 | }, 27 | }, 28 | }, 29 | environmentOptions: { 30 | jsdom: { 31 | resources: "usable", 32 | }, 33 | }, 34 | }, 35 | css: { 36 | preprocessorOptions: { 37 | less: { 38 | javascriptEnabled: true, 39 | }, 40 | }, 41 | }, 42 | server: { 43 | port: 3000, 44 | }, 45 | preview: { 46 | port: 3000, 47 | }, 48 | define: { "process.env.NODE_ENV": `"${mode}"`, 49 | }, 50 | }); 51 | }; 52 | -------------------------------------------------------------------------------- /backend/benchmarks/serializers.py: -------------------------------------------------------------------------------- 1 | from djmoney.contrib.django_rest_framework import MoneyField 2 | from rest_framework import serializers 3 | 4 | from benchmarks.models import Benchmark, BenchmarkYear 5 | 6 | 7 | class BenchmarkSerializer(serializers.ModelSerializer): 8 | class Meta: 9 | model = Benchmark 10 | fields = [ 11 | "id", 12 | "name", 13 | "date_created", 14 | "last_updated", 15 | ] 16 | 17 | 18 | class BenchmarkYearSerializer(serializers.ModelSerializer): 19 | value = MoneyField(max_digits=12, decimal_places=3) 20 | value_currency = serializers.CharField(max_length=50) 21 | 22 | class Meta: 23 | model = BenchmarkYear 24 | fields = [ 25 | "id", 26 | "year", 27 | "benchmark", 28 | "value", 29 | "return_percentage", 30 | "value_currency", 31 | "date_created", 32 | "last_updated", 33 | ] 34 | 35 | 36 | class BenchmarkSerializerDetails(serializers.ModelSerializer): 37 | years = BenchmarkYearSerializer(many=True, read_only=True) 38 | 39 | class Meta: 40 | model = Benchmark 41 | fields = ["id", "name", "date_created", "last_updated", "years"] 42 | -------------------------------------------------------------------------------- /backend/portfolios/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-04-07 09:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | dependencies = [] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="Portfolio", 14 | fields=[ 15 | ("id", models.AutoField(primary_key=True, serialize=False)), 16 | ("name", models.CharField(max_length=200)), 17 | ("description", models.TextField(blank=True, default="")), 18 | ("color", models.CharField(max_length=200)), 19 | ("hide_closed_companies", models.BooleanField(default=False)), 20 | ("date_created", models.DateTimeField(auto_now_add=True)), 21 | ("last_updated", models.DateTimeField(auto_now=True)), 22 | ("base_currency", models.CharField(max_length=50)), 23 | ("country_code", models.CharField(max_length=200)), 24 | ], 25 | options={ 26 | "verbose_name": "Portfolio", 27 | "verbose_name_plural": "Portfolios", 28 | "ordering": ["name"], 29 | }, 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /docs/user-guides/create-company.md: -------------------------------------------------------------------------------- 1 | # 3. Create a company 2 | 3 | You can create a company from the portfolio details page, clicking on the "Add company" button. 4 | 5 | Portfolio details page empty 6 | 7 | If you enter the ticker and then click on "Search company", you will get some fields from the Yfinance API that can be used to populate the form automatically. 8 | 9 | Set the fields of the company 10 | 11 | Not all the fields can be populated since they don't match 1:1 the app's information, but you can write them manually. 12 | 13 | Screenshot 2024-08-17 at 11 27 46 14 | 15 | Once you have finished, click on the "Create" button and the company will be added to the portfolio. 16 | 17 | Screenshot 2024-08-17 at 11 29 38 18 | 19 | Next: [Add shares and dividends transactions manually] 20 | Previous: [Create a portfolio](create-portfolio.md) 21 | -------------------------------------------------------------------------------- /client/src/hooks/use-task-results/use-task-results.ts: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { notifications } from "@mantine/notifications"; 3 | import { useMutation, useQuery } from "@tanstack/react-query"; 4 | import { apiClient } from "api/api-client"; 5 | import queryClient from "api/query-client"; 6 | import { ITaskResult } from "types/task-result"; 7 | 8 | export const fetchTasksResults = async () => { 9 | const { data } = await apiClient.get("/tasks-results/"); 10 | return data; 11 | }; 12 | 13 | export function useTasksResults() { 14 | return useQuery({ 15 | queryKey: ["tasks-results"], 16 | queryFn: fetchTasksResults, 17 | }); 18 | } 19 | 20 | export const useStartTask = () => { 21 | const { t } = useTranslation(); 22 | 23 | return useMutation({ 24 | mutationFn: () => apiClient.post(`/start-task/`), 25 | onSuccess: () => { 26 | notifications.show({ 27 | color: "green", 28 | message: t("Task created"), 29 | }); 30 | queryClient.invalidateQueries({ queryKey: ["tasks-results"] }); 31 | }, 32 | onError: () => { 33 | notifications.show({ 34 | color: "red", 35 | message: t("Unable to create task"), 36 | }); 37 | }, 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /client/src/pages/import/components/ImportSteps/components/TradesImportStep/TradesImportStep.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { Stack, Title } from "@mantine/core"; 3 | import TradesImportFormProvider from "./components/TradesImportForm/TradesImportFormProvider"; 4 | import { ICsvTradesRow } from "types/csv"; 5 | 6 | interface Props { 7 | trades: ICsvTradesRow[]; 8 | portfolioId: number | undefined; 9 | onTradeImported: () => void; 10 | } 11 | 12 | export default function TradesImportStep({ 13 | trades, 14 | portfolioId, 15 | onTradeImported, 16 | }: Props) { 17 | const { t } = useTranslation(); 18 | 19 | if (!portfolioId) { 20 | return
    {t("Select a portfolio to import trades.")}
    ; 21 | } 22 | 23 | if (trades && trades.length > 0) { 24 | console.log(trades); 25 | return ( 26 | 27 | {t("Import shares")} 28 | {trades.map((trade) => ( 29 | 35 | ))} 36 | 37 | ); 38 | } 39 | 40 | return
    {t("No trades found on the CSV file")}
    ; 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/docker-build-publish-backend-tag.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build & Publish Backend tag 2 | on: 3 | release: 4 | types: 5 | - created 6 | 7 | jobs: 8 | build-and-publish: 9 | name: Build and Publish Docker Image (tag) 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up QEMU 16 | uses: docker/setup-qemu-action@v3 17 | 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v3 20 | 21 | - name: Login to GitHub Container Registry 22 | uses: docker/login-action@v3 23 | with: 24 | registry: ghcr.io 25 | username: ${{ github.actor }} 26 | password: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | - name: Build and Push backend Docker Image (tag) 29 | uses: docker/build-push-action@v5 30 | with: 31 | context: . # Path to your Dockerfile and other build context files 32 | platforms: linux/amd64,linux/arm64 33 | push: true 34 | tags: | 35 | ghcr.io/${{ github.repository }}:${{ github.head_ref || github.ref_name }} 36 | labels: | 37 | org.opencontainers.image.source=https://github.com/bocabitlabs/${{ github.repository }} 38 | -------------------------------------------------------------------------------- /backend/companies/tests/factory.py: -------------------------------------------------------------------------------- 1 | from factory import Faker, SubFactory, django, post_generation 2 | 3 | from companies.models import Company 4 | from markets.tests.factory import MarketFactory 5 | from portfolios.tests.factory import PortfolioFactory 6 | from sectors.tests.factory import SectorFactory 7 | 8 | 9 | class CompanyFactory(django.DjangoModelFactory): 10 | class Meta: 11 | model = Company 12 | 13 | name = Faker("company") 14 | ticker = Faker("pystr", max_chars=4) 15 | alt_tickers = Faker("paragraph") 16 | description = Faker("paragraph") 17 | url = Faker("url") 18 | color = Faker("color") 19 | broker = Faker("company") 20 | is_closed = Faker("boolean") 21 | country_code = Faker("country_code") 22 | 23 | base_currency = "USD" 24 | dividends_currency = "USD" 25 | 26 | sector = SubFactory(SectorFactory) 27 | market = SubFactory(MarketFactory) 28 | portfolio = SubFactory(PortfolioFactory) 29 | 30 | @post_generation 31 | def set_currencies(self, create, extracted, use_base_currency=False, **kwargs): 32 | if not create: 33 | return 34 | if extracted and use_base_currency: 35 | self.base_currency = extracted["base_currency"] 36 | self.dividends_currency = extracted["base_currency"] 37 | -------------------------------------------------------------------------------- /backend/stock_prices/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from rest_framework import viewsets 4 | from rest_framework.pagination import LimitOffsetPagination 5 | 6 | from stock_prices.models import StockPrice 7 | from stock_prices.serializers import StockPriceSerializer 8 | 9 | logger = logging.getLogger("buho_backend") 10 | 11 | 12 | class ExchangeRateViewSet(viewsets.ModelViewSet): 13 | """Get all the exchange rates from a user""" 14 | 15 | pagination_class = LimitOffsetPagination 16 | serializer_class = StockPriceSerializer 17 | 18 | def get_queryset(self): 19 | sort_by = self.request.query_params.get("sort_by", "transactionDate") 20 | order_by = self.request.query_params.get("order_by", "desc") 21 | 22 | sort_by_fields = { 23 | "transactionDate": "transaction_date", 24 | "transactionPrice": "transaction_price", 25 | "ticker": "ticker", 26 | "priceCurrency": "price_currency", 27 | "price": "price", 28 | } 29 | 30 | # Sort and order the queryset 31 | if order_by == "desc": 32 | queryset = StockPrice.objects.order_by(f"-{sort_by_fields[sort_by]}") 33 | else: 34 | queryset = StockPrice.objects.order_by(f"{sort_by_fields[sort_by]}") 35 | 36 | return queryset 37 | -------------------------------------------------------------------------------- /.github/workflows/docker-build-publish-client-latest.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build & Publish Client latest 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | build-and-publish: 9 | name: Build and Publish Docker Image (latest) 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up QEMU 16 | uses: docker/setup-qemu-action@v3 17 | 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v3 20 | 21 | - name: Login to GitHub Container Registry 22 | uses: docker/login-action@v3 23 | with: 24 | registry: ghcr.io 25 | username: ${{ github.actor }} 26 | password: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | - name: Build and Push client Docker Image 29 | uses: docker/build-push-action@v5 30 | with: 31 | context: . # Path to your Dockerfile and other build context files 32 | file: docker.client.Dockerfile 33 | platforms: linux/amd64,linux/arm64 34 | push: true 35 | tags: | 36 | ghcr.io/${{ github.repository }}-client:latest 37 | labels: | 38 | org.opencontainers.image.source=https://github.com/bocabitlabs/${{ github.repository }} 39 | -------------------------------------------------------------------------------- /client/src/i18n.ts: -------------------------------------------------------------------------------- 1 | import { initReactI18next } from "react-i18next"; 2 | import i18n from "i18next"; 3 | import english from "locales/en/translation.json"; 4 | import spanishSectors from "locales/es/sectors.json"; 5 | import spanish from "locales/es/translation.json"; 6 | 7 | // the translations 8 | // (tip move them in a JSON file and import them, 9 | // or even better, manage them via a UI: https://react.i18next.com/guides/multiple-translation-files#manage-your-translations-with-a-management-gui) 10 | const resources = { 11 | en: { 12 | translation: { ...english }, 13 | }, 14 | es: { translation: { ...spanish, ...spanishSectors } }, 15 | }; 16 | 17 | // eslint-disable-next-line import/no-named-as-default-member 18 | i18n 19 | .use(initReactI18next) // passes i18n down to react-i18next 20 | .init({ 21 | resources, 22 | lng: "en", // language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources 23 | // you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage 24 | // if you're using a language detector, do not define the lng option 25 | interpolation: { 26 | escapeValue: false, // react already safes from xss 27 | }, 28 | }); 29 | 30 | export default i18n; 31 | -------------------------------------------------------------------------------- /backend/exchange_rates/management/commands/get_exchange.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | 4 | from django.core.management.base import BaseCommand 5 | 6 | from exchange_rates.services.yfinance_api_client import YFinanceExchangeClient 7 | 8 | logger = logging.getLogger("buho_backend") 9 | 10 | 11 | class Command(BaseCommand): 12 | help = "Gets the exchange rate of a given ticker" 13 | 14 | def add_arguments(self, parser): 15 | parser.add_argument("from_currency", type=str) 16 | parser.add_argument("to_currency", type=str) 17 | parser.add_argument("date", type=str, help="Date in format YYYY-MM-DD") 18 | 19 | def handle(self, *args, **options): 20 | from_currency = options["from_currency"] 21 | to_currency = options["to_currency"] 22 | used_date = options["date"] 23 | 24 | self.stdout.write( 25 | f"Getting data for {from_currency} to {to_currency} on {used_date}" 26 | ) 27 | used_date_as_datetime = datetime.strptime(used_date, "%Y-%m-%d") 28 | api_client = YFinanceExchangeClient() 29 | currency = api_client.get_exchange_rate_for_date( 30 | from_currency, to_currency, used_date_as_datetime 31 | ) 32 | 33 | self.stdout.write(self.style.SUCCESS(f"{used_date} Data: {currency}")) 34 | -------------------------------------------------------------------------------- /client/src/pages/import/components/ImportSteps/components/DividendsImportStep/DividendsImportStep.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { Stack, Title } from "@mantine/core"; 3 | import DividendsImportFormProvider from "./components/DividendsImportForm/DividendsImportFormProvider"; 4 | import { ICsvDividendRow } from "types/csv"; 5 | 6 | interface Props { 7 | dividends: ICsvDividendRow[]; 8 | portfolioId: number | undefined; 9 | onDividendImported: () => void; 10 | } 11 | 12 | export default function DividendsImportStep({ 13 | dividends, 14 | portfolioId, 15 | onDividendImported, 16 | }: Props) { 17 | const { t } = useTranslation(); 18 | 19 | if (!portfolioId) { 20 | return
    {t("Select a portfolio to import dividends.")}
    ; 21 | } 22 | 23 | if (dividends && dividends.length > 0) { 24 | return ( 25 | 26 | {t("Import dividends")} 27 | {dividends.map((dividend) => ( 28 | 34 | ))} 35 | 36 | ); 37 | } 38 | 39 | return
    {t("No dividends found on the CSV file")}
    ; 40 | } 41 | -------------------------------------------------------------------------------- /backend/settings/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from markets.models import TIMEZONES 4 | 5 | 6 | # Create your models here. 7 | class UserSettings(models.Model): 8 | language = models.CharField(max_length=200) 9 | main_portfolio = models.CharField(max_length=200, blank=True, default="") 10 | portfolio_sort_by = models.CharField(max_length=200, blank=True, default="") 11 | portfolio_display_mode = models.CharField(max_length=200, blank=True, default="") 12 | company_sort_by = models.CharField(max_length=200, blank=True, default="") 13 | company_display_mode = models.CharField(max_length=200, blank=True, default="") 14 | timezone = models.CharField(max_length=200, choices=TIMEZONES, default="UTC") 15 | sentry_dsn = models.CharField(max_length=200, blank=True, default="") 16 | sentry_enabled = models.BooleanField(default=False) 17 | display_welcome = models.BooleanField(default=True) 18 | 19 | allow_fetch = models.BooleanField(default=False) 20 | 21 | date_created = models.DateTimeField(auto_now_add=True) 22 | last_updated = models.DateTimeField(auto_now=True) 23 | 24 | class Meta: 25 | verbose_name = "User Settings" 26 | verbose_name_plural = "User Settings" 27 | 28 | def __str__(self): 29 | return f"Language: {self.language}, {self.main_portfolio}" 30 | -------------------------------------------------------------------------------- /docs/user-guides/deploy-docker-compose.md: -------------------------------------------------------------------------------- 1 | # Deploy using Docker Compose 2 | 3 | You can deploy this application using Docker Compose. To do so, follow these steps. 4 | 5 | ## Requirements 6 | 7 | - Docker 8 | 9 | ## Database 10 | 11 | Please refer to [Choosing a database docs](/docs/development/database-selection) to select and run a database for the application. 12 | 13 | You can choose between `SQLite` and `MySQL`/`MariaDB`. 14 | 15 | ## Create a .env.prod file 16 | 17 | Use the `.env.sample` file and rename it to `.env.prod` (`cp .env.sample .env.prod`) and populate all its values to the desired ones. 18 | 19 | ### Deploy the application with Docker Compose 20 | 21 | ```bash 22 | docker-compose up 23 | ``` 24 | 25 | This command will deploy all the containers required by the application (backend, frontend, database, redis and celery). 26 | 27 | It will take the values from the `.env.prod` file. 28 | 29 | ### Configuring the volumes (optional) 30 | 31 | By default, the volumes will be handled automatically by Docker itself (check the `docker-compose.yml` file). If you want to point them to your own paths, you can modify this file to specify it. 32 | 33 | An example pointing to your own path: 34 | 35 | ```yaml 36 | volumes: 37 | - /volume2/buho-stocks/logs:/app/media 38 | ``` 39 | 40 | Next: [Initialize the app data](initialize-app-data.md) -------------------------------------------------------------------------------- /.github/workflows/docker-build-publish-client-tag.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build & Publish Client tag 2 | on: 3 | release: 4 | types: 5 | - created 6 | 7 | jobs: 8 | build-and-publish: 9 | name: Build and Publish Docker Image (tag) 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up QEMU 16 | uses: docker/setup-qemu-action@v3 17 | 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v3 20 | 21 | - name: Login to GitHub Container Registry 22 | uses: docker/login-action@v3 23 | with: 24 | registry: ghcr.io 25 | username: ${{ github.actor }} 26 | password: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | - name: Build and Push client Docker Image (tag) 29 | uses: docker/build-push-action@v5 30 | with: 31 | context: . # Path to your Dockerfile and other build context files 32 | file: docker.client.Dockerfile 33 | platforms: linux/amd64,linux/arm64 34 | push: true 35 | tags: | 36 | ghcr.io/${{ github.repository }}-client:${{ github.head_ref || github.ref_name }} 37 | labels: | 38 | org.opencontainers.image.source=https://github.com/bocabitlabs/${{ github.repository }} 39 | -------------------------------------------------------------------------------- /backend/rights_transactions/management/commands/set_rights_total.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from rights_transactions.models import RightsTransaction 6 | 7 | logger = logging.getLogger("buho_backend") 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Set the rights total amount for all the shares transactions" 12 | 13 | def handle(self, *args, **options): 14 | self.stdout.write("Updating total amount for rights transactions") 15 | # Iterate all the dividends transactions and set the total amount 16 | for transaction in RightsTransaction.objects.all(): 17 | transaction.total_amount = ( 18 | transaction.count * transaction.gross_price_per_share 19 | ) 20 | transaction.save() 21 | self.stdout.write("Updating total amount currency for rights transactions") 22 | # Iterate all the dividends transactions and set the total amount currency to 23 | # the company dividends currency 24 | for transaction in RightsTransaction.objects.all(): 25 | transaction.total_amount.currency = transaction.company.base_currency 26 | transaction.save() 27 | 28 | self.stdout.write( 29 | self.style.SUCCESS("Successfully set the rights total amount") 30 | ) 31 | -------------------------------------------------------------------------------- /backend/shares_transactions/management/commands/set_shares_total.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from shares_transactions.models import SharesTransaction 6 | 7 | logger = logging.getLogger("buho_backend") 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Set the shares total amount for all the shares transactions" 12 | 13 | def handle(self, *args, **options): 14 | self.stdout.write("Updating total amount for shares transactions") 15 | # Iterate all the dividends transactions and set the total amount 16 | for transaction in SharesTransaction.objects.all(): 17 | transaction.total_amount = ( 18 | transaction.count * transaction.gross_price_per_share 19 | ) 20 | transaction.save() 21 | self.stdout.write("Updating total amount currency for shares transactions") 22 | # Iterate all the dividends transactions and set the total amount currency 23 | # to the company dividends currency 24 | for transaction in SharesTransaction.objects.all(): 25 | transaction.total_amount.currency = transaction.company.base_currency 26 | transaction.save() 27 | 28 | self.stdout.write( 29 | self.style.SUCCESS("Successfully set the shares total amount") 30 | ) 31 | -------------------------------------------------------------------------------- /backend/log_messages/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-04-07 09:12 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [ 11 | ("portfolios", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="LogMessage", 17 | fields=[ 18 | ("id", models.AutoField(primary_key=True, serialize=False)), 19 | ("message_text", models.CharField(max_length=400)), 20 | ("message_type", models.CharField(max_length=100)), 21 | ("date_created", models.DateTimeField(auto_now_add=True)), 22 | ("last_updated", models.DateTimeField(auto_now=True)), 23 | ( 24 | "portfolio", 25 | models.ForeignKey( 26 | on_delete=django.db.models.deletion.CASCADE, 27 | related_name="log_messages", 28 | to="portfolios.portfolio", 29 | ), 30 | ), 31 | ], 32 | options={ 33 | "verbose_name": "Log Message", 34 | "verbose_name_plural": "Log Messages", 35 | }, 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MantineProvider } from "@mantine/core"; 3 | import { QueryClientProvider } from "@tanstack/react-query"; 4 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 5 | import dayjs from "dayjs"; 6 | import customParseFormat from "dayjs/plugin/customParseFormat"; 7 | import timezone from "dayjs/plugin/timezone"; 8 | import utc from "dayjs/plugin/utc"; // ES 2015 9 | import ReactDOM from "react-dom/client"; 10 | import "./i18n"; 11 | import queryClient from "api/query-client"; 12 | import App from "App"; 13 | import "@mantine/core/styles.css"; 14 | import "@mantine/dropzone/styles.css"; 15 | import "@mantine/dates/styles.css"; 16 | import "mantine-react-table/styles.css"; 17 | import "@mantine/charts/styles.css"; 18 | import "@mantine/tiptap/styles.css"; 19 | import "@mantine/notifications/styles.css"; 20 | 21 | // dependent on utc plugin 22 | dayjs.extend(utc); 23 | dayjs.extend(timezone); 24 | dayjs.extend(customParseFormat); 25 | 26 | const root = ReactDOM.createRoot( 27 | document.getElementById("root") as HTMLElement, 28 | ); 29 | 30 | root.render( 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | , 39 | ); 40 | -------------------------------------------------------------------------------- /client/src/pages/portfolios/PorfolioChartsPage/components/ChartsList/components/ChartInvestedByCompany/ChartInvestedByCompanyProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { useParams } from "react-router-dom"; 3 | import { Center, Loader, Stack, Title } from "@mantine/core"; 4 | import ChartInvestedByCompany from "./ChartInvestedByCompany"; 5 | import { usePortfolioYearStatsByCompany } from "hooks/use-stats/use-portfolio-stats"; 6 | 7 | type Props = { selectedYear: string; currency: string }; 8 | 9 | export default function ChartInvestedByCompanyProvider({ 10 | selectedYear, 11 | currency, 12 | }: Props) { 13 | const { id } = useParams(); 14 | const { t } = useTranslation(); 15 | 16 | // Hooks 17 | const { 18 | data: statsData, 19 | isLoading, 20 | isError, 21 | error, 22 | } = usePortfolioYearStatsByCompany(+id!, selectedYear); 23 | 24 | if (isLoading) { 25 | return ; 26 | } 27 | if (isError) { 28 | return
    {error.message ? error.message : t("An error occurred")}
    ; 29 | } 30 | 31 | if (statsData) { 32 | return ( 33 | 34 |
    35 | {t("Accumulated investment")} 36 |
    37 |
    38 | 39 |
    40 |
    41 | ); 42 | } 43 | return null; 44 | } 45 | -------------------------------------------------------------------------------- /client/src/pages/portfolios/PorfolioChartsPage/components/ChartsList/components/ChartSectorsByCompany/ChartSectorsByCompanyProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { useParams } from "react-router-dom"; 3 | import { Center, Loader, Stack, Title } from "@mantine/core"; 4 | import { useElementSize } from "@mantine/hooks"; 5 | import ChartSectorsByCompany from "./ChartSectorsByCompany"; 6 | import { usePortfolioYearStatsByCompany } from "hooks/use-stats/use-portfolio-stats"; 7 | 8 | type Props = { selectedYear: string }; 9 | 10 | export default function ChartSectorsByCompanyProvider({ selectedYear }: Props) { 11 | const { t } = useTranslation(); 12 | const { id } = useParams(); 13 | const { 14 | data: statsData, 15 | isLoading, 16 | isError, 17 | error, 18 | } = usePortfolioYearStatsByCompany(+id!, selectedYear); 19 | const { ref, width } = useElementSize(); 20 | 21 | if (isLoading) { 22 | return ; 23 | } 24 | if (isError) { 25 | return
    {error.message ? error.message : t("An error occurred")}
    ; 26 | } 27 | 28 | if (statsData) { 29 | return ( 30 | 31 |
    32 | {t("Sectors")} 33 |
    34 |
    35 | 36 |
    37 |
    38 | ); 39 | } 40 | return null; 41 | } 42 | -------------------------------------------------------------------------------- /client/src/pages/portfolios/PorfolioChartsPage/components/ChartsList/components/ChartMarketByCompany/ChartMarketsByCompanyProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { useParams } from "react-router-dom"; 3 | import { Center, Loader, Stack, Title } from "@mantine/core"; 4 | import { useElementSize } from "@mantine/hooks"; 5 | import ChartMarketByCompany from "./ChartMarketByCompany"; 6 | import { usePortfolioYearStatsByCompany } from "hooks/use-stats/use-portfolio-stats"; 7 | 8 | interface Props { 9 | selectedYear: string; 10 | } 11 | 12 | export default function ChartMarketsByCompanyProvider({ selectedYear }: Props) { 13 | const { t } = useTranslation(); 14 | const { id } = useParams(); 15 | const { 16 | data: statsData, 17 | isLoading, 18 | isError, 19 | error, 20 | } = usePortfolioYearStatsByCompany(+id!, selectedYear); 21 | const { ref, width } = useElementSize(); 22 | if (isLoading) { 23 | return ; 24 | } 25 | if (isError) { 26 | return
    {error.message ? error.message : t("An error occurred")}
    ; 27 | } 28 | 29 | if (statsData) { 30 | return ( 31 | 32 |
    33 | {t("Markets")} 34 |
    35 |
    36 | 37 |
    38 |
    39 | ); 40 | } 41 | return null; 42 | } 43 | -------------------------------------------------------------------------------- /client/src/pages/portfolios/PorfolioChartsPage/components/ChartsList/components/ChartInvestedByCompanyYearly/ChartInvestedByCompanyYearlyProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { useParams } from "react-router-dom"; 3 | import { Center, Loader, Stack, Title } from "@mantine/core"; 4 | import ChartInvestedByCompanyYearly from "./ChartInvestedByCompanyYearly"; 5 | import { usePortfolioYearStatsByCompany } from "hooks/use-stats/use-portfolio-stats"; 6 | 7 | type Props = { selectedYear: string; currency: string }; 8 | 9 | export default function ChartInvestedByCompanyYearlyProvider({ 10 | selectedYear, 11 | currency, 12 | }: Props) { 13 | const { id } = useParams(); 14 | const { t } = useTranslation(); 15 | 16 | const { 17 | data: statsData, 18 | isLoading, 19 | isError, 20 | error, 21 | } = usePortfolioYearStatsByCompany(+id!, selectedYear); 22 | 23 | if (isLoading) { 24 | return ; 25 | } 26 | if (isError) { 27 | return
    {error.message ? error.message : t("An error occurred")}
    ; 28 | } 29 | 30 | if (statsData) { 31 | return ( 32 | 33 |
    34 | {t("Invested by company yearly")} 35 |
    36 |
    37 | 38 |
    39 |
    40 | ); 41 | } 42 | return null; 43 | } 44 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "files.associations": { 4 | "*.html": "jinja-html", 5 | "*.xml": "jinja-xml" 6 | }, 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll": "explicit" 9 | }, 10 | "eslint.validate": [ 11 | "javascript" 12 | ], 13 | "isort.check": true, 14 | "isort.args": [ 15 | "--profile", 16 | "black" 17 | ], 18 | "[html]": { 19 | "editor.defaultFormatter": "monosans.djlint" 20 | }, 21 | "[django-html]": { 22 | "editor.defaultFormatter": "monosans.djlint" 23 | }, 24 | "[jinja]": { 25 | "editor.defaultFormatter": "monosans.djlint" 26 | }, 27 | "[jinja-html]": { 28 | "editor.defaultFormatter": "monosans.djlint" 29 | }, 30 | "python.analysis.autoSearchPaths": true, 31 | "python.analysis.extraPaths": ["backend"], 32 | "python.analysis.ignore": [ 33 | "**/site-packages/**/*.py", 34 | "**/migrations/*.py" 35 | ], 36 | "python.autoComplete.extraPaths": [ 37 | ".venv", 38 | "backend" 39 | ], 40 | "python.terminal.activateEnvInCurrentTerminal": true, 41 | "python.testing.pytestEnabled": true, 42 | "[python]": { 43 | "editor.codeActionsOnSave": { 44 | "source.organizeImports": "explicit" 45 | } 46 | }, 47 | "search.exclude": { 48 | "**/.venv": true, 49 | "**/node_modules": true, 50 | "**/mypy_cache": true, 51 | "**/package-lock.json": true, 52 | "**/poetry.lock": true, 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /client/src/pages/portfolios/PorfolioChartsPage/components/ChartsList/components/ChartBrokerByCompany/ChartBrokerByCompanyProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { useParams } from "react-router-dom"; 3 | import { Center, Loader, Stack, Title } from "@mantine/core"; 4 | import { useElementSize } from "@mantine/hooks"; 5 | import ChartBrokerByCompany from "./ChartBrokerByCompany"; 6 | import { usePortfolioYearStatsByCompany } from "hooks/use-stats/use-portfolio-stats"; 7 | 8 | interface Props { 9 | selectedYear: string; 10 | } 11 | 12 | export default function ChartBrokerByCompanyProvider({ selectedYear }: Props) { 13 | const { t } = useTranslation(); 14 | const { id } = useParams(); 15 | 16 | const { 17 | data: statsData, 18 | isLoading, 19 | isError, 20 | error, 21 | } = usePortfolioYearStatsByCompany(+id!, selectedYear); 22 | const { ref, width } = useElementSize(); 23 | 24 | if (isLoading) { 25 | return ; 26 | } 27 | if (isError) { 28 | return
    {error.message ? error.message : t("An error occurred")}
    ; 29 | } 30 | 31 | if (statsData) { 32 | return ( 33 | 34 |
    35 | {t("Brokers")} 36 |
    37 |
    38 | 39 |
    40 |
    41 | ); 42 | } 43 | return null; 44 | } 45 | -------------------------------------------------------------------------------- /client/src/pages/portfolios/PorfolioChartsPage/components/ChartsList/components/ChartValueByCompany/ChartValueByCompanyProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { useParams } from "react-router-dom"; 4 | import { Center, Loader, Stack, Title } from "@mantine/core"; 5 | import ChartValueByCompany from "./ChartValueByCompany"; 6 | import { usePortfolioYearStatsByCompany } from "hooks/use-stats/use-portfolio-stats"; 7 | 8 | type Props = { selectedYear: string; currency: string }; 9 | 10 | export default function ChartValueByCompanyProvider({ 11 | selectedYear, 12 | currency, 13 | }: Props) { 14 | const { id } = useParams(); 15 | const { t } = useTranslation(); 16 | const { 17 | data: statsData, 18 | isLoading, 19 | isError, 20 | error, 21 | } = usePortfolioYearStatsByCompany(+id!, selectedYear); 22 | if (isLoading) { 23 | return ; 24 | } 25 | if (isError) { 26 | return
    {error.message ? error.message : t("An error occurred")}
    ; 27 | } 28 | if (statsData) { 29 | return ( 30 | 31 |
    32 | 33 | {t("Portfolio value by company (accumulated)")} 34 | 35 |
    36 |
    37 | 38 |
    39 |
    40 | ); 41 | } 42 | return null; 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/django.yml: -------------------------------------------------------------------------------- 1 | name: Django CI 2 | 3 | on: 4 | push: 5 | branches: [main, develop] 6 | pull_request: 7 | branches: [main, develop] 8 | 9 | jobs: 10 | build: 11 | env: 12 | DJANGO_ENV: test 13 | runs-on: ubuntu-latest 14 | strategy: 15 | max-parallel: 4 16 | matrix: 17 | python-version: [3.11.9] 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install poetry 26 | uses: abatilo/actions-poetry@v2 27 | - uses: actions/cache@v3 28 | name: Define a cache for the virtual environment based on the dependencies lock file 29 | with: 30 | path: ./.venv 31 | key: venv-${{ hashFiles('poetry.lock') }} 32 | - name: Install the project dependencies 33 | run: | 34 | mv .env.ci.sample .env 35 | poetry install 36 | - name: Run Tests 37 | run: | 38 | cd backend && poetry run coverage run manage.py test 39 | poetry run coverage report 40 | poetry run coverage xml 41 | - name: Upload coverage to Codecov 42 | uses: codecov/codecov-action@v4 43 | with: 44 | token: ${{ secrets.CODECOV_TOKEN }} # required 45 | verbose: true # optional (default = false) 46 | --------------------------------------------------------------------------------