Compare commits

...

7 Commits

Author SHA1 Message Date
Jonathan fd21aa7f4e Add DCA Calculator feature to dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 21:17: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 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
Jonathan 672f5b74a4 Fix production deployment: replace serve with nginx reverse proxy
Frontend container now uses nginx to serve static files and proxy
/api/* requests to the backend container internally, eliminating
the hardcoded localhost:8000 build-time URL that caused "Network
error" on any non-local server. CORS origins are also configurable
via ALLOWED_ORIGINS env var.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:18:38 +02:00
Jonathan a2ca82062e Cache last known BTC price and show stale warning in UI
When the CoinGecko API fails, fall back to the last successful price
instead of 0.0, and surface a warning indicator on the price card.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:01:30 +02:00
Jonathan 5bb67d6663 Add purchase date picker and sells feature
- Purchase form now includes a date picker (defaults to today)
- New Sell model, CRUD endpoints (/sells), and stats integration
- AddSell and SellList components added to dashboard
- Portfolio chart updated to reflect sells over time

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:52:24 +02:00
19 changed files with 826 additions and 69 deletions
+40 -20
View File
@@ -1,16 +1,19 @@
# 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
--- ---
@@ -18,12 +21,12 @@ A full-stack Bitcoin portfolio tracker with live EUR pricing, purchase history,
## 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,26 +68,31 @@ 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
``` ```
--- ---
@@ -92,15 +100,23 @@ btc-portfolio/
## 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 |
| 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 | `/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 |
--- ---
@@ -109,14 +125,18 @@ 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.
+12 -2
View File
@@ -1,17 +1,25 @@
import os
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware 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 from .routes import users, purchases, stats, history, admin, candles, sells, dca
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
app = FastAPI(title="BTC Portfolio API") app = FastAPI(title="BTC Portfolio API")
_raw_origins = os.environ.get(
"ALLOWED_ORIGINS",
"http://localhost:3000,http://localhost:3001",
)
allowed_origins = [o.strip() for o in _raw_origins.split(",") if o.strip()]
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:3001"], allow_origins=allowed_origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"], allow_headers=["Content-Type", "Authorization"],
@@ -23,6 +31,8 @@ app.include_router(stats.router)
app.include_router(history.router) 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(dca.router)
@app.on_event("startup") @app.on_event("startup")
+13
View File
@@ -13,6 +13,7 @@ class User(Base):
is_admin = Column(Boolean, default=False, nullable=False, server_default='0') is_admin = Column(Boolean, default=False, nullable=False, server_default='0')
purchases = relationship("Purchase", back_populates="owner", cascade="all, delete") purchases = relationship("Purchase", back_populates="owner", cascade="all, delete")
sells = relationship("Sell", back_populates="owner", cascade="all, delete")
class Purchase(Base): class Purchase(Base):
@@ -27,6 +28,18 @@ class Purchase(Base):
owner = relationship("User", back_populates="purchases") owner = relationship("User", back_populates="purchases")
class Sell(Base):
__tablename__ = "sells"
id = Column(Integer, primary_key=True, index=True)
btc_amount = Column(Float, nullable=False)
price_eur = Column(Float, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
owner = relationship("User", back_populates="sells")
class OHLCCandle(Base): class OHLCCandle(Base):
__tablename__ = "ohlc_candles" __tablename__ = "ohlc_candles"
@@ -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()
+108
View File
@@ -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,
}
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import List from typing import List, Optional
from datetime import datetime from datetime import datetime
from ..database import get_db from ..database import get_db
@@ -14,6 +14,7 @@ router = APIRouter()
class PurchaseCreate(BaseModel): class PurchaseCreate(BaseModel):
amount_eur: float = Field(gt=0, le=10_000_000) amount_eur: float = Field(gt=0, le=10_000_000)
price_eur: float = Field(gt=0, le=10_000_000) price_eur: float = Field(gt=0, le=10_000_000)
created_at: Optional[datetime] = None
class PurchaseUpdate(BaseModel): class PurchaseUpdate(BaseModel):
@@ -54,6 +55,7 @@ def add_purchase(
purchase = models.Purchase( purchase = models.Purchase(
amount_eur=purchase_in.amount_eur, amount_eur=purchase_in.amount_eur,
price_eur=purchase_in.price_eur, price_eur=purchase_in.price_eur,
created_at=purchase_in.created_at or datetime.utcnow(),
user_id=current_user.id, user_id=current_user.id,
) )
db.add(purchase) db.add(purchase)
+101
View File
@@ -0,0 +1,101 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
from ..database import get_db
from .. import models
from ..dependencies import get_current_user
router = APIRouter()
class SellCreate(BaseModel):
btc_amount: float = Field(gt=0, le=21_000_000)
price_eur: float = Field(gt=0, le=10_000_000)
created_at: Optional[datetime] = None
class SellUpdate(BaseModel):
btc_amount: float = Field(gt=0, le=21_000_000)
price_eur: float = Field(gt=0, le=10_000_000)
created_at: datetime
class SellOut(BaseModel):
id: int
btc_amount: float
price_eur: float
created_at: datetime
class Config:
from_attributes = True
@router.get("/sells", response_model=List[SellOut])
def list_sells(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
return (
db.query(models.Sell)
.filter(models.Sell.user_id == current_user.id)
.order_by(models.Sell.created_at)
.all()
)
@router.post("/sells", response_model=SellOut, status_code=status.HTTP_201_CREATED)
def add_sell(
sell_in: SellCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
sell = models.Sell(
btc_amount=sell_in.btc_amount,
price_eur=sell_in.price_eur,
created_at=sell_in.created_at or datetime.utcnow(),
user_id=current_user.id,
)
db.add(sell)
db.commit()
db.refresh(sell)
return sell
@router.put("/sells/{sell_id}", response_model=SellOut)
def update_sell(
sell_id: int,
sell_in: SellUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
sell = db.query(models.Sell).filter(
models.Sell.id == sell_id,
models.Sell.user_id == current_user.id,
).first()
if not sell:
raise HTTPException(status_code=404, detail="Sell not found")
sell.btc_amount = sell_in.btc_amount
sell.price_eur = sell_in.price_eur
sell.created_at = sell_in.created_at
db.commit()
db.refresh(sell)
return sell
@router.delete("/sells/{sell_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_sell(
sell_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
sell = db.query(models.Sell).filter(
models.Sell.id == sell_id,
models.Sell.user_id == current_user.id,
).first()
if not sell:
raise HTTPException(status_code=404, detail="Sell not found")
db.delete(sell)
db.commit()
+15 -7
View File
@@ -15,19 +15,27 @@ def get_stats(
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
purchases = db.query(models.Purchase).filter(models.Purchase.user_id == current_user.id).all() purchases = db.query(models.Purchase).filter(models.Purchase.user_id == current_user.id).all()
sells = db.query(models.Sell).filter(models.Sell.user_id == current_user.id).all()
total_invested = sum(p.amount_eur for p in purchases) total_invested = sum(p.amount_eur for p in purchases)
total_btc = sum(p.amount_eur / p.price_eur for p in purchases) if purchases else 0.0 total_btc_bought = sum(p.amount_eur / p.price_eur for p in purchases) if purchases else 0.0
average_price = total_invested / total_btc if total_btc > 0 else 0.0
current_price = get_btc_price_eur() total_btc_sold = sum(s.btc_amount for s in sells)
portfolio_value = total_btc * current_price proceeds_eur = sum(s.btc_amount * s.price_eur for s in sells)
profit_loss = portfolio_value - total_invested
net_btc = total_btc_bought - total_btc_sold
net_invested = total_invested - proceeds_eur
average_price = net_invested / net_btc if net_btc > 0 else 0.0
current_price, price_is_cached = get_btc_price_eur()
portfolio_value = net_btc * current_price
profit_loss = portfolio_value - net_invested
return { return {
"total_invested": round(total_invested, 2), "total_invested": round(net_invested, 2),
"total_btc": round(total_btc, 8), "total_btc": round(net_btc, 8),
"average_price": round(average_price, 2), "average_price": round(average_price, 2),
"current_price": round(current_price, 2), "current_price": round(current_price, 2),
"price_is_cached": price_is_cached,
"portfolio_value": round(portfolio_value, 2), "portfolio_value": round(portfolio_value, 2),
"profit_loss": round(profit_loss, 2), "profit_loss": round(profit_loss, 2),
} }
+10 -3
View File
@@ -54,7 +54,12 @@ def aggregate_to_daily(raw: list) -> dict:
return by_date return by_date
def get_btc_price_eur() -> float: _last_known_price: float = 0.0
def get_btc_price_eur() -> tuple[float, bool]:
"""Returns (price, is_cached). is_cached=True when using a stale fallback."""
global _last_known_price
try: try:
resp = requests.get( resp = requests.get(
"https://api.coingecko.com/api/v3/simple/price", "https://api.coingecko.com/api/v3/simple/price",
@@ -62,7 +67,9 @@ def get_btc_price_eur() -> float:
timeout=10, timeout=10,
) )
resp.raise_for_status() resp.raise_for_status()
return float(resp.json()["bitcoin"]["eur"]) price = float(resp.json()["bitcoin"]["eur"])
_last_known_price = price
return price, False
except Exception as e: except Exception as e:
logger.error(f"Failed to fetch BTC price: {e}") logger.error(f"Failed to fetch BTC price: {e}")
return 0.0 return _last_known_price, True
+4 -3
View File
@@ -8,6 +8,7 @@ services:
environment: environment:
- DATABASE_URL=sqlite:////app/data/btc_portfolio.db - DATABASE_URL=sqlite:////app/data/btc_portfolio.db
- SECRET_KEY=${SECRET_KEY:-dev-insecure-key-change-me} - SECRET_KEY=${SECRET_KEY:-dev-insecure-key-change-me}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:3001}
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"]
@@ -20,14 +21,14 @@ services:
build: build:
context: ./frontend context: ./frontend
args: args:
- REACT_APP_API_URL=http://localhost:8000 - REACT_APP_API_URL=/api
ports: ports:
- "3001:3001" - "3001:80"
depends_on: depends_on:
- backend - backend
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/"] test: ["CMD", "wget", "-qO-", "http://localhost:80/"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
+6 -10
View File
@@ -4,16 +4,12 @@ WORKDIR /app
COPY package.json ./ COPY package.json ./
RUN npm install RUN npm install
COPY . . COPY . .
ARG REACT_APP_API_URL=http://localhost:8000 ARG REACT_APP_API_URL=/api
ENV REACT_APP_API_URL=$REACT_APP_API_URL ENV REACT_APP_API_URL=$REACT_APP_API_URL
RUN npm run build RUN npm run build
FROM node:18-alpine FROM nginx:alpine
RUN npm install -g serve COPY --from=build /app/build /usr/share/nginx/html
RUN addgroup -S appgroup && adduser -S appuser -G appgroup COPY nginx.conf /etc/nginx/conf.d/default.conf
WORKDIR /app EXPOSE 80
COPY --from=build /app/build ./build CMD ["nginx", "-g", "daemon off;"]
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3001
CMD ["serve", "-s", "build", "-l", "3001"]
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
@@ -12,6 +12,7 @@ const styles = {
}; };
export default function AddPurchase({ onAdded }) { export default function AddPurchase({ onAdded }) {
const [purchaseDate, setPurchaseDate] = useState(new Date().toISOString().split('T')[0]);
const [amountEur, setAmountEur] = useState(''); const [amountEur, setAmountEur] = useState('');
const [priceEur, setPriceEur] = useState(''); const [priceEur, setPriceEur] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -30,12 +31,14 @@ export default function AddPurchase({ onAdded }) {
body: JSON.stringify({ body: JSON.stringify({
amount_eur: parseFloat(amountEur), amount_eur: parseFloat(amountEur),
price_eur: parseFloat(priceEur), price_eur: parseFloat(priceEur),
created_at: new Date(purchaseDate + 'T12:00:00').toISOString(),
}), }),
}); });
if (!res.ok) { if (!res.ok) {
setError('Failed to add purchase'); setError('Failed to add purchase');
return; return;
} }
setPurchaseDate(new Date().toISOString().split('T')[0]);
setAmountEur(''); setAmountEur('');
setPriceEur(''); setPriceEur('');
onAdded(); onAdded();
@@ -50,6 +53,13 @@ export default function AddPurchase({ onAdded }) {
<div style={styles.title}>Add Purchase</div> <div style={styles.title}>Add Purchase</div>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div style={styles.row}> <div style={styles.row}>
<input
style={styles.input}
type="date"
value={purchaseDate}
onChange={e => setPurchaseDate(e.target.value)}
required
/>
<input <input
style={styles.input} style={styles.input}
type="number" type="number"
@@ -0,0 +1,87 @@
import React, { useState } from 'react';
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
const styles = {
card: { background: '#1a1a1a', padding: '1.5rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
title: { fontSize: '1.1rem', fontWeight: 700, marginBottom: '1rem', color: '#f7931a' },
row: { display: 'flex', gap: '0.75rem', flexWrap: 'wrap' },
input: { flex: 1, minWidth: '140px', padding: '0.65rem', background: '#2a2a2a', border: '1px solid #444', borderRadius: '8px', color: '#e0e0e0', fontSize: '1rem' },
button: { padding: '0.65rem 1.5rem', background: '#f7931a', color: '#000', border: 'none', borderRadius: '8px', fontWeight: 700, cursor: 'pointer' },
error: { color: '#ff6b6b', marginTop: '0.5rem', fontSize: '0.9rem' },
};
export default function AddSell({ onAdded }) {
const [sellDate, setSellDate] = useState(new Date().toISOString().split('T')[0]);
const [btcAmount, setBtcAmount] = useState('');
const [priceEur, setPriceEur] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
const token = localStorage.getItem('token');
try {
const res = await fetch(`${API}/sells`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
btc_amount: parseFloat(btcAmount),
price_eur: parseFloat(priceEur),
created_at: new Date(sellDate + 'T12:00:00').toISOString(),
}),
});
if (!res.ok) {
setError('Failed to add sell');
return;
}
setSellDate(new Date().toISOString().split('T')[0]);
setBtcAmount('');
setPriceEur('');
onAdded();
} catch (err) {
console.error('AddSell network error:', err);
setError('Network error');
}
};
return (
<div style={styles.card}>
<div style={styles.title}>Add Sell</div>
<form onSubmit={handleSubmit}>
<div style={styles.row}>
<input
style={styles.input}
type="date"
value={sellDate}
onChange={e => setSellDate(e.target.value)}
required
/>
<input
style={styles.input}
type="number"
step="any"
placeholder="BTC Amount"
value={btcAmount}
onChange={e => setBtcAmount(e.target.value)}
required
/>
<input
style={styles.input}
type="number"
step="any"
placeholder="BTC Price (EUR)"
value={priceEur}
onChange={e => setPriceEur(e.target.value)}
required
/>
<button style={styles.button} type="submit">Add</button>
</div>
{error && <div style={styles.error}>{error}</div>}
</form>
</div>
);
}
@@ -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:&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>
);
}
@@ -42,12 +42,13 @@ function priceOn(date, priceMap, currentPrice, isToday, sortedPurchases) {
return fallback; return fallback;
} }
export default function PortfolioChart({ purchases, stats, btcHistory }) { export default function PortfolioChart({ purchases, sells, stats, btcHistory }) {
const chartRef = useRef(null); const chartRef = useRef(null);
if (!purchases || purchases.length === 0) return null; if (!purchases || purchases.length === 0) return null;
const sorted = [...purchases].sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); const sorted = [...purchases].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
const sortedSells = [...(sells || [])].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
@@ -89,6 +90,14 @@ export default function PortfolioChart({ purchases, stats, btcHistory }) {
cumInvested += p.amount_eur; cumInvested += p.amount_eur;
} }
}); });
sortedSells.forEach(s => {
const sDate = new Date(s.created_at);
sDate.setHours(0, 0, 0, 0);
if (sDate <= date) {
cumBtc -= s.btc_amount;
cumInvested -= s.btc_amount * s.price_eur;
}
});
if (cumBtc === 0) return; // no purchases yet at this date if (cumBtc === 0) return; // no purchases yet at this date
@@ -0,0 +1,119 @@
import React, { useState } from 'react';
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
const styles = {
card: { background: '#1a1a1a', padding: '1.5rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
title: { fontSize: '1.1rem', fontWeight: 700, marginBottom: '1rem', color: '#f7931a' },
table: { width: '100%', borderCollapse: 'collapse' },
th: { textAlign: 'left', padding: '0.5rem 0.75rem', borderBottom: '1px solid #333', color: '#888', fontSize: '0.85rem' },
td: { padding: '0.6rem 0.75rem', borderBottom: '1px solid #222', fontSize: '0.95rem' },
input: { background: '#2a2a2a', border: '1px solid #555', borderRadius: '6px', color: '#e0e0e0', padding: '0.3rem 0.5rem', fontSize: '0.9rem', width: '100%' },
editBtn: { background: 'none', border: '1px solid #555', color: '#4fc3f7', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem', marginRight: '0.4rem' },
saveBtn: { background: 'none', border: '1px solid #6bff8e', color: '#6bff8e', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem', marginRight: '0.4rem' },
cancelBtn: { background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem', marginRight: '0.4rem' },
deleteBtn: { background: 'none', border: '1px solid #555', color: '#ff6b6b', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem' },
empty: { color: '#555', textAlign: 'center', padding: '1rem' },
};
function toDateInputValue(isoString) {
return isoString ? isoString.slice(0, 10) : '';
}
export default function SellList({ sells, onChanged }) {
const [editingId, setEditingId] = useState(null);
const [editForm, setEditForm] = useState({});
const token = () => localStorage.getItem('token');
const startEdit = (s) => {
setEditingId(s.id);
setEditForm({
btc_amount: s.btc_amount,
price_eur: s.price_eur,
created_at: toDateInputValue(s.created_at),
});
};
const cancelEdit = () => setEditingId(null);
const handleSave = async (id) => {
const res = await fetch(`${API}/sells/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token()}` },
body: JSON.stringify({
btc_amount: parseFloat(editForm.btc_amount),
price_eur: parseFloat(editForm.price_eur),
created_at: new Date(editForm.created_at + 'T12:00:00').toISOString(),
}),
});
if (res.ok) {
setEditingId(null);
onChanged();
}
};
const handleDelete = async (id) => {
await fetch(`${API}/sells/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token()}` },
});
onChanged();
};
const set = (field) => (e) => setEditForm(f => ({ ...f, [field]: e.target.value }));
return (
<div style={styles.card}>
<div style={styles.title}>Sells</div>
{sells.length === 0 ? (
<div style={styles.empty}>No sells yet.</div>
) : (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Date</th>
<th style={styles.th}>BTC Amount</th>
<th style={styles.th}>Price (/BTC)</th>
<th style={styles.th}>Value ()</th>
<th style={styles.th}></th>
</tr>
</thead>
<tbody>
{sells.map(s => editingId === s.id ? (
<tr key={s.id}>
<td style={styles.td}>
<input style={styles.input} type="date" value={editForm.created_at} onChange={set('created_at')} />
</td>
<td style={styles.td}>
<input style={styles.input} type="number" step="any" value={editForm.btc_amount} onChange={set('btc_amount')} />
</td>
<td style={styles.td}>
<input style={styles.input} type="number" step="any" value={editForm.price_eur} onChange={set('price_eur')} />
</td>
<td style={styles.td}>
{(parseFloat(editForm.btc_amount) * parseFloat(editForm.price_eur) || 0).toLocaleString()}
</td>
<td style={styles.td}>
<button style={styles.saveBtn} onClick={() => handleSave(s.id)}>Save</button>
<button style={styles.cancelBtn} onClick={cancelEdit}>Cancel</button>
</td>
</tr>
) : (
<tr key={s.id}>
<td style={styles.td}>{new Date(s.created_at).toLocaleDateString('en-GB')}</td>
<td style={styles.td}>{s.btc_amount.toFixed(8)}</td>
<td style={styles.td}>{s.price_eur.toLocaleString()}</td>
<td style={styles.td}>{(s.btc_amount * s.price_eur).toLocaleString()}</td>
<td style={styles.td}>
<button style={styles.editBtn} onClick={() => startEdit(s)}>Edit</button>
<button style={styles.deleteBtn} onClick={() => handleDelete(s.id)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
+78 -5
View File
@@ -2,8 +2,11 @@ import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import AddPurchase from '../components/AddPurchase'; import AddPurchase from '../components/AddPurchase';
import PurchaseList from '../components/PurchaseList'; import PurchaseList from '../components/PurchaseList';
import AddSell from '../components/AddSell';
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';
@@ -15,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 },
@@ -26,11 +36,19 @@ const styles = {
tabActive: { padding: '0.45rem 1.1rem', borderRadius: '8px', border: '1px solid #f7931a', background: 'rgba(247,147,26,0.1)', color: '#f7931a', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 700 }, tabActive: { padding: '0.45rem 1.1rem', borderRadius: '8px', border: '1px solid #f7931a', background: 'rgba(247,147,26,0.1)', color: '#f7931a', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 700 },
}; };
function StatCard({ label, value, highlight }) { function StatCard({ label, value, highlight, warning }) {
return ( return (
<div style={styles.statCard}> <div style={styles.statCard}>
<div style={styles.statLabel}>{label}</div> <div style={{ ...styles.statLabel, display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
{label}
{warning && (
<span title={warning} style={{ color: '#f7931a', cursor: 'default', fontSize: '0.85rem' }}></span>
)}
</div>
<div style={{ ...styles.statValue, ...(highlight ? styles[highlight] : {}) }}>{value}</div> <div style={{ ...styles.statValue, ...(highlight ? styles[highlight] : {}) }}>{value}</div>
{warning && (
<div style={{ color: '#888', fontSize: '0.7rem', marginTop: '0.25rem' }}>{warning}</div>
)}
</div> </div>
); );
} }
@@ -38,10 +56,12 @@ function StatCard({ label, value, highlight }) {
export default function Dashboard() { export default function Dashboard() {
const [stats, setStats] = useState(null); const [stats, setStats] = useState(null);
const [purchases, setPurchases] = useState([]); const [purchases, setPurchases] = useState([]);
const [sells, setSells] = useState([]);
const [candles, setCandles] = useState(null); const [candles, setCandles] = useState(null);
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 = () => ({
@@ -50,9 +70,10 @@ export default function Dashboard() {
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
const [statsRes, purchasesRes, candlesRes] = await Promise.all([ const [statsRes, purchasesRes, sellsRes, candlesRes] = await Promise.all([
fetch(`${API}/stats`, { headers: authHeaders() }), fetch(`${API}/stats`, { headers: authHeaders() }),
fetch(`${API}/purchases`, { headers: authHeaders() }), fetch(`${API}/purchases`, { headers: authHeaders() }),
fetch(`${API}/sells`, { headers: authHeaders() }),
fetch(`${API}/candles?days=365`, { headers: authHeaders() }), fetch(`${API}/candles?days=365`, { headers: authHeaders() }),
]); ]);
if (statsRes.status === 401) { if (statsRes.status === 401) {
@@ -62,6 +83,7 @@ export default function Dashboard() {
} }
setStats(await statsRes.json()); setStats(await statsRes.json());
setPurchases(await purchasesRes.json()); setPurchases(await purchasesRes.json());
setSells(await sellsRes.json());
setCandles(await candlesRes.json()); setCandles(await candlesRes.json());
} catch { } catch {
// silently fail — network may be unavailable // silently fail — network may be unavailable
@@ -94,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';
@@ -116,7 +144,11 @@ export default function Dashboard() {
<StatCard label="Total Invested" value={`${stats.total_invested.toLocaleString()}`} /> <StatCard label="Total Invested" value={`${stats.total_invested.toLocaleString()}`} />
<StatCard label="Avg Buy Price" value={`${stats.average_price.toLocaleString()}`} /> <StatCard label="Avg Buy Price" value={`${stats.average_price.toLocaleString()}`} />
<StatCard label="Total BTC" value={`${stats.total_btc}`} highlight="neutral" /> <StatCard label="Total BTC" value={`${stats.total_btc}`} highlight="neutral" />
<StatCard label="Current BTC Price" value={`${stats.current_price.toLocaleString()}`} /> <StatCard
label="Current BTC Price"
value={`${stats.current_price.toLocaleString()}`}
warning={stats.price_is_cached ? 'Price may be outdated — live fetch failed' : undefined}
/>
<StatCard label="Portfolio Value" value={`${stats.portfolio_value.toLocaleString()}`} /> <StatCard label="Portfolio Value" value={`${stats.portfolio_value.toLocaleString()}`} />
<StatCard <StatCard
label="Profit / Loss" label="Profit / Loss"
@@ -126,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
@@ -135,18 +205,21 @@ export default function Dashboard() {
>{label}</button> >{label}</button>
))} ))}
</div> </div>
{(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} stats={stats} btcHistory={candles?.candles ?? []} />} {(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} sells={sells} stats={stats} btcHistory={candles?.candles ?? []} />}
{(chartView === 'both' || chartView === 'history') && ( {(chartView === 'both' || chartView === 'history') && (
<BTCCandlestickChart <BTCCandlestickChart
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}
/> />
)} )}
<AddPurchase onAdded={fetchData} /> <AddPurchase onAdded={fetchData} />
<PurchaseList purchases={purchases} onChanged={fetchData} /> <PurchaseList purchases={purchases} onChanged={fetchData} />
<AddSell onAdded={fetchData} />
<SellList sells={sells} onChanged={fetchData} />
</div> </div>
); );
} }