06/01 Adding porfolio charts
This commit is contained in:
@@ -6,7 +6,7 @@ from wtforms.validators import DataRequired, Optional, NumberRange, Length
|
|||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.investment import Investment, InvestmentTransaction
|
from app.models.investment import Investment, InvestmentTransaction
|
||||||
from app.services.investment_service import (
|
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
|
ASSET_COLORS, ASSET_TYPE_LABELS
|
||||||
)
|
)
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@@ -273,3 +273,22 @@ def api_price(ticker):
|
|||||||
'price': price,
|
'price': price,
|
||||||
'error': error,
|
'error': error,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@investments_bp.route('/api/history/<ticker>')
|
||||||
|
@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)
|
||||||
|
|||||||
@@ -102,6 +102,81 @@ def _parse_v8_price(data):
|
|||||||
return None
|
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):
|
def update_prices(investment_ids=None):
|
||||||
"""
|
"""
|
||||||
Update current_price for all (or specified) investments with a ticker.
|
Update current_price for all (or specified) investments with a ticker.
|
||||||
|
|||||||
@@ -12,6 +12,45 @@
|
|||||||
<a href="{{ url_for('investments.new') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i>Add Holding</a>
|
<a href="{{ url_for('investments.new') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i>Add Holding</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.chg-badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 3px;
|
||||||
|
font-size: 11px; font-weight: 600; font-family: 'DM Mono', monospace;
|
||||||
|
padding: 2px 7px; border-radius: 4px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chg-up { background: #d1fae5; color: #065f46; }
|
||||||
|
.chg-down { background: #fee2e2; color: #991b1b; }
|
||||||
|
.chg-flat { background: #f1f5f9; color: #64748b; }
|
||||||
|
.chg-loading { background: #f1f5f9; color: #94a3b8; animation: pulse .8s ease infinite alternate; }
|
||||||
|
@keyframes pulse { from { opacity: .5; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
.expand-btn {
|
||||||
|
background: none; border: none; padding: 3px 6px;
|
||||||
|
color: var(--muted); border-radius: 4px; cursor: pointer;
|
||||||
|
transition: all .15s; line-height: 1;
|
||||||
|
}
|
||||||
|
.expand-btn:hover { background: var(--border); color: var(--text); }
|
||||||
|
.expand-btn.open { color: var(--accent); }
|
||||||
|
|
||||||
|
.chart-row td { padding: 0 !important; border-bottom: 2px solid var(--accent) !important; }
|
||||||
|
.chart-panel {
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: linear-gradient(180deg, #f8faff 0%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
.tf-btn {
|
||||||
|
font-size: 11px; font-weight: 600; padding: 3px 10px;
|
||||||
|
border-radius: 4px; border: 1px solid var(--border);
|
||||||
|
background: #fff; color: var(--muted); cursor: pointer;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.tf-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.tf-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
.chart-wrap { position: relative; height: 180px; margin-top: 12px; }
|
||||||
|
.chart-meta { font-size: 12px; color: var(--muted); }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if portfolio.count == 0 %}
|
{% if portfolio.count == 0 %}
|
||||||
<div class="pcard text-center py-5">
|
<div class="pcard text-center py-5">
|
||||||
@@ -78,10 +117,10 @@
|
|||||||
|
|
||||||
<!-- Chart + Allocation -->
|
<!-- Chart + Allocation -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-12 col-lg-5">
|
<div class="col-12 col-lg-4">
|
||||||
<div class="pcard h-100">
|
<div class="pcard h-100">
|
||||||
<div class="pcard-title mb-3">Allocation</div>
|
<div class="pcard-title mb-3">Allocation</div>
|
||||||
<div style="position:relative;height:220px;display:flex;align-items:center;justify-content:center;">
|
<div style="position:relative;height:200px;display:flex;align-items:center;justify-content:center;">
|
||||||
<canvas id="allocChart"></canvas>
|
<canvas id="allocChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
@@ -102,24 +141,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Holdings table -->
|
<!-- Holdings table -->
|
||||||
<div class="col-12 col-lg-7">
|
<div class="col-12 col-lg-8">
|
||||||
<div class="pcard p-0 h-100">
|
<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);">
|
<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="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>
|
</div>
|
||||||
<table class="pfm-table">
|
<table class="pfm-table" id="holdingsTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="padding-left:20px;">Asset</th>
|
<th style="padding-left:20px;">Asset</th>
|
||||||
<th class="text-end">Shares</th>
|
|
||||||
<th class="text-end">Price</th>
|
<th class="text-end">Price</th>
|
||||||
|
<th class="text-end">1D Chg</th>
|
||||||
<th class="text-end">Value</th>
|
<th class="text-end">Value</th>
|
||||||
<th class="text-end" style="padding-right:20px;">P&L</th>
|
<th class="text-end" style="padding-right:8px;">P&L</th>
|
||||||
|
<th style="width:36px;"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for inv in portfolio.investments %}
|
{% for inv in portfolio.investments %}
|
||||||
<tr style="cursor:pointer;" onclick="location.href='{{ url_for('investments.detail', id=inv.id) }}'">
|
{# Main holding row #}
|
||||||
|
<tr id="row-{{ inv.id }}" style="cursor:pointer;"
|
||||||
|
onclick="location.href='{{ url_for('investments.detail', id=inv.id) }}'">
|
||||||
<td style="padding-left:20px;">
|
<td style="padding-left:20px;">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<div style="width:28px;height:28px;border-radius:7px;background:{{ asset_colors.get(inv.asset_type,'#94a3b8') }}22;color:{{ asset_colors.get(inv.asset_type,'#94a3b8') }};display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;">
|
<div style="width:28px;height:28px;border-radius:7px;background:{{ asset_colors.get(inv.asset_type,'#94a3b8') }}22;color:{{ asset_colors.get(inv.asset_type,'#94a3b8') }};display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;">
|
||||||
@@ -134,9 +177,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end mono" style="font-size:12px;color:var(--muted);">
|
|
||||||
{{ inv.shares | shares }}
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
{% if inv.current_price %}
|
{% if inv.current_price %}
|
||||||
<span class="mono" style="font-size:12px;">{{ inv.current_price | currency }}</span>
|
<span class="mono" style="font-size:12px;">{{ inv.current_price | currency }}</span>
|
||||||
@@ -144,8 +184,15 @@
|
|||||||
<span class="text-muted" style="font-size:12px;">—</span>
|
<span class="text-muted" style="font-size:12px;">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="text-end" id="chg-{{ inv.id }}">
|
||||||
|
{% if inv.ticker %}
|
||||||
|
<span class="chg-badge chg-loading">…</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted" style="font-size:12px;">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</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:20px;">
|
<td class="text-end" style="padding-right:8px;">
|
||||||
<div class="mono {% if inv.unrealized_gain >= 0 %}text-income{% else %}text-expense{% endif %}" style="font-size:12px;font-weight:600;">
|
<div class="mono {% if inv.unrealized_gain >= 0 %}text-income{% else %}text-expense{% endif %}" style="font-size:12px;font-weight:600;">
|
||||||
{% if inv.unrealized_gain >= 0 %}+{% endif %}{{ inv.unrealized_gain | currency }}
|
{% if inv.unrealized_gain >= 0 %}+{% endif %}{{ inv.unrealized_gain | currency }}
|
||||||
</div>
|
</div>
|
||||||
@@ -153,7 +200,43 @@
|
|||||||
{% if inv.unrealized_gain_pct >= 0 %}+{% endif %}{{ inv.unrealized_gain_pct }}%
|
{% if inv.unrealized_gain_pct >= 0 %}+{% endif %}{{ inv.unrealized_gain_pct }}%
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td onclick="event.stopPropagation();" style="padding:0 8px;">
|
||||||
|
{% if inv.ticker %}
|
||||||
|
<button class="expand-btn" id="expand-{{ inv.id }}"
|
||||||
|
onclick="toggleChart({{ inv.id }}, '{{ inv.ticker }}', '{{ asset_colors.get(inv.asset_type,'#3b82f6') }}')"
|
||||||
|
title="Show price chart">
|
||||||
|
<i class="bi bi-bar-chart-line" style="font-size:14px;"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
{# Hidden chart row for this holding #}
|
||||||
|
{% if inv.ticker %}
|
||||||
|
<tr class="chart-row" id="chart-row-{{ inv.id }}" style="display:none;">
|
||||||
|
<td colspan="6">
|
||||||
|
<div class="chart-panel" id="chart-panel-{{ inv.id }}">
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span style="font-size:13px;font-weight:600;color:var(--text);">{{ inv.asset_name }}</span>
|
||||||
|
<span class="mono text-muted" style="font-size:11px;">{{ inv.ticker }}</span>
|
||||||
|
<span id="chart-chg-{{ inv.id }}" class="chg-badge chg-flat" style="display:none;"></span>
|
||||||
|
<div class="ms-auto d-flex gap-1 flex-wrap">
|
||||||
|
{% for tf in ['1W','1M','3M','6M','1Y'] %}
|
||||||
|
<button class="tf-btn{% if tf == '1M' %} active{% endif %}"
|
||||||
|
onclick="loadChart({{ inv.id }}, '{{ inv.ticker }}', '{{ tf }}', '{{ asset_colors.get(inv.asset_type,'#3b82f6') }}')">
|
||||||
|
{{ tf }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-meta mt-1" id="chart-info-{{ inv.id }}">Loading…</div>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<canvas id="canvas-{{ inv.id }}"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -168,6 +251,10 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Allocation doughnut ────────────────────────────────────────────────────
|
||||||
|
(function(){
|
||||||
const ctx = document.getElementById('allocChart').getContext('2d');
|
const ctx = document.getElementById('allocChart').getContext('2d');
|
||||||
new Chart(ctx, {
|
new Chart(ctx, {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
@@ -176,28 +263,203 @@
|
|||||||
datasets: [{
|
datasets: [{
|
||||||
data: {{ chart_values | tojson }},
|
data: {{ chart_values | tojson }},
|
||||||
backgroundColor: {{ chart_colors | tojson }},
|
backgroundColor: {{ chart_colors | tojson }},
|
||||||
borderWidth: 2,
|
borderWidth: 2, borderColor: '#ffffff', hoverOffset: 6,
|
||||||
borderColor: '#ffffff',
|
|
||||||
hoverOffset: 6,
|
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true, maintainAspectRatio: false, cutout: '68%',
|
||||||
maintainAspectRatio: false,
|
|
||||||
cutout: '68%',
|
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: false },
|
legend: { display: false },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: function(ctx) {
|
label: ctx => ' {{ current_user.currency_symbol }}' +
|
||||||
const sym = '{{ current_user.currency_symbol }}';
|
ctx.parsed.toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})
|
||||||
return ' ' + sym + ctx.parsed.toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Per-holding state ─────────────────────────────────────────────────────
|
||||||
|
const chartInstances = {}; // inv_id → Chart instance
|
||||||
|
const loadedTf = {}; // inv_id → last loaded timeframe
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
const sym = '{{ current_user.currency_symbol }}';
|
||||||
|
|
||||||
|
function fmtPrice(v) {
|
||||||
|
return sym + parseFloat(v).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:4});
|
||||||
|
}
|
||||||
|
function fmtChg(change, pct) {
|
||||||
|
const sign = change >= 0 ? '+' : '';
|
||||||
|
return `${sign}${fmtPrice(change)} (${sign}${parseFloat(pct).toFixed(2)}%)`;
|
||||||
|
}
|
||||||
|
function chgClass(v) {
|
||||||
|
return v > 0 ? 'chg-up' : v < 0 ? 'chg-down' : 'chg-flat';
|
||||||
|
}
|
||||||
|
function chgIcon(v) {
|
||||||
|
return v > 0 ? 'bi-arrow-up-short' : v < 0 ? 'bi-arrow-down-short' : 'bi-dash';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBadge(el, change, pct) {
|
||||||
|
el.className = 'chg-badge ' + chgClass(change);
|
||||||
|
el.innerHTML = `<i class="bi ${chgIcon(change)}"></i>${fmtChg(change, pct)}`;
|
||||||
|
el.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load 1D change badges for all tickers ─────────────────────────────────
|
||||||
|
const tickerRows = [
|
||||||
|
{% for inv in portfolio.investments %}{% if inv.ticker %}
|
||||||
|
{ id: {{ inv.id }}, ticker: '{{ inv.ticker }}' },
|
||||||
|
{% endif %}{% endfor %}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fetch all day-changes in parallel
|
||||||
|
Promise.all(
|
||||||
|
tickerRows.map(r =>
|
||||||
|
fetch(`{{ url_for('investments.api_price_history', ticker='__T__') }}`.replace('__T__', r.ticker) + '?tf=1W')
|
||||||
|
.then(res => res.ok ? res.json() : null)
|
||||||
|
.then(data => ({ id: r.id, data }))
|
||||||
|
.catch(() => ({ id: r.id, data: null }))
|
||||||
|
)
|
||||||
|
).then(results => {
|
||||||
|
results.forEach(({ id, data }) => {
|
||||||
|
const cell = document.getElementById('chg-' + id);
|
||||||
|
if (!cell) return;
|
||||||
|
if (data && data.day_change !== undefined) {
|
||||||
|
const badge = cell.querySelector('.chg-badge') || cell;
|
||||||
|
badge.className = 'chg-badge ' + chgClass(data.day_change);
|
||||||
|
badge.innerHTML =
|
||||||
|
`<i class="bi ${chgIcon(data.day_change)}"></i>` +
|
||||||
|
(data.day_change >= 0 ? '+' : '') +
|
||||||
|
parseFloat(data.day_change_pct).toFixed(2) + '%';
|
||||||
|
} else {
|
||||||
|
cell.innerHTML = '<span class="text-muted" style="font-size:12px;">—</span>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Toggle chart row ──────────────────────────────────────────────────────
|
||||||
|
window.toggleChart = function(id, ticker, color) {
|
||||||
|
const row = document.getElementById('chart-row-' + id);
|
||||||
|
const btn = document.getElementById('expand-' + id);
|
||||||
|
const isOpen = row.style.display !== 'none';
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
row.style.display = 'none';
|
||||||
|
btn.classList.remove('open');
|
||||||
|
} else {
|
||||||
|
row.style.display = '';
|
||||||
|
btn.classList.add('open');
|
||||||
|
// Load default timeframe on first open
|
||||||
|
if (!loadedTf[id]) {
|
||||||
|
loadChart(id, ticker, '1M', color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Load / refresh chart for a holding ────────────────────────────────────
|
||||||
|
window.loadChart = function(id, ticker, tf, color) {
|
||||||
|
// Update active timeframe button
|
||||||
|
const panel = document.getElementById('chart-panel-' + id);
|
||||||
|
panel.querySelectorAll('.tf-btn').forEach(b => {
|
||||||
|
b.classList.toggle('active', b.textContent.trim() === tf);
|
||||||
|
});
|
||||||
|
|
||||||
|
const infoEl = document.getElementById('chart-info-' + id);
|
||||||
|
infoEl.textContent = 'Loading…';
|
||||||
|
|
||||||
|
const url = `{{ url_for('investments.api_price_history', ticker='__T__') }}`
|
||||||
|
.replace('__T__', ticker) + '?tf=' + tf;
|
||||||
|
|
||||||
|
fetch(url)
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
||||||
|
.then(data => renderChart(id, data, color, tf))
|
||||||
|
.catch(err => {
|
||||||
|
infoEl.textContent = 'Could not load price data.';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderChart(id, data, color, tf) {
|
||||||
|
const infoEl = document.getElementById('chart-info-' + id);
|
||||||
|
const chgEl = document.getElementById('chart-chg-' + id);
|
||||||
|
|
||||||
|
// Update period change badge in chart header
|
||||||
|
setBadge(chgEl, data.period_change, data.period_change_pct);
|
||||||
|
|
||||||
|
// Info line
|
||||||
|
const tfLabel = { '1W':'1 Week','1M':'1 Month','3M':'3 Months','6M':'6 Months','1Y':'1 Year' }[tf] || tf;
|
||||||
|
infoEl.innerHTML =
|
||||||
|
`Current: <strong>${fmtPrice(data.current)}</strong> · ` +
|
||||||
|
`${tfLabel} change: <strong class="${data.period_change >= 0 ? 'text-income' : 'text-expense'}">${
|
||||||
|
fmtChg(data.period_change, data.period_change_pct)
|
||||||
|
}</strong> · ` +
|
||||||
|
`Prev close: ${fmtPrice(data.prev_close)}`;
|
||||||
|
|
||||||
|
const lineColor = data.period_change >= 0 ? '#10b981' : '#ef4444';
|
||||||
|
const fillColor = data.period_change >= 0 ? '#10b98118' : '#ef444418';
|
||||||
|
|
||||||
|
const canvas = document.getElementById('canvas-' + id);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Destroy previous chart if exists
|
||||||
|
if (chartInstances[id]) {
|
||||||
|
chartInstances[id].destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInstances[id] = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.dates,
|
||||||
|
datasets: [{
|
||||||
|
label: data.ticker,
|
||||||
|
data: data.closes,
|
||||||
|
borderColor: lineColor,
|
||||||
|
backgroundColor: fillColor,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: data.closes.length > 60 ? 0 : 2,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: c => ' ' + fmtPrice(c.parsed.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: {
|
||||||
|
font: { size: 10 },
|
||||||
|
maxTicksLimit: 8,
|
||||||
|
maxRotation: 0,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
position: 'right',
|
||||||
|
grid: { color: '#f1f5f9' },
|
||||||
|
ticks: {
|
||||||
|
font: { size: 10 },
|
||||||
|
callback: v => sym + parseFloat(v).toLocaleString(undefined, {minimumFractionDigits:2}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadedTf[id] = tf;
|
||||||
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
Reference in New Issue
Block a user