Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd21aa7f4e | |||
| 59f833d7fd | |||
| d5197cde14 | |||
| d90ac5365a | |||
| 672f5b74a4 | |||
| a2ca82062e | |||
| 5bb67d6663 |
@@ -1,29 +1,32 @@
|
|||||||
# BTC Portfolio Tracker
|
# BTC Portfolio Tracker
|
||||||
|
|
||||||
A full-stack Bitcoin portfolio tracker with live EUR pricing, purchase history, and interactive charts.
|
A full-stack Bitcoin portfolio tracker with live EUR pricing, purchase/sell history, candlestick charts, and price predictions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Live BTC price** — fetched from CoinGecko in EUR
|
- **Live BTC price** — fetched from CoinGecko in EUR, with cached fallback and stale warning
|
||||||
- **Purchase tracking** — log BTC buys with amount (EUR) and price per BTC
|
- **Purchase tracking** — log BTC buys with amount (EUR), price per BTC, and a custom date
|
||||||
- **Portfolio stats** — total invested, current value, profit/loss
|
- **Sell tracking** — log BTC sells with BTC amount, price per BTC, and a custom date
|
||||||
- **Interactive charts** — portfolio value over time and 1-year BTC price history
|
- **Portfolio stats** — total invested, current value, profit/loss, net BTC held
|
||||||
- **Edit & delete** — manage purchases with inline editing
|
- **Price prediction** — enter a target BTC price to see projected portfolio value and P&L
|
||||||
|
- **Interactive charts** — portfolio value over time and BTC candlestick chart (OHLC stored locally)
|
||||||
|
- **Edit & delete** — inline editing and deletion for both purchases and sells
|
||||||
|
- **Admin panel** — admin users can create, list, and delete accounts
|
||||||
- **JWT authentication** — secure per-user portfolios
|
- **JWT authentication** — secure per-user portfolios
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|----------|-------------------------------------|
|
|----------|-----------------------------------------|
|
||||||
| Frontend | React 18, Chart.js, dark theme |
|
| Frontend | React 18, Chart.js, dark theme |
|
||||||
| Backend | FastAPI, SQLAlchemy, SQLite |
|
| Backend | FastAPI, SQLAlchemy, SQLite |
|
||||||
| Auth | JWT (HS256, 24h expiry) + bcrypt |
|
| Auth | JWT (HS256, 24h expiry) + bcrypt |
|
||||||
| Pricing | CoinGecko API (EUR) |
|
| Pricing | CoinGecko API (EUR) |
|
||||||
| Deploy | Docker + Docker Compose |
|
| Deploy | Docker + Docker Compose + nginx |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -65,42 +68,55 @@ btc-portfolio/
|
|||||||
├── backend/
|
├── backend/
|
||||||
│ └── app/
|
│ └── app/
|
||||||
│ ├── main.py # FastAPI app + CORS
|
│ ├── main.py # FastAPI app + CORS
|
||||||
│ ├── models.py # User & Purchase ORM models
|
│ ├── models.py # User, Purchase, Sell, OHLCCandle ORM models
|
||||||
│ ├── auth.py # JWT + bcrypt
|
│ ├── auth.py # JWT + bcrypt
|
||||||
│ ├── dependencies.py # Auth dependency injection
|
│ ├── dependencies.py # Auth dependency injection
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
│ │ ├── users.py # POST /register, POST /login
|
│ │ ├── users.py # POST /register, POST /login
|
||||||
│ │ ├── purchases.py # CRUD /purchases
|
│ │ ├── purchases.py # CRUD /purchases
|
||||||
|
│ │ ├── sells.py # CRUD /sells
|
||||||
│ │ ├── stats.py # GET /stats
|
│ │ ├── stats.py # GET /stats
|
||||||
│ │ └── history.py # GET /history (365-day BTC prices)
|
│ │ ├── history.py # GET /history (365-day BTC prices)
|
||||||
|
│ │ ├── candles.py # GET /candles (OHLC data + purchases overlay)
|
||||||
|
│ │ └── admin.py # GET/POST/DELETE /admin/users
|
||||||
│ └── services/
|
│ └── services/
|
||||||
│ └── btc.py # CoinGecko integration
|
│ └── btc.py # CoinGecko integration
|
||||||
└── frontend/
|
└── frontend/
|
||||||
└── src/
|
└── src/
|
||||||
├── App.js # Routing
|
├── App.js # Routing
|
||||||
├── pages/
|
├── pages/
|
||||||
│ └── Dashboard.js # Main view
|
│ └── Dashboard.js # Main view: stats, predictions, charts, tables
|
||||||
└── components/
|
└── components/
|
||||||
├── AddPurchase.js # Purchase form
|
├── AddPurchase.js # Purchase form (amount EUR, price, date)
|
||||||
├── PurchaseList.js # Purchase table (edit/delete)
|
├── PurchaseList.js # Purchase table (inline edit/delete)
|
||||||
├── PortfolioChart.js # Invested vs current value
|
├── AddSell.js # Sell form (BTC amount, price, date)
|
||||||
└── BTCHistoryChart.js # 1-year BTC price history
|
├── SellList.js # Sell table (inline edit/delete)
|
||||||
|
├── PortfolioChart.js # Invested vs current value over time
|
||||||
|
└── BTCCandlestickChart.js # OHLC candlestick chart with purchase markers
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
| Method | Endpoint | Description | Auth |
|
| Method | Endpoint | Description | Auth |
|
||||||
|--------|-------------------|------------------------------|------|
|
|--------|------------------------|------------------------------------|-------|
|
||||||
| POST | `/register` | Create account | No |
|
| POST | `/register` | Create account | No |
|
||||||
| POST | `/login` | Get JWT token | No |
|
| POST | `/login` | Get JWT token | No |
|
||||||
| GET | `/purchases` | List user purchases | Yes |
|
| GET | `/purchases` | List user purchases | Yes |
|
||||||
| POST | `/purchases` | Add a purchase | Yes |
|
| POST | `/purchases` | Add a purchase | Yes |
|
||||||
| PUT | `/purchases/{id}` | Edit a purchase | Yes |
|
| PUT | `/purchases/{id}` | Edit a purchase | Yes |
|
||||||
| DELETE | `/purchases/{id}` | Delete a purchase | Yes |
|
| DELETE | `/purchases/{id}` | Delete a purchase | Yes |
|
||||||
| GET | `/stats` | Portfolio stats (P&L) | Yes |
|
| GET | `/sells` | List user sells | Yes |
|
||||||
| GET | `/history` | 365-day BTC price history | Yes |
|
| POST | `/sells` | Add a sell | Yes |
|
||||||
|
| PUT | `/sells/{sell_id}` | Edit a sell | Yes |
|
||||||
|
| DELETE | `/sells/{sell_id}` | Delete a sell | Yes |
|
||||||
|
| GET | `/stats` | Portfolio stats (P&L, net BTC) | Yes |
|
||||||
|
| GET | `/history` | 365-day BTC price history | Yes |
|
||||||
|
| GET | `/candles` | OHLC candles + purchase overlay | Yes |
|
||||||
|
| GET | `/admin/users` | List all users (admin only) | Admin |
|
||||||
|
| POST | `/admin/users` | Create a user (admin only) | Admin |
|
||||||
|
| DELETE | `/admin/users/{id}` | Delete a user (admin only) | Admin |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -108,15 +124,19 @@ btc-portfolio/
|
|||||||
|
|
||||||
SQLite, stored at `/app/data/btc_portfolio.db` (persisted via Docker volume).
|
SQLite, stored at `/app/data/btc_portfolio.db` (persisted via Docker volume).
|
||||||
|
|
||||||
| Table | Columns |
|
| Table | Columns |
|
||||||
|-------------|------------------------------------------------------|
|
|----------------|---------------------------------------------------------------|
|
||||||
| `users` | id, username (unique), password (bcrypt hash) |
|
| `users` | id, username (unique), password (bcrypt hash), is_admin |
|
||||||
| `purchases` | id, amount_eur, price_eur, created_at, user_id (FK) |
|
| `purchases` | id, amount_eur, price_eur, created_at, user_id (FK) |
|
||||||
|
| `sells` | id, btc_amount, price_eur, created_at, user_id (FK) |
|
||||||
|
| `ohlc_candles` | id, date (unique, YYYY-MM-DD), open, high, low, close |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- The `SECRET_KEY` in `auth.py` is hardcoded — use an environment variable in production.
|
- The `SECRET_KEY` in `auth.py` is hardcoded — use an environment variable in production.
|
||||||
- CoinGecko requests are unauthenticated; failures return `0.0` gracefully.
|
- CoinGecko requests are unauthenticated; failures fall back to the last cached price with a UI warning.
|
||||||
|
- OHLC candle data is fetched from CoinGecko and stored locally to reduce API calls.
|
||||||
- CORS is restricted to `localhost:3000` by default.
|
- CORS is restricted to `localhost:3000` by default.
|
||||||
|
- The frontend is served via nginx in the Docker production setup.
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from .database import engine, Base, SessionLocal
|
from .database import engine, Base, SessionLocal
|
||||||
from .routes import users, purchases, stats, history, admin, candles
|
from .routes import users, purchases, stats, history, admin, candles, sells, dca
|
||||||
|
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
app = FastAPI(title="BTC Portfolio API")
|
app = FastAPI(title="BTC Portfolio API")
|
||||||
|
|
||||||
|
_raw_origins = os.environ.get(
|
||||||
|
"ALLOWED_ORIGINS",
|
||||||
|
"http://localhost:3000,http://localhost:3001",
|
||||||
|
)
|
||||||
|
allowed_origins = [o.strip() for o in _raw_origins.split(",") if o.strip()]
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["http://localhost:3000", "http://localhost:3001"],
|
allow_origins=allowed_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["Content-Type", "Authorization"],
|
allow_headers=["Content-Type", "Authorization"],
|
||||||
@@ -23,6 +31,8 @@ app.include_router(stats.router)
|
|||||||
app.include_router(history.router)
|
app.include_router(history.router)
|
||||||
app.include_router(admin.router, prefix="/admin")
|
app.include_router(admin.router, prefix="/admin")
|
||||||
app.include_router(candles.router)
|
app.include_router(candles.router)
|
||||||
|
app.include_router(sells.router)
|
||||||
|
app.include_router(dca.router)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class User(Base):
|
|||||||
is_admin = Column(Boolean, default=False, nullable=False, server_default='0')
|
is_admin = Column(Boolean, default=False, nullable=False, server_default='0')
|
||||||
|
|
||||||
purchases = relationship("Purchase", back_populates="owner", cascade="all, delete")
|
purchases = relationship("Purchase", back_populates="owner", cascade="all, delete")
|
||||||
|
sells = relationship("Sell", back_populates="owner", cascade="all, delete")
|
||||||
|
|
||||||
|
|
||||||
class Purchase(Base):
|
class Purchase(Base):
|
||||||
@@ -27,6 +28,18 @@ class Purchase(Base):
|
|||||||
owner = relationship("User", back_populates="purchases")
|
owner = relationship("User", back_populates="purchases")
|
||||||
|
|
||||||
|
|
||||||
|
class Sell(Base):
|
||||||
|
__tablename__ = "sells"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
btc_amount = Column(Float, nullable=False)
|
||||||
|
price_eur = Column(Float, nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
|
||||||
|
owner = relationship("User", back_populates="sells")
|
||||||
|
|
||||||
|
|
||||||
class OHLCCandle(Base):
|
class OHLCCandle(Base):
|
||||||
__tablename__ = "ohlc_candles"
|
__tablename__ = "ohlc_candles"
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from .. import models
|
from .. import models
|
||||||
from ..dependencies import get_current_user
|
from ..dependencies import get_current_user
|
||||||
|
from ..services.candles import refresh_latest_candles
|
||||||
|
from ..services.btc import get_btc_price_eur
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -14,6 +17,9 @@ def get_candles(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
# Refresh candles on every request (no-op if DB is already current)
|
||||||
|
refresh_latest_candles(db)
|
||||||
|
|
||||||
query = db.query(models.OHLCCandle).order_by(models.OHLCCandle.date.asc())
|
query = db.query(models.OHLCCandle).order_by(models.OHLCCandle.date.asc())
|
||||||
|
|
||||||
if days != "all":
|
if days != "all":
|
||||||
@@ -38,6 +44,26 @@ def get_candles(
|
|||||||
for c in candles_db
|
for c in candles_db
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Patch today's candle with the live price so it tracks intraday movement
|
||||||
|
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
live_price, _ = get_btc_price_eur()
|
||||||
|
if live_price and candles:
|
||||||
|
last = candles[-1]
|
||||||
|
if last["date"] == today:
|
||||||
|
last["close"] = round(live_price, 2)
|
||||||
|
last["high"] = round(max(last["high"], live_price), 2)
|
||||||
|
last["low"] = round(min(last["low"], live_price), 2)
|
||||||
|
elif last["date"] < today:
|
||||||
|
# No candle for today yet — create a synthetic one from live price
|
||||||
|
prev_close = last["close"]
|
||||||
|
candles.append({
|
||||||
|
"date": today,
|
||||||
|
"open": prev_close,
|
||||||
|
"high": round(max(prev_close, live_price), 2),
|
||||||
|
"low": round(min(prev_close, live_price), 2),
|
||||||
|
"close": round(live_price, 2),
|
||||||
|
})
|
||||||
|
|
||||||
purchases_db = db.query(models.Purchase).filter(
|
purchases_db = db.query(models.Purchase).filter(
|
||||||
models.Purchase.user_id == current_user.id
|
models.Purchase.user_id == current_user.id
|
||||||
).all()
|
).all()
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from .. import models
|
||||||
|
from ..dependencies import get_current_user
|
||||||
|
from ..services.btc import get_btc_price_eur
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _first_of_month(year: int, month: int) -> date:
|
||||||
|
return date(year, month, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_month(year: int, month: int) -> tuple[int, int]:
|
||||||
|
if month == 12:
|
||||||
|
return year + 1, 1
|
||||||
|
return year, month + 1
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dca")
|
||||||
|
def get_dca(
|
||||||
|
monthly_amount: float = Query(..., gt=0),
|
||||||
|
start_date: Optional[str] = Query(default=None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
# Determine start date
|
||||||
|
if start_date:
|
||||||
|
try:
|
||||||
|
sim_start = date.fromisoformat(start_date)
|
||||||
|
except ValueError:
|
||||||
|
sim_start = None
|
||||||
|
else:
|
||||||
|
sim_start = None
|
||||||
|
|
||||||
|
if sim_start is None:
|
||||||
|
earliest = (
|
||||||
|
db.query(models.Purchase)
|
||||||
|
.filter(models.Purchase.user_id == current_user.id)
|
||||||
|
.order_by(models.Purchase.created_at.asc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if earliest:
|
||||||
|
sim_start = earliest.created_at.date()
|
||||||
|
else:
|
||||||
|
sim_start = date.today() - timedelta(days=365)
|
||||||
|
|
||||||
|
# Load all candles into a lookup dict {date_str: close_price}
|
||||||
|
candles_db = db.query(models.OHLCCandle).all()
|
||||||
|
price_by_date: dict[str, float] = {c.date: c.close for c in candles_db}
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
current_price, _ = get_btc_price_eur()
|
||||||
|
# Patch today's price in case the candle isn't refreshed yet
|
||||||
|
if current_price:
|
||||||
|
price_by_date[today.isoformat()] = current_price
|
||||||
|
|
||||||
|
# Walk month by month and simulate buys
|
||||||
|
year, month = sim_start.year, sim_start.month
|
||||||
|
end_year, end_month = today.year, today.month
|
||||||
|
|
||||||
|
dca_invested = 0.0
|
||||||
|
dca_btc = 0.0
|
||||||
|
monthly_series = []
|
||||||
|
|
||||||
|
while (year, month) <= (end_year, end_month):
|
||||||
|
# Find the closest available candle on or after the 1st of this month
|
||||||
|
buy_date = None
|
||||||
|
buy_price = None
|
||||||
|
for day_offset in range(8):
|
||||||
|
candidate = _first_of_month(year, month) + timedelta(days=day_offset)
|
||||||
|
key = candidate.isoformat()
|
||||||
|
if key in price_by_date:
|
||||||
|
buy_date = key
|
||||||
|
buy_price = price_by_date[key]
|
||||||
|
break
|
||||||
|
|
||||||
|
if buy_price and buy_price > 0:
|
||||||
|
btc_bought = monthly_amount / buy_price
|
||||||
|
dca_btc += btc_bought
|
||||||
|
dca_invested += monthly_amount
|
||||||
|
monthly_series.append({
|
||||||
|
"month": f"{year:04d}-{month:02d}",
|
||||||
|
"price_used": round(buy_price, 2),
|
||||||
|
"btc_bought": round(btc_bought, 8),
|
||||||
|
"cumulative_btc": round(dca_btc, 8),
|
||||||
|
"cumulative_invested": round(dca_invested, 2),
|
||||||
|
})
|
||||||
|
|
||||||
|
year, month = _next_month(year, month)
|
||||||
|
|
||||||
|
dca_current_value = dca_btc * current_price if current_price else 0.0
|
||||||
|
dca_profit_loss = dca_current_value - dca_invested
|
||||||
|
|
||||||
|
return {
|
||||||
|
"start_date": sim_start.isoformat(),
|
||||||
|
"monthly_amount": monthly_amount,
|
||||||
|
"dca_total_invested": round(dca_invested, 2),
|
||||||
|
"dca_total_btc": round(dca_btc, 8),
|
||||||
|
"dca_current_value": round(dca_current_value, 2),
|
||||||
|
"dca_profit_loss": round(dca_profit_loss, 2),
|
||||||
|
"monthly_series": monthly_series,
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
@@ -14,6 +14,7 @@ router = APIRouter()
|
|||||||
class PurchaseCreate(BaseModel):
|
class PurchaseCreate(BaseModel):
|
||||||
amount_eur: float = Field(gt=0, le=10_000_000)
|
amount_eur: float = Field(gt=0, le=10_000_000)
|
||||||
price_eur: float = Field(gt=0, le=10_000_000)
|
price_eur: float = Field(gt=0, le=10_000_000)
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class PurchaseUpdate(BaseModel):
|
class PurchaseUpdate(BaseModel):
|
||||||
@@ -54,6 +55,7 @@ def add_purchase(
|
|||||||
purchase = models.Purchase(
|
purchase = models.Purchase(
|
||||||
amount_eur=purchase_in.amount_eur,
|
amount_eur=purchase_in.amount_eur,
|
||||||
price_eur=purchase_in.price_eur,
|
price_eur=purchase_in.price_eur,
|
||||||
|
created_at=purchase_in.created_at or datetime.utcnow(),
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
)
|
)
|
||||||
db.add(purchase)
|
db.add(purchase)
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from .. import models
|
||||||
|
from ..dependencies import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class SellCreate(BaseModel):
|
||||||
|
btc_amount: float = Field(gt=0, le=21_000_000)
|
||||||
|
price_eur: float = Field(gt=0, le=10_000_000)
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SellUpdate(BaseModel):
|
||||||
|
btc_amount: float = Field(gt=0, le=21_000_000)
|
||||||
|
price_eur: float = Field(gt=0, le=10_000_000)
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SellOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
btc_amount: float
|
||||||
|
price_eur: float
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sells", response_model=List[SellOut])
|
||||||
|
def list_sells(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
db.query(models.Sell)
|
||||||
|
.filter(models.Sell.user_id == current_user.id)
|
||||||
|
.order_by(models.Sell.created_at)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sells", response_model=SellOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def add_sell(
|
||||||
|
sell_in: SellCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
sell = models.Sell(
|
||||||
|
btc_amount=sell_in.btc_amount,
|
||||||
|
price_eur=sell_in.price_eur,
|
||||||
|
created_at=sell_in.created_at or datetime.utcnow(),
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(sell)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(sell)
|
||||||
|
return sell
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/sells/{sell_id}", response_model=SellOut)
|
||||||
|
def update_sell(
|
||||||
|
sell_id: int,
|
||||||
|
sell_in: SellUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
sell = db.query(models.Sell).filter(
|
||||||
|
models.Sell.id == sell_id,
|
||||||
|
models.Sell.user_id == current_user.id,
|
||||||
|
).first()
|
||||||
|
if not sell:
|
||||||
|
raise HTTPException(status_code=404, detail="Sell not found")
|
||||||
|
sell.btc_amount = sell_in.btc_amount
|
||||||
|
sell.price_eur = sell_in.price_eur
|
||||||
|
sell.created_at = sell_in.created_at
|
||||||
|
db.commit()
|
||||||
|
db.refresh(sell)
|
||||||
|
return sell
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sells/{sell_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_sell(
|
||||||
|
sell_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
sell = db.query(models.Sell).filter(
|
||||||
|
models.Sell.id == sell_id,
|
||||||
|
models.Sell.user_id == current_user.id,
|
||||||
|
).first()
|
||||||
|
if not sell:
|
||||||
|
raise HTTPException(status_code=404, detail="Sell not found")
|
||||||
|
db.delete(sell)
|
||||||
|
db.commit()
|
||||||
@@ -15,19 +15,27 @@ def get_stats(
|
|||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
purchases = db.query(models.Purchase).filter(models.Purchase.user_id == current_user.id).all()
|
purchases = db.query(models.Purchase).filter(models.Purchase.user_id == current_user.id).all()
|
||||||
|
sells = db.query(models.Sell).filter(models.Sell.user_id == current_user.id).all()
|
||||||
|
|
||||||
total_invested = sum(p.amount_eur for p in purchases)
|
total_invested = sum(p.amount_eur for p in purchases)
|
||||||
total_btc = sum(p.amount_eur / p.price_eur for p in purchases) if purchases else 0.0
|
total_btc_bought = sum(p.amount_eur / p.price_eur for p in purchases) if purchases else 0.0
|
||||||
average_price = total_invested / total_btc if total_btc > 0 else 0.0
|
|
||||||
current_price = get_btc_price_eur()
|
total_btc_sold = sum(s.btc_amount for s in sells)
|
||||||
portfolio_value = total_btc * current_price
|
proceeds_eur = sum(s.btc_amount * s.price_eur for s in sells)
|
||||||
profit_loss = portfolio_value - total_invested
|
|
||||||
|
net_btc = total_btc_bought - total_btc_sold
|
||||||
|
net_invested = total_invested - proceeds_eur
|
||||||
|
average_price = net_invested / net_btc if net_btc > 0 else 0.0
|
||||||
|
current_price, price_is_cached = get_btc_price_eur()
|
||||||
|
portfolio_value = net_btc * current_price
|
||||||
|
profit_loss = portfolio_value - net_invested
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_invested": round(total_invested, 2),
|
"total_invested": round(net_invested, 2),
|
||||||
"total_btc": round(total_btc, 8),
|
"total_btc": round(net_btc, 8),
|
||||||
"average_price": round(average_price, 2),
|
"average_price": round(average_price, 2),
|
||||||
"current_price": round(current_price, 2),
|
"current_price": round(current_price, 2),
|
||||||
|
"price_is_cached": price_is_cached,
|
||||||
"portfolio_value": round(portfolio_value, 2),
|
"portfolio_value": round(portfolio_value, 2),
|
||||||
"profit_loss": round(profit_loss, 2),
|
"profit_loss": round(profit_loss, 2),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,12 @@ def aggregate_to_daily(raw: list) -> dict:
|
|||||||
return by_date
|
return by_date
|
||||||
|
|
||||||
|
|
||||||
def get_btc_price_eur() -> float:
|
_last_known_price: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def get_btc_price_eur() -> tuple[float, bool]:
|
||||||
|
"""Returns (price, is_cached). is_cached=True when using a stale fallback."""
|
||||||
|
global _last_known_price
|
||||||
try:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
"https://api.coingecko.com/api/v3/simple/price",
|
"https://api.coingecko.com/api/v3/simple/price",
|
||||||
@@ -62,7 +67,9 @@ def get_btc_price_eur() -> float:
|
|||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return float(resp.json()["bitcoin"]["eur"])
|
price = float(resp.json()["bitcoin"]["eur"])
|
||||||
|
_last_known_price = price
|
||||||
|
return price, False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch BTC price: {e}")
|
logger.error(f"Failed to fetch BTC price: {e}")
|
||||||
return 0.0
|
return _last_known_price, True
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=sqlite:////app/data/btc_portfolio.db
|
- DATABASE_URL=sqlite:////app/data/btc_portfolio.db
|
||||||
- SECRET_KEY=${SECRET_KEY:-dev-insecure-key-change-me}
|
- SECRET_KEY=${SECRET_KEY:-dev-insecure-key-change-me}
|
||||||
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:3001}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"]
|
||||||
@@ -20,14 +21,14 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
args:
|
args:
|
||||||
- REACT_APP_API_URL=http://localhost:8000
|
- REACT_APP_API_URL=/api
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:3001/"]
|
test: ["CMD", "wget", "-qO-", "http://localhost:80/"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -4,16 +4,12 @@ WORKDIR /app
|
|||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG REACT_APP_API_URL=http://localhost:8000
|
ARG REACT_APP_API_URL=/api
|
||||||
ENV REACT_APP_API_URL=$REACT_APP_API_URL
|
ENV REACT_APP_API_URL=$REACT_APP_API_URL
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:18-alpine
|
FROM nginx:alpine
|
||||||
RUN npm install -g serve
|
COPY --from=build /app/build /usr/share/nginx/html
|
||||||
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
WORKDIR /app
|
EXPOSE 80
|
||||||
COPY --from=build /app/build ./build
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
RUN chown -R appuser:appgroup /app
|
|
||||||
USER appuser
|
|
||||||
EXPOSE 3001
|
|
||||||
CMD ["serve", "-s", "build", "-l", "3001"]
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ const styles = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function AddPurchase({ onAdded }) {
|
export default function AddPurchase({ onAdded }) {
|
||||||
|
const [purchaseDate, setPurchaseDate] = useState(new Date().toISOString().split('T')[0]);
|
||||||
const [amountEur, setAmountEur] = useState('');
|
const [amountEur, setAmountEur] = useState('');
|
||||||
const [priceEur, setPriceEur] = useState('');
|
const [priceEur, setPriceEur] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -30,12 +31,14 @@ export default function AddPurchase({ onAdded }) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
amount_eur: parseFloat(amountEur),
|
amount_eur: parseFloat(amountEur),
|
||||||
price_eur: parseFloat(priceEur),
|
price_eur: parseFloat(priceEur),
|
||||||
|
created_at: new Date(purchaseDate + 'T12:00:00').toISOString(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError('Failed to add purchase');
|
setError('Failed to add purchase');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setPurchaseDate(new Date().toISOString().split('T')[0]);
|
||||||
setAmountEur('');
|
setAmountEur('');
|
||||||
setPriceEur('');
|
setPriceEur('');
|
||||||
onAdded();
|
onAdded();
|
||||||
@@ -50,6 +53,13 @@ export default function AddPurchase({ onAdded }) {
|
|||||||
<div style={styles.title}>Add Purchase</div>
|
<div style={styles.title}>Add Purchase</div>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div style={styles.row}>
|
<div style={styles.row}>
|
||||||
|
<input
|
||||||
|
style={styles.input}
|
||||||
|
type="date"
|
||||||
|
value={purchaseDate}
|
||||||
|
onChange={e => setPurchaseDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
card: { background: '#1a1a1a', padding: '1.5rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
|
||||||
|
title: { fontSize: '1.1rem', fontWeight: 700, marginBottom: '1rem', color: '#f7931a' },
|
||||||
|
row: { display: 'flex', gap: '0.75rem', flexWrap: 'wrap' },
|
||||||
|
input: { flex: 1, minWidth: '140px', padding: '0.65rem', background: '#2a2a2a', border: '1px solid #444', borderRadius: '8px', color: '#e0e0e0', fontSize: '1rem' },
|
||||||
|
button: { padding: '0.65rem 1.5rem', background: '#f7931a', color: '#000', border: 'none', borderRadius: '8px', fontWeight: 700, cursor: 'pointer' },
|
||||||
|
error: { color: '#ff6b6b', marginTop: '0.5rem', fontSize: '0.9rem' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AddSell({ onAdded }) {
|
||||||
|
const [sellDate, setSellDate] = useState(new Date().toISOString().split('T')[0]);
|
||||||
|
const [btcAmount, setBtcAmount] = useState('');
|
||||||
|
const [priceEur, setPriceEur] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/sells`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
btc_amount: parseFloat(btcAmount),
|
||||||
|
price_eur: parseFloat(priceEur),
|
||||||
|
created_at: new Date(sellDate + 'T12:00:00').toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setError('Failed to add sell');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSellDate(new Date().toISOString().split('T')[0]);
|
||||||
|
setBtcAmount('');
|
||||||
|
setPriceEur('');
|
||||||
|
onAdded();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('AddSell network error:', err);
|
||||||
|
setError('Network error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.card}>
|
||||||
|
<div style={styles.title}>Add Sell</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div style={styles.row}>
|
||||||
|
<input
|
||||||
|
style={styles.input}
|
||||||
|
type="date"
|
||||||
|
value={sellDate}
|
||||||
|
onChange={e => setSellDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
style={styles.input}
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="BTC Amount"
|
||||||
|
value={btcAmount}
|
||||||
|
onChange={e => setBtcAmount(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
style={styles.input}
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="BTC Price (EUR)"
|
||||||
|
value={priceEur}
|
||||||
|
onChange={e => setPriceEur(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button style={styles.button} type="submit">Add</button>
|
||||||
|
</div>
|
||||||
|
{error && <div style={styles.error}>{error}</div>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ const btnStyle = {
|
|||||||
marginLeft: '0.5rem',
|
marginLeft: '0.5rem',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BTCCandlestickChart({ candles, purchases, stats, fullscreen, onToggleFullscreen }) {
|
export default function BTCCandlestickChart({ candles, purchases, stats, fullscreen, onToggleFullscreen, livePrice }) {
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const chartRef = useRef(null);
|
const chartRef = useRef(null);
|
||||||
const candleSeriesRef = useRef(null);
|
const candleSeriesRef = useRef(null);
|
||||||
@@ -167,7 +167,14 @@ export default function BTCCandlestickChart({ candles, purchases, stats, fullscr
|
|||||||
return (
|
return (
|
||||||
<div style={fullscreen ? fullscreenStyle : cardStyle}>
|
<div style={fullscreen ? fullscreenStyle : cardStyle}>
|
||||||
<div style={headerStyle}>
|
<div style={headerStyle}>
|
||||||
<div style={titleStyle}>BTC Candles (EUR)</div>
|
<div style={titleStyle}>
|
||||||
|
BTC Candles (EUR)
|
||||||
|
{livePrice != null && (
|
||||||
|
<span style={{ fontSize: '0.9rem', fontWeight: 400, color: '#ccc', marginLeft: '0.75rem' }}>
|
||||||
|
€{livePrice.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button style={btnStyle} onClick={handleSave}>Save as PNG</button>
|
<button style={btnStyle} onClick={handleSave}>Save as PNG</button>
|
||||||
<button style={btnStyle} onClick={onToggleFullscreen}>
|
<button style={btnStyle} onClick={onToggleFullscreen}>
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
card: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
|
||||||
|
header: { color: '#888', fontSize: '0.8rem', marginBottom: '0.75rem' },
|
||||||
|
row: { display: 'flex', gap: '1rem', alignItems: 'flex-start', flexWrap: 'wrap' },
|
||||||
|
inputWrap: { display: 'flex', flexDirection: 'column', gap: '0.3rem' },
|
||||||
|
label: { color: '#888', fontSize: '0.8rem' },
|
||||||
|
input: { background: '#111', border: '1px solid #444', borderRadius: '8px', color: '#fff', padding: '0.55rem 0.75rem', fontSize: '1rem', width: '160px', outline: 'none' },
|
||||||
|
results: { display: 'flex', flexDirection: 'column', gap: '0.75rem', flex: 1 },
|
||||||
|
statsRow: { display: 'flex', gap: '1rem', flexWrap: 'wrap' },
|
||||||
|
statCard: { background: '#111', padding: '0.75rem 1rem', borderRadius: '10px', border: '1px solid #2a2a2a', minWidth: '130px' },
|
||||||
|
statLabel: { color: '#888', fontSize: '0.75rem', marginBottom: '0.2rem' },
|
||||||
|
statValue: { fontSize: '1.1rem', fontWeight: 700 },
|
||||||
|
positive: { color: '#6bff8e' },
|
||||||
|
negative: { color: '#ff6b6b' },
|
||||||
|
neutral: { color: '#f7931a' },
|
||||||
|
comparison: { color: '#888', fontSize: '0.8rem' },
|
||||||
|
placeholder: { color: '#555', fontSize: '0.9rem', padding: '0.5rem 0' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function MiniStatCard({ label, value, highlight }) {
|
||||||
|
return (
|
||||||
|
<div style={styles.statCard}>
|
||||||
|
<div style={styles.statLabel}>{label}</div>
|
||||||
|
<div style={{ ...styles.statValue, ...(highlight ? styles[highlight] : {}) }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DCACalculator({ purchases, stats }) {
|
||||||
|
const defaultStart = () => {
|
||||||
|
if (purchases && purchases.length > 0) {
|
||||||
|
const sorted = [...purchases].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||||
|
return sorted[0].created_at.slice(0, 10);
|
||||||
|
}
|
||||||
|
const d = new Date();
|
||||||
|
d.setFullYear(d.getFullYear() - 1);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [monthlyAmount, setMonthlyAmount] = useState('');
|
||||||
|
const [startDate, setStartDate] = useState(defaultStart);
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const debounceRef = useRef(null);
|
||||||
|
|
||||||
|
// Update default start date when purchases load
|
||||||
|
useEffect(() => {
|
||||||
|
if (purchases && purchases.length > 0 && !monthlyAmount) {
|
||||||
|
setStartDate(defaultStart());
|
||||||
|
}
|
||||||
|
}, [purchases]); // eslint-disable-line
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const amount = parseFloat(monthlyAmount);
|
||||||
|
if (!amount || amount <= 0 || !startDate) {
|
||||||
|
setResult(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API}/dca?monthly_amount=${amount}&start_date=${startDate}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }
|
||||||
|
);
|
||||||
|
if (res.ok) setResult(await res.json());
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearTimeout(debounceRef.current);
|
||||||
|
}, [monthlyAmount, startDate]);
|
||||||
|
|
||||||
|
const plHighlight = result
|
||||||
|
? result.dca_profit_loss >= 0 ? 'positive' : 'negative'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const actualPL = stats ? stats.profit_loss : null;
|
||||||
|
const actualHighlight = actualPL != null ? (actualPL >= 0 ? 'positive' : 'negative') : null;
|
||||||
|
|
||||||
|
const fmt = (n) => `€${n.toLocaleString()}`;
|
||||||
|
const fmtPL = (n) => `${n >= 0 ? '+' : ''}${fmt(n)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.card}>
|
||||||
|
<div style={styles.header}>DCA Calculator — What if you had invested monthly?</div>
|
||||||
|
<div style={styles.row}>
|
||||||
|
<div style={styles.inputWrap}>
|
||||||
|
<label style={styles.label}>Monthly amount (€)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="e.g. 200"
|
||||||
|
value={monthlyAmount}
|
||||||
|
onChange={e => setMonthlyAmount(e.target.value)}
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={styles.inputWrap}>
|
||||||
|
<label style={styles.label}>Starting from</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={e => setStartDate(e.target.value)}
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result ? (
|
||||||
|
<div style={styles.results}>
|
||||||
|
<div style={styles.statsRow}>
|
||||||
|
<MiniStatCard label="DCA Invested" value={fmt(result.dca_total_invested)} />
|
||||||
|
<MiniStatCard label="DCA BTC" value={`₿${result.dca_total_btc}`} highlight="neutral" />
|
||||||
|
<MiniStatCard label="DCA Value" value={fmt(result.dca_current_value)} />
|
||||||
|
<MiniStatCard label="DCA P&L" value={fmtPL(result.dca_profit_loss)} highlight={plHighlight} />
|
||||||
|
</div>
|
||||||
|
{actualPL != null && (
|
||||||
|
<div style={styles.comparison}>
|
||||||
|
Actual P&L:
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,12 +42,13 @@ function priceOn(date, priceMap, currentPrice, isToday, sortedPurchases) {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PortfolioChart({ purchases, stats, btcHistory }) {
|
export default function PortfolioChart({ purchases, sells, stats, btcHistory }) {
|
||||||
const chartRef = useRef(null);
|
const chartRef = useRef(null);
|
||||||
|
|
||||||
if (!purchases || purchases.length === 0) return null;
|
if (!purchases || purchases.length === 0) return null;
|
||||||
|
|
||||||
const sorted = [...purchases].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
const sorted = [...purchases].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||||
|
const sortedSells = [...(sells || [])].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
@@ -89,6 +90,14 @@ export default function PortfolioChart({ purchases, stats, btcHistory }) {
|
|||||||
cumInvested += p.amount_eur;
|
cumInvested += p.amount_eur;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
sortedSells.forEach(s => {
|
||||||
|
const sDate = new Date(s.created_at);
|
||||||
|
sDate.setHours(0, 0, 0, 0);
|
||||||
|
if (sDate <= date) {
|
||||||
|
cumBtc -= s.btc_amount;
|
||||||
|
cumInvested -= s.btc_amount * s.price_eur;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (cumBtc === 0) return; // no purchases yet at this date
|
if (cumBtc === 0) return; // no purchases yet at this date
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
card: { background: '#1a1a1a', padding: '1.5rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
|
||||||
|
title: { fontSize: '1.1rem', fontWeight: 700, marginBottom: '1rem', color: '#f7931a' },
|
||||||
|
table: { width: '100%', borderCollapse: 'collapse' },
|
||||||
|
th: { textAlign: 'left', padding: '0.5rem 0.75rem', borderBottom: '1px solid #333', color: '#888', fontSize: '0.85rem' },
|
||||||
|
td: { padding: '0.6rem 0.75rem', borderBottom: '1px solid #222', fontSize: '0.95rem' },
|
||||||
|
input: { background: '#2a2a2a', border: '1px solid #555', borderRadius: '6px', color: '#e0e0e0', padding: '0.3rem 0.5rem', fontSize: '0.9rem', width: '100%' },
|
||||||
|
editBtn: { background: 'none', border: '1px solid #555', color: '#4fc3f7', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem', marginRight: '0.4rem' },
|
||||||
|
saveBtn: { background: 'none', border: '1px solid #6bff8e', color: '#6bff8e', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem', marginRight: '0.4rem' },
|
||||||
|
cancelBtn: { background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem', marginRight: '0.4rem' },
|
||||||
|
deleteBtn: { background: 'none', border: '1px solid #555', color: '#ff6b6b', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem' },
|
||||||
|
empty: { color: '#555', textAlign: 'center', padding: '1rem' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function toDateInputValue(isoString) {
|
||||||
|
return isoString ? isoString.slice(0, 10) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SellList({ sells, onChanged }) {
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [editForm, setEditForm] = useState({});
|
||||||
|
|
||||||
|
const token = () => localStorage.getItem('token');
|
||||||
|
|
||||||
|
const startEdit = (s) => {
|
||||||
|
setEditingId(s.id);
|
||||||
|
setEditForm({
|
||||||
|
btc_amount: s.btc_amount,
|
||||||
|
price_eur: s.price_eur,
|
||||||
|
created_at: toDateInputValue(s.created_at),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => setEditingId(null);
|
||||||
|
|
||||||
|
const handleSave = async (id) => {
|
||||||
|
const res = await fetch(`${API}/sells/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token()}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
btc_amount: parseFloat(editForm.btc_amount),
|
||||||
|
price_eur: parseFloat(editForm.price_eur),
|
||||||
|
created_at: new Date(editForm.created_at + 'T12:00:00').toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setEditingId(null);
|
||||||
|
onChanged();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
await fetch(`${API}/sells/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token()}` },
|
||||||
|
});
|
||||||
|
onChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
const set = (field) => (e) => setEditForm(f => ({ ...f, [field]: e.target.value }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.card}>
|
||||||
|
<div style={styles.title}>Sells</div>
|
||||||
|
{sells.length === 0 ? (
|
||||||
|
<div style={styles.empty}>No sells yet.</div>
|
||||||
|
) : (
|
||||||
|
<table style={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={styles.th}>Date</th>
|
||||||
|
<th style={styles.th}>BTC Amount</th>
|
||||||
|
<th style={styles.th}>Price (€/BTC)</th>
|
||||||
|
<th style={styles.th}>Value (€)</th>
|
||||||
|
<th style={styles.th}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sells.map(s => editingId === s.id ? (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td style={styles.td}>
|
||||||
|
<input style={styles.input} type="date" value={editForm.created_at} onChange={set('created_at')} />
|
||||||
|
</td>
|
||||||
|
<td style={styles.td}>
|
||||||
|
<input style={styles.input} type="number" step="any" value={editForm.btc_amount} onChange={set('btc_amount')} />
|
||||||
|
</td>
|
||||||
|
<td style={styles.td}>
|
||||||
|
<input style={styles.input} type="number" step="any" value={editForm.price_eur} onChange={set('price_eur')} />
|
||||||
|
</td>
|
||||||
|
<td style={styles.td}>
|
||||||
|
€{(parseFloat(editForm.btc_amount) * parseFloat(editForm.price_eur) || 0).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td style={styles.td}>
|
||||||
|
<button style={styles.saveBtn} onClick={() => handleSave(s.id)}>Save</button>
|
||||||
|
<button style={styles.cancelBtn} onClick={cancelEdit}>Cancel</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td style={styles.td}>{new Date(s.created_at).toLocaleDateString('en-GB')}</td>
|
||||||
|
<td style={styles.td}>₿{s.btc_amount.toFixed(8)}</td>
|
||||||
|
<td style={styles.td}>€{s.price_eur.toLocaleString()}</td>
|
||||||
|
<td style={styles.td}>€{(s.btc_amount * s.price_eur).toLocaleString()}</td>
|
||||||
|
<td style={styles.td}>
|
||||||
|
<button style={styles.editBtn} onClick={() => startEdit(s)}>Edit</button>
|
||||||
|
<button style={styles.deleteBtn} onClick={() => handleDelete(s.id)}>Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@ import React, { useState, useEffect, useCallback } from 'react';
|
|||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import AddPurchase from '../components/AddPurchase';
|
import AddPurchase from '../components/AddPurchase';
|
||||||
import PurchaseList from '../components/PurchaseList';
|
import PurchaseList from '../components/PurchaseList';
|
||||||
|
import AddSell from '../components/AddSell';
|
||||||
|
import SellList from '../components/SellList';
|
||||||
import PortfolioChart from '../components/PortfolioChart';
|
import PortfolioChart from '../components/PortfolioChart';
|
||||||
import BTCCandlestickChart from '../components/BTCCandlestickChart';
|
import BTCCandlestickChart from '../components/BTCCandlestickChart';
|
||||||
|
import DCACalculator from '../components/DCACalculator';
|
||||||
|
|
||||||
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
@@ -15,6 +18,13 @@ const styles = {
|
|||||||
adminBtn: { background: 'none', border: '1px solid #f7931a', color: '#f7931a', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer', textDecoration: 'none', fontSize: '1rem' },
|
adminBtn: { background: 'none', border: '1px solid #f7931a', color: '#f7931a', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer', textDecoration: 'none', fontSize: '1rem' },
|
||||||
headerBtns: { display: 'flex', gap: '0.5rem', alignItems: 'center' },
|
headerBtns: { display: 'flex', gap: '0.5rem', alignItems: 'center' },
|
||||||
statsGrid: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', marginBottom: '1.5rem' },
|
statsGrid: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', marginBottom: '1.5rem' },
|
||||||
|
predictionSection: { background: '#1a1a1a', border: '1px solid #333', borderRadius: '12px', padding: '1rem', marginBottom: '1.5rem' },
|
||||||
|
predictionHeader: { color: '#888', fontSize: '0.8rem', marginBottom: '0.75rem' },
|
||||||
|
predictionRow: { display: 'flex', gap: '1rem', alignItems: 'flex-start', flexWrap: 'wrap' },
|
||||||
|
predictionInputWrap: { display: 'flex', flexDirection: 'column', gap: '0.3rem' },
|
||||||
|
predictionLabel: { color: '#888', fontSize: '0.8rem' },
|
||||||
|
predictionInput: { background: '#111', border: '1px solid #444', borderRadius: '8px', color: '#fff', padding: '0.55rem 0.75rem', fontSize: '1rem', width: '160px', outline: 'none' },
|
||||||
|
predictionCards: { display: 'flex', gap: '1rem', flex: 1, flexWrap: 'wrap' },
|
||||||
statCard: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333' },
|
statCard: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333' },
|
||||||
statLabel: { color: '#888', fontSize: '0.8rem', marginBottom: '0.3rem' },
|
statLabel: { color: '#888', fontSize: '0.8rem', marginBottom: '0.3rem' },
|
||||||
statValue: { fontSize: '1.2rem', fontWeight: 700 },
|
statValue: { fontSize: '1.2rem', fontWeight: 700 },
|
||||||
@@ -26,11 +36,19 @@ const styles = {
|
|||||||
tabActive: { padding: '0.45rem 1.1rem', borderRadius: '8px', border: '1px solid #f7931a', background: 'rgba(247,147,26,0.1)', color: '#f7931a', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 700 },
|
tabActive: { padding: '0.45rem 1.1rem', borderRadius: '8px', border: '1px solid #f7931a', background: 'rgba(247,147,26,0.1)', color: '#f7931a', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 700 },
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatCard({ label, value, highlight }) {
|
function StatCard({ label, value, highlight, warning }) {
|
||||||
return (
|
return (
|
||||||
<div style={styles.statCard}>
|
<div style={styles.statCard}>
|
||||||
<div style={styles.statLabel}>{label}</div>
|
<div style={{ ...styles.statLabel, display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
|
||||||
|
{label}
|
||||||
|
{warning && (
|
||||||
|
<span title={warning} style={{ color: '#f7931a', cursor: 'default', fontSize: '0.85rem' }}>⚠</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div style={{ ...styles.statValue, ...(highlight ? styles[highlight] : {}) }}>{value}</div>
|
<div style={{ ...styles.statValue, ...(highlight ? styles[highlight] : {}) }}>{value}</div>
|
||||||
|
{warning && (
|
||||||
|
<div style={{ color: '#888', fontSize: '0.7rem', marginTop: '0.25rem' }}>{warning}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -38,10 +56,12 @@ function StatCard({ label, value, highlight }) {
|
|||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [stats, setStats] = useState(null);
|
const [stats, setStats] = useState(null);
|
||||||
const [purchases, setPurchases] = useState([]);
|
const [purchases, setPurchases] = useState([]);
|
||||||
|
const [sells, setSells] = useState([]);
|
||||||
const [candles, setCandles] = useState(null);
|
const [candles, setCandles] = useState(null);
|
||||||
const [candlesAll, setCandlesAll] = useState(null);
|
const [candlesAll, setCandlesAll] = useState(null);
|
||||||
const [fullscreenChart, setFullscreenChart] = useState(false);
|
const [fullscreenChart, setFullscreenChart] = useState(false);
|
||||||
const [chartView, setChartView] = useState('both'); // 'both' | 'portfolio' | 'history'
|
const [chartView, setChartView] = useState('both'); // 'both' | 'portfolio' | 'history'
|
||||||
|
const [predictionPrice, setPredictionPrice] = useState('');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const authHeaders = () => ({
|
const authHeaders = () => ({
|
||||||
@@ -50,9 +70,10 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [statsRes, purchasesRes, candlesRes] = await Promise.all([
|
const [statsRes, purchasesRes, sellsRes, candlesRes] = await Promise.all([
|
||||||
fetch(`${API}/stats`, { headers: authHeaders() }),
|
fetch(`${API}/stats`, { headers: authHeaders() }),
|
||||||
fetch(`${API}/purchases`, { headers: authHeaders() }),
|
fetch(`${API}/purchases`, { headers: authHeaders() }),
|
||||||
|
fetch(`${API}/sells`, { headers: authHeaders() }),
|
||||||
fetch(`${API}/candles?days=365`, { headers: authHeaders() }),
|
fetch(`${API}/candles?days=365`, { headers: authHeaders() }),
|
||||||
]);
|
]);
|
||||||
if (statsRes.status === 401) {
|
if (statsRes.status === 401) {
|
||||||
@@ -62,6 +83,7 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
setStats(await statsRes.json());
|
setStats(await statsRes.json());
|
||||||
setPurchases(await purchasesRes.json());
|
setPurchases(await purchasesRes.json());
|
||||||
|
setSells(await sellsRes.json());
|
||||||
setCandles(await candlesRes.json());
|
setCandles(await candlesRes.json());
|
||||||
} catch {
|
} catch {
|
||||||
// silently fail — network may be unavailable
|
// silently fail — network may be unavailable
|
||||||
@@ -94,6 +116,12 @@ export default function Dashboard() {
|
|||||||
setFullscreenChart(f => !f);
|
setFullscreenChart(f => !f);
|
||||||
}, [fullscreenChart, candlesAll, fetchAllCandles]);
|
}, [fullscreenChart, candlesAll, fetchAllCandles]);
|
||||||
|
|
||||||
|
const predPrice = parseFloat(predictionPrice);
|
||||||
|
const predValid = stats && predictionPrice !== '' && predPrice > 0;
|
||||||
|
const predValue = predValid ? +(stats.total_btc * predPrice).toFixed(2) : null;
|
||||||
|
const predPL = predValid ? +(predValue - stats.total_invested).toFixed(2) : null;
|
||||||
|
const predVsCurrent = predValid ? +(predValue - stats.portfolio_value).toFixed(2) : null;
|
||||||
|
|
||||||
const plHighlight = stats
|
const plHighlight = stats
|
||||||
? stats.profit_loss >= 0 ? 'positive' : 'negative'
|
? stats.profit_loss >= 0 ? 'positive' : 'negative'
|
||||||
: 'neutral';
|
: 'neutral';
|
||||||
@@ -116,7 +144,11 @@ export default function Dashboard() {
|
|||||||
<StatCard label="Total Invested" value={`€${stats.total_invested.toLocaleString()}`} />
|
<StatCard label="Total Invested" value={`€${stats.total_invested.toLocaleString()}`} />
|
||||||
<StatCard label="Avg Buy Price" value={`€${stats.average_price.toLocaleString()}`} />
|
<StatCard label="Avg Buy Price" value={`€${stats.average_price.toLocaleString()}`} />
|
||||||
<StatCard label="Total BTC" value={`₿${stats.total_btc}`} highlight="neutral" />
|
<StatCard label="Total BTC" value={`₿${stats.total_btc}`} highlight="neutral" />
|
||||||
<StatCard label="Current BTC Price" value={`€${stats.current_price.toLocaleString()}`} />
|
<StatCard
|
||||||
|
label="Current BTC Price"
|
||||||
|
value={`€${stats.current_price.toLocaleString()}`}
|
||||||
|
warning={stats.price_is_cached ? 'Price may be outdated — live fetch failed' : undefined}
|
||||||
|
/>
|
||||||
<StatCard label="Portfolio Value" value={`€${stats.portfolio_value.toLocaleString()}`} />
|
<StatCard label="Portfolio Value" value={`€${stats.portfolio_value.toLocaleString()}`} />
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Profit / Loss"
|
label="Profit / Loss"
|
||||||
@@ -126,6 +158,44 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div style={styles.predictionSection}>
|
||||||
|
<div style={styles.predictionHeader}>Price Prediction</div>
|
||||||
|
<div style={styles.predictionRow}>
|
||||||
|
<div style={styles.predictionInputWrap}>
|
||||||
|
<label style={styles.predictionLabel}>BTC Price (€)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="e.g. 100000"
|
||||||
|
value={predictionPrice}
|
||||||
|
onChange={e => setPredictionPrice(e.target.value)}
|
||||||
|
style={styles.predictionInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{predValid && (
|
||||||
|
<div style={styles.predictionCards}>
|
||||||
|
<StatCard
|
||||||
|
label="Predicted Value"
|
||||||
|
value={`€${predValue.toLocaleString()}`}
|
||||||
|
highlight="neutral"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Predicted P&L"
|
||||||
|
value={`${predPL >= 0 ? '+' : ''}€${predPL.toLocaleString()}`}
|
||||||
|
highlight={predPL >= 0 ? 'positive' : 'negative'}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="vs. Current"
|
||||||
|
value={`${predVsCurrent >= 0 ? '+' : ''}€${predVsCurrent.toLocaleString()}`}
|
||||||
|
highlight={predVsCurrent >= 0 ? 'positive' : 'negative'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DCACalculator purchases={purchases} stats={stats} />
|
||||||
|
|
||||||
<div style={styles.tabs}>
|
<div style={styles.tabs}>
|
||||||
{[['both', 'Both'], ['portfolio', 'Portfolio'], ['history', 'BTC Candles']].map(([key, label]) => (
|
{[['both', 'Both'], ['portfolio', 'Portfolio'], ['history', 'BTC Candles']].map(([key, label]) => (
|
||||||
<button
|
<button
|
||||||
@@ -135,18 +205,21 @@ export default function Dashboard() {
|
|||||||
>{label}</button>
|
>{label}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} stats={stats} btcHistory={candles?.candles ?? []} />}
|
{(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} sells={sells} stats={stats} btcHistory={candles?.candles ?? []} />}
|
||||||
{(chartView === 'both' || chartView === 'history') && (
|
{(chartView === 'both' || chartView === 'history') && (
|
||||||
<BTCCandlestickChart
|
<BTCCandlestickChart
|
||||||
candles={activeCandles?.candles ?? null}
|
candles={activeCandles?.candles ?? null}
|
||||||
purchases={activeCandles?.purchases ?? purchases}
|
purchases={activeCandles?.purchases ?? purchases}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
|
livePrice={stats?.current_price ?? null}
|
||||||
fullscreen={fullscreenChart}
|
fullscreen={fullscreenChart}
|
||||||
onToggleFullscreen={handleToggleFullscreen}
|
onToggleFullscreen={handleToggleFullscreen}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<AddPurchase onAdded={fetchData} />
|
<AddPurchase onAdded={fetchData} />
|
||||||
<PurchaseList purchases={purchases} onChanged={fetchData} />
|
<PurchaseList purchases={purchases} onChanged={fetchData} />
|
||||||
|
<AddSell onAdded={fetchData} />
|
||||||
|
<SellList sells={sells} onChanged={fetchData} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user