Phase 3 — Investments: - Multi-currency support: holdings track purchase currency, FX rates convert to base for totals - Capital gains report using UK Section 104 pool method, grouped by tax year - Capital Gains tab added to Reports page Phase 5 — Polish & Hardening: - Mobile-responsive layout: bottom nav, sidebar hidden on mobile, logo in TopBar, compact header buttons, hover-only actions now always visible on touch - Backup system: encrypted GPG backups via backup.sh, nightly scheduler job, admin API (list/trigger/download/restore), Settings UI with drag-to-restore confirmation - Docker entrypoint with gosu privilege drop to fix bind-mount ownership on fresh deployments - OWASP fixes: refresh token now bound to its session (new refresh_token_hash column + migration), CSRF secure flag tied to environment, IP-level rate limiting on login, TOTPEnableRequest Pydantic schema replaces raw dict - AES-256-GCM key rotation script (rotate_keys.py) with dry-run mode and atomic DB transaction - CLAUDE.md added for AI-assisted development context - README updated: correct reverse proxy port, accurate backup/restore commands, key rotation instructions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
233 lines
8.9 KiB
Python
233 lines
8.9 KiB
Python
import uuid
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
|
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
|
|
from sqlalchemy import select, delete as sa_delete
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.dependencies import get_current_user, get_db
|
|
from app.db.models.user import User
|
|
from app.schemas.investment import (
|
|
AssetSearch,
|
|
AssetPricePoint,
|
|
CapitalGainsReport,
|
|
HoldingCreate,
|
|
HoldingResponse,
|
|
InvestmentTxnCreate,
|
|
InvestmentTxnResponse,
|
|
PerformanceMetrics,
|
|
PortfolioSummary,
|
|
)
|
|
from app.services import investment_service
|
|
from app.services.price_feed_service import search_yahoo, fetch_history
|
|
|
|
router = APIRouter(tags=["investments"])
|
|
|
|
|
|
# ── Portfolio ──────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/investments/portfolio", response_model=PortfolioSummary)
|
|
async def get_portfolio(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
return await investment_service.get_portfolio(db, current_user.id, current_user.base_currency)
|
|
|
|
|
|
@router.get("/investments/performance", response_model=PerformanceMetrics)
|
|
async def get_performance(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
return await investment_service.get_performance(db, current_user.id, current_user.base_currency)
|
|
|
|
|
|
@router.get("/investments/capital-gains", response_model=CapitalGainsReport)
|
|
async def get_capital_gains(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
return await investment_service.get_capital_gains(db, current_user.id, current_user.base_currency)
|
|
|
|
|
|
# ── Holdings ───────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/investments/holdings", response_model=HoldingResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_holding(
|
|
data: HoldingCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
from app.db.models.asset import Asset
|
|
from sqlalchemy import select
|
|
asset_result = await db.execute(select(Asset).where(Asset.id == data.asset_id))
|
|
asset = asset_result.scalar_one_or_none()
|
|
if not asset:
|
|
raise HTTPException(status_code=404, detail="Asset not found")
|
|
|
|
holding = await investment_service.create_holding(db, current_user.id, data)
|
|
await db.commit()
|
|
await db.refresh(holding)
|
|
return investment_service._holding_to_response(holding, asset)
|
|
|
|
|
|
@router.patch("/investments/holdings/{holding_id}", response_model=HoldingResponse)
|
|
async def update_holding(
|
|
holding_id: uuid.UUID,
|
|
quantity: Decimal = Body(...),
|
|
avg_cost_basis: Decimal = Body(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
holding = await investment_service.get_holding(db, current_user.id, holding_id)
|
|
if not holding:
|
|
raise HTTPException(status_code=404, detail="Holding not found")
|
|
holding.quantity = quantity
|
|
holding.avg_cost_basis = avg_cost_basis
|
|
await db.commit()
|
|
await db.refresh(holding)
|
|
from app.db.models.asset import Asset
|
|
result = await db.execute(select(Asset).where(Asset.id == holding.asset_id))
|
|
asset = result.scalar_one_or_none()
|
|
return investment_service._holding_to_response(holding, asset)
|
|
|
|
|
|
@router.delete("/investments/holdings/{holding_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_holding(
|
|
holding_id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
holding = await investment_service.get_holding(db, current_user.id, holding_id)
|
|
if not holding:
|
|
raise HTTPException(status_code=404, detail="Holding not found")
|
|
from app.db.models.investment_transaction import InvestmentTransaction
|
|
await db.execute(sa_delete(InvestmentTransaction).where(InvestmentTransaction.holding_id == holding_id))
|
|
await db.delete(holding)
|
|
await db.commit()
|
|
|
|
|
|
# ── Investment transactions ────────────────────────────────────────────────
|
|
|
|
@router.get("/investments/holdings/{holding_id}/transactions", response_model=list[InvestmentTxnResponse])
|
|
async def list_transactions(
|
|
holding_id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
return await investment_service.list_investment_transactions(db, current_user.id, holding_id)
|
|
|
|
|
|
@router.post("/investments/transactions", response_model=InvestmentTxnResponse, status_code=status.HTTP_201_CREATED)
|
|
async def add_transaction(
|
|
data: InvestmentTxnCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
try:
|
|
txn = await investment_service.add_investment_transaction(db, current_user.id, data)
|
|
await db.commit()
|
|
return txn
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
|
|
# ── Assets ─────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/assets/search", response_model=list[AssetSearch])
|
|
async def search_assets(
|
|
q: str = Query(..., min_length=1),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
# First search the local DB
|
|
local = await investment_service.search_assets(db, q)
|
|
if local:
|
|
from app.db.models.asset import Asset
|
|
return [AssetSearch(
|
|
id=a.id, symbol=a.symbol, name=a.name, type=a.type,
|
|
currency=a.currency, exchange=a.exchange,
|
|
last_price=a.last_price, price_change_24h=a.price_change_24h,
|
|
data_source=a.data_source,
|
|
) for a in local]
|
|
|
|
# Fall back to live Yahoo search
|
|
import asyncio
|
|
loop = asyncio.get_event_loop()
|
|
results = await loop.run_in_executor(None, search_yahoo, q)
|
|
if not results:
|
|
return []
|
|
|
|
# Upsert into DB so future searches are local
|
|
created = []
|
|
for r in results:
|
|
asset = await investment_service.get_or_create_asset(
|
|
db, r["symbol"], r["name"], r["type"],
|
|
r["currency"], r["data_source"], r.get("data_source_id"),
|
|
r.get("exchange"),
|
|
)
|
|
created.append(asset)
|
|
await db.commit()
|
|
|
|
return [AssetSearch(
|
|
id=a.id, symbol=a.symbol, name=a.name, type=a.type,
|
|
currency=a.currency, exchange=a.exchange,
|
|
last_price=a.last_price, price_change_24h=a.price_change_24h,
|
|
data_source=a.data_source,
|
|
) for a in created]
|
|
|
|
|
|
@router.get("/assets/{asset_id}/prices", response_model=list[AssetPricePoint])
|
|
async def get_price_history(
|
|
asset_id: uuid.UUID,
|
|
days: int = Query(default=365, ge=7, le=1825),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
from app.db.models.asset import Asset
|
|
from sqlalchemy import select
|
|
asset_result = await db.execute(select(Asset).where(Asset.id == asset_id))
|
|
asset = asset_result.scalar_one_or_none()
|
|
if not asset:
|
|
raise HTTPException(status_code=404, detail="Asset not found")
|
|
|
|
# Fetch from DB; if sparse, refresh from Yahoo
|
|
prices = await investment_service.get_price_history(db, asset_id, days)
|
|
if len(prices) < 5 and asset.data_source == "yahoo_finance":
|
|
rows = await fetch_history(asset.symbol, days)
|
|
if rows:
|
|
await investment_service.upsert_price_history(db, asset_id, rows)
|
|
await db.commit()
|
|
prices = await investment_service.get_price_history(db, asset_id, days)
|
|
|
|
return [
|
|
AssetPricePoint(
|
|
date=p.date, open=p.open, high=p.high, low=p.low,
|
|
close=p.close, volume=p.volume,
|
|
)
|
|
for p in prices
|
|
]
|
|
|
|
|
|
@router.post("/assets", response_model=AssetSearch, status_code=status.HTTP_201_CREATED)
|
|
async def create_asset(
|
|
symbol: str,
|
|
name: str,
|
|
asset_type: str = "stock",
|
|
currency: str = "GBP",
|
|
data_source: str = "yahoo_finance",
|
|
data_source_id: str | None = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
asset = await investment_service.get_or_create_asset(
|
|
db, symbol, name, asset_type, currency, data_source, data_source_id
|
|
)
|
|
await db.commit()
|
|
return AssetSearch(
|
|
id=asset.id, symbol=asset.symbol, name=asset.name, type=asset.type,
|
|
currency=asset.currency, exchange=asset.exchange,
|
|
last_price=asset.last_price, price_change_24h=asset.price_change_24h,
|
|
data_source=asset.data_source,
|
|
)
|