Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aedc6a8a17 | |||
| db9624822b | |||
| 470dd80ed8 |
@@ -1,2 +0,0 @@
|
|||||||
# Local wallet scripts with credentials — never commit
|
|
||||||
btc_wallet.py
|
|
||||||
@@ -1,32 +1,29 @@
|
|||||||
# BTC Portfolio Tracker
|
# BTC Portfolio Tracker
|
||||||
|
|
||||||
A full-stack Bitcoin portfolio tracker with live EUR pricing, purchase/sell history, candlestick charts, and price predictions.
|
A full-stack Bitcoin portfolio tracker with live EUR pricing, purchase history, and interactive charts.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Live BTC price** — fetched from CoinGecko in EUR, with cached fallback and stale warning
|
- **Live BTC price** — fetched from CoinGecko in EUR
|
||||||
- **Purchase tracking** — log BTC buys with amount (EUR), price per BTC, and a custom date
|
- **Purchase tracking** — log BTC buys with amount (EUR) and price per BTC
|
||||||
- **Sell tracking** — log BTC sells with BTC amount, price per BTC, and a custom date
|
- **Portfolio stats** — total invested, current value, profit/loss
|
||||||
- **Portfolio stats** — total invested, current value, profit/loss, net BTC held
|
- **Interactive charts** — portfolio value over time and 1-year BTC price history
|
||||||
- **Price prediction** — enter a target BTC price to see projected portfolio value and P&L
|
- **Edit & delete** — manage purchases with inline editing
|
||||||
- **Interactive charts** — portfolio value over time and BTC candlestick chart (OHLC stored locally)
|
|
||||||
- **Edit & delete** — inline editing and deletion for both purchases and sells
|
|
||||||
- **Admin panel** — admin users can create, list, and delete accounts
|
|
||||||
- **JWT authentication** — secure per-user portfolios
|
- **JWT authentication** — secure per-user portfolios
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|----------|-----------------------------------------|
|
|----------|-------------------------------------|
|
||||||
| Frontend | React 18, Chart.js, dark theme |
|
| Frontend | React 18, Chart.js, dark theme |
|
||||||
| Backend | FastAPI, SQLAlchemy, SQLite |
|
| Backend | FastAPI, SQLAlchemy, SQLite |
|
||||||
| Auth | JWT (HS256, 24h expiry) + bcrypt |
|
| Auth | JWT (HS256, 24h expiry) + bcrypt |
|
||||||
| Pricing | CoinGecko API (EUR) |
|
| Pricing | CoinGecko API (EUR) |
|
||||||
| Deploy | Docker + Docker Compose + nginx |
|
| Deploy | Docker + Docker Compose |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -68,55 +65,42 @@ btc-portfolio/
|
|||||||
├── backend/
|
├── backend/
|
||||||
│ └── app/
|
│ └── app/
|
||||||
│ ├── main.py # FastAPI app + CORS
|
│ ├── main.py # FastAPI app + CORS
|
||||||
│ ├── models.py # User, Purchase, Sell, OHLCCandle ORM models
|
│ ├── models.py # User & Purchase ORM models
|
||||||
│ ├── auth.py # JWT + bcrypt
|
│ ├── auth.py # JWT + bcrypt
|
||||||
│ ├── dependencies.py # Auth dependency injection
|
│ ├── dependencies.py # Auth dependency injection
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
│ │ ├── users.py # POST /register, POST /login
|
│ │ ├── users.py # POST /register, POST /login
|
||||||
│ │ ├── purchases.py # CRUD /purchases
|
│ │ ├── purchases.py # CRUD /purchases
|
||||||
│ │ ├── sells.py # CRUD /sells
|
|
||||||
│ │ ├── stats.py # GET /stats
|
│ │ ├── stats.py # GET /stats
|
||||||
│ │ ├── history.py # GET /history (365-day BTC prices)
|
│ │ └── history.py # GET /history (365-day BTC prices)
|
||||||
│ │ ├── candles.py # GET /candles (OHLC data + purchases overlay)
|
|
||||||
│ │ └── admin.py # GET/POST/DELETE /admin/users
|
|
||||||
│ └── services/
|
│ └── services/
|
||||||
│ └── btc.py # CoinGecko integration
|
│ └── btc.py # CoinGecko integration
|
||||||
└── frontend/
|
└── frontend/
|
||||||
└── src/
|
└── src/
|
||||||
├── App.js # Routing
|
├── App.js # Routing
|
||||||
├── pages/
|
├── pages/
|
||||||
│ └── Dashboard.js # Main view: stats, predictions, charts, tables
|
│ └── Dashboard.js # Main view
|
||||||
└── components/
|
└── components/
|
||||||
├── AddPurchase.js # Purchase form (amount EUR, price, date)
|
├── AddPurchase.js # Purchase form
|
||||||
├── PurchaseList.js # Purchase table (inline edit/delete)
|
├── PurchaseList.js # Purchase table (edit/delete)
|
||||||
├── AddSell.js # Sell form (BTC amount, price, date)
|
├── PortfolioChart.js # Invested vs current value
|
||||||
├── SellList.js # Sell table (inline edit/delete)
|
└── BTCHistoryChart.js # 1-year BTC price history
|
||||||
├── PortfolioChart.js # Invested vs current value over time
|
|
||||||
└── BTCCandlestickChart.js # OHLC candlestick chart with purchase markers
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
| Method | Endpoint | Description | Auth |
|
| Method | Endpoint | Description | Auth |
|
||||||
|--------|------------------------|------------------------------------|-------|
|
|--------|-------------------|------------------------------|------|
|
||||||
| POST | `/register` | Create account | No |
|
| POST | `/register` | Create account | No |
|
||||||
| POST | `/login` | Get JWT token | No |
|
| POST | `/login` | Get JWT token | No |
|
||||||
| GET | `/purchases` | List user purchases | Yes |
|
| GET | `/purchases` | List user purchases | Yes |
|
||||||
| POST | `/purchases` | Add a purchase | Yes |
|
| POST | `/purchases` | Add a purchase | Yes |
|
||||||
| PUT | `/purchases/{id}` | Edit a purchase | Yes |
|
| PUT | `/purchases/{id}` | Edit a purchase | Yes |
|
||||||
| DELETE | `/purchases/{id}` | Delete a purchase | Yes |
|
| DELETE | `/purchases/{id}` | Delete a purchase | Yes |
|
||||||
| GET | `/sells` | List user sells | Yes |
|
| GET | `/stats` | Portfolio stats (P&L) | Yes |
|
||||||
| POST | `/sells` | Add a sell | Yes |
|
| GET | `/history` | 365-day BTC price history | 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 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -124,19 +108,15 @@ btc-portfolio/
|
|||||||
|
|
||||||
SQLite, stored at `/app/data/btc_portfolio.db` (persisted via Docker volume).
|
SQLite, stored at `/app/data/btc_portfolio.db` (persisted via Docker volume).
|
||||||
|
|
||||||
| Table | Columns |
|
| Table | Columns |
|
||||||
|----------------|---------------------------------------------------------------|
|
|-------------|------------------------------------------------------|
|
||||||
| `users` | id, username (unique), password (bcrypt hash), is_admin |
|
| `users` | id, username (unique), password (bcrypt hash) |
|
||||||
| `purchases` | id, amount_eur, price_eur, created_at, user_id (FK) |
|
| `purchases` | id, amount_eur, price_eur, created_at, user_id (FK) |
|
||||||
| `sells` | id, btc_amount, price_eur, created_at, user_id (FK) |
|
|
||||||
| `ohlc_candles` | id, date (unique, YYYY-MM-DD), open, high, low, close |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- The `SECRET_KEY` in `auth.py` is hardcoded — use an environment variable in production.
|
- The `SECRET_KEY` in `auth.py` is hardcoded — use an environment variable in production.
|
||||||
- CoinGecko requests are unauthenticated; failures fall back to the last cached price with a UI warning.
|
- CoinGecko requests are unauthenticated; failures return `0.0` gracefully.
|
||||||
- OHLC candle data is fetched from CoinGecko and stored locally to reduce API calls.
|
|
||||||
- CORS is restricted to `localhost:3000` by default.
|
- CORS is restricted to `localhost:3000` by default.
|
||||||
- The frontend is served via nginx in the Docker production setup.
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
.git
|
|
||||||
__pycache__
|
|
||||||
*.pyc
|
|
||||||
*.pyo
|
|
||||||
.env
|
|
||||||
*.egg-info
|
|
||||||
.pytest_cache
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import os
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-insecure-key-change-me")
|
SECRET_KEY = "change-me-in-production-use-a-long-random-string"
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,3 @@ def get_current_user(
|
|||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
return user
|
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,55 +2,28 @@ import os
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from .database import engine, Base, SessionLocal
|
from .database import engine, Base
|
||||||
from .routes import users, purchases, stats, history, admin, candles, sells, dca
|
from .routes import users, purchases, stats, history
|
||||||
|
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
app = FastAPI(title="BTC Portfolio API")
|
app = FastAPI(title="BTC Portfolio API")
|
||||||
|
|
||||||
_raw_origins = os.environ.get(
|
origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:3001").split(",")
|
||||||
"ALLOWED_ORIGINS",
|
|
||||||
"http://localhost:3000,http://localhost:3001",
|
|
||||||
)
|
|
||||||
allowed_origins = [o.strip() for o in _raw_origins.split(",") if o.strip()]
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=allowed_origins,
|
allow_origins=origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
allow_methods=["*"],
|
||||||
allow_headers=["Content-Type", "Authorization"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(purchases.router)
|
app.include_router(purchases.router)
|
||||||
app.include_router(stats.router)
|
app.include_router(stats.router)
|
||||||
app.include_router(history.router)
|
app.include_router(history.router)
|
||||||
app.include_router(admin.router, prefix="/admin")
|
|
||||||
app.include_router(candles.router)
|
|
||||||
app.include_router(sells.router)
|
|
||||||
app.include_router(dca.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("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime, Boolean
|
from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from .database import Base
|
from .database import Base
|
||||||
@@ -10,10 +10,8 @@ class User(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
username = Column(String, unique=True, index=True, nullable=False)
|
username = Column(String, unique=True, index=True, nullable=False)
|
||||||
password = Column(String, 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")
|
purchases = relationship("Purchase", back_populates="owner", cascade="all, delete")
|
||||||
sells = relationship("Sell", back_populates="owner", cascade="all, delete")
|
|
||||||
|
|
||||||
|
|
||||||
class Purchase(Base):
|
class Purchase(Base):
|
||||||
@@ -26,26 +24,3 @@ class Purchase(Base):
|
|||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
|
||||||
owner = relationship("User", back_populates="purchases")
|
owner = relationship("User", back_populates="purchases")
|
||||||
|
|
||||||
|
|
||||||
class Sell(Base):
|
|
||||||
__tablename__ = "sells"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
btc_amount = Column(Float, nullable=False)
|
|
||||||
price_eur = Column(Float, nullable=False)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
||||||
|
|
||||||
owner = relationship("User", back_populates="sells")
|
|
||||||
|
|
||||||
|
|
||||||
class OHLCCandle(Base):
|
|
||||||
__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)
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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,108 +0,0 @@
|
|||||||
from datetime import date, timedelta
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from ..database import get_db
|
|
||||||
from .. import models
|
|
||||||
from ..dependencies import get_current_user
|
|
||||||
from ..services.btc import get_btc_price_eur
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _first_of_month(year: int, month: int) -> date:
|
|
||||||
return date(year, month, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def _next_month(year: int, month: int) -> tuple[int, int]:
|
|
||||||
if month == 12:
|
|
||||||
return year + 1, 1
|
|
||||||
return year, month + 1
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dca")
|
|
||||||
def get_dca(
|
|
||||||
monthly_amount: float = Query(..., gt=0),
|
|
||||||
start_date: Optional[str] = Query(default=None),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: models.User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
# Determine start date
|
|
||||||
if start_date:
|
|
||||||
try:
|
|
||||||
sim_start = date.fromisoformat(start_date)
|
|
||||||
except ValueError:
|
|
||||||
sim_start = None
|
|
||||||
else:
|
|
||||||
sim_start = None
|
|
||||||
|
|
||||||
if sim_start is None:
|
|
||||||
earliest = (
|
|
||||||
db.query(models.Purchase)
|
|
||||||
.filter(models.Purchase.user_id == current_user.id)
|
|
||||||
.order_by(models.Purchase.created_at.asc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if earliest:
|
|
||||||
sim_start = earliest.created_at.date()
|
|
||||||
else:
|
|
||||||
sim_start = date.today() - timedelta(days=365)
|
|
||||||
|
|
||||||
# Load all candles into a lookup dict {date_str: close_price}
|
|
||||||
candles_db = db.query(models.OHLCCandle).all()
|
|
||||||
price_by_date: dict[str, float] = {c.date: c.close for c in candles_db}
|
|
||||||
|
|
||||||
today = date.today()
|
|
||||||
current_price, _ = get_btc_price_eur()
|
|
||||||
# Patch today's price in case the candle isn't refreshed yet
|
|
||||||
if current_price:
|
|
||||||
price_by_date[today.isoformat()] = current_price
|
|
||||||
|
|
||||||
# Walk month by month and simulate buys
|
|
||||||
year, month = sim_start.year, sim_start.month
|
|
||||||
end_year, end_month = today.year, today.month
|
|
||||||
|
|
||||||
dca_invested = 0.0
|
|
||||||
dca_btc = 0.0
|
|
||||||
monthly_series = []
|
|
||||||
|
|
||||||
while (year, month) <= (end_year, end_month):
|
|
||||||
# Find the closest available candle on or after the 1st of this month
|
|
||||||
buy_date = None
|
|
||||||
buy_price = None
|
|
||||||
for day_offset in range(8):
|
|
||||||
candidate = _first_of_month(year, month) + timedelta(days=day_offset)
|
|
||||||
key = candidate.isoformat()
|
|
||||||
if key in price_by_date:
|
|
||||||
buy_date = key
|
|
||||||
buy_price = price_by_date[key]
|
|
||||||
break
|
|
||||||
|
|
||||||
if buy_price and buy_price > 0:
|
|
||||||
btc_bought = monthly_amount / buy_price
|
|
||||||
dca_btc += btc_bought
|
|
||||||
dca_invested += monthly_amount
|
|
||||||
monthly_series.append({
|
|
||||||
"month": f"{year:04d}-{month:02d}",
|
|
||||||
"price_used": round(buy_price, 2),
|
|
||||||
"btc_bought": round(btc_bought, 8),
|
|
||||||
"cumulative_btc": round(dca_btc, 8),
|
|
||||||
"cumulative_invested": round(dca_invested, 2),
|
|
||||||
})
|
|
||||||
|
|
||||||
year, month = _next_month(year, month)
|
|
||||||
|
|
||||||
dca_current_value = dca_btc * current_price if current_price else 0.0
|
|
||||||
dca_profit_loss = dca_current_value - dca_invested
|
|
||||||
|
|
||||||
return {
|
|
||||||
"start_date": sim_start.isoformat(),
|
|
||||||
"monthly_amount": monthly_amount,
|
|
||||||
"dca_total_invested": round(dca_invested, 2),
|
|
||||||
"dca_total_btc": round(dca_btc, 8),
|
|
||||||
"dca_current_value": round(dca_current_value, 2),
|
|
||||||
"dca_profit_loss": round(dca_profit_loss, 2),
|
|
||||||
"monthly_series": monthly_series,
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel
|
||||||
from typing import List, Optional
|
from typing import List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
@@ -12,14 +12,13 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseCreate(BaseModel):
|
class PurchaseCreate(BaseModel):
|
||||||
amount_eur: float = Field(gt=0, le=10_000_000)
|
amount_eur: float
|
||||||
price_eur: float = Field(gt=0, le=10_000_000)
|
price_eur: float
|
||||||
created_at: Optional[datetime] = None
|
|
||||||
|
|
||||||
|
|
||||||
class PurchaseUpdate(BaseModel):
|
class PurchaseUpdate(BaseModel):
|
||||||
amount_eur: float = Field(gt=0, le=10_000_000)
|
amount_eur: float
|
||||||
price_eur: float = Field(gt=0, le=10_000_000)
|
price_eur: float
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +54,6 @@ def add_purchase(
|
|||||||
purchase = models.Purchase(
|
purchase = models.Purchase(
|
||||||
amount_eur=purchase_in.amount_eur,
|
amount_eur=purchase_in.amount_eur,
|
||||||
price_eur=purchase_in.price_eur,
|
price_eur=purchase_in.price_eur,
|
||||||
created_at=purchase_in.created_at or datetime.utcnow(),
|
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
)
|
)
|
||||||
db.add(purchase)
|
db.add(purchase)
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
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,27 +15,19 @@ def get_stats(
|
|||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
purchases = db.query(models.Purchase).filter(models.Purchase.user_id == current_user.id).all()
|
purchases = db.query(models.Purchase).filter(models.Purchase.user_id == current_user.id).all()
|
||||||
sells = db.query(models.Sell).filter(models.Sell.user_id == current_user.id).all()
|
|
||||||
|
|
||||||
total_invested = sum(p.amount_eur for p in purchases)
|
total_invested = sum(p.amount_eur for p in purchases)
|
||||||
total_btc_bought = sum(p.amount_eur / p.price_eur for p in purchases) if purchases else 0.0
|
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
|
||||||
total_btc_sold = sum(s.btc_amount for s in sells)
|
current_price = get_btc_price_eur()
|
||||||
proceeds_eur = sum(s.btc_amount * s.price_eur for s in sells)
|
portfolio_value = total_btc * current_price
|
||||||
|
profit_loss = portfolio_value - total_invested
|
||||||
net_btc = total_btc_bought - total_btc_sold
|
|
||||||
net_invested = total_invested - proceeds_eur
|
|
||||||
average_price = net_invested / net_btc if net_btc > 0 else 0.0
|
|
||||||
current_price, price_is_cached = get_btc_price_eur()
|
|
||||||
portfolio_value = net_btc * current_price
|
|
||||||
profit_loss = portfolio_value - net_invested
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_invested": round(net_invested, 2),
|
"total_invested": round(total_invested, 2),
|
||||||
"total_btc": round(net_btc, 8),
|
"total_btc": round(total_btc, 8),
|
||||||
"average_price": round(average_price, 2),
|
"average_price": round(average_price, 2),
|
||||||
"current_price": round(current_price, 2),
|
"current_price": round(current_price, 2),
|
||||||
"price_is_cached": price_is_cached,
|
|
||||||
"portfolio_value": round(portfolio_value, 2),
|
"portfolio_value": round(portfolio_value, 2),
|
||||||
"profit_loss": round(profit_loss, 2),
|
"profit_loss": round(profit_loss, 2),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from .. import models
|
from .. import models
|
||||||
@@ -10,11 +10,6 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
class UserCreate(BaseModel):
|
class UserCreate(BaseModel):
|
||||||
username: str = Field(min_length=3, max_length=50)
|
|
||||||
password: str = Field(min_length=8)
|
|
||||||
|
|
||||||
|
|
||||||
class UserLogin(BaseModel):
|
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
@@ -22,7 +17,6 @@ class UserLogin(BaseModel):
|
|||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str
|
token_type: str
|
||||||
is_admin: bool
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||||
@@ -30,11 +24,9 @@ def register(user_in: UserCreate, db: Session = Depends(get_db)):
|
|||||||
existing = db.query(models.User).filter(models.User.username == user_in.username).first()
|
existing = db.query(models.User).filter(models.User.username == user_in.username).first()
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail="Username already taken")
|
raise HTTPException(status_code=400, detail="Username already taken")
|
||||||
no_users_yet = db.query(models.User).first() is None
|
|
||||||
user = models.User(
|
user = models.User(
|
||||||
username=user_in.username,
|
username=user_in.username,
|
||||||
password=hash_password(user_in.password),
|
password=hash_password(user_in.password),
|
||||||
is_admin=no_users_yet,
|
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -42,9 +34,9 @@ def register(user_in: UserCreate, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
@router.post("/login", response_model=Token)
|
||||||
def login(user_in: UserLogin, db: Session = Depends(get_db)):
|
def login(user_in: UserCreate, db: Session = Depends(get_db)):
|
||||||
user = db.query(models.User).filter(models.User.username == user_in.username).first()
|
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):
|
if not user or not verify_password(user_in.password, user.password):
|
||||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
token = create_access_token({"sub": user.username})
|
token = create_access_token({"sub": user.username})
|
||||||
return {"access_token": token, "token_type": "bearer", "is_admin": user.is_admin}
|
return {"access_token": token, "token_type": "bearer"}
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import logging
|
|
||||||
import requests
|
import requests
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def get_btc_history_eur() -> list:
|
def get_btc_history_eur() -> list:
|
||||||
@@ -14,52 +10,11 @@ def get_btc_history_eur() -> list:
|
|||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json().get("prices", []) # [[timestamp_ms, price], ...]
|
return resp.json().get("prices", []) # [[timestamp_ms, price], ...]
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(f"Failed to fetch BTC history: {e}")
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def get_btc_ohlc_eur(days: int) -> list:
|
def get_btc_price_eur() -> float:
|
||||||
"""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:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
"https://api.coingecko.com/api/v3/simple/price",
|
"https://api.coingecko.com/api/v3/simple/price",
|
||||||
@@ -67,9 +22,6 @@ def get_btc_price_eur() -> tuple[float, bool]:
|
|||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
price = float(resp.json()["bitcoin"]["eur"])
|
return float(resp.json()["bitcoin"]["eur"])
|
||||||
_last_known_price = price
|
except Exception:
|
||||||
return price, False
|
return 0.0
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to fetch BTC price: {e}")
|
|
||||||
return _last_known_price, True
|
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
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,29 +7,15 @@ services:
|
|||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=sqlite:////app/data/btc_portfolio.db
|
- DATABASE_URL=sqlite:////app/data/btc_portfolio.db
|
||||||
- SECRET_KEY=${SECRET_KEY:-dev-insecure-key-change-me}
|
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:3001}
|
|
||||||
restart: unless-stopped
|
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:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
args:
|
args:
|
||||||
- REACT_APP_API_URL=/api
|
- REACT_APP_API_URL=http://localhost:8000
|
||||||
ports:
|
ports:
|
||||||
- "3001:80"
|
- "3001:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:80/"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 20s
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
.git
|
|
||||||
node_modules
|
|
||||||
build
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
npm-debug.log
|
|
||||||
@@ -4,12 +4,13 @@ WORKDIR /app
|
|||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG REACT_APP_API_URL=/api
|
ARG REACT_APP_API_URL=http://localhost:8000
|
||||||
ENV REACT_APP_API_URL=$REACT_APP_API_URL
|
ENV REACT_APP_API_URL=$REACT_APP_API_URL
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM node:18-alpine
|
||||||
COPY --from=build /app/build /usr/share/nginx/html
|
RUN npm install -g serve
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
WORKDIR /app
|
||||||
EXPOSE 80
|
COPY --from=build /app/build ./build
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
EXPOSE 3000
|
||||||
|
CMD ["serve", "-s", "build", "-l", "3000"]
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
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,8 +8,7 @@
|
|||||||
"react-router-dom": "^6.22.0",
|
"react-router-dom": "^6.22.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
"react-chartjs-2": "^5.2.0",
|
"react-chartjs-2": "^5.2.0"
|
||||||
"lightweight-charts": "^4.2.0"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
|
|||||||
@@ -3,18 +3,11 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
|||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import AdminPage from './pages/AdminPage';
|
|
||||||
|
|
||||||
function PrivateRoute({ children }) {
|
function PrivateRoute({ children }) {
|
||||||
return localStorage.getItem('token') ? children : <Navigate to="/login" />;
|
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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@@ -22,7 +15,6 @@ export default function App() {
|
|||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/" element={<PrivateRoute><Dashboard /></PrivateRoute>} />
|
<Route path="/" element={<PrivateRoute><Dashboard /></PrivateRoute>} />
|
||||||
<Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} />
|
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const styles = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function AddPurchase({ onAdded }) {
|
export default function AddPurchase({ onAdded }) {
|
||||||
const [purchaseDate, setPurchaseDate] = useState(new Date().toISOString().split('T')[0]);
|
|
||||||
const [amountEur, setAmountEur] = useState('');
|
const [amountEur, setAmountEur] = useState('');
|
||||||
const [priceEur, setPriceEur] = useState('');
|
const [priceEur, setPriceEur] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -31,19 +30,16 @@ export default function AddPurchase({ onAdded }) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
amount_eur: parseFloat(amountEur),
|
amount_eur: parseFloat(amountEur),
|
||||||
price_eur: parseFloat(priceEur),
|
price_eur: parseFloat(priceEur),
|
||||||
created_at: new Date(purchaseDate + 'T12:00:00').toISOString(),
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError('Failed to add purchase');
|
setError('Failed to add purchase');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPurchaseDate(new Date().toISOString().split('T')[0]);
|
|
||||||
setAmountEur('');
|
setAmountEur('');
|
||||||
setPriceEur('');
|
setPriceEur('');
|
||||||
onAdded();
|
onAdded();
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error('AddPurchase network error:', err);
|
|
||||||
setError('Network error');
|
setError('Network error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -53,13 +49,6 @@ export default function AddPurchase({ onAdded }) {
|
|||||||
<div style={styles.title}>Add Purchase</div>
|
<div style={styles.title}>Add Purchase</div>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div style={styles.row}>
|
<div style={styles.row}>
|
||||||
<input
|
|
||||||
style={styles.input}
|
|
||||||
type="date"
|
|
||||||
value={purchaseDate}
|
|
||||||
onChange={e => setPurchaseDate(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
|
||||||
|
|
||||||
const styles = {
|
|
||||||
card: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
|
|
||||||
header: { color: '#888', fontSize: '0.8rem', marginBottom: '0.75rem' },
|
|
||||||
row: { display: 'flex', gap: '1rem', alignItems: 'flex-start', flexWrap: 'wrap' },
|
|
||||||
inputWrap: { display: 'flex', flexDirection: 'column', gap: '0.3rem' },
|
|
||||||
label: { color: '#888', fontSize: '0.8rem' },
|
|
||||||
input: { background: '#111', border: '1px solid #444', borderRadius: '8px', color: '#fff', padding: '0.55rem 0.75rem', fontSize: '1rem', width: '160px', outline: 'none' },
|
|
||||||
results: { display: 'flex', flexDirection: 'column', gap: '0.75rem', flex: 1 },
|
|
||||||
statsRow: { display: 'flex', gap: '1rem', flexWrap: 'wrap' },
|
|
||||||
statCard: { background: '#111', padding: '0.75rem 1rem', borderRadius: '10px', border: '1px solid #2a2a2a', minWidth: '130px' },
|
|
||||||
statLabel: { color: '#888', fontSize: '0.75rem', marginBottom: '0.2rem' },
|
|
||||||
statValue: { fontSize: '1.1rem', fontWeight: 700 },
|
|
||||||
positive: { color: '#6bff8e' },
|
|
||||||
negative: { color: '#ff6b6b' },
|
|
||||||
neutral: { color: '#f7931a' },
|
|
||||||
comparison: { color: '#888', fontSize: '0.8rem' },
|
|
||||||
placeholder: { color: '#555', fontSize: '0.9rem', padding: '0.5rem 0' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function MiniStatCard({ label, value, highlight }) {
|
|
||||||
return (
|
|
||||||
<div style={styles.statCard}>
|
|
||||||
<div style={styles.statLabel}>{label}</div>
|
|
||||||
<div style={{ ...styles.statValue, ...(highlight ? styles[highlight] : {}) }}>{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DCACalculator({ purchases, stats }) {
|
|
||||||
const defaultStart = () => {
|
|
||||||
if (purchases && purchases.length > 0) {
|
|
||||||
const sorted = [...purchases].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
|
||||||
return sorted[0].created_at.slice(0, 10);
|
|
||||||
}
|
|
||||||
const d = new Date();
|
|
||||||
d.setFullYear(d.getFullYear() - 1);
|
|
||||||
return d.toISOString().slice(0, 10);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [monthlyAmount, setMonthlyAmount] = useState('');
|
|
||||||
const [startDate, setStartDate] = useState(defaultStart);
|
|
||||||
const [result, setResult] = useState(null);
|
|
||||||
const debounceRef = useRef(null);
|
|
||||||
|
|
||||||
// Update default start date when purchases load
|
|
||||||
useEffect(() => {
|
|
||||||
if (purchases && purchases.length > 0 && !monthlyAmount) {
|
|
||||||
setStartDate(defaultStart());
|
|
||||||
}
|
|
||||||
}, [purchases]); // eslint-disable-line
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const amount = parseFloat(monthlyAmount);
|
|
||||||
if (!amount || amount <= 0 || !startDate) {
|
|
||||||
setResult(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
debounceRef.current = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`${API}/dca?monthly_amount=${amount}&start_date=${startDate}`,
|
|
||||||
{ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }
|
|
||||||
);
|
|
||||||
if (res.ok) setResult(await res.json());
|
|
||||||
} catch {
|
|
||||||
// silently fail
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
return () => clearTimeout(debounceRef.current);
|
|
||||||
}, [monthlyAmount, startDate]);
|
|
||||||
|
|
||||||
const plHighlight = result
|
|
||||||
? result.dca_profit_loss >= 0 ? 'positive' : 'negative'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const actualPL = stats ? stats.profit_loss : null;
|
|
||||||
const actualHighlight = actualPL != null ? (actualPL >= 0 ? 'positive' : 'negative') : null;
|
|
||||||
|
|
||||||
const fmt = (n) => `€${n.toLocaleString()}`;
|
|
||||||
const fmtPL = (n) => `${n >= 0 ? '+' : ''}${fmt(n)}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={styles.card}>
|
|
||||||
<div style={styles.header}>DCA Calculator — What if you had invested monthly?</div>
|
|
||||||
<div style={styles.row}>
|
|
||||||
<div style={styles.inputWrap}>
|
|
||||||
<label style={styles.label}>Monthly amount (€)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
placeholder="e.g. 200"
|
|
||||||
value={monthlyAmount}
|
|
||||||
onChange={e => setMonthlyAmount(e.target.value)}
|
|
||||||
style={styles.input}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={styles.inputWrap}>
|
|
||||||
<label style={styles.label}>Starting from</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
onChange={e => setStartDate(e.target.value)}
|
|
||||||
style={styles.input}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{result ? (
|
|
||||||
<div style={styles.results}>
|
|
||||||
<div style={styles.statsRow}>
|
|
||||||
<MiniStatCard label="DCA Invested" value={fmt(result.dca_total_invested)} />
|
|
||||||
<MiniStatCard label="DCA BTC" value={`₿${result.dca_total_btc}`} highlight="neutral" />
|
|
||||||
<MiniStatCard label="DCA Value" value={fmt(result.dca_current_value)} />
|
|
||||||
<MiniStatCard label="DCA P&L" value={fmtPL(result.dca_profit_loss)} highlight={plHighlight} />
|
|
||||||
</div>
|
|
||||||
{actualPL != null && (
|
|
||||||
<div style={styles.comparison}>
|
|
||||||
Actual P&L:
|
|
||||||
<span style={styles[actualHighlight]}>{fmtPL(actualPL)}</span>
|
|
||||||
|
|
|
||||||
DCA P&L:
|
|
||||||
<span style={styles[plHighlight]}>{fmtPL(result.dca_profit_loss)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ ...styles.placeholder, alignSelf: 'center' }}>
|
|
||||||
Enter a monthly amount to simulate DCA
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -19,93 +19,40 @@ 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' },
|
saveBtn: { marginTop: '0.75rem', background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '6px', padding: '0.4rem 1rem', cursor: 'pointer', fontSize: '0.85rem' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function toDateKey(date) {
|
export default function PortfolioChart({ purchases, stats }) {
|
||||||
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);
|
const chartRef = useRef(null);
|
||||||
|
|
||||||
if (!purchases || purchases.length === 0) return 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 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 labels = [];
|
||||||
const portfolioValues = [];
|
const portfolioValues = [];
|
||||||
const investedValues = [];
|
const investedValues = [];
|
||||||
|
|
||||||
dates.forEach(date => {
|
sorted.forEach((p, i) => {
|
||||||
const isToday = toDateKey(date) === toDateKey(today);
|
cumInvested += p.amount_eur;
|
||||||
const price = priceOn(date, priceMap, stats?.current_price, isToday, sorted);
|
cumBtc += p.amount_eur / p.price_eur;
|
||||||
if (price === null) return; // no price data available, skip
|
const currentVal = cumBtc * (stats?.current_price || p.price_eur);
|
||||||
|
labels.push(new Date(p.created_at).toLocaleDateString());
|
||||||
// Cumulative BTC and invested up to this date
|
portfolioValues.push(parseFloat(currentVal.toFixed(2)));
|
||||||
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)));
|
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 = {
|
const data = {
|
||||||
labels,
|
labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export default function PurchaseList({ purchases, onChanged }) {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
<tr key={p.id}>
|
<tr key={p.id}>
|
||||||
<td style={styles.td}>{new Date(p.created_at).toLocaleDateString('en-GB')}</td>
|
<td style={styles.td}>{new Date(p.created_at).toLocaleDateString()}</td>
|
||||||
<td style={styles.td}>€{p.amount_eur.toLocaleString()}</td>
|
<td style={styles.td}>€{p.amount_eur.toLocaleString()}</td>
|
||||||
<td style={styles.td}>€{p.price_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>
|
<td style={styles.td}>{(p.amount_eur / p.price_eur).toFixed(6)}</td>
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
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,12 +1,9 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import AddPurchase from '../components/AddPurchase';
|
import AddPurchase from '../components/AddPurchase';
|
||||||
import PurchaseList from '../components/PurchaseList';
|
import PurchaseList from '../components/PurchaseList';
|
||||||
import AddSell from '../components/AddSell';
|
|
||||||
import SellList from '../components/SellList';
|
|
||||||
import PortfolioChart from '../components/PortfolioChart';
|
import PortfolioChart from '../components/PortfolioChart';
|
||||||
import BTCCandlestickChart from '../components/BTCCandlestickChart';
|
import BTCHistoryChart from '../components/BTCHistoryChart';
|
||||||
import DCACalculator from '../components/DCACalculator';
|
|
||||||
|
|
||||||
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
@@ -15,16 +12,7 @@ const styles = {
|
|||||||
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' },
|
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' },
|
||||||
logo: { fontSize: '1.4rem', fontWeight: 700, color: '#f7931a' },
|
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' },
|
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' },
|
statsGrid: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', marginBottom: '1.5rem' },
|
||||||
predictionSection: { background: '#1a1a1a', border: '1px solid #333', borderRadius: '12px', padding: '1rem', marginBottom: '1.5rem' },
|
|
||||||
predictionHeader: { color: '#888', fontSize: '0.8rem', marginBottom: '0.75rem' },
|
|
||||||
predictionRow: { display: 'flex', gap: '1rem', alignItems: 'flex-start', flexWrap: 'wrap' },
|
|
||||||
predictionInputWrap: { display: 'flex', flexDirection: 'column', gap: '0.3rem' },
|
|
||||||
predictionLabel: { color: '#888', fontSize: '0.8rem' },
|
|
||||||
predictionInput: { background: '#111', border: '1px solid #444', borderRadius: '8px', color: '#fff', padding: '0.55rem 0.75rem', fontSize: '1rem', width: '160px', outline: 'none' },
|
|
||||||
predictionCards: { display: 'flex', gap: '1rem', flex: 1, flexWrap: 'wrap' },
|
|
||||||
statCard: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333' },
|
statCard: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333' },
|
||||||
statLabel: { color: '#888', fontSize: '0.8rem', marginBottom: '0.3rem' },
|
statLabel: { color: '#888', fontSize: '0.8rem', marginBottom: '0.3rem' },
|
||||||
statValue: { fontSize: '1.2rem', fontWeight: 700 },
|
statValue: { fontSize: '1.2rem', fontWeight: 700 },
|
||||||
@@ -36,19 +24,11 @@ const styles = {
|
|||||||
tabActive: { padding: '0.45rem 1.1rem', borderRadius: '8px', border: '1px solid #f7931a', background: 'rgba(247,147,26,0.1)', color: '#f7931a', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 700 },
|
tabActive: { padding: '0.45rem 1.1rem', borderRadius: '8px', border: '1px solid #f7931a', background: 'rgba(247,147,26,0.1)', color: '#f7931a', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 700 },
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatCard({ label, value, highlight, warning }) {
|
function StatCard({ label, value, highlight }) {
|
||||||
return (
|
return (
|
||||||
<div style={styles.statCard}>
|
<div style={styles.statCard}>
|
||||||
<div style={{ ...styles.statLabel, display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
|
<div style={styles.statLabel}>{label}</div>
|
||||||
{label}
|
|
||||||
{warning && (
|
|
||||||
<span title={warning} style={{ color: '#f7931a', cursor: 'default', fontSize: '0.85rem' }}>⚠</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ ...styles.statValue, ...(highlight ? styles[highlight] : {}) }}>{value}</div>
|
<div style={{ ...styles.statValue, ...(highlight ? styles[highlight] : {}) }}>{value}</div>
|
||||||
{warning && (
|
|
||||||
<div style={{ color: '#888', fontSize: '0.7rem', marginTop: '0.25rem' }}>{warning}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,12 +36,8 @@ function StatCard({ label, value, highlight, warning }) {
|
|||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [stats, setStats] = useState(null);
|
const [stats, setStats] = useState(null);
|
||||||
const [purchases, setPurchases] = useState([]);
|
const [purchases, setPurchases] = useState([]);
|
||||||
const [sells, setSells] = useState([]);
|
const [history, setHistory] = useState(null);
|
||||||
const [candles, setCandles] = useState(null);
|
|
||||||
const [candlesAll, setCandlesAll] = useState(null);
|
|
||||||
const [fullscreenChart, setFullscreenChart] = useState(false);
|
|
||||||
const [chartView, setChartView] = useState('both'); // 'both' | 'portfolio' | 'history'
|
const [chartView, setChartView] = useState('both'); // 'both' | 'portfolio' | 'history'
|
||||||
const [predictionPrice, setPredictionPrice] = useState('');
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const authHeaders = () => ({
|
const authHeaders = () => ({
|
||||||
@@ -70,11 +46,10 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [statsRes, purchasesRes, sellsRes, candlesRes] = await Promise.all([
|
const [statsRes, purchasesRes, historyRes] = await Promise.all([
|
||||||
fetch(`${API}/stats`, { headers: authHeaders() }),
|
fetch(`${API}/stats`, { headers: authHeaders() }),
|
||||||
fetch(`${API}/purchases`, { headers: authHeaders() }),
|
fetch(`${API}/purchases`, { headers: authHeaders() }),
|
||||||
fetch(`${API}/sells`, { headers: authHeaders() }),
|
fetch(`${API}/history`, { headers: authHeaders() }),
|
||||||
fetch(`${API}/candles?days=365`, { headers: authHeaders() }),
|
|
||||||
]);
|
]);
|
||||||
if (statsRes.status === 401) {
|
if (statsRes.status === 401) {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
@@ -83,60 +58,30 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
setStats(await statsRes.json());
|
setStats(await statsRes.json());
|
||||||
setPurchases(await purchasesRes.json());
|
setPurchases(await purchasesRes.json());
|
||||||
setSells(await sellsRes.json());
|
setHistory(await historyRes.json());
|
||||||
setCandles(await candlesRes.json());
|
|
||||||
} catch {
|
} catch {
|
||||||
// silently fail — network may be unavailable
|
// silently fail — network may be unavailable
|
||||||
}
|
}
|
||||||
}, [navigate]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
const isAdmin = localStorage.getItem('is_admin') === 'true';
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('is_admin');
|
|
||||||
navigate('/login');
|
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
|
const plHighlight = stats
|
||||||
? stats.profit_loss >= 0 ? 'positive' : 'negative'
|
? stats.profit_loss >= 0 ? 'positive' : 'negative'
|
||||||
: 'neutral';
|
: 'neutral';
|
||||||
|
|
||||||
// Fullscreen uses all-candles data once loaded, otherwise falls back to 365-day set
|
|
||||||
const activeCandles = (fullscreenChart && candlesAll) ? candlesAll : candles;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.app}>
|
<div style={styles.app}>
|
||||||
<div style={styles.header}>
|
<div style={styles.header}>
|
||||||
<div style={styles.logo}>₿ BTC Portfolio</div>
|
<div style={styles.logo}>₿ BTC Portfolio</div>
|
||||||
<div style={styles.headerBtns}>
|
<button style={styles.logoutBtn} onClick={handleLogout}>Logout</button>
|
||||||
{isAdmin && <Link to="/admin" style={styles.adminBtn}>Manage Users</Link>}
|
|
||||||
<button style={styles.logoutBtn} onClick={handleLogout}>Logout</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stats && (
|
{stats && (
|
||||||
@@ -144,11 +89,7 @@ export default function Dashboard() {
|
|||||||
<StatCard label="Total Invested" value={`€${stats.total_invested.toLocaleString()}`} />
|
<StatCard label="Total Invested" value={`€${stats.total_invested.toLocaleString()}`} />
|
||||||
<StatCard label="Avg Buy Price" value={`€${stats.average_price.toLocaleString()}`} />
|
<StatCard label="Avg Buy Price" value={`€${stats.average_price.toLocaleString()}`} />
|
||||||
<StatCard label="Total BTC" value={`₿${stats.total_btc}`} highlight="neutral" />
|
<StatCard label="Total BTC" value={`₿${stats.total_btc}`} highlight="neutral" />
|
||||||
<StatCard
|
<StatCard label="Current BTC Price" value={`€${stats.current_price.toLocaleString()}`} />
|
||||||
label="Current BTC Price"
|
|
||||||
value={`€${stats.current_price.toLocaleString()}`}
|
|
||||||
warning={stats.price_is_cached ? 'Price may be outdated — live fetch failed' : undefined}
|
|
||||||
/>
|
|
||||||
<StatCard label="Portfolio Value" value={`€${stats.portfolio_value.toLocaleString()}`} />
|
<StatCard label="Portfolio Value" value={`€${stats.portfolio_value.toLocaleString()}`} />
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Profit / Loss"
|
label="Profit / Loss"
|
||||||
@@ -158,46 +99,8 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={styles.predictionSection}>
|
|
||||||
<div style={styles.predictionHeader}>Price Prediction</div>
|
|
||||||
<div style={styles.predictionRow}>
|
|
||||||
<div style={styles.predictionInputWrap}>
|
|
||||||
<label style={styles.predictionLabel}>BTC Price (€)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
placeholder="e.g. 100000"
|
|
||||||
value={predictionPrice}
|
|
||||||
onChange={e => setPredictionPrice(e.target.value)}
|
|
||||||
style={styles.predictionInput}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{predValid && (
|
|
||||||
<div style={styles.predictionCards}>
|
|
||||||
<StatCard
|
|
||||||
label="Predicted Value"
|
|
||||||
value={`€${predValue.toLocaleString()}`}
|
|
||||||
highlight="neutral"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Predicted P&L"
|
|
||||||
value={`${predPL >= 0 ? '+' : ''}€${predPL.toLocaleString()}`}
|
|
||||||
highlight={predPL >= 0 ? 'positive' : 'negative'}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="vs. Current"
|
|
||||||
value={`${predVsCurrent >= 0 ? '+' : ''}€${predVsCurrent.toLocaleString()}`}
|
|
||||||
highlight={predVsCurrent >= 0 ? 'positive' : 'negative'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DCACalculator purchases={purchases} stats={stats} />
|
|
||||||
|
|
||||||
<div style={styles.tabs}>
|
<div style={styles.tabs}>
|
||||||
{[['both', 'Both'], ['portfolio', 'Portfolio'], ['history', 'BTC Candles']].map(([key, label]) => (
|
{[['both', 'Both'], ['portfolio', 'Portfolio'], ['history', '1-Year BTC']].map(([key, label]) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
style={chartView === key ? styles.tabActive : styles.tab}
|
style={chartView === key ? styles.tabActive : styles.tab}
|
||||||
@@ -205,21 +108,10 @@ export default function Dashboard() {
|
|||||||
>{label}</button>
|
>{label}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} sells={sells} stats={stats} btcHistory={candles?.candles ?? []} />}
|
{(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} stats={stats} />}
|
||||||
{(chartView === 'both' || chartView === 'history') && (
|
{(chartView === 'both' || chartView === 'history') && <BTCHistoryChart history={history} stats={stats} />}
|
||||||
<BTCCandlestickChart
|
|
||||||
candles={activeCandles?.candles ?? null}
|
|
||||||
purchases={activeCandles?.purchases ?? purchases}
|
|
||||||
stats={stats}
|
|
||||||
livePrice={stats?.current_price ?? null}
|
|
||||||
fullscreen={fullscreenChart}
|
|
||||||
onToggleFullscreen={handleToggleFullscreen}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<AddPurchase onAdded={fetchData} />
|
<AddPurchase onAdded={fetchData} />
|
||||||
<PurchaseList purchases={purchases} onChanged={fetchData} />
|
<PurchaseList purchases={purchases} onChanged={fetchData} />
|
||||||
<AddSell onAdded={fetchData} />
|
|
||||||
<SellList sells={sells} onChanged={fetchData} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,10 +35,8 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
localStorage.setItem('token', data.access_token);
|
localStorage.setItem('token', data.access_token);
|
||||||
localStorage.setItem('is_admin', data.is_admin ? 'true' : 'false');
|
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error('Login network error:', err);
|
|
||||||
setError('Network error');
|
setError('Network error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ export default function Register() {
|
|||||||
}
|
}
|
||||||
setSuccess('Account created! Redirecting...');
|
setSuccess('Account created! Redirecting...');
|
||||||
setTimeout(() => navigate('/login'), 1500);
|
setTimeout(() => navigate('/login'), 1500);
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error('Register network error:', err);
|
|
||||||
setError('Network error');
|
setError('Network error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user