06/03 Optimize codes, Schwab account
This commit is contained in:
@@ -6,6 +6,9 @@ class Investment(db.Model):
|
|||||||
__tablename__ = 'investments'
|
__tablename__ = 'investments'
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
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)
|
asset_name = db.Column(db.String(100), nullable=False)
|
||||||
ticker = db.Column(db.String(20), nullable=True)
|
ticker = db.Column(db.String(20), nullable=True)
|
||||||
asset_type = db.Column(
|
asset_type = db.Column(
|
||||||
@@ -22,6 +25,7 @@ class Investment(db.Model):
|
|||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=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',
|
inv_transactions = db.relationship('InvestmentTransaction', back_populates='investment',
|
||||||
lazy='dynamic', cascade='all, delete-orphan')
|
lazy='dynamic', cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
|||||||
@@ -324,8 +324,16 @@ def update_prices(investment_ids=None):
|
|||||||
|
|
||||||
|
|
||||||
def get_portfolio_summary():
|
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_cost = sum(i.total_cost for i in investments)
|
||||||
total_value = sum(i.current_value 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'),
|
'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 {
|
return {
|
||||||
'investments': investments,
|
'investments': investments,
|
||||||
'total_cost': total_cost,
|
'total_cost': total_cost,
|
||||||
@@ -356,4 +389,5 @@ def get_portfolio_summary():
|
|||||||
'total_gain_pct': total_gain_pct,
|
'total_gain_pct': total_gain_pct,
|
||||||
'allocation': allocation,
|
'allocation': allocation,
|
||||||
'count': len(investments),
|
'count': len(investments),
|
||||||
|
'account_groups': sorted_groups if multi_account else [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,7 +264,13 @@ def sync_account_snapshot(schwab_account):
|
|||||||
market_value = float(pos.get('marketValue') or 0)
|
market_value = float(pos.get('marketValue') or 0)
|
||||||
cur_price = round(market_value / long_qty, 4) if long_qty > 0 else avg_price
|
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:
|
if inv:
|
||||||
inv.shares = long_qty
|
inv.shares = long_qty
|
||||||
if avg_price > 0:
|
if avg_price > 0:
|
||||||
@@ -274,6 +280,7 @@ def sync_account_snapshot(schwab_account):
|
|||||||
else:
|
else:
|
||||||
name = (instrument.get('description') or symbol).strip()
|
name = (instrument.get('description') or symbol).strip()
|
||||||
inv = Investment(
|
inv = Investment(
|
||||||
|
account_id = pfm_acct_id,
|
||||||
asset_name = name,
|
asset_name = name,
|
||||||
ticker = symbol,
|
ticker = symbol,
|
||||||
asset_type = pfm_type,
|
asset_type = pfm_type,
|
||||||
|
|||||||
@@ -132,39 +132,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chart + Allocation -->
|
{# ── Reusable holdings table macro ──────────────────────────────────────── #}
|
||||||
<div class="row g-3 mb-4">
|
{% macro holdings_table(inv_list) %}
|
||||||
<div class="col-12 col-lg-4">
|
<table class="pfm-table">
|
||||||
<div class="pcard h-100">
|
|
||||||
<div class="pcard-title mb-3">Allocation</div>
|
|
||||||
<div style="position:relative;height:200px;display:flex;align-items:center;justify-content:center;">
|
|
||||||
<canvas id="allocChart"></canvas>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3">
|
|
||||||
{% for item in portfolio.allocation %}
|
|
||||||
<div class="d-flex justify-content-between align-items-center py-1" style="border-bottom:1px solid var(--border);">
|
|
||||||
<div class="d-flex align-items-center gap-2">
|
|
||||||
<div style="width:10px;height:10px;border-radius:2px;background:{{ item.color }};flex-shrink:0;"></div>
|
|
||||||
<span style="font-size:13px;">{{ item.type | replace('_',' ') | title }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<span class="mono text-muted" style="font-size:12px;">{{ item.value | currency }}</span>
|
|
||||||
<span class="mono" style="font-size:12px;width:40px;text-align:right;">{{ item.pct }}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Holdings table -->
|
|
||||||
<div class="col-12 col-lg-8">
|
|
||||||
<div class="pcard p-0 h-100">
|
|
||||||
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-bottom:1px solid var(--border);">
|
|
||||||
<span class="pcard-title mb-0">Holdings</span>
|
|
||||||
<span class="text-muted" style="font-size:11px;">Click <i class="bi bi-bar-chart-line"></i> to expand price chart</span>
|
|
||||||
</div>
|
|
||||||
<table class="pfm-table" id="holdingsTable">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="padding-left:20px;">Asset</th>
|
<th style="padding-left:20px;">Asset</th>
|
||||||
@@ -176,8 +146,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for inv in portfolio.investments %}
|
{% for inv in inv_list %}
|
||||||
{# Main holding row #}
|
|
||||||
<tr id="row-{{ inv.id }}" style="cursor:pointer;"
|
<tr id="row-{{ inv.id }}" style="cursor:pointer;"
|
||||||
onclick="location.href='{{ url_for('investments.detail', id=inv.id) }}'">
|
onclick="location.href='{{ url_for('investments.detail', id=inv.id) }}'">
|
||||||
<td style="padding-left:20px;">
|
<td style="padding-left:20px;">
|
||||||
@@ -202,11 +171,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end d-mob-none" id="chg-{{ inv.id }}">
|
<td class="text-end d-mob-none" id="chg-{{ inv.id }}">
|
||||||
{% if inv.ticker %}
|
{% if inv.ticker %}<span class="chg-badge chg-loading">…</span>
|
||||||
<span class="chg-badge chg-loading">…</span>
|
{% else %}<span class="text-muted" style="font-size:12px;">—</span>{% endif %}
|
||||||
{% else %}
|
|
||||||
<span class="text-muted" style="font-size:12px;">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end mono" style="font-size:13px;font-weight:600;">{{ inv.current_value | currency }}</td>
|
<td class="text-end mono" style="font-size:13px;font-weight:600;">{{ inv.current_value | currency }}</td>
|
||||||
<td class="text-end" style="padding-right:8px;">
|
<td class="text-end" style="padding-right:8px;">
|
||||||
@@ -227,8 +193,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{# Hidden chart row for this holding #}
|
|
||||||
{% if inv.ticker %}
|
{% if inv.ticker %}
|
||||||
<tr class="chart-row" id="chart-row-{{ inv.id }}" style="display:none;">
|
<tr class="chart-row" id="chart-row-{{ inv.id }}" style="display:none;">
|
||||||
<td colspan="6">
|
<td colspan="6">
|
||||||
@@ -247,17 +211,77 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-meta mt-1" id="chart-info-{{ inv.id }}">Loading…</div>
|
<div class="chart-meta mt-1" id="chart-info-{{ inv.id }}">Loading…</div>
|
||||||
<div class="chart-wrap">
|
<div class="chart-wrap"><canvas id="canvas-{{ inv.id }}"></canvas></div>
|
||||||
<canvas id="canvas-{{ inv.id }}"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
<!-- Chart + Allocation -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<div class="pcard h-100">
|
||||||
|
<div class="pcard-title mb-3">Allocation</div>
|
||||||
|
<div style="position:relative;height:200px;display:flex;align-items:center;justify-content:center;">
|
||||||
|
<canvas id="allocChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
{% for item in portfolio.allocation %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center py-1" style="border-bottom:1px solid var(--border);">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div style="width:10px;height:10px;border-radius:2px;background:{{ item.color }};flex-shrink:0;"></div>
|
||||||
|
<span style="font-size:13px;">{{ item.type | replace('_',' ') | title }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<span class="mono text-muted" style="font-size:12px;">{{ item.value | currency }}</span>
|
||||||
|
<span class="mono" style="font-size:12px;width:40px;text-align:right;">{{ item.pct }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{# Per-account balance breakdown #}
|
||||||
|
{% if portfolio.account_groups %}
|
||||||
|
<div class="mt-3 pt-2" style="border-top:1px solid var(--border);">
|
||||||
|
<div style="font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px;">By Account</div>
|
||||||
|
{% for grp in portfolio.account_groups %}
|
||||||
|
<div class="d-flex justify-content-between py-1">
|
||||||
|
<span style="font-size:12px;">{{ grp.account.name if grp.account else 'Unlinked' }}</span>
|
||||||
|
<span class="mono text-invest" style="font-size:12px;font-weight:600;">{{ grp.total_value | currency }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Holdings: one section per account when multi-account, single table otherwise -->
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
{% if portfolio.account_groups %}
|
||||||
|
{% for grp in portfolio.account_groups %}
|
||||||
|
<div class="pcard p-0 {% if not loop.last %}mb-3{% endif %}">
|
||||||
|
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-bottom:1px solid var(--border);">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<span class="pcard-title mb-0">{{ grp.account.name if grp.account else 'Unlinked Holdings' }}</span>
|
||||||
|
<span class="mono text-invest" style="font-size:14px;font-weight:700;">{{ grp.total_value | currency }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-muted" style="font-size:11px;">{{ grp.investments | length }} holding(s)</span>
|
||||||
|
</div>
|
||||||
|
{{ holdings_table(grp.investments) }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="pcard p-0 h-100">
|
||||||
|
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-bottom:1px solid var(--border);">
|
||||||
|
<span class="pcard-title mb-0">Holdings</span>
|
||||||
|
<span class="text-muted" style="font-size:11px;">Click <i class="bi bi-bar-chart-line"></i> to expand price chart</span>
|
||||||
|
</div>
|
||||||
|
{{ holdings_table(portfolio.investments) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -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.")
|
||||||
Reference in New Issue
Block a user