MyMidas/backend/app/api/v1/investments.py
megaproxy fe4e69b9ad Complete Phase 3, Phase 5 polish and hardening
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>
2026-04-22 14:59:11 +00:00

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,
)