Initial commit: MyMidas personal finance tracker

Full-stack self-hosted finance app with FastAPI backend and React frontend.

Features:
- Accounts, transactions, budgets, investments with GBP base currency
- CSV import with auto-detection for 10 UK bank formats
- ML predictions: spending forecast, net worth projection, Monte Carlo
- 7 selectable themes (Obsidian, Arctic, Midnight, Vault, Terminal, Synthwave, Ledger)
- Receipt/document attachments on transactions (JPEG, PNG, WebP, PDF)
- AES-256-GCM field encryption, RS256 JWT, TOTP 2FA, RLS, audit log
- Encrypted nightly backups + key rotation script
- Mobile-responsive layout with bottom nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-21 11:56:10 +00:00
commit 61a7884ee5
127 changed files with 13323 additions and 0 deletions

View file

@ -0,0 +1,199 @@
import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
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,
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)
@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)
# ── 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.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")
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,
)