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) %} +
| Asset | +Price | +1D Chg | +Value | +P&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 %} + | +
| Asset | -Price | -1D Chg | -Value | -P&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 %} - | -