Compare commits
24 Commits
db9624822b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 15358c05c3 | |||
| 59f833d7fd | |||
| d5197cde14 | |||
| 9c0db31580 | |||
| d90ac5365a | |||
| ce9547a623 | |||
| 672f5b74a4 | |||
| a2ca82062e | |||
| 5bb67d6663 | |||
| 33656c4512 | |||
| 5cf3726f59 | |||
| ce227e47d6 | |||
| 11b020907d | |||
| 4da23c9def | |||
| 85455f3271 | |||
| 4616accc63 | |||
| a0692501b3 | |||
| cb28979208 | |||
| 79b565cfb6 | |||
| befbe12bcf | |||
| 0803d86e38 | |||
| aedc6a8a17 | |||
| c1371e9c72 | |||
| e93b9dfa53 |
@@ -0,0 +1,2 @@
|
||||
# Local wallet scripts with credentials — never commit
|
||||
btc_wallet.py
|
||||
@@ -70,3 +70,9 @@ Dashboard has a chart toggle (Both / Portfolio / 1-Year BTC) using tab state in
|
||||
- `purchases`: id, amount_eur, price_eur, created_at, user_id (FK)
|
||||
|
||||
Database persists via Docker volume at `/app/data/btc_portfolio.db`.
|
||||
|
||||
## Git Workflow
|
||||
|
||||
- **Never work directly on `main`.** All development happens on `development` (or a feature branch).
|
||||
- After any merge/push to `main`, always switch back to `development` immediately.
|
||||
- `main` is only touched to merge in completed work from `development`.
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
# 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
|
||||
|
||||
- **Live BTC price** — fetched from CoinGecko in EUR
|
||||
- **Purchase tracking** — log BTC buys with amount (EUR) and price per BTC
|
||||
- **Portfolio stats** — total invested, current value, profit/loss
|
||||
- **Interactive charts** — portfolio value over time and 1-year BTC price history
|
||||
- **Edit & delete** — manage purchases with inline editing
|
||||
- **Live BTC price** — fetched from CoinGecko in EUR, with cached fallback and stale warning
|
||||
- **Purchase tracking** — log BTC buys with amount (EUR), price per BTC, and a custom date
|
||||
- **Sell tracking** — log BTC sells with BTC amount, price per BTC, and a custom date
|
||||
- **Portfolio stats** — total invested, current value, profit/loss, net BTC held
|
||||
- **Price prediction** — enter a target BTC price to see projected portfolio value and P&L
|
||||
- **Interactive charts** — portfolio value over time and BTC candlestick chart (OHLC stored locally)
|
||||
- **Edit & delete** — inline editing and deletion for both purchases and sells
|
||||
- **Admin panel** — admin users can create, list, and delete accounts
|
||||
- **JWT authentication** — secure per-user portfolios
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|----------|-------------------------------------|
|
||||
| Frontend | React 18, Chart.js, dark theme |
|
||||
| Backend | FastAPI, SQLAlchemy, SQLite |
|
||||
| Auth | JWT (HS256, 24h expiry) + bcrypt |
|
||||
| Pricing | CoinGecko API (EUR) |
|
||||
| Deploy | Docker + Docker Compose |
|
||||
| Layer | Technology |
|
||||
|----------|-----------------------------------------|
|
||||
| Frontend | React 18, Chart.js, dark theme |
|
||||
| Backend | FastAPI, SQLAlchemy, SQLite |
|
||||
| Auth | JWT (HS256, 24h expiry) + bcrypt |
|
||||
| Pricing | CoinGecko API (EUR) |
|
||||
| Deploy | Docker + Docker Compose + nginx |
|
||||
|
||||
---
|
||||
|
||||
@@ -65,42 +68,55 @@ btc-portfolio/
|
||||
├── backend/
|
||||
│ └── app/
|
||||
│ ├── main.py # FastAPI app + CORS
|
||||
│ ├── models.py # User & Purchase ORM models
|
||||
│ ├── models.py # User, Purchase, Sell, OHLCCandle ORM models
|
||||
│ ├── auth.py # JWT + bcrypt
|
||||
│ ├── dependencies.py # Auth dependency injection
|
||||
│ ├── routes/
|
||||
│ │ ├── users.py # POST /register, POST /login
|
||||
│ │ ├── purchases.py # CRUD /purchases
|
||||
│ │ ├── sells.py # CRUD /sells
|
||||
│ │ ├── stats.py # GET /stats
|
||||
│ │ └── history.py # GET /history (365-day BTC prices)
|
||||
│ │ ├── history.py # GET /history (365-day BTC prices)
|
||||
│ │ ├── candles.py # GET /candles (OHLC data + purchases overlay)
|
||||
│ │ └── admin.py # GET/POST/DELETE /admin/users
|
||||
│ └── services/
|
||||
│ └── btc.py # CoinGecko integration
|
||||
└── frontend/
|
||||
└── src/
|
||||
├── App.js # Routing
|
||||
├── pages/
|
||||
│ └── Dashboard.js # Main view
|
||||
│ └── Dashboard.js # Main view: stats, predictions, charts, tables
|
||||
└── components/
|
||||
├── AddPurchase.js # Purchase form
|
||||
├── PurchaseList.js # Purchase table (edit/delete)
|
||||
├── PortfolioChart.js # Invested vs current value
|
||||
└── BTCHistoryChart.js # 1-year BTC price history
|
||||
├── AddPurchase.js # Purchase form (amount EUR, price, date)
|
||||
├── PurchaseList.js # Purchase table (inline edit/delete)
|
||||
├── AddSell.js # Sell form (BTC amount, price, date)
|
||||
├── SellList.js # Sell table (inline edit/delete)
|
||||
├── PortfolioChart.js # Invested vs current value over time
|
||||
└── BTCCandlestickChart.js # OHLC candlestick chart with purchase markers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|-------------------|------------------------------|------|
|
||||
| POST | `/register` | Create account | No |
|
||||
| POST | `/login` | Get JWT token | No |
|
||||
| GET | `/purchases` | List user purchases | Yes |
|
||||
| POST | `/purchases` | Add a purchase | Yes |
|
||||
| PUT | `/purchases/{id}` | Edit a purchase | Yes |
|
||||
| DELETE | `/purchases/{id}` | Delete a purchase | Yes |
|
||||
| GET | `/stats` | Portfolio stats (P&L) | Yes |
|
||||
| GET | `/history` | 365-day BTC price history | Yes |
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|------------------------|------------------------------------|-------|
|
||||
| POST | `/register` | Create account | No |
|
||||
| POST | `/login` | Get JWT token | No |
|
||||
| GET | `/purchases` | List user purchases | Yes |
|
||||
| POST | `/purchases` | Add a purchase | Yes |
|
||||
| PUT | `/purchases/{id}` | Edit a purchase | Yes |
|
||||
| DELETE | `/purchases/{id}` | Delete a purchase | Yes |
|
||||
| GET | `/sells` | List user sells | Yes |
|
||||
| POST | `/sells` | Add a sell | Yes |
|
||||
| PUT | `/sells/{sell_id}` | Edit a sell | Yes |
|
||||
| DELETE | `/sells/{sell_id}` | Delete a sell | Yes |
|
||||
| GET | `/stats` | Portfolio stats (P&L, net BTC) | Yes |
|
||||
| GET | `/history` | 365-day BTC price history | Yes |
|
||||
| GET | `/candles` | OHLC candles + purchase overlay | Yes |
|
||||
| GET | `/admin/users` | List all users (admin only) | Admin |
|
||||
| POST | `/admin/users` | Create a user (admin only) | Admin |
|
||||
| DELETE | `/admin/users/{id}` | Delete a user (admin only) | Admin |
|
||||
|
||||
---
|
||||
|
||||
@@ -108,15 +124,19 @@ btc-portfolio/
|
||||
|
||||
SQLite, stored at `/app/data/btc_portfolio.db` (persisted via Docker volume).
|
||||
|
||||
| Table | Columns |
|
||||
|-------------|------------------------------------------------------|
|
||||
| `users` | id, username (unique), password (bcrypt hash) |
|
||||
| `purchases` | id, amount_eur, price_eur, created_at, user_id (FK) |
|
||||
| Table | Columns |
|
||||
|----------------|---------------------------------------------------------------|
|
||||
| `users` | id, username (unique), password (bcrypt hash), is_admin |
|
||||
| `purchases` | id, amount_eur, price_eur, created_at, user_id (FK) |
|
||||
| `sells` | id, btc_amount, price_eur, created_at, user_id (FK) |
|
||||
| `ohlc_candles` | id, date (unique, YYYY-MM-DD), open, high, low, close |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- 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.
|
||||
- The frontend is served via nginx in the Docker production setup.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
*.egg-info
|
||||
.pytest_cache
|
||||
@@ -1,8 +1,9 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
SECRET_KEY = "change-me-in-production-use-a-long-random-string"
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-insecure-key-change-me")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day
|
||||
|
||||
|
||||
@@ -27,3 +27,9 @@ def get_current_user(
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
def get_current_admin(current_user: models.User = Depends(get_current_user)) -> models.User:
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return current_user
|
||||
|
||||
@@ -2,28 +2,54 @@ import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
|
||||
from .database import engine, Base
|
||||
from .routes import users, purchases, stats, history
|
||||
from .database import engine, Base, SessionLocal
|
||||
from .routes import users, purchases, stats, history, admin, candles, sells
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(title="BTC Portfolio API")
|
||||
|
||||
origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:3001").split(",")
|
||||
_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(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_origins=allowed_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization"],
|
||||
)
|
||||
|
||||
app.include_router(users.router)
|
||||
app.include_router(purchases.router)
|
||||
app.include_router(stats.router)
|
||||
app.include_router(history.router)
|
||||
app.include_router(admin.router, prefix="/admin")
|
||||
app.include_router(candles.router)
|
||||
app.include_router(sells.router)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
# Schema migration: add is_admin column if missing
|
||||
with engine.connect() as conn:
|
||||
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(users)"))]
|
||||
if "is_admin" not in cols:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT 0"))
|
||||
conn.commit()
|
||||
|
||||
# Seed / refresh BTC candle data
|
||||
from .services.candles import refresh_latest_candles
|
||||
db = SessionLocal()
|
||||
try:
|
||||
refresh_latest_candles(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime
|
||||
from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from .database import Base
|
||||
@@ -10,8 +10,10 @@ class User(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, index=True, nullable=False)
|
||||
password = Column(String, nullable=False)
|
||||
is_admin = Column(Boolean, default=False, nullable=False, server_default='0')
|
||||
|
||||
purchases = relationship("Purchase", back_populates="owner", cascade="all, delete")
|
||||
sells = relationship("Sell", back_populates="owner", cascade="all, delete")
|
||||
|
||||
|
||||
class Purchase(Base):
|
||||
@@ -24,3 +26,26 @@ class Purchase(Base):
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
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):
|
||||
__tablename__ = "ohlc_candles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
date = Column(String, unique=True, index=True, nullable=False) # "YYYY-MM-DD" UTC
|
||||
open = Column(Float, nullable=False)
|
||||
high = Column(Float, nullable=False)
|
||||
low = Column(Float, nullable=False)
|
||||
close = Column(Float, nullable=False)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
|
||||
from ..database import get_db
|
||||
from .. import models
|
||||
from ..auth import hash_password
|
||||
from ..dependencies import get_current_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
is_admin: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
@router.get("/users", response_model=List[UserOut])
|
||||
def list_users(db: Session = Depends(get_db), _: models.User = Depends(get_current_admin)):
|
||||
return db.query(models.User).all()
|
||||
|
||||
|
||||
@router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(user_in: UserCreate, db: Session = Depends(get_db), _: models.User = Depends(get_current_admin)):
|
||||
if db.query(models.User).filter(models.User.username == user_in.username).first():
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
user = models.User(
|
||||
username=user_in.username,
|
||||
password=hash_password(user_in.password),
|
||||
is_admin=user_in.is_admin,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_user(user_id: int, db: Session = Depends(get_db), current_admin: models.User = Depends(get_current_admin)):
|
||||
if user_id == current_admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete your own account")
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
@@ -0,0 +1,79 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..database import get_db
|
||||
from .. import models
|
||||
from ..dependencies import get_current_user
|
||||
from ..services.candles import refresh_latest_candles
|
||||
from ..services.btc import get_btc_price_eur
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/candles")
|
||||
def get_candles(
|
||||
days: str = Query(default="365"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
# Refresh candles on every request (no-op if DB is already current)
|
||||
refresh_latest_candles(db)
|
||||
|
||||
query = db.query(models.OHLCCandle).order_by(models.OHLCCandle.date.asc())
|
||||
|
||||
if days != "all":
|
||||
try:
|
||||
limit = int(days)
|
||||
except ValueError:
|
||||
limit = 365
|
||||
# Fetch the most recent `limit` rows
|
||||
total = query.count()
|
||||
if total > limit:
|
||||
query = query.offset(total - limit)
|
||||
|
||||
candles_db = query.all()
|
||||
candles = [
|
||||
{
|
||||
"date": c.date,
|
||||
"open": round(c.open, 2),
|
||||
"high": round(c.high, 2),
|
||||
"low": round(c.low, 2),
|
||||
"close": round(c.close, 2),
|
||||
}
|
||||
for c in candles_db
|
||||
]
|
||||
|
||||
# Patch today's candle with the live price so it tracks intraday movement
|
||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
live_price, _ = get_btc_price_eur()
|
||||
if live_price and candles:
|
||||
last = candles[-1]
|
||||
if last["date"] == today:
|
||||
last["close"] = round(live_price, 2)
|
||||
last["high"] = round(max(last["high"], live_price), 2)
|
||||
last["low"] = round(min(last["low"], live_price), 2)
|
||||
elif last["date"] < today:
|
||||
# No candle for today yet — create a synthetic one from live price
|
||||
prev_close = last["close"]
|
||||
candles.append({
|
||||
"date": today,
|
||||
"open": prev_close,
|
||||
"high": round(max(prev_close, live_price), 2),
|
||||
"low": round(min(prev_close, live_price), 2),
|
||||
"close": round(live_price, 2),
|
||||
})
|
||||
|
||||
purchases_db = db.query(models.Purchase).filter(
|
||||
models.Purchase.user_id == current_user.id
|
||||
).all()
|
||||
purchases = [
|
||||
{
|
||||
"date": p.created_at.strftime("%Y-%m-%d"),
|
||||
"amount_eur": round(p.amount_eur, 2),
|
||||
"price_eur": round(p.price_eur, 2),
|
||||
}
|
||||
for p in purchases_db
|
||||
]
|
||||
|
||||
return {"candles": candles, "purchases": purchases}
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from ..database import get_db
|
||||
@@ -12,13 +12,14 @@ router = APIRouter()
|
||||
|
||||
|
||||
class PurchaseCreate(BaseModel):
|
||||
amount_eur: float
|
||||
price_eur: float
|
||||
amount_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):
|
||||
amount_eur: float
|
||||
price_eur: float
|
||||
amount_eur: float = Field(gt=0, le=10_000_000)
|
||||
price_eur: float = Field(gt=0, le=10_000_000)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -54,6 +55,7 @@ def add_purchase(
|
||||
purchase = models.Purchase(
|
||||
amount_eur=purchase_in.amount_eur,
|
||||
price_eur=purchase_in.price_eur,
|
||||
created_at=purchase_in.created_at or datetime.utcnow(),
|
||||
user_id=current_user.id,
|
||||
)
|
||||
db.add(purchase)
|
||||
|
||||
@@ -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,19 +15,27 @@ def get_stats(
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
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_btc = 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()
|
||||
portfolio_value = total_btc * current_price
|
||||
profit_loss = portfolio_value - total_invested
|
||||
total_btc_bought = sum(p.amount_eur / p.price_eur for p in purchases) if purchases else 0.0
|
||||
|
||||
total_btc_sold = sum(s.btc_amount for s in sells)
|
||||
proceeds_eur = sum(s.btc_amount * s.price_eur for s in sells)
|
||||
|
||||
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 {
|
||||
"total_invested": round(total_invested, 2),
|
||||
"total_btc": round(total_btc, 8),
|
||||
"total_invested": round(net_invested, 2),
|
||||
"total_btc": round(net_btc, 8),
|
||||
"average_price": round(average_price, 2),
|
||||
"current_price": round(current_price, 2),
|
||||
"price_is_cached": price_is_cached,
|
||||
"portfolio_value": round(portfolio_value, 2),
|
||||
"profit_loss": round(profit_loss, 2),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..database import get_db
|
||||
from .. import models
|
||||
@@ -10,6 +10,11 @@ router = APIRouter()
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=50)
|
||||
password: str = Field(min_length=8)
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
@@ -17,6 +22,7 @@ class UserCreate(BaseModel):
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
is_admin: bool
|
||||
|
||||
|
||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||
@@ -24,9 +30,11 @@ def register(user_in: UserCreate, db: Session = Depends(get_db)):
|
||||
existing = db.query(models.User).filter(models.User.username == user_in.username).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
no_users_yet = db.query(models.User).first() is None
|
||||
user = models.User(
|
||||
username=user_in.username,
|
||||
password=hash_password(user_in.password),
|
||||
is_admin=no_users_yet,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
@@ -34,9 +42,9 @@ def register(user_in: UserCreate, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(user_in: UserCreate, db: Session = Depends(get_db)):
|
||||
def login(user_in: UserLogin, db: Session = Depends(get_db)):
|
||||
user = db.query(models.User).filter(models.User.username == user_in.username).first()
|
||||
if not user or not verify_password(user_in.password, user.password):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
token = create_access_token({"sub": user.username})
|
||||
return {"access_token": token, "token_type": "bearer"}
|
||||
return {"access_token": token, "token_type": "bearer", "is_admin": user.is_admin}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import logging
|
||||
import requests
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_btc_history_eur() -> list:
|
||||
@@ -10,11 +14,52 @@ def get_btc_history_eur() -> list:
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("prices", []) # [[timestamp_ms, price], ...]
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch BTC history: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_btc_price_eur() -> float:
|
||||
def get_btc_ohlc_eur(days: int) -> list:
|
||||
"""Fetch OHLC candles from CoinGecko. Returns [[ts_ms, open, high, low, close], ...]."""
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://api.coingecko.com/api/v3/coins/bitcoin/ohlc",
|
||||
params={"vs_currency": "eur", "days": str(days)},
|
||||
timeout=20,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json() # [[timestamp_ms, open, high, low, close], ...]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch BTC OHLC: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def aggregate_to_daily(raw: list) -> dict:
|
||||
"""Collapse intraday OHLC rows into one candle per UTC calendar date.
|
||||
|
||||
Returns {date_str: {open, high, low, close}} using first open, max high,
|
||||
min low, last close per day.
|
||||
"""
|
||||
by_date: dict = {}
|
||||
for row in raw:
|
||||
ts_ms, o, h, l, c = row
|
||||
date = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
if date not in by_date:
|
||||
by_date[date] = {"open": o, "high": h, "low": l, "close": c}
|
||||
else:
|
||||
existing = by_date[date]
|
||||
existing["high"] = max(existing["high"], h)
|
||||
existing["low"] = min(existing["low"], l)
|
||||
existing["close"] = c # last close wins
|
||||
return by_date
|
||||
|
||||
|
||||
_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:
|
||||
resp = requests.get(
|
||||
"https://api.coingecko.com/api/v3/simple/price",
|
||||
@@ -22,6 +67,9 @@ def get_btc_price_eur() -> float:
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return float(resp.json()["bitcoin"]["eur"])
|
||||
except Exception:
|
||||
return 0.0
|
||||
price = float(resp.json()["bitcoin"]["eur"])
|
||||
_last_known_price = price
|
||||
return price, False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch BTC price: {e}")
|
||||
return _last_known_price, True
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta, date as dt_date
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import OHLCCandle
|
||||
from .btc import get_btc_ohlc_eur, aggregate_to_daily, get_btc_history_eur
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def seed_candles(db: Session) -> None:
|
||||
"""Fetch 30 days of daily OHLC candles from CoinGecko and store them.
|
||||
Free tier gives 4-hour bars for days<=30, which aggregate cleanly to daily candles.
|
||||
days>30 drops to 4-day granularity (unusable for a daily chart).
|
||||
"""
|
||||
raw = get_btc_ohlc_eur(days=30)
|
||||
if not raw:
|
||||
logger.warning("Candle seed: CoinGecko returned no data — will retry on next startup.")
|
||||
return
|
||||
|
||||
daily = aggregate_to_daily(raw)
|
||||
rows = [
|
||||
OHLCCandle(date=date, open=v["open"], high=v["high"], low=v["low"], close=v["close"])
|
||||
for date, v in sorted(daily.items())
|
||||
]
|
||||
for row in rows:
|
||||
db.merge(row)
|
||||
db.commit()
|
||||
logger.info("Candle seed: stored %d daily candles (%s → %s).", len(rows), min(daily.keys()), max(daily.keys()))
|
||||
|
||||
|
||||
def seed_historical_prices(db: Session) -> None:
|
||||
"""Backfill up to 365 days of daily close prices from CoinGecko market_chart.
|
||||
Uses previous day's close as each day's open to produce red/green candles.
|
||||
Clears entries older than 31 days on each run so the data stays fresh.
|
||||
Real OHLC entries (last 30 days) are never touched.
|
||||
"""
|
||||
raw = get_btc_history_eur()
|
||||
if not raw:
|
||||
logger.warning("Historical price seed: CoinGecko returned no data.")
|
||||
return
|
||||
|
||||
prices = {}
|
||||
for ts_ms, price in raw:
|
||||
date = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
prices[date] = price
|
||||
|
||||
# Remove stale historical entries (older than 31 days) so they get re-seeded with current data
|
||||
cutoff = (datetime.now(tz=timezone.utc) - timedelta(days=31)).strftime("%Y-%m-%d")
|
||||
db.query(OHLCCandle).filter(OHLCCandle.date < cutoff).delete()
|
||||
db.commit()
|
||||
|
||||
existing = {c.date for c in db.query(OHLCCandle).all()}
|
||||
|
||||
new_rows = []
|
||||
prev_close = None
|
||||
for date, close in sorted(prices.items()):
|
||||
if date in existing:
|
||||
prev_close = close
|
||||
continue
|
||||
open_ = prev_close if prev_close is not None else close
|
||||
high = max(open_, close)
|
||||
low = min(open_, close)
|
||||
new_rows.append(OHLCCandle(date=date, open=open_, high=high, low=low, close=close))
|
||||
prev_close = close
|
||||
|
||||
if new_rows:
|
||||
db.add_all(new_rows)
|
||||
db.commit()
|
||||
logger.info("Historical price seed: stored %d daily entries (%s → %s).", len(new_rows), new_rows[0].date, new_rows[-1].date)
|
||||
|
||||
|
||||
def refresh_latest_candles(db: Session) -> None:
|
||||
"""Add any missing candles up to today. Seeds the DB if empty.
|
||||
Also detects and replaces coarse (>2-day gap) legacy data from a previous days=365 seed.
|
||||
"""
|
||||
# Always backfill historical prices for dates not yet in DB (no-op once populated)
|
||||
seed_historical_prices(db)
|
||||
|
||||
# Sparse-data detection: if existing candles have >2-day gaps, wipe and re-seed
|
||||
first_two = db.query(OHLCCandle).order_by(OHLCCandle.date.asc()).limit(2).all()
|
||||
if len(first_two) == 2:
|
||||
d1 = dt_date.fromisoformat(first_two[0].date)
|
||||
d2 = dt_date.fromisoformat(first_two[1].date)
|
||||
if (d2 - d1).days > 2:
|
||||
logger.warning("Candle refresh: detected coarse candle data (gap=%d days). Wiping and re-seeding with daily candles.", (d2 - d1).days)
|
||||
db.query(OHLCCandle).delete()
|
||||
db.commit()
|
||||
seed_candles(db)
|
||||
return
|
||||
|
||||
latest = db.query(OHLCCandle).order_by(OHLCCandle.date.desc()).first()
|
||||
|
||||
if latest is None:
|
||||
seed_candles(db)
|
||||
return
|
||||
|
||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
if latest.date >= today:
|
||||
return # Already up to date
|
||||
|
||||
raw = get_btc_ohlc_eur(days=7)
|
||||
if not raw:
|
||||
logger.warning("Candle refresh: CoinGecko returned no data.")
|
||||
return
|
||||
|
||||
daily = aggregate_to_daily(raw)
|
||||
new_dates = [d for d in daily if d > latest.date]
|
||||
if not new_dates:
|
||||
return
|
||||
|
||||
for date in new_dates:
|
||||
v = daily[date]
|
||||
db.merge(OHLCCandle(date=date, open=v["open"], high=v["high"], low=v["low"], close=v["close"]))
|
||||
db.commit()
|
||||
logger.info("Candle refresh: upserted %d candle(s) up to %s.", len(new_dates), max(new_dates))
|
||||
@@ -7,15 +7,29 @@ services:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:////app/data/btc_portfolio.db
|
||||
- SECRET_KEY=${SECRET_KEY:-dev-insecure-key-change-me}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:3001}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
- REACT_APP_API_URL=http://localhost:8000
|
||||
- REACT_APP_API_URL=/api
|
||||
ports:
|
||||
- "3001:3000"
|
||||
- "3001:80"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:80/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
node_modules
|
||||
build
|
||||
.env
|
||||
.env.local
|
||||
npm-debug.log
|
||||
@@ -4,13 +4,12 @@ WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
ARG REACT_APP_API_URL=http://localhost:8000
|
||||
ARG REACT_APP_API_URL=/api
|
||||
ENV REACT_APP_API_URL=$REACT_APP_API_URL
|
||||
RUN npm run build
|
||||
|
||||
FROM node:18-alpine
|
||||
RUN npm install -g serve
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/build ./build
|
||||
EXPOSE 3000
|
||||
CMD ["serve", "-s", "build", "-l", "3000"]
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@
|
||||
"react-router-dom": "^6.22.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"chart.js": "^4.4.0",
|
||||
"react-chartjs-2": "^5.2.0"
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"lightweight-charts": "^4.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
|
||||
@@ -3,11 +3,18 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import AdminPage from './pages/AdminPage';
|
||||
|
||||
function PrivateRoute({ children }) {
|
||||
return localStorage.getItem('token') ? children : <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
function AdminRoute({ children }) {
|
||||
if (!localStorage.getItem('token')) return <Navigate to="/login" />;
|
||||
if (localStorage.getItem('is_admin') !== 'true') return <Navigate to="/" />;
|
||||
return children;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
@@ -15,6 +22,7 @@ export default function App() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/" element={<PrivateRoute><Dashboard /></PrivateRoute>} />
|
||||
<Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -12,6 +12,7 @@ const styles = {
|
||||
};
|
||||
|
||||
export default function AddPurchase({ onAdded }) {
|
||||
const [purchaseDate, setPurchaseDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [amountEur, setAmountEur] = useState('');
|
||||
const [priceEur, setPriceEur] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
@@ -30,16 +31,19 @@ export default function AddPurchase({ onAdded }) {
|
||||
body: JSON.stringify({
|
||||
amount_eur: parseFloat(amountEur),
|
||||
price_eur: parseFloat(priceEur),
|
||||
created_at: new Date(purchaseDate + 'T12:00:00').toISOString(),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError('Failed to add purchase');
|
||||
return;
|
||||
}
|
||||
setPurchaseDate(new Date().toISOString().split('T')[0]);
|
||||
setAmountEur('');
|
||||
setPriceEur('');
|
||||
onAdded();
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('AddPurchase network error:', err);
|
||||
setError('Network error');
|
||||
}
|
||||
};
|
||||
@@ -49,6 +53,13 @@ export default function AddPurchase({ onAdded }) {
|
||||
<div style={styles.title}>Add Purchase</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={styles.row}>
|
||||
<input
|
||||
style={styles.input}
|
||||
type="date"
|
||||
value={purchaseDate}
|
||||
onChange={e => setPurchaseDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
style={styles.input}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { createChart, LineStyle } from 'lightweight-charts';
|
||||
|
||||
const cardStyle = {
|
||||
background: '#1a1a1a',
|
||||
padding: '1.5rem',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #333',
|
||||
marginBottom: '1.5rem',
|
||||
};
|
||||
|
||||
const fullscreenStyle = {
|
||||
position: 'fixed',
|
||||
top: 0, left: 0, right: 0, bottom: 0,
|
||||
zIndex: 9999,
|
||||
background: '#0d0d0d',
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
};
|
||||
|
||||
const headerStyle = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '1rem',
|
||||
};
|
||||
|
||||
const titleStyle = { fontSize: '1.1rem', fontWeight: 700, color: '#f7931a' };
|
||||
|
||||
const btnStyle = {
|
||||
background: 'none',
|
||||
border: '1px solid #555',
|
||||
color: '#aaa',
|
||||
borderRadius: '6px',
|
||||
padding: '0.4rem 1rem',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.85rem',
|
||||
marginLeft: '0.5rem',
|
||||
};
|
||||
|
||||
export default function BTCCandlestickChart({ candles, purchases, stats, fullscreen, onToggleFullscreen, livePrice }) {
|
||||
const containerRef = useRef(null);
|
||||
const chartRef = useRef(null);
|
||||
const candleSeriesRef = useRef(null);
|
||||
|
||||
// Build/rebuild chart when candles data or fullscreen changes
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !candles || candles.length === 0) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const { width, height } = container.getBoundingClientRect();
|
||||
|
||||
const chart = createChart(container, {
|
||||
width: width || 800,
|
||||
height: fullscreen ? height - 20 : 350,
|
||||
layout: {
|
||||
background: { color: fullscreen ? '#0d0d0d' : '#1a1a1a' },
|
||||
textColor: '#ccc',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#2a2a2a' },
|
||||
horzLines: { color: '#2a2a2a' },
|
||||
},
|
||||
crosshair: { mode: 1 },
|
||||
rightPriceScale: { borderColor: '#333' },
|
||||
timeScale: { borderColor: '#333', timeVisible: false },
|
||||
});
|
||||
|
||||
chartRef.current = chart;
|
||||
|
||||
// Candlestick series
|
||||
const candleSeries = chart.addCandlestickSeries({
|
||||
upColor: '#6bff8e',
|
||||
downColor: '#ff6b6b',
|
||||
borderUpColor: '#6bff8e',
|
||||
borderDownColor: '#ff6b6b',
|
||||
wickUpColor: '#6bff8e',
|
||||
wickDownColor: '#ff6b6b',
|
||||
});
|
||||
candleSeriesRef.current = candleSeries;
|
||||
|
||||
const candleData = candles.map(c => ({
|
||||
time: c.date,
|
||||
open: c.open,
|
||||
high: c.high,
|
||||
low: c.low,
|
||||
close: c.close,
|
||||
}));
|
||||
candleSeries.setData(candleData);
|
||||
|
||||
// Average buy price line
|
||||
const avgPrice = stats?.average_price ?? 0;
|
||||
if (avgPrice > 0) {
|
||||
const avgSeries = chart.addLineSeries({
|
||||
color: '#4fc3f7',
|
||||
lineWidth: 1,
|
||||
lineStyle: LineStyle.Dashed,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
title: `Avg Buy €${avgPrice.toLocaleString()}`,
|
||||
});
|
||||
avgSeries.setData(candles.map(c => ({ time: c.date, value: avgPrice })));
|
||||
}
|
||||
|
||||
// Purchase markers — use nearest-candle lookup so purchases always show
|
||||
if (purchases && purchases.length > 0) {
|
||||
const sortedDates = candles.map(c => c.date).sort();
|
||||
|
||||
const nearestDate = (target) => {
|
||||
let closest = sortedDates[0];
|
||||
for (const d of sortedDates) {
|
||||
if (d <= target) closest = d;
|
||||
else break;
|
||||
}
|
||||
return closest;
|
||||
};
|
||||
|
||||
// Merge multiple purchases on the same candle into one marker
|
||||
const markerMap = {};
|
||||
for (const p of purchases) {
|
||||
const d = nearestDate(p.date);
|
||||
if (!markerMap[d]) {
|
||||
markerMap[d] = { time: d, position: 'belowBar', color: '#f7931a', shape: 'arrowUp', text: `€${p.amount_eur.toLocaleString()}` };
|
||||
} else {
|
||||
markerMap[d].text += ` +€${p.amount_eur.toLocaleString()}`;
|
||||
}
|
||||
}
|
||||
const markers = Object.values(markerMap).sort((a, b) => a.time.localeCompare(b.time));
|
||||
if (markers.length > 0) candleSeries.setMarkers(markers);
|
||||
}
|
||||
|
||||
chart.timeScale().fitContent();
|
||||
|
||||
// Responsive resize
|
||||
const observer = new ResizeObserver(entries => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
chart.applyOptions({
|
||||
width: entry.contentRect.width,
|
||||
height: fullscreen ? entry.contentRect.height - 20 : 350,
|
||||
});
|
||||
});
|
||||
observer.observe(container);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
chart.remove();
|
||||
chartRef.current = null;
|
||||
candleSeriesRef.current = null;
|
||||
};
|
||||
}, [candles, purchases, stats, fullscreen]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!chartRef.current) return;
|
||||
const canvas = chartRef.current.takeScreenshot();
|
||||
const a = document.createElement('a');
|
||||
a.href = canvas.toDataURL('image/png');
|
||||
a.download = 'btc-candles.png';
|
||||
a.click();
|
||||
};
|
||||
|
||||
const containerWrapStyle = fullscreen
|
||||
? { flex: 1, minHeight: 0 }
|
||||
: { width: '100%' };
|
||||
|
||||
return (
|
||||
<div style={fullscreen ? fullscreenStyle : cardStyle}>
|
||||
<div style={headerStyle}>
|
||||
<div style={titleStyle}>
|
||||
BTC Candles (EUR)
|
||||
{livePrice != null && (
|
||||
<span style={{ fontSize: '0.9rem', fontWeight: 400, color: '#ccc', marginLeft: '0.75rem' }}>
|
||||
€{livePrice.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<button style={btnStyle} onClick={handleSave}>Save as PNG</button>
|
||||
<button style={btnStyle} onClick={onToggleFullscreen}>
|
||||
{fullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(!candles || candles.length === 0) ? (
|
||||
<div style={{ color: '#666', padding: '1rem 0' }}>Loading price data…</div>
|
||||
) : (
|
||||
<div ref={containerRef} style={containerWrapStyle} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,32 +19,93 @@ const styles = {
|
||||
saveBtn: { marginTop: '0.75rem', background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '6px', padding: '0.4rem 1rem', cursor: 'pointer', fontSize: '0.85rem' },
|
||||
};
|
||||
|
||||
export default function PortfolioChart({ purchases, stats }) {
|
||||
function toDateKey(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function priceOn(date, priceMap, currentPrice, isToday, sortedPurchases) {
|
||||
if (isToday) return currentPrice || 0;
|
||||
// Try candle history (walk back up to 7 days)
|
||||
for (let i = 0; i <= 7; i++) {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() - i);
|
||||
const p = priceMap[toDateKey(d)];
|
||||
if (p) return p;
|
||||
}
|
||||
// Fall back to most recent purchase price up to this date
|
||||
let fallback = null;
|
||||
for (const p of sortedPurchases) {
|
||||
const pd = new Date(p.created_at);
|
||||
pd.setHours(0, 0, 0, 0);
|
||||
if (pd <= date) fallback = p.price_eur;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export default function PortfolioChart({ purchases, sells, stats, btcHistory }) {
|
||||
const chartRef = useRef(null);
|
||||
|
||||
if (!purchases || purchases.length === 0) return null;
|
||||
|
||||
// Build cumulative data points sorted by date
|
||||
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();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// Build price lookup from candle history
|
||||
const priceMap = {};
|
||||
(btcHistory || []).forEach(({ date, close }) => { priceMap[date] = close; });
|
||||
|
||||
const firstDate = new Date(sorted[0].created_at);
|
||||
firstDate.setHours(0, 0, 0, 0);
|
||||
|
||||
// Generate biweekly dates from first purchase to today
|
||||
const dates = [];
|
||||
const cursor = new Date(firstDate);
|
||||
while (cursor <= today) {
|
||||
dates.push(new Date(cursor));
|
||||
cursor.setDate(cursor.getDate() + 7);
|
||||
}
|
||||
if (toDateKey(dates[dates.length - 1]) !== toDateKey(today)) {
|
||||
dates.push(new Date(today));
|
||||
}
|
||||
|
||||
let cumInvested = 0;
|
||||
let cumBtc = 0;
|
||||
const labels = [];
|
||||
const portfolioValues = [];
|
||||
const investedValues = [];
|
||||
|
||||
sorted.forEach((p, i) => {
|
||||
cumInvested += p.amount_eur;
|
||||
cumBtc += p.amount_eur / p.price_eur;
|
||||
const currentVal = cumBtc * (stats?.current_price || p.price_eur);
|
||||
labels.push(new Date(p.created_at).toLocaleDateString());
|
||||
portfolioValues.push(parseFloat(currentVal.toFixed(2)));
|
||||
dates.forEach(date => {
|
||||
const isToday = toDateKey(date) === toDateKey(today);
|
||||
const price = priceOn(date, priceMap, stats?.current_price, isToday, sorted);
|
||||
if (price === null) return; // no price data available, skip
|
||||
|
||||
// Cumulative BTC and invested up to this date
|
||||
let cumBtc = 0;
|
||||
let cumInvested = 0;
|
||||
sorted.forEach(p => {
|
||||
const pDate = new Date(p.created_at);
|
||||
pDate.setHours(0, 0, 0, 0);
|
||||
if (pDate <= date) {
|
||||
cumBtc += p.amount_eur / p.price_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
|
||||
|
||||
labels.push(date.toLocaleDateString('en-GB'));
|
||||
portfolioValues.push(parseFloat((cumBtc * price).toFixed(2)));
|
||||
investedValues.push(parseFloat(cumInvested.toFixed(2)));
|
||||
});
|
||||
|
||||
const currentPrice = stats?.current_price || 0;
|
||||
const breakEvenLine = labels.map(() => stats?.average_price || 0);
|
||||
|
||||
const data = {
|
||||
labels,
|
||||
datasets: [
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function PurchaseList({ purchases, onChanged }) {
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={p.id}>
|
||||
<td style={styles.td}>{new Date(p.created_at).toLocaleDateString()}</td>
|
||||
<td style={styles.td}>{new Date(p.created_at).toLocaleDateString('en-GB')}</td>
|
||||
<td style={styles.td}>€{p.amount_eur.toLocaleString()}</td>
|
||||
<td style={styles.td}>€{p.price_eur.toLocaleString()}</td>
|
||||
<td style={styles.td}>{(p.amount_eur / p.price_eur).toFixed(6)}</td>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
const styles = {
|
||||
app: { maxWidth: '900px', margin: '0 auto', padding: '1.5rem' },
|
||||
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' },
|
||||
title: { fontSize: '1.4rem', fontWeight: 700, color: '#f7931a' },
|
||||
backBtn: { background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer' },
|
||||
card: { background: '#1a1a1a', padding: '1.5rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
|
||||
sectionTitle: { fontSize: '1rem', fontWeight: 700, color: '#f7931a', marginBottom: '1rem' },
|
||||
table: { width: '100%', borderCollapse: 'collapse' },
|
||||
th: { textAlign: 'left', color: '#888', fontSize: '0.8rem', paddingBottom: '0.5rem', borderBottom: '1px solid #333' },
|
||||
td: { padding: '0.6rem 0', borderBottom: '1px solid #222', color: '#e0e0e0', fontSize: '0.95rem' },
|
||||
adminBadge: { background: 'rgba(247,147,26,0.15)', color: '#f7931a', borderRadius: '4px', padding: '0.1rem 0.5rem', fontSize: '0.75rem', marginLeft: '0.5rem' },
|
||||
deleteBtn: { background: 'none', border: '1px solid #555', color: '#ff6b6b', borderRadius: '6px', padding: '0.25rem 0.75rem', cursor: 'pointer', fontSize: '0.85rem' },
|
||||
form: { display: 'flex', flexDirection: 'column', gap: '0.75rem' },
|
||||
row: { display: 'flex', gap: '0.75rem', flexWrap: 'wrap' },
|
||||
input: { flex: 1, minWidth: '140px', padding: '0.6rem 0.75rem', background: '#2a2a2a', border: '1px solid #444', borderRadius: '8px', color: '#e0e0e0', fontSize: '0.95rem' },
|
||||
checkLabel: { display: 'flex', alignItems: 'center', gap: '0.4rem', color: '#aaa', fontSize: '0.9rem', cursor: 'pointer' },
|
||||
submitBtn: { alignSelf: 'flex-start', padding: '0.6rem 1.5rem', background: '#f7931a', color: '#000', border: 'none', borderRadius: '8px', fontWeight: 700, cursor: 'pointer' },
|
||||
error: { color: '#ff6b6b', fontSize: '0.9rem' },
|
||||
success: { color: '#6bff8e', fontSize: '0.9rem' },
|
||||
};
|
||||
|
||||
export default function AdminPage() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const authHeaders = () => ({
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
const res = await fetch(`${API}/admin/users`, { headers: authHeaders() });
|
||||
if (res.status === 401 || res.status === 403) { navigate('/'); return; }
|
||||
setUsers(await res.json());
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => { fetchUsers(); }, [fetchUsers]);
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(''); setSuccess('');
|
||||
const res = await fetch(`${API}/admin/users`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ username, password, is_admin: isAdmin }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.detail || 'Failed to create user');
|
||||
return;
|
||||
}
|
||||
setSuccess(`User "${username}" created.`);
|
||||
setUsername(''); setPassword(''); setIsAdmin(false);
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
const handleDelete = async (id, name) => {
|
||||
if (!window.confirm(`Delete user "${name}"? This also deletes all their purchases.`)) return;
|
||||
const res = await fetch(`${API}/admin/users/${id}`, { method: 'DELETE', headers: authHeaders() });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.detail || 'Failed to delete user');
|
||||
return;
|
||||
}
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
const currentUsername = (() => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
return JSON.parse(atob(token.split('.')[1])).sub;
|
||||
} catch { return null; }
|
||||
})();
|
||||
|
||||
return (
|
||||
<div style={styles.app}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.title}>User Management</div>
|
||||
<button style={styles.backBtn} onClick={() => navigate('/')}>Back to Dashboard</button>
|
||||
</div>
|
||||
|
||||
<div style={styles.card}>
|
||||
<div style={styles.sectionTitle}>All Users</div>
|
||||
<table style={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={styles.th}>Username</th>
|
||||
<th style={styles.th}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td style={styles.td}>
|
||||
{u.username}
|
||||
{u.is_admin && <span style={styles.adminBadge}>admin</span>}
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
{u.username !== currentUsername && (
|
||||
<button style={styles.deleteBtn} onClick={() => handleDelete(u.id, u.username)}>Delete</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={styles.card}>
|
||||
<div style={styles.sectionTitle}>Create User</div>
|
||||
<form style={styles.form} onSubmit={handleCreate}>
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
{success && <div style={styles.success}>{success}</div>}
|
||||
<div style={styles.row}>
|
||||
<input style={styles.input} placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} required />
|
||||
<input style={styles.input} type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||
</div>
|
||||
<label style={styles.checkLabel}>
|
||||
<input type="checkbox" checked={isAdmin} onChange={e => setIsAdmin(e.target.checked)} />
|
||||
Admin
|
||||
</label>
|
||||
<button style={styles.submitBtn} type="submit">Create User</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import AddPurchase from '../components/AddPurchase';
|
||||
import PurchaseList from '../components/PurchaseList';
|
||||
import AddSell from '../components/AddSell';
|
||||
import SellList from '../components/SellList';
|
||||
import PortfolioChart from '../components/PortfolioChart';
|
||||
import BTCHistoryChart from '../components/BTCHistoryChart';
|
||||
import BTCCandlestickChart from '../components/BTCCandlestickChart';
|
||||
|
||||
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
@@ -12,7 +14,16 @@ const styles = {
|
||||
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' },
|
||||
logo: { fontSize: '1.4rem', fontWeight: 700, color: '#f7931a' },
|
||||
logoutBtn: { background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer' },
|
||||
adminBtn: { background: 'none', border: '1px solid #f7931a', color: '#f7931a', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer', textDecoration: 'none', fontSize: '1rem' },
|
||||
headerBtns: { display: 'flex', gap: '0.5rem', alignItems: 'center' },
|
||||
statsGrid: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', marginBottom: '1.5rem' },
|
||||
predictionSection: { background: '#1a1a1a', border: '1px solid #333', borderRadius: '12px', padding: '1rem', marginBottom: '1.5rem' },
|
||||
predictionHeader: { color: '#888', fontSize: '0.8rem', marginBottom: '0.75rem' },
|
||||
predictionRow: { display: 'flex', gap: '1rem', alignItems: 'flex-start', flexWrap: 'wrap' },
|
||||
predictionInputWrap: { display: 'flex', flexDirection: 'column', gap: '0.3rem' },
|
||||
predictionLabel: { color: '#888', fontSize: '0.8rem' },
|
||||
predictionInput: { background: '#111', border: '1px solid #444', borderRadius: '8px', color: '#fff', padding: '0.55rem 0.75rem', fontSize: '1rem', width: '160px', outline: 'none' },
|
||||
predictionCards: { display: 'flex', gap: '1rem', flex: 1, flexWrap: 'wrap' },
|
||||
statCard: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333' },
|
||||
statLabel: { color: '#888', fontSize: '0.8rem', marginBottom: '0.3rem' },
|
||||
statValue: { fontSize: '1.2rem', fontWeight: 700 },
|
||||
@@ -24,11 +35,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 },
|
||||
};
|
||||
|
||||
function StatCard({ label, value, highlight }) {
|
||||
function StatCard({ label, value, highlight, warning }) {
|
||||
return (
|
||||
<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>
|
||||
{warning && (
|
||||
<div style={{ color: '#888', fontSize: '0.7rem', marginTop: '0.25rem' }}>{warning}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,8 +55,12 @@ function StatCard({ label, value, highlight }) {
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [purchases, setPurchases] = useState([]);
|
||||
const [history, setHistory] = useState(null);
|
||||
const [sells, setSells] = useState([]);
|
||||
const [candles, setCandles] = useState(null);
|
||||
const [candlesAll, setCandlesAll] = useState(null);
|
||||
const [fullscreenChart, setFullscreenChart] = useState(false);
|
||||
const [chartView, setChartView] = useState('both'); // 'both' | 'portfolio' | 'history'
|
||||
const [predictionPrice, setPredictionPrice] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const authHeaders = () => ({
|
||||
@@ -46,10 +69,11 @@ export default function Dashboard() {
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [statsRes, purchasesRes, historyRes] = await Promise.all([
|
||||
fetch(`${API}/stats`, { headers: authHeaders() }),
|
||||
fetch(`${API}/purchases`, { headers: authHeaders() }),
|
||||
fetch(`${API}/history`, { headers: authHeaders() }),
|
||||
const [statsRes, purchasesRes, sellsRes, candlesRes] = await Promise.all([
|
||||
fetch(`${API}/stats`, { headers: authHeaders() }),
|
||||
fetch(`${API}/purchases`, { headers: authHeaders() }),
|
||||
fetch(`${API}/sells`, { headers: authHeaders() }),
|
||||
fetch(`${API}/candles?days=365`, { headers: authHeaders() }),
|
||||
]);
|
||||
if (statsRes.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
@@ -58,30 +82,60 @@ export default function Dashboard() {
|
||||
}
|
||||
setStats(await statsRes.json());
|
||||
setPurchases(await purchasesRes.json());
|
||||
setHistory(await historyRes.json());
|
||||
setSells(await sellsRes.json());
|
||||
setCandles(await candlesRes.json());
|
||||
} catch {
|
||||
// silently fail — network may be unavailable
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
const fetchAllCandles = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API}/candles?days=all`, { headers: authHeaders() });
|
||||
if (res.ok) setCandlesAll(await res.json());
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const isAdmin = localStorage.getItem('is_admin') === 'true';
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('is_admin');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleToggleFullscreen = useCallback(() => {
|
||||
if (!fullscreenChart && !candlesAll) fetchAllCandles();
|
||||
setFullscreenChart(f => !f);
|
||||
}, [fullscreenChart, candlesAll, fetchAllCandles]);
|
||||
|
||||
const predPrice = parseFloat(predictionPrice);
|
||||
const predValid = stats && predictionPrice !== '' && predPrice > 0;
|
||||
const predValue = predValid ? +(stats.total_btc * predPrice).toFixed(2) : null;
|
||||
const predPL = predValid ? +(predValue - stats.total_invested).toFixed(2) : null;
|
||||
const predVsCurrent = predValid ? +(predValue - stats.portfolio_value).toFixed(2) : null;
|
||||
|
||||
const plHighlight = stats
|
||||
? stats.profit_loss >= 0 ? 'positive' : 'negative'
|
||||
: 'neutral';
|
||||
|
||||
// Fullscreen uses all-candles data once loaded, otherwise falls back to 365-day set
|
||||
const activeCandles = (fullscreenChart && candlesAll) ? candlesAll : candles;
|
||||
|
||||
return (
|
||||
<div style={styles.app}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.logo}>₿ BTC Portfolio</div>
|
||||
<button style={styles.logoutBtn} onClick={handleLogout}>Logout</button>
|
||||
<div style={styles.headerBtns}>
|
||||
{isAdmin && <Link to="/admin" style={styles.adminBtn}>Manage Users</Link>}
|
||||
<button style={styles.logoutBtn} onClick={handleLogout}>Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
@@ -89,7 +143,11 @@ export default function Dashboard() {
|
||||
<StatCard label="Total Invested" value={`€${stats.total_invested.toLocaleString()}`} />
|
||||
<StatCard label="Avg Buy Price" value={`€${stats.average_price.toLocaleString()}`} />
|
||||
<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="Profit / Loss"
|
||||
@@ -99,8 +157,44 @@ export default function Dashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={styles.predictionSection}>
|
||||
<div style={styles.predictionHeader}>Price Prediction</div>
|
||||
<div style={styles.predictionRow}>
|
||||
<div style={styles.predictionInputWrap}>
|
||||
<label style={styles.predictionLabel}>BTC Price (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="e.g. 100000"
|
||||
value={predictionPrice}
|
||||
onChange={e => setPredictionPrice(e.target.value)}
|
||||
style={styles.predictionInput}
|
||||
/>
|
||||
</div>
|
||||
{predValid && (
|
||||
<div style={styles.predictionCards}>
|
||||
<StatCard
|
||||
label="Predicted Value"
|
||||
value={`€${predValue.toLocaleString()}`}
|
||||
highlight="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label="Predicted P&L"
|
||||
value={`${predPL >= 0 ? '+' : ''}€${predPL.toLocaleString()}`}
|
||||
highlight={predPL >= 0 ? 'positive' : 'negative'}
|
||||
/>
|
||||
<StatCard
|
||||
label="vs. Current"
|
||||
value={`${predVsCurrent >= 0 ? '+' : ''}€${predVsCurrent.toLocaleString()}`}
|
||||
highlight={predVsCurrent >= 0 ? 'positive' : 'negative'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.tabs}>
|
||||
{[['both', 'Both'], ['portfolio', 'Portfolio'], ['history', '1-Year BTC']].map(([key, label]) => (
|
||||
{[['both', 'Both'], ['portfolio', 'Portfolio'], ['history', 'BTC Candles']].map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
style={chartView === key ? styles.tabActive : styles.tab}
|
||||
@@ -108,10 +202,21 @@ export default function Dashboard() {
|
||||
>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
{(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} stats={stats} />}
|
||||
{(chartView === 'both' || chartView === 'history') && <BTCHistoryChart history={history} stats={stats} />}
|
||||
{(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} sells={sells} stats={stats} btcHistory={candles?.candles ?? []} />}
|
||||
{(chartView === 'both' || chartView === 'history') && (
|
||||
<BTCCandlestickChart
|
||||
candles={activeCandles?.candles ?? null}
|
||||
purchases={activeCandles?.purchases ?? purchases}
|
||||
stats={stats}
|
||||
livePrice={stats?.current_price ?? null}
|
||||
fullscreen={fullscreenChart}
|
||||
onToggleFullscreen={handleToggleFullscreen}
|
||||
/>
|
||||
)}
|
||||
<AddPurchase onAdded={fetchData} />
|
||||
<PurchaseList purchases={purchases} onChanged={fetchData} />
|
||||
<AddSell onAdded={fetchData} />
|
||||
<SellList sells={sells} onChanged={fetchData} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,8 +35,10 @@ export default function Login() {
|
||||
}
|
||||
const data = await res.json();
|
||||
localStorage.setItem('token', data.access_token);
|
||||
localStorage.setItem('is_admin', data.is_admin ? 'true' : 'false');
|
||||
navigate('/');
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('Login network error:', err);
|
||||
setError('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -38,7 +38,8 @@ export default function Register() {
|
||||
}
|
||||
setSuccess('Account created! Redirecting...');
|
||||
setTimeout(() => navigate('/login'), 1500);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('Register network error:', err);
|
||||
setError('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user