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:
commit
61a7884ee5
127 changed files with 13323 additions and 0 deletions
199
backend/app/api/v1/investments.py
Normal file
199
backend/app/api/v1/investments.py
Normal 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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue