diff --git a/btc-portfolio/backend/app/main.py b/btc-portfolio/backend/app/main.py index caab39d..cb66d15 100644 --- a/btc-portfolio/backend/app/main.py +++ b/btc-portfolio/backend/app/main.py @@ -5,17 +5,21 @@ 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") -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=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["Content-Type", "Authorization"], @@ -27,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") diff --git a/btc-portfolio/backend/app/models.py b/btc-portfolio/backend/app/models.py index 30959f7..a40a218 100644 --- a/btc-portfolio/backend/app/models.py +++ b/btc-portfolio/backend/app/models.py @@ -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" diff --git a/btc-portfolio/backend/app/routes/purchases.py b/btc-portfolio/backend/app/routes/purchases.py index f8c3c65..454ccd1 100644 --- a/btc-portfolio/backend/app/routes/purchases.py +++ b/btc-portfolio/backend/app/routes/purchases.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from pydantic import BaseModel, Field -from typing import List +from typing import List, Optional from datetime import datetime from ..database import get_db @@ -14,6 +14,7 @@ router = APIRouter() class PurchaseCreate(BaseModel): 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): @@ -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) diff --git a/btc-portfolio/backend/app/routes/sells.py b/btc-portfolio/backend/app/routes/sells.py new file mode 100644 index 0000000..ba3d070 --- /dev/null +++ b/btc-portfolio/backend/app/routes/sells.py @@ -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() diff --git a/btc-portfolio/backend/app/routes/stats.py b/btc-portfolio/backend/app/routes/stats.py index b619099..c78ad03 100644 --- a/btc-portfolio/backend/app/routes/stats.py +++ b/btc-portfolio/backend/app/routes/stats.py @@ -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), } diff --git a/btc-portfolio/backend/app/services/btc.py b/btc-portfolio/backend/app/services/btc.py index 02f471c..0de2c97 100644 --- a/btc-portfolio/backend/app/services/btc.py +++ b/btc-portfolio/backend/app/services/btc.py @@ -54,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", @@ -62,7 +67,9 @@ def get_btc_price_eur() -> float: timeout=10, ) resp.raise_for_status() - return float(resp.json()["bitcoin"]["eur"]) + price = float(resp.json()["bitcoin"]["eur"]) + _last_known_price = price + return price, False except Exception as e: logger.error(f"Failed to fetch BTC price: {e}") - return 0.0 + return _last_known_price, True diff --git a/btc-portfolio/docker-compose.yml b/btc-portfolio/docker-compose.yml index 1308903..3fe6b64 100644 --- a/btc-portfolio/docker-compose.yml +++ b/btc-portfolio/docker-compose.yml @@ -8,6 +8,7 @@ services: 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/')"] @@ -20,14 +21,14 @@ services: 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:3001/"] + test: ["CMD", "wget", "-qO-", "http://localhost:80/"] interval: 30s timeout: 10s retries: 3 diff --git a/btc-portfolio/frontend/Dockerfile b/btc-portfolio/frontend/Dockerfile index 7f73f59..66aac03 100644 --- a/btc-portfolio/frontend/Dockerfile +++ b/btc-portfolio/frontend/Dockerfile @@ -4,16 +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 -RUN addgroup -S appgroup && adduser -S appuser -G appgroup -WORKDIR /app -COPY --from=build /app/build ./build -RUN chown -R appuser:appgroup /app -USER appuser -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;"] diff --git a/btc-portfolio/frontend/nginx.conf b/btc-portfolio/frontend/nginx.conf new file mode 100644 index 0000000..57bf754 --- /dev/null +++ b/btc-portfolio/frontend/nginx.conf @@ -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; + } +} diff --git a/btc-portfolio/frontend/src/components/AddPurchase.js b/btc-portfolio/frontend/src/components/AddPurchase.js index a4c1add..9aa7d1a 100644 --- a/btc-portfolio/frontend/src/components/AddPurchase.js +++ b/btc-portfolio/frontend/src/components/AddPurchase.js @@ -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,12 +31,14 @@ 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(); @@ -50,6 +53,13 @@ export default function AddPurchase({ onAdded }) {