diff --git a/btc-portfolio/backend/app/main.py b/btc-portfolio/backend/app/main.py index 5a46d52..b785c2f 100644 --- a/btc-portfolio/backend/app/main.py +++ b/btc-portfolio/backend/app/main.py @@ -4,8 +4,8 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import text -from .database import engine, Base -from .routes import users, purchases, stats, history, admin +from .database import engine, Base, SessionLocal +from .routes import users, purchases, stats, history, admin, candles Base.metadata.create_all(bind=engine) @@ -26,16 +26,26 @@ app.include_router(purchases.router) app.include_router(stats.router) app.include_router(history.router) app.include_router(admin.router, prefix="/admin") +app.include_router(candles.router) @app.on_event("startup") -def migrate(): +def startup(): + # Schema migration: add is_admin column if missing with engine.connect() as conn: cols = [r[1] for r in conn.execute(text("PRAGMA table_info(users)"))] if "is_admin" not in cols: conn.execute(text("ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT 0")) conn.commit() + # Seed / refresh BTC candle data + from .services.candles import refresh_latest_candles + db = SessionLocal() + try: + refresh_latest_candles(db) + finally: + db.close() + @app.get("/") def root(): diff --git a/btc-portfolio/backend/app/models.py b/btc-portfolio/backend/app/models.py index 72084d0..30959f7 100644 --- a/btc-portfolio/backend/app/models.py +++ b/btc-portfolio/backend/app/models.py @@ -25,3 +25,14 @@ class Purchase(Base): user_id = Column(Integer, ForeignKey("users.id"), nullable=False) owner = relationship("User", back_populates="purchases") + + +class OHLCCandle(Base): + __tablename__ = "ohlc_candles" + + id = Column(Integer, primary_key=True, index=True) + date = Column(String, unique=True, index=True, nullable=False) # "YYYY-MM-DD" UTC + open = Column(Float, nullable=False) + high = Column(Float, nullable=False) + low = Column(Float, nullable=False) + close = Column(Float, nullable=False) diff --git a/btc-portfolio/backend/app/routes/candles.py b/btc-portfolio/backend/app/routes/candles.py new file mode 100644 index 0000000..b286be4 --- /dev/null +++ b/btc-portfolio/backend/app/routes/candles.py @@ -0,0 +1,53 @@ +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 + +router = APIRouter() + + +@router.get("/candles") +def get_candles( + days: str = Query(default="365"), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + query = db.query(models.OHLCCandle).order_by(models.OHLCCandle.date.asc()) + + if days != "all": + try: + limit = int(days) + except ValueError: + limit = 365 + # Fetch the most recent `limit` rows + total = query.count() + if total > limit: + query = query.offset(total - limit) + + candles_db = query.all() + candles = [ + { + "date": c.date, + "open": round(c.open, 2), + "high": round(c.high, 2), + "low": round(c.low, 2), + "close": round(c.close, 2), + } + for c in candles_db + ] + + purchases_db = db.query(models.Purchase).filter( + models.Purchase.user_id == current_user.id + ).all() + purchases = [ + { + "date": p.created_at.strftime("%Y-%m-%d"), + "amount_eur": round(p.amount_eur, 2), + "price_eur": round(p.price_eur, 2), + } + for p in purchases_db + ] + + return {"candles": candles, "purchases": purchases} diff --git a/btc-portfolio/backend/app/services/btc.py b/btc-portfolio/backend/app/services/btc.py index de914f2..20d50f0 100644 --- a/btc-portfolio/backend/app/services/btc.py +++ b/btc-portfolio/backend/app/services/btc.py @@ -1,4 +1,5 @@ import requests +from datetime import datetime, timezone def get_btc_history_eur() -> list: @@ -14,6 +15,40 @@ def get_btc_history_eur() -> list: return [] +def get_btc_ohlc_eur(days: int) -> list: + """Fetch OHLC candles from CoinGecko. Returns [[ts_ms, open, high, low, close], ...].""" + try: + resp = requests.get( + "https://api.coingecko.com/api/v3/coins/bitcoin/ohlc", + params={"vs_currency": "eur", "days": str(days)}, + timeout=20, + ) + resp.raise_for_status() + return resp.json() # [[timestamp_ms, open, high, low, close], ...] + except Exception: + return [] + + +def aggregate_to_daily(raw: list) -> dict: + """Collapse intraday OHLC rows into one candle per UTC calendar date. + + Returns {date_str: {open, high, low, close}} using first open, max high, + min low, last close per day. + """ + by_date: dict = {} + for row in raw: + ts_ms, o, h, l, c = row + date = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d") + if date not in by_date: + by_date[date] = {"open": o, "high": h, "low": l, "close": c} + else: + existing = by_date[date] + existing["high"] = max(existing["high"], h) + existing["low"] = min(existing["low"], l) + existing["close"] = c # last close wins + return by_date + + def get_btc_price_eur() -> float: try: resp = requests.get( diff --git a/btc-portfolio/backend/app/services/candles.py b/btc-portfolio/backend/app/services/candles.py new file mode 100644 index 0000000..434a569 --- /dev/null +++ b/btc-portfolio/backend/app/services/candles.py @@ -0,0 +1,73 @@ +import logging +from datetime import datetime, timezone, date as dt_date + +from sqlalchemy.orm import Session + +from ..models import OHLCCandle +from .btc import get_btc_ohlc_eur, aggregate_to_daily + +logger = logging.getLogger(__name__) + + +def seed_candles(db: Session) -> None: + """Fetch 30 days of daily OHLC candles from CoinGecko and store them. + Free tier gives 4-hour bars for days<=30, which aggregate cleanly to daily candles. + days>30 drops to 4-day granularity (unusable for a daily chart). + """ + raw = get_btc_ohlc_eur(days=30) + if not raw: + logger.warning("Candle seed: CoinGecko returned no data — will retry on next startup.") + return + + daily = aggregate_to_daily(raw) + rows = [ + OHLCCandle(date=date, open=v["open"], high=v["high"], low=v["low"], close=v["close"]) + for date, v in sorted(daily.items()) + ] + for row in rows: + db.merge(row) + db.commit() + logger.info("Candle seed: stored %d daily candles (%s → %s).", len(rows), min(daily.keys()), max(daily.keys())) + + +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. + """ + # 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: + d1 = dt_date.fromisoformat(first_two[0].date) + d2 = dt_date.fromisoformat(first_two[1].date) + if (d2 - d1).days > 2: + logger.warning("Candle refresh: detected coarse candle data (gap=%d days). Wiping and re-seeding with daily candles.", (d2 - d1).days) + db.query(OHLCCandle).delete() + db.commit() + seed_candles(db) + return + + latest = db.query(OHLCCandle).order_by(OHLCCandle.date.desc()).first() + + if latest is None: + seed_candles(db) + return + + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + if latest.date >= today: + return # Already up to date + + raw = get_btc_ohlc_eur(days=7) + if not raw: + logger.warning("Candle refresh: CoinGecko returned no data.") + return + + daily = aggregate_to_daily(raw) + new_dates = [d for d in daily if d > latest.date] + if not new_dates: + return + + for date in new_dates: + v = daily[date] + db.merge(OHLCCandle(date=date, open=v["open"], high=v["high"], low=v["low"], close=v["close"])) + db.commit() + logger.info("Candle refresh: upserted %d candle(s) up to %s.", len(new_dates), max(new_dates)) diff --git a/btc-portfolio/docker-compose.yml b/btc-portfolio/docker-compose.yml index 6733b42..d126cfc 100644 --- a/btc-portfolio/docker-compose.yml +++ b/btc-portfolio/docker-compose.yml @@ -15,7 +15,7 @@ services: args: - REACT_APP_API_URL=http://localhost:8000 ports: - - "3001:3000" + - "3001:3001" depends_on: - backend restart: unless-stopped diff --git a/btc-portfolio/frontend/Dockerfile b/btc-portfolio/frontend/Dockerfile index 4975171..42fb6de 100644 --- a/btc-portfolio/frontend/Dockerfile +++ b/btc-portfolio/frontend/Dockerfile @@ -12,5 +12,5 @@ FROM node:18-alpine RUN npm install -g serve WORKDIR /app COPY --from=build /app/build ./build -EXPOSE 3000 -CMD ["serve", "-s", "build", "-l", "3000"] +EXPOSE 3001 +CMD ["serve", "-s", "build", "-l", "3001"] diff --git a/btc-portfolio/frontend/package.json b/btc-portfolio/frontend/package.json index 77acbe6..1761141 100644 --- a/btc-portfolio/frontend/package.json +++ b/btc-portfolio/frontend/package.json @@ -8,7 +8,8 @@ "react-router-dom": "^6.22.0", "react-scripts": "5.0.1", "chart.js": "^4.4.0", - "react-chartjs-2": "^5.2.0" + "react-chartjs-2": "^5.2.0", + "lightweight-charts": "^4.2.0" }, "scripts": { "start": "react-scripts start", diff --git a/btc-portfolio/frontend/src/components/BTCCandlestickChart.js b/btc-portfolio/frontend/src/components/BTCCandlestickChart.js new file mode 100644 index 0000000..94e5946 --- /dev/null +++ b/btc-portfolio/frontend/src/components/BTCCandlestickChart.js @@ -0,0 +1,186 @@ +import React, { useRef, useEffect } from 'react'; +import { createChart, LineStyle } from 'lightweight-charts'; + +const cardStyle = { + background: '#1a1a1a', + padding: '1.5rem', + borderRadius: '12px', + border: '1px solid #333', + marginBottom: '1.5rem', +}; + +const fullscreenStyle = { + position: 'fixed', + top: 0, left: 0, right: 0, bottom: 0, + zIndex: 9999, + background: '#0d0d0d', + padding: '1.5rem', + display: 'flex', + flexDirection: 'column', +}; + +const headerStyle = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '1rem', +}; + +const titleStyle = { fontSize: '1.1rem', fontWeight: 700, color: '#f7931a' }; + +const btnStyle = { + background: 'none', + border: '1px solid #555', + color: '#aaa', + borderRadius: '6px', + padding: '0.4rem 1rem', + cursor: 'pointer', + fontSize: '0.85rem', + marginLeft: '0.5rem', +}; + +export default function BTCCandlestickChart({ candles, purchases, stats, fullscreen, onToggleFullscreen }) { + const containerRef = useRef(null); + const chartRef = useRef(null); + const candleSeriesRef = useRef(null); + + // Build/rebuild chart when candles data or fullscreen changes + useEffect(() => { + if (!containerRef.current || !candles || candles.length === 0) return; + + const container = containerRef.current; + const { width, height } = container.getBoundingClientRect(); + + const chart = createChart(container, { + width: width || 800, + height: fullscreen ? height - 20 : 350, + layout: { + background: { color: fullscreen ? '#0d0d0d' : '#1a1a1a' }, + textColor: '#ccc', + }, + grid: { + vertLines: { color: '#2a2a2a' }, + horzLines: { color: '#2a2a2a' }, + }, + crosshair: { mode: 1 }, + rightPriceScale: { borderColor: '#333' }, + timeScale: { borderColor: '#333', timeVisible: false }, + }); + + chartRef.current = chart; + + // Candlestick series + const candleSeries = chart.addCandlestickSeries({ + upColor: '#6bff8e', + downColor: '#ff6b6b', + borderUpColor: '#6bff8e', + borderDownColor: '#ff6b6b', + wickUpColor: '#6bff8e', + wickDownColor: '#ff6b6b', + }); + candleSeriesRef.current = candleSeries; + + const candleData = candles.map(c => ({ + time: c.date, + open: c.open, + high: c.high, + low: c.low, + close: c.close, + })); + candleSeries.setData(candleData); + + // Average buy price line + const avgPrice = stats?.average_price ?? 0; + if (avgPrice > 0) { + const avgSeries = chart.addLineSeries({ + color: '#4fc3f7', + lineWidth: 1, + lineStyle: LineStyle.Dashed, + priceLineVisible: false, + lastValueVisible: false, + title: `Avg Buy €${avgPrice.toLocaleString()}`, + }); + avgSeries.setData(candles.map(c => ({ time: c.date, value: avgPrice }))); + } + + // Purchase markers — use nearest-candle lookup so purchases always show + if (purchases && purchases.length > 0) { + const sortedDates = candles.map(c => c.date).sort(); + + const nearestDate = (target) => { + let closest = sortedDates[0]; + for (const d of sortedDates) { + if (d <= target) closest = d; + else break; + } + return closest; + }; + + // Merge multiple purchases on the same candle into one marker + const markerMap = {}; + for (const p of purchases) { + const d = nearestDate(p.date); + if (!markerMap[d]) { + markerMap[d] = { time: d, position: 'belowBar', color: '#f7931a', shape: 'arrowUp', text: `€${p.amount_eur.toLocaleString()}` }; + } else { + markerMap[d].text += ` +€${p.amount_eur.toLocaleString()}`; + } + } + const markers = Object.values(markerMap).sort((a, b) => a.time.localeCompare(b.time)); + if (markers.length > 0) candleSeries.setMarkers(markers); + } + + chart.timeScale().fitContent(); + + // Responsive resize + const observer = new ResizeObserver(entries => { + const entry = entries[0]; + if (!entry) return; + chart.applyOptions({ + width: entry.contentRect.width, + height: fullscreen ? entry.contentRect.height - 20 : 350, + }); + }); + observer.observe(container); + + return () => { + observer.disconnect(); + chart.remove(); + chartRef.current = null; + candleSeriesRef.current = null; + }; + }, [candles, purchases, stats, fullscreen]); + + const handleSave = () => { + if (!chartRef.current) return; + const canvas = chartRef.current.takeScreenshot(); + const a = document.createElement('a'); + a.href = canvas.toDataURL('image/png'); + a.download = 'btc-candles.png'; + a.click(); + }; + + const containerWrapStyle = fullscreen + ? { flex: 1, minHeight: 0 } + : { width: '100%' }; + + return ( +
+
+
BTC Candles (EUR)
+
+ + +
+
+ + {(!candles || candles.length === 0) ? ( +
Loading price data…
+ ) : ( +
+ )} +
+ ); +} diff --git a/btc-portfolio/frontend/src/components/PurchaseList.js b/btc-portfolio/frontend/src/components/PurchaseList.js index 971f9a2..89b0805 100644 --- a/btc-portfolio/frontend/src/components/PurchaseList.js +++ b/btc-portfolio/frontend/src/components/PurchaseList.js @@ -101,7 +101,7 @@ export default function PurchaseList({ purchases, onChanged }) { ) : ( - {new Date(p.created_at).toLocaleDateString()} + {new Date(p.created_at).toLocaleDateString('en-GB')} €{p.amount_eur.toLocaleString()} €{p.price_eur.toLocaleString()} {(p.amount_eur / p.price_eur).toFixed(6)} diff --git a/btc-portfolio/frontend/src/pages/Dashboard.js b/btc-portfolio/frontend/src/pages/Dashboard.js index 4ea31e1..dd5baff 100644 --- a/btc-portfolio/frontend/src/pages/Dashboard.js +++ b/btc-portfolio/frontend/src/pages/Dashboard.js @@ -3,7 +3,7 @@ import { useNavigate, Link } from 'react-router-dom'; import AddPurchase from '../components/AddPurchase'; import PurchaseList from '../components/PurchaseList'; import PortfolioChart from '../components/PortfolioChart'; -import BTCHistoryChart from '../components/BTCHistoryChart'; +import BTCCandlestickChart from '../components/BTCCandlestickChart'; const API = process.env.REACT_APP_API_URL || 'http://localhost:8000'; @@ -38,7 +38,9 @@ function StatCard({ label, value, highlight }) { export default function Dashboard() { const [stats, setStats] = useState(null); const [purchases, setPurchases] = useState([]); - const [history, setHistory] = useState(null); + const [candles, setCandles] = useState(null); + const [candlesAll, setCandlesAll] = useState(null); + const [fullscreenChart, setFullscreenChart] = useState(false); const [chartView, setChartView] = useState('both'); // 'both' | 'portfolio' | 'history' const navigate = useNavigate(); @@ -48,10 +50,10 @@ export default function Dashboard() { const fetchData = useCallback(async () => { try { - const [statsRes, purchasesRes, historyRes] = await Promise.all([ - fetch(`${API}/stats`, { headers: authHeaders() }), - fetch(`${API}/purchases`, { headers: authHeaders() }), - fetch(`${API}/history`, { headers: authHeaders() }), + const [statsRes, purchasesRes, candlesRes] = await Promise.all([ + fetch(`${API}/stats`, { headers: authHeaders() }), + fetch(`${API}/purchases`, { headers: authHeaders() }), + fetch(`${API}/candles?days=365`, { headers: authHeaders() }), ]); if (statsRes.status === 401) { localStorage.removeItem('token'); @@ -60,12 +62,21 @@ export default function Dashboard() { } setStats(await statsRes.json()); setPurchases(await purchasesRes.json()); - setHistory(await historyRes.json()); + setCandles(await candlesRes.json()); } catch { // silently fail — network may be unavailable } }, [navigate]); + const fetchAllCandles = useCallback(async () => { + try { + const res = await fetch(`${API}/candles?days=all`, { headers: authHeaders() }); + if (res.ok) setCandlesAll(await res.json()); + } catch { + // silently fail + } + }, []); + useEffect(() => { fetchData(); }, [fetchData]); @@ -78,10 +89,18 @@ export default function Dashboard() { navigate('/login'); }; + const handleToggleFullscreen = useCallback(() => { + if (!fullscreenChart && !candlesAll) fetchAllCandles(); + setFullscreenChart(f => !f); + }, [fullscreenChart, candlesAll, fetchAllCandles]); + const plHighlight = stats ? stats.profit_loss >= 0 ? 'positive' : 'negative' : 'neutral'; + // Fullscreen uses all-candles data once loaded, otherwise falls back to 365-day set + const activeCandles = (fullscreenChart && candlesAll) ? candlesAll : candles; + return (
@@ -108,7 +127,7 @@ export default function Dashboard() { )}
- {[['both', 'Both'], ['portfolio', 'Portfolio'], ['history', '1-Year BTC']].map(([key, label]) => ( + {[['both', 'Both'], ['portfolio', 'Portfolio'], ['history', 'BTC Candles']].map(([key, label]) => (