Compare commits
9 Commits
development
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 15358c05c3 | |||
| 9c0db31580 | |||
| ce9547a623 | |||
| 33656c4512 | |||
| 4616accc63 | |||
| befbe12bcf | |||
| aedc6a8a17 | |||
| db9624822b | |||
| 470dd80ed8 |
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
|
||||
from .database import engine, Base, SessionLocal
|
||||
from .routes import users, purchases, stats, history, admin, candles, sells, dca
|
||||
from .routes import users, purchases, stats, history, admin, candles, sells
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
@@ -32,7 +32,6 @@ 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")
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import AddSell from '../components/AddSell';
|
||||
import SellList from '../components/SellList';
|
||||
import PortfolioChart from '../components/PortfolioChart';
|
||||
import BTCCandlestickChart from '../components/BTCCandlestickChart';
|
||||
import DCACalculator from '../components/DCACalculator';
|
||||
|
||||
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
@@ -194,8 +193,6 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DCACalculator purchases={purchases} stats={stats} />
|
||||
|
||||
<div style={styles.tabs}>
|
||||
{[['both', 'Both'], ['portfolio', 'Portfolio'], ['history', 'BTC Candles']].map(([key, label]) => (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user