Compare commits
20 Commits
a0692501b3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 15358c05c3 | |||
| 59f833d7fd | |||
| d5197cde14 | |||
| 9c0db31580 | |||
| d90ac5365a | |||
| ce9547a623 | |||
| 672f5b74a4 | |||
| a2ca82062e | |||
| 5bb67d6663 | |||
| 33656c4512 | |||
| 5cf3726f59 | |||
| ce227e47d6 | |||
| 11b020907d | |||
| 4da23c9def | |||
| 85455f3271 | |||
| 4616accc63 | |||
| befbe12bcf | |||
| aedc6a8a17 | |||
| db9624822b | |||
| 470dd80ed8 |
@@ -0,0 +1,2 @@
|
||||
# Local wallet scripts with credentials — never commit
|
||||
btc_wallet.py
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
|
||||
from .database import engine, Base, SessionLocal
|
||||
from .routes import users, purchases, stats, history, admin, candles
|
||||
from .routes import users, purchases, stats, history, admin, candles, sells
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
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(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://localhost:3001"],
|
||||
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)
|
||||
@@ -23,6 +31,7 @@ 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")
|
||||
|
||||
@@ -13,6 +13,7 @@ class User(Base):
|
||||
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):
|
||||
@@ -27,6 +28,18 @@ class Purchase(Base):
|
||||
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"
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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()
|
||||
|
||||
@@ -14,6 +17,9 @@ def get_candles(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
# Refresh candles on every request (no-op if DB is already current)
|
||||
refresh_latest_candles(db)
|
||||
|
||||
query = db.query(models.OHLCCandle).order_by(models.OHLCCandle.date.asc())
|
||||
|
||||
if days != "all":
|
||||
@@ -38,6 +44,26 @@ def get_candles(
|
||||
for c in candles_db
|
||||
]
|
||||
|
||||
# Patch today's candle with the live price so it tracks intraday movement
|
||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
live_price, _ = get_btc_price_eur()
|
||||
if live_price and candles:
|
||||
last = candles[-1]
|
||||
if last["date"] == today:
|
||||
last["close"] = round(live_price, 2)
|
||||
last["high"] = round(max(last["high"], live_price), 2)
|
||||
last["low"] = round(min(last["low"], live_price), 2)
|
||||
elif last["date"] < today:
|
||||
# No candle for today yet — create a synthetic one from live price
|
||||
prev_close = last["close"]
|
||||
candles.append({
|
||||
"date": today,
|
||||
"open": prev_close,
|
||||
"high": round(max(prev_close, live_price), 2),
|
||||
"low": round(min(prev_close, live_price), 2),
|
||||
"close": round(live_price, 2),
|
||||
})
|
||||
|
||||
purchases_db = db.query(models.Purchase).filter(
|
||||
models.Purchase.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,7 +42,7 @@ 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")
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import logging
|
||||
import requests
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_btc_history_eur() -> list:
|
||||
try:
|
||||
@@ -11,7 +14,8 @@ 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 []
|
||||
|
||||
|
||||
@@ -25,7 +29,8 @@ def get_btc_ohlc_eur(days: int) -> list:
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json() # [[timestamp_ms, open, high, low, close], ...]
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch BTC OHLC: {e}")
|
||||
return []
|
||||
|
||||
|
||||
@@ -49,7 +54,12 @@ def aggregate_to_daily(raw: list) -> dict:
|
||||
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:
|
||||
resp = requests.get(
|
||||
"https://api.coingecko.com/api/v3/simple/price",
|
||||
@@ -57,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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone, date as dt_date
|
||||
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
|
||||
from .btc import get_btc_ohlc_eur, aggregate_to_daily, get_btc_history_eur
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,10 +30,54 @@ def seed_candles(db: Session) -> None:
|
||||
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:
|
||||
|
||||
@@ -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:3001"
|
||||
- "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 3001
|
||||
CMD ["serve", "-s", "build", "-l", "3001"]
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,7 @@ const btnStyle = {
|
||||
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 chartRef = useRef(null);
|
||||
const candleSeriesRef = useRef(null);
|
||||
@@ -167,7 +167,14 @@ export default function BTCCandlestickChart({ candles, purchases, stats, fullscr
|
||||
return (
|
||||
<div style={fullscreen ? fullscreenStyle : cardStyle}>
|
||||
<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>
|
||||
<button style={btnStyle} onClick={handleSave}>Save as PNG</button>
|
||||
<button style={btnStyle} onClick={onToggleFullscreen}>
|
||||
|
||||
@@ -19,40 +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 todayLabel = new Date().toLocaleDateString();
|
||||
if (labels.length === 0 || labels[labels.length - 1] !== todayLabel) {
|
||||
const currentVal = cumBtc * (stats?.current_price || 0);
|
||||
labels.push(todayLabel);
|
||||
portfolioValues.push(parseFloat(currentVal.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: [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,12 @@ export default function AdminPage() {
|
||||
|
||||
const handleDelete = async (id, name) => {
|
||||
if (!window.confirm(`Delete user "${name}"? This also deletes all their purchases.`)) return;
|
||||
await fetch(`${API}/admin/users/${id}`, { method: 'DELETE', headers: authHeaders() });
|
||||
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();
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
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 BTCCandlestickChart from '../components/BTCCandlestickChart';
|
||||
|
||||
@@ -15,6 +17,13 @@ const styles = {
|
||||
adminBtn: { background: 'none', border: '1px solid #f7931a', color: '#f7931a', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer', textDecoration: 'none', fontSize: '1rem' },
|
||||
headerBtns: { display: 'flex', gap: '0.5rem', alignItems: 'center' },
|
||||
statsGrid: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', marginBottom: '1.5rem' },
|
||||
predictionSection: { background: '#1a1a1a', border: '1px solid #333', borderRadius: '12px', padding: '1rem', marginBottom: '1.5rem' },
|
||||
predictionHeader: { color: '#888', fontSize: '0.8rem', marginBottom: '0.75rem' },
|
||||
predictionRow: { display: 'flex', gap: '1rem', alignItems: 'flex-start', flexWrap: 'wrap' },
|
||||
predictionInputWrap: { display: 'flex', flexDirection: 'column', gap: '0.3rem' },
|
||||
predictionLabel: { color: '#888', fontSize: '0.8rem' },
|
||||
predictionInput: { background: '#111', border: '1px solid #444', borderRadius: '8px', color: '#fff', padding: '0.55rem 0.75rem', fontSize: '1rem', width: '160px', outline: 'none' },
|
||||
predictionCards: { display: 'flex', gap: '1rem', flex: 1, flexWrap: 'wrap' },
|
||||
statCard: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333' },
|
||||
statLabel: { color: '#888', fontSize: '0.8rem', marginBottom: '0.3rem' },
|
||||
statValue: { fontSize: '1.2rem', fontWeight: 700 },
|
||||
@@ -26,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>
|
||||
);
|
||||
}
|
||||
@@ -38,10 +55,12 @@ function StatCard({ label, value, highlight }) {
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [purchases, setPurchases] = useState([]);
|
||||
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 = () => ({
|
||||
@@ -50,9 +69,10 @@ export default function Dashboard() {
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [statsRes, purchasesRes, candlesRes] = await Promise.all([
|
||||
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) {
|
||||
@@ -62,6 +82,7 @@ export default function Dashboard() {
|
||||
}
|
||||
setStats(await statsRes.json());
|
||||
setPurchases(await purchasesRes.json());
|
||||
setSells(await sellsRes.json());
|
||||
setCandles(await candlesRes.json());
|
||||
} catch {
|
||||
// silently fail — network may be unavailable
|
||||
@@ -94,6 +115,12 @@ export default function Dashboard() {
|
||||
setFullscreenChart(f => !f);
|
||||
}, [fullscreenChart, candlesAll, fetchAllCandles]);
|
||||
|
||||
const predPrice = parseFloat(predictionPrice);
|
||||
const predValid = stats && predictionPrice !== '' && predPrice > 0;
|
||||
const predValue = predValid ? +(stats.total_btc * predPrice).toFixed(2) : null;
|
||||
const predPL = predValid ? +(predValue - stats.total_invested).toFixed(2) : null;
|
||||
const predVsCurrent = predValid ? +(predValue - stats.portfolio_value).toFixed(2) : null;
|
||||
|
||||
const plHighlight = stats
|
||||
? stats.profit_loss >= 0 ? 'positive' : 'negative'
|
||||
: 'neutral';
|
||||
@@ -116,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"
|
||||
@@ -126,6 +157,42 @@ 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', 'BTC Candles']].map(([key, label]) => (
|
||||
<button
|
||||
@@ -135,18 +202,21 @@ export default function Dashboard() {
|
||||
>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
{(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,8 @@ export default function Login() {
|
||||
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