diff --git a/btc-portfolio/backend/app/main.py b/btc-portfolio/backend/app/main.py index 2496285..de3b482 100644 --- a/btc-portfolio/backend/app/main.py +++ b/btc-portfolio/backend/app/main.py @@ -1,8 +1,8 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from .database import engine, Base -from .routes import users, purchases, stats, history +from .database import engine, Base, SessionLocal +from .routes import users, purchases, stats, history, candles Base.metadata.create_all(bind=engine) @@ -10,7 +10,7 @@ app = FastAPI(title="BTC Portfolio API") app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:3000"], + allow_origins=["http://localhost:3000", "http://localhost:3001"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -20,6 +20,17 @@ app.include_router(users.router) app.include_router(purchases.router) app.include_router(stats.router) app.include_router(history.router) +app.include_router(candles.router) + + +@app.on_event("startup") +def startup(): + from .services.candles import refresh_latest_candles + db = SessionLocal() + try: + refresh_latest_candles(db) + finally: + db.close() @app.get("/") diff --git a/btc-portfolio/backend/app/models.py b/btc-portfolio/backend/app/models.py index 22fa09a..973a485 100644 --- a/btc-portfolio/backend/app/models.py +++ b/btc-portfolio/backend/app/models.py @@ -24,3 +24,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 ef03644..d126cfc 100644 --- a/btc-portfolio/docker-compose.yml +++ b/btc-portfolio/docker-compose.yml @@ -10,11 +10,12 @@ services: restart: unless-stopped frontend: - build: ./frontend + build: + context: ./frontend + args: + - REACT_APP_API_URL=http://localhost:8000 ports: - - "3000:3000" - environment: - - REACT_APP_API_URL=http://localhost:8000 + - "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 ( +