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>
This commit is contained in:
2026-03-24 20:23:46 +01:00
parent 39404ca208
commit 79b565cfb6
10 changed files with 419 additions and 21 deletions
+2 -2
View File
@@ -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"]
+2 -1
View File
@@ -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",
@@ -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>
);
}
+38 -11
View File
@@ -3,7 +3,7 @@ import { useNavigate } 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';
@@ -12,7 +12,7 @@ const styles = {
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' },
logo: { fontSize: '1.4rem', fontWeight: 700, color: '#f7931a' },
logoutBtn: { background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer' },
statsGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '1rem', marginBottom: '1.5rem' },
statsGrid: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', marginBottom: '1.5rem' },
statCard: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333' },
statLabel: { color: '#888', fontSize: '0.8rem', marginBottom: '0.3rem' },
statValue: { fontSize: '1.2rem', fontWeight: 700 },
@@ -36,7 +36,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();
@@ -46,10 +48,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');
@@ -58,12 +60,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]);
@@ -73,10 +84,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 (
<div style={styles.app}>
<div style={styles.header}>
@@ -87,8 +106,8 @@ export default function Dashboard() {
{stats && (
<div style={styles.statsGrid}>
<StatCard label="Total Invested" value={`${stats.total_invested.toLocaleString()}`} />
<StatCard label="Total BTC" value={`${stats.total_btc}`} highlight="neutral" />
<StatCard label="Avg Buy Price" value={`${stats.average_price.toLocaleString()}`} />
<StatCard label="Total BTC" value={`${stats.total_btc}`} highlight="neutral" />
<StatCard label="Current BTC Price" value={`${stats.current_price.toLocaleString()}`} />
<StatCard label="Portfolio Value" value={`${stats.portfolio_value.toLocaleString()}`} />
<StatCard
@@ -100,7 +119,7 @@ export default function Dashboard() {
)}
<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
key={key}
style={chartView === key ? styles.tabActive : styles.tab}
@@ -109,7 +128,15 @@ export default function Dashboard() {
))}
</div>
{(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} />
<PurchaseList purchases={purchases} onChanged={fetchData} />
</div>