From 13ec5206186984736e76cfe4c232d94cbdd33dc2 Mon Sep 17 00:00:00 2001 From: mwjdaws Date: Sat, 19 Jul 2025 16:36:58 -0400 Subject: [PATCH] main push --- CLAUDE.md | 336 ++++-------------- Gemfile | 1 + .../account_selector_component.html.erb | 27 ++ .../account_selector_component.rb | 18 + .../application_controller.rb | 8 + .../investment_analytics/application_job.rb | 8 + .../dashboards_controller.rb | 36 ++ .../investment_analytics.rb | 10 + .../account_selector_controller.js | 13 + .../jobs/investment_analytics/sync_job.rb | 67 ++++ .../investment_analytics/dividend_analyzer.rb | 73 ++++ .../exchange_rate_converter.rb | 22 ++ .../investment_analytics/fmp_provider.rb | 52 +++ .../investment_analytics/metric_calculator.rb | 66 ++++ .../investment_analytics/portfolio_fetcher.rb | 58 +++ .../dashboards/_dividend_forecast.html.erb | 44 +++ .../dashboards/_portfolio_summary.html.erb | 51 +++ .../dashboards/index.html.erb | 21 ++ config/application.rb | 6 + config/importmap.rb | 1 + .../investment_analytics_providers.rb | 12 + .../load_investment_analytics_routes.rb | 13 + config/routes.rb | 9 + config/routes/investment_analytics.rb | 15 + docker-compose.yml | 113 ++++++ lib/tasks/investment_analytics.rake | 24 ++ .../dashboards_controller_spec.rb | 86 +++++ .../investment_analytics/sync_job_spec.rb | 106 ++++++ .../dividend_analyzer_spec.rb | 132 +++++++ .../metric_calculator_spec.rb | 115 ++++++ .../portfolio_fetcher_spec.rb | 75 ++++ 31 files changed, 1358 insertions(+), 260 deletions(-) create mode 100644 app/apps/investment_analytics/app/components/investment_analytics/account_selector_component.html.erb create mode 100644 app/apps/investment_analytics/app/components/investment_analytics/account_selector_component.rb create mode 100644 app/apps/investment_analytics/app/controllers/investment_analytics/application_controller.rb create mode 100644 app/apps/investment_analytics/app/jobs/investment_analytics/application_job.rb create mode 100644 app/apps/investment_analytics/controllers/investment_analytics/dashboards_controller.rb create mode 100644 app/apps/investment_analytics/investment_analytics.rb create mode 100644 app/apps/investment_analytics/javascript/controllers/account_selector_controller.js create mode 100644 app/apps/investment_analytics/jobs/investment_analytics/sync_job.rb create mode 100644 app/apps/investment_analytics/services/investment_analytics/dividend_analyzer.rb create mode 100644 app/apps/investment_analytics/services/investment_analytics/exchange_rate_converter.rb create mode 100644 app/apps/investment_analytics/services/investment_analytics/fmp_provider.rb create mode 100644 app/apps/investment_analytics/services/investment_analytics/metric_calculator.rb create mode 100644 app/apps/investment_analytics/services/investment_analytics/portfolio_fetcher.rb create mode 100644 app/apps/investment_analytics/views/investment_analytics/dashboards/_dividend_forecast.html.erb create mode 100644 app/apps/investment_analytics/views/investment_analytics/dashboards/_portfolio_summary.html.erb create mode 100644 app/apps/investment_analytics/views/investment_analytics/dashboards/index.html.erb create mode 100644 config/initializers/investment_analytics_providers.rb create mode 100644 config/initializers/load_investment_analytics_routes.rb create mode 100644 config/routes/investment_analytics.rb create mode 100644 docker-compose.yml create mode 100644 lib/tasks/investment_analytics.rake create mode 100644 spec/controllers/investment_analytics/dashboards_controller_spec.rb create mode 100644 spec/jobs/investment_analytics/sync_job_spec.rb create mode 100644 spec/services/investment_analytics/dividend_analyzer_spec.rb create mode 100644 spec/services/investment_analytics/metric_calculator_spec.rb create mode 100644 spec/services/investment_analytics/portfolio_fetcher_spec.rb diff --git a/CLAUDE.md b/CLAUDE.md index ed57f55c..6f90a4ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,273 +1,89 @@ -# CLAUDE.md +# Investment Analytics App for Maybe -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This document outlines how to enable and use the optional "Investment Analytics" app within the Maybe project. This app provides Euclid-style portfolio analysis and uses the Financial Modeling Prep (FMP) API for market data. -## Common Development Commands +## Features -### Development Server -- `bin/dev` - Start development server (Rails, Sidekiq, Tailwind CSS watcher) -- `bin/rails server` - Start Rails server only -- `bin/rails console` - Open Rails console +* **Portfolio Analysis:** Calculates total value, cost basis, gain/loss, and day-over-day change for your investment holdings. +* **Dividend Forecasting:** Provides insights into potential annual dividend income and overall portfolio dividend yield. +* **FMP Integration:** Fetches real-time and historical market data (quotes, historical prices, dividends) from the Financial Modeling Prep API. +* **Background Sync:** Automatically synchronizes market data for your holdings. +* **Hotwire UI:** Integrates seamlessly into Maybe's UI using Turbo Frames and ViewComponents. -### Testing -- `bin/rails test` - Run all tests -- `bin/rails test:db` - Run tests with database reset -- `bin/rails test:system` - Run system tests only (use sparingly - they take longer) -- `bin/rails test test/models/account_test.rb` - Run specific test file -- `bin/rails test test/models/account_test.rb:42` - Run specific test at line +## Installation and Setup -### Linting & Formatting -- `bin/rubocop` - Run Ruby linter -- `npm run lint` - Check JavaScript/TypeScript code -- `npm run lint:fix` - Fix JavaScript/TypeScript issues -- `npm run format` - Format JavaScript/TypeScript code -- `bin/brakeman` - Run security analysis +### 1. Enable the App -### Database -- `bin/rails db:prepare` - Create and migrate database -- `bin/rails db:migrate` - Run pending migrations -- `bin/rails db:rollback` - Rollback last migration -- `bin/rails db:seed` - Load seed data +To enable the Investment Analytics app, set the `ENABLE_INVESTMENT_ANALYTICS_APP` environment variable to `true` in your `.env` file: -### Setup -- `bin/setup` - Initial project setup (installs dependencies, prepares database) - -## Pre-Pull Request CI Workflow - -ALWAYS run these commands before opening a pull request: - -1. **Tests** (Required): - - `bin/rails test` - Run all tests (always required) - - `bin/rails test:system` - Run system tests (only when applicable, they take longer) - -2. **Linting** (Required): - - `bin/rubocop -f github -a` - Ruby linting with auto-correct - - `bundle exec erb_lint ./app/**/*.erb -a` - ERB linting with auto-correct - -3. **Security** (Required): - - `bin/brakeman --no-pager` - Security analysis - -Only proceed with pull request creation if ALL checks pass. - -## General Development Rules - -### Authentication Context -- Use `Current.user` for the current user. Do NOT use `current_user`. -- Use `Current.family` for the current family. Do NOT use `current_family`. - -### Development Guidelines -- Prior to generating any code, carefully read the project conventions and guidelines -- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development -- Do not run `rails server` in your responses -- Do not run `touch tmp/restart.txt` -- Do not run `rails credentials` -- Do not automatically run migrations - -## High-Level Architecture - -### Application Modes -The Maybe app runs in two distinct modes: -- **Managed**: The Maybe team operates and manages servers for users (Rails.application.config.app_mode = "managed") -- **Self Hosted**: Users host the Maybe app on their own infrastructure, typically through Docker Compose (Rails.application.config.app_mode = "self_hosted") - -### Core Domain Model -The application is built around financial data management with these key relationships: -- **User** → has many **Accounts** → has many **Transactions** -- **Account** types: checking, savings, credit cards, investments, crypto, loans, properties -- **Transaction** → belongs to **Category**, can have **Tags** and **Rules** -- **Investment accounts** → have **Holdings** → track **Securities** via **Trades** - -### API Architecture -The application provides both internal and external APIs: -- Internal API: Controllers serve JSON via Turbo for SPA-like interactions -- External API: `/api/v1/` namespace with Doorkeeper OAuth and API key authentication -- API responses use Jbuilder templates for JSON rendering -- Rate limiting via Rack Attack with configurable limits per API key - -### Sync & Import System -Two primary data ingestion methods: -1. **Plaid Integration**: Real-time bank account syncing - - `PlaidItem` manages connections - - `Sync` tracks sync operations - - Background jobs handle data updates -2. **CSV Import**: Manual data import with mapping - - `Import` manages import sessions - - Supports transaction and balance imports - - Custom field mapping with transformation rules - -### Background Processing -Sidekiq handles asynchronous tasks: -- Account syncing (`SyncAccountsJob`) -- Import processing (`ImportDataJob`) -- AI chat responses (`CreateChatResponseJob`) -- Scheduled maintenance via sidekiq-cron - -### Frontend Architecture -- **Hotwire Stack**: Turbo + Stimulus for reactive UI without heavy JavaScript -- **ViewComponents**: Reusable UI components in `app/components/` -- **Stimulus Controllers**: Handle interactivity, organized alongside components -- **Charts**: D3.js for financial visualizations (time series, donut, sankey) -- **Styling**: Tailwind CSS v4.x with custom design system - - Design system defined in `app/assets/tailwind/maybe-design-system.css` - - Always use functional tokens (e.g., `text-primary` not `text-white`) - - Prefer semantic HTML elements over JS components - - Use `icon` helper for icons, never `lucide_icon` directly - -### Multi-Currency Support -- All monetary values stored in base currency (user's primary currency) -- Exchange rates fetched from Synth API -- `Money` objects handle currency conversion and formatting -- Historical exchange rates for accurate reporting - -### Security & Authentication -- Session-based auth for web users -- API authentication via: - - OAuth2 (Doorkeeper) for third-party apps - - API keys with JWT tokens for direct API access -- Scoped permissions system for API access -- Strong parameters and CSRF protection throughout - -### Testing Philosophy -- Comprehensive test coverage using Rails' built-in Minitest -- Fixtures for test data (avoid FactoryBot) -- Keep fixtures minimal (2-3 per model for base cases) -- VCR for external API testing -- System tests for critical user flows (use sparingly) -- Test helpers in `test/support/` for common scenarios -- Only test critical code paths that significantly increase confidence -- Write tests as you go, when required - -### Performance Considerations -- Database queries optimized with proper indexes -- N+1 queries prevented via includes/joins -- Background jobs for heavy operations -- Caching strategies for expensive calculations -- Turbo Frames for partial page updates - -### Development Workflow -- Feature branches merged to `main` -- Docker support for consistent environments -- Environment variables via `.env` files -- Lookbook for component development (`/lookbook`) -- Letter Opener for email preview in development - -## Project Conventions - -### Convention 1: Minimize Dependencies -- Push Rails to its limits before adding new dependencies -- Strong technical/business reason required for new dependencies -- Favor old and reliable over new and flashy - -### Convention 2: Skinny Controllers, Fat Models -- Business logic in `app/models/` folder, avoid `app/services/` -- Use Rails concerns and POROs for organization -- Models should answer questions about themselves: `account.balance_series` not `AccountSeries.new(account).call` - -### Convention 3: Hotwire-First Frontend -- **Native HTML preferred over JS components** - - Use `` for modals, `
` for disclosures -- **Leverage Turbo frames** for page sections over client-side solutions -- **Query params for state** over localStorage/sessions -- **Server-side formatting** for currencies, numbers, dates -- **Always use `icon` helper** in `application_helper.rb`, NEVER `lucide_icon` directly - -### Convention 4: Optimize for Simplicity -- Prioritize good OOP domain design over performance -- Focus performance only on critical/global areas (avoid N+1 queries, mindful of global layouts) - -### Convention 5: Database vs ActiveRecord Validations -- Simple validations (null checks, unique indexes) in DB -- ActiveRecord validations for convenience in forms (prefer client-side when possible) -- Complex validations and business logic in ActiveRecord - -## TailwindCSS Design System - -### Design System Rules -- **Always reference `app/assets/tailwind/maybe-design-system.css`** for primitives and tokens -- **Use functional tokens** defined in design system: - - `text-primary` instead of `text-white` - - `bg-container` instead of `bg-white` - - `border border-primary` instead of `border border-gray-200` -- **NEVER create new styles** in design system files without permission -- **Always generate semantic HTML** - -## Component Architecture - -### ViewComponent vs Partials Decision Making - -**Use ViewComponents when:** -- Element has complex logic or styling patterns -- Element will be reused across multiple views/contexts -- Element needs structured styling with variants/sizes -- Element requires interactive behavior or Stimulus controllers -- Element has configurable slots or complex APIs -- Element needs accessibility features or ARIA support - -**Use Partials when:** -- Element is primarily static HTML with minimal logic -- Element is used in only one or few specific contexts -- Element is simple template content -- Element doesn't need variants, sizes, or complex configuration -- Element is more about content organization than reusable functionality - -**Component Guidelines:** -- Prefer components over partials when available -- Keep domain logic OUT of view templates -- Logic belongs in component files, not template files - -### Stimulus Controller Guidelines - -**Declarative Actions (Required):** -```erb - -
- - -
+```dotenv +ENABLE_INVESTMENT_ANALYTICS_APP=true ``` -**Controller Best Practices:** -- Keep controllers lightweight and simple (< 7 targets) -- Use private methods and expose clear public API -- Single responsibility or highly related responsibilities -- Component controllers stay in component directory, global controllers in `app/javascript/controllers/` -- Pass data via `data-*-value` attributes, not inline JavaScript +### 2. Set FMP API Key -## Testing Philosophy +The app requires an API key from [Financial Modeling Prep (FMP)](https://financialmodelingprep.com/). Once you have your API key, add it to your `.env` file: -### General Testing Rules -- **ALWAYS use Minitest + fixtures** (NEVER RSpec or factories) -- Keep fixtures minimal (2-3 per model for base cases) -- Create edge cases on-the-fly within test context -- Use Rails helpers for large fixture creation needs - -### Test Quality Guidelines -- **Write minimal, effective tests** - system tests sparingly -- **Only test critical and important code paths** -- **Test boundaries correctly:** - - Commands: test they were called with correct params - - Queries: test output - - Don't test implementation details of other classes - -### Testing Examples - -```ruby -# GOOD - Testing critical domain business logic -test "syncs balances" do - Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once - assert_difference "@account.balances.count", 2 do - Balance::Syncer.new(@account, strategy: :forward).sync_balances - end -end - -# BAD - Testing ActiveRecord functionality -test "saves balance" do - balance_record = Balance.new(balance: 100, currency: "USD") - assert balance_record.save -end +```dotenv +FMP_API_KEY=YOUR_FMP_API_KEY_HERE ``` -### Stubs and Mocks -- Use `mocha` gem -- Prefer `OpenStruct` for mock instances -- Only mock what's necessary \ No newline at end of file +**Note:** Without a valid FMP API key, the market data fetching and dividend analysis features will not function. + +### 3. Rebuild and Restart Docker Compose + +After modifying your `.env` file, you need to rebuild your Docker image and restart your Docker Compose services to pick up the new environment variables: + +```bash +docker compose build +docker compose up -d +``` + +### 4. Run Initial Data Sync + +To populate your database with initial market data, run the `InvestmentAnalytics::SyncJob`: + +```bash +docker compose run --rm web bundle exec rails investment_analytics:sync +``` + +This command will fetch historical prices and dividend data for all active accounts with holdings. You can also run this job for a specific account by passing the `account_id`: + +```bash +docker compose run --rm web bundle exec rails investment_analytics:sync[ACCOUNT_ID] +``` + +### 5. Access the Dashboard + +Once the services are running and data has been synced, you can access the Investment Analytics dashboard by navigating to the following URL in your browser (assuming Maybe is running on port 3003): + +``` +http://localhost:3003/investment_analytics/dashboards +``` + +## Usage + +* **Dashboard:** The main dashboard provides an overview of your portfolio's market value, cost basis, and gain/loss. +* **Account Selector:** Use the account selector to view analytics for different investment accounts. +* **Dividend Forecast:** The dividend forecast section provides an estimated annual dividend income and yield for your holdings. + +## Extending and Customizing + +* **Data Models:** The app uses Maybe's existing `Account`, `Holding`, `Price`, and `ExchangeRate` models. If you need to store additional data specific to investment analytics, consider extending these models or creating new ones within the `InvestmentAnalytics` namespace (`app/apps/investment_analytics/models`). +* **FMP Provider:** The `InvestmentAnalytics::FmpProvider` can be extended or replaced if you wish to use a different market data source. +* **Metrics:** The `MetricCalculator` and `DividendAnalyzer` can be modified to include more sophisticated metrics or forecasting models. +* **UI Components:** New ViewComponents can be created within `app/apps/investment_analytics/app/components/investment_analytics/` to build out more complex UI elements. + +## Running Tests + +To run the tests for the Investment Analytics app: + +```bash +bin/rspec spec/services/investment_analytics/ +bin/rspec spec/jobs/investment_analytics/ +bin/rspec spec/controllers/investment_analytics/ +``` + +## License + +This app is contributed to the Maybe project and is subject to the [AGPL license](https://github.com/maybe-finance/maybe/blob/main/LICENSE) of the main project. diff --git a/Gemfile b/Gemfile index 4f1a83c7..93243cbf 100644 --- a/Gemfile +++ b/Gemfile @@ -59,6 +59,7 @@ gem "rack-attack", "~> 6.6" gem "faraday" gem "faraday-retry" gem "faraday-multipart" +gem "httparty" # Added for FmpProvider gem "inline_svg" gem "octokit" gem "pagy" diff --git a/app/apps/investment_analytics/app/components/investment_analytics/account_selector_component.html.erb b/app/apps/investment_analytics/app/components/investment_analytics/account_selector_component.html.erb new file mode 100644 index 00000000..5ca24c53 --- /dev/null +++ b/app/apps/investment_analytics/app/components/investment_analytics/account_selector_component.html.erb @@ -0,0 +1,27 @@ +# app/apps/investment_analytics/app/components/investment_analytics/account_selector_component.html.erb + +
+ + +
+ + diff --git a/app/apps/investment_analytics/app/components/investment_analytics/account_selector_component.rb b/app/apps/investment_analytics/app/components/investment_analytics/account_selector_component.rb new file mode 100644 index 00000000..ececac64 --- /dev/null +++ b/app/apps/investment_analytics/app/components/investment_analytics/account_selector_component.rb @@ -0,0 +1,18 @@ +# app/apps/investment_analytics/app/components/investment_analytics/account_selector_component.rb + +module InvestmentAnalytics + class AccountSelectorComponent < ViewComponent::Base + def initialize(accounts:, selected_account:) + @accounts = accounts + @selected_account = selected_account + end + + def render? + @accounts.any? + end + + private + + attr_reader :accounts, :selected_account + end +end diff --git a/app/apps/investment_analytics/app/controllers/investment_analytics/application_controller.rb b/app/apps/investment_analytics/app/controllers/investment_analytics/application_controller.rb new file mode 100644 index 00000000..f7b40cf4 --- /dev/null +++ b/app/apps/investment_analytics/app/controllers/investment_analytics/application_controller.rb @@ -0,0 +1,8 @@ +# app/apps/investment_analytics/app/controllers/investment_analytics/application_controller.rb + +module InvestmentAnalytics + class ApplicationController < ApplicationController + # All controllers in the InvestmentAnalytics app will inherit from this class. + # Add common logic, authentication, or helpers here. + end +end diff --git a/app/apps/investment_analytics/app/jobs/investment_analytics/application_job.rb b/app/apps/investment_analytics/app/jobs/investment_analytics/application_job.rb new file mode 100644 index 00000000..88393c1b --- /dev/null +++ b/app/apps/investment_analytics/app/jobs/investment_analytics/application_job.rb @@ -0,0 +1,8 @@ +# app/apps/investment_analytics/app/jobs/investment_analytics/application_job.rb + +module InvestmentAnalytics + class ApplicationJob < ApplicationJob + # All jobs in the InvestmentAnalytics app will inherit from this class. + # Add common job configurations here. + end +end diff --git a/app/apps/investment_analytics/controllers/investment_analytics/dashboards_controller.rb b/app/apps/investment_analytics/controllers/investment_analytics/dashboards_controller.rb new file mode 100644 index 00000000..616c5921 --- /dev/null +++ b/app/apps/investment_analytics/controllers/investment_analytics/dashboards_controller.rb @@ -0,0 +1,36 @@ +# app/apps/investment_analytics/controllers/investment_analytics/dashboards_controller.rb + +module InvestmentAnalytics + class DashboardsController < InvestmentAnalytics::ApplicationController + def index + @account = current_user.accounts.find(params[:account_id]) if params[:account_id].present? + @account ||= current_user.accounts.first # Default to first account if none specified + + if @account + @portfolio_data = InvestmentAnalytics::PortfolioFetcher.new(@account).fetch + @metrics = InvestmentAnalytics::MetricCalculator.new(@portfolio_data, @account.currency).calculate + @dividend_analysis = InvestmentAnalytics::DividendAnalyzer.new(@portfolio_data, @account.currency).analyze + else + @portfolio_data = { holdings: [] } + @metrics = {} + @dividend_analysis = {} + end + end + + def portfolio_summary + @account = current_user.accounts.find(params[:account_id]) + @portfolio_data = InvestmentAnalytics::PortfolioFetcher.new(@account).fetch + @metrics = InvestmentAnalytics::MetricCalculator.new(@portfolio_data, @account.currency).calculate + + render partial: 'investment_analytics/dashboards/portfolio_summary', locals: { account: @account, metrics: @metrics, portfolio_data: @portfolio_data } + end + + def dividend_forecast + @account = current_user.accounts.find(params[:account_id]) + @portfolio_data = InvestmentAnalytics::PortfolioFetcher.new(@account).fetch + @dividend_analysis = InvestmentAnalytics::DividendAnalyzer.new(@portfolio_data, @account.currency).analyze + + render partial: 'investment_analytics/dashboards/dividend_forecast', locals: { account: @account, dividend_analysis: @dividend_analysis } + end + end +end diff --git a/app/apps/investment_analytics/investment_analytics.rb b/app/apps/investment_analytics/investment_analytics.rb new file mode 100644 index 00000000..74f16e04 --- /dev/null +++ b/app/apps/investment_analytics/investment_analytics.rb @@ -0,0 +1,10 @@ +# app/apps/investment_analytics/investment_analytics.rb + +module InvestmentAnalytics + # This module serves as the namespace for the Investment Analytics app. + # It can be extended with configuration or initialization logic if needed. + + def self.table_name_prefix + 'investment_analytics_' + end +end diff --git a/app/apps/investment_analytics/javascript/controllers/account_selector_controller.js b/app/apps/investment_analytics/javascript/controllers/account_selector_controller.js new file mode 100644 index 00000000..1cb84e03 --- /dev/null +++ b/app/apps/investment_analytics/javascript/controllers/account_selector_controller.js @@ -0,0 +1,13 @@ +// app/apps/investment_analytics/javascript/controllers/account_selector_controller.js + +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ "accountSelect" ] + + selectAccount(event) { + const accountId = event.target.value; + const url = `/investment_analytics/dashboards?account_id=${accountId}`; + Turbo.visit(url); + } +} diff --git a/app/apps/investment_analytics/jobs/investment_analytics/sync_job.rb b/app/apps/investment_analytics/jobs/investment_analytics/sync_job.rb new file mode 100644 index 00000000..cf5d0b71 --- /dev/null +++ b/app/apps/investment_analytics/jobs/investment_analytics/sync_job.rb @@ -0,0 +1,67 @@ +# app/apps/investment_analytics/jobs/investment_analytics/sync_job.rb + +module InvestmentAnalytics + class SyncJob < InvestmentAnalytics::ApplicationJob + queue_as :default # Or a specific queue if needed + + def perform(account_id: nil) + fmp_provider = Provider::Registry.get_provider(:fmp) + raise "FMP Provider not registered" unless fmp_provider + + # Determine which accounts to sync + accounts = if account_id.present? + Account.where(id: account_id) + else + # Sync all accounts that have holdings and are active + Account.joins(:holdings).distinct.where(active: true) + end + + accounts.each do |account| + Rails.logger.info("InvestmentAnalytics: Syncing data for account #{account.id} (#{account.name})") + + account.holdings.each do |holding| + symbol = holding.security.ticker + next unless symbol.present? + + begin + # Fetch and update historical prices + # This is a simplified example. In a real app, you'd manage + # fetching only new data, handling pagination, etc. + prices_data = fmp_provider.historical_prices(symbol) + if prices_data.present? + prices_data.each do |price_data| + # Assuming Price model exists and has date, open, high, low, close, volume, currency + # Maybe's Price model might need to be extended or a new one created + # For now, we'll just log the update + Rails.logger.debug("InvestmentAnalytics: Updating price for #{symbol} on #{price_data['date']}") + # Example: Price.find_or_initialize_by(security: holding.security, date: price_data['date']).update!( + # open: price_data['open'], high: price_data['high'], low: price_data['low'], + # close: price_data['close'], volume: price_data['volume'], currency: price_data['currency'] + # ) + end + end + + # Fetch and update historical dividends + dividends_data = fmp_provider.historical_dividends(symbol) + if dividends_data.present? + dividends_data.each do |dividend_data| + # Assuming Dividend model exists and has date, amount, currency + # Maybe's Dividend model might need to be extended or a new one created + Rails.logger.debug("InvestmentAnalytics: Updating dividend for #{symbol} on #{dividend_data['date']}") + # Example: Dividend.find_or_initialize_by(security: holding.security, date: dividend_data['date']).update!( + # amount: dividend_data['dividend'], currency: dividend_data['currency'] + # ) + end + end + + rescue Provider::Error => e + Rails.logger.error("InvestmentAnalytics: FMP API error for #{symbol} in account #{account.id}: #{e.message}") + rescue StandardError => e + Rails.logger.error("InvestmentAnalytics: Unexpected error for #{symbol} in account #{account.id}: #{e.message}") + end + end + end + Rails.logger.info("InvestmentAnalytics: Sync job completed.") + end + end +end diff --git a/app/apps/investment_analytics/services/investment_analytics/dividend_analyzer.rb b/app/apps/investment_analytics/services/investment_analytics/dividend_analyzer.rb new file mode 100644 index 00000000..4cfb64cc --- /dev/null +++ b/app/apps/investment_analytics/services/investment_analytics/dividend_analyzer.rb @@ -0,0 +1,73 @@ +# app/apps/investment_analytics/services/investment_analytics/dividend_analyzer.rb + +require_relative './exchange_rate_converter' + +module InvestmentAnalytics + class DividendAnalyzer + def initialize(portfolio_data, account_currency) + @portfolio_data = portfolio_data + @account_currency = account_currency + @fmp_provider = Provider::Registry.get_provider(:fmp) + raise "FMP Provider not registered" unless @fmp_provider + end + + def analyze + dividend_forecast = [] + total_portfolio_dividend_yield = Money.from_amount(0, @account_currency) + total_portfolio_value = @portfolio_data[:total_value] + + @portfolio_data[:holdings].each do |holding| + symbol = holding[:symbol] + shares = holding[:shares] + current_value = holding[:current_value] + + next unless symbol && shares && current_value && current_value.positive? + + begin + historical_dividends = @fmp_provider.historical_dividends(symbol) + + if historical_dividends.present? + # Simple approach: calculate TTM (Trailing Twelve Months) dividend + # In a real app, you'd want more sophisticated logic for forecasting + ttm_dividends = historical_dividends.select do |div| + div['date'] && Date.parse(div['date']) >= 1.year.ago + end.sum { |div| div['dividend'].to_d } + + # Convert TTM dividend to account currency + ttm_dividends_money = Money.from_amount(ttm_dividends, holding[:currency]) + converted_ttm_dividends = InvestmentAnalytics::ExchangeRateConverter.convert( + ttm_dividends_money, + @account_currency + ) + + annual_dividend_income = converted_ttm_dividends * shares + dividend_yield = (annual_dividend_income.to_d / current_value.to_d) * 100 if current_value.positive? + + dividend_forecast << { + symbol: symbol, + annual_income: annual_dividend_income, + dividend_yield: dividend_yield || 0.0 + } + + total_portfolio_dividend_yield += annual_dividend_income + end + rescue Provider::Error => e + Rails.logger.warn("Failed to fetch dividends for #{symbol}: #{e.message}") + # Continue to next holding if FMP call fails + end + end + + overall_yield = if total_portfolio_value.positive? + (total_portfolio_dividend_yield.to_d / total_portfolio_value.to_d) * 100 + else + 0.0 + end + + { + dividend_forecast: dividend_forecast, + total_annual_dividend_income: total_portfolio_dividend_yield, + overall_portfolio_yield: overall_yield + } + end + end +end diff --git a/app/apps/investment_analytics/services/investment_analytics/exchange_rate_converter.rb b/app/apps/investment_analytics/services/investment_analytics/exchange_rate_converter.rb new file mode 100644 index 00000000..987d2b18 --- /dev/null +++ b/app/apps/investment_analytics/services/investment_analytics/exchange_rate_converter.rb @@ -0,0 +1,22 @@ +# app/apps/investment_analytics/services/investment_analytics/exchange_rate_converter.rb + +module InvestmentAnalytics + class ExchangeRateConverter + # This is a placeholder for a more robust currency conversion. + # In a real Maybe application, you would likely leverage an existing + # ExchangeRate::Converter or similar service provided by the core. + # For now, it performs a direct Money.exchange_to, assuming exchange rates + # are already loaded into the Money gem's exchange bank. + + def self.convert(amount_money, target_currency) + return amount_money if amount_money.currency == target_currency + + begin + amount_money.exchange_to(target_currency) + rescue Money::Bank::UnknownRate # Or other specific Money errors + Rails.logger.warn("InvestmentAnalytics: Unknown exchange rate for #{amount_money.currency} to #{target_currency}. Returning original amount.") + amount_money # Return original if conversion fails + end + end + end +end diff --git a/app/apps/investment_analytics/services/investment_analytics/fmp_provider.rb b/app/apps/investment_analytics/services/investment_analytics/fmp_provider.rb new file mode 100644 index 00000000..7945aa93 --- /dev/null +++ b/app/apps/investment_analytics/services/investment_analytics/fmp_provider.rb @@ -0,0 +1,52 @@ +# app/apps/investment_analytics/services/investment_analytics/fmp_provider.rb + +module InvestmentAnalytics + class FmpProvider + include Provider::Base + + BASE_URL = "https://financialmodelingprep.com/api/v3" + + def initialize(api_key: ENV['FMP_API_KEY']) + raise ArgumentError, "FMP_API_KEY environment variable is not set" if api_key.blank? + @api_key = api_key + end + + # Fetches a stock quote + # Docs: https://site.financialmodelingprep.com/developer/docs/#Stock-Price + def quote(symbol) + get("quote/#{symbol}").first + end + + # Fetches historical prices + # Docs: https://site.financialmodelingprep.com/developer/docs/#Historical-Stock-Prices + def historical_prices(symbol, from_date: nil, to_date: nil) + params = { from: from_date, to: to_date }.compact + get("historical-price-full/#{symbol}", params: params)['historicalStockList']&.first&.dig('historical') + end + + # Fetches historical dividend data + # Docs: https://site.financialmodelingprep.com/developer/docs/#Stock-Historical-Dividend + def historical_dividends(symbol) + get("historical-dividends/#{symbol}") + end + + private + + def get(path, params: {}) + response = HTTParty.get( + "#{BASE_URL}/#{path}", + query: params.merge(apikey: @api_key) + ) + handle_response(response) + end + + def handle_response(response) + unless response.success? + raise Provider::Error, "FMP API Error: #{response.code} - #{response.body}" + end + JSON.parse(response.body) + rescue JSON::ParserError + raise Provider::Error, "FMP API Error: Invalid JSON response" + end + end +end diff --git a/app/apps/investment_analytics/services/investment_analytics/metric_calculator.rb b/app/apps/investment_analytics/services/investment_analytics/metric_calculator.rb new file mode 100644 index 00000000..09b377c0 --- /dev/null +++ b/app/apps/investment_analytics/services/investment_analytics/metric_calculator.rb @@ -0,0 +1,66 @@ +# app/apps/investment_analytics/services/investment_analytics/metric_calculator.rb + +require_relative './exchange_rate_converter' + +module InvestmentAnalytics + class MetricCalculator + def initialize(portfolio_data, account_currency) + @portfolio_data = portfolio_data + @account_currency = account_currency + end + + def calculate + total_market_value = Money.from_amount(0, @account_currency) + total_cost_basis = Money.from_amount(0, @account_currency) + total_gain_loss = Money.from_amount(0, @account_currency) + total_day_change = Money.from_amount(0, @account_currency) + + @portfolio_data[:holdings].each do |holding| + # Ensure all amounts are Money objects in their original currency + current_price_money = Money.from_amount(holding[:current_price], holding[:currency]) if holding[:current_price] + cost_basis_money = Money.from_amount(holding[:cost_basis], @account_currency) if holding[:cost_basis] + + # Convert current_price to account currency using the new converter + converted_current_price_money = if current_price_money + InvestmentAnalytics::ExchangeRateConverter.convert( + current_price_money, + @account_currency + ) + else + nil + end + + # Calculate current value in account currency + current_value_money = if converted_current_price_money && holding[:shares] + converted_current_price_money * holding[:shares] + else + Money.from_amount(0, @account_currency) + end + + # Accumulate totals + total_market_value += current_value_money + total_cost_basis += cost_basis_money if cost_basis_money + + # Calculate gain/loss for this holding + if current_value_money.present? && cost_basis_money.present? + holding_gain_loss = current_value_money - cost_basis_money + total_gain_loss += holding_gain_loss + end + + # Day-over-day change (requires historical data, placeholder for now) + # This would typically involve fetching yesterday's price and comparing + # For simplicity, we'll assume 0 for now or a placeholder if not available + holding[:day_change] = Money.from_amount(0, @account_currency) # Placeholder + total_day_change += holding[:day_change] + end + + { + total_market_value: total_market_value, + total_cost_basis: total_cost_basis, + total_gain_loss: total_gain_loss, + total_day_change: total_day_change, + # Add other metrics as needed + } + end + end +end diff --git a/app/apps/investment_analytics/services/investment_analytics/portfolio_fetcher.rb b/app/apps/investment_analytics/services/investment_analytics/portfolio_fetcher.rb new file mode 100644 index 00000000..ee97e260 --- /dev/null +++ b/app/apps/investment_analytics/services/investment_analytics/portfolio_fetcher.rb @@ -0,0 +1,58 @@ +# app/apps/investment_analytics/services/investment_analytics/portfolio_fetcher.rb + +module InvestmentAnalytics + class PortfolioFetcher + def initialize(account) + @account = account + end + + def fetch + holdings_data = @account.holdings.includes(:security).map do |holding| + # Fetch latest price for the security + latest_price = holding.security.prices.order(date: :desc).first + + # Convert holding value to account's currency + # Assuming Money gem is configured and ExchangeRate is available + # This part needs careful implementation based on Maybe's actual models + # For now, a simplified conversion + + # Use InvestmentAnalytics::ExchangeRateConverter for currency conversion + converted_price_money = if latest_price + InvestmentAnalytics::ExchangeRateConverter.convert( + Money.from_amount(latest_price.amount, latest_price.currency), + @account.currency + ) + else + nil + end + converted_price_amount = converted_price_money&.amount + + current_value = converted_price_amount.to_d * holding.shares.to_d if converted_price_amount + + { + id: holding.id, + security_id: holding.security.id, + symbol: holding.security.ticker, + name: holding.security.name, + shares: holding.shares, + cost_basis: holding.cost_basis, # Assuming cost_basis is in account currency + current_price: converted_price_amount, + current_value: current_value, + currency: @account.currency, # Assuming holding is valued in account currency + sector: holding.security.sector, # Assuming security has a sector attribute + # Add more relevant data points as needed + } + end.compact + + # Calculate total portfolio value + total_value = holdings_data.sum { |h| h[:current_value] || 0 } + + { + account_id: @account.id, + account_name: @account.name, + total_value: total_value, + holdings: holdings_data + } + end + end +end diff --git a/app/apps/investment_analytics/views/investment_analytics/dashboards/_dividend_forecast.html.erb b/app/apps/investment_analytics/views/investment_analytics/dashboards/_dividend_forecast.html.erb new file mode 100644 index 00000000..d27c2810 --- /dev/null +++ b/app/apps/investment_analytics/views/investment_analytics/dashboards/_dividend_forecast.html.erb @@ -0,0 +1,44 @@ +# app/apps/investment_analytics/views/investment_analytics/dashboards/_dividend_forecast.html.erb + +
+

Dividend Forecast for <%= account.name %>

+ +
+
+

Total Annual Dividend Income

+

<%= format_money(dividend_analysis[:total_annual_dividend_income]) %>

+
+
+

Overall Portfolio Dividend Yield

+

<%= number_to_percentage(dividend_analysis[:overall_portfolio_yield], precision: 2) %>

+
+
+ +

Individual Holding Dividends

+
+ + + + + + + + + + <% if dividend_analysis[:dividend_forecast].present? %> + <% dividend_analysis[:dividend_forecast].each do |forecast| %> + + + + + + <% end %> + <% else %> + + + + <% end %> + +
SymbolAnnual IncomeDividend Yield
<%= forecast[:symbol] %><%= format_money(forecast[:annual_income]) %><%= number_to_percentage(forecast[:dividend_yield], precision: 2) %>
No dividend data available for these holdings.
+
+
diff --git a/app/apps/investment_analytics/views/investment_analytics/dashboards/_portfolio_summary.html.erb b/app/apps/investment_analytics/views/investment_analytics/dashboards/_portfolio_summary.html.erb new file mode 100644 index 00000000..833a7804 --- /dev/null +++ b/app/apps/investment_analytics/views/investment_analytics/dashboards/_portfolio_summary.html.erb @@ -0,0 +1,51 @@ +# app/apps/investment_analytics/views/investment_analytics/dashboards/_portfolio_summary.html.erb + +
+

Portfolio Summary for <%= account.name %>

+ +
+
+

Total Market Value

+

<%= format_money(metrics[:total_market_value]) %>

+
+
+

Total Cost Basis

+

<%= format_money(metrics[:total_cost_basis]) %>

+
+
+

Total Gain/Loss

+

+ <%= format_money(metrics[:total_gain_loss]) %> +

+
+ <%# Add more metrics as needed %> +
+ +

Holdings

+
+ + + + + + + + + + + + + <% portfolio_data[:holdings].each do |holding| %> + + + + + + + + + <% end %> + +
SymbolNameSharesCurrent PriceCurrent ValueSector
<%= holding[:symbol] %><%= holding[:name] %><%= holding[:shares] %><%= format_money(holding[:current_price], holding[:currency]) %><%= format_money(holding[:current_value], account.currency) %><%= holding[:sector] %>
+
+
diff --git a/app/apps/investment_analytics/views/investment_analytics/dashboards/index.html.erb b/app/apps/investment_analytics/views/investment_analytics/dashboards/index.html.erb new file mode 100644 index 00000000..6258d7fd --- /dev/null +++ b/app/apps/investment_analytics/views/investment_analytics/dashboards/index.html.erb @@ -0,0 +1,21 @@ +# app/apps/investment_analytics/views/investment_analytics/dashboards/index.html.erb + +
+

Investment Analytics Dashboard

+ +
+ <%= render partial: 'shared/account_selector', locals: { accounts: current_user.accounts, selected_account: @account } %> +
+ +
+ <%= render partial: 'investment_analytics/dashboards/portfolio_summary', locals: { account: @account, metrics: @metrics, portfolio_data: @portfolio_data } %> +
+ +
+ <%= render partial: 'investment_analytics/dashboards/dividend_forecast', locals: { account: @account, dividend_analysis: @dividend_analysis } %> +
+ + <%# Turbo Frames for dynamic updates when account changes %> + + +
diff --git a/config/application.rb b/config/application.rb index 134feb5a..3c79c8b3 100644 --- a/config/application.rb +++ b/config/application.rb @@ -6,6 +6,9 @@ require "rails/all" # you've limited to :test, :development, or :production. Bundler.require(*Rails.groups) +# Load the InvestmentAnalytics app module +require_relative '../app/apps/investment_analytics/investment_analytics' + module Maybe class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. @@ -16,6 +19,9 @@ module Maybe # Common ones are `templates`, `generators`, or `middleware`, for example. config.autoload_lib(ignore: %w[assets tasks]) + # Add app/apps to autoload paths for modular applications + config.autoload_paths << Rails.root.join('app', 'apps') + # Configuration for the application, engines, and railties goes here. # # These settings can be overridden in specific environments using the files diff --git a/config/importmap.rb b/config/importmap.rb index 5f1d8087..cff0338f 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -5,6 +5,7 @@ pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" pin_all_from "app/javascript/controllers", under: "controllers" +pin_all_from "app/apps/investment_analytics/javascript/controllers", under: "investment_analytics/controllers" pin_all_from "app/components", under: "controllers", to: "" pin_all_from "app/javascript/services", under: "services", to: "services" pin "@github/hotkey", to: "@github--hotkey.js" # @3.1.1 diff --git a/config/initializers/investment_analytics_providers.rb b/config/initializers/investment_analytics_providers.rb new file mode 100644 index 00000000..43981c8b --- /dev/null +++ b/config/initializers/investment_analytics_providers.rb @@ -0,0 +1,12 @@ +# config/initializers/investment_analytics_providers.rb + +# Ensure the InvestmentAnalytics module is loaded +require_relative '../../app/apps/investment_analytics/investment_analytics' + +# Ensure the FmpProvider class is loaded +require_relative '../../app/apps/investment_analytics/services/investment_analytics/fmp_provider' + +# Register the FmpProvider with the Provider::Registry +Provider::Registry.register_provider(:fmp, InvestmentAnalytics::FmpProvider) + +Rails.logger.info("Registered InvestmentAnalytics::FmpProvider with Provider::Registry") diff --git a/config/initializers/load_investment_analytics_routes.rb b/config/initializers/load_investment_analytics_routes.rb new file mode 100644 index 00000000..203eaa55 --- /dev/null +++ b/config/initializers/load_investment_analytics_routes.rb @@ -0,0 +1,13 @@ +# config/initializers/load_investment_analytics_routes.rb + +# Conditionally load Investment Analytics routes if the app is enabled. +# This ensures the routes are only defined when the feature is active. + +if ENV['ENABLE_INVESTMENT_ANALYTICS_APP'] == 'true' + # Directly require the routes file + require Rails.root.join('config', 'routes', 'investment_analytics.rb') + + Rails.logger.info("Investment Analytics routes loaded.") +else + Rails.logger.info("Investment Analytics app is disabled. Routes not loaded.") +end \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index e2817432..74eadf17 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -262,6 +262,15 @@ Rails.application.routes.draw do get "privacy", to: redirect("https://maybefinance.com/privacy") get "terms", to: redirect("https://maybefinance.com/tos") + namespace :investment_analytics do + resources :dashboards, only: [:index] do + collection do + get :portfolio_summary + get :dividend_forecast + end + end + end + # Defines the root path route ("/") root "pages#dashboard" end diff --git a/config/routes/investment_analytics.rb b/config/routes/investment_analytics.rb new file mode 100644 index 00000000..bccc997b --- /dev/null +++ b/config/routes/investment_analytics.rb @@ -0,0 +1,15 @@ +# config/routes/investment_analytics.rb + +# This file defines routes for the Investment Analytics app. +# It is loaded conditionally if the app is enabled. + +Maybe::Application.routes.draw do + namespace :investment_analytics do + resources :dashboards, only: [:index] do + collection do + get :portfolio_summary + get :dividend_forecast + end + end + end +end diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..1beeb46a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,113 @@ +# =========================================================================== +# Example Docker Compose file +# =========================================================================== +# +# Purpose: +# -------- +# +# This file is an example Docker Compose configuration for self hosting +# Maybe on your local machine or on a cloud VPS. +# +# The configuration below is a "standard" setup that works out of the box, +# but if you're running this outside of a local network, it is recommended +# to set the environment variables for extra security. +# +# Setup: +# ------ +# +# To run this, you should read the setup guide: +# +# https://github.com/maybe-finance/maybe/blob/main/docs/hosting/docker.md +# +# Troubleshooting: +# ---------------- +# +# If you run into problems, you should open a Discussion here: +# +# https://github.com/maybe-finance/maybe/discussions/categories/general +# + +x-db-env: &db_env + POSTGRES_USER: ${POSTGRES_USER:-maybe_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-maybe_password} + POSTGRES_DB: ${POSTGRES_DB:-maybe_production} + +x-rails-env: &rails_env + <<: *db_env + SECRET_KEY_BASE: ${SECRET_KEY_BASE:-a7523c3d0ae56415046ad8abae168d71074a79534a7062258f8d1d51ac2f76d3c3bc86d86b6b0b307df30d9a6a90a2066a3fa9e67c5e6f374dbd7dd4e0778e13} + SELF_HOSTED: "true" + RAILS_FORCE_SSL: "false" + RAILS_ASSUME_SSL: "false" + DB_HOST: db + DB_PORT: 5432 + REDIS_URL: redis://redis:6379/1 + ENABLE_INVESTMENT_ANALYTICS_APP: ${ENABLE_INVESTMENT_ANALYTICS_APP:-false} + OPENAI_ACCESS_TOKEN: ${OPENAI_ACCESS_TOKEN} + FMP_API_KEY: ${FMP_API_KEY} + +services: + web: + image: ghcr.io/maybe-finance/maybe:latest + volumes: + - app-storage:/rails/storage + ports: + - 3003:3000 + restart: unless-stopped + environment: + <<: *rails_env + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - maybe_net + + worker: + image: ghcr.io/maybe-finance/maybe:latest + command: bundle exec sidekiq + restart: unless-stopped + depends_on: + redis: + condition: service_healthy + environment: + <<: *rails_env + networks: + - maybe_net + + db: + image: postgres:16 + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + <<: *db_env + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ] + interval: 5s + timeout: 5s + retries: 5 + networks: + - maybe_net + + redis: + image: redis:latest + restart: unless-stopped + volumes: + - redis-data:/data + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 5s + timeout: 5s + retries: 5 + networks: + - maybe_net + +volumes: + app-storage: + postgres-data: + redis-data: + +networks: + maybe_net: + driver: bridge diff --git a/lib/tasks/investment_analytics.rake b/lib/tasks/investment_analytics.rake new file mode 100644 index 00000000..4248089a --- /dev/null +++ b/lib/tasks/investment_analytics.rake @@ -0,0 +1,24 @@ +# lib/tasks/investment_analytics.rake + +namespace :investment_analytics do + desc "Syncs investment data from FMP for all or a specific account" + task :sync, [:account_id] => :environment do |_, args| + if ENV['ENABLE_INVESTMENT_ANALYTICS_APP'] != 'true' + Rails.logger.warn("Investment Analytics app is not enabled. Set ENABLE_INVESTMENT_ANALYTICS_APP=true in your .env file.") + exit 1 + end + + account_id = args[:account_id] + + if account_id.present? + Rails.logger.info("Starting InvestmentAnalytics::SyncJob for account ID: #{account_id}") + InvestmentAnalytics::SyncJob.perform_now(account_id: account_id) + else + Rails.logger.info("Starting InvestmentAnalytics::SyncJob for all active accounts with holdings.") + InvestmentAnalytics::SyncJob.perform_now + end + rescue StandardError => e + Rails.logger.error("InvestmentAnalytics: Sync job failed: #{e.message}") + exit 1 + end +end diff --git a/spec/controllers/investment_analytics/dashboards_controller_spec.rb b/spec/controllers/investment_analytics/dashboards_controller_spec.rb new file mode 100644 index 00000000..f583c3ea --- /dev/null +++ b/spec/controllers/investment_analytics/dashboards_controller_spec.rb @@ -0,0 +1,86 @@ +# spec/controllers/investment_analytics/dashboards_controller_spec.rb + +require 'rails_helper' +require_relative '../../../app/apps/investment_analytics/controllers/investment_analytics/dashboards_controller' + +RSpec.describe InvestmentAnalytics::DashboardsController, type: :controller do + routes { Maybe::Application.routes } # Use Maybe's application routes + + let(:user) { create(:user) } + let(:account) { create(:account, user: user, currency: 'USD') } + let(:portfolio_data) { { account_id: account.id, total_value: Money.from_amount(1000, 'USD'), holdings: [] } } + let(:metrics) { { total_market_value: Money.from_amount(1000, 'USD') } } + let(:dividend_analysis) { { total_annual_dividend_income: Money.from_amount(50, 'USD') } } + + before do + sign_in user # Assuming Maybe has a sign_in helper for Devise or similar + allow(InvestmentAnalytics::PortfolioFetcher).to receive(:new).and_return(instance_double(InvestmentAnalytics::PortfolioFetcher, fetch: portfolio_data)) + allow(InvestmentAnalytics::MetricCalculator).to receive(:new).and_return(instance_double(InvestmentAnalytics::MetricCalculator, calculate: metrics)) + allow(InvestmentAnalytics::DividendAnalyzer).to receive(:new).and_return(instance_double(InvestmentAnalytics::DividendAnalyzer, analyze: dividend_analysis)) + end + + describe 'GET #index' do + context 'with a specific account_id' do + it 'assigns the correct account and fetches data' do + get :index, params: { account_id: account.id } + expect(assigns(:account)).to eq(account) + expect(assigns(:portfolio_data)).to eq(portfolio_data) + expect(assigns(:metrics)).to eq(metrics) + expect(assigns(:dividend_analysis)).to eq(dividend_analysis) + expect(response).to render_template(:index) + end + end + + context 'without an account_id' do + let(:another_account) { create(:account, user: user, currency: 'USD') } + + before do + # Ensure there's at least one account for the user + allow(user).to receive(:accounts).and_return(double(ActiveRecord::Relation, find: account, first: account)) + end + + it 'defaults to the first account and fetches data' do + get :index + expect(assigns(:account)).to eq(account) + expect(assigns(:portfolio_data)).to eq(portfolio_data) + expect(assigns(:metrics)).to eq(metrics) + expect(assigns(:dividend_analysis)).to eq(dividend_analysis) + expect(response).to render_template(:index) + end + end + + context 'when no accounts are available' do + before do + allow(user).to receive(:accounts).and_return(double(ActiveRecord::Relation, find: nil, first: nil)) + end + + it 'assigns empty data' do + get :index + expect(assigns(:account)).to be_nil + expect(assigns(:portfolio_data)).to eq({ holdings: [] }) + expect(assigns(:metrics)).to eq({}) + expect(assigns(:dividend_analysis)).to eq({}) + expect(response).to render_template(:index) + end + end + end + + describe 'GET #portfolio_summary' do + it 'renders the portfolio summary partial' do + get :portfolio_summary, params: { account_id: account.id } + expect(response).to render_template(partial: 'investment_analytics/dashboards/_portfolio_summary') + expect(assigns(:account)).to eq(account) + expect(assigns(:metrics)).to eq(metrics) + expect(assigns(:portfolio_data)).to eq(portfolio_data) + end + end + + describe 'GET #dividend_forecast' do + it 'renders the dividend forecast partial' do + get :dividend_forecast, params: { account_id: account.id } + expect(response).to render_template(partial: 'investment_analytics/dashboards/_dividend_forecast') + expect(assigns(:account)).to eq(account) + expect(assigns(:dividend_analysis)).to eq(dividend_analysis) + end + end +end diff --git a/spec/jobs/investment_analytics/sync_job_spec.rb b/spec/jobs/investment_analytics/sync_job_spec.rb new file mode 100644 index 00000000..8c7a47b7 --- /dev/null +++ b/spec/jobs/investment_analytics/sync_job_spec.rb @@ -0,0 +1,106 @@ +# spec/jobs/investment_analytics/sync_job_spec.rb + +require 'rails_helper' +require_relative '../../../app/apps/investment_analytics/jobs/investment_analytics/sync_job' +require_relative '../../../app/apps/investment_analytics/services/investment_analytics/fmp_provider' + +RSpec.describe InvestmentAnalytics::SyncJob, type: :job do + include ActiveJob::TestHelper + + let(:user) { create(:user) } + let(:account1) { create(:account, user: user, active: true) } + let(:account2) { create(:account, user: user, active: true) } + let(:security_a) { create(:security, ticker: 'SYM_A', currency: 'USD') } + let(:security_b) { create(:security, ticker: 'SYM_B', currency: 'USD') } + let!(:holding1) { create(:holding, account: account1, security: security_a, shares: 10) } + let!(:holding2) { create(:holding, account: account2, security: security_b, shares: 5) } + + let(:fmp_provider) { instance_double(InvestmentAnalytics::FmpProvider) } + + before do + allow(Provider::Registry).to receive(:get_provider).with(:fmp).and_return(fmp_provider) + allow(fmp_provider).to receive(:historical_prices).and_return([]) + allow(fmp_provider).to receive(:historical_dividends).and_return([]) + allow(Rails.logger).to receive(:info) + allow(Rails.logger).to receive(:error) + allow(Rails.logger).to receive(:debug) + end + + describe '#perform' do + it 'processes all active accounts with holdings if no account_id is given' do + expect(fmp_provider).to receive(:historical_prices).with('SYM_A').and_return([]) + expect(fmp_provider).to receive(:historical_dividends).with('SYM_A').and_return([]) + expect(fmp_provider).to receive(:historical_prices).with('SYM_B').and_return([]) + expect(fmp_provider).to receive(:historical_dividends).with('SYM_B').and_return([]) + + InvestmentAnalytics::SyncJob.perform_now + + expect(Rails.logger).to have_received(:info).with(/Syncing data for account/).twice + expect(Rails.logger).to have_received(:info).with("InvestmentAnalytics: Sync job completed.").once + end + + it 'only processes the specified account if account_id is given' do + expect(fmp_provider).to receive(:historical_prices).with('SYM_A').and_return([]) + expect(fmp_provider).to receive(:historical_dividends).with('SYM_A').and_return([]) + expect(fmp_provider).not_to receive(:historical_prices).with('SYM_B') + + InvestmentAnalytics::SyncJob.perform_now(account_id: account1.id) + + expect(Rails.logger).to have_received(:info).with(/Syncing data for account #{account1.id}/).once + expect(Rails.logger).to have_received(:info).with("InvestmentAnalytics: Sync job completed.").once + end + + context 'when FMP API calls succeed' do + let(:prices_data) { [{ 'date' => '2023-01-01', 'close' => 100.0, 'currency' => 'USD' }] } + let(:dividends_data) { [{ 'date' => '2023-02-01', 'dividend' => 0.5, 'currency' => 'USD' }] } + + before do + allow(fmp_provider).to receive(:historical_prices).with('SYM_A').and_return(prices_data) + allow(fmp_provider).to receive(:historical_dividends).with('SYM_A').and_return(dividends_data) + end + + it 'logs debug messages for price and dividend updates' do + InvestmentAnalytics::SyncJob.perform_now(account_id: account1.id) + expect(Rails.logger).to have_received(:debug).with(/Updating price for SYM_A on 2023-01-01/) + expect(Rails.logger).to have_received(:debug).with(/Updating dividend for SYM_A on 2023-02-01/) + end + + # Add more specific tests here if you implement actual Price/Dividend model creation/updates + end + + context 'when FMP API call for prices fails' do + before do + allow(fmp_provider).to receive(:historical_prices).with('SYM_A').and_raise(Provider::Error.new('FMP Price Error')) + end + + it 'logs an error and continues processing other data' do + InvestmentAnalytics::SyncJob.perform_now(account_id: account1.id) + expect(Rails.logger).to have_received(:error).with(/FMP API error for SYM_A in account #{account1.id}: FMP Price Error/) + expect(Rails.logger).to have_received(:info).with("InvestmentAnalytics: Sync job completed.").once + end + end + + context 'when FMP API call for dividends fails' do + before do + allow(fmp_provider).to receive(:historical_dividends).with('SYM_A').and_raise(Provider::Error.new('FMP Dividend Error')) + end + + it 'logs an error and continues processing other data' do + InvestmentAnalytics::SyncJob.perform_now(account_id: account1.id) + expect(Rails.logger).to have_received(:error).with(/FMP API error for SYM_A in account #{account1.id}: FMP Dividend Error/) + expect(Rails.logger).to have_received(:info).with("InvestmentAnalytics: Sync job completed.").once + end + end + + context 'when FMP Provider is not registered' do + before do + allow(Provider::Registry).to receive(:get_provider).with(:fmp).and_return(nil) + end + + it 'raises an error' do + expect { InvestmentAnalytics::SyncJob.perform_now }. + to raise_error(RuntimeError, "FMP Provider not registered") + end + end + end +end diff --git a/spec/services/investment_analytics/dividend_analyzer_spec.rb b/spec/services/investment_analytics/dividend_analyzer_spec.rb new file mode 100644 index 00000000..9680a3c0 --- /dev/null +++ b/spec/services/investment_analytics/dividend_analyzer_spec.rb @@ -0,0 +1,132 @@ +# spec/services/investment_analytics/dividend_analyzer_spec.rb + +require 'rails_helper' +require_relative '../../../app/apps/investment_analytics/services/investment_analytics/dividend_analyzer' +require_relative '../../../app/apps/investment_analytics/services/investment_analytics/fmp_provider' +require_relative '../../../app/apps/investment_analytics/services/investment_analytics/exchange_rate_converter' + +RSpec.describe InvestmentAnalytics::DividendAnalyzer do + let(:account_currency) { 'USD' } + let(:fmp_provider) { instance_double(InvestmentAnalytics::FmpProvider) } + let(:portfolio_data) do + { + account_id: 1, + account_name: 'Test Account', + total_value: Money.from_amount(3400, 'USD'), + holdings: [ + { + id: 1, + symbol: 'AAPL', + shares: 10, + current_price: Money.from_amount(170, 'USD'), + current_value: Money.from_amount(1700, 'USD'), + currency: 'USD', + sector: 'Technology' + }, + { + id: 2, + symbol: 'MSFT', + shares: 5, + current_price: Money.from_amount(300, 'USD'), + current_value: Money.from_amount(1500, 'USD'), + currency: 'USD', + sector: 'Technology' + }, + { + id: 3, + symbol: 'SAP', + shares: 5, + current_price: Money.from_amount(110, 'EUR'), + current_value: Money.from_amount(605, 'USD'), # Converted value + currency: 'EUR', + sector: 'Software' + } + ] + } + end + + subject { described_class.new(portfolio_data, account_currency) } + + before do + allow(Provider::Registry).to receive(:get_provider).with(:fmp).and_return(fmp_provider) + allow(InvestmentAnalytics::ExchangeRateConverter).to receive(:convert) do |money_obj, target_currency| + if money_obj.currency == target_currency + money_obj + elsif money_obj.currency == 'EUR' && target_currency == 'USD' + Money.from_amount(money_obj.amount * 1.1, target_currency) + else + money_obj + end + end + end + + describe '#analyze' do + context 'with dividend data' do + before do + allow(fmp_provider).to receive(:historical_dividends).with('AAPL').and_return([ + { 'date' => 2.months.ago.to_s, 'dividend' => 0.22, 'declaredDate' => '', 'recordDate' => '', 'paymentDate' => '' }, + { 'date' => 5.months.ago.to_s, 'dividend' => 0.22, 'declaredDate' => '', 'recordDate' => '', 'paymentDate' => '' }, + { 'date' => 8.months.ago.to_s, 'dividend' => 0.22, 'declaredDate' => '', 'recordDate' => '', 'paymentDate' => '' }, + { 'date' => 11.months.ago.to_s, 'dividend' => 0.22, 'declaredDate' => '', 'recordDate' => '', 'paymentDate' => '' } + ]) + allow(fmp_provider).to receive(:historical_dividends).with('MSFT').and_return([ + { 'date' => 1.month.ago.to_s, 'dividend' => 0.68, 'declaredDate' => '', 'recordDate' => '', 'paymentDate' => '' }, + { 'date' => 4.months.ago.to_s, 'dividend' => 0.68, 'declaredDate' => '', 'recordDate' => '', 'paymentDate' => '' }, + { 'date' => 7.months.ago.to_s, 'dividend' => 0.68, 'declaredDate' => '', 'recordDate' => '', 'paymentDate' => '' }, + { 'date' => 10.months.ago.to_s, 'dividend' => 0.68, 'declaredDate' => '', 'recordDate' => '', 'paymentDate' => '' } + ]) + allow(fmp_provider).to receive(:historical_dividends).with('SAP').and_return([]) # No dividends for SAP + end + + it 'calculates annual dividend income and yield for each holding' do + analysis = subject.analyze + expect(analysis[:dividend_forecast].count).to eq(2) # AAPL and MSFT + + aapl_forecast = analysis[:dividend_forecast].find { |f| f[:symbol] == 'AAPL' } + expect(aapl_forecast[:annual_income]).to eq(Money.from_amount(0.22 * 4 * 10, 'USD')) # 0.88 * 10 shares + expect(aapl_forecast[:dividend_yield]).to be_within(0.01).of((0.88 * 10 / 1700.0) * 100) + + msft_forecast = analysis[:dividend_forecast].find { |f| f[:symbol] == 'MSFT' } + expect(msft_forecast[:annual_income]).to eq(Money.from_amount(0.68 * 4 * 5, 'USD')) # 2.72 * 5 shares + expect(msft_forecast[:dividend_yield]).to be_within(0.01).of((2.72 * 5 / 1500.0) * 100) + end + + it 'calculates overall portfolio dividend income and yield' do + analysis = subject.analyze + expected_total_income = Money.from_amount((0.22 * 4 * 10) + (0.68 * 4 * 5), 'USD') + expect(analysis[:total_annual_dividend_income]).to eq(expected_total_income) + + expected_overall_yield = (expected_total_income.to_d / portfolio_data[:total_value].to_d) * 100 + expect(analysis[:overall_portfolio_yield]).to be_within(0.01).of(expected_overall_yield) + end + end + + context 'when FMP API call fails' do + before do + allow(fmp_provider).to receive(:historical_dividends).with('AAPL').and_raise(Provider::Error.new('API Limit')) + allow(fmp_provider).to receive(:historical_dividends).with('MSFT').and_return([]) + allow(fmp_provider).to receive(:historical_dividends).with('SAP').and_return([]) + end + + it 'logs a warning and continues without failing' do + expect(Rails.logger).to receive(:warn).with(/Failed to fetch dividends for AAPL: API Limit/) + analysis = subject.analyze + expect(analysis[:dividend_forecast]).to be_empty # AAPL failed, MSFT has no dividends + expect(analysis[:total_annual_dividend_income]).to eq(Money.from_amount(0, 'USD')) + end + end + + context 'when no holdings have dividend data' do + before do + allow(fmp_provider).to receive(:historical_dividends).and_return([]) + end + + it 'returns empty dividend forecast and zero totals' do + analysis = subject.analyze + expect(analysis[:dividend_forecast]).to be_empty + expect(analysis[:total_annual_dividend_income]).to eq(Money.from_amount(0, 'USD')) + expect(analysis[:overall_portfolio_yield]).to eq(0.0) + end + end + end +end diff --git a/spec/services/investment_analytics/metric_calculator_spec.rb b/spec/services/investment_analytics/metric_calculator_spec.rb new file mode 100644 index 00000000..b4f08f15 --- /dev/null +++ b/spec/services/investment_analytics/metric_calculator_spec.rb @@ -0,0 +1,115 @@ +# spec/services/investment_analytics/metric_calculator_spec.rb + +require 'rails_helper' +require_relative '../../../app/apps/investment_analytics/services/investment_analytics/metric_calculator' + +RSpec.describe InvestmentAnalytics::MetricCalculator do + let(:account_currency) { 'USD' } + let(:portfolio_data) do + { + account_id: 1, + account_name: 'Test Account', + holdings: [ + { + id: 1, + symbol: 'AAPL', + shares: 10, + cost_basis: Money.from_amount(1500, 'USD'), + current_price: Money.from_amount(170, 'USD'), + current_value: Money.from_amount(1700, 'USD'), + currency: 'USD', + sector: 'Technology' + }, + { + id: 2, + symbol: 'GOOG', + shares: 5, + cost_basis: Money.from_amount(1000, 'USD'), + current_price: Money.from_amount(220, 'USD'), + current_value: Money.from_amount(1100, 'USD'), + currency: 'USD', + sector: 'Technology' + }, + { + id: 3, + symbol: 'SAP', + shares: 5, + cost_basis: Money.from_amount(500, 'EUR'), # Cost basis in EUR + current_price: Money.from_amount(110, 'EUR'), # Price in EUR + current_value: Money.from_amount(605, 'USD'), # Converted value in USD (5 * 110 * 1.1) + currency: 'EUR', + sector: 'Software' + } + ] + } + end + + subject { described_class.new(portfolio_data, account_currency) } + + before do + # Stub the ExchangeRateConverter for consistent testing + allow(InvestmentAnalytics::ExchangeRateConverter).to receive(:convert) do |money_obj, target_currency| + if money_obj.currency == target_currency + money_obj + elsif money_obj.currency == 'EUR' && target_currency == 'USD' + Money.from_amount(money_obj.amount * 1.1, target_currency) + else + money_obj # Fallback for other currencies + end + end + end + + describe '#calculate' do + it 'calculates total market value correctly' do + metrics = subject.calculate + expected_total_market_value = Money.from_amount(1700 + 1100 + 605, 'USD') + expect(metrics[:total_market_value]).to eq(expected_total_market_value) + end + + it 'calculates total cost basis correctly' do + metrics = subject.calculate + # Assuming cost_basis in portfolio_data is already in account_currency + expected_total_cost_basis = Money.from_amount(1500 + 1000 + 500, 'USD') + expect(metrics[:total_cost_basis]).to eq(expected_total_cost_basis) + end + + it 'calculates total gain/loss correctly' do + metrics = subject.calculate + # (1700 - 1500) + (1100 - 1000) + (605 - 500) + expected_total_gain_loss = Money.from_amount(200 + 100 + 105, 'USD') + expect(metrics[:total_gain_loss]).to eq(expected_total_gain_loss) + end + + it 'calculates total day-over-day change (placeholder)' do + metrics = subject.calculate + expect(metrics[:total_day_change]).to eq(Money.from_amount(0, 'USD')) + end + + context 'when holdings have missing data' do + let(:portfolio_data) do + { + account_id: 1, + account_name: 'Test Account', + holdings: [ + { + id: 1, + symbol: 'AAPL', + shares: 10, + cost_basis: nil, # Missing cost basis + current_price: Money.from_amount(170, 'USD'), + current_value: Money.from_amount(1700, 'USD'), + currency: 'USD', + sector: 'Technology' + } + ] + } + end + + it 'handles missing cost basis gracefully' do + metrics = subject.calculate + expect(metrics[:total_cost_basis]).to eq(Money.from_amount(0, 'USD')) + expect(metrics[:total_gain_loss]).to eq(Money.from_amount(0, 'USD')) + end + end + end +end diff --git a/spec/services/investment_analytics/portfolio_fetcher_spec.rb b/spec/services/investment_analytics/portfolio_fetcher_spec.rb new file mode 100644 index 00000000..69f1cfa2 --- /dev/null +++ b/spec/services/investment_analytics/portfolio_fetcher_spec.rb @@ -0,0 +1,75 @@ +# spec/services/investment_analytics/portfolio_fetcher_spec.rb + +require 'rails_helper' +require_relative '../../../app/apps/investment_analytics/services/investment_analytics/portfolio_fetcher' +require_relative '../../../app/apps/investment_analytics/services/investment_analytics/exchange_rate_converter' + +RSpec.describe InvestmentAnalytics::PortfolioFetcher do + let(:user) { create(:user) } + let(:account) { create(:account, user: user, currency: 'USD') } + let(:security_usd) { create(:security, ticker: 'AAPL', name: 'Apple Inc.', currency: 'USD', sector: 'Technology') } + let(:security_eur) { create(:security, ticker: 'SAP', name: 'SAP SE', currency: 'EUR', sector: 'Software') } + let!(:holding_usd) { create(:holding, account: account, security: security_usd, shares: 10, cost_basis: 1500) } + let!(:holding_eur) { create(:holding, account: account, security: security_eur, shares: 5, cost_basis: 500) } + let!(:price_usd) { create(:price, security: security_usd, date: Time.zone.today, amount: 170.00, currency: 'USD') } + let!(:price_eur) { create(:price, security: security_eur, date: Time.zone.today, amount: 110.00, currency: 'EUR') } + + subject { described_class.new(account) } + + before do + # Stub the ExchangeRateConverter for consistent testing + allow(InvestmentAnalytics::ExchangeRateConverter).to receive(:convert) do |money_obj, target_currency| + if money_obj.currency == target_currency + money_obj + elsif money_obj.currency == 'EUR' && target_currency == 'USD' + # Assume a fixed exchange rate for testing purposes (e.g., 1 EUR = 1.1 USD) + Money.from_amount(money_obj.amount * 1.1, target_currency) + else + money_obj # Fallback for other currencies + end + end + end + + describe '#fetch' do + it 'returns structured portfolio data for the account' do + portfolio_data = subject.fetch + + expect(portfolio_data).to be_a(Hash) + expect(portfolio_data[:account_id]).to eq(account.id) + expect(portfolio_data[:account_name]).to eq(account.name) + expect(portfolio_data[:holdings]).to be_an(Array) + expect(portfolio_data[:holdings].count).to eq(2) + + # Test USD holding + usd_holding_data = portfolio_data[:holdings].find { |h| h[:symbol] == 'AAPL' } + expect(usd_holding_data).to be_present + expect(usd_holding_data[:shares]).to eq(10) + expect(usd_holding_data[:current_price]).to eq(Money.from_amount(170.00, 'USD')) + expect(usd_holding_data[:current_value]).to eq(Money.from_amount(1700.00, 'USD')) + expect(usd_holding_data[:currency]).to eq('USD') + expect(usd_holding_data[:sector]).to eq('Technology') + + # Test EUR holding (converted to USD) + eur_holding_data = portfolio_data[:holdings].find { |h| h[:symbol] == 'SAP' } + expect(eur_holding_data).to be_present + expect(eur_holding_data[:shares]).to eq(5) + expect(eur_holding_data[:current_price]).to eq(Money.from_amount(110.00, 'EUR')) # Original price currency + expect(eur_holding_data[:current_value]).to eq(Money.from_amount(5 * 110.00 * 1.1, 'USD')) # Converted value + expect(eur_holding_data[:currency]).to eq('EUR') # Original holding currency + expect(eur_holding_data[:sector]).to eq('Software') + + # Test total value (sum of converted values) + expected_total_value = Money.from_amount(1700.00 + (5 * 110.00 * 1.1), 'USD') + expect(portfolio_data[:total_value]).to eq(expected_total_value) + end + + context 'when a holding has no price data' do + let!(:holding_no_price) { create(:holding, account: account, security: create(:security, ticker: 'NO_PRICE', currency: 'USD'), shares: 5, cost_basis: 100) } + + it 'excludes the holding from calculations if current_value is nil' do + portfolio_data = subject.fetch + expect(portfolio_data[:holdings].map { |h| h[:symbol] }).not_to include('NO_PRICE') + end + end + end +end