Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd21aa7f4e | |||
| 59f833d7fd | |||
| d5197cde14 | |||
| d90ac5365a |
@@ -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.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from .database import engine, Base, SessionLocal
|
from .database import engine, Base, SessionLocal
|
||||||
from .routes import users, purchases, stats, history, admin, candles, sells
|
from .routes import users, purchases, stats, history, admin, candles, sells, dca
|
||||||
|
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ app.include_router(history.router)
|
|||||||
app.include_router(admin.router, prefix="/admin")
|
app.include_router(admin.router, prefix="/admin")
|
||||||
app.include_router(candles.router)
|
app.include_router(candles.router)
|
||||||
app.include_router(sells.router)
|
app.include_router(sells.router)
|
||||||
|
app.include_router(dca.router)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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',
|
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}>
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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:
|
||||||
|
<span style={styles[actualHighlight]}>{fmtPL(actualPL)}</span>
|
||||||
|
|
|
||||||
|
DCA P&L:
|
||||||
|
<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,6 +6,7 @@ import AddSell from '../components/AddSell';
|
|||||||
import SellList from '../components/SellList';
|
import SellList from '../components/SellList';
|
||||||
import PortfolioChart from '../components/PortfolioChart';
|
import PortfolioChart from '../components/PortfolioChart';
|
||||||
import BTCCandlestickChart from '../components/BTCCandlestickChart';
|
import BTCCandlestickChart from '../components/BTCCandlestickChart';
|
||||||
|
import DCACalculator from '../components/DCACalculator';
|
||||||
|
|
||||||
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
@@ -17,6 +18,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 +61,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 +116,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 +158,44 @@ 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>
|
||||||
|
|
||||||
|
<DCACalculator purchases={purchases} stats={stats} />
|
||||||
|
|
||||||
<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 +211,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