From 302fbf5fe2505634660944115158d1d196df253b Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 3 Jun 2026 14:46:30 -0400 Subject: [PATCH] 06/03 Optimize codes, Schwab account --- app/models/investment.py | 4 + app/services/investment_service.py | 38 ++++- app/services/schwab_service.py | 9 +- app/templates/investments/index.html | 212 +++++++++++++++------------ scripts/add_investment_account.py | 38 +++++ 5 files changed, 204 insertions(+), 97 deletions(-) create mode 100644 scripts/add_investment_account.py diff --git a/app/models/investment.py b/app/models/investment.py index 388551f..cf64312 100644 --- a/app/models/investment.py +++ b/app/models/investment.py @@ -6,6 +6,9 @@ class Investment(db.Model): __tablename__ = 'investments' id = db.Column(db.Integer, primary_key=True) + # Optional link to a PFM account (e.g. Schwab Individual, Schwab Roth IRA). + # NULL means the holding is not tied to a specific account. + account_id = db.Column(db.Integer, db.ForeignKey('accounts.id'), nullable=True, index=True) asset_name = db.Column(db.String(100), nullable=False) ticker = db.Column(db.String(20), nullable=True) asset_type = db.Column( @@ -22,6 +25,7 @@ class Investment(db.Model): created_at = db.Column(db.DateTime, default=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + account = db.relationship('Account', foreign_keys=[account_id]) inv_transactions = db.relationship('InvestmentTransaction', back_populates='investment', lazy='dynamic', cascade='all, delete-orphan') diff --git a/app/services/investment_service.py b/app/services/investment_service.py index 3a04b8a..bd13255 100644 --- a/app/services/investment_service.py +++ b/app/services/investment_service.py @@ -324,8 +324,16 @@ def update_prices(investment_ids=None): def get_portfolio_summary(): - """Return portfolio-level aggregates across all active investments.""" - investments = Investment.query.filter_by(is_active=True).all() + """ + Return portfolio-level aggregates across all active investments. + Also returns per-account groups for investments that are linked to an account + (e.g. separate Schwab Individual vs Roth IRA views). + """ + investments = Investment.query.filter_by(is_active=True).order_by( + Investment.account_id.asc().nullslast(), + Investment.asset_type, + Investment.asset_name, + ).all() total_cost = sum(i.total_cost for i in investments) total_value = sum(i.current_value for i in investments) @@ -348,6 +356,31 @@ def get_portfolio_summary(): 'color': ASSET_COLORS.get(asset_type, '#94a3b8'), }) + # Build per-account groups (only for investments with account_id set) + account_groups = {} # account_id (or None) → {'account': Account|None, 'investments': [...]} + for inv in investments: + key = inv.account_id + if key not in account_groups: + account_groups[key] = { + 'account': inv.account, # Account object or None + 'investments': [], + 'total_value': 0, + 'total_cost': 0, + } + account_groups[key]['investments'].append(inv) + account_groups[key]['total_value'] += inv.current_value + account_groups[key]['total_cost'] += inv.total_cost + + # Sort: named accounts first (sorted by name), then unlinked holdings last + sorted_groups = sorted( + account_groups.values(), + key=lambda g: (g['account'] is None, g['account'].name if g['account'] else ''), + ) + + # Only return groups if there's more than one distinct account present + multi_account = len([g for g in sorted_groups if g['account'] is not None]) > 1 or \ + (len(sorted_groups) > 1) + return { 'investments': investments, 'total_cost': total_cost, @@ -356,4 +389,5 @@ def get_portfolio_summary(): 'total_gain_pct': total_gain_pct, 'allocation': allocation, 'count': len(investments), + 'account_groups': sorted_groups if multi_account else [], } diff --git a/app/services/schwab_service.py b/app/services/schwab_service.py index cac4778..7b62e61 100644 --- a/app/services/schwab_service.py +++ b/app/services/schwab_service.py @@ -264,7 +264,13 @@ def sync_account_snapshot(schwab_account): market_value = float(pos.get('marketValue') or 0) cur_price = round(market_value / long_qty, 4) if long_qty > 0 else avg_price - inv = Investment.query.filter_by(ticker=symbol, is_active=True).first() + pfm_acct_id = schwab_account.pfm_account_id + + # Match on (ticker, account_id) so the same ticker in different accounts + # (e.g. AAPL in Individual vs Roth IRA) remains separate. + inv = Investment.query.filter_by( + ticker=symbol, account_id=pfm_acct_id, is_active=True + ).first() if inv: inv.shares = long_qty if avg_price > 0: @@ -274,6 +280,7 @@ def sync_account_snapshot(schwab_account): else: name = (instrument.get('description') or symbol).strip() inv = Investment( + account_id = pfm_acct_id, asset_name = name, ticker = symbol, asset_type = pfm_type, diff --git a/app/templates/investments/index.html b/app/templates/investments/index.html index 9ecf832..c02c55b 100644 --- a/app/templates/investments/index.html +++ b/app/templates/investments/index.html @@ -132,6 +132,95 @@ +{# ── Reusable holdings table macro ──────────────────────────────────────── #} +{% macro holdings_table(inv_list) %} + + + + + + + + + + + + + {% for inv in inv_list %} + + + + + + + + + {% if inv.ticker %} + + + + {% endif %} + {% endfor %} + +
AssetPrice1D ChgValueP&L
+
+
+ {{ (inv.ticker or inv.asset_name[:2]).upper()[:2] }} +
+
+
{{ inv.asset_name }}
+
+ {% if inv.ticker %}{{ inv.ticker }} · {% endif %} + {{ asset_labels.get(inv.asset_type, inv.asset_type) }} +
+
+
+
+ {% if inv.current_price %} + {{ inv.current_price | currency }} + {% else %} + + {% endif %} + + {% if inv.ticker %} + {% else %}{% endif %} + {{ inv.current_value | currency }} +
+ {% if inv.unrealized_gain >= 0 %}+{% endif %}{{ inv.unrealized_gain | currency }} +
+
+ {% if inv.unrealized_gain_pct >= 0 %}+{% endif %}{{ inv.unrealized_gain_pct }}% +
+
+ {% if inv.ticker %} + + {% endif %} +
+{% endmacro %} +
@@ -154,110 +243,45 @@
{% endfor %}
+ {# Per-account balance breakdown #} + {% if portfolio.account_groups %} +
+
By Account
+ {% for grp in portfolio.account_groups %} +
+ {{ grp.account.name if grp.account else 'Unlinked' }} + {{ grp.total_value | currency }} +
+ {% endfor %} +
+ {% endif %} - +
+ {% if portfolio.account_groups %} + {% for grp in portfolio.account_groups %} +
+
+
+ {{ grp.account.name if grp.account else 'Unlinked Holdings' }} + {{ grp.total_value | currency }} +
+ {{ grp.investments | length }} holding(s) +
+ {{ holdings_table(grp.investments) }} +
+ {% endfor %} + {% else %}
Holdings Click to expand price chart
- - - - - - - - - - - - - {% for inv in portfolio.investments %} - {# Main holding row #} - - - - - - - - - - {# Hidden chart row for this holding #} - {% if inv.ticker %} - - - - {% endif %} - {% endfor %} - -
AssetPrice1D ChgValueP&L
-
-
- {{ (inv.ticker or inv.asset_name[:2]).upper()[:2] }} -
-
-
{{ inv.asset_name }}
-
- {% if inv.ticker %}{{ inv.ticker }} · {% endif %} - {{ asset_labels.get(inv.asset_type, inv.asset_type) }} -
-
-
-
- {% if inv.current_price %} - {{ inv.current_price | currency }} - {% else %} - - {% endif %} - - {% if inv.ticker %} - - {% else %} - - {% endif %} - {{ inv.current_value | currency }} -
- {% if inv.unrealized_gain >= 0 %}+{% endif %}{{ inv.unrealized_gain | currency }} -
-
- {% if inv.unrealized_gain_pct >= 0 %}+{% endif %}{{ inv.unrealized_gain_pct }}% -
-
- {% if inv.ticker %} - - {% endif %} -
+ {{ holdings_table(portfolio.investments) }}
+ {% endif %}
{% endif %} diff --git a/scripts/add_investment_account.py b/scripts/add_investment_account.py new file mode 100644 index 0000000..9965f57 --- /dev/null +++ b/scripts/add_investment_account.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +""" +Migration: add account_id to investments table. +Run once: python scripts/add_investment_account.py +Safe to re-run — skips if the column already exists. +""" +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app import create_app +from app.extensions import db + +app = create_app() + +with app.app_context(): + with db.engine.connect() as conn: + # Check if column already exists + result = conn.execute(db.text( + "SELECT COUNT(*) FROM information_schema.columns " + "WHERE table_schema = DATABASE() " + "AND table_name = 'investments' " + "AND column_name = 'account_id'" + )) + exists = result.scalar() + + if exists: + print("Column investments.account_id already exists — nothing to do.") + else: + conn.execute(db.text( + "ALTER TABLE investments " + "ADD COLUMN account_id INT NULL DEFAULT NULL, " + "ADD INDEX ix_investments_account_id (account_id), " + "ADD CONSTRAINT fk_investments_account_id " + " FOREIGN KEY (account_id) REFERENCES accounts(id) " + " ON DELETE SET NULL" + )) + conn.commit() + print("Added investments.account_id column successfully.")