From e040f0a0f8c8338f4580deb07e6958792d3eaf79 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 1 Jun 2026 17:19:24 -0400 Subject: [PATCH] 06/01 Adding porfolio charts --- app/routes/investments.py | 21 +- app/services/investment_service.py | 75 ++++++ app/templates/investments/index.html | 334 ++++++++++++++++++++++++--- 3 files changed, 393 insertions(+), 37 deletions(-) diff --git a/app/routes/investments.py b/app/routes/investments.py index 2c5ca1a..b570a8b 100644 --- a/app/routes/investments.py +++ b/app/routes/investments.py @@ -6,7 +6,7 @@ from wtforms.validators import DataRequired, Optional, NumberRange, Length from app.extensions import db from app.models.investment import Investment, InvestmentTransaction from app.services.investment_service import ( - get_portfolio_summary, update_prices, fetch_price, + get_portfolio_summary, update_prices, fetch_price, fetch_price_history, ASSET_COLORS, ASSET_TYPE_LABELS ) from datetime import date @@ -273,3 +273,22 @@ def api_price(ticker): 'price': price, 'error': error, }) + + +@investments_bp.route('/api/history/') +@login_required +def api_price_history(ticker): + """ + Return OHLC history + day/period change for a ticker. + Query param: tf = 1W | 1M | 3M | 6M | 1Y (default 1M) + Used by the portfolio page inline charts. + """ + tf = request.args.get('tf', '1M').upper() + if tf not in ('1W', '1M', '3M', '6M', '1Y'): + tf = '1M' + + data = fetch_price_history(ticker.upper().strip(), tf) + if data is None: + return jsonify({'error': f'No history data available for {ticker}'}), 404 + + return jsonify(data) diff --git a/app/services/investment_service.py b/app/services/investment_service.py index 6c9cc89..59d6e76 100644 --- a/app/services/investment_service.py +++ b/app/services/investment_service.py @@ -102,6 +102,81 @@ def _parse_v8_price(data): return None +TIMEFRAME_MAP = { + '1W': ('5d', '1d'), + '1M': ('1mo', '1d'), + '3M': ('3mo', '1d'), + '6M': ('6mo', '1wk'), + '1Y': ('1y', '1wk'), +} + + +def fetch_price_history(ticker, timeframe='1M'): + """ + Fetch historical closing prices for a ticker via Yahoo Finance v8 API. + timeframe: '1W' | '1M' | '3M' | '6M' | '1Y' + + Returns dict: + ticker, current, prev_close, day_change, day_change_pct, + period_change, period_change_pct, dates, closes, timeframe + Returns None on failure. + """ + if not ticker: + return None + + ticker = ticker.upper().strip() + yf_range, yf_interval = TIMEFRAME_MAP.get(timeframe, ('1mo', '1d')) + + for subdomain in ('query1', 'query2'): + url = ( + f'https://{subdomain}.finance.yahoo.com/v8/finance/chart/{ticker}' + f'?range={yf_range}&interval={yf_interval}&includePrePost=false' + ) + try: + resp = requests.get(url, headers=HEADERS, timeout=15) + if resp.status_code != 200: + continue + data = resp.json() + result = data.get('chart', {}).get('result') + if not result: + return None + + timestamps = result[0].get('timestamp', []) + closes_raw = result[0]['indicators']['quote'][0].get('close', []) + pairs = [(t, c) for t, c in zip(timestamps, closes_raw) if c is not None] + if not pairs: + return None + + dates = [datetime.utcfromtimestamp(t).strftime('%Y-%m-%d') for t, _ in pairs] + closes = [round(float(c), 4) for _, c in pairs] + + current = closes[-1] + prev = closes[-2] if len(closes) > 1 else current + day_change = round(current - prev, 4) + day_change_pct = round(day_change / prev * 100, 2) if prev != 0 else 0 + period_change = round(current - closes[0], 4) + period_change_pct = round(period_change / closes[0] * 100, 2) if closes[0] != 0 else 0 + + log.info('[investment] %s history: %d points (%s)', ticker, len(closes), timeframe) + return { + 'ticker': ticker, + 'current': current, + 'prev_close': prev, + 'day_change': day_change, + 'day_change_pct': day_change_pct, + 'period_change': period_change, + 'period_change_pct': period_change_pct, + 'dates': dates, + 'closes': closes, + 'timeframe': timeframe, + } + except Exception as exc: + log.warning('[investment] %s history fetch failed (%s): %s', ticker, subdomain, exc) + + log.error('[investment] %s: history fetch failed on all subdomains', ticker) + return None + + def update_prices(investment_ids=None): """ Update current_price for all (or specified) investments with a ticker. diff --git a/app/templates/investments/index.html b/app/templates/investments/index.html index 19713d3..dba2710 100644 --- a/app/templates/investments/index.html +++ b/app/templates/investments/index.html @@ -12,6 +12,45 @@ Add Holding {% endblock %} +{% block extra_css %} + +{% endblock %} + {% block content %} {% if portfolio.count == 0 %}
@@ -78,10 +117,10 @@
-
+
Allocation
-
+
@@ -102,24 +141,28 @@
-
+
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 %}
AssetShares Price1D Chg ValueP&LP&L
@@ -134,9 +177,6 @@
- {{ inv.shares | shares }} - {% if inv.current_price %} {{ inv.current_price | currency }} @@ -144,8 +184,15 @@ {% endif %} + {% if inv.ticker %} + + {% else %} + + {% endif %} + {{ inv.current_value | currency }} +
{% if inv.unrealized_gain >= 0 %}+{% endif %}{{ inv.unrealized_gain | currency }}
@@ -153,7 +200,43 @@ {% if inv.unrealized_gain_pct >= 0 %}+{% endif %}{{ inv.unrealized_gain_pct }}%
+ {% if inv.ticker %} + + {% endif %} +
@@ -168,36 +251,215 @@ {% endif %}