Add DCA Calculator feature to dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user