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:
parent
321a343df4
commit
13ec520618
31 changed files with 1358 additions and 260 deletions
336
CLAUDE.md
336
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 `<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.
|
||||
|
|
1
Gemfile
1
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"
|
||||
|
|
|
@ -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>
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
10
app/apps/investment_analytics/investment_analytics.rb
Normal file
10
app/apps/investment_analytics/investment_analytics.rb
Normal 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
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
12
config/initializers/investment_analytics_providers.rb
Normal file
12
config/initializers/investment_analytics_providers.rb
Normal 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")
|
13
config/initializers/load_investment_analytics_routes.rb
Normal file
13
config/initializers/load_investment_analytics_routes.rb
Normal 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
|
|
@ -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
|
||||
|
|
15
config/routes/investment_analytics.rb
Normal file
15
config/routes/investment_analytics.rb
Normal 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
113
docker-compose.yml
Normal 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
|
24
lib/tasks/investment_analytics.rake
Normal file
24
lib/tasks/investment_analytics.rake
Normal 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
|
|
@ -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
|
106
spec/jobs/investment_analytics/sync_job_spec.rb
Normal file
106
spec/jobs/investment_analytics/sync_job_spec.rb
Normal 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
|
132
spec/services/investment_analytics/dividend_analyzer_spec.rb
Normal file
132
spec/services/investment_analytics/dividend_analyzer_spec.rb
Normal 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
|
115
spec/services/investment_analytics/metric_calculator_spec.rb
Normal file
115
spec/services/investment_analytics/metric_calculator_spec.rb
Normal 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
|
75
spec/services/investment_analytics/portfolio_fetcher_spec.rb
Normal file
75
spec/services/investment_analytics/portfolio_fetcher_spec.rb
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue