Add purchase date picker and sells feature

- Purchase form now includes a date picker (defaults to today)
- New Sell model, CRUD endpoints (/sells), and stats integration
- AddSell and SellList components added to dashboard
- Portfolio chart updated to reflect sells over time

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 19:52:24 +02:00
parent 5cf3726f59
commit 5bb67d6663
10 changed files with 367 additions and 11 deletions
+2 -1
View File
@@ -3,7 +3,7 @@ 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
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@@ -23,6 +23,7 @@ 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.on_event("startup") @app.on_event("startup")
+13
View File
@@ -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,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)
+101
View File
@@ -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()
+13 -6
View File
@@ -15,17 +15,24 @@ 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
total_btc_sold = sum(s.btc_amount for s in sells)
proceeds_eur = sum(s.btc_amount * s.price_eur for s in sells)
net_btc = total_btc_bought - total_btc_sold
net_invested = total_invested - proceeds_eur
average_price = net_invested / net_btc if net_btc > 0 else 0.0
current_price = get_btc_price_eur() current_price = get_btc_price_eur()
portfolio_value = total_btc * current_price portfolio_value = net_btc * current_price
profit_loss = portfolio_value - total_invested 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),
"portfolio_value": round(portfolio_value, 2), "portfolio_value": round(portfolio_value, 2),
@@ -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>
);
}
@@ -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,6 +2,8 @@ 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';
@@ -38,6 +40,7 @@ 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);
@@ -50,9 +53,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 +66,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
@@ -135,7 +140,7 @@ 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}
@@ -147,6 +152,8 @@ export default function Dashboard() {
)} )}
<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>
); );
} }