Compare commits

..

5 Commits

Author SHA1 Message Date
Jonathan 15358c05c3 Merge branch 'development' 2026-04-14 19:58:46 +02:00
Jonathan 59f833d7fd Fix candle chart staleness and show live price on today's candle
- Refresh candles on every /candles request instead of only at startup
- Patch today's candle close/high/low with the live BTC price intraday
- Synthesise today's candle from live price if CoinGecko hasn't published it yet
- Display current BTC price next to the chart title

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 19:55:01 +02:00
Jonathan d5197cde14 Update README to reflect all features added since initial docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:43:06 +02:00
Jonathan 9c0db31580 Merge branch 'development' 2026-04-06 20:32:49 +02:00
Jonathan d90ac5365a Add price prediction feature to dashboard
Enter a hypothetical BTC price to instantly see predicted portfolio value,
predicted P&L, and difference vs current value — all calculated client-side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:32:25 +02:00
4 changed files with 141 additions and 37 deletions
+55 -35
View File
@@ -1,29 +1,32 @@
# BTC Portfolio Tracker # BTC Portfolio Tracker
A full-stack Bitcoin portfolio tracker with live EUR pricing, purchase history, and interactive charts. A full-stack Bitcoin portfolio tracker with live EUR pricing, purchase/sell history, candlestick charts, and price predictions.
--- ---
## Features ## Features
- **Live BTC price** — fetched from CoinGecko in EUR - **Live BTC price** — fetched from CoinGecko in EUR, with cached fallback and stale warning
- **Purchase tracking** — log BTC buys with amount (EUR) and price per BTC - **Purchase tracking** — log BTC buys with amount (EUR), price per BTC, and a custom date
- **Portfolio stats** — total invested, current value, profit/loss - **Sell tracking** — log BTC sells with BTC amount, price per BTC, and a custom date
- **Interactive charts** — portfolio value over time and 1-year BTC price history - **Portfolio stats** — total invested, current value, profit/loss, net BTC held
- **Edit & delete** — manage purchases with inline editing - **Price prediction** — enter a target BTC price to see projected portfolio value and P&L
- **Interactive charts** — portfolio value over time and BTC candlestick chart (OHLC stored locally)
- **Edit & delete** — inline editing and deletion for both purchases and sells
- **Admin panel** — admin users can create, list, and delete accounts
- **JWT authentication** — secure per-user portfolios - **JWT authentication** — secure per-user portfolios
--- ---
## Tech Stack ## Tech Stack
| Layer | Technology | | Layer | Technology |
|----------|-------------------------------------| |----------|-----------------------------------------|
| Frontend | React 18, Chart.js, dark theme | | Frontend | React 18, Chart.js, dark theme |
| Backend | FastAPI, SQLAlchemy, SQLite | | Backend | FastAPI, SQLAlchemy, SQLite |
| Auth | JWT (HS256, 24h expiry) + bcrypt | | Auth | JWT (HS256, 24h expiry) + bcrypt |
| Pricing | CoinGecko API (EUR) | | Pricing | CoinGecko API (EUR) |
| Deploy | Docker + Docker Compose | | Deploy | Docker + Docker Compose + nginx |
--- ---
@@ -65,42 +68,55 @@ btc-portfolio/
├── backend/ ├── backend/
│ └── app/ │ └── app/
│ ├── main.py # FastAPI app + CORS │ ├── main.py # FastAPI app + CORS
│ ├── models.py # User & Purchase ORM models │ ├── models.py # User, Purchase, Sell, OHLCCandle ORM models
│ ├── auth.py # JWT + bcrypt │ ├── auth.py # JWT + bcrypt
│ ├── dependencies.py # Auth dependency injection │ ├── dependencies.py # Auth dependency injection
│ ├── routes/ │ ├── routes/
│ │ ├── users.py # POST /register, POST /login │ │ ├── users.py # POST /register, POST /login
│ │ ├── purchases.py # CRUD /purchases │ │ ├── purchases.py # CRUD /purchases
│ │ ├── sells.py # CRUD /sells
│ │ ├── stats.py # GET /stats │ │ ├── stats.py # GET /stats
│ │ ── history.py # GET /history (365-day BTC prices) │ │ ── history.py # GET /history (365-day BTC prices)
│ │ ├── candles.py # GET /candles (OHLC data + purchases overlay)
│ │ └── admin.py # GET/POST/DELETE /admin/users
│ └── services/ │ └── services/
│ └── btc.py # CoinGecko integration │ └── btc.py # CoinGecko integration
└── frontend/ └── frontend/
└── src/ └── src/
├── App.js # Routing ├── App.js # Routing
├── pages/ ├── pages/
│ └── Dashboard.js # Main view │ └── Dashboard.js # Main view: stats, predictions, charts, tables
└── components/ └── components/
├── AddPurchase.js # Purchase form ├── AddPurchase.js # Purchase form (amount EUR, price, date)
├── PurchaseList.js # Purchase table (edit/delete) ├── PurchaseList.js # Purchase table (inline edit/delete)
├── PortfolioChart.js # Invested vs current value ├── AddSell.js # Sell form (BTC amount, price, date)
── BTCHistoryChart.js # 1-year BTC price history ── SellList.js # Sell table (inline edit/delete)
├── PortfolioChart.js # Invested vs current value over time
└── BTCCandlestickChart.js # OHLC candlestick chart with purchase markers
``` ```
--- ---
## API Endpoints ## API Endpoints
| Method | Endpoint | Description | Auth | | Method | Endpoint | Description | Auth |
|--------|-------------------|------------------------------|------| |--------|------------------------|------------------------------------|-------|
| POST | `/register` | Create account | No | | POST | `/register` | Create account | No |
| POST | `/login` | Get JWT token | No | | POST | `/login` | Get JWT token | No |
| GET | `/purchases` | List user purchases | Yes | | GET | `/purchases` | List user purchases | Yes |
| POST | `/purchases` | Add a purchase | Yes | | POST | `/purchases` | Add a purchase | Yes |
| PUT | `/purchases/{id}` | Edit a purchase | Yes | | PUT | `/purchases/{id}` | Edit a purchase | Yes |
| DELETE | `/purchases/{id}` | Delete a purchase | Yes | | DELETE | `/purchases/{id}` | Delete a purchase | Yes |
| GET | `/stats` | Portfolio stats (P&L) | Yes | | GET | `/sells` | List user sells | Yes |
| GET | `/history` | 365-day BTC price history | Yes | | POST | `/sells` | Add a sell | Yes |
| PUT | `/sells/{sell_id}` | Edit a sell | Yes |
| DELETE | `/sells/{sell_id}` | Delete a sell | Yes |
| GET | `/stats` | Portfolio stats (P&L, net BTC) | Yes |
| GET | `/history` | 365-day BTC price history | Yes |
| GET | `/candles` | OHLC candles + purchase overlay | Yes |
| GET | `/admin/users` | List all users (admin only) | Admin |
| POST | `/admin/users` | Create a user (admin only) | Admin |
| DELETE | `/admin/users/{id}` | Delete a user (admin only) | Admin |
--- ---
@@ -108,15 +124,19 @@ btc-portfolio/
SQLite, stored at `/app/data/btc_portfolio.db` (persisted via Docker volume). SQLite, stored at `/app/data/btc_portfolio.db` (persisted via Docker volume).
| Table | Columns | | Table | Columns |
|-------------|------------------------------------------------------| |----------------|---------------------------------------------------------------|
| `users` | id, username (unique), password (bcrypt hash) | | `users` | id, username (unique), password (bcrypt hash), is_admin |
| `purchases` | id, amount_eur, price_eur, created_at, user_id (FK) | | `purchases` | id, amount_eur, price_eur, created_at, user_id (FK) |
| `sells` | id, btc_amount, price_eur, created_at, user_id (FK) |
| `ohlc_candles` | id, date (unique, YYYY-MM-DD), open, high, low, close |
--- ---
## Notes ## Notes
- The `SECRET_KEY` in `auth.py` is hardcoded — use an environment variable in production. - The `SECRET_KEY` in `auth.py` is hardcoded — use an environment variable in production.
- CoinGecko requests are unauthenticated; failures return `0.0` gracefully. - CoinGecko requests are unauthenticated; failures fall back to the last cached price with a UI warning.
- OHLC candle data is fetched from CoinGecko and stored locally to reduce API calls.
- CORS is restricted to `localhost:3000` by default. - CORS is restricted to `localhost:3000` by default.
- The frontend is served via nginx in the Docker production setup.
@@ -1,9 +1,12 @@
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime, timezone
from ..database import get_db from ..database import get_db
from .. import models from .. import models
from ..dependencies import get_current_user from ..dependencies import get_current_user
from ..services.candles import refresh_latest_candles
from ..services.btc import get_btc_price_eur
router = APIRouter() router = APIRouter()
@@ -14,6 +17,9 @@ def get_candles(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
# Refresh candles on every request (no-op if DB is already current)
refresh_latest_candles(db)
query = db.query(models.OHLCCandle).order_by(models.OHLCCandle.date.asc()) query = db.query(models.OHLCCandle).order_by(models.OHLCCandle.date.asc())
if days != "all": if days != "all":
@@ -38,6 +44,26 @@ def get_candles(
for c in candles_db for c in candles_db
] ]
# Patch today's candle with the live price so it tracks intraday movement
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
live_price, _ = get_btc_price_eur()
if live_price and candles:
last = candles[-1]
if last["date"] == today:
last["close"] = round(live_price, 2)
last["high"] = round(max(last["high"], live_price), 2)
last["low"] = round(min(last["low"], live_price), 2)
elif last["date"] < today:
# No candle for today yet — create a synthetic one from live price
prev_close = last["close"]
candles.append({
"date": today,
"open": prev_close,
"high": round(max(prev_close, live_price), 2),
"low": round(min(prev_close, live_price), 2),
"close": round(live_price, 2),
})
purchases_db = db.query(models.Purchase).filter( purchases_db = db.query(models.Purchase).filter(
models.Purchase.user_id == current_user.id models.Purchase.user_id == current_user.id
).all() ).all()
@@ -39,7 +39,7 @@ const btnStyle = {
marginLeft: '0.5rem', marginLeft: '0.5rem',
}; };
export default function BTCCandlestickChart({ candles, purchases, stats, fullscreen, onToggleFullscreen }) { export default function BTCCandlestickChart({ candles, purchases, stats, fullscreen, onToggleFullscreen, livePrice }) {
const containerRef = useRef(null); const containerRef = useRef(null);
const chartRef = useRef(null); const chartRef = useRef(null);
const candleSeriesRef = useRef(null); const candleSeriesRef = useRef(null);
@@ -167,7 +167,14 @@ export default function BTCCandlestickChart({ candles, purchases, stats, fullscr
return ( return (
<div style={fullscreen ? fullscreenStyle : cardStyle}> <div style={fullscreen ? fullscreenStyle : cardStyle}>
<div style={headerStyle}> <div style={headerStyle}>
<div style={titleStyle}>BTC Candles (EUR)</div> <div style={titleStyle}>
BTC Candles (EUR)
{livePrice != null && (
<span style={{ fontSize: '0.9rem', fontWeight: 400, color: '#ccc', marginLeft: '0.75rem' }}>
{livePrice.toLocaleString()}
</span>
)}
</div>
<div> <div>
<button style={btnStyle} onClick={handleSave}>Save as PNG</button> <button style={btnStyle} onClick={handleSave}>Save as PNG</button>
<button style={btnStyle} onClick={onToggleFullscreen}> <button style={btnStyle} onClick={onToggleFullscreen}>
@@ -17,6 +17,13 @@ const styles = {
adminBtn: { background: 'none', border: '1px solid #f7931a', color: '#f7931a', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer', textDecoration: 'none', fontSize: '1rem' }, adminBtn: { background: 'none', border: '1px solid #f7931a', color: '#f7931a', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer', textDecoration: 'none', fontSize: '1rem' },
headerBtns: { display: 'flex', gap: '0.5rem', alignItems: 'center' }, headerBtns: { display: 'flex', gap: '0.5rem', alignItems: 'center' },
statsGrid: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', marginBottom: '1.5rem' }, statsGrid: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', marginBottom: '1.5rem' },
predictionSection: { background: '#1a1a1a', border: '1px solid #333', borderRadius: '12px', padding: '1rem', marginBottom: '1.5rem' },
predictionHeader: { color: '#888', fontSize: '0.8rem', marginBottom: '0.75rem' },
predictionRow: { display: 'flex', gap: '1rem', alignItems: 'flex-start', flexWrap: 'wrap' },
predictionInputWrap: { display: 'flex', flexDirection: 'column', gap: '0.3rem' },
predictionLabel: { color: '#888', fontSize: '0.8rem' },
predictionInput: { background: '#111', border: '1px solid #444', borderRadius: '8px', color: '#fff', padding: '0.55rem 0.75rem', fontSize: '1rem', width: '160px', outline: 'none' },
predictionCards: { display: 'flex', gap: '1rem', flex: 1, flexWrap: 'wrap' },
statCard: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333' }, statCard: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333' },
statLabel: { color: '#888', fontSize: '0.8rem', marginBottom: '0.3rem' }, statLabel: { color: '#888', fontSize: '0.8rem', marginBottom: '0.3rem' },
statValue: { fontSize: '1.2rem', fontWeight: 700 }, statValue: { fontSize: '1.2rem', fontWeight: 700 },
@@ -53,6 +60,7 @@ export default function Dashboard() {
const [candlesAll, setCandlesAll] = useState(null); const [candlesAll, setCandlesAll] = useState(null);
const [fullscreenChart, setFullscreenChart] = useState(false); const [fullscreenChart, setFullscreenChart] = useState(false);
const [chartView, setChartView] = useState('both'); // 'both' | 'portfolio' | 'history' const [chartView, setChartView] = useState('both'); // 'both' | 'portfolio' | 'history'
const [predictionPrice, setPredictionPrice] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const authHeaders = () => ({ const authHeaders = () => ({
@@ -107,6 +115,12 @@ export default function Dashboard() {
setFullscreenChart(f => !f); setFullscreenChart(f => !f);
}, [fullscreenChart, candlesAll, fetchAllCandles]); }, [fullscreenChart, candlesAll, fetchAllCandles]);
const predPrice = parseFloat(predictionPrice);
const predValid = stats && predictionPrice !== '' && predPrice > 0;
const predValue = predValid ? +(stats.total_btc * predPrice).toFixed(2) : null;
const predPL = predValid ? +(predValue - stats.total_invested).toFixed(2) : null;
const predVsCurrent = predValid ? +(predValue - stats.portfolio_value).toFixed(2) : null;
const plHighlight = stats const plHighlight = stats
? stats.profit_loss >= 0 ? 'positive' : 'negative' ? stats.profit_loss >= 0 ? 'positive' : 'negative'
: 'neutral'; : 'neutral';
@@ -143,6 +157,42 @@ export default function Dashboard() {
</div> </div>
)} )}
<div style={styles.predictionSection}>
<div style={styles.predictionHeader}>Price Prediction</div>
<div style={styles.predictionRow}>
<div style={styles.predictionInputWrap}>
<label style={styles.predictionLabel}>BTC Price ()</label>
<input
type="number"
min="0"
placeholder="e.g. 100000"
value={predictionPrice}
onChange={e => setPredictionPrice(e.target.value)}
style={styles.predictionInput}
/>
</div>
{predValid && (
<div style={styles.predictionCards}>
<StatCard
label="Predicted Value"
value={`${predValue.toLocaleString()}`}
highlight="neutral"
/>
<StatCard
label="Predicted P&L"
value={`${predPL >= 0 ? '+' : ''}${predPL.toLocaleString()}`}
highlight={predPL >= 0 ? 'positive' : 'negative'}
/>
<StatCard
label="vs. Current"
value={`${predVsCurrent >= 0 ? '+' : ''}${predVsCurrent.toLocaleString()}`}
highlight={predVsCurrent >= 0 ? 'positive' : 'negative'}
/>
</div>
)}
</div>
</div>
<div style={styles.tabs}> <div style={styles.tabs}>
{[['both', 'Both'], ['portfolio', 'Portfolio'], ['history', 'BTC Candles']].map(([key, label]) => ( {[['both', 'Both'], ['portfolio', 'Portfolio'], ['history', 'BTC Candles']].map(([key, label]) => (
<button <button
@@ -158,6 +208,7 @@ export default function Dashboard() {
candles={activeCandles?.candles ?? null} candles={activeCandles?.candles ?? null}
purchases={activeCandles?.purchases ?? purchases} purchases={activeCandles?.purchases ?? purchases}
stats={stats} stats={stats}
livePrice={stats?.current_price ?? null}
fullscreen={fullscreenChart} fullscreen={fullscreenChart}
onToggleFullscreen={handleToggleFullscreen} onToggleFullscreen={handleToggleFullscreen}
/> />