From 5cf3726f598f97057526ed85050e6ce98cf98d4f Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 6 Apr 2026 19:30:48 +0200 Subject: [PATCH] Improve portfolio chart with historical price-based data points Chart now plots weekly data points from first purchase to today using candle/history price data, giving an accurate view of portfolio value over time rather than just at purchase dates. Backend seeds up to 365 days of daily close prices from CoinGecko as synthetic OHLC candles, refreshing stale entries older than 31 days. Co-Authored-By: Claude Sonnet 4.6 --- btc-portfolio/backend/app/services/candles.py | 48 ++++++++++- .../frontend/src/components/PortfolioChart.js | 86 ++++++++++++++----- btc-portfolio/frontend/src/pages/Dashboard.js | 2 +- 3 files changed, 112 insertions(+), 24 deletions(-) diff --git a/btc-portfolio/backend/app/services/candles.py b/btc-portfolio/backend/app/services/candles.py index 434a569..31016ad 100644 --- a/btc-portfolio/backend/app/services/candles.py +++ b/btc-portfolio/backend/app/services/candles.py @@ -1,10 +1,10 @@ import logging -from datetime import datetime, timezone, date as dt_date +from datetime import datetime, timezone, timedelta, date as dt_date from sqlalchemy.orm import Session from ..models import OHLCCandle -from .btc import get_btc_ohlc_eur, aggregate_to_daily +from .btc import get_btc_ohlc_eur, aggregate_to_daily, get_btc_history_eur logger = logging.getLogger(__name__) @@ -30,10 +30,54 @@ def seed_candles(db: Session) -> None: logger.info("Candle seed: stored %d daily candles (%s → %s).", len(rows), min(daily.keys()), max(daily.keys())) +def seed_historical_prices(db: Session) -> None: + """Backfill up to 365 days of daily close prices from CoinGecko market_chart. + Uses previous day's close as each day's open to produce red/green candles. + Clears entries older than 31 days on each run so the data stays fresh. + Real OHLC entries (last 30 days) are never touched. + """ + raw = get_btc_history_eur() + if not raw: + logger.warning("Historical price seed: CoinGecko returned no data.") + return + + prices = {} + for ts_ms, price in raw: + date = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d") + prices[date] = price + + # Remove stale historical entries (older than 31 days) so they get re-seeded with current data + cutoff = (datetime.now(tz=timezone.utc) - timedelta(days=31)).strftime("%Y-%m-%d") + db.query(OHLCCandle).filter(OHLCCandle.date < cutoff).delete() + db.commit() + + existing = {c.date for c in db.query(OHLCCandle).all()} + + new_rows = [] + prev_close = None + for date, close in sorted(prices.items()): + if date in existing: + prev_close = close + continue + open_ = prev_close if prev_close is not None else close + high = max(open_, close) + low = min(open_, close) + new_rows.append(OHLCCandle(date=date, open=open_, high=high, low=low, close=close)) + prev_close = close + + if new_rows: + db.add_all(new_rows) + db.commit() + logger.info("Historical price seed: stored %d daily entries (%s → %s).", len(new_rows), new_rows[0].date, new_rows[-1].date) + + def refresh_latest_candles(db: Session) -> None: """Add any missing candles up to today. Seeds the DB if empty. Also detects and replaces coarse (>2-day gap) legacy data from a previous days=365 seed. """ + # Always backfill historical prices for dates not yet in DB (no-op once populated) + seed_historical_prices(db) + # Sparse-data detection: if existing candles have >2-day gaps, wipe and re-seed first_two = db.query(OHLCCandle).order_by(OHLCCandle.date.asc()).limit(2).all() if len(first_two) == 2: diff --git a/btc-portfolio/frontend/src/components/PortfolioChart.js b/btc-portfolio/frontend/src/components/PortfolioChart.js index f8a0ffd..628e70c 100644 --- a/btc-portfolio/frontend/src/components/PortfolioChart.js +++ b/btc-portfolio/frontend/src/components/PortfolioChart.js @@ -19,40 +19,84 @@ const styles = { saveBtn: { marginTop: '0.75rem', background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '6px', padding: '0.4rem 1rem', cursor: 'pointer', fontSize: '0.85rem' }, }; -export default function PortfolioChart({ purchases, stats }) { +function toDateKey(date) { + return date.toISOString().split('T')[0]; +} + +function priceOn(date, priceMap, currentPrice, isToday, sortedPurchases) { + if (isToday) return currentPrice || 0; + // Try candle history (walk back up to 7 days) + for (let i = 0; i <= 7; i++) { + const d = new Date(date); + d.setDate(d.getDate() - i); + const p = priceMap[toDateKey(d)]; + if (p) return p; + } + // Fall back to most recent purchase price up to this date + let fallback = null; + for (const p of sortedPurchases) { + const pd = new Date(p.created_at); + pd.setHours(0, 0, 0, 0); + if (pd <= date) fallback = p.price_eur; + } + return fallback; +} + +export default function PortfolioChart({ purchases, stats, btcHistory }) { const chartRef = useRef(null); if (!purchases || purchases.length === 0) return null; - // Build cumulative data points sorted by date const sorted = [...purchases].sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Build price lookup from candle history + const priceMap = {}; + (btcHistory || []).forEach(({ date, close }) => { priceMap[date] = close; }); + + const firstDate = new Date(sorted[0].created_at); + firstDate.setHours(0, 0, 0, 0); + + // Generate biweekly dates from first purchase to today + const dates = []; + const cursor = new Date(firstDate); + while (cursor <= today) { + dates.push(new Date(cursor)); + cursor.setDate(cursor.getDate() + 7); + } + if (toDateKey(dates[dates.length - 1]) !== toDateKey(today)) { + dates.push(new Date(today)); + } - let cumInvested = 0; - let cumBtc = 0; const labels = []; const portfolioValues = []; const investedValues = []; - sorted.forEach((p, i) => { - cumInvested += p.amount_eur; - cumBtc += p.amount_eur / p.price_eur; - const currentVal = cumBtc * (stats?.current_price || p.price_eur); - labels.push(new Date(p.created_at).toLocaleDateString()); - portfolioValues.push(parseFloat(currentVal.toFixed(2))); + dates.forEach(date => { + const isToday = toDateKey(date) === toDateKey(today); + const price = priceOn(date, priceMap, stats?.current_price, isToday, sorted); + if (price === null) return; // no price data available, skip + + // Cumulative BTC and invested up to this date + let cumBtc = 0; + let cumInvested = 0; + sorted.forEach(p => { + const pDate = new Date(p.created_at); + pDate.setHours(0, 0, 0, 0); + if (pDate <= date) { + cumBtc += p.amount_eur / p.price_eur; + cumInvested += p.amount_eur; + } + }); + + if (cumBtc === 0) return; // no purchases yet at this date + + labels.push(date.toLocaleDateString('en-GB')); + portfolioValues.push(parseFloat((cumBtc * price).toFixed(2))); investedValues.push(parseFloat(cumInvested.toFixed(2))); }); - const todayLabel = new Date().toLocaleDateString(); - if (labels.length === 0 || labels[labels.length - 1] !== todayLabel) { - const currentVal = cumBtc * (stats?.current_price || 0); - labels.push(todayLabel); - portfolioValues.push(parseFloat(currentVal.toFixed(2))); - investedValues.push(parseFloat(cumInvested.toFixed(2))); - } - - const currentPrice = stats?.current_price || 0; - const breakEvenLine = labels.map(() => stats?.average_price || 0); - const data = { labels, datasets: [ diff --git a/btc-portfolio/frontend/src/pages/Dashboard.js b/btc-portfolio/frontend/src/pages/Dashboard.js index dd5baff..4b3762c 100644 --- a/btc-portfolio/frontend/src/pages/Dashboard.js +++ b/btc-portfolio/frontend/src/pages/Dashboard.js @@ -135,7 +135,7 @@ export default function Dashboard() { >{label} ))} - {(chartView === 'both' || chartView === 'portfolio') && } + {(chartView === 'both' || chartView === 'portfolio') && } {(chartView === 'both' || chartView === 'history') && (