1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-06 14:05:20 +02:00

main push

This commit is contained in:
mwjdaws 2025-07-19 16:36:58 -04:00
parent 321a343df4
commit 13ec520618
31 changed files with 1358 additions and 260 deletions

336
CLAUDE.md
View file

@ -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 `<dialog>` for modals, `<details><summary>` 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
<!-- GOOD: Declarative - HTML declares what happens -->
<div data-controller="toggle">
<button data-action="click->toggle#toggle" data-toggle-target="button">Show</button>
<div data-toggle-target="content" class="hidden">Hello World!</div>
</div>
```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
**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.

View file

@ -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"

View file

@ -0,0 +1,27 @@
# app/apps/investment_analytics/app/components/investment_analytics/account_selector_component.html.erb
<div class="flex items-center space-x-2">
<label for="account-select" class="text-gray-700">Select Account:</label>
<select id="account-select" class="form-select mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
data-controller="account-selector"
data-action="change->account-selector#selectAccount">
<% accounts.each do |account| %>
<option value="<%= account.id %>" <%= 'selected' if account == selected_account %>><%= account.name %></option>
<% end %>
</select>
</div>
<script type="module">
// Stimulus controller for account selection
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);
}
}
</script>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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);
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,44 @@
# app/apps/investment_analytics/views/investment_analytics/dashboards/_dividend_forecast.html.erb
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-xl font-semibold mb-4">Dividend Forecast for <%= account.name %></h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<p class="text-gray-500 text-sm">Total Annual Dividend Income</p>
<p class="text-2xl font-bold"><%= format_money(dividend_analysis[:total_annual_dividend_income]) %></p>
</div>
<div>
<p class="text-gray-500 text-sm">Overall Portfolio Dividend Yield</p>
<p class="text-2xl font-bold"><%= number_to_percentage(dividend_analysis[:overall_portfolio_yield], precision: 2) %></p>
</div>
</div>
<h3 class="text-lg font-semibold mt-6 mb-3">Individual Holding Dividends</h3>
<div class="overflow-x-auto">
<table class="min-w-full bg-white">
<thead>
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Symbol</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Annual Income</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Dividend Yield</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<% if dividend_analysis[:dividend_forecast].present? %>
<% dividend_analysis[:dividend_forecast].each do |forecast| %>
<tr>
<td class="px-4 py-2 whitespace-nowrap"><%= forecast[:symbol] %></td>
<td class="px-4 py-2 whitespace-nowrap"><%= format_money(forecast[:annual_income]) %></td>
<td class="px-4 py-2 whitespace-nowrap"><%= number_to_percentage(forecast[:dividend_yield], precision: 2) %></td>
</tr>
<% end %>
<% else %>
<tr>
<td colspan="3" class="px-4 py-2 text-center text-gray-500">No dividend data available for these holdings.</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>

View file

@ -0,0 +1,51 @@
# app/apps/investment_analytics/views/investment_analytics/dashboards/_portfolio_summary.html.erb
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-xl font-semibold mb-4">Portfolio Summary for <%= account.name %></h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<p class="text-gray-500 text-sm">Total Market Value</p>
<p class="text-2xl font-bold"><%= format_money(metrics[:total_market_value]) %></p>
</div>
<div>
<p class="text-gray-500 text-sm">Total Cost Basis</p>
<p class="text-2xl font-bold"><%= format_money(metrics[:total_cost_basis]) %></p>
</div>
<div>
<p class="text-gray-500 text-sm">Total Gain/Loss</p>
<p class="text-2xl font-bold <%= metrics[:total_gain_loss]&.positive? ? 'text-green-600' : 'text-red-600' %>">
<%= format_money(metrics[:total_gain_loss]) %>
</p>
</div>
<%# Add more metrics as needed %>
</div>
<h3 class="text-lg font-semibold mt-6 mb-3">Holdings</h3>
<div class="overflow-x-auto">
<table class="min-w-full bg-white">
<thead>
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Symbol</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Shares</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Current Price</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Current Value</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Sector</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<% portfolio_data[:holdings].each do |holding| %>
<tr>
<td class="px-4 py-2 whitespace-nowrap"><%= holding[:symbol] %></td>
<td class="px-4 py-2 whitespace-nowrap"><%= holding[:name] %></td>
<td class="px-4 py-2 whitespace-nowrap"><%= holding[:shares] %></td>
<td class="px-4 py-2 whitespace-nowrap"><%= format_money(holding[:current_price], holding[:currency]) %></td>
<td class="px-4 py-2 whitespace-nowrap"><%= format_money(holding[:current_value], account.currency) %></td>
<td class="px-4 py-2 whitespace-nowrap"><%= holding[:sector] %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>

View file

@ -0,0 +1,21 @@
# app/apps/investment_analytics/views/investment_analytics/dashboards/index.html.erb
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-6">Investment Analytics Dashboard</h1>
<div class="mb-6">
<%= render partial: 'shared/account_selector', locals: { accounts: current_user.accounts, selected_account: @account } %>
</div>
<div id="portfolio-summary-<%= @account.id %>" class="mb-8">
<%= render partial: 'investment_analytics/dashboards/portfolio_summary', locals: { account: @account, metrics: @metrics, portfolio_data: @portfolio_data } %>
</div>
<div id="dividend-forecast-<%= @account.id %>" class="mb-8">
<%= render partial: 'investment_analytics/dashboards/dividend_forecast', locals: { account: @account, dividend_analysis: @dividend_analysis } %>
</div>
<%# Turbo Frames for dynamic updates when account changes %>
<turbo-frame id="portfolio-summary-<%= @account.id %>" src="<%= portfolio_summary_investment_analytics_dashboards_path(account_id: @account.id) %>"></turbo-frame>
<turbo-frame id="dividend-forecast-<%= @account.id %>" src="<%= dividend_forecast_investment_analytics_dashboards_path(account_id: @account.id) %>"></turbo-frame>
</div>

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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

113
docker-compose.yml Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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