Compare commits

..

7 Commits

Author SHA1 Message Date
Jonathan ce9547a623 Merge development into main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:20:08 +02:00
Jonathan 33656c4512 Merge development: security hardening, login fix, chart improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:31:11 +02:00
Jonathan 4616accc63 Merge branch 'development' 2026-03-24 20:28:59 +01:00
Jonathan befbe12bcf Merge branch 'development' 2026-03-24 19:39:41 +01:00
Jonathan aedc6a8a17 Merge branch 'development' 2026-03-24 19:18:56 +01:00
Jonathan db9624822b Merge development: fix CORS for local dev on port 3001
Resolved conflict by keeping env var approach from main and updating
the default to include both localhost:3000 and localhost:3001.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 18:58:50 +01:00
Jonathan 470dd80ed8 Make CORS allowed origins configurable via ALLOWED_ORIGINS env var
Defaults to localhost:3000 for local dev. Server deployments can
pass a comma-separated list via the environment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 18:15:44 +01:00
7 changed files with 38 additions and 394 deletions
+35 -55
View File
@@ -1,32 +1,29 @@
# BTC Portfolio Tracker
A full-stack Bitcoin portfolio tracker with live EUR pricing, purchase/sell history, candlestick charts, and price predictions.
A full-stack Bitcoin portfolio tracker with live EUR pricing, purchase history, and interactive charts.
---
## Features
- **Live BTC price** — fetched from CoinGecko in EUR, with cached fallback and stale warning
- **Purchase tracking** — log BTC buys with amount (EUR), price per BTC, and a custom date
- **Sell tracking** — log BTC sells with BTC amount, price per BTC, and a custom date
- **Portfolio stats** — total invested, current value, profit/loss, net BTC held
- **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
- **Live BTC price** — fetched from CoinGecko in EUR
- **Purchase tracking** — log BTC buys with amount (EUR) and price per BTC
- **Portfolio stats** — total invested, current value, profit/loss
- **Interactive charts** — portfolio value over time and 1-year BTC price history
- **Edit & delete** — manage purchases with inline editing
- **JWT authentication** — secure per-user portfolios
---
## Tech Stack
| Layer | Technology |
|----------|-----------------------------------------|
| Frontend | React 18, Chart.js, dark theme |
| Backend | FastAPI, SQLAlchemy, SQLite |
| Auth | JWT (HS256, 24h expiry) + bcrypt |
| Pricing | CoinGecko API (EUR) |
| Deploy | Docker + Docker Compose + nginx |
| Layer | Technology |
|----------|-------------------------------------|
| Frontend | React 18, Chart.js, dark theme |
| Backend | FastAPI, SQLAlchemy, SQLite |
| Auth | JWT (HS256, 24h expiry) + bcrypt |
| Pricing | CoinGecko API (EUR) |
| Deploy | Docker + Docker Compose |
---
@@ -68,55 +65,42 @@ btc-portfolio/
├── backend/
│ └── app/
│ ├── main.py # FastAPI app + CORS
│ ├── models.py # User, Purchase, Sell, OHLCCandle ORM models
│ ├── models.py # User & Purchase ORM models
│ ├── auth.py # JWT + bcrypt
│ ├── dependencies.py # Auth dependency injection
│ ├── routes/
│ │ ├── users.py # POST /register, POST /login
│ │ ├── purchases.py # CRUD /purchases
│ │ ├── sells.py # CRUD /sells
│ │ ├── stats.py # GET /stats
│ │ ── history.py # GET /history (365-day BTC prices)
│ │ ├── candles.py # GET /candles (OHLC data + purchases overlay)
│ │ └── admin.py # GET/POST/DELETE /admin/users
│ │ ── history.py # GET /history (365-day BTC prices)
│ └── services/
│ └── btc.py # CoinGecko integration
└── frontend/
└── src/
├── App.js # Routing
├── pages/
│ └── Dashboard.js # Main view: stats, predictions, charts, tables
│ └── Dashboard.js # Main view
└── components/
├── AddPurchase.js # Purchase form (amount EUR, price, date)
├── PurchaseList.js # Purchase table (inline edit/delete)
├── AddSell.js # Sell form (BTC amount, price, date)
── SellList.js # Sell table (inline edit/delete)
├── PortfolioChart.js # Invested vs current value over time
└── BTCCandlestickChart.js # OHLC candlestick chart with purchase markers
├── AddPurchase.js # Purchase form
├── PurchaseList.js # Purchase table (edit/delete)
├── PortfolioChart.js # Invested vs current value
── BTCHistoryChart.js # 1-year BTC price history
```
---
## API Endpoints
| Method | Endpoint | Description | Auth |
|--------|------------------------|------------------------------------|-------|
| POST | `/register` | Create account | No |
| POST | `/login` | Get JWT token | No |
| GET | `/purchases` | List user purchases | Yes |
| POST | `/purchases` | Add a purchase | Yes |
| PUT | `/purchases/{id}` | Edit a purchase | Yes |
| DELETE | `/purchases/{id}` | Delete a purchase | Yes |
| GET | `/sells` | List user sells | 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 |
| Method | Endpoint | Description | Auth |
|--------|-------------------|------------------------------|------|
| POST | `/register` | Create account | No |
| POST | `/login` | Get JWT token | No |
| GET | `/purchases` | List user purchases | Yes |
| POST | `/purchases` | Add a purchase | Yes |
| PUT | `/purchases/{id}` | Edit a purchase | Yes |
| DELETE | `/purchases/{id}` | Delete a purchase | Yes |
| GET | `/stats` | Portfolio stats (P&L) | Yes |
| GET | `/history` | 365-day BTC price history | Yes |
---
@@ -124,19 +108,15 @@ btc-portfolio/
SQLite, stored at `/app/data/btc_portfolio.db` (persisted via Docker volume).
| Table | Columns |
|----------------|---------------------------------------------------------------|
| `users` | id, username (unique), password (bcrypt hash), is_admin |
| `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 |
| Table | Columns |
|-------------|------------------------------------------------------|
| `users` | id, username (unique), password (bcrypt hash) |
| `purchases` | id, amount_eur, price_eur, created_at, user_id (FK) |
---
## Notes
- The `SECRET_KEY` in `auth.py` is hardcoded — use an environment variable in production.
- 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.
- CoinGecko requests are unauthenticated; failures return `0.0` gracefully.
- CORS is restricted to `localhost:3000` by default.
- The frontend is served via nginx in the Docker production setup.
+1 -2
View File
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from .database import engine, Base, SessionLocal
from .routes import users, purchases, stats, history, admin, candles, sells, dca
from .routes import users, purchases, stats, history, admin, candles, sells
Base.metadata.create_all(bind=engine)
@@ -32,7 +32,6 @@ app.include_router(history.router)
app.include_router(admin.router, prefix="/admin")
app.include_router(candles.router)
app.include_router(sells.router)
app.include_router(dca.router)
@app.on_event("startup")
@@ -1,12 +1,9 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from datetime import datetime, timezone
from ..database import get_db
from .. import models
from ..dependencies import get_current_user
from ..services.candles import refresh_latest_candles
from ..services.btc import get_btc_price_eur
router = APIRouter()
@@ -17,9 +14,6 @@ def get_candles(
db: Session = Depends(get_db),
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())
if days != "all":
@@ -44,26 +38,6 @@ def get_candles(
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(
models.Purchase.user_id == current_user.id
).all()
-108
View File
@@ -1,108 +0,0 @@
from datetime import date, timedelta
from typing import Optional
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
from ..services.btc import get_btc_price_eur
router = APIRouter()
def _first_of_month(year: int, month: int) -> date:
return date(year, month, 1)
def _next_month(year: int, month: int) -> tuple[int, int]:
if month == 12:
return year + 1, 1
return year, month + 1
@router.get("/dca")
def get_dca(
monthly_amount: float = Query(..., gt=0),
start_date: Optional[str] = Query(default=None),
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
# Determine start date
if start_date:
try:
sim_start = date.fromisoformat(start_date)
except ValueError:
sim_start = None
else:
sim_start = None
if sim_start is None:
earliest = (
db.query(models.Purchase)
.filter(models.Purchase.user_id == current_user.id)
.order_by(models.Purchase.created_at.asc())
.first()
)
if earliest:
sim_start = earliest.created_at.date()
else:
sim_start = date.today() - timedelta(days=365)
# Load all candles into a lookup dict {date_str: close_price}
candles_db = db.query(models.OHLCCandle).all()
price_by_date: dict[str, float] = {c.date: c.close for c in candles_db}
today = date.today()
current_price, _ = get_btc_price_eur()
# Patch today's price in case the candle isn't refreshed yet
if current_price:
price_by_date[today.isoformat()] = current_price
# Walk month by month and simulate buys
year, month = sim_start.year, sim_start.month
end_year, end_month = today.year, today.month
dca_invested = 0.0
dca_btc = 0.0
monthly_series = []
while (year, month) <= (end_year, end_month):
# Find the closest available candle on or after the 1st of this month
buy_date = None
buy_price = None
for day_offset in range(8):
candidate = _first_of_month(year, month) + timedelta(days=day_offset)
key = candidate.isoformat()
if key in price_by_date:
buy_date = key
buy_price = price_by_date[key]
break
if buy_price and buy_price > 0:
btc_bought = monthly_amount / buy_price
dca_btc += btc_bought
dca_invested += monthly_amount
monthly_series.append({
"month": f"{year:04d}-{month:02d}",
"price_used": round(buy_price, 2),
"btc_bought": round(btc_bought, 8),
"cumulative_btc": round(dca_btc, 8),
"cumulative_invested": round(dca_invested, 2),
})
year, month = _next_month(year, month)
dca_current_value = dca_btc * current_price if current_price else 0.0
dca_profit_loss = dca_current_value - dca_invested
return {
"start_date": sim_start.isoformat(),
"monthly_amount": monthly_amount,
"dca_total_invested": round(dca_invested, 2),
"dca_total_btc": round(dca_btc, 8),
"dca_current_value": round(dca_current_value, 2),
"dca_profit_loss": round(dca_profit_loss, 2),
"monthly_series": monthly_series,
}
@@ -39,7 +39,7 @@ const btnStyle = {
marginLeft: '0.5rem',
};
export default function BTCCandlestickChart({ candles, purchases, stats, fullscreen, onToggleFullscreen, livePrice }) {
export default function BTCCandlestickChart({ candles, purchases, stats, fullscreen, onToggleFullscreen }) {
const containerRef = useRef(null);
const chartRef = useRef(null);
const candleSeriesRef = useRef(null);
@@ -167,14 +167,7 @@ export default function BTCCandlestickChart({ candles, purchases, stats, fullscr
return (
<div style={fullscreen ? fullscreenStyle : cardStyle}>
<div style={headerStyle}>
<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 style={titleStyle}>BTC Candles (EUR)</div>
<div>
<button style={btnStyle} onClick={handleSave}>Save as PNG</button>
<button style={btnStyle} onClick={onToggleFullscreen}>
@@ -1,140 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
const styles = {
card: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
header: { color: '#888', fontSize: '0.8rem', marginBottom: '0.75rem' },
row: { display: 'flex', gap: '1rem', alignItems: 'flex-start', flexWrap: 'wrap' },
inputWrap: { display: 'flex', flexDirection: 'column', gap: '0.3rem' },
label: { color: '#888', fontSize: '0.8rem' },
input: { background: '#111', border: '1px solid #444', borderRadius: '8px', color: '#fff', padding: '0.55rem 0.75rem', fontSize: '1rem', width: '160px', outline: 'none' },
results: { display: 'flex', flexDirection: 'column', gap: '0.75rem', flex: 1 },
statsRow: { display: 'flex', gap: '1rem', flexWrap: 'wrap' },
statCard: { background: '#111', padding: '0.75rem 1rem', borderRadius: '10px', border: '1px solid #2a2a2a', minWidth: '130px' },
statLabel: { color: '#888', fontSize: '0.75rem', marginBottom: '0.2rem' },
statValue: { fontSize: '1.1rem', fontWeight: 700 },
positive: { color: '#6bff8e' },
negative: { color: '#ff6b6b' },
neutral: { color: '#f7931a' },
comparison: { color: '#888', fontSize: '0.8rem' },
placeholder: { color: '#555', fontSize: '0.9rem', padding: '0.5rem 0' },
};
function MiniStatCard({ label, value, highlight }) {
return (
<div style={styles.statCard}>
<div style={styles.statLabel}>{label}</div>
<div style={{ ...styles.statValue, ...(highlight ? styles[highlight] : {}) }}>{value}</div>
</div>
);
}
export default function DCACalculator({ purchases, stats }) {
const defaultStart = () => {
if (purchases && purchases.length > 0) {
const sorted = [...purchases].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
return sorted[0].created_at.slice(0, 10);
}
const d = new Date();
d.setFullYear(d.getFullYear() - 1);
return d.toISOString().slice(0, 10);
};
const [monthlyAmount, setMonthlyAmount] = useState('');
const [startDate, setStartDate] = useState(defaultStart);
const [result, setResult] = useState(null);
const debounceRef = useRef(null);
// Update default start date when purchases load
useEffect(() => {
if (purchases && purchases.length > 0 && !monthlyAmount) {
setStartDate(defaultStart());
}
}, [purchases]); // eslint-disable-line
useEffect(() => {
const amount = parseFloat(monthlyAmount);
if (!amount || amount <= 0 || !startDate) {
setResult(null);
return;
}
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(
`${API}/dca?monthly_amount=${amount}&start_date=${startDate}`,
{ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }
);
if (res.ok) setResult(await res.json());
} catch {
// silently fail
}
}, 500);
return () => clearTimeout(debounceRef.current);
}, [monthlyAmount, startDate]);
const plHighlight = result
? result.dca_profit_loss >= 0 ? 'positive' : 'negative'
: null;
const actualPL = stats ? stats.profit_loss : null;
const actualHighlight = actualPL != null ? (actualPL >= 0 ? 'positive' : 'negative') : null;
const fmt = (n) => `${n.toLocaleString()}`;
const fmtPL = (n) => `${n >= 0 ? '+' : ''}${fmt(n)}`;
return (
<div style={styles.card}>
<div style={styles.header}>DCA Calculator What if you had invested monthly?</div>
<div style={styles.row}>
<div style={styles.inputWrap}>
<label style={styles.label}>Monthly amount ()</label>
<input
type="number"
min="1"
placeholder="e.g. 200"
value={monthlyAmount}
onChange={e => setMonthlyAmount(e.target.value)}
style={styles.input}
/>
</div>
<div style={styles.inputWrap}>
<label style={styles.label}>Starting from</label>
<input
type="date"
value={startDate}
onChange={e => setStartDate(e.target.value)}
style={styles.input}
/>
</div>
{result ? (
<div style={styles.results}>
<div style={styles.statsRow}>
<MiniStatCard label="DCA Invested" value={fmt(result.dca_total_invested)} />
<MiniStatCard label="DCA BTC" value={`${result.dca_total_btc}`} highlight="neutral" />
<MiniStatCard label="DCA Value" value={fmt(result.dca_current_value)} />
<MiniStatCard label="DCA P&L" value={fmtPL(result.dca_profit_loss)} highlight={plHighlight} />
</div>
{actualPL != null && (
<div style={styles.comparison}>
Actual P&L:&nbsp;
<span style={styles[actualHighlight]}>{fmtPL(actualPL)}</span>
&nbsp;&nbsp;|&nbsp;&nbsp;
DCA P&L:&nbsp;
<span style={styles[plHighlight]}>{fmtPL(result.dca_profit_loss)}</span>
</div>
)}
</div>
) : (
<div style={{ ...styles.placeholder, alignSelf: 'center' }}>
Enter a monthly amount to simulate DCA
</div>
)}
</div>
</div>
);
}
@@ -6,7 +6,6 @@ import AddSell from '../components/AddSell';
import SellList from '../components/SellList';
import PortfolioChart from '../components/PortfolioChart';
import BTCCandlestickChart from '../components/BTCCandlestickChart';
import DCACalculator from '../components/DCACalculator';
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
@@ -18,13 +17,6 @@ const styles = {
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' },
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' },
statLabel: { color: '#888', fontSize: '0.8rem', marginBottom: '0.3rem' },
statValue: { fontSize: '1.2rem', fontWeight: 700 },
@@ -61,7 +53,6 @@ export default function Dashboard() {
const [candlesAll, setCandlesAll] = useState(null);
const [fullscreenChart, setFullscreenChart] = useState(false);
const [chartView, setChartView] = useState('both'); // 'both' | 'portfolio' | 'history'
const [predictionPrice, setPredictionPrice] = useState('');
const navigate = useNavigate();
const authHeaders = () => ({
@@ -116,12 +107,6 @@ export default function Dashboard() {
setFullscreenChart(f => !f);
}, [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
? stats.profit_loss >= 0 ? 'positive' : 'negative'
: 'neutral';
@@ -158,44 +143,6 @@ export default function Dashboard() {
</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>
<DCACalculator purchases={purchases} stats={stats} />
<div style={styles.tabs}>
{[['both', 'Both'], ['portfolio', 'Portfolio'], ['history', 'BTC Candles']].map(([key, label]) => (
<button
@@ -211,7 +158,6 @@ export default function Dashboard() {
candles={activeCandles?.candles ?? null}
purchases={activeCandles?.purchases ?? purchases}
stats={stats}
livePrice={stats?.current_price ?? null}
fullscreen={fullscreenChart}
onToggleFullscreen={handleToggleFullscreen}
/>