Compare commits

...

4 Commits

Author SHA1 Message Date
Jonathan 4616accc63 Merge branch 'development' 2026-03-24 20:28:59 +01:00
Jonathan a0692501b3 Use en-GB locale for dd/mm/yyyy date format in purchases list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:28:51 +01:00
Jonathan cb28979208 Merge feature/btc-history-chart: BTC candlestick chart with local OHLC storage
Resolves conflicts: combined admin + candles routers in main.py,
fixed docker-compose port to 3001:3001, restored admin styles in Dashboard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:24:50 +01:00
Jonathan 79b565cfb6 Add BTC candlestick chart with local OHLC storage
- Store daily BTC OHLC candles in SQLite to avoid hitting CoinGecko on every load
- Seed with 30 days of daily candles on first boot (free tier gives daily granularity for days<=30)
- Auto-detect and replace coarse legacy candle data on startup
- Daily refresh adds new candles on each container restart
- New GET /candles endpoint (supports ?days=365 and ?days=all)
- Switch BTCHistoryChart to BTCCandlestickChart using lightweight-charts (TradingView)
- Purchase markers with nearest-candle matching and multi-purchase merging
- Fullscreen mode showing all stored candles
- Fix frontend port to 3001 and pass REACT_APP_API_URL as build arg

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:23:46 +01:00
11 changed files with 413 additions and 17 deletions
+13 -3
View File
@@ -4,8 +4,8 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text from sqlalchemy import text
from .database import engine, Base from .database import engine, Base, SessionLocal
from .routes import users, purchases, stats, history, admin from .routes import users, purchases, stats, history, admin, candles
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@@ -26,16 +26,26 @@ app.include_router(purchases.router)
app.include_router(stats.router) app.include_router(stats.router)
app.include_router(history.router) app.include_router(history.router)
app.include_router(admin.router, prefix="/admin") app.include_router(admin.router, prefix="/admin")
app.include_router(candles.router)
@app.on_event("startup") @app.on_event("startup")
def migrate(): def startup():
# Schema migration: add is_admin column if missing
with engine.connect() as conn: with engine.connect() as conn:
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(users)"))] cols = [r[1] for r in conn.execute(text("PRAGMA table_info(users)"))]
if "is_admin" not in cols: if "is_admin" not in cols:
conn.execute(text("ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT 0")) conn.execute(text("ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT 0"))
conn.commit() 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("/") @app.get("/")
def root(): def root():
+11
View File
@@ -25,3 +25,14 @@ class Purchase(Base):
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
owner = relationship("User", back_populates="purchases") 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)
@@ -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}
+35
View File
@@ -1,4 +1,5 @@
import requests import requests
from datetime import datetime, timezone
def get_btc_history_eur() -> list: def get_btc_history_eur() -> list:
@@ -14,6 +15,40 @@ def get_btc_history_eur() -> list:
return [] 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: def get_btc_price_eur() -> float:
try: try:
resp = requests.get( resp = requests.get(
@@ -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))
+1 -1
View File
@@ -15,7 +15,7 @@ services:
args: args:
- REACT_APP_API_URL=http://localhost:8000 - REACT_APP_API_URL=http://localhost:8000
ports: ports:
- "3001:3000" - "3001:3001"
depends_on: depends_on:
- backend - backend
restart: unless-stopped restart: unless-stopped
+2 -2
View File
@@ -12,5 +12,5 @@ FROM node:18-alpine
RUN npm install -g serve RUN npm install -g serve
WORKDIR /app WORKDIR /app
COPY --from=build /app/build ./build COPY --from=build /app/build ./build
EXPOSE 3000 EXPOSE 3001
CMD ["serve", "-s", "build", "-l", "3000"] CMD ["serve", "-s", "build", "-l", "3001"]
+2 -1
View File
@@ -8,7 +8,8 @@
"react-router-dom": "^6.22.0", "react-router-dom": "^6.22.0",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"chart.js": "^4.4.0", "chart.js": "^4.4.0",
"react-chartjs-2": "^5.2.0" "react-chartjs-2": "^5.2.0",
"lightweight-charts": "^4.2.0"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
@@ -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 (
<div style={fullscreen ? fullscreenStyle : cardStyle}>
<div style={headerStyle}>
<div style={titleStyle}>BTC Candles (EUR)</div>
<div>
<button style={btnStyle} onClick={handleSave}>Save as PNG</button>
<button style={btnStyle} onClick={onToggleFullscreen}>
{fullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
</button>
</div>
</div>
{(!candles || candles.length === 0) ? (
<div style={{ color: '#666', padding: '1rem 0' }}>Loading price data</div>
) : (
<div ref={containerRef} style={containerWrapStyle} />
)}
</div>
);
}
@@ -101,7 +101,7 @@ export default function PurchaseList({ purchases, onChanged }) {
</tr> </tr>
) : ( ) : (
<tr key={p.id}> <tr key={p.id}>
<td style={styles.td}>{new Date(p.created_at).toLocaleDateString()}</td> <td style={styles.td}>{new Date(p.created_at).toLocaleDateString('en-GB')}</td>
<td style={styles.td}>{p.amount_eur.toLocaleString()}</td> <td style={styles.td}>{p.amount_eur.toLocaleString()}</td>
<td style={styles.td}>{p.price_eur.toLocaleString()}</td> <td style={styles.td}>{p.price_eur.toLocaleString()}</td>
<td style={styles.td}>{(p.amount_eur / p.price_eur).toFixed(6)}</td> <td style={styles.td}>{(p.amount_eur / p.price_eur).toFixed(6)}</td>
+36 -9
View File
@@ -3,7 +3,7 @@ import { useNavigate, Link } from 'react-router-dom';
import AddPurchase from '../components/AddPurchase'; import AddPurchase from '../components/AddPurchase';
import PurchaseList from '../components/PurchaseList'; import PurchaseList from '../components/PurchaseList';
import PortfolioChart from '../components/PortfolioChart'; 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'; const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
@@ -38,7 +38,9 @@ function StatCard({ label, value, highlight }) {
export default function Dashboard() { export default function Dashboard() {
const [stats, setStats] = useState(null); const [stats, setStats] = useState(null);
const [purchases, setPurchases] = useState([]); 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 [chartView, setChartView] = useState('both'); // 'both' | 'portfolio' | 'history'
const navigate = useNavigate(); const navigate = useNavigate();
@@ -48,10 +50,10 @@ export default function Dashboard() {
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
const [statsRes, purchasesRes, historyRes] = await Promise.all([ const [statsRes, purchasesRes, candlesRes] = await Promise.all([
fetch(`${API}/stats`, { headers: authHeaders() }), fetch(`${API}/stats`, { headers: authHeaders() }),
fetch(`${API}/purchases`, { headers: authHeaders() }), fetch(`${API}/purchases`, { headers: authHeaders() }),
fetch(`${API}/history`, { headers: authHeaders() }), fetch(`${API}/candles?days=365`, { headers: authHeaders() }),
]); ]);
if (statsRes.status === 401) { if (statsRes.status === 401) {
localStorage.removeItem('token'); localStorage.removeItem('token');
@@ -60,12 +62,21 @@ export default function Dashboard() {
} }
setStats(await statsRes.json()); setStats(await statsRes.json());
setPurchases(await purchasesRes.json()); setPurchases(await purchasesRes.json());
setHistory(await historyRes.json()); setCandles(await candlesRes.json());
} catch { } catch {
// silently fail — network may be unavailable // silently fail — network may be unavailable
} }
}, [navigate]); }, [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(() => { useEffect(() => {
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
@@ -78,10 +89,18 @@ export default function Dashboard() {
navigate('/login'); navigate('/login');
}; };
const handleToggleFullscreen = useCallback(() => {
if (!fullscreenChart && !candlesAll) fetchAllCandles();
setFullscreenChart(f => !f);
}, [fullscreenChart, candlesAll, fetchAllCandles]);
const plHighlight = stats const plHighlight = stats
? stats.profit_loss >= 0 ? 'positive' : 'negative' ? stats.profit_loss >= 0 ? 'positive' : 'negative'
: 'neutral'; : 'neutral';
// Fullscreen uses all-candles data once loaded, otherwise falls back to 365-day set
const activeCandles = (fullscreenChart && candlesAll) ? candlesAll : candles;
return ( return (
<div style={styles.app}> <div style={styles.app}>
<div style={styles.header}> <div style={styles.header}>
@@ -108,7 +127,7 @@ export default function Dashboard() {
)} )}
<div style={styles.tabs}> <div style={styles.tabs}>
{[['both', 'Both'], ['portfolio', 'Portfolio'], ['history', '1-Year BTC']].map(([key, label]) => ( {[['both', 'Both'], ['portfolio', 'Portfolio'], ['history', 'BTC Candles']].map(([key, label]) => (
<button <button
key={key} key={key}
style={chartView === key ? styles.tabActive : styles.tab} style={chartView === key ? styles.tabActive : styles.tab}
@@ -117,7 +136,15 @@ export default function Dashboard() {
))} ))}
</div> </div>
{(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} stats={stats} />} {(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} stats={stats} />}
{(chartView === 'both' || chartView === 'history') && <BTCHistoryChart history={history} stats={stats} />} {(chartView === 'both' || chartView === 'history') && (
<BTCCandlestickChart
candles={activeCandles?.candles ?? null}
purchases={activeCandles?.purchases ?? purchases}
stats={stats}
fullscreen={fullscreenChart}
onToggleFullscreen={handleToggleFullscreen}
/>
)}
<AddPurchase onAdded={fetchData} /> <AddPurchase onAdded={fetchData} />
<PurchaseList purchases={purchases} onChanged={fetchData} /> <PurchaseList purchases={purchases} onChanged={fetchData} />
</div> </div>