From fd21aa7f4e919b82f204cc697e05dbfb6c0e2a83 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 28 Apr 2026 21:17:46 +0200 Subject: [PATCH] Add DCA Calculator feature to dashboard Co-Authored-By: Claude Sonnet 4.6 --- btc-portfolio/backend/app/main.py | 3 +- btc-portfolio/backend/app/routes/dca.py | 108 ++++++++++++++ .../frontend/src/components/DCACalculator.js | 140 ++++++++++++++++++ btc-portfolio/frontend/src/pages/Dashboard.js | 3 + 4 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 btc-portfolio/backend/app/routes/dca.py create mode 100644 btc-portfolio/frontend/src/components/DCACalculator.js diff --git a/btc-portfolio/backend/app/main.py b/btc-portfolio/backend/app/main.py index cb66d15..862b060 100644 --- a/btc-portfolio/backend/app/main.py +++ b/btc-portfolio/backend/app/main.py @@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import text from .database import engine, Base, SessionLocal -from .routes import users, purchases, stats, history, admin, candles, sells +from .routes import users, purchases, stats, history, admin, candles, sells, dca Base.metadata.create_all(bind=engine) @@ -32,6 +32,7 @@ app.include_router(history.router) app.include_router(admin.router, prefix="/admin") app.include_router(candles.router) app.include_router(sells.router) +app.include_router(dca.router) @app.on_event("startup") diff --git a/btc-portfolio/backend/app/routes/dca.py b/btc-portfolio/backend/app/routes/dca.py new file mode 100644 index 0000000..0cf56fd --- /dev/null +++ b/btc-portfolio/backend/app/routes/dca.py @@ -0,0 +1,108 @@ +from datetime import date, timedelta +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from ..database import get_db +from .. import models +from ..dependencies import get_current_user +from ..services.btc import get_btc_price_eur + +router = APIRouter() + + +def _first_of_month(year: int, month: int) -> date: + return date(year, month, 1) + + +def _next_month(year: int, month: int) -> tuple[int, int]: + if month == 12: + return year + 1, 1 + return year, month + 1 + + +@router.get("/dca") +def get_dca( + monthly_amount: float = Query(..., gt=0), + start_date: Optional[str] = Query(default=None), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + # Determine start date + if start_date: + try: + sim_start = date.fromisoformat(start_date) + except ValueError: + sim_start = None + else: + sim_start = None + + if sim_start is None: + earliest = ( + db.query(models.Purchase) + .filter(models.Purchase.user_id == current_user.id) + .order_by(models.Purchase.created_at.asc()) + .first() + ) + if earliest: + sim_start = earliest.created_at.date() + else: + sim_start = date.today() - timedelta(days=365) + + # Load all candles into a lookup dict {date_str: close_price} + candles_db = db.query(models.OHLCCandle).all() + price_by_date: dict[str, float] = {c.date: c.close for c in candles_db} + + today = date.today() + current_price, _ = get_btc_price_eur() + # Patch today's price in case the candle isn't refreshed yet + if current_price: + price_by_date[today.isoformat()] = current_price + + # Walk month by month and simulate buys + year, month = sim_start.year, sim_start.month + end_year, end_month = today.year, today.month + + dca_invested = 0.0 + dca_btc = 0.0 + monthly_series = [] + + while (year, month) <= (end_year, end_month): + # Find the closest available candle on or after the 1st of this month + buy_date = None + buy_price = None + for day_offset in range(8): + candidate = _first_of_month(year, month) + timedelta(days=day_offset) + key = candidate.isoformat() + if key in price_by_date: + buy_date = key + buy_price = price_by_date[key] + break + + if buy_price and buy_price > 0: + btc_bought = monthly_amount / buy_price + dca_btc += btc_bought + dca_invested += monthly_amount + monthly_series.append({ + "month": f"{year:04d}-{month:02d}", + "price_used": round(buy_price, 2), + "btc_bought": round(btc_bought, 8), + "cumulative_btc": round(dca_btc, 8), + "cumulative_invested": round(dca_invested, 2), + }) + + year, month = _next_month(year, month) + + dca_current_value = dca_btc * current_price if current_price else 0.0 + dca_profit_loss = dca_current_value - dca_invested + + return { + "start_date": sim_start.isoformat(), + "monthly_amount": monthly_amount, + "dca_total_invested": round(dca_invested, 2), + "dca_total_btc": round(dca_btc, 8), + "dca_current_value": round(dca_current_value, 2), + "dca_profit_loss": round(dca_profit_loss, 2), + "monthly_series": monthly_series, + } diff --git a/btc-portfolio/frontend/src/components/DCACalculator.js b/btc-portfolio/frontend/src/components/DCACalculator.js new file mode 100644 index 0000000..7d03de1 --- /dev/null +++ b/btc-portfolio/frontend/src/components/DCACalculator.js @@ -0,0 +1,140 @@ +import React, { useState, useEffect, useRef } from 'react'; + +const API = process.env.REACT_APP_API_URL || 'http://localhost:8000'; + +const styles = { + card: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' }, + header: { color: '#888', fontSize: '0.8rem', marginBottom: '0.75rem' }, + row: { display: 'flex', gap: '1rem', alignItems: 'flex-start', flexWrap: 'wrap' }, + inputWrap: { display: 'flex', flexDirection: 'column', gap: '0.3rem' }, + label: { color: '#888', fontSize: '0.8rem' }, + input: { background: '#111', border: '1px solid #444', borderRadius: '8px', color: '#fff', padding: '0.55rem 0.75rem', fontSize: '1rem', width: '160px', outline: 'none' }, + results: { display: 'flex', flexDirection: 'column', gap: '0.75rem', flex: 1 }, + statsRow: { display: 'flex', gap: '1rem', flexWrap: 'wrap' }, + statCard: { background: '#111', padding: '0.75rem 1rem', borderRadius: '10px', border: '1px solid #2a2a2a', minWidth: '130px' }, + statLabel: { color: '#888', fontSize: '0.75rem', marginBottom: '0.2rem' }, + statValue: { fontSize: '1.1rem', fontWeight: 700 }, + positive: { color: '#6bff8e' }, + negative: { color: '#ff6b6b' }, + neutral: { color: '#f7931a' }, + comparison: { color: '#888', fontSize: '0.8rem' }, + placeholder: { color: '#555', fontSize: '0.9rem', padding: '0.5rem 0' }, +}; + +function MiniStatCard({ label, value, highlight }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export default function DCACalculator({ purchases, stats }) { + const defaultStart = () => { + if (purchases && purchases.length > 0) { + const sorted = [...purchases].sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); + return sorted[0].created_at.slice(0, 10); + } + const d = new Date(); + d.setFullYear(d.getFullYear() - 1); + return d.toISOString().slice(0, 10); + }; + + const [monthlyAmount, setMonthlyAmount] = useState(''); + const [startDate, setStartDate] = useState(defaultStart); + const [result, setResult] = useState(null); + const debounceRef = useRef(null); + + // Update default start date when purchases load + useEffect(() => { + if (purchases && purchases.length > 0 && !monthlyAmount) { + setStartDate(defaultStart()); + } + }, [purchases]); // eslint-disable-line + + useEffect(() => { + const amount = parseFloat(monthlyAmount); + if (!amount || amount <= 0 || !startDate) { + setResult(null); + return; + } + + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(async () => { + try { + const res = await fetch( + `${API}/dca?monthly_amount=${amount}&start_date=${startDate}`, + { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } } + ); + if (res.ok) setResult(await res.json()); + } catch { + // silently fail + } + }, 500); + + return () => clearTimeout(debounceRef.current); + }, [monthlyAmount, startDate]); + + const plHighlight = result + ? result.dca_profit_loss >= 0 ? 'positive' : 'negative' + : null; + + const actualPL = stats ? stats.profit_loss : null; + const actualHighlight = actualPL != null ? (actualPL >= 0 ? 'positive' : 'negative') : null; + + const fmt = (n) => `€${n.toLocaleString()}`; + const fmtPL = (n) => `${n >= 0 ? '+' : ''}${fmt(n)}`; + + return ( +
+
DCA Calculator — What if you had invested monthly?
+
+
+ + setMonthlyAmount(e.target.value)} + style={styles.input} + /> +
+
+ + setStartDate(e.target.value)} + style={styles.input} + /> +
+ + {result ? ( +
+
+ + + + +
+ {actualPL != null && ( +
+ Actual P&L:  + {fmtPL(actualPL)} +   |   + DCA P&L:  + {fmtPL(result.dca_profit_loss)} +
+ )} +
+ ) : ( +
+ Enter a monthly amount to simulate DCA +
+ )} +
+
+ ); +} diff --git a/btc-portfolio/frontend/src/pages/Dashboard.js b/btc-portfolio/frontend/src/pages/Dashboard.js index b3ca947..70f2cfc 100644 --- a/btc-portfolio/frontend/src/pages/Dashboard.js +++ b/btc-portfolio/frontend/src/pages/Dashboard.js @@ -6,6 +6,7 @@ import AddSell from '../components/AddSell'; import SellList from '../components/SellList'; import PortfolioChart from '../components/PortfolioChart'; import BTCCandlestickChart from '../components/BTCCandlestickChart'; +import DCACalculator from '../components/DCACalculator'; const API = process.env.REACT_APP_API_URL || 'http://localhost:8000'; @@ -193,6 +194,8 @@ export default function Dashboard() { + +
{[['both', 'Both'], ['portfolio', 'Portfolio'], ['history', 'BTC Candles']].map(([key, label]) => (