Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15358c05c3 | |||
| 59f833d7fd | |||
| d5197cde14 |
@@ -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}>
|
||||||
|
|||||||
@@ -208,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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user