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>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user