Spaces:
Running
Running
feat: add funds module, scheduler, and refactor portfolio/stocks/users
Browse files- Add complete funds router: scrapers for iTrust, UTT, Orbit, models, routes, fund_data.json static info
- Add App/scheduler.py for background task scheduling
- Refactor portfolio routes, service, schemas, models, utils
- Refactor stocks routes, service, utils
- Refactor users routes, models, schemas, utils
- Update bonds routes and utils
- Update main.py, db.py, App/schemas.py for new modules
- Update tasks routes and test_users
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- App/routers/bonds/routes.py +25 -21
- App/routers/bonds/utils.py +0 -1
- App/routers/funds/__init__.py +0 -0
- App/routers/funds/base_scraper.py +151 -0
- App/routers/funds/fund_data.json +711 -0
- App/routers/funds/managers/__init__.py +0 -0
- App/routers/funds/managers/itrust/__init__.py +0 -0
- App/routers/funds/managers/itrust/scraper.py +107 -0
- App/routers/funds/managers/orbit/__init__.py +0 -0
- App/routers/funds/managers/orbit/scraper.py +202 -0
- App/routers/funds/managers/utt/__init__.py +0 -0
- App/routers/funds/managers/utt/scraper.py +223 -0
- App/routers/funds/models.py +54 -0
- App/routers/funds/routes.py +436 -0
- App/routers/funds/runner.py +180 -0
- App/routers/portfolio/models.py +129 -112
- App/routers/portfolio/routes.py +670 -1142
- App/routers/portfolio/schemas.py +180 -317
- App/routers/portfolio/service.py +544 -705
- App/routers/portfolio/utils.py +31 -31
- App/routers/stocks/routes.py +79 -38
- App/routers/stocks/service.py +14 -7
- App/routers/stocks/utils.py +26 -6
- App/routers/tasks/routes.py +8 -6
- App/routers/users/models.py +10 -26
- App/routers/users/routes.py +99 -63
- App/routers/users/schemas.py +38 -10
- App/routers/users/utils.py +30 -6
- App/routers/utt/routes.py +67 -31
- App/scheduler.py +160 -0
- App/schemas.py +6 -15
- db.py +16 -21
- main.py +26 -5
- tests/test_users.py +1 -1
App/routers/bonds/routes.py
CHANGED
|
@@ -1,19 +1,23 @@
|
|
| 1 |
-
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
| 2 |
from tortoise.contrib.pydantic.creator import pydantic_queryset_creator
|
| 3 |
from tortoise.transactions import in_transaction
|
| 4 |
-
from
|
| 5 |
-
from App.routers.tasks.models import ImportTask
|
| 6 |
-
from
|
| 7 |
-
from
|
| 8 |
-
from App.schemas import ResponseModel
|
|
|
|
| 9 |
from typing import List
|
| 10 |
|
| 11 |
|
| 12 |
router = APIRouter(prefix="/bonds", tags=["Bonds"])
|
| 13 |
|
| 14 |
-
# --- CRUD for Bond
|
| 15 |
-
@router.post("
|
| 16 |
-
async def create_bond_entry(
|
|
|
|
|
|
|
|
|
|
| 17 |
# Check for existing bond using ISIN or combination of auction_number, auction_date, holding_number
|
| 18 |
existing_bond = None
|
| 19 |
if payload.isin:
|
|
@@ -23,29 +27,25 @@ async def create_bond_entry(payload: BondCreate):
|
|
| 23 |
existing_bond = await Bond.get_or_none(
|
| 24 |
auction_number=payload.auction_number,
|
| 25 |
auction_date=payload.auction_date,
|
| 26 |
-
holding_number=payload.holding_number
|
| 27 |
)
|
| 28 |
|
| 29 |
if existing_bond:
|
| 30 |
# Update existing bond
|
| 31 |
-
await Bond.filter(id=existing_bond.id).update(**payload.
|
| 32 |
bond = await Bond.get(id=existing_bond.id)
|
| 33 |
message = "Bond updated successfully"
|
| 34 |
else:
|
| 35 |
# Create new bond
|
| 36 |
-
bond = await Bond.create(**payload.
|
| 37 |
message = "Bond created successfully"
|
| 38 |
|
| 39 |
return ResponseModel(success=True, message=message, data=await BondResponse.from_tortoise_orm(bond))
|
| 40 |
|
| 41 |
-
@router.get("
|
| 42 |
async def list_bonds_entries():
|
| 43 |
-
|
| 44 |
_bonds = await Bond.all()
|
| 45 |
-
|
| 46 |
-
bonds= await Bond.get_list(_bonds)
|
| 47 |
-
print(bonds)
|
| 48 |
-
|
| 49 |
return ResponseModel(success=True, message="Bonds retrieved successfully", data={"bonds": bonds})
|
| 50 |
|
| 51 |
# --- Import Task ---
|
|
@@ -113,14 +113,18 @@ async def run_bond_import_task(task_id: int):
|
|
| 113 |
|
| 114 |
|
| 115 |
@router.post("/import-bonds", response_model=ResponseModel)
|
| 116 |
-
async def trigger_bond_import(
|
|
|
|
|
|
|
|
|
|
| 117 |
task = await ImportTask.create(task_type="bond_import", status="pending")
|
| 118 |
background_tasks.add_task(run_bond_import_task, task.id)
|
| 119 |
return ResponseModel(success=True, message="Bond import task started.", data={"task_id": task.id})
|
| 120 |
|
|
|
|
| 121 |
@router.get("/import-status/{task_id}", response_model=ResponseModel)
|
| 122 |
async def get_import_status(task_id: int):
|
| 123 |
task = await ImportTask.get_or_none(id=task_id)
|
| 124 |
if not task:
|
| 125 |
-
raise
|
| 126 |
-
return ResponseModel(success=True, message="Task status retrieved", data=task)
|
|
|
|
| 1 |
+
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
| 2 |
from tortoise.contrib.pydantic.creator import pydantic_queryset_creator
|
| 3 |
from tortoise.transactions import in_transaction
|
| 4 |
+
from .models import Bond
|
| 5 |
+
from App.routers.tasks.models import ImportTask
|
| 6 |
+
from .schemas import BondCreate, BondResponse
|
| 7 |
+
from .utils import BondDataScraper
|
| 8 |
+
from App.schemas import ResponseModel, AppException
|
| 9 |
+
from App.routers.users.utils import get_current_user
|
| 10 |
from typing import List
|
| 11 |
|
| 12 |
|
| 13 |
router = APIRouter(prefix="/bonds", tags=["Bonds"])
|
| 14 |
|
| 15 |
+
# --- CRUD for Bond ---
|
| 16 |
+
@router.post("", response_model=ResponseModel)
|
| 17 |
+
async def create_bond_entry(
|
| 18 |
+
payload: BondCreate,
|
| 19 |
+
current_user=Depends(get_current_user)
|
| 20 |
+
):
|
| 21 |
# Check for existing bond using ISIN or combination of auction_number, auction_date, holding_number
|
| 22 |
existing_bond = None
|
| 23 |
if payload.isin:
|
|
|
|
| 27 |
existing_bond = await Bond.get_or_none(
|
| 28 |
auction_number=payload.auction_number,
|
| 29 |
auction_date=payload.auction_date,
|
| 30 |
+
holding_number=payload.holding_number
|
| 31 |
)
|
| 32 |
|
| 33 |
if existing_bond:
|
| 34 |
# Update existing bond
|
| 35 |
+
await Bond.filter(id=existing_bond.id).update(**payload.model_dump(exclude_unset=True))
|
| 36 |
bond = await Bond.get(id=existing_bond.id)
|
| 37 |
message = "Bond updated successfully"
|
| 38 |
else:
|
| 39 |
# Create new bond
|
| 40 |
+
bond = await Bond.create(**payload.model_dump())
|
| 41 |
message = "Bond created successfully"
|
| 42 |
|
| 43 |
return ResponseModel(success=True, message=message, data=await BondResponse.from_tortoise_orm(bond))
|
| 44 |
|
| 45 |
+
@router.get("", response_model=ResponseModel)
|
| 46 |
async def list_bonds_entries():
|
|
|
|
| 47 |
_bonds = await Bond.all()
|
| 48 |
+
bonds = await Bond.get_list(_bonds)
|
|
|
|
|
|
|
|
|
|
| 49 |
return ResponseModel(success=True, message="Bonds retrieved successfully", data={"bonds": bonds})
|
| 50 |
|
| 51 |
# --- Import Task ---
|
|
|
|
| 113 |
|
| 114 |
|
| 115 |
@router.post("/import-bonds", response_model=ResponseModel)
|
| 116 |
+
async def trigger_bond_import(
|
| 117 |
+
background_tasks: BackgroundTasks,
|
| 118 |
+
current_user=Depends(get_current_user)
|
| 119 |
+
):
|
| 120 |
task = await ImportTask.create(task_type="bond_import", status="pending")
|
| 121 |
background_tasks.add_task(run_bond_import_task, task.id)
|
| 122 |
return ResponseModel(success=True, message="Bond import task started.", data={"task_id": task.id})
|
| 123 |
|
| 124 |
+
|
| 125 |
@router.get("/import-status/{task_id}", response_model=ResponseModel)
|
| 126 |
async def get_import_status(task_id: int):
|
| 127 |
task = await ImportTask.get_or_none(id=task_id)
|
| 128 |
if not task:
|
| 129 |
+
raise AppException(status_code=404, message="Import task not found")
|
| 130 |
+
return ResponseModel(success=True, message="Task status retrieved", data=task)
|
App/routers/bonds/utils.py
CHANGED
|
@@ -236,7 +236,6 @@ class BondDataScraper:
|
|
| 236 |
await session.get(self.TBONDS_URL, headers=self.headers, impersonate=self.IMPERSONATE_PROFILE,timeout=60*5)
|
| 237 |
|
| 238 |
main_page_html = await self._fetch_content(session, self.TBONDS_URL, method="GET")
|
| 239 |
-
print(main_page_html)
|
| 240 |
if not main_page_html:
|
| 241 |
print("Failed to fetch main T-Bonds page.")
|
| 242 |
return
|
|
|
|
| 236 |
await session.get(self.TBONDS_URL, headers=self.headers, impersonate=self.IMPERSONATE_PROFILE,timeout=60*5)
|
| 237 |
|
| 238 |
main_page_html = await self._fetch_content(session, self.TBONDS_URL, method="GET")
|
|
|
|
| 239 |
if not main_page_html:
|
| 240 |
print("Failed to fetch main T-Bonds page.")
|
| 241 |
return
|
App/routers/funds/__init__.py
ADDED
|
File without changes
|
App/routers/funds/base_scraper.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Self-contained base for all fund scrapers.
|
| 3 |
+
Provides FundRecord dataclass + BaseFundScraper abstract class with
|
| 4 |
+
shared helpers (date parsing, number cleaning, error handling).
|
| 5 |
+
"""
|
| 6 |
+
from abc import ABC, abstractmethod
|
| 7 |
+
from dataclasses import dataclass, field
|
| 8 |
+
from datetime import date, datetime
|
| 9 |
+
from decimal import Decimal
|
| 10 |
+
from typing import List, Optional, Any
|
| 11 |
+
import logging
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class FundRecord:
|
| 18 |
+
"""Standardised mutual fund record returned by every scraper."""
|
| 19 |
+
fund_name: str = ""
|
| 20 |
+
manager: str = ""
|
| 21 |
+
net_asset_value: float = 0.0
|
| 22 |
+
outstanding_units: float = 0.0
|
| 23 |
+
nav_per_unit: float = 0.0
|
| 24 |
+
sale_price: float = 0.0
|
| 25 |
+
repurchase_price: float = 0.0
|
| 26 |
+
date: Optional[date] = None
|
| 27 |
+
currency: str = "TZS"
|
| 28 |
+
|
| 29 |
+
def to_dict(self) -> dict:
|
| 30 |
+
return {
|
| 31 |
+
"fund_name": self.fund_name,
|
| 32 |
+
"manager": self.manager,
|
| 33 |
+
"date": self.date.isoformat() if self.date else None,
|
| 34 |
+
"nav_per_unit": self.nav_per_unit,
|
| 35 |
+
"sale_price": self.sale_price,
|
| 36 |
+
"repurchase_price": self.repurchase_price,
|
| 37 |
+
"net_asset_value": self.net_asset_value,
|
| 38 |
+
"outstanding_units": self.outstanding_units,
|
| 39 |
+
"currency": self.currency,
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class BaseFundScraper(ABC):
|
| 44 |
+
"""Abstract base – subclasses must implement fetch_raw() and parse()."""
|
| 45 |
+
|
| 46 |
+
# Override in subclass
|
| 47 |
+
manager_name: str = ""
|
| 48 |
+
fund_names: List[str] = []
|
| 49 |
+
base_url: str = ""
|
| 50 |
+
|
| 51 |
+
# ------------------------------------------------------------------
|
| 52 |
+
# Abstract interface
|
| 53 |
+
# ------------------------------------------------------------------
|
| 54 |
+
|
| 55 |
+
@abstractmethod
|
| 56 |
+
def fetch_raw(self, fund_name: str) -> Any:
|
| 57 |
+
"""Fetch raw data for a single fund from source."""
|
| 58 |
+
...
|
| 59 |
+
|
| 60 |
+
@abstractmethod
|
| 61 |
+
def parse(self, raw_data: Any, fund_name: str) -> List[FundRecord]:
|
| 62 |
+
"""Parse raw data into a list of FundRecord objects."""
|
| 63 |
+
...
|
| 64 |
+
|
| 65 |
+
# ------------------------------------------------------------------
|
| 66 |
+
# Core engine
|
| 67 |
+
# ------------------------------------------------------------------
|
| 68 |
+
|
| 69 |
+
def scrape_fund(self, fund_name: str) -> List[FundRecord]:
|
| 70 |
+
"""Scrape a single fund, returning [] on error."""
|
| 71 |
+
try:
|
| 72 |
+
logger.info(f"[{self.manager_name}] Scraping {fund_name}...")
|
| 73 |
+
self.pre_fetch_hook(fund_name)
|
| 74 |
+
raw = self.fetch_raw(fund_name)
|
| 75 |
+
if raw is None:
|
| 76 |
+
raise ValueError(f"No data returned for {fund_name}")
|
| 77 |
+
records = self.parse(raw, fund_name)
|
| 78 |
+
self.post_fetch_hook(fund_name, records)
|
| 79 |
+
logger.info(f"[{self.manager_name}] {fund_name}: {len(records)} records")
|
| 80 |
+
return records
|
| 81 |
+
except Exception as e:
|
| 82 |
+
self.handle_error(fund_name, e)
|
| 83 |
+
return []
|
| 84 |
+
|
| 85 |
+
def scrape_all(self) -> dict:
|
| 86 |
+
"""Scrape every fund for this manager."""
|
| 87 |
+
results = {name: self.scrape_fund(name) for name in self.fund_names}
|
| 88 |
+
total = sum(len(v) for v in results.values())
|
| 89 |
+
logger.info(f"[{self.manager_name}] Done — {total} total records")
|
| 90 |
+
return results
|
| 91 |
+
|
| 92 |
+
def scrape_latest(self, fund_name: str) -> Optional[FundRecord]:
|
| 93 |
+
records = self.scrape_fund(fund_name)
|
| 94 |
+
if records:
|
| 95 |
+
return sorted(records, key=lambda r: r.date or date.min, reverse=True)[0]
|
| 96 |
+
return None
|
| 97 |
+
|
| 98 |
+
def scrape_all_latest(self) -> dict:
|
| 99 |
+
results = {}
|
| 100 |
+
for name in self.fund_names:
|
| 101 |
+
rec = self.scrape_latest(name)
|
| 102 |
+
if rec:
|
| 103 |
+
results[name] = rec
|
| 104 |
+
return results
|
| 105 |
+
|
| 106 |
+
# ------------------------------------------------------------------
|
| 107 |
+
# Hooks (optional override)
|
| 108 |
+
# ------------------------------------------------------------------
|
| 109 |
+
|
| 110 |
+
def pre_fetch_hook(self, fund_name: str):
|
| 111 |
+
pass
|
| 112 |
+
|
| 113 |
+
def post_fetch_hook(self, fund_name: str, records: List[FundRecord]):
|
| 114 |
+
pass
|
| 115 |
+
|
| 116 |
+
# ------------------------------------------------------------------
|
| 117 |
+
# Shared helpers
|
| 118 |
+
# ------------------------------------------------------------------
|
| 119 |
+
|
| 120 |
+
def handle_error(self, context: str, exc: Exception):
|
| 121 |
+
logger.error(f"[{self.manager_name}] Error in '{context}': {exc}")
|
| 122 |
+
|
| 123 |
+
@staticmethod
|
| 124 |
+
def parse_date_mdy(value: str) -> date:
|
| 125 |
+
"""Parse MM/DD/YYYY or MM-DD-YYYY."""
|
| 126 |
+
for fmt in ("%m/%d/%Y", "%m-%d-%Y", "%Y-%m-%d"):
|
| 127 |
+
try:
|
| 128 |
+
return datetime.strptime(value.strip(), fmt).date()
|
| 129 |
+
except ValueError:
|
| 130 |
+
continue
|
| 131 |
+
raise ValueError(f"Cannot parse date (MDY): {value!r}")
|
| 132 |
+
|
| 133 |
+
@staticmethod
|
| 134 |
+
def parse_date_dmy(value: str) -> date:
|
| 135 |
+
"""Parse DD/MM/YYYY or DD-MM-YYYY or D Month YYYY."""
|
| 136 |
+
for fmt in ("%d/%m/%Y", "%d-%m-%Y", "%d %B %Y", "%d %b %Y", "%Y-%m-%d"):
|
| 137 |
+
try:
|
| 138 |
+
return datetime.strptime(value.strip(), fmt).date()
|
| 139 |
+
except ValueError:
|
| 140 |
+
continue
|
| 141 |
+
raise ValueError(f"Cannot parse date (DMY): {value!r}")
|
| 142 |
+
|
| 143 |
+
@staticmethod
|
| 144 |
+
def parse_comma_number(value: Any) -> float:
|
| 145 |
+
"""Remove commas/spaces and convert to float; 0.0 on failure."""
|
| 146 |
+
if value is None:
|
| 147 |
+
return 0.0
|
| 148 |
+
try:
|
| 149 |
+
return float(str(value).replace(",", "").replace(" ", "").strip())
|
| 150 |
+
except (ValueError, TypeError):
|
| 151 |
+
return 0.0
|
App/routers/funds/fund_data.json
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"funds": [
|
| 3 |
+
{
|
| 4 |
+
"name": "Umoja Fund",
|
| 5 |
+
"manager": "UTT AMIS",
|
| 6 |
+
"fund_type": "Balanced",
|
| 7 |
+
"currency": "TZS",
|
| 8 |
+
"objective": "To grow investors' capital over the medium to long term. The fund invests in some shares listed on the Dar es Salaam Stock Exchange (DSE) and in money markets.",
|
| 9 |
+
"strategy": "Diversified investment in listed equities (DSE) and money market instruments. Uses compounding interest (Faida Jumuishi) to grow capital over time.",
|
| 10 |
+
"benchmark": null,
|
| 11 |
+
"risk_level": "Medium",
|
| 12 |
+
"suitable_for": "Tanzanian residents and non-residents; groups, companies, institutions, organisations and associations of various kinds.",
|
| 13 |
+
"min_initial": 10,
|
| 14 |
+
"min_additional": null,
|
| 15 |
+
"min_initial_note": "Minimum of 10 units",
|
| 16 |
+
"entry_load": "None",
|
| 17 |
+
"exit_load": "None",
|
| 18 |
+
"management_fee": null,
|
| 19 |
+
"redemption_days": null,
|
| 20 |
+
"pays_income": false,
|
| 21 |
+
"income_frequency": null,
|
| 22 |
+
"asset_allocation": {
|
| 23 |
+
"note": "Mix of DSE-listed equities and money market instruments; exact split not disclosed"
|
| 24 |
+
},
|
| 25 |
+
"custodian": null,
|
| 26 |
+
"trustee": null,
|
| 27 |
+
"auditor": null,
|
| 28 |
+
"inception_date": null,
|
| 29 |
+
"why_choose": [
|
| 30 |
+
"Diversified portfolio in equities and money market instruments",
|
| 31 |
+
"Compounding interest grows your wealth significantly over time",
|
| 32 |
+
"No entry or exit fees",
|
| 33 |
+
"Start with just 10 units — one of the lowest minimums available",
|
| 34 |
+
"Daily NAV published every working day for full transparency",
|
| 35 |
+
"Regulated by CMSA"
|
| 36 |
+
],
|
| 37 |
+
"contact": {
|
| 38 |
+
"phone": "+255 22 2122501",
|
| 39 |
+
"email": "uwekezaji@uttamis.co.tz",
|
| 40 |
+
"website": "www.uttamis.co.tz",
|
| 41 |
+
"ussd": "*150*82#",
|
| 42 |
+
"toll_free": "+255 754 800 544 / +255 754 800 455"
|
| 43 |
+
},
|
| 44 |
+
"other_facts": {
|
| 45 |
+
"min_units": 10,
|
| 46 |
+
"unit_price_published": "Daily (every working day)",
|
| 47 |
+
"investment_channels": [
|
| 48 |
+
"UTT Microfinance",
|
| 49 |
+
"All CRDB Bank branches",
|
| 50 |
+
"All DSE stockbrokers",
|
| 51 |
+
"*150*82# mobile"
|
| 52 |
+
],
|
| 53 |
+
"regulator": "Capital Markets and Securities Authority (CMSA)",
|
| 54 |
+
"projected_returns": "8%–15% p.a. (compounded over 10 years)",
|
| 55 |
+
"example": "TZS 1,000,000 at 10% p.a. compounded over 10 years grows to TZS 2,593,742"
|
| 56 |
+
}
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"name": "Wekeza Maisha Fund",
|
| 60 |
+
"manager": "UTT AMIS",
|
| 61 |
+
"fund_type": "Balanced",
|
| 62 |
+
"currency": "TZS",
|
| 63 |
+
"objective": "A fund that combines investment with insurance benefits. More than 99% of investor funds are invested while less than 1% covers insurance costs. Aims to grow capital over a 10-year term while providing life, accident, permanent disability and funeral cover.",
|
| 64 |
+
"strategy": "Diversified investment in listed equities and various fixed income/money market instruments. Insurance benefits capped at TZS 25,000,000.",
|
| 65 |
+
"benchmark": null,
|
| 66 |
+
"risk_level": "Medium",
|
| 67 |
+
"suitable_for": "Any Tanzanian citizen aged 18–55 years.",
|
| 68 |
+
"min_initial": 1000000,
|
| 69 |
+
"min_additional": 8340,
|
| 70 |
+
"entry_load": "None",
|
| 71 |
+
"exit_load": "Applicable subject to fund terms",
|
| 72 |
+
"management_fee": null,
|
| 73 |
+
"redemption_days": null,
|
| 74 |
+
"pays_income": false,
|
| 75 |
+
"income_frequency": null,
|
| 76 |
+
"asset_allocation": {
|
| 77 |
+
"note": "Mix of equities and fixed income; >99% invested, <1% insurance premium"
|
| 78 |
+
},
|
| 79 |
+
"custodian": null,
|
| 80 |
+
"trustee": null,
|
| 81 |
+
"auditor": null,
|
| 82 |
+
"inception_date": null,
|
| 83 |
+
"why_choose": [
|
| 84 |
+
"Investment + insurance in a single product",
|
| 85 |
+
"Life, accident, permanent disability, and funeral cover included",
|
| 86 |
+
"Over 99% of your money is actively invested",
|
| 87 |
+
"Loyalty bonus after 10 years of investment",
|
| 88 |
+
"Insurance coverage up to TZS 25,000,000",
|
| 89 |
+
"Projected returns of 8%–15% p.a. compounded"
|
| 90 |
+
],
|
| 91 |
+
"contact": {
|
| 92 |
+
"phone": "+255 22 2122501",
|
| 93 |
+
"email": "uwekezaji@uttamis.co.tz",
|
| 94 |
+
"website": "www.uttamis.co.tz",
|
| 95 |
+
"ussd": "*150*82#"
|
| 96 |
+
},
|
| 97 |
+
"other_facts": {
|
| 98 |
+
"investment_term_years": 10,
|
| 99 |
+
"min_monthly_contribution": 8340,
|
| 100 |
+
"insurance_benefits": [
|
| 101 |
+
"Life insurance",
|
| 102 |
+
"Accident insurance",
|
| 103 |
+
"Permanent disability insurance",
|
| 104 |
+
"Funeral cover"
|
| 105 |
+
],
|
| 106 |
+
"insurance_cap_tzs": 25000000,
|
| 107 |
+
"bonus": "Loyalty bonus after 10 years of investment",
|
| 108 |
+
"unit_price_published": "Daily (every working day)",
|
| 109 |
+
"investment_channels": [
|
| 110 |
+
"UTT Microfinance",
|
| 111 |
+
"All CRDB Bank branches",
|
| 112 |
+
"All DSE stockbrokers",
|
| 113 |
+
"*150*82# mobile"
|
| 114 |
+
],
|
| 115 |
+
"regulator": "Capital Markets and Securities Authority (CMSA)",
|
| 116 |
+
"projected_returns": "8%–15% p.a. (compounded over 10 years)"
|
| 117 |
+
}
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"name": "Watoto Fund",
|
| 121 |
+
"manager": "UTT AMIS",
|
| 122 |
+
"fund_type": "Balanced",
|
| 123 |
+
"currency": "TZS",
|
| 124 |
+
"objective": "A fund designed to benefit children for their future lives. Aims to save and grow the value of a child's money over the long term; specifically structured to help pay school fees or grow capital for a child investor.",
|
| 125 |
+
"strategy": "Diversified investment in equities and various fixed income/money market instruments. Two investment plans: (1) School fees payment plan; (2) Capital growth plan.",
|
| 126 |
+
"benchmark": null,
|
| 127 |
+
"risk_level": "Medium",
|
| 128 |
+
"suitable_for": "Tanzanian children under 18 years of age. Institutions, companies and groups may also invest on behalf of children.",
|
| 129 |
+
"min_initial": 10000,
|
| 130 |
+
"min_additional": 5000,
|
| 131 |
+
"entry_load": "None",
|
| 132 |
+
"exit_load": "Subject to fund terms",
|
| 133 |
+
"management_fee": null,
|
| 134 |
+
"redemption_days": null,
|
| 135 |
+
"pays_income": false,
|
| 136 |
+
"income_frequency": null,
|
| 137 |
+
"asset_allocation": {
|
| 138 |
+
"note": "Mix of equities and fixed income; exact split not disclosed"
|
| 139 |
+
},
|
| 140 |
+
"custodian": null,
|
| 141 |
+
"trustee": null,
|
| 142 |
+
"auditor": null,
|
| 143 |
+
"inception_date": null,
|
| 144 |
+
"investment_plans": [
|
| 145 |
+
{
|
| 146 |
+
"name": "School Fees Payment Plan",
|
| 147 |
+
"description": "Periodic withdrawals structured to pay school fees at key intervals",
|
| 148 |
+
"min_initial": 10000
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"name": "Capital Growth Plan",
|
| 152 |
+
"description": "Reinvest all returns to maximise long-term capital growth",
|
| 153 |
+
"min_initial": 10000
|
| 154 |
+
}
|
| 155 |
+
],
|
| 156 |
+
"why_choose": [
|
| 157 |
+
"Purpose-built to fund a child's education or future",
|
| 158 |
+
"Compounding over 12 years can grow TZS 10,000/month into TZS 2.6M",
|
| 159 |
+
"Two flexible plans: school fees or capital growth",
|
| 160 |
+
"Start with just TZS 10,000",
|
| 161 |
+
"Accounts held in trust for the child beneficiary",
|
| 162 |
+
"Regulated by CMSA"
|
| 163 |
+
],
|
| 164 |
+
"contact": {
|
| 165 |
+
"phone": "+255 22 2122501",
|
| 166 |
+
"email": "uwekezaji@uttamis.co.tz",
|
| 167 |
+
"website": "www.uttamis.co.tz",
|
| 168 |
+
"ussd": "*150*82#"
|
| 169 |
+
},
|
| 170 |
+
"other_facts": {
|
| 171 |
+
"investment_plans": ["School fees payment plan", "Capital growth plan"],
|
| 172 |
+
"unit_price_published": "Daily (every working day)",
|
| 173 |
+
"investment_channels": [
|
| 174 |
+
"UTT Microfinance",
|
| 175 |
+
"All CRDB Bank branches",
|
| 176 |
+
"All DSE stockbrokers",
|
| 177 |
+
"*150*82# mobile"
|
| 178 |
+
],
|
| 179 |
+
"regulator": "Capital Markets and Securities Authority (CMSA)",
|
| 180 |
+
"projected_returns_example": "10% p.a. assumed; TZS 10,000/month for 12 years grows to TZS 2,616,758",
|
| 181 |
+
"note": "Accounts held in trust for the child beneficiary"
|
| 182 |
+
}
|
| 183 |
+
},
|
| 184 |
+
{
|
| 185 |
+
"name": "Jikimu Fund",
|
| 186 |
+
"manager": "UTT AMIS",
|
| 187 |
+
"fund_type": "Balanced",
|
| 188 |
+
"currency": "TZS",
|
| 189 |
+
"objective": "A fund with the goal of providing dividends/distributions and capital growth for investors over the medium to long term. The fund invests in equities and money markets.",
|
| 190 |
+
"strategy": "Diversified investment in DSE-listed equities and money market instruments. Investors choose between quarterly or annual dividend distribution plan, or capital growth plan where dividends are reinvested.",
|
| 191 |
+
"benchmark": null,
|
| 192 |
+
"risk_level": "Medium",
|
| 193 |
+
"suitable_for": "Tanzanian residents and non-residents; groups, companies, institutions and associations of various kinds.",
|
| 194 |
+
"min_initial": 1000000,
|
| 195 |
+
"min_additional": 15000,
|
| 196 |
+
"entry_load": "None",
|
| 197 |
+
"exit_load": "None",
|
| 198 |
+
"management_fee": null,
|
| 199 |
+
"redemption_days": null,
|
| 200 |
+
"pays_income": true,
|
| 201 |
+
"income_frequency": "Quarterly or annually (depending on investment plan)",
|
| 202 |
+
"asset_allocation": {
|
| 203 |
+
"note": "Mix of DSE-listed equities and money market instruments; exact split not disclosed"
|
| 204 |
+
},
|
| 205 |
+
"custodian": null,
|
| 206 |
+
"trustee": null,
|
| 207 |
+
"auditor": null,
|
| 208 |
+
"inception_date": null,
|
| 209 |
+
"investment_plans": [
|
| 210 |
+
{
|
| 211 |
+
"name": "Quarterly Dividend Plan",
|
| 212 |
+
"description": "Receive dividend distributions every quarter (4× per year)",
|
| 213 |
+
"min_initial": 2000000,
|
| 214 |
+
"min_additional": 15000
|
| 215 |
+
},
|
| 216 |
+
{
|
| 217 |
+
"name": "Annual Dividend Plan",
|
| 218 |
+
"description": "Receive dividend distributions once per year",
|
| 219 |
+
"min_initial": 1000000,
|
| 220 |
+
"min_additional": 15000
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"name": "Capital Growth Plan",
|
| 224 |
+
"description": "Dividends are reinvested to grow your capital",
|
| 225 |
+
"min_initial": 5000,
|
| 226 |
+
"min_additional": 5000
|
| 227 |
+
}
|
| 228 |
+
],
|
| 229 |
+
"why_choose": [
|
| 230 |
+
"Earn regular income through quarterly or annual dividend distributions",
|
| 231 |
+
"Maximum distribution rate of 16% p.a. (4% per quarter)",
|
| 232 |
+
"Choose between income distribution or capital growth",
|
| 233 |
+
"No entry or exit fees",
|
| 234 |
+
"Capital also appreciates alongside distributions",
|
| 235 |
+
"Regulated by CMSA"
|
| 236 |
+
],
|
| 237 |
+
"contact": {
|
| 238 |
+
"phone": "+255 22 2122501",
|
| 239 |
+
"email": "uwekezaji@uttamis.co.tz",
|
| 240 |
+
"website": "www.uttamis.co.tz",
|
| 241 |
+
"ussd": "*150*82#"
|
| 242 |
+
},
|
| 243 |
+
"other_facts": {
|
| 244 |
+
"investment_plans": [
|
| 245 |
+
"Quarterly dividend plan — min TZS 2,000,000",
|
| 246 |
+
"Annual dividend plan — min TZS 1,000,000",
|
| 247 |
+
"Capital growth plan — min TZS 5,000"
|
| 248 |
+
],
|
| 249 |
+
"max_distribution_rate": "16% p.a. (4% per quarter)",
|
| 250 |
+
"distribution_example": "TZS 50,000,000 at 4% quarterly = TZS 2,000,000/quarter",
|
| 251 |
+
"unit_price_published": "Daily (every working day)",
|
| 252 |
+
"investment_channels": [
|
| 253 |
+
"UTT Microfinance",
|
| 254 |
+
"All CRDB Bank branches",
|
| 255 |
+
"All DSE stockbrokers",
|
| 256 |
+
"*150*82# mobile"
|
| 257 |
+
],
|
| 258 |
+
"regulator": "Capital Markets and Securities Authority (CMSA)"
|
| 259 |
+
}
|
| 260 |
+
},
|
| 261 |
+
{
|
| 262 |
+
"name": "Liquid Fund",
|
| 263 |
+
"manager": "UTT AMIS",
|
| 264 |
+
"fund_type": "Money Market",
|
| 265 |
+
"currency": "TZS",
|
| 266 |
+
"objective": "A fund that provides opportunities for investors who need to invest their funds for a short or long period. The fund invests only in money markets, minimising the risk of the investor losing their capital.",
|
| 267 |
+
"strategy": "Invests exclusively in money market instruments and various fixed income securities. Capital preservation oriented.",
|
| 268 |
+
"benchmark": null,
|
| 269 |
+
"risk_level": "Low",
|
| 270 |
+
"suitable_for": "Tanzanian residents and non-residents; groups, companies, institutions, organisations and associations of various kinds.",
|
| 271 |
+
"min_initial": 100000,
|
| 272 |
+
"min_additional": 10000,
|
| 273 |
+
"entry_load": "None",
|
| 274 |
+
"exit_load": "None",
|
| 275 |
+
"management_fee": null,
|
| 276 |
+
"redemption_days": 3,
|
| 277 |
+
"pays_income": false,
|
| 278 |
+
"income_frequency": null,
|
| 279 |
+
"asset_allocation": {
|
| 280 |
+
"money_market": 100,
|
| 281 |
+
"note": "100% money market instruments only"
|
| 282 |
+
},
|
| 283 |
+
"custodian": null,
|
| 284 |
+
"trustee": null,
|
| 285 |
+
"auditor": null,
|
| 286 |
+
"inception_date": null,
|
| 287 |
+
"why_choose": [
|
| 288 |
+
"100% money market — lowest risk UTT AMIS fund",
|
| 289 |
+
"Redeem within 3 working days (T+3)",
|
| 290 |
+
"No entry or exit fees",
|
| 291 |
+
"Suitable for both short-term and long-term investors",
|
| 292 |
+
"Projected returns of 8%–15% p.a. compounded",
|
| 293 |
+
"Capital preservation focus"
|
| 294 |
+
],
|
| 295 |
+
"contact": {
|
| 296 |
+
"phone": "+255 22 2122501",
|
| 297 |
+
"email": "uwekezaji@uttamis.co.tz",
|
| 298 |
+
"website": "www.uttamis.co.tz",
|
| 299 |
+
"ussd": "*150*82#"
|
| 300 |
+
},
|
| 301 |
+
"other_facts": {
|
| 302 |
+
"unit_price_published": "Daily (every working day)",
|
| 303 |
+
"investment_channels": [
|
| 304 |
+
"UTT Microfinance",
|
| 305 |
+
"All CRDB Bank branches",
|
| 306 |
+
"All DSE stockbrokers",
|
| 307 |
+
"*150*82# mobile"
|
| 308 |
+
],
|
| 309 |
+
"regulator": "Capital Markets and Securities Authority (CMSA)",
|
| 310 |
+
"projected_returns_example": "10% p.a.; TZS 5,000,000 for 10 years grows to TZS 12,968,712"
|
| 311 |
+
}
|
| 312 |
+
},
|
| 313 |
+
{
|
| 314 |
+
"name": "Bond Fund",
|
| 315 |
+
"manager": "UTT AMIS",
|
| 316 |
+
"fund_type": "Bond",
|
| 317 |
+
"currency": "TZS",
|
| 318 |
+
"objective": "An open-ended fixed income fund that invests in low risk Treasury Bonds, listed corporate bonds and money market investments. The Fund aims at providing capital appreciation for long term investors and distributing income periodically.",
|
| 319 |
+
"strategy": "Invests in Treasury Bonds, listed corporate bonds and money market instruments. Offers three investment options: Reinvestment Plan, Monthly Income Distribution Plan, and Semi-annual Income Distribution Plan.",
|
| 320 |
+
"benchmark": "7-year Treasury Bill",
|
| 321 |
+
"risk_level": "Low",
|
| 322 |
+
"suitable_for": "Investors seeking capital protection; growth and income distribution on short to long term; optimal returns with minimum risk.",
|
| 323 |
+
"min_initial": 50000,
|
| 324 |
+
"min_additional": 5000,
|
| 325 |
+
"entry_load": "None",
|
| 326 |
+
"exit_load": "None",
|
| 327 |
+
"management_fee": null,
|
| 328 |
+
"redemption_days": null,
|
| 329 |
+
"pays_income": true,
|
| 330 |
+
"income_frequency": "Monthly or semi-annually (depending on plan chosen); or reinvested",
|
| 331 |
+
"asset_allocation": {
|
| 332 |
+
"note": "Treasury Bonds, listed corporate bonds, and money market investments; exact split not disclosed"
|
| 333 |
+
},
|
| 334 |
+
"custodian": null,
|
| 335 |
+
"trustee": null,
|
| 336 |
+
"auditor": null,
|
| 337 |
+
"inception_date": "June 2021",
|
| 338 |
+
"investment_plans": [
|
| 339 |
+
{
|
| 340 |
+
"name": "Reinvestment Plan",
|
| 341 |
+
"description": "Income is reinvested to compound and grow your capital",
|
| 342 |
+
"min_initial": 50000,
|
| 343 |
+
"min_additional": 5000
|
| 344 |
+
},
|
| 345 |
+
{
|
| 346 |
+
"name": "Monthly Income Distribution Plan",
|
| 347 |
+
"description": "Receive income distributions every month",
|
| 348 |
+
"min_initial": 10000000,
|
| 349 |
+
"min_additional": 5000
|
| 350 |
+
},
|
| 351 |
+
{
|
| 352 |
+
"name": "Semi-annual Income Distribution Plan",
|
| 353 |
+
"description": "Receive income distributions every six months",
|
| 354 |
+
"min_initial": 5000000,
|
| 355 |
+
"min_additional": 5000
|
| 356 |
+
}
|
| 357 |
+
],
|
| 358 |
+
"why_choose": [
|
| 359 |
+
"Invests in low-risk Treasury Bonds and listed corporate bonds",
|
| 360 |
+
"Three plans: reinvest, monthly income, or semi-annual income",
|
| 361 |
+
"Start investing from just TZS 50,000 (reinvestment plan)",
|
| 362 |
+
"No entry or exit fees",
|
| 363 |
+
"Benchmarked against the 7-year Treasury Bill",
|
| 364 |
+
"Capital protection with periodic income distribution"
|
| 365 |
+
],
|
| 366 |
+
"contact": {
|
| 367 |
+
"phone": "+255 22 2122501",
|
| 368 |
+
"email": "uwekezaji@uttamis.co.tz",
|
| 369 |
+
"website": "www.uttamis.co.tz",
|
| 370 |
+
"ussd": "*150*82#"
|
| 371 |
+
},
|
| 372 |
+
"other_facts": {
|
| 373 |
+
"investment_options": [
|
| 374 |
+
"Reinvestment Plan — min TZS 50,000",
|
| 375 |
+
"Monthly Income Distribution Plan — min TZS 10,000,000",
|
| 376 |
+
"Semi-annual Income Distribution Plan — min TZS 5,000,000"
|
| 377 |
+
],
|
| 378 |
+
"investment_channels": [
|
| 379 |
+
"www.uttamis.co.tz",
|
| 380 |
+
"UTT AMIS Mobile App",
|
| 381 |
+
"SimInvest *150*82#"
|
| 382 |
+
],
|
| 383 |
+
"regulator": "Capital Markets and Securities Authority (CMSA)"
|
| 384 |
+
}
|
| 385 |
+
},
|
| 386 |
+
{
|
| 387 |
+
"name": "iCash",
|
| 388 |
+
"manager": "iTrust Finance",
|
| 389 |
+
"fund_type": "Money Market",
|
| 390 |
+
"currency": "TZS",
|
| 391 |
+
"objective": "A highly liquid, low risk, money market fund that aims at preserving a client's wealth with high and stable levels of return. The Fund seeks to provide Investors with an opportunity to manage their liquidity by investing in short-term fixed income securities while earning competitive returns.",
|
| 392 |
+
"strategy": "Invests in money market instruments such as treasury bills, call and fixed deposits, and short-term fixed income securities such as corporate bonds, sukuks, treasury and other listed and unlisted bonds approved by CMSA.",
|
| 393 |
+
"benchmark": "364-Day Treasury Bill Weighted Average Yield (Bank of Tanzania)",
|
| 394 |
+
"risk_level": "Low",
|
| 395 |
+
"suitable_for": "Investors who seek to invest in a money market fund; investors who seek investment income with high liquidity; investors that have low risk tolerance; investors who prefer a short to medium-term investment horizon.",
|
| 396 |
+
"min_initial": 100000,
|
| 397 |
+
"min_additional": 10000,
|
| 398 |
+
"entry_load": "None",
|
| 399 |
+
"exit_load": "None",
|
| 400 |
+
"management_fee": null,
|
| 401 |
+
"redemption_days": 3,
|
| 402 |
+
"pays_income": false,
|
| 403 |
+
"income_frequency": null,
|
| 404 |
+
"asset_allocation": {
|
| 405 |
+
"note": "Short-term money market instruments including T-bills, call/fixed deposits, corporate bonds, sukuks"
|
| 406 |
+
},
|
| 407 |
+
"custodian": null,
|
| 408 |
+
"trustee": null,
|
| 409 |
+
"auditor": null,
|
| 410 |
+
"inception_date": "November 2024",
|
| 411 |
+
"why_choose": [
|
| 412 |
+
"Higher returns than bank deposits",
|
| 413 |
+
"Free full or partial withdrawal at any time",
|
| 414 |
+
"Diversified securities reduce single-asset risk",
|
| 415 |
+
"Highly liquid — ideal for short to medium-term investing",
|
| 416 |
+
"T+3 settlement (3 working days)"
|
| 417 |
+
],
|
| 418 |
+
"contact": {
|
| 419 |
+
"phone": "+255 659 071 777",
|
| 420 |
+
"email": "customerservice@itrust.co.tz",
|
| 421 |
+
"website": "www.itrust.co.tz"
|
| 422 |
+
},
|
| 423 |
+
"collection_account": {
|
| 424 |
+
"bank": "NBC Bank",
|
| 425 |
+
"branch": "Sea Cliff Branch",
|
| 426 |
+
"account_name": "iCash Collections Account",
|
| 427 |
+
"account_number": "047188000066"
|
| 428 |
+
},
|
| 429 |
+
"other_facts": {
|
| 430 |
+
"fund_structure": "Open-ended money market unit trust scheme",
|
| 431 |
+
"illustrated_return_rate": "11% p.a.",
|
| 432 |
+
"example_return": "TZS 100,000/month × 12 months: invested TZS 1,300,000; return TZS 73,959; balance TZS 1,373,959",
|
| 433 |
+
"min_withdrawal": 10000,
|
| 434 |
+
"monitoring": "Monthly statements by 5th of each month via iTrust Finance app/portal",
|
| 435 |
+
"regulator": "Capital Markets and Securities Authority (CMSA)"
|
| 436 |
+
}
|
| 437 |
+
},
|
| 438 |
+
{
|
| 439 |
+
"name": "iSave",
|
| 440 |
+
"manager": "iTrust Finance",
|
| 441 |
+
"fund_type": "Bond",
|
| 442 |
+
"currency": "TZS",
|
| 443 |
+
"objective": "A low risk, fixed income fund that aims to grow the client's wealth with consistently high returns. The Fund seeks to provide a low-risk investment opportunity for investors to maximize their wealth through investments in a diversified portfolio of long-term fixed income securities.",
|
| 444 |
+
"strategy": "Invests in a diversified portfolio of long-term fixed income securities, primarily high-yielding government bonds. Compounding of interest earned to preserve and enhance capital over time.",
|
| 445 |
+
"benchmark": "10-year Treasury Bond Weighted Average Yield (Bank of Tanzania)",
|
| 446 |
+
"risk_level": "Low",
|
| 447 |
+
"suitable_for": "Investors who seek to invest in long term fixed income securities with minimal capital; investors who seek investment income with capital stability; investors that have low risk tolerance; investors who prefer a medium to long term investment horizon.",
|
| 448 |
+
"min_initial": 100000,
|
| 449 |
+
"min_additional": 10000,
|
| 450 |
+
"entry_load": "None",
|
| 451 |
+
"exit_load": "1% of NAV",
|
| 452 |
+
"management_fee": null,
|
| 453 |
+
"redemption_days": 3,
|
| 454 |
+
"pays_income": false,
|
| 455 |
+
"income_frequency": null,
|
| 456 |
+
"asset_allocation": {
|
| 457 |
+
"note": "Diversified portfolio of long-term fixed income securities, primarily government bonds"
|
| 458 |
+
},
|
| 459 |
+
"custodian": null,
|
| 460 |
+
"trustee": null,
|
| 461 |
+
"auditor": null,
|
| 462 |
+
"inception_date": "November 2024",
|
| 463 |
+
"why_choose": [
|
| 464 |
+
"Stable, high returns through a portfolio of government bonds",
|
| 465 |
+
"Access to a diversified bond portfolio with minimal capital",
|
| 466 |
+
"Compounding interest preserves and enhances capital",
|
| 467 |
+
"Low risk compared to equities",
|
| 468 |
+
"Benchmarked against the 10-year Treasury Bond yield"
|
| 469 |
+
],
|
| 470 |
+
"contact": {
|
| 471 |
+
"phone": "+255 659 071 777",
|
| 472 |
+
"email": "customerservice@itrust.co.tz",
|
| 473 |
+
"website": "www.itrust.co.tz"
|
| 474 |
+
},
|
| 475 |
+
"collection_account": {
|
| 476 |
+
"bank": "NBC Bank",
|
| 477 |
+
"branch": "Sea Cliff Branch",
|
| 478 |
+
"account_name": "iSave Collections Account",
|
| 479 |
+
"account_number": "047188000108"
|
| 480 |
+
},
|
| 481 |
+
"other_facts": {
|
| 482 |
+
"fund_structure": "Open-ended fixed income unit trust scheme",
|
| 483 |
+
"illustrated_return_rate": "13% p.a.",
|
| 484 |
+
"example_return": "TZS 100,000/month × 12 months: invested TZS 1,300,000; return TZS 87,949; balance TZS 1,387,949",
|
| 485 |
+
"min_withdrawal": 10000,
|
| 486 |
+
"monitoring": "Monthly statements by 5th of each month via iTrust Finance app/portal",
|
| 487 |
+
"regulator": "Capital Markets and Securities Authority (CMSA)"
|
| 488 |
+
}
|
| 489 |
+
},
|
| 490 |
+
{
|
| 491 |
+
"name": "iIncome",
|
| 492 |
+
"manager": "iTrust Finance",
|
| 493 |
+
"fund_type": "Bond",
|
| 494 |
+
"currency": "TZS",
|
| 495 |
+
"objective": "A low risk, fixed income fund that seeks to preserve capital whilst distributing a regular income generated through investments in a diversified portfolio of fixed income securities. It seeks to achieve its objective by investing in short-term and long-term fixed income securities.",
|
| 496 |
+
"strategy": "Invests in a diversified portfolio of short-term and long-term fixed income securities to generate regular distributable income while preserving capital.",
|
| 497 |
+
"benchmark": "5-year Treasury Bond Weighted Average Yield (Bank of Tanzania)",
|
| 498 |
+
"risk_level": "Low",
|
| 499 |
+
"suitable_for": "Investors who seek to invest in short-term and long-term fixed income securities; investors that seek periodic distribution of income; investors that have low-risk tolerance; investors who prefer a medium to long term investment horizon.",
|
| 500 |
+
"min_initial": 10000000,
|
| 501 |
+
"min_additional": 100000,
|
| 502 |
+
"entry_load": "None",
|
| 503 |
+
"exit_load": "1% of NAV",
|
| 504 |
+
"management_fee": null,
|
| 505 |
+
"redemption_days": 3,
|
| 506 |
+
"pays_income": true,
|
| 507 |
+
"income_frequency": "Semi-annually (at manager's discretion)",
|
| 508 |
+
"asset_allocation": {
|
| 509 |
+
"note": "Mix of short-term and long-term fixed income securities"
|
| 510 |
+
},
|
| 511 |
+
"custodian": null,
|
| 512 |
+
"trustee": null,
|
| 513 |
+
"auditor": null,
|
| 514 |
+
"inception_date": "November 2024",
|
| 515 |
+
"why_choose": [
|
| 516 |
+
"The only iTrust fund that distributes periodic income to investors",
|
| 517 |
+
"Regular semi-annual income distributions",
|
| 518 |
+
"Capital is preserved while generating income",
|
| 519 |
+
"Invests in both short-term and long-term fixed income securities",
|
| 520 |
+
"Benchmarked against the 5-year Treasury Bond yield"
|
| 521 |
+
],
|
| 522 |
+
"contact": {
|
| 523 |
+
"phone": "+255 659 071 777",
|
| 524 |
+
"email": "customerservice@itrust.co.tz",
|
| 525 |
+
"website": "www.itrust.co.tz"
|
| 526 |
+
},
|
| 527 |
+
"collection_account": {
|
| 528 |
+
"bank": "NBC Bank",
|
| 529 |
+
"branch": "Sea Cliff Branch",
|
| 530 |
+
"account_name": "iIncome Collections Account",
|
| 531 |
+
"account_number": "047188000080"
|
| 532 |
+
},
|
| 533 |
+
"other_facts": {
|
| 534 |
+
"fund_structure": "Open-ended fixed income unit trust scheme",
|
| 535 |
+
"min_balance_to_maintain": 10000000,
|
| 536 |
+
"min_withdrawal": 100000,
|
| 537 |
+
"monitoring": "Monthly statements by 5th of each month via iTrust Finance app/portal",
|
| 538 |
+
"regulator": "Capital Markets and Securities Authority (CMSA)",
|
| 539 |
+
"note": "Only iTrust fund that distributes periodic income to unit holders"
|
| 540 |
+
}
|
| 541 |
+
},
|
| 542 |
+
{
|
| 543 |
+
"name": "iGrowth",
|
| 544 |
+
"manager": "iTrust Finance",
|
| 545 |
+
"fund_type": "Equity",
|
| 546 |
+
"currency": "TZS",
|
| 547 |
+
"objective": "A Balanced Fund that aims at growing a client's wealth over a long period of time. The fund invests in fixed income securities and DSE listed equities. It seeks long-term capital growth consistent with moderate investment risk through investments in a diversified portfolio.",
|
| 548 |
+
"strategy": "Invests in a diversified portfolio of fixed income securities and DSE-listed equities. Balances risk between equity growth and fixed income stability.",
|
| 549 |
+
"benchmark": "Composite: DSE Tanzania Share Index (TSI) return + Weighted Average Yield of the 5-year Treasury Bond",
|
| 550 |
+
"risk_level": "Moderate",
|
| 551 |
+
"suitable_for": "Investors with a moderate risk appetite who wish to grow their capital at a less volatile rate; investors who prefer a medium to long-term investment horizon; investors who seek to invest in equities while having a balanced risk profile.",
|
| 552 |
+
"min_initial": 100000,
|
| 553 |
+
"min_additional": 10000,
|
| 554 |
+
"entry_load": "None",
|
| 555 |
+
"exit_load": "1% of NAV",
|
| 556 |
+
"management_fee": null,
|
| 557 |
+
"redemption_days": 3,
|
| 558 |
+
"pays_income": false,
|
| 559 |
+
"income_frequency": null,
|
| 560 |
+
"asset_allocation": {
|
| 561 |
+
"note": "Balanced between DSE-listed equities and fixed income securities; exact percentages not disclosed"
|
| 562 |
+
},
|
| 563 |
+
"custodian": null,
|
| 564 |
+
"trustee": null,
|
| 565 |
+
"auditor": null,
|
| 566 |
+
"inception_date": "November 2024",
|
| 567 |
+
"why_choose": [
|
| 568 |
+
"More stable than direct stock investments through portfolio diversification",
|
| 569 |
+
"Free entry — no brokerage fees unlike buying stocks/bonds directly",
|
| 570 |
+
"Redeem in just 3 working days",
|
| 571 |
+
"Access to DSE equities and bonds with a single investment",
|
| 572 |
+
"Composite benchmark: DSE TSI + 5-year Treasury Bond yield"
|
| 573 |
+
],
|
| 574 |
+
"contact": {
|
| 575 |
+
"phone": "+255 659 071 777",
|
| 576 |
+
"email": "customerservice@itrust.co.tz",
|
| 577 |
+
"website": "www.itrust.co.tz"
|
| 578 |
+
},
|
| 579 |
+
"collection_account": {
|
| 580 |
+
"bank": "NBC Bank",
|
| 581 |
+
"branch": "Sea Cliff Branch",
|
| 582 |
+
"account_name": "iGrowth Collections Account",
|
| 583 |
+
"account_number": "047188000078"
|
| 584 |
+
},
|
| 585 |
+
"other_facts": {
|
| 586 |
+
"fund_structure": "Open-ended balanced unit trust scheme",
|
| 587 |
+
"min_withdrawal": 10000,
|
| 588 |
+
"monitoring": "Monthly statements by 5th of each month via iTrust Finance app/portal",
|
| 589 |
+
"regulator": "Capital Markets and Securities Authority (CMSA)"
|
| 590 |
+
}
|
| 591 |
+
},
|
| 592 |
+
{
|
| 593 |
+
"name": "Imaan",
|
| 594 |
+
"manager": "iTrust Finance",
|
| 595 |
+
"fund_type": "Islamic",
|
| 596 |
+
"currency": "TZS",
|
| 597 |
+
"objective": "A Shariah-compliant balanced fund that aims to appreciate the client's capital over a long period of time by investing in Shariah compliant securities. The primary objective is to seek long-term capital growth consistent with moderate investment risk through investments in a diversified portfolio of Shariah compliant securities.",
|
| 598 |
+
"strategy": "Invests in Shariah-compliant instruments only. Excludes Riba (interest), gambling, speculation, impermissible products/services and all haram activities. Includes halal equities and Shariah-compliant deposits (sukuks etc.).",
|
| 599 |
+
"benchmark": "Average Fixed Deposit Rates from Islamic Banks/Windows",
|
| 600 |
+
"risk_level": "Low to Medium",
|
| 601 |
+
"suitable_for": "Investors who seek to invest in Shariah-compliant instruments; investors who seek investment income with capital stability; investors that have low to medium risk tolerance; investors who prefer a medium to long-term investment horizon.",
|
| 602 |
+
"min_initial": 100000,
|
| 603 |
+
"min_additional": 10000,
|
| 604 |
+
"entry_load": "None",
|
| 605 |
+
"exit_load": "1% of NAV",
|
| 606 |
+
"management_fee": null,
|
| 607 |
+
"redemption_days": 3,
|
| 608 |
+
"pays_income": true,
|
| 609 |
+
"income_frequency": "Semi-Annually",
|
| 610 |
+
"asset_allocation": {
|
| 611 |
+
"note": "Shariah-compliant securities only — halal equities, sukuks, Islamic deposits"
|
| 612 |
+
},
|
| 613 |
+
"custodian": null,
|
| 614 |
+
"trustee": null,
|
| 615 |
+
"auditor": null,
|
| 616 |
+
"inception_date": "November 2024",
|
| 617 |
+
"why_choose": [
|
| 618 |
+
"100% Shariah-compliant — no Riba, gambling, or haram activities",
|
| 619 |
+
"Returns higher than Islamic bank deposits and more stable than halal equities",
|
| 620 |
+
"Semi-annual income distributions",
|
| 621 |
+
"Redeem in just 3 working days",
|
| 622 |
+
"Managed under iTrust's dedicated Imaan division"
|
| 623 |
+
],
|
| 624 |
+
"contact": {
|
| 625 |
+
"phone": "+255 659 071 777",
|
| 626 |
+
"email": "customerservice@itrust.co.tz",
|
| 627 |
+
"website": "www.itrust.co.tz"
|
| 628 |
+
},
|
| 629 |
+
"collection_account": {
|
| 630 |
+
"bank": "NBC Bank",
|
| 631 |
+
"branch": "Sea Cliff Branch",
|
| 632 |
+
"account_name": "Imaan Collections Account",
|
| 633 |
+
"account_number": "047188000091"
|
| 634 |
+
},
|
| 635 |
+
"other_facts": {
|
| 636 |
+
"fund_structure": "Open-ended Shariah-compliant unit trust scheme",
|
| 637 |
+
"min_withdrawal": 10000,
|
| 638 |
+
"monitoring": "Monthly statements by 5th of each month via iTrust Finance app/portal",
|
| 639 |
+
"regulator": "Capital Markets and Securities Authority (CMSA)",
|
| 640 |
+
"shariah_compliance": "Excludes riba, gambling, speculation, haram activities"
|
| 641 |
+
}
|
| 642 |
+
},
|
| 643 |
+
{
|
| 644 |
+
"name": "iDollar",
|
| 645 |
+
"manager": "iTrust Finance",
|
| 646 |
+
"fund_type": "Money Market",
|
| 647 |
+
"currency": "USD",
|
| 648 |
+
"objective": "An open-ended multi-currency USD-denominated money market fund that aims to preserve investors' capital whilst providing competitive USD-denominated returns. The Fund invests in USD-denominated money market and fixed income instruments to offer investors a liquid, low-risk investment in US Dollars.",
|
| 649 |
+
"strategy": "Invests at least 70% in USD-denominated assets (bank deposits, bonds, and fixed income securities) and up to 30% in TZS-denominated fixed income instruments. Capital preservation oriented with a 90-day lock-in period from date of investment.",
|
| 650 |
+
"benchmark": "Average of US Dollar Fixed Deposit Rates from Commercial Banks in Tanzania",
|
| 651 |
+
"risk_level": "Low",
|
| 652 |
+
"suitable_for": "Investors seeking to hold or grow USD-denominated assets; investors seeking high liquidity with low risk; investors who prefer a short to medium-term investment horizon in a money market instrument.",
|
| 653 |
+
"min_initial": 1000,
|
| 654 |
+
"min_additional": 100,
|
| 655 |
+
"entry_load": "None",
|
| 656 |
+
"exit_load": "None",
|
| 657 |
+
"management_fee": "1% per annum of NAV",
|
| 658 |
+
"redemption_days": 5,
|
| 659 |
+
"pays_income": false,
|
| 660 |
+
"income_frequency": null,
|
| 661 |
+
"asset_allocation": {
|
| 662 |
+
"usd_denominated_min_pct": 70,
|
| 663 |
+
"tzs_denominated_max_pct": 30,
|
| 664 |
+
"note": "≥70% USD-denominated deposits, bonds, and fixed income securities; ≤30% TZS-denominated fixed income instruments"
|
| 665 |
+
},
|
| 666 |
+
"custodian": "CRDB Bank Plc",
|
| 667 |
+
"trustee": "CRDB Bank Plc",
|
| 668 |
+
"auditor": "PricewaterhouseCoopers Tanzania",
|
| 669 |
+
"inception_date": "July 8, 2025",
|
| 670 |
+
"why_choose": [
|
| 671 |
+
"Tanzania's first USD-denominated unit trust fund",
|
| 672 |
+
"Earn competitive USD returns while preserving capital",
|
| 673 |
+
"Units can be used as collateral for loans",
|
| 674 |
+
"No entry or exit fees",
|
| 675 |
+
"Managed by iTrust Finance, regulated by CMSA",
|
| 676 |
+
"Open to resident and non-resident Tanzanians, diaspora, and institutions"
|
| 677 |
+
],
|
| 678 |
+
"contact": {
|
| 679 |
+
"phone": "+255 659 071 777",
|
| 680 |
+
"email": "customerservice@itrust.co.tz",
|
| 681 |
+
"website": "www.itrust.co.tz"
|
| 682 |
+
},
|
| 683 |
+
"collection_account": {
|
| 684 |
+
"bank": "CRDB Bank",
|
| 685 |
+
"branch": "Palm Beach Branch",
|
| 686 |
+
"account_name": "iDollar Collections Account",
|
| 687 |
+
"account_number": "02DI012915600",
|
| 688 |
+
"swift": "CORUTZTZ"
|
| 689 |
+
},
|
| 690 |
+
"other_facts": {
|
| 691 |
+
"fund_structure": "Open-ended multi-currency money market unit trust scheme",
|
| 692 |
+
"face_value_usd": 100,
|
| 693 |
+
"lock_in_days": 90,
|
| 694 |
+
"min_redemption_usd": 100,
|
| 695 |
+
"custodian_fee": "0.1% per annum of NAV",
|
| 696 |
+
"cmsa_certificate": "I.0074/F06",
|
| 697 |
+
"cmsa_issue_date": "June 26, 2025",
|
| 698 |
+
"collateral_eligible": true,
|
| 699 |
+
"permitted_investments": [
|
| 700 |
+
"USD-denominated bank deposits",
|
| 701 |
+
"Treasury Bills",
|
| 702 |
+
"Treasury Bonds",
|
| 703 |
+
"Corporate Bonds / Sukuk Bonds",
|
| 704 |
+
"TZS-denominated fixed income instruments (up to 30%)"
|
| 705 |
+
],
|
| 706 |
+
"monitoring": "Monthly statements via iTrust Finance app/portal",
|
| 707 |
+
"regulator": "Capital Markets and Securities Authority (CMSA)"
|
| 708 |
+
}
|
| 709 |
+
}
|
| 710 |
+
]
|
| 711 |
+
}
|
App/routers/funds/managers/__init__.py
ADDED
|
File without changes
|
App/routers/funds/managers/itrust/__init__.py
ADDED
|
File without changes
|
App/routers/funds/managers/itrust/scraper.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
iTrust Finance fund scraper.
|
| 3 |
+
Fetches NAV history from the iTrust public API.
|
| 4 |
+
Funds: iCash, iSave, iIncome, iGrowth, Imaan, iDollar
|
| 5 |
+
"""
|
| 6 |
+
import requests
|
| 7 |
+
from typing import List, Any
|
| 8 |
+
from App.routers.funds.base_scraper import BaseFundScraper, FundRecord
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
FUND_TYPE_MAP = {
|
| 12 |
+
"iCash": "Money Market",
|
| 13 |
+
"iSave": "Money Market",
|
| 14 |
+
"iIncome": "Bond",
|
| 15 |
+
"iGrowth": "Equity",
|
| 16 |
+
"Imaan": "Islamic",
|
| 17 |
+
"iDollar": "Money Market",
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
CURRENCY_MAP = {
|
| 21 |
+
"iDollar": "USD",
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
DIVIDEND_FUNDS = {"iIncome", "Imaan"}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ITrustScraper(BaseFundScraper):
|
| 28 |
+
|
| 29 |
+
manager_name = "iTrust Finance"
|
| 30 |
+
fund_names = ["iCash", "iSave", "iIncome", "iGrowth", "Imaan", "iDollar"]
|
| 31 |
+
base_url = "https://api.itrust.co.tz/api/fund"
|
| 32 |
+
|
| 33 |
+
def __init__(self):
|
| 34 |
+
self._session = requests.Session()
|
| 35 |
+
self._session.headers.update({
|
| 36 |
+
"User-Agent": (
|
| 37 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 38 |
+
"AppleWebKit/537.36 Chrome/144.0.0.0 Safari/537.36"
|
| 39 |
+
),
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
def get_manager_abbreviation(self) -> str:
|
| 43 |
+
return "ITRUST"
|
| 44 |
+
|
| 45 |
+
def fetch_raw(self, fund_name: str) -> Any:
|
| 46 |
+
url = f"{self.base_url}/{fund_name}"
|
| 47 |
+
try:
|
| 48 |
+
resp = self._session.get(url, timeout=30)
|
| 49 |
+
if resp.status_code == 404:
|
| 50 |
+
print(f"Fund {fund_name} not found (404), skipping...")
|
| 51 |
+
return None
|
| 52 |
+
resp.raise_for_status()
|
| 53 |
+
return resp.json()
|
| 54 |
+
except Exception as e:
|
| 55 |
+
raise RuntimeError(f"Failed to fetch {fund_name}: {e}") from e
|
| 56 |
+
|
| 57 |
+
def parse(self, raw_data: Any, fund_name: str) -> List[FundRecord]:
|
| 58 |
+
"""
|
| 59 |
+
iTrust API returns a list of dicts:
|
| 60 |
+
{
|
| 61 |
+
"_id": "...",
|
| 62 |
+
"fundName": "iCash Fund",
|
| 63 |
+
"date": "11/17/2025", <- MM/DD/YYYY
|
| 64 |
+
"navPerUnit": 1234.56,
|
| 65 |
+
"salePricePerUnit": 1234.56,
|
| 66 |
+
"repurchasePricePerUnit": 1234.56,
|
| 67 |
+
"outStandingUnits": 1234567.89,
|
| 68 |
+
"netAssetValue": 9876543.21
|
| 69 |
+
}
|
| 70 |
+
"""
|
| 71 |
+
if not isinstance(raw_data, list):
|
| 72 |
+
raw_data = raw_data if raw_data else []
|
| 73 |
+
|
| 74 |
+
records = []
|
| 75 |
+
currency = CURRENCY_MAP.get(fund_name, "TZS")
|
| 76 |
+
|
| 77 |
+
for entry in raw_data:
|
| 78 |
+
try:
|
| 79 |
+
raw_name = entry.get("fundName", fund_name)
|
| 80 |
+
# Strip trailing " Fund" if present
|
| 81 |
+
display_name = raw_name.replace(" Fund", "").strip()
|
| 82 |
+
|
| 83 |
+
# Try MM/DD/YYYY first, fallback to DD/MM/YYYY
|
| 84 |
+
try:
|
| 85 |
+
parsed_date = self.parse_date_mdy(entry["date"])
|
| 86 |
+
except ValueError:
|
| 87 |
+
parsed_date = self.parse_date_dmy(entry["date"])
|
| 88 |
+
|
| 89 |
+
record = FundRecord(
|
| 90 |
+
fund_name=display_name,
|
| 91 |
+
manager=self.manager_name,
|
| 92 |
+
date=parsed_date,
|
| 93 |
+
nav_per_unit=round(float(entry.get("navPerUnit", 0)), 4),
|
| 94 |
+
sale_price=round(float(entry.get("salePricePerUnit", 0)), 4),
|
| 95 |
+
repurchase_price=round(
|
| 96 |
+
float(entry.get("repurchasePricePerUnit", 0)), 4
|
| 97 |
+
),
|
| 98 |
+
outstanding_units=float(entry.get("outStandingUnits", 0)),
|
| 99 |
+
net_asset_value=float(entry.get("netAssetValue", 0)),
|
| 100 |
+
currency=currency,
|
| 101 |
+
)
|
| 102 |
+
records.append(record)
|
| 103 |
+
except (KeyError, ValueError) as e:
|
| 104 |
+
self.handle_error(fund_name, e)
|
| 105 |
+
continue
|
| 106 |
+
|
| 107 |
+
return records
|
App/routers/funds/managers/orbit/__init__.py
ADDED
|
File without changes
|
App/routers/funds/managers/orbit/scraper.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Orbit Securities fund scraper.
|
| 3 |
+
Fetches NAV history from the Orbit Inuka Fund page (Livewire snapshot).
|
| 4 |
+
Funds: Inuka Money Market Fund, Inuka IDIF
|
| 5 |
+
"""
|
| 6 |
+
import json
|
| 7 |
+
import html as html_lib
|
| 8 |
+
import requests
|
| 9 |
+
from bs4 import BeautifulSoup
|
| 10 |
+
from typing import List, Any, Dict, Optional
|
| 11 |
+
from App.routers.funds.base_scraper import BaseFundScraper, FundRecord
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# Internal Livewire key → display name
|
| 15 |
+
FUND_MAP: Dict[str, str] = {
|
| 16 |
+
"inuka_money_market": "Inuka Money Market Fund",
|
| 17 |
+
"dozen_index": "Inuka IDIF",
|
| 18 |
+
}
|
| 19 |
+
_REVERSE_MAP: Dict[str, str] = {v: k for k, v in FUND_MAP.items()}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class OrbitScraper(BaseFundScraper):
|
| 23 |
+
|
| 24 |
+
manager_name = "Orbit Securities"
|
| 25 |
+
base_url = "https://orbit.co.tz"
|
| 26 |
+
|
| 27 |
+
@property
|
| 28 |
+
def fund_names(self) -> List[str]:
|
| 29 |
+
return list(FUND_MAP.values())
|
| 30 |
+
|
| 31 |
+
def __init__(self):
|
| 32 |
+
self._session: Optional[requests.Session] = None
|
| 33 |
+
self._snapshot_cache: Optional[Dict[str, list]] = None
|
| 34 |
+
|
| 35 |
+
def _ensure_session(self):
|
| 36 |
+
if self._session is None:
|
| 37 |
+
self._session = requests.Session()
|
| 38 |
+
self._session.headers.update({
|
| 39 |
+
"User-Agent": (
|
| 40 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 41 |
+
"AppleWebKit/537.36 Chrome/144.0.0.0 Safari/537.36"
|
| 42 |
+
),
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
# ------------------------------------------------------------------
|
| 46 |
+
# Page fetching
|
| 47 |
+
# ------------------------------------------------------------------
|
| 48 |
+
|
| 49 |
+
def _fetch_page(self) -> str:
|
| 50 |
+
self._ensure_session()
|
| 51 |
+
resp = self._session.get(f"{self.base_url}/inuka-fund", timeout=30)
|
| 52 |
+
resp.raise_for_status()
|
| 53 |
+
return resp.text
|
| 54 |
+
|
| 55 |
+
# ------------------------------------------------------------------
|
| 56 |
+
# Livewire snapshot parser
|
| 57 |
+
# ------------------------------------------------------------------
|
| 58 |
+
|
| 59 |
+
def _extract_snapshot(self, html: str) -> dict:
|
| 60 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 61 |
+
div = soup.find("div", attrs={"wire:snapshot": True})
|
| 62 |
+
if not div:
|
| 63 |
+
raise ValueError("No wire:snapshot div found")
|
| 64 |
+
return json.loads(html_lib.unescape(div["wire:snapshot"]))
|
| 65 |
+
|
| 66 |
+
def _flatten(self, obj) -> list:
|
| 67 |
+
"""Recursively collect entries that look like NAV rows."""
|
| 68 |
+
entries = []
|
| 69 |
+
if isinstance(obj, dict):
|
| 70 |
+
if "Date" in obj and "NAVPUnit" in obj:
|
| 71 |
+
entries.append(obj)
|
| 72 |
+
else:
|
| 73 |
+
for v in obj.values():
|
| 74 |
+
entries.extend(self._flatten(v))
|
| 75 |
+
elif isinstance(obj, list):
|
| 76 |
+
for item in obj:
|
| 77 |
+
entries.extend(self._flatten(item))
|
| 78 |
+
return entries
|
| 79 |
+
|
| 80 |
+
def _parse_snapshot(self, snapshot: dict) -> Dict[str, list]:
|
| 81 |
+
result: Dict[str, list] = {}
|
| 82 |
+
fund_data = snapshot.get("data", {}).get("values", [])
|
| 83 |
+
if isinstance(fund_data, list) and fund_data:
|
| 84 |
+
fund_data = fund_data[0] if isinstance(fund_data[0], dict) else {}
|
| 85 |
+
for internal, display in FUND_MAP.items():
|
| 86 |
+
result[display] = self._flatten(fund_data.get(internal, []))
|
| 87 |
+
return result
|
| 88 |
+
|
| 89 |
+
# ------------------------------------------------------------------
|
| 90 |
+
# HTML table fallback
|
| 91 |
+
# ------------------------------------------------------------------
|
| 92 |
+
|
| 93 |
+
def _parse_html_table(self, html: str) -> list:
|
| 94 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 95 |
+
table = soup.find("table", id="historicalNAVTable")
|
| 96 |
+
if not table:
|
| 97 |
+
return []
|
| 98 |
+
rows = []
|
| 99 |
+
for tr in (table.find("tbody") or table).find_all("tr"):
|
| 100 |
+
cols = tr.find_all("td")
|
| 101 |
+
if len(cols) >= 4:
|
| 102 |
+
rows.append({
|
| 103 |
+
"Date": cols[0].get_text(strip=True),
|
| 104 |
+
"AssetsUnderManagement": cols[1].get_text(strip=True),
|
| 105 |
+
"OutstandingUnits": cols[2].get_text(strip=True),
|
| 106 |
+
"NAVPUnit": cols[3].get_text(strip=True),
|
| 107 |
+
"UnitSellPrice": cols[4].get_text(strip=True) if len(cols) > 4 else cols[3].get_text(strip=True),
|
| 108 |
+
})
|
| 109 |
+
return rows
|
| 110 |
+
|
| 111 |
+
# ------------------------------------------------------------------
|
| 112 |
+
# BaseFundScraper interface
|
| 113 |
+
# ------------------------------------------------------------------
|
| 114 |
+
|
| 115 |
+
def pre_fetch_hook(self, fund_name: str):
|
| 116 |
+
self._ensure_session()
|
| 117 |
+
|
| 118 |
+
def fetch_raw(self, fund_name: str) -> Any:
|
| 119 |
+
# Use cached snapshot if available
|
| 120 |
+
if self._snapshot_cache and fund_name in self._snapshot_cache:
|
| 121 |
+
return self._snapshot_cache[fund_name]
|
| 122 |
+
|
| 123 |
+
html = self._fetch_page()
|
| 124 |
+
|
| 125 |
+
# Try snapshot first
|
| 126 |
+
try:
|
| 127 |
+
all_funds = self._parse_snapshot(self._extract_snapshot(html))
|
| 128 |
+
self._snapshot_cache = all_funds
|
| 129 |
+
rows = all_funds.get(fund_name, [])
|
| 130 |
+
if rows:
|
| 131 |
+
return rows
|
| 132 |
+
except Exception as e:
|
| 133 |
+
self.handle_error("snapshot", e)
|
| 134 |
+
|
| 135 |
+
# Fallback to HTML table (only meaningful for one fund at a time)
|
| 136 |
+
return self._parse_html_table(html)
|
| 137 |
+
|
| 138 |
+
def parse(self, raw_data: Any, fund_name: str) -> List[FundRecord]:
|
| 139 |
+
"""
|
| 140 |
+
Each entry (from snapshot or HTML table):
|
| 141 |
+
{
|
| 142 |
+
"Date": "01/04/2025", <- DD/MM/YYYY
|
| 143 |
+
"AssetsUnderManagement": "1,234,567,890",
|
| 144 |
+
"OutstandingUnits": "9,876,543",
|
| 145 |
+
"NAVPUnit": "125.0000",
|
| 146 |
+
"UnitSellPrice": "125.0000",
|
| 147 |
+
}
|
| 148 |
+
"""
|
| 149 |
+
records = []
|
| 150 |
+
for entry in raw_data:
|
| 151 |
+
try:
|
| 152 |
+
nav = self.parse_comma_number(entry.get("NAVPUnit", 0))
|
| 153 |
+
sale = self.parse_comma_number(
|
| 154 |
+
entry.get("UnitSellPrice") or entry.get("NAVPUnit", 0)
|
| 155 |
+
)
|
| 156 |
+
# Orbit: 0% exit load, so repurchase == sale
|
| 157 |
+
repurchase = sale
|
| 158 |
+
|
| 159 |
+
record = FundRecord(
|
| 160 |
+
fund_name=fund_name,
|
| 161 |
+
manager=self.manager_name,
|
| 162 |
+
date=self.parse_date_dmy(entry["Date"]),
|
| 163 |
+
net_asset_value=self.parse_comma_number(
|
| 164 |
+
entry.get("AssetsUnderManagement", 0)
|
| 165 |
+
),
|
| 166 |
+
outstanding_units=self.parse_comma_number(
|
| 167 |
+
entry.get("OutstandingUnits", 0)
|
| 168 |
+
),
|
| 169 |
+
nav_per_unit=round(nav, 4),
|
| 170 |
+
sale_price=round(sale, 4),
|
| 171 |
+
repurchase_price=round(repurchase, 4),
|
| 172 |
+
currency="TZS",
|
| 173 |
+
)
|
| 174 |
+
records.append(record)
|
| 175 |
+
except Exception as e:
|
| 176 |
+
self.handle_error(fund_name, e)
|
| 177 |
+
continue
|
| 178 |
+
return records
|
| 179 |
+
|
| 180 |
+
# ------------------------------------------------------------------
|
| 181 |
+
# Optimised: fetch page once, parse both funds
|
| 182 |
+
# ------------------------------------------------------------------
|
| 183 |
+
|
| 184 |
+
def scrape_all(self) -> dict:
|
| 185 |
+
self._ensure_session()
|
| 186 |
+
self._snapshot_cache = None
|
| 187 |
+
try:
|
| 188 |
+
html = self._fetch_page()
|
| 189 |
+
try:
|
| 190 |
+
all_funds = self._parse_snapshot(self._extract_snapshot(html))
|
| 191 |
+
self._snapshot_cache = all_funds
|
| 192 |
+
except Exception:
|
| 193 |
+
all_funds = {}
|
| 194 |
+
|
| 195 |
+
results = {}
|
| 196 |
+
for name in self.fund_names:
|
| 197 |
+
raw = all_funds.get(name) or self._parse_html_table(html)
|
| 198 |
+
results[name] = self.parse(raw, name)
|
| 199 |
+
return results
|
| 200 |
+
except Exception as e:
|
| 201 |
+
self.handle_error("scrape_all", e)
|
| 202 |
+
return {name: [] for name in self.fund_names}
|
App/routers/funds/managers/utt/__init__.py
ADDED
|
File without changes
|
App/routers/funds/managers/utt/scraper.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
UTT AMIS fund scraper.
|
| 3 |
+
Fetches NAV history from the UTT AMIS DataTables API.
|
| 4 |
+
|
| 5 |
+
The /navs endpoint returns ALL funds in a single response regardless of any
|
| 6 |
+
filter parameter. Each row carries a `sname` field (e.g. "Umoja Fund") that
|
| 7 |
+
identifies which fund it belongs to. We therefore override scrape_all() to
|
| 8 |
+
make one HTTP round-trip and distribute rows by sname.
|
| 9 |
+
"""
|
| 10 |
+
import logging
|
| 11 |
+
import requests
|
| 12 |
+
import re
|
| 13 |
+
from datetime import date
|
| 14 |
+
from typing import Any, Dict, List, Optional
|
| 15 |
+
|
| 16 |
+
from App.routers.funds.base_scraper import BaseFundScraper, FundRecord
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
FUND_CONFIGS = [
|
| 21 |
+
{"fund_name": "Umoja Fund", "fund_type": "Balanced", "pays_income": False},
|
| 22 |
+
{"fund_name": "Wekeza Maisha Fund", "fund_type": "Balanced", "pays_income": False},
|
| 23 |
+
{"fund_name": "Watoto Fund", "fund_type": "Balanced", "pays_income": False},
|
| 24 |
+
{"fund_name": "Jikimu Fund", "fund_type": "Money Market", "pays_income": True, "income_frequency": "Quarterly"},
|
| 25 |
+
{"fund_name": "Liquid Fund", "fund_type": "Money Market", "pays_income": False},
|
| 26 |
+
{"fund_name": "Bond Fund", "fund_type": "Bond", "pays_income": True, "income_frequency": "Monthly"},
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
# Columns in the order the actual API expects them
|
| 30 |
+
_COLUMNS = [
|
| 31 |
+
("DT_RowIndex", "DT_RowIndex", "false", "false"),
|
| 32 |
+
("sname", "sname.name", "true", "true"),
|
| 33 |
+
("net_asset_value", "net_asset_value", "true", "true"),
|
| 34 |
+
("outstanding_number_of_units", "outstanding_number_of_units", "true", "true"),
|
| 35 |
+
("nav_per_unit", "nav_per_unit", "true", "true"),
|
| 36 |
+
("sale_price_per_unit", "sale_price_per_unit", "true", "true"),
|
| 37 |
+
("repurchase_price_per_unit", "repurchase_price_per_unit", "true", "true"),
|
| 38 |
+
("date_valued", "date_valued", "true", "true"),
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class UTTScraper(BaseFundScraper):
|
| 43 |
+
|
| 44 |
+
manager_name = "UTT AMIS"
|
| 45 |
+
base_url = "https://www.uttamis.co.tz"
|
| 46 |
+
|
| 47 |
+
@property
|
| 48 |
+
def fund_names(self) -> List[str]:
|
| 49 |
+
return [c["fund_name"] for c in FUND_CONFIGS]
|
| 50 |
+
|
| 51 |
+
def __init__(self):
|
| 52 |
+
self._session = requests.Session()
|
| 53 |
+
self._csrf_token: str = ""
|
| 54 |
+
self._base_headers = {
|
| 55 |
+
"Accept": "application/json, text/javascript, */*; q=0.01",
|
| 56 |
+
"Accept-Language": "en-US,en;q=0.9",
|
| 57 |
+
"Connection": "keep-alive",
|
| 58 |
+
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
| 59 |
+
"User-Agent": (
|
| 60 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 61 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"
|
| 62 |
+
),
|
| 63 |
+
"X-Requested-With": "XMLHttpRequest",
|
| 64 |
+
"sec-ch-ua": '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
|
| 65 |
+
"sec-ch-ua-mobile": "?0",
|
| 66 |
+
"sec-ch-ua-platform": '"Windows"',
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
def get_manager_abbreviation(self) -> str:
|
| 70 |
+
return "UTT"
|
| 71 |
+
|
| 72 |
+
# ── session / CSRF ─────────────────────────────────────────────────────────
|
| 73 |
+
|
| 74 |
+
def _refresh_csrf(self):
|
| 75 |
+
resp = self._session.get(
|
| 76 |
+
f"{self.base_url}/fund-performance",
|
| 77 |
+
headers={"User-Agent": self._base_headers["User-Agent"]},
|
| 78 |
+
timeout=30,
|
| 79 |
+
)
|
| 80 |
+
resp.raise_for_status()
|
| 81 |
+
match = re.search(r'<meta name="csrf-token" content="([^"]+)"', resp.text)
|
| 82 |
+
if not match:
|
| 83 |
+
raise RuntimeError("CSRF token not found on fund-performance page")
|
| 84 |
+
self._csrf_token = match.group(1)
|
| 85 |
+
|
| 86 |
+
# ── payload ────────────────────────────────────────────────────────────────
|
| 87 |
+
|
| 88 |
+
PAGE_SIZE = 5000 # server rejects requests above ~10 000 rows
|
| 89 |
+
|
| 90 |
+
def _build_payload(self, start: int = 0, length: int = PAGE_SIZE) -> dict:
|
| 91 |
+
payload: dict = {
|
| 92 |
+
"csrf-token": self._csrf_token,
|
| 93 |
+
"draw": "1",
|
| 94 |
+
"start": str(start),
|
| 95 |
+
"length": str(length),
|
| 96 |
+
"search[value]": "",
|
| 97 |
+
"search[regex]": "false",
|
| 98 |
+
}
|
| 99 |
+
for i, (data, name, searchable, orderable) in enumerate(_COLUMNS):
|
| 100 |
+
payload[f"columns[{i}][data]"] = data
|
| 101 |
+
payload[f"columns[{i}][name]"] = name
|
| 102 |
+
payload[f"columns[{i}][searchable]"] = searchable
|
| 103 |
+
payload[f"columns[{i}][orderable]"] = orderable
|
| 104 |
+
payload[f"columns[{i}][search][value]"] = ""
|
| 105 |
+
payload[f"columns[{i}][search][regex]"] = "false"
|
| 106 |
+
return payload
|
| 107 |
+
|
| 108 |
+
def _post_page(self, start: int) -> dict:
|
| 109 |
+
headers = {
|
| 110 |
+
**self._base_headers,
|
| 111 |
+
"X-CSRF-TOKEN": self._csrf_token,
|
| 112 |
+
"Referer": f"{self.base_url}/fund-performance",
|
| 113 |
+
}
|
| 114 |
+
resp = self._session.post(
|
| 115 |
+
f"{self.base_url}/navs",
|
| 116 |
+
headers=headers,
|
| 117 |
+
data=self._build_payload(start=start),
|
| 118 |
+
timeout=90,
|
| 119 |
+
)
|
| 120 |
+
if resp.status_code == 419:
|
| 121 |
+
self._csrf_token = ""
|
| 122 |
+
self._refresh_csrf()
|
| 123 |
+
headers["X-CSRF-TOKEN"] = self._csrf_token
|
| 124 |
+
resp = self._session.post(
|
| 125 |
+
f"{self.base_url}/navs",
|
| 126 |
+
headers=headers,
|
| 127 |
+
data=self._build_payload(start=start),
|
| 128 |
+
timeout=90,
|
| 129 |
+
)
|
| 130 |
+
resp.raise_for_status()
|
| 131 |
+
return resp.json()
|
| 132 |
+
|
| 133 |
+
# ── fetch all funds — paginated ────────────────────────────────────────────
|
| 134 |
+
|
| 135 |
+
def _fetch_all_raw(self) -> List[dict]:
|
| 136 |
+
if not self._csrf_token:
|
| 137 |
+
self._refresh_csrf()
|
| 138 |
+
|
| 139 |
+
all_rows: List[dict] = []
|
| 140 |
+
start = 0
|
| 141 |
+
|
| 142 |
+
first_page = self._post_page(start)
|
| 143 |
+
total = first_page.get("recordsTotal", 0)
|
| 144 |
+
all_rows.extend(first_page.get("data", []))
|
| 145 |
+
logger.info(f"[UTT AMIS] page start=0: {len(all_rows)}/{total} rows")
|
| 146 |
+
|
| 147 |
+
start += self.PAGE_SIZE
|
| 148 |
+
while start < total:
|
| 149 |
+
page = self._post_page(start)
|
| 150 |
+
rows = page.get("data", [])
|
| 151 |
+
if not rows:
|
| 152 |
+
break
|
| 153 |
+
all_rows.extend(rows)
|
| 154 |
+
logger.info(f"[UTT AMIS] page start={start}: {len(all_rows)}/{total} rows")
|
| 155 |
+
start += self.PAGE_SIZE
|
| 156 |
+
|
| 157 |
+
return all_rows
|
| 158 |
+
|
| 159 |
+
# ── parse ──────────────────────────────────────────────────────────────────
|
| 160 |
+
|
| 161 |
+
def _row_to_record(self, row: dict) -> Optional[FundRecord]:
|
| 162 |
+
try:
|
| 163 |
+
fund_name = row.get("sname") or row.get("scheme_name") or ""
|
| 164 |
+
if not fund_name:
|
| 165 |
+
return None
|
| 166 |
+
|
| 167 |
+
def clean(key: str) -> float:
|
| 168 |
+
return self.parse_comma_number(row.get(key, 0))
|
| 169 |
+
|
| 170 |
+
raw_date = row.get("date_valued", "")
|
| 171 |
+
# Dates come as "28-04-2026" (DD-MM-YYYY); parse_date_dmy handles this.
|
| 172 |
+
# parse_date_mdy is only needed if the site switches to slash-delimited M/D/Y.
|
| 173 |
+
parsed_date = (
|
| 174 |
+
self.parse_date_mdy(raw_date)
|
| 175 |
+
if "/" in raw_date
|
| 176 |
+
else self.parse_date_dmy(raw_date)
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
return FundRecord(
|
| 180 |
+
fund_name=fund_name,
|
| 181 |
+
manager=self.manager_name,
|
| 182 |
+
date=parsed_date,
|
| 183 |
+
nav_per_unit=round(clean("nav_per_unit"), 4),
|
| 184 |
+
sale_price=round(clean("sale_price_per_unit"), 4),
|
| 185 |
+
repurchase_price=round(clean("repurchase_price_per_unit"), 4),
|
| 186 |
+
outstanding_units=clean("outstanding_number_of_units"),
|
| 187 |
+
net_asset_value=clean("net_asset_value"),
|
| 188 |
+
currency="TZS",
|
| 189 |
+
)
|
| 190 |
+
except Exception as exc:
|
| 191 |
+
logger.warning(f"[UTT AMIS] skipping row {row.get('id')}: {exc}")
|
| 192 |
+
return None
|
| 193 |
+
|
| 194 |
+
# ── BaseFundScraper overrides ──────────────────────────────────────────────
|
| 195 |
+
|
| 196 |
+
def fetch_raw(self, fund_name: str) -> Any:
|
| 197 |
+
# Not used — scrape_all() is overridden to fetch once for all funds.
|
| 198 |
+
return self._fetch_all_raw()
|
| 199 |
+
|
| 200 |
+
def parse(self, raw_data: Any, fund_name: str) -> List[FundRecord]:
|
| 201 |
+
# Not used — scrape_all() handles parsing.
|
| 202 |
+
return [r for row in raw_data if (r := self._row_to_record(row)) and r.fund_name == fund_name]
|
| 203 |
+
|
| 204 |
+
def scrape_all(self) -> Dict[str, List[FundRecord]]:
|
| 205 |
+
"""Single HTTP round-trip — distribute rows by sname into per-fund buckets."""
|
| 206 |
+
results: Dict[str, List[FundRecord]] = {name: [] for name in self.fund_names}
|
| 207 |
+
try:
|
| 208 |
+
self._refresh_csrf()
|
| 209 |
+
raw = self._fetch_all_raw()
|
| 210 |
+
logger.info(f"[UTT AMIS] fetched {len(raw)} raw rows")
|
| 211 |
+
|
| 212 |
+
for row in raw:
|
| 213 |
+
record = self._row_to_record(row)
|
| 214 |
+
if record and record.fund_name in results:
|
| 215 |
+
results[record.fund_name].append(record)
|
| 216 |
+
|
| 217 |
+
for name, recs in results.items():
|
| 218 |
+
logger.info(f"[UTT AMIS] {name}: {len(recs)} records")
|
| 219 |
+
|
| 220 |
+
except Exception as exc:
|
| 221 |
+
logger.error(f"[UTT AMIS] scrape_all failed: {exc}")
|
| 222 |
+
|
| 223 |
+
return results
|
App/routers/funds/models.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from tortoise import fields, models
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class FundManager(models.Model):
|
| 5 |
+
name = fields.CharField(max_length=100, unique=True)
|
| 6 |
+
phone = fields.CharField(max_length=20, null=True)
|
| 7 |
+
email = fields.CharField(max_length=100, null=True)
|
| 8 |
+
website = fields.CharField(max_length=200, null=True)
|
| 9 |
+
status = fields.CharField(max_length=20, default="Active")
|
| 10 |
+
|
| 11 |
+
class Meta:
|
| 12 |
+
table = "fund_managers"
|
| 13 |
+
|
| 14 |
+
def __str__(self):
|
| 15 |
+
return self.name
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class MutualFund(models.Model):
|
| 19 |
+
manager = fields.ForeignKeyField("models.FundManager", related_name="funds")
|
| 20 |
+
name = fields.CharField(max_length=200)
|
| 21 |
+
fund_type = fields.CharField(max_length=50, null=True)
|
| 22 |
+
currency = fields.CharField(max_length=10, default="TZS")
|
| 23 |
+
entry_load = fields.DecimalField(max_digits=5, decimal_places=2, default=0)
|
| 24 |
+
exit_load = fields.CharField(max_length=200, null=True)
|
| 25 |
+
min_initial = fields.CharField(max_length=100, null=True)
|
| 26 |
+
min_additional = fields.CharField(max_length=100, null=True)
|
| 27 |
+
redemption_days = fields.IntField(null=True)
|
| 28 |
+
pays_income = fields.BooleanField(default=False)
|
| 29 |
+
income_frequency = fields.CharField(max_length=50, null=True)
|
| 30 |
+
income_amount = fields.CharField(max_length=100, null=True)
|
| 31 |
+
benchmark = fields.CharField(max_length=300, null=True)
|
| 32 |
+
status = fields.CharField(max_length=20, default="Active")
|
| 33 |
+
|
| 34 |
+
class Meta:
|
| 35 |
+
table = "mutual_funds"
|
| 36 |
+
unique_together = ("manager", "name")
|
| 37 |
+
|
| 38 |
+
def __str__(self):
|
| 39 |
+
return self.name
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class FundPerformance(models.Model):
|
| 43 |
+
fund = fields.ForeignKeyField("models.MutualFund", related_name="performance")
|
| 44 |
+
record_date = fields.DateField()
|
| 45 |
+
net_asset_value = fields.DecimalField(max_digits=20, decimal_places=4, null=True)
|
| 46 |
+
outstanding_units = fields.DecimalField(max_digits=20, decimal_places=4, null=True)
|
| 47 |
+
nav_per_unit = fields.DecimalField(max_digits=15, decimal_places=4, null=True)
|
| 48 |
+
sale_price = fields.DecimalField(max_digits=15, decimal_places=4, null=True)
|
| 49 |
+
repurchase_price = fields.DecimalField(max_digits=15, decimal_places=4, null=True)
|
| 50 |
+
|
| 51 |
+
class Meta:
|
| 52 |
+
table = "fund_performance"
|
| 53 |
+
unique_together = ("fund", "record_date")
|
| 54 |
+
ordering = ["-record_date"]
|
App/routers/funds/routes.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
| 2 |
+
from datetime import date, timedelta
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from App.schemas import ResponseModel, AppException
|
| 8 |
+
from .models import FundManager, MutualFund, FundPerformance
|
| 9 |
+
from App.routers.users.utils import get_current_user
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/funds", tags=["Mutual Funds"])
|
| 12 |
+
|
| 13 |
+
# Load static fund data extracted from PDFs
|
| 14 |
+
_FUND_DATA_PATH = Path(__file__).parent / "fund_data.json"
|
| 15 |
+
_fund_data_cache: dict | None = None
|
| 16 |
+
|
| 17 |
+
def _get_fund_info(fund_name: str) -> dict | None:
|
| 18 |
+
global _fund_data_cache
|
| 19 |
+
if _fund_data_cache is None:
|
| 20 |
+
try:
|
| 21 |
+
_fund_data_cache = json.loads(_FUND_DATA_PATH.read_text(encoding="utf-8"))
|
| 22 |
+
except Exception:
|
| 23 |
+
_fund_data_cache = {"funds": []}
|
| 24 |
+
for entry in _fund_data_cache.get("funds", []):
|
| 25 |
+
if entry["name"].lower() == fund_name.lower():
|
| 26 |
+
return entry
|
| 27 |
+
return None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ── LIST ALL FUNDS (with latest NAV) ────────────────────────────────────────
|
| 31 |
+
|
| 32 |
+
@router.get("", response_model=ResponseModel)
|
| 33 |
+
async def list_funds():
|
| 34 |
+
"""Return all active mutual funds with their latest NAV, grouped by manager."""
|
| 35 |
+
funds = await MutualFund.filter(status="Active").select_related("manager").all()
|
| 36 |
+
|
| 37 |
+
if not funds:
|
| 38 |
+
return ResponseModel(success=True, message="No funds found", data={"funds": [], "count": 0})
|
| 39 |
+
|
| 40 |
+
# Optimized latest NAV retrieval
|
| 41 |
+
# Using a loop for simplicity but with minimal data fetch, or better, fetch all and filter in memory if small dataset
|
| 42 |
+
# For now, let's keep it robust for the local environment
|
| 43 |
+
fund_list = []
|
| 44 |
+
for f in funds:
|
| 45 |
+
latest = await FundPerformance.filter(fund_id=f.id).order_by("-record_date").first()
|
| 46 |
+
fund_list.append({
|
| 47 |
+
"id": f.id,
|
| 48 |
+
"name": f.name,
|
| 49 |
+
"fund_type": f.fund_type,
|
| 50 |
+
"currency": f.currency,
|
| 51 |
+
"manager_id": f.manager_id,
|
| 52 |
+
"manager_name": f.manager.name,
|
| 53 |
+
"nav_per_unit": float(latest.nav_per_unit) if latest and latest.nav_per_unit else None,
|
| 54 |
+
"sale_price": float(latest.sale_price) if latest and latest.sale_price else None,
|
| 55 |
+
"repurchase_price": float(latest.repurchase_price) if latest and latest.repurchase_price else None,
|
| 56 |
+
"latest_date": latest.record_date.isoformat() if latest else None,
|
| 57 |
+
"pays_income": f.pays_income,
|
| 58 |
+
"redemption_days": f.redemption_days,
|
| 59 |
+
"min_initial": f.min_initial,
|
| 60 |
+
"benchmark": f.benchmark,
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
return ResponseModel(
|
| 64 |
+
success=True,
|
| 65 |
+
message=f"Retrieved {len(fund_list)} mutual funds",
|
| 66 |
+
data={"funds": fund_list, "count": len(fund_list)},
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ── LIST MANAGERS ────────────────────────────────────────────────────────────
|
| 71 |
+
|
| 72 |
+
@router.get("/managers", response_model=ResponseModel)
|
| 73 |
+
async def list_managers():
|
| 74 |
+
"""Return all fund managers."""
|
| 75 |
+
managers = await FundManager.all()
|
| 76 |
+
data = [
|
| 77 |
+
{
|
| 78 |
+
"id": m.id,
|
| 79 |
+
"name": m.name,
|
| 80 |
+
"phone": m.phone,
|
| 81 |
+
"email": m.email,
|
| 82 |
+
"website": m.website,
|
| 83 |
+
"status": m.status,
|
| 84 |
+
}
|
| 85 |
+
for m in managers
|
| 86 |
+
]
|
| 87 |
+
return ResponseModel(success=True, message="Fund managers retrieved", data={"managers": data})
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ── FUND PERFORMANCE SUMMARY ─────────────────────────────────────────────────
|
| 91 |
+
|
| 92 |
+
@router.get("/performance", response_model=ResponseModel)
|
| 93 |
+
async def get_funds_performance():
|
| 94 |
+
"""Return all active funds with weekly, monthly and YTD return metrics."""
|
| 95 |
+
today = date.today()
|
| 96 |
+
week_ago = today - timedelta(days=7)
|
| 97 |
+
month_ago = today - timedelta(days=30)
|
| 98 |
+
ytd_start = date(today.year, 1, 1)
|
| 99 |
+
|
| 100 |
+
funds = await MutualFund.filter(status="Active").select_related("manager").all()
|
| 101 |
+
if not funds:
|
| 102 |
+
return ResponseModel(success=True, message="No funds found", data={"funds": []})
|
| 103 |
+
|
| 104 |
+
def pct(old_val, new_val):
|
| 105 |
+
try:
|
| 106 |
+
o = float(old_val)
|
| 107 |
+
n = float(new_val)
|
| 108 |
+
return round((n - o) / o * 100, 4) if o else None
|
| 109 |
+
except (TypeError, ZeroDivisionError):
|
| 110 |
+
return None
|
| 111 |
+
|
| 112 |
+
result = []
|
| 113 |
+
for f in funds:
|
| 114 |
+
latest = await FundPerformance.filter(fund=f).order_by("-record_date").first()
|
| 115 |
+
if not latest or latest.nav_per_unit is None:
|
| 116 |
+
continue
|
| 117 |
+
curr = float(latest.nav_per_unit)
|
| 118 |
+
|
| 119 |
+
week_rec = await FundPerformance.filter(fund=f, record_date__lte=week_ago).order_by("-record_date").first()
|
| 120 |
+
month_rec = await FundPerformance.filter(fund=f, record_date__lte=month_ago).order_by("-record_date").first()
|
| 121 |
+
ytd_rec = await FundPerformance.filter(fund=f, record_date__lte=ytd_start).order_by("-record_date").first()
|
| 122 |
+
|
| 123 |
+
week_nav = float(week_rec.nav_per_unit) if week_rec and week_rec.nav_per_unit is not None else None
|
| 124 |
+
month_nav = float(month_rec.nav_per_unit) if month_rec and month_rec.nav_per_unit is not None else None
|
| 125 |
+
ytd_nav = float(ytd_rec.nav_per_unit) if ytd_rec and ytd_rec.nav_per_unit is not None else None
|
| 126 |
+
|
| 127 |
+
result.append({
|
| 128 |
+
"id": f.id,
|
| 129 |
+
"name": f.name,
|
| 130 |
+
"fund_type": f.fund_type,
|
| 131 |
+
"currency": f.currency,
|
| 132 |
+
"manager_name": f.manager.name,
|
| 133 |
+
"nav_per_unit": curr,
|
| 134 |
+
"latest_date": latest.record_date.isoformat(),
|
| 135 |
+
"week_nav": week_nav,
|
| 136 |
+
"week_date": week_rec.record_date.isoformat() if week_rec else None,
|
| 137 |
+
"month_nav": month_nav,
|
| 138 |
+
"month_date": month_rec.record_date.isoformat() if month_rec else None,
|
| 139 |
+
"ytd_nav": ytd_nav,
|
| 140 |
+
"ytd_date": ytd_rec.record_date.isoformat() if ytd_rec else None,
|
| 141 |
+
"weekly_return": pct(week_nav, curr),
|
| 142 |
+
"monthly_return": pct(month_nav, curr),
|
| 143 |
+
"ytd_return": pct(ytd_nav, curr),
|
| 144 |
+
})
|
| 145 |
+
|
| 146 |
+
result.sort(key=lambda x: x.get("monthly_return") or 0, reverse=True)
|
| 147 |
+
return ResponseModel(success=True, message="Fund performance retrieved", data={"funds": result})
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ── FUND COMPARISON ──────────────────────────────────────────────────────────
|
| 151 |
+
|
| 152 |
+
@router.get("/compare", response_model=ResponseModel)
|
| 153 |
+
async def compare_funds(
|
| 154 |
+
fund_ids: str = Query(..., description="Comma-separated fund IDs, e.g. 1,2,3"),
|
| 155 |
+
from_date: str = Query(..., description="Start date YYYY-MM-DD"),
|
| 156 |
+
to_date: Optional[str] = Query(None, description="End date YYYY-MM-DD (default: today)"),
|
| 157 |
+
):
|
| 158 |
+
"""
|
| 159 |
+
Return NAV/unit history for multiple funds over a date range.
|
| 160 |
+
All series are indexed to 100 at the closest available record on or after from_date,
|
| 161 |
+
so returns are directly comparable regardless of fund price level.
|
| 162 |
+
"""
|
| 163 |
+
try:
|
| 164 |
+
start = date.fromisoformat(from_date)
|
| 165 |
+
except ValueError:
|
| 166 |
+
raise AppException(status_code=400, message="Invalid from_date — use YYYY-MM-DD")
|
| 167 |
+
|
| 168 |
+
end = date.today()
|
| 169 |
+
if to_date:
|
| 170 |
+
try:
|
| 171 |
+
end = date.fromisoformat(to_date)
|
| 172 |
+
except ValueError:
|
| 173 |
+
raise AppException(status_code=400, message="Invalid to_date — use YYYY-MM-DD")
|
| 174 |
+
|
| 175 |
+
try:
|
| 176 |
+
ids: List[int] = [int(i.strip()) for i in fund_ids.split(",") if i.strip()]
|
| 177 |
+
except ValueError:
|
| 178 |
+
raise AppException(status_code=400, message="fund_ids must be comma-separated integers")
|
| 179 |
+
|
| 180 |
+
if not ids or len(ids) > 10:
|
| 181 |
+
raise AppException(status_code=400, message="Provide between 1 and 10 fund IDs")
|
| 182 |
+
|
| 183 |
+
funds = await MutualFund.filter(id__in=ids).select_related("manager").all()
|
| 184 |
+
if not funds:
|
| 185 |
+
raise AppException(status_code=404, message="No funds found for provided IDs")
|
| 186 |
+
|
| 187 |
+
result = []
|
| 188 |
+
for fund in funds:
|
| 189 |
+
rows = (
|
| 190 |
+
await FundPerformance.filter(
|
| 191 |
+
fund=fund,
|
| 192 |
+
record_date__gte=start,
|
| 193 |
+
record_date__lte=end,
|
| 194 |
+
nav_per_unit__isnull=False,
|
| 195 |
+
)
|
| 196 |
+
.order_by("record_date")
|
| 197 |
+
.values("record_date", "nav_per_unit")
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
if not rows:
|
| 201 |
+
result.append({
|
| 202 |
+
"id": fund.id,
|
| 203 |
+
"name": fund.name,
|
| 204 |
+
"fund_type": fund.fund_type,
|
| 205 |
+
"currency": fund.currency,
|
| 206 |
+
"manager_name": fund.manager.name,
|
| 207 |
+
"data": [],
|
| 208 |
+
"start_nav": None,
|
| 209 |
+
"end_nav": None,
|
| 210 |
+
"total_return": None,
|
| 211 |
+
"annualized_return": None,
|
| 212 |
+
})
|
| 213 |
+
continue
|
| 214 |
+
|
| 215 |
+
base_nav = float(rows[0]["nav_per_unit"])
|
| 216 |
+
end_nav = float(rows[-1]["nav_per_unit"])
|
| 217 |
+
|
| 218 |
+
days = (rows[-1]["record_date"] - rows[0]["record_date"]).days
|
| 219 |
+
years = days / 365.25
|
| 220 |
+
|
| 221 |
+
try:
|
| 222 |
+
total_return = round((end_nav - base_nav) / base_nav * 100, 4) if base_nav else None
|
| 223 |
+
annualized = round(((end_nav / base_nav) ** (1 / years) - 1) * 100, 4) if base_nav and years >= 0.08 else total_return
|
| 224 |
+
except (ZeroDivisionError, ValueError):
|
| 225 |
+
total_return = annualized = None
|
| 226 |
+
|
| 227 |
+
data = [
|
| 228 |
+
{
|
| 229 |
+
"date": r["record_date"].isoformat(),
|
| 230 |
+
"nav": float(r["nav_per_unit"]),
|
| 231 |
+
"indexed": round(float(r["nav_per_unit"]) / base_nav * 100, 4),
|
| 232 |
+
}
|
| 233 |
+
for r in rows
|
| 234 |
+
]
|
| 235 |
+
|
| 236 |
+
result.append({
|
| 237 |
+
"id": fund.id,
|
| 238 |
+
"name": fund.name,
|
| 239 |
+
"fund_type": fund.fund_type,
|
| 240 |
+
"currency": fund.currency,
|
| 241 |
+
"manager_name": fund.manager.name,
|
| 242 |
+
"data": data,
|
| 243 |
+
"start_nav": base_nav,
|
| 244 |
+
"start_date": rows[0]["record_date"].isoformat(),
|
| 245 |
+
"end_nav": end_nav,
|
| 246 |
+
"end_date": rows[-1]["record_date"].isoformat(),
|
| 247 |
+
"total_return": total_return,
|
| 248 |
+
"annualized_return": annualized,
|
| 249 |
+
"days": days,
|
| 250 |
+
})
|
| 251 |
+
|
| 252 |
+
return ResponseModel(
|
| 253 |
+
success=True,
|
| 254 |
+
message=f"Comparison data for {len(result)} fund(s)",
|
| 255 |
+
data={"funds": result, "from_date": start.isoformat(), "to_date": end.isoformat()},
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
# ── STATIC FUND INFO (from PDFs) ─────────────────────────────────────────────
|
| 260 |
+
|
| 261 |
+
@router.get("/info/all", response_model=ResponseModel)
|
| 262 |
+
async def list_fund_info():
|
| 263 |
+
"""Return static fund details extracted from offer documents / brochures."""
|
| 264 |
+
global _fund_data_cache
|
| 265 |
+
if _fund_data_cache is None:
|
| 266 |
+
try:
|
| 267 |
+
_fund_data_cache = json.loads(_FUND_DATA_PATH.read_text(encoding="utf-8"))
|
| 268 |
+
except Exception:
|
| 269 |
+
_fund_data_cache = {"funds": []}
|
| 270 |
+
return ResponseModel(success=True, message="Fund info retrieved", data=_fund_data_cache)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# ── FUND DETAIL + PRICE HISTORY ──────────────────────────────────────────────
|
| 274 |
+
|
| 275 |
+
@router.get("/{identifier}", response_model=ResponseModel)
|
| 276 |
+
async def get_fund(
|
| 277 |
+
identifier: str,
|
| 278 |
+
period: str = Query("Max", enum=["1M", "3M", "6M", "1Y", "3Y", "Max"]),
|
| 279 |
+
page: int = Query(1, ge=1),
|
| 280 |
+
limit: int = Query(50, ge=1, le=5000),
|
| 281 |
+
):
|
| 282 |
+
"""Return fund metadata plus paginated performance history. Identifier can be ID or Name."""
|
| 283 |
+
if identifier.isdigit():
|
| 284 |
+
fund = await MutualFund.get_or_none(id=int(identifier))
|
| 285 |
+
else:
|
| 286 |
+
fund = await MutualFund.get_or_none(name__iexact=identifier.replace("-", " "))
|
| 287 |
+
|
| 288 |
+
if not fund:
|
| 289 |
+
raise AppException(status_code=404, message="Fund not found")
|
| 290 |
+
|
| 291 |
+
await fund.fetch_related("manager")
|
| 292 |
+
|
| 293 |
+
period_map = {
|
| 294 |
+
"1M": timedelta(days=30),
|
| 295 |
+
"3M": timedelta(days=90),
|
| 296 |
+
"6M": timedelta(days=180),
|
| 297 |
+
"1Y": timedelta(days=365),
|
| 298 |
+
"3Y": timedelta(days=365 * 3),
|
| 299 |
+
"Max": None,
|
| 300 |
+
}
|
| 301 |
+
delta = period_map.get(period)
|
| 302 |
+
perf_qs = FundPerformance.filter(fund=fund).order_by("-record_date")
|
| 303 |
+
if delta:
|
| 304 |
+
cutoff = date.today() - delta
|
| 305 |
+
perf_qs = perf_qs.filter(record_date__gte=cutoff)
|
| 306 |
+
|
| 307 |
+
total = await perf_qs.count()
|
| 308 |
+
rows = await perf_qs.offset((page - 1) * limit).limit(limit)
|
| 309 |
+
|
| 310 |
+
prices = [
|
| 311 |
+
{
|
| 312 |
+
"date": r.record_date.isoformat(),
|
| 313 |
+
"nav_per_unit": float(r.nav_per_unit) if r.nav_per_unit is not None else None,
|
| 314 |
+
"sale_price": float(r.sale_price) if r.sale_price is not None else None,
|
| 315 |
+
"repurchase_price": float(r.repurchase_price) if r.repurchase_price is not None else None,
|
| 316 |
+
"net_asset_value": float(r.net_asset_value) if r.net_asset_value is not None else None,
|
| 317 |
+
"outstanding_units": float(r.outstanding_units) if r.outstanding_units is not None else None,
|
| 318 |
+
}
|
| 319 |
+
for r in rows
|
| 320 |
+
]
|
| 321 |
+
|
| 322 |
+
latest = rows[0] if rows else None
|
| 323 |
+
|
| 324 |
+
static_info = _get_fund_info(fund.name)
|
| 325 |
+
|
| 326 |
+
return ResponseModel(
|
| 327 |
+
success=True,
|
| 328 |
+
message="Fund retrieved",
|
| 329 |
+
data={
|
| 330 |
+
"id": fund.id,
|
| 331 |
+
"name": fund.name,
|
| 332 |
+
"fund_type": fund.fund_type,
|
| 333 |
+
"currency": fund.currency,
|
| 334 |
+
"manager_id": fund.manager_id,
|
| 335 |
+
"manager_name": fund.manager.name,
|
| 336 |
+
"entry_load": float(fund.entry_load),
|
| 337 |
+
"exit_load": fund.exit_load,
|
| 338 |
+
"min_initial": fund.min_initial,
|
| 339 |
+
"min_additional": fund.min_additional,
|
| 340 |
+
"redemption_days": fund.redemption_days,
|
| 341 |
+
"pays_income": fund.pays_income,
|
| 342 |
+
"income_frequency": fund.income_frequency,
|
| 343 |
+
"income_amount": fund.income_amount,
|
| 344 |
+
"benchmark": fund.benchmark,
|
| 345 |
+
"nav_per_unit": float(latest.nav_per_unit) if latest and latest.nav_per_unit else None,
|
| 346 |
+
"sale_price": float(latest.sale_price) if latest and latest.sale_price else None,
|
| 347 |
+
"repurchase_price": float(latest.repurchase_price) if latest and latest.repurchase_price else None,
|
| 348 |
+
"latest_date": latest.record_date.isoformat() if latest else None,
|
| 349 |
+
"prices": prices,
|
| 350 |
+
"pagination": {
|
| 351 |
+
"total": total,
|
| 352 |
+
"page": page,
|
| 353 |
+
"limit": limit,
|
| 354 |
+
"pages": (total + limit - 1) // limit if limit else 1,
|
| 355 |
+
},
|
| 356 |
+
# Static data from offer documents / brochures
|
| 357 |
+
"info": static_info,
|
| 358 |
+
},
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
# ── PRICE BY DATE ────────────────────────────────────────────────────────────
|
| 363 |
+
|
| 364 |
+
@router.get("/{identifier}/price/{price_date}", response_model=ResponseModel)
|
| 365 |
+
async def get_fund_price_by_date(identifier: str, price_date: str):
|
| 366 |
+
"""Return the most recent NAV on or before price_date. Identifier = fund ID or name."""
|
| 367 |
+
if identifier.isdigit():
|
| 368 |
+
fund = await MutualFund.get_or_none(id=int(identifier))
|
| 369 |
+
else:
|
| 370 |
+
fund = await MutualFund.get_or_none(name__iexact=identifier.replace("-", " "))
|
| 371 |
+
if not fund:
|
| 372 |
+
raise AppException(status_code=404, message="Fund not found")
|
| 373 |
+
|
| 374 |
+
try:
|
| 375 |
+
target = date.fromisoformat(price_date)
|
| 376 |
+
except ValueError:
|
| 377 |
+
raise AppException(status_code=400, message="Invalid date format — use YYYY-MM-DD")
|
| 378 |
+
|
| 379 |
+
perf = (
|
| 380 |
+
await FundPerformance.filter(fund=fund, record_date__lte=target)
|
| 381 |
+
.order_by("-record_date")
|
| 382 |
+
.first()
|
| 383 |
+
)
|
| 384 |
+
if not perf:
|
| 385 |
+
raise AppException(status_code=404, message="No price data found for this date")
|
| 386 |
+
|
| 387 |
+
return ResponseModel(
|
| 388 |
+
success=True,
|
| 389 |
+
message="Fund price retrieved",
|
| 390 |
+
data={
|
| 391 |
+
"id": fund.id,
|
| 392 |
+
"name": fund.name,
|
| 393 |
+
"date": perf.record_date.isoformat(),
|
| 394 |
+
"nav_per_unit": float(perf.nav_per_unit) if perf.nav_per_unit is not None else None,
|
| 395 |
+
"sale_price": float(perf.sale_price) if perf.sale_price is not None else None,
|
| 396 |
+
"repurchase_price": float(perf.repurchase_price) if perf.repurchase_price is not None else None,
|
| 397 |
+
},
|
| 398 |
+
)
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
# ── IMPORT ENDPOINTS ─────────────────────────────────────────────────────────
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
@router.post("/import/all", response_model=ResponseModel)
|
| 405 |
+
async def import_all_funds(
|
| 406 |
+
background_tasks: BackgroundTasks,
|
| 407 |
+
current_user=Depends(get_current_user)
|
| 408 |
+
):
|
| 409 |
+
"""Trigger a background import for all fund managers (iTrust, UTT, Orbit)."""
|
| 410 |
+
from .runner import run_import
|
| 411 |
+
background_tasks.add_task(run_import, "all")
|
| 412 |
+
return ResponseModel(
|
| 413 |
+
success=True,
|
| 414 |
+
message="Import started for all managers",
|
| 415 |
+
data={"managers": ["iTrust Finance", "UTT AMIS", "Orbit Securities"]},
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
@router.post("/import/{manager_name}", response_model=ResponseModel)
|
| 420 |
+
async def import_manager_funds(
|
| 421 |
+
manager_name: str,
|
| 422 |
+
background_tasks: BackgroundTasks,
|
| 423 |
+
current_user=Depends(get_current_user)
|
| 424 |
+
):
|
| 425 |
+
"""Trigger a background import for a specific manager (itrust | utt | orbit)."""
|
| 426 |
+
from .runner import run_import, _get_scrapers
|
| 427 |
+
try:
|
| 428 |
+
scrapers = _get_scrapers(manager_name)
|
| 429 |
+
except ValueError as e:
|
| 430 |
+
raise AppException(status_code=400, message=str(e))
|
| 431 |
+
background_tasks.add_task(run_import, manager_name)
|
| 432 |
+
return ResponseModel(
|
| 433 |
+
success=True,
|
| 434 |
+
message=f"Import started for {scrapers[0].manager_name}",
|
| 435 |
+
data={"manager": scrapers[0].manager_name},
|
| 436 |
+
)
|
App/routers/funds/runner.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
runner.py — saves scraped FundRecord data into MutualFund / FundPerformance tables.
|
| 3 |
+
|
| 4 |
+
Usage (from FastAPI BackgroundTasks):
|
| 5 |
+
from App.routers.funds.runner import run_import
|
| 6 |
+
background_tasks.add_task(run_import, manager_name="all")
|
| 7 |
+
"""
|
| 8 |
+
import asyncio
|
| 9 |
+
import logging
|
| 10 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 11 |
+
from typing import List, Optional
|
| 12 |
+
|
| 13 |
+
from App.routers.funds.base_scraper import BaseFundScraper, FundRecord
|
| 14 |
+
from App.routers.funds.models import FundManager, MutualFund, FundPerformance
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
# Fund-level metadata not stored in FundRecord fields — kept here so
|
| 19 |
+
# the runner can populate MutualFund rows correctly.
|
| 20 |
+
_FUND_META = {
|
| 21 |
+
# iTrust — iCash and iDollar have no exit fee; all others charge 1% of NAV on redemption
|
| 22 |
+
"iCash": {"fund_type": "Money Market", "currency": "TZS", "exit_load": "None"},
|
| 23 |
+
"iSave": {"fund_type": "Money Market", "currency": "TZS", "exit_load": "1% of NAV"},
|
| 24 |
+
"iIncome": {"fund_type": "Bond", "currency": "TZS", "pays_income": True, "income_frequency": "Semi-Annually", "exit_load": "1% of NAV"},
|
| 25 |
+
"iGrowth": {"fund_type": "Equity", "currency": "TZS", "exit_load": "1% of NAV"},
|
| 26 |
+
"Imaan": {"fund_type": "Islamic", "currency": "TZS", "pays_income": True, "income_frequency": "Semi-Annually", "exit_load": "1% of NAV"},
|
| 27 |
+
"iDollar": {"fund_type": "Money Market", "currency": "USD", "exit_load": "None"},
|
| 28 |
+
# UTT
|
| 29 |
+
"Umoja Fund": {"fund_type": "Balanced", "currency": "TZS"},
|
| 30 |
+
"Wekeza Maisha Fund": {"fund_type": "Balanced", "currency": "TZS"},
|
| 31 |
+
"Watoto Fund": {"fund_type": "Balanced", "currency": "TZS"},
|
| 32 |
+
"Jikimu Fund": {"fund_type": "Money Market", "currency": "TZS", "pays_income": True, "income_frequency": "Quarterly"},
|
| 33 |
+
"Liquid Fund": {"fund_type": "Money Market", "currency": "TZS"},
|
| 34 |
+
"Bond Fund": {"fund_type": "Bond", "currency": "TZS", "pays_income": True, "income_frequency": "Monthly"},
|
| 35 |
+
# Orbit
|
| 36 |
+
"Inuka Money Market Fund": {"fund_type": "Money Market", "currency": "TZS"},
|
| 37 |
+
"Inuka IDIF": {"fund_type": "Equity", "currency": "TZS"},
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _get_scrapers(manager_name: str = "all") -> List[BaseFundScraper]:
|
| 42 |
+
from App.routers.funds.managers.itrust.scraper import ITrustScraper
|
| 43 |
+
from App.routers.funds.managers.utt.scraper import UTTScraper
|
| 44 |
+
from App.routers.funds.managers.orbit.scraper import OrbitScraper
|
| 45 |
+
|
| 46 |
+
all_scrapers = {
|
| 47 |
+
"itrust": ITrustScraper,
|
| 48 |
+
"utt": UTTScraper,
|
| 49 |
+
"orbit": OrbitScraper,
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
if manager_name == "all":
|
| 53 |
+
return [cls() for cls in all_scrapers.values()]
|
| 54 |
+
|
| 55 |
+
key = manager_name.lower().replace(" ", "")
|
| 56 |
+
for k, cls in all_scrapers.items():
|
| 57 |
+
if k in key or key in k:
|
| 58 |
+
return [cls()]
|
| 59 |
+
|
| 60 |
+
raise ValueError(f"Unknown manager: {manager_name!r}. Choose from: {list(all_scrapers)}")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _run_scraper_sync(scraper: BaseFundScraper) -> List[FundRecord]:
|
| 64 |
+
"""Run sync scraper; returns flat list of all FundRecord objects."""
|
| 65 |
+
try:
|
| 66 |
+
results = scraper.scrape_all()
|
| 67 |
+
records: List[FundRecord] = []
|
| 68 |
+
for fund_records in results.values():
|
| 69 |
+
records.extend(fund_records)
|
| 70 |
+
return records
|
| 71 |
+
except Exception as e:
|
| 72 |
+
logger.error(f"[{scraper.manager_name}] scrape failed: {e}")
|
| 73 |
+
return []
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
async def _save_records(records: List[FundRecord], manager_name: str, website: str = "") -> dict:
|
| 77 |
+
"""Persist FundRecord list to DB using get_or_create logic."""
|
| 78 |
+
stats = {"funds_created": 0, "funds_updated": 0, "rows_added": 0, "rows_skipped": 0}
|
| 79 |
+
|
| 80 |
+
if not records:
|
| 81 |
+
return stats
|
| 82 |
+
|
| 83 |
+
# 1. Ensure FundManager exists
|
| 84 |
+
manager, created = await FundManager.get_or_create(
|
| 85 |
+
name=manager_name,
|
| 86 |
+
defaults={"website": website or "", "status": "Active"},
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
# 2. Group records by fund name
|
| 90 |
+
by_fund: dict = {}
|
| 91 |
+
for rec in records:
|
| 92 |
+
by_fund.setdefault(rec.fund_name, []).append(rec)
|
| 93 |
+
|
| 94 |
+
# 3. For each fund, upsert MutualFund then bulk-insert new FundPerformance rows
|
| 95 |
+
for fund_name, fund_records in by_fund.items():
|
| 96 |
+
meta = _FUND_META.get(fund_name, {})
|
| 97 |
+
currency = meta.get("currency") or (fund_records[0].currency if fund_records else "TZS")
|
| 98 |
+
|
| 99 |
+
fund, f_created = await MutualFund.get_or_create(
|
| 100 |
+
manager=manager,
|
| 101 |
+
name=fund_name,
|
| 102 |
+
defaults={
|
| 103 |
+
"fund_type": meta.get("fund_type"),
|
| 104 |
+
"currency": currency,
|
| 105 |
+
"pays_income": meta.get("pays_income", False),
|
| 106 |
+
"income_frequency": meta.get("income_frequency"),
|
| 107 |
+
"exit_load": meta.get("exit_load"),
|
| 108 |
+
"status": "Active",
|
| 109 |
+
},
|
| 110 |
+
)
|
| 111 |
+
if f_created:
|
| 112 |
+
stats["funds_created"] += 1
|
| 113 |
+
else:
|
| 114 |
+
stats["funds_updated"] += 1
|
| 115 |
+
# Keep metadata fields in sync on every import
|
| 116 |
+
update_fields = {}
|
| 117 |
+
if meta.get("exit_load") is not None and fund.exit_load != meta["exit_load"]:
|
| 118 |
+
update_fields["exit_load"] = meta["exit_load"]
|
| 119 |
+
if update_fields:
|
| 120 |
+
await MutualFund.filter(id=fund.id).update(**update_fields)
|
| 121 |
+
|
| 122 |
+
# Find dates already stored
|
| 123 |
+
existing_dates = set(
|
| 124 |
+
await FundPerformance.filter(fund=fund).values_list("record_date", flat=True)
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
new_rows = []
|
| 128 |
+
for rec in fund_records:
|
| 129 |
+
if not rec.date or rec.date in existing_dates:
|
| 130 |
+
stats["rows_skipped"] += 1
|
| 131 |
+
continue
|
| 132 |
+
new_rows.append(
|
| 133 |
+
FundPerformance(
|
| 134 |
+
fund=fund,
|
| 135 |
+
record_date=rec.date,
|
| 136 |
+
net_asset_value=rec.net_asset_value or None,
|
| 137 |
+
outstanding_units=rec.outstanding_units or None,
|
| 138 |
+
nav_per_unit=rec.nav_per_unit or None,
|
| 139 |
+
sale_price=rec.sale_price or None,
|
| 140 |
+
repurchase_price=rec.repurchase_price or None,
|
| 141 |
+
)
|
| 142 |
+
)
|
| 143 |
+
existing_dates.add(rec.date)
|
| 144 |
+
|
| 145 |
+
if new_rows:
|
| 146 |
+
await FundPerformance.bulk_create(new_rows, ignore_conflicts=True)
|
| 147 |
+
stats["rows_added"] += len(new_rows)
|
| 148 |
+
|
| 149 |
+
return stats
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
async def run_import(manager_name: str = "all") -> dict:
|
| 153 |
+
"""
|
| 154 |
+
Entry point called by FastAPI BackgroundTasks.
|
| 155 |
+
Runs sync scrapers in a thread pool, then saves results to DB.
|
| 156 |
+
"""
|
| 157 |
+
scrapers = _get_scrapers(manager_name)
|
| 158 |
+
loop = asyncio.get_event_loop()
|
| 159 |
+
all_stats: dict = {"managers": {}}
|
| 160 |
+
|
| 161 |
+
with ThreadPoolExecutor(max_workers=len(scrapers)) as pool:
|
| 162 |
+
futures = {
|
| 163 |
+
loop.run_in_executor(pool, _run_scraper_sync, s): s
|
| 164 |
+
for s in scrapers
|
| 165 |
+
}
|
| 166 |
+
for future, scraper in futures.items():
|
| 167 |
+
try:
|
| 168 |
+
records = await future
|
| 169 |
+
stats = await _save_records(
|
| 170 |
+
records,
|
| 171 |
+
manager_name=scraper.manager_name,
|
| 172 |
+
website=getattr(scraper, "base_url", ""),
|
| 173 |
+
)
|
| 174 |
+
all_stats["managers"][scraper.manager_name] = stats
|
| 175 |
+
logger.info(f"[{scraper.manager_name}] import done: {stats}")
|
| 176 |
+
except Exception as e:
|
| 177 |
+
logger.error(f"[{scraper.manager_name}] import error: {e}")
|
| 178 |
+
all_stats["managers"][scraper.manager_name] = {"error": str(e)}
|
| 179 |
+
|
| 180 |
+
return all_stats
|
App/routers/portfolio/models.py
CHANGED
|
@@ -1,183 +1,200 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
| 2 |
from tortoise import fields, models
|
| 3 |
-
from
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
from tortoise.queryset import QuerySet
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
user = fields.ForeignKeyField("models.User", related_name="portfolios")
|
| 11 |
name = fields.CharField(max_length=100)
|
| 12 |
-
description = fields.TextField(null=True)
|
| 13 |
is_active = fields.BooleanField(default=True)
|
| 14 |
created_at = fields.DatetimeField(auto_now_add=True)
|
| 15 |
updated_at = fields.DatetimeField(auto_now=True)
|
| 16 |
|
| 17 |
-
async def to_dict(self):
|
| 18 |
-
if type(self) == models.Model:
|
| 19 |
-
parser = pydantic_model_creator(Portfolio)
|
| 20 |
-
return await parser.from_tortoise_orm(self)
|
| 21 |
-
if type(self) == QuerySet:
|
| 22 |
-
parser = pydantic_queryset_creator(Portfolio)
|
| 23 |
-
return await parser.from_queryset(self)
|
| 24 |
-
|
| 25 |
-
|
| 26 |
class Meta:
|
| 27 |
table = "portfolios"
|
| 28 |
-
unique_together = ("user", "name")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
portfolio = fields.ForeignKeyField("models.Portfolio", related_name="stocks")
|
| 33 |
stock = fields.ForeignKeyField("models.Stock", related_name="portfolio_holdings")
|
| 34 |
-
quantity = fields.
|
| 35 |
purchase_price = fields.DecimalField(max_digits=15, decimal_places=2)
|
| 36 |
purchase_date = fields.DateField()
|
| 37 |
-
notes = fields.TextField(null=True)
|
| 38 |
created_at = fields.DatetimeField(auto_now_add=True)
|
| 39 |
updated_at = fields.DatetimeField(auto_now=True)
|
| 40 |
|
| 41 |
-
async def to_dict(self):
|
| 42 |
-
if type(self) == models.Model:
|
| 43 |
-
parser = pydantic_model_creator(PortfolioStock)
|
| 44 |
-
return await parser.from_tortoise_orm(self)
|
| 45 |
-
if type(self) == QuerySet:
|
| 46 |
-
parser = pydantic_queryset_creator(PortfolioStock)
|
| 47 |
-
return await parser.from_queryset(self)
|
| 48 |
-
|
| 49 |
class Meta:
|
| 50 |
table = "portfolio_stocks"
|
|
|
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
portfolio = fields.ForeignKeyField("models.Portfolio", related_name="utts")
|
| 55 |
-
|
|
|
|
|
|
|
| 56 |
units_held = fields.DecimalField(max_digits=15, decimal_places=4)
|
| 57 |
purchase_price = fields.DecimalField(max_digits=15, decimal_places=2)
|
| 58 |
purchase_date = fields.DateField()
|
| 59 |
-
notes = fields.TextField(null=True)
|
| 60 |
created_at = fields.DatetimeField(auto_now_add=True)
|
| 61 |
updated_at = fields.DatetimeField(auto_now=True)
|
| 62 |
|
| 63 |
-
async def to_dict(self):
|
| 64 |
-
if type(self) == models.Model:
|
| 65 |
-
parser = pydantic_model_creator(PortfolioUTT)
|
| 66 |
-
return await parser.from_tortoise_orm(self)
|
| 67 |
-
if type(self) == QuerySet:
|
| 68 |
-
parser = pydantic_queryset_creator(PortfolioUTT)
|
| 69 |
-
return await parser.from_queryset(self)
|
| 70 |
-
|
| 71 |
class Meta:
|
| 72 |
-
table = "
|
|
|
|
|
|
|
| 73 |
|
| 74 |
-
class PortfolioBond(models.Model):
|
| 75 |
-
id = fields.IntField(pk=True)
|
| 76 |
portfolio = fields.ForeignKeyField("models.Portfolio", related_name="bonds")
|
| 77 |
bond = fields.ForeignKeyField("models.Bond", related_name="portfolio_holdings")
|
| 78 |
face_value_held = fields.BigIntField()
|
| 79 |
purchase_price = fields.DecimalField(max_digits=15, decimal_places=2)
|
| 80 |
purchase_date = fields.DateField()
|
| 81 |
-
notes = fields.TextField(null=True)
|
| 82 |
created_at = fields.DatetimeField(auto_now_add=True)
|
| 83 |
updated_at = fields.DatetimeField(auto_now=True)
|
| 84 |
|
| 85 |
-
async def to_dict(self):
|
| 86 |
-
if type(self) == models.Model:
|
| 87 |
-
parser = pydantic_model_creator(PortfolioBond)
|
| 88 |
-
return await parser.from_tortoise_orm(self)
|
| 89 |
-
if type(self) == QuerySet:
|
| 90 |
-
parser = pydantic_queryset_creator(PortfolioBond)
|
| 91 |
-
return await parser.from_queryset(self)
|
| 92 |
-
|
| 93 |
class Meta:
|
| 94 |
table = "portfolio_bonds"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
| 103 |
quantity = fields.DecimalField(max_digits=15, decimal_places=4)
|
| 104 |
price = fields.DecimalField(max_digits=15, decimal_places=2)
|
| 105 |
total_amount = fields.DecimalField(max_digits=15, decimal_places=2)
|
| 106 |
transaction_date = fields.DateField()
|
| 107 |
-
notes = fields.TextField(null=True)
|
| 108 |
created_at = fields.DatetimeField(auto_now_add=True)
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
parser = pydantic_queryset_creator(PortfolioTransaction)
|
| 114 |
-
return await parser.from_queryset(data)
|
| 115 |
|
| 116 |
-
async def to_dict(self):
|
| 117 |
-
if type(self) == models.Model:
|
| 118 |
-
parser = pydantic_model_creator(PortfolioTransaction)
|
| 119 |
-
return await parser.from_tortoise_orm(self)
|
| 120 |
|
|
|
|
| 121 |
|
| 122 |
-
class Meta:
|
| 123 |
-
table = "portfolio_transactions"
|
| 124 |
|
| 125 |
-
class PortfolioCalendar(models.Model):
|
| 126 |
-
|
| 127 |
-
|
|
|
|
| 128 |
event_date = fields.DateField()
|
| 129 |
-
event_type = fields.CharField(max_length=50)
|
| 130 |
title = fields.CharField(max_length=200)
|
| 131 |
-
description = fields.TextField(null=True)
|
| 132 |
-
asset_type = fields.CharField(max_length=10, null=True)
|
| 133 |
asset_id = fields.IntField(null=True)
|
| 134 |
-
estimated_amount = fields.DecimalField(
|
|
|
|
|
|
|
| 135 |
is_completed = fields.BooleanField(default=False)
|
| 136 |
created_at = fields.DatetimeField(auto_now_add=True)
|
| 137 |
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
parser = pydantic_queryset_creator(PortfolioCalendar)
|
| 142 |
-
return await parser.from_queryset(data)
|
| 143 |
|
| 144 |
|
| 145 |
-
|
| 146 |
-
if type(self) == models.Model:
|
| 147 |
-
parser = pydantic_model_creator(PortfolioCalendar)
|
| 148 |
-
return await parser.from_tortoise_orm(self)
|
| 149 |
|
| 150 |
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
id = fields.IntField(pk=True)
|
| 157 |
-
portfolio = fields.ForeignKeyField("models.Portfolio", related_name="snapshots")
|
| 158 |
-
snapshot_date = fields.DatetimeField()
|
| 159 |
total_value = fields.DecimalField(max_digits=20, decimal_places=2)
|
| 160 |
stock_value = fields.DecimalField(max_digits=20, decimal_places=2, default=0)
|
| 161 |
bond_value = fields.DecimalField(max_digits=20, decimal_places=2, default=0)
|
| 162 |
-
|
| 163 |
cash_value = fields.DecimalField(max_digits=20, decimal_places=2, default=0)
|
| 164 |
total_cost = fields.DecimalField(max_digits=20, decimal_places=2)
|
| 165 |
unrealized_gain_loss = fields.DecimalField(max_digits=20, decimal_places=2)
|
| 166 |
created_at = fields.DatetimeField(auto_now_add=True)
|
| 167 |
|
| 168 |
-
|
| 169 |
-
@staticmethod
|
| 170 |
-
async def get_list(data):
|
| 171 |
-
if type(data) == QuerySet:
|
| 172 |
-
parser = pydantic_queryset_creator(PortfolioSnapshot)
|
| 173 |
-
return await parser.from_queryset(data)
|
| 174 |
-
|
| 175 |
-
async def to_dict(self):
|
| 176 |
-
if type(self) == models.Model:
|
| 177 |
-
parser = pydantic_model_creator(PortfolioSnapshot)
|
| 178 |
-
return await parser.from_tortoise_orm(self)
|
| 179 |
-
|
| 180 |
-
|
| 181 |
class Meta:
|
| 182 |
table = "portfolio_snapshots"
|
| 183 |
-
unique_together = ("portfolio", "snapshot_date")
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Portfolio models — ONLY imports from tortoise.
|
| 3 |
+
NEVER import from .service, .routes, .schemas, or .utils
|
| 4 |
+
"""
|
| 5 |
from tortoise import fields, models
|
| 6 |
+
from tortoise.contrib.pydantic.creator import (
|
| 7 |
+
pydantic_model_creator,
|
| 8 |
+
pydantic_queryset_creator,
|
| 9 |
+
)
|
| 10 |
from tortoise.queryset import QuerySet
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# ──────────────────────────── MIXIN ────────────────────────────
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class SerializeMixin:
|
| 17 |
+
async def to_dict(self) -> dict:
|
| 18 |
+
schema = pydantic_model_creator(self.__class__)
|
| 19 |
+
obj = await schema.from_tortoise_orm(self)
|
| 20 |
+
return obj.model_dump()
|
| 21 |
+
|
| 22 |
+
@classmethod
|
| 23 |
+
async def get_list(cls, queryset) -> list[dict]:
|
| 24 |
+
if isinstance(queryset, QuerySet):
|
| 25 |
+
schema = pydantic_queryset_creator(cls)
|
| 26 |
+
obj = await schema.from_queryset(queryset)
|
| 27 |
+
return obj.model_dump()
|
| 28 |
+
return []
|
| 29 |
+
|
| 30 |
+
@classmethod
|
| 31 |
+
async def get_one(cls, pk) -> dict | None:
|
| 32 |
+
instance = await cls.get_or_none(pk=pk)
|
| 33 |
+
if instance:
|
| 34 |
+
return await instance.to_dict()
|
| 35 |
+
return None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ──────────────────────────── CONSTANTS ────────────────────────────
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class TransactionType:
|
| 42 |
+
BUY = "BUY"
|
| 43 |
+
SELL = "SELL"
|
| 44 |
+
DIVIDEND = "DIVIDEND"
|
| 45 |
+
COUPON = "COUPON"
|
| 46 |
+
ALL = ["BUY", "SELL", "DIVIDEND", "COUPON"]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class AssetType:
|
| 50 |
+
STOCK = "STOCK"
|
| 51 |
+
BOND = "BOND"
|
| 52 |
+
UTT = "UTT"
|
| 53 |
+
ALL = ["STOCK", "BOND", "UTT"]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class EventType:
|
| 57 |
+
COUPON = "COUPON"
|
| 58 |
+
DIVIDEND = "DIVIDEND"
|
| 59 |
+
MATURITY = "MATURITY"
|
| 60 |
+
EARNINGS = "EARNINGS"
|
| 61 |
+
ALL = ["COUPON", "DIVIDEND", "MATURITY", "EARNINGS"]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ──────────────────────────── PORTFOLIO ────────────────────────────
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class Portfolio(SerializeMixin, models.Model):
|
| 68 |
user = fields.ForeignKeyField("models.User", related_name="portfolios")
|
| 69 |
name = fields.CharField(max_length=100)
|
| 70 |
+
description = fields.TextField(null=True, default="")
|
| 71 |
is_active = fields.BooleanField(default=True)
|
| 72 |
created_at = fields.DatetimeField(auto_now_add=True)
|
| 73 |
updated_at = fields.DatetimeField(auto_now=True)
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
class Meta:
|
| 76 |
table = "portfolios"
|
| 77 |
+
unique_together = ("user", "name")
|
| 78 |
+
|
| 79 |
+
def __str__(self):
|
| 80 |
+
return self.name
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ──────────────────────────── HOLDINGS ────────────────────────────
|
| 84 |
|
| 85 |
+
|
| 86 |
+
class PortfolioStock(SerializeMixin, models.Model):
|
| 87 |
portfolio = fields.ForeignKeyField("models.Portfolio", related_name="stocks")
|
| 88 |
stock = fields.ForeignKeyField("models.Stock", related_name="portfolio_holdings")
|
| 89 |
+
quantity = fields.DecimalField(max_digits=15, decimal_places=4)
|
| 90 |
purchase_price = fields.DecimalField(max_digits=15, decimal_places=2)
|
| 91 |
purchase_date = fields.DateField()
|
| 92 |
+
notes = fields.TextField(null=True, default="")
|
| 93 |
created_at = fields.DatetimeField(auto_now_add=True)
|
| 94 |
updated_at = fields.DatetimeField(auto_now=True)
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
class Meta:
|
| 97 |
table = "portfolio_stocks"
|
| 98 |
+
unique_together = ("portfolio", "stock")
|
| 99 |
|
| 100 |
+
|
| 101 |
+
class PortfolioUTT(SerializeMixin, models.Model):
|
| 102 |
portfolio = fields.ForeignKeyField("models.Portfolio", related_name="utts")
|
| 103 |
+
fund = fields.ForeignKeyField(
|
| 104 |
+
"models.MutualFund", related_name="portfolio_holdings"
|
| 105 |
+
)
|
| 106 |
units_held = fields.DecimalField(max_digits=15, decimal_places=4)
|
| 107 |
purchase_price = fields.DecimalField(max_digits=15, decimal_places=2)
|
| 108 |
purchase_date = fields.DateField()
|
| 109 |
+
notes = fields.TextField(null=True, default="")
|
| 110 |
created_at = fields.DatetimeField(auto_now_add=True)
|
| 111 |
updated_at = fields.DatetimeField(auto_now=True)
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
class Meta:
|
| 114 |
+
table = "portfolio_funds"
|
| 115 |
+
unique_together = ("portfolio", "fund")
|
| 116 |
+
|
| 117 |
|
| 118 |
+
class PortfolioBond(SerializeMixin, models.Model):
|
|
|
|
| 119 |
portfolio = fields.ForeignKeyField("models.Portfolio", related_name="bonds")
|
| 120 |
bond = fields.ForeignKeyField("models.Bond", related_name="portfolio_holdings")
|
| 121 |
face_value_held = fields.BigIntField()
|
| 122 |
purchase_price = fields.DecimalField(max_digits=15, decimal_places=2)
|
| 123 |
purchase_date = fields.DateField()
|
| 124 |
+
notes = fields.TextField(null=True, default="")
|
| 125 |
created_at = fields.DatetimeField(auto_now_add=True)
|
| 126 |
updated_at = fields.DatetimeField(auto_now=True)
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
class Meta:
|
| 129 |
table = "portfolio_bonds"
|
| 130 |
+
unique_together = ("portfolio", "bond")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ──────────────────────────── TRANSACTIONS ────────────────────────────
|
| 134 |
|
| 135 |
+
|
| 136 |
+
class PortfolioTransaction(SerializeMixin, models.Model):
|
| 137 |
+
portfolio = fields.ForeignKeyField(
|
| 138 |
+
"models.Portfolio", related_name="transactions"
|
| 139 |
+
)
|
| 140 |
+
transaction_type = fields.CharField(max_length=20)
|
| 141 |
+
asset_type = fields.CharField(max_length=10)
|
| 142 |
+
asset_id = fields.IntField()
|
| 143 |
+
asset_name = fields.CharField(max_length=100, null=True, default="")
|
| 144 |
quantity = fields.DecimalField(max_digits=15, decimal_places=4)
|
| 145 |
price = fields.DecimalField(max_digits=15, decimal_places=2)
|
| 146 |
total_amount = fields.DecimalField(max_digits=15, decimal_places=2)
|
| 147 |
transaction_date = fields.DateField()
|
| 148 |
+
notes = fields.TextField(null=True, default="")
|
| 149 |
created_at = fields.DatetimeField(auto_now_add=True)
|
| 150 |
|
| 151 |
+
class Meta:
|
| 152 |
+
table = "portfolio_transactions"
|
| 153 |
+
ordering = ["-transaction_date", "-created_at"]
|
|
|
|
|
|
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
+
# ──────────────────────────── CALENDAR ────────────────────────────
|
| 157 |
|
|
|
|
|
|
|
| 158 |
|
| 159 |
+
class PortfolioCalendar(SerializeMixin, models.Model):
|
| 160 |
+
portfolio = fields.ForeignKeyField(
|
| 161 |
+
"models.Portfolio", related_name="calendar_events"
|
| 162 |
+
)
|
| 163 |
event_date = fields.DateField()
|
| 164 |
+
event_type = fields.CharField(max_length=50)
|
| 165 |
title = fields.CharField(max_length=200)
|
| 166 |
+
description = fields.TextField(null=True, default="")
|
| 167 |
+
asset_type = fields.CharField(max_length=10, null=True)
|
| 168 |
asset_id = fields.IntField(null=True)
|
| 169 |
+
estimated_amount = fields.DecimalField(
|
| 170 |
+
max_digits=15, decimal_places=2, null=True
|
| 171 |
+
)
|
| 172 |
is_completed = fields.BooleanField(default=False)
|
| 173 |
created_at = fields.DatetimeField(auto_now_add=True)
|
| 174 |
|
| 175 |
+
class Meta:
|
| 176 |
+
table = "portfolio_calendar"
|
| 177 |
+
ordering = ["event_date"]
|
|
|
|
|
|
|
| 178 |
|
| 179 |
|
| 180 |
+
# ──────────────────────────── SNAPSHOTS ────────────────────────────
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
|
| 183 |
+
class PortfolioSnapshot(SerializeMixin, models.Model):
|
| 184 |
+
portfolio = fields.ForeignKeyField(
|
| 185 |
+
"models.Portfolio", related_name="snapshots"
|
| 186 |
+
)
|
| 187 |
+
snapshot_date = fields.DateField()
|
|
|
|
|
|
|
|
|
|
| 188 |
total_value = fields.DecimalField(max_digits=20, decimal_places=2)
|
| 189 |
stock_value = fields.DecimalField(max_digits=20, decimal_places=2, default=0)
|
| 190 |
bond_value = fields.DecimalField(max_digits=20, decimal_places=2, default=0)
|
| 191 |
+
fund_value = fields.DecimalField(max_digits=20, decimal_places=2, default=0)
|
| 192 |
cash_value = fields.DecimalField(max_digits=20, decimal_places=2, default=0)
|
| 193 |
total_cost = fields.DecimalField(max_digits=20, decimal_places=2)
|
| 194 |
unrealized_gain_loss = fields.DecimalField(max_digits=20, decimal_places=2)
|
| 195 |
created_at = fields.DatetimeField(auto_now_add=True)
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
class Meta:
|
| 198 |
table = "portfolio_snapshots"
|
| 199 |
+
unique_together = ("portfolio", "snapshot_date")
|
| 200 |
+
ordering = ["-snapshot_date"]
|
App/routers/portfolio/routes.py
CHANGED
|
@@ -1,1379 +1,907 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
from
|
| 8 |
-
from
|
| 9 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from App.routers.users.utils import get_current_user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
from .models import (
|
| 12 |
Portfolio,
|
| 13 |
-
PortfolioSnapshot,
|
| 14 |
-
PortfolioBond,
|
| 15 |
-
PortfolioCalendar,
|
| 16 |
-
PortfolioTransaction,
|
| 17 |
PortfolioStock,
|
| 18 |
PortfolioUTT,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
)
|
| 20 |
from .schemas import (
|
| 21 |
PortfolioCreate,
|
| 22 |
PortfolioUpdate,
|
| 23 |
-
PortfolioBase,
|
| 24 |
-
PortfolioSummary,
|
| 25 |
StockHoldingCreate,
|
| 26 |
StockHoldingUpdate,
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
BondHoldingCreate,
|
| 32 |
BondHoldingUpdate,
|
| 33 |
-
|
| 34 |
CalendarEventCreate,
|
| 35 |
CalendarEventResponse,
|
| 36 |
TransactionDetailResponse,
|
| 37 |
-
PortfolioListResponse,
|
| 38 |
PositionResponse,
|
| 39 |
-
StockSellSchema,
|
| 40 |
-
UTTSellSchema,
|
| 41 |
-
BondSellSchema,
|
| 42 |
)
|
| 43 |
-
from .service import PortfolioService
|
| 44 |
-
from App.schemas import ResponseModel, AppException
|
| 45 |
-
from tortoise.contrib.pydantic import pydantic_model_creator, pydantic_queryset_creator
|
| 46 |
|
|
|
|
| 47 |
|
| 48 |
-
from fastapi import BackgroundTasks
|
| 49 |
-
from .service import PortfolioService # Ensure service is imported
|
| 50 |
-
from App.routers.tasks.models import ImportTask
|
| 51 |
-
from tortoise.expressions import Q # For querying JSON fields
|
| 52 |
-
from datetime import date
|
| 53 |
-
from datetime import date, datetime, timedelta
|
| 54 |
-
from App.routers.stocks.models import Dividend, Stock, StockPriceData
|
| 55 |
-
from decimal import Decimal
|
| 56 |
-
from .schemas import CalendarEventResponse # Import our new schema
|
| 57 |
-
from .models import Portfolio, PortfolioStock, PortfolioBond
|
| 58 |
-
from App.routers.utt.models import UTTFund
|
| 59 |
-
from App.routers.bonds.models import Bond
|
| 60 |
|
| 61 |
-
|
| 62 |
-
PortfolioStock_Pydantic = pydantic_model_creator(PortfolioStock, name="PortfolioStock")
|
| 63 |
-
PortfolioUTT_Pydantic = pydantic_model_creator(PortfolioUTT, name="PortfolioUTT")
|
| 64 |
-
PortfolioBond_Pydantic = pydantic_model_creator(PortfolioBond, name="PortfolioBond")
|
| 65 |
-
PortfolioTransaction_Pydantic = pydantic_model_creator(
|
| 66 |
-
PortfolioTransaction, name="PortfolioTransaction"
|
| 67 |
-
)
|
| 68 |
-
PortfolioCalendar_Pydantic = pydantic_model_creator(
|
| 69 |
-
PortfolioCalendar, name="PortfolioCalendar"
|
| 70 |
-
)
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
PortfolioSnapshotPydantic = pydantic_model_creator(
|
| 80 |
-
PortfolioSnapshot, name="PortfolioSnapshotResponse"
|
| 81 |
-
) # Renamed for clarity
|
| 82 |
|
| 83 |
-
router = APIRouter(prefix="/portfolios", tags=["portfolios"])
|
| 84 |
|
| 85 |
-
|
| 86 |
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
):
|
| 92 |
-
try:
|
| 93 |
-
portfolios = await PortfolioService.get_user_portfolios(
|
| 94 |
-
user_id=current_user.id, include_inactive=include_inactive
|
| 95 |
-
)
|
| 96 |
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
raise AppException(status_code=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
|
| 111 |
-
@router.post("
|
| 112 |
async def create_portfolio(
|
| 113 |
-
|
|
|
|
| 114 |
):
|
| 115 |
try:
|
| 116 |
portfolio = await PortfolioService.create_portfolio(
|
| 117 |
user_id=current_user.id,
|
| 118 |
-
name=
|
| 119 |
-
description=
|
| 120 |
-
)
|
| 121 |
-
portfolio_pydantic_data = await Portfolio_Pydantic.from_tortoise_orm(portfolio)
|
| 122 |
-
return ResponseModel(
|
| 123 |
-
success=True,
|
| 124 |
-
message="Portfolio created successfully",
|
| 125 |
-
data=portfolio_pydantic_data,
|
| 126 |
)
|
| 127 |
except Exception as e:
|
| 128 |
-
if "unique
|
| 129 |
-
|
| 130 |
-
)
|
| 131 |
-
raise AppException(status_code=400, detail="Portfolio name already exists")
|
| 132 |
-
raise AppException(status_code=500, detail=str(e))
|
| 133 |
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
@router.get("/{portfolio_id}", response_model=ResponseModel)
|
| 136 |
-
async def get_portfolio_summary_route( # Renamed to avoid conflict with service method
|
| 137 |
-
portfolio_id: int, current_user=Depends(get_current_user)
|
| 138 |
-
):
|
| 139 |
-
try:
|
| 140 |
-
portfolio = await Portfolio.get_or_none(
|
| 141 |
-
id=portfolio_id, user_id=current_user.id
|
| 142 |
-
)
|
| 143 |
-
if not portfolio:
|
| 144 |
-
raise AppException(status_code=404, detail="Portfolio not found")
|
| 145 |
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
raise
|
| 155 |
-
except Exception as e:
|
| 156 |
-
raise AppException(status_code=500, detail=str(e))
|
| 157 |
|
| 158 |
|
| 159 |
-
@router.put("/{portfolio_id}",
|
| 160 |
async def update_portfolio(
|
| 161 |
portfolio_id: int,
|
| 162 |
-
|
| 163 |
current_user=Depends(get_current_user),
|
| 164 |
):
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
)
|
| 169 |
-
if not portfolio:
|
| 170 |
-
raise AppException(status_code=404, detail="Portfolio not found")
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
await portfolio.update_from_dict(update_data).save()
|
| 175 |
-
|
| 176 |
-
portfolio_pydantic_data = await Portfolio_Pydantic.from_tortoise_orm(portfolio)
|
| 177 |
-
return ResponseModel(
|
| 178 |
-
success=True,
|
| 179 |
-
message="Portfolio updated successfully",
|
| 180 |
-
data=portfolio_pydantic_data,
|
| 181 |
-
)
|
| 182 |
-
except AppException:
|
| 183 |
-
raise
|
| 184 |
-
except Exception as e:
|
| 185 |
-
raise AppException(status_code=500, detail=str(e))
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
@router.delete("/{portfolio_id}", response_model=ResponseModel)
|
| 189 |
-
async def delete_portfolio(portfolio_id: int, current_user=Depends(get_current_user)):
|
| 190 |
-
try:
|
| 191 |
-
portfolio = await Portfolio.get_or_none(
|
| 192 |
-
id=portfolio_id, user_id=current_user.id
|
| 193 |
-
)
|
| 194 |
-
if not portfolio:
|
| 195 |
-
raise AppException(status_code=404, detail="Portfolio not found")
|
| 196 |
|
| 197 |
-
portfolio.is_active = False
|
| 198 |
-
await portfolio.save()
|
| 199 |
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
|
| 210 |
|
| 211 |
-
#
|
|
|
|
|
|
|
| 212 |
|
| 213 |
|
| 214 |
-
@router.post(
|
| 215 |
-
|
| 216 |
-
response_model=ResponseModel,
|
| 217 |
-
summary="Buy/Add Stock to Portfolio",
|
| 218 |
-
)
|
| 219 |
-
async def add_stock_to_portfolio_route( # Renamed
|
| 220 |
portfolio_id: int,
|
| 221 |
-
|
| 222 |
current_user=Depends(get_current_user),
|
| 223 |
):
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
quantity_to_add=stock_data.quantity,
|
| 237 |
-
purchase_price_of_lot=stock_data.purchase_price,
|
| 238 |
-
purchase_date=stock_data.purchase_date,
|
| 239 |
-
notes=stock_data.notes,
|
| 240 |
-
)
|
| 241 |
-
# Convert full holding with related stock to response model if needed, or use Pydantic ORM model
|
| 242 |
-
# For simplicity, using the Pydantic model from ORM.
|
| 243 |
-
# The PortfolioStock_Pydantic might not include stock_symbol, stock_name if not configured.
|
| 244 |
-
# Re-fetch for full response if needed or ensure PortfolioStock_Pydantic has nested details.
|
| 245 |
-
# For now, assume PortfolioStock_Pydantic is sufficient.
|
| 246 |
-
holding_pydantic_data = await PortfolioStock_Pydantic.from_tortoise_orm(holding)
|
| 247 |
-
return ResponseModel(
|
| 248 |
-
success=True,
|
| 249 |
-
message="Stock bought and added/updated in portfolio successfully",
|
| 250 |
-
data=holding_pydantic_data, # This will be the ORM model, not StockHoldingResponse
|
| 251 |
-
)
|
| 252 |
-
except AppException:
|
| 253 |
-
raise
|
| 254 |
-
except Exception as e:
|
| 255 |
-
raise AppException(status_code=500, detail=str(e))
|
| 256 |
|
| 257 |
|
| 258 |
-
@router.post(
|
| 259 |
-
|
| 260 |
-
response_model=ResponseModel,
|
| 261 |
-
summary="Sell Stock from Portfolio",
|
| 262 |
-
)
|
| 263 |
-
async def sell_stock_from_portfolio(
|
| 264 |
portfolio_id: int,
|
| 265 |
-
stock_id: int,
|
| 266 |
-
|
| 267 |
current_user=Depends(get_current_user),
|
| 268 |
):
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
quantity_to_sell=sell_data.quantity,
|
| 282 |
-
sell_price=sell_data.sell_price,
|
| 283 |
-
sell_date=sell_data.sell_date,
|
| 284 |
-
notes=sell_data.notes,
|
| 285 |
-
)
|
| 286 |
-
transaction_pydantic_data = (
|
| 287 |
-
await PortfolioTransaction_Pydantic.from_tortoise_orm(transaction)
|
| 288 |
-
)
|
| 289 |
-
return ResponseModel(
|
| 290 |
-
success=True,
|
| 291 |
-
message="Stock sold successfully",
|
| 292 |
-
data=transaction_pydantic_data,
|
| 293 |
-
)
|
| 294 |
-
except DoesNotExist as e:
|
| 295 |
-
raise AppException(status_code=404, detail=str(e))
|
| 296 |
-
except AppException:
|
| 297 |
-
raise
|
| 298 |
-
except Exception as e:
|
| 299 |
-
raise AppException(status_code=500, detail=str(e))
|
| 300 |
|
| 301 |
|
| 302 |
-
@router.put("/{portfolio_id}/stocks/{stock_id}",
|
| 303 |
async def update_stock_holding(
|
| 304 |
portfolio_id: int,
|
| 305 |
-
stock_id: int,
|
| 306 |
-
|
| 307 |
current_user=Depends(get_current_user),
|
| 308 |
):
|
| 309 |
-
|
| 310 |
-
portfolio = await Portfolio.get_or_none(
|
| 311 |
-
id=portfolio_id, user_id=current_user.id
|
| 312 |
-
)
|
| 313 |
-
if not portfolio:
|
| 314 |
-
raise AppException(status_code=404, detail="Portfolio not found")
|
| 315 |
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
raise AppException(
|
| 322 |
-
status_code=404,
|
| 323 |
-
detail="Stock holding for this stock not found in portfolio.",
|
| 324 |
-
)
|
| 325 |
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
# This endpoint should primarily be for 'notes' or very specific adjustments.
|
| 330 |
-
if (
|
| 331 |
-
"quantity" in update_data
|
| 332 |
-
or "purchase_price" in update_data
|
| 333 |
-
or "purchase_date" in update_data
|
| 334 |
-
):
|
| 335 |
-
# Consider adding specific service methods for these adjustments if complex logic is needed.
|
| 336 |
-
pass # Allowing direct update for now.
|
| 337 |
-
|
| 338 |
-
if update_data:
|
| 339 |
-
await holding.update_from_dict(update_data).save()
|
| 340 |
-
|
| 341 |
-
holding_pydantic_data = await PortfolioStock_Pydantic.from_tortoise_orm(holding)
|
| 342 |
-
return ResponseModel(
|
| 343 |
-
success=True,
|
| 344 |
-
message="Stock holding updated successfully",
|
| 345 |
-
data=holding_pydantic_data,
|
| 346 |
-
)
|
| 347 |
-
except DoesNotExist as e: # Should be caught by the get_or_none checks
|
| 348 |
-
raise AppException(status_code=404, detail=str(e))
|
| 349 |
-
except AppException:
|
| 350 |
-
raise
|
| 351 |
-
except Exception as e:
|
| 352 |
-
raise AppException(status_code=500, detail=str(e))
|
| 353 |
|
|
|
|
|
|
|
| 354 |
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
summary="Delete Stock Holding",
|
| 359 |
-
)
|
| 360 |
-
async def remove_stock_from_portfolio(
|
| 361 |
portfolio_id: int,
|
| 362 |
-
stock_id: int,
|
| 363 |
current_user=Depends(get_current_user),
|
| 364 |
):
|
| 365 |
-
|
| 366 |
-
portfolio = await Portfolio.get_or_none(
|
| 367 |
-
id=portfolio_id, user_id=current_user.id
|
| 368 |
-
)
|
| 369 |
-
if not portfolio:
|
| 370 |
-
raise AppException(status_code=404, detail="Portfolio not found")
|
| 371 |
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
)
|
| 377 |
-
if not success:
|
| 378 |
-
raise AppException(
|
| 379 |
-
status_code=404,
|
| 380 |
-
detail="Stock holding not found or could not be deleted",
|
| 381 |
-
)
|
| 382 |
|
| 383 |
-
return ResponseModel(
|
| 384 |
-
success=True,
|
| 385 |
-
message="Stock holding removed from portfolio successfully",
|
| 386 |
-
data=None,
|
| 387 |
-
)
|
| 388 |
-
except AppException:
|
| 389 |
-
raise
|
| 390 |
-
except Exception as e:
|
| 391 |
-
raise AppException(status_code=500, detail=str(e))
|
| 392 |
|
|
|
|
|
|
|
|
|
|
| 393 |
|
| 394 |
-
# UTT Holdings Routes
|
| 395 |
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
"/{portfolio_id}/utts",
|
| 399 |
-
response_model=ResponseModel,
|
| 400 |
-
summary="Buy/Add UTT to Portfolio",
|
| 401 |
-
)
|
| 402 |
-
async def add_utt_to_portfolio_route( # Renamed
|
| 403 |
portfolio_id: int,
|
| 404 |
-
|
| 405 |
current_user=Depends(get_current_user),
|
| 406 |
):
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
units_to_add=utt_data.units_held,
|
| 420 |
-
purchase_price_of_lot=utt_data.purchase_price,
|
| 421 |
-
purchase_date=utt_data.purchase_date,
|
| 422 |
-
notes=utt_data.notes,
|
| 423 |
-
)
|
| 424 |
-
holding_pydantic_data = await PortfolioUTT_Pydantic.from_tortoise_orm(holding)
|
| 425 |
-
return ResponseModel(
|
| 426 |
-
success=True,
|
| 427 |
-
message="UTT fund bought and added/updated in portfolio successfully",
|
| 428 |
-
data=holding_pydantic_data,
|
| 429 |
-
)
|
| 430 |
-
except DoesNotExist as e:
|
| 431 |
-
raise AppException(status_code=404, detail=str(e))
|
| 432 |
-
except AppException:
|
| 433 |
-
raise
|
| 434 |
-
except Exception as e:
|
| 435 |
-
raise AppException(status_code=500, detail=str(e))
|
| 436 |
|
| 437 |
|
| 438 |
-
@router.post(
|
| 439 |
-
|
| 440 |
-
response_model=ResponseModel,
|
| 441 |
-
summary="Sell UTT from Portfolio",
|
| 442 |
-
)
|
| 443 |
-
async def sell_utt_from_portfolio(
|
| 444 |
portfolio_id: int,
|
| 445 |
-
|
| 446 |
-
|
| 447 |
current_user=Depends(get_current_user),
|
| 448 |
):
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
units_to_sell=sell_data.units_to_sell, # Ensure schema field name is correct
|
| 462 |
-
sell_price=sell_data.sell_price,
|
| 463 |
-
sell_date=sell_data.sell_date,
|
| 464 |
-
notes=sell_data.notes,
|
| 465 |
-
)
|
| 466 |
-
transaction_pydantic_data = (
|
| 467 |
-
await PortfolioTransaction_Pydantic.from_tortoise_orm(transaction)
|
| 468 |
-
)
|
| 469 |
-
return ResponseModel(
|
| 470 |
-
success=True,
|
| 471 |
-
message="UTT units sold successfully",
|
| 472 |
-
data=transaction_pydantic_data,
|
| 473 |
-
)
|
| 474 |
-
except DoesNotExist as e:
|
| 475 |
-
raise AppException(status_code=404, detail=str(e))
|
| 476 |
-
except AppException:
|
| 477 |
-
raise
|
| 478 |
-
except Exception as e:
|
| 479 |
-
raise AppException(status_code=500, detail=str(e))
|
| 480 |
|
| 481 |
|
| 482 |
-
@router.put("/{portfolio_id}/
|
| 483 |
-
async def
|
| 484 |
portfolio_id: int,
|
| 485 |
-
|
| 486 |
-
|
| 487 |
current_user=Depends(get_current_user),
|
| 488 |
):
|
| 489 |
-
|
| 490 |
-
portfolio = await Portfolio.get_or_none(
|
| 491 |
-
id=portfolio_id, user_id=current_user.id
|
| 492 |
-
)
|
| 493 |
-
if not portfolio:
|
| 494 |
-
raise AppException(status_code=404, detail="Portfolio not found")
|
| 495 |
-
|
| 496 |
-
holding = await PortfolioUTT.get_or_none(
|
| 497 |
-
utt_fund_id=utt_fund_id, portfolio_id=portfolio_id
|
| 498 |
-
)
|
| 499 |
-
if not holding:
|
| 500 |
-
raise AppException(
|
| 501 |
-
status_code=404,
|
| 502 |
-
detail="UTT holding for this fund not found in portfolio.",
|
| 503 |
-
)
|
| 504 |
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
"units_held" in update_data
|
| 509 |
-
or "purchase_price" in update_data
|
| 510 |
-
or "purchase_date" in update_data
|
| 511 |
-
):
|
| 512 |
-
pass # Allowing direct update
|
| 513 |
|
| 514 |
-
|
| 515 |
-
|
|
|
|
| 516 |
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
success=True,
|
| 520 |
-
message="UTT holding updated successfully",
|
| 521 |
-
data=holding_pydantic_data,
|
| 522 |
-
)
|
| 523 |
-
except DoesNotExist as e:
|
| 524 |
-
raise AppException(status_code=404, detail=str(e))
|
| 525 |
-
except AppException:
|
| 526 |
-
raise
|
| 527 |
-
except Exception as e:
|
| 528 |
-
raise AppException(status_code=500, detail=str(e))
|
| 529 |
|
| 530 |
|
| 531 |
-
@router.delete(
|
| 532 |
-
|
| 533 |
-
response_model=ResponseModel,
|
| 534 |
-
summary="Delete UTT Holding",
|
| 535 |
-
)
|
| 536 |
-
async def remove_utt_from_portfolio(
|
| 537 |
portfolio_id: int,
|
| 538 |
-
|
| 539 |
current_user=Depends(get_current_user),
|
| 540 |
):
|
| 541 |
-
|
| 542 |
-
portfolio = await Portfolio.get_or_none(
|
| 543 |
-
id=portfolio_id, user_id=current_user.id
|
| 544 |
-
)
|
| 545 |
-
if not portfolio:
|
| 546 |
-
raise AppException(status_code=404, detail="Portfolio not found")
|
| 547 |
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
)
|
| 553 |
-
if not success:
|
| 554 |
-
raise AppException(
|
| 555 |
-
status_code=404, detail="UTT holding not found or could not be deleted"
|
| 556 |
-
)
|
| 557 |
-
|
| 558 |
-
return ResponseModel(
|
| 559 |
-
success=True,
|
| 560 |
-
message="UTT fund holding removed from portfolio successfully",
|
| 561 |
-
data=None,
|
| 562 |
-
)
|
| 563 |
-
except AppException:
|
| 564 |
-
raise
|
| 565 |
-
except Exception as e:
|
| 566 |
-
raise AppException(status_code=500, detail=str(e))
|
| 567 |
|
| 568 |
|
| 569 |
-
#
|
|
|
|
|
|
|
| 570 |
|
| 571 |
|
| 572 |
-
@router.post(
|
| 573 |
-
|
| 574 |
-
response_model=ResponseModel,
|
| 575 |
-
summary="Buy/Add Bond to Portfolio",
|
| 576 |
-
)
|
| 577 |
-
async def add_bond_to_portfolio_route( # Renamed
|
| 578 |
portfolio_id: int,
|
| 579 |
-
|
| 580 |
current_user=Depends(get_current_user),
|
| 581 |
):
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
|
|
|
|
|
|
| 587 |
raise AppException(
|
| 588 |
-
status_code=404,
|
| 589 |
)
|
|
|
|
| 590 |
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
bond_id=_bond.id,
|
| 595 |
-
face_value_to_add=bond_data.face_value_held,
|
| 596 |
-
total_purchase_price_of_lot=bond_data.purchase_price, # Assumed total cost from schema
|
| 597 |
-
purchase_date=bond_data.purchase_date,
|
| 598 |
-
notes=bond_data.notes,
|
| 599 |
-
)
|
| 600 |
-
holding_pydantic_data = await PortfolioBond_Pydantic.from_tortoise_orm(holding)
|
| 601 |
-
return ResponseModel(
|
| 602 |
-
success=True,
|
| 603 |
-
message="Bond bought and added/updated in portfolio successfully",
|
| 604 |
-
data=holding_pydantic_data,
|
| 605 |
)
|
| 606 |
-
except DoesNotExist as e:
|
| 607 |
-
raise AppException(status_code=404, detail=str(e))
|
| 608 |
-
except AppException:
|
| 609 |
-
raise
|
| 610 |
-
except Exception as e:
|
| 611 |
-
raise AppException(status_code=500, detail=str(e))
|
| 612 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
summary="Sell Bond from Portfolio",
|
| 618 |
-
)
|
| 619 |
-
async def sell_bond_from_portfolio(
|
| 620 |
portfolio_id: int,
|
| 621 |
-
bond_id: int,
|
| 622 |
-
|
| 623 |
current_user=Depends(get_current_user),
|
| 624 |
):
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
face_value_to_sell=sell_data.face_value_to_sell,
|
| 638 |
-
sell_price_total=sell_data.sell_price, # Assumed total proceeds from schema
|
| 639 |
-
sell_date=sell_data.sell_date,
|
| 640 |
-
notes=sell_data.notes,
|
| 641 |
-
)
|
| 642 |
-
transaction_pydantic_data = (
|
| 643 |
-
await PortfolioTransaction_Pydantic.from_tortoise_orm(transaction)
|
| 644 |
-
)
|
| 645 |
-
return ResponseModel(
|
| 646 |
-
success=True,
|
| 647 |
-
message="Bond portion sold successfully",
|
| 648 |
-
data=transaction_pydantic_data,
|
| 649 |
-
)
|
| 650 |
-
except DoesNotExist as e:
|
| 651 |
-
raise AppException(status_code=404, detail=str(e))
|
| 652 |
-
except AppException:
|
| 653 |
-
raise
|
| 654 |
-
except Exception as e:
|
| 655 |
-
raise AppException(status_code=500, detail=str(e))
|
| 656 |
|
| 657 |
|
| 658 |
-
@router.put("/{portfolio_id}/bonds/{bond_id}",
|
| 659 |
async def update_bond_holding(
|
| 660 |
portfolio_id: int,
|
| 661 |
-
bond_id: int,
|
| 662 |
-
|
| 663 |
current_user=Depends(get_current_user),
|
| 664 |
):
|
| 665 |
-
|
| 666 |
-
portfolio = await Portfolio.get_or_none(
|
| 667 |
-
id=portfolio_id, user_id=current_user.id
|
| 668 |
-
)
|
| 669 |
-
if not portfolio:
|
| 670 |
-
raise AppException(status_code=404, detail="Portfolio not found")
|
| 671 |
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
status_code=404,
|
| 678 |
-
detail="Bond holding for this bond not found in portfolio.",
|
| 679 |
-
)
|
| 680 |
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
# should ideally be adjusted proportionally to maintain average cost per unit of FV,
|
| 685 |
-
# unless it's a specific correction.
|
| 686 |
-
if (
|
| 687 |
-
"face_value_held" in update_data
|
| 688 |
-
and "purchase_price" not in update_data
|
| 689 |
-
and holding.face_value_held > 0
|
| 690 |
-
):
|
| 691 |
-
# If only face_value_held is changing, adjust purchase_price proportionally
|
| 692 |
-
# This is complex for a simple PUT, better handled by specific service method or by requiring both.
|
| 693 |
-
# For now, if only FV changes, the total cost is NOT proportionally adjusted here.
|
| 694 |
-
# User would need to provide new total purchase_price if FV changes and cost basis needs adjustment.
|
| 695 |
-
pass
|
| 696 |
-
elif "purchase_price" in update_data: # Allows direct update of total cost
|
| 697 |
-
pass
|
| 698 |
-
|
| 699 |
-
if update_data:
|
| 700 |
-
await holding.update_from_dict(update_data).save()
|
| 701 |
-
|
| 702 |
-
holding_pydantic_data = await PortfolioBond_Pydantic.from_tortoise_orm(holding)
|
| 703 |
-
return ResponseModel(
|
| 704 |
-
success=True,
|
| 705 |
-
message="Bond holding updated successfully",
|
| 706 |
-
data=holding_pydantic_data,
|
| 707 |
-
)
|
| 708 |
-
except DoesNotExist as e:
|
| 709 |
-
raise AppException(status_code=404, detail=str(e))
|
| 710 |
-
except AppException:
|
| 711 |
-
raise
|
| 712 |
-
except Exception as e:
|
| 713 |
-
raise AppException(status_code=500, detail=str(e))
|
| 714 |
|
|
|
|
|
|
|
| 715 |
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
summary="Delete Bond Holding",
|
| 720 |
-
)
|
| 721 |
-
async def remove_bond_from_portfolio(
|
| 722 |
portfolio_id: int,
|
| 723 |
-
bond_id: int,
|
| 724 |
current_user=Depends(get_current_user),
|
| 725 |
):
|
| 726 |
-
|
| 727 |
-
portfolio = await Portfolio.get_or_none(
|
| 728 |
-
id=portfolio_id, user_id=current_user.id
|
| 729 |
-
)
|
| 730 |
-
if not portfolio:
|
| 731 |
-
raise AppException(status_code=404, detail="Portfolio not found")
|
| 732 |
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
)
|
| 738 |
-
if not success:
|
| 739 |
-
raise AppException(
|
| 740 |
-
status_code=404, detail="Bond holding not found or could not be deleted"
|
| 741 |
-
)
|
| 742 |
-
|
| 743 |
-
return ResponseModel(
|
| 744 |
-
success=True,
|
| 745 |
-
message="Bond holding removed from portfolio successfully",
|
| 746 |
-
data=None,
|
| 747 |
-
)
|
| 748 |
-
except AppException:
|
| 749 |
-
raise
|
| 750 |
-
except Exception as e:
|
| 751 |
-
raise AppException(status_code=500, detail=str(e))
|
| 752 |
|
| 753 |
|
| 754 |
-
#
|
|
|
|
|
|
|
| 755 |
|
| 756 |
|
| 757 |
-
@router.
|
| 758 |
-
async def
|
| 759 |
-
portfolio_id: int,
|
| 760 |
-
event_data: CalendarEventCreate,
|
| 761 |
-
current_user=Depends(get_current_user),
|
| 762 |
-
):
|
| 763 |
-
try:
|
| 764 |
-
portfolio = await Portfolio.get_or_none(
|
| 765 |
-
id=portfolio_id, user_id=current_user.id
|
| 766 |
-
)
|
| 767 |
-
if not portfolio:
|
| 768 |
-
raise AppException(status_code=404, detail="Portfolio not found")
|
| 769 |
-
|
| 770 |
-
event = await PortfolioCalendar.create(
|
| 771 |
-
portfolio_id=portfolio_id, **event_data.dict()
|
| 772 |
-
)
|
| 773 |
-
event_pydantic_data = await PortfolioCalendar_Pydantic.from_tortoise_orm(event)
|
| 774 |
-
return ResponseModel(
|
| 775 |
-
success=True,
|
| 776 |
-
message="Calendar event added successfully",
|
| 777 |
-
data=event_pydantic_data,
|
| 778 |
-
)
|
| 779 |
-
except AppException:
|
| 780 |
-
raise
|
| 781 |
-
except Exception as e:
|
| 782 |
-
raise AppException(status_code=500, detail=str(e))
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
@router.get("/{portfolio_id}/transactions", response_model=ResponseModel)
|
| 786 |
-
async def get_portfolio_transactions(
|
| 787 |
portfolio_id: int,
|
| 788 |
limit: int = Query(50, ge=1, le=200),
|
| 789 |
offset: int = Query(0, ge=0),
|
| 790 |
current_user=Depends(get_current_user),
|
| 791 |
):
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
)
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
.offset(offset)
|
| 804 |
-
.limit(limit)
|
| 805 |
-
)
|
| 806 |
-
transactions_list = await transactions_query.all()
|
| 807 |
-
|
| 808 |
-
# --- ENRICHMENT LOGIC STARTS HERE ---
|
| 809 |
-
|
| 810 |
-
# 2. COLLECT UNIQUE ASSET IDs FROM THE TRANSACTION LIST
|
| 811 |
-
stock_ids = set()
|
| 812 |
-
utt_ids = set()
|
| 813 |
-
bond_ids = set()
|
| 814 |
-
|
| 815 |
-
for t in transactions_list:
|
| 816 |
-
if t.asset_type == "STOCK":
|
| 817 |
-
stock_ids.add(t.asset_id)
|
| 818 |
-
elif t.asset_type == "UTT":
|
| 819 |
-
utt_ids.add(t.asset_id)
|
| 820 |
-
elif t.asset_type == "BOND":
|
| 821 |
-
bond_ids.add(t.asset_id)
|
| 822 |
-
|
| 823 |
-
# 3. BULK FETCH ASSET DETAILS
|
| 824 |
-
stocks_map: Dict[int, Stock] = {
|
| 825 |
-
s.id: s for s in await Stock.filter(id__in=list(stock_ids))
|
| 826 |
-
}
|
| 827 |
-
utts_map: Dict[int, UTTFund] = {
|
| 828 |
-
u.id: u for u in await UTTFund.filter(id__in=list(utt_ids))
|
| 829 |
-
}
|
| 830 |
-
bonds_map: Dict[int, Bond] = {
|
| 831 |
-
b.id: b for b in await Bond.filter(id__in=list(bond_ids))
|
| 832 |
-
}
|
| 833 |
-
|
| 834 |
-
# 4. CONSTRUCT THE ENRICHED RESPONSE
|
| 835 |
-
enriched_transactions: List[TransactionDetailResponse] = []
|
| 836 |
-
for t in transactions_list:
|
| 837 |
-
asset_name = None
|
| 838 |
-
asset_symbol = None
|
| 839 |
-
|
| 840 |
-
if t.asset_type == "STOCK" and t.asset_id in stocks_map:
|
| 841 |
-
asset_name = stocks_map[t.asset_id].name
|
| 842 |
-
asset_symbol = stocks_map[t.asset_id].symbol
|
| 843 |
-
elif t.asset_type == "UTT" and t.asset_id in utts_map:
|
| 844 |
-
asset_name = utts_map[t.asset_id].name
|
| 845 |
-
asset_symbol = utts_map[t.asset_id].symbol
|
| 846 |
-
elif t.asset_type == "BOND" and t.asset_id in bonds_map:
|
| 847 |
-
bond = bonds_map[t.asset_id]
|
| 848 |
-
asset_name = f"{bond.maturity_years} Yr Treasury Bond"
|
| 849 |
-
asset_symbol = bond.isin
|
| 850 |
-
|
| 851 |
-
# Create the enriched Pydantic model
|
| 852 |
-
enriched_transaction = TransactionDetailResponse.model_validate(
|
| 853 |
-
{
|
| 854 |
-
**t.__dict__, # Unpack the transaction's own fields
|
| 855 |
-
"asset_name": asset_name,
|
| 856 |
-
"asset_symbol": asset_symbol,
|
| 857 |
-
}
|
| 858 |
-
)
|
| 859 |
-
enriched_transactions.append(enriched_transaction)
|
| 860 |
-
|
| 861 |
-
# --- ENRICHMENT LOGIC ENDS ---
|
| 862 |
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
|
|
|
| 866 |
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 877 |
)
|
| 878 |
-
except Exception as e:
|
| 879 |
-
raise AppException(status_code=500, detail=str(e))
|
| 880 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 881 |
|
| 882 |
-
# Performance and Analytics Routes
|
| 883 |
|
|
|
|
|
|
|
|
|
|
| 884 |
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
)
|
| 890 |
-
async def get_portfolio_positions(
|
| 891 |
-
portfolio_id: int, current_user=Depends(get_current_user)
|
| 892 |
):
|
| 893 |
-
|
| 894 |
-
Calculates and retrieves all current positions in a portfolio.
|
| 895 |
-
It processes all buy/sell transactions to determine average cost,
|
| 896 |
-
fetches the latest market price, and calculates current value and profit/loss.
|
| 897 |
-
"""
|
| 898 |
-
# 1. AUTHENTICATION & VALIDATION
|
| 899 |
-
portfolio = await Portfolio.get_or_none(id=portfolio_id, user_id=current_user.id)
|
| 900 |
-
if not portfolio:
|
| 901 |
-
raise AppException(status_code=404, detail="Portfolio not found")
|
| 902 |
|
| 903 |
-
#
|
| 904 |
transactions = await PortfolioTransaction.filter(
|
| 905 |
portfolio_id=portfolio_id
|
| 906 |
).order_by("transaction_date")
|
| 907 |
|
| 908 |
-
|
| 909 |
-
# Key: (asset_type, asset_id), Value: {buy_qty, buy_cost, sell_qty}
|
| 910 |
-
aggregated_data: Dict[tuple, Dict] = {}
|
| 911 |
-
|
| 912 |
for t in transactions:
|
| 913 |
-
|
| 914 |
-
if
|
| 915 |
-
|
| 916 |
-
"buy_qty": Decimal("0.0"),
|
| 917 |
-
"buy_cost": Decimal("0.0"),
|
| 918 |
-
"sell_qty": Decimal("0.0"),
|
| 919 |
-
}
|
| 920 |
|
| 921 |
if t.transaction_type == "BUY":
|
| 922 |
-
|
| 923 |
-
|
| 924 |
elif t.transaction_type == "SELL":
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
# 3. PROCESS AGGREGATES AND FETCH LIVE DATA
|
| 928 |
-
position_responses: List[PositionResponse] = []
|
| 929 |
-
|
| 930 |
-
for asset_key, data in aggregated_data.items():
|
| 931 |
-
asset_type, asset_id = asset_key
|
| 932 |
|
| 933 |
-
|
|
|
|
| 934 |
|
| 935 |
-
|
| 936 |
-
|
|
|
|
| 937 |
continue
|
| 938 |
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
data["buy_cost"] / data["buy_qty"]
|
| 942 |
-
if data["buy_qty"] > 0
|
| 943 |
-
else Decimal("0.0")
|
| 944 |
-
)
|
| 945 |
-
total_invested = current_quantity * avg_buy_price
|
| 946 |
|
| 947 |
-
|
| 948 |
-
current_price = Decimal("0.0")
|
| 949 |
asset_name = "Unknown"
|
| 950 |
asset_symbol = "N/A"
|
| 951 |
|
|
|
|
| 952 |
if asset_type == "STOCK":
|
| 953 |
stock = await Stock.get_or_none(id=asset_id)
|
| 954 |
if stock:
|
| 955 |
asset_name = stock.name
|
| 956 |
asset_symbol = stock.symbol
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
.
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
| 967 |
-
|
| 968 |
-
|
| 969 |
-
asset_symbol = utt.symbol
|
| 970 |
-
price_data = (
|
| 971 |
-
await UTTFundData.filter(fund_id=asset_id).order_by("-date").first()
|
| 972 |
-
)
|
| 973 |
-
if price_data:
|
| 974 |
-
current_price = Decimal(str(price_data.nav_per_unit))
|
| 975 |
|
| 976 |
elif asset_type == "BOND":
|
| 977 |
bond = await Bond.get_or_none(id=asset_id)
|
| 978 |
if bond:
|
| 979 |
asset_name = f"{bond.maturity_years} Yr Treasury Bond"
|
| 980 |
-
asset_symbol = bond
|
| 981 |
-
# Bond valuation is complex. We'll use a simplified assumption that the
|
| 982 |
-
# "price" is 100 for valuation purposes against its face value.
|
| 983 |
-
# Here, we'll represent price_per_100.
|
| 984 |
current_price = (
|
| 985 |
Decimal(str(bond.price_per_100))
|
| 986 |
-
if bond
|
| 987 |
-
else Decimal("100
|
| 988 |
)
|
| 989 |
|
| 990 |
-
|
| 991 |
-
current_value = current_quantity * current_price
|
| 992 |
profit_loss = current_value - total_invested
|
| 993 |
-
|
| 994 |
-
(profit_loss / total_invested
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 995 |
)
|
| 996 |
|
| 997 |
-
# Create the response object
|
| 998 |
-
position = PositionResponse(
|
| 999 |
-
asset_id=asset_id,
|
| 1000 |
-
asset_type=asset_type.capitalize(), # "STOCK" -> "Stock"
|
| 1001 |
-
asset_name=asset_name,
|
| 1002 |
-
asset_symbol=asset_symbol,
|
| 1003 |
-
quantity=current_quantity,
|
| 1004 |
-
avg_buy_price=round(avg_buy_price, 4),
|
| 1005 |
-
total_invested=round(total_invested, 2),
|
| 1006 |
-
current_price=round(current_price, 4),
|
| 1007 |
-
current_value=round(current_value, 2),
|
| 1008 |
-
profit_loss=round(profit_loss, 2),
|
| 1009 |
-
profit_loss_percent=round(float(profit_loss_percent), 2),
|
| 1010 |
-
)
|
| 1011 |
-
position_responses.append(position)
|
| 1012 |
-
|
| 1013 |
-
# 4. RETURN THE FINAL RESPONSE
|
| 1014 |
return ResponseModel(
|
| 1015 |
success=True,
|
| 1016 |
-
message="Positions retrieved
|
| 1017 |
-
data={"positions":
|
| 1018 |
)
|
| 1019 |
|
| 1020 |
|
| 1021 |
-
|
| 1022 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1023 |
portfolio_id: int,
|
| 1024 |
-
|
| 1025 |
-
None, description="Date for the snapshot. Defaults to today if None."
|
| 1026 |
-
),
|
| 1027 |
current_user=Depends(get_current_user),
|
| 1028 |
):
|
| 1029 |
-
|
| 1030 |
-
portfolio = await Portfolio.get_or_none(
|
| 1031 |
-
id=portfolio_id, user_id=current_user.id
|
| 1032 |
-
)
|
| 1033 |
-
if not portfolio:
|
| 1034 |
-
raise AppException(status_code=404, detail="Portfolio not found")
|
| 1035 |
-
|
| 1036 |
-
snapshot_orm = await PortfolioService.create_portfolio_snapshot(
|
| 1037 |
-
portfolio_id=portfolio_id, snapshot_date_input=snapshot_date
|
| 1038 |
-
)
|
| 1039 |
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
|
| 1043 |
-
|
| 1044 |
-
|
| 1045 |
-
success=True,
|
| 1046 |
-
message="Portfolio snapshot created successfully",
|
| 1047 |
-
data=snapshot_pydantic_data,
|
| 1048 |
-
)
|
| 1049 |
-
except NotImplementedError as e: # Catch specific error from service
|
| 1050 |
-
raise AppException(status_code=501, detail=str(e))
|
| 1051 |
-
except DoesNotExist:
|
| 1052 |
-
raise AppException(
|
| 1053 |
-
status_code=404, detail="Portfolio not found when creating snapshot."
|
| 1054 |
-
)
|
| 1055 |
-
except AppException:
|
| 1056 |
-
raise
|
| 1057 |
-
except Exception as e:
|
| 1058 |
-
raise AppException(
|
| 1059 |
-
status_code=500, detail=f"Failed to create snapshot: {str(e)}"
|
| 1060 |
-
)
|
| 1061 |
|
| 1062 |
|
| 1063 |
-
@router.get(
|
| 1064 |
-
|
| 1065 |
-
response_model=ResponseModel,
|
| 1066 |
-
summary="Get Portfolio Performance Timeseries",
|
| 1067 |
-
)
|
| 1068 |
-
async def get_portfolio_performance(
|
| 1069 |
portfolio_id: int,
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
-
"1M",
|
| 1073 |
-
enum=["1D", "1W", "1M", "YTD", "1Y", "Max"],
|
| 1074 |
-
description="The time period for the performance data.",
|
| 1075 |
-
),
|
| 1076 |
current_user=Depends(get_current_user),
|
| 1077 |
):
|
| 1078 |
-
|
| 1079 |
-
|
| 1080 |
-
|
| 1081 |
-
|
| 1082 |
-
"""
|
| 1083 |
-
try:
|
| 1084 |
-
# 1. AUTHENTICATION
|
| 1085 |
-
portfolio = await Portfolio.get_or_none(
|
| 1086 |
-
id=portfolio_id, user_id=current_user.id
|
| 1087 |
-
)
|
| 1088 |
-
if not portfolio:
|
| 1089 |
-
raise AppException(status_code=404, detail="Portfolio not found")
|
| 1090 |
-
|
| 1091 |
-
# 2. CONSOLIDATED TASK CHECK: Check if ANY relevant task is already running.
|
| 1092 |
-
active_task = await ImportTask.filter(
|
| 1093 |
-
Q(details__contains={"portfolio_id": portfolio_id}),
|
| 1094 |
-
Q(task_type__in=["portfolio_regeneration", "portfolio_snapshot_history"]),
|
| 1095 |
-
status__in=["pending", "running"],
|
| 1096 |
-
).first()
|
| 1097 |
-
|
| 1098 |
-
if active_task:
|
| 1099 |
-
return ResponseModel(
|
| 1100 |
-
success=False,
|
| 1101 |
-
message="Portfolio performance data is currently being prepared. Please check back in a few moments.",
|
| 1102 |
-
data={"task_id": active_task.id, "status": active_task.status},
|
| 1103 |
-
)
|
| 1104 |
|
| 1105 |
-
|
| 1106 |
-
end_date = date.today()
|
| 1107 |
-
start_date = None
|
| 1108 |
-
if period == "1D":
|
| 1109 |
-
start_date = end_date - timedelta(days=1)
|
| 1110 |
-
elif period == "1W":
|
| 1111 |
-
start_date = end_date - timedelta(weeks=1)
|
| 1112 |
-
elif period == "1M":
|
| 1113 |
-
start_date = end_date - timedelta(days=30)
|
| 1114 |
-
elif period == "YTD":
|
| 1115 |
-
start_date = date(end_date.year, 1, 1)
|
| 1116 |
-
elif period == "1Y":
|
| 1117 |
-
start_date = end_date - timedelta(days=365)
|
| 1118 |
-
if period == "Max":
|
| 1119 |
-
start_date = end_date - timedelta(days=365 * 10) # A 10-year fallback
|
| 1120 |
-
|
| 1121 |
-
query = PortfolioSnapshot.filter(portfolio_id=portfolio_id)
|
| 1122 |
-
if start_date:
|
| 1123 |
-
start_datetime = datetime.combine(start_date, datetime.min.time())
|
| 1124 |
-
query = query.filter(snapshot_date__gte=start_datetime)
|
| 1125 |
-
|
| 1126 |
-
snapshots = await query.order_by("snapshot_date").values(
|
| 1127 |
-
"snapshot_date", "total_value"
|
| 1128 |
-
)
|
| 1129 |
|
| 1130 |
-
|
| 1131 |
-
|
| 1132 |
-
# 4. DECISION POINT: Serve data OR trigger generation.
|
| 1133 |
-
# If we found no snapshots for the requested period, it's time to generate.
|
| 1134 |
-
if not snapshots:
|
| 1135 |
-
# Since we already checked for active tasks, we know it's safe to start a new one.
|
| 1136 |
-
task = await ImportTask.create(
|
| 1137 |
-
task_type="portfolio_snapshot_history",
|
| 1138 |
-
status="pending",
|
| 1139 |
-
details={
|
| 1140 |
-
"portfolio_id": portfolio_id,
|
| 1141 |
-
"reason": "First-time data request.",
|
| 1142 |
-
},
|
| 1143 |
-
)
|
| 1144 |
-
# We call the task without a start_date, so it will find the earliest transaction.
|
| 1145 |
-
background_tasks.add_task(
|
| 1146 |
-
PortfolioService.regenerate_snapshots_task, task.id, portfolio_id
|
| 1147 |
-
)
|
| 1148 |
|
| 1149 |
-
|
| 1150 |
-
|
| 1151 |
-
|
| 1152 |
-
|
| 1153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1154 |
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
|
| 1164 |
-
|
| 1165 |
-
|
| 1166 |
-
|
| 1167 |
-
|
| 1168 |
-
|
| 1169 |
-
|
| 1170 |
-
|
| 1171 |
-
|
| 1172 |
-
|
|
|
|
|
|
|
|
|
|
| 1173 |
)
|
| 1174 |
|
| 1175 |
-
|
| 1176 |
-
|
| 1177 |
-
change_value = last_value - first_value
|
| 1178 |
-
change_percentage = (
|
| 1179 |
-
(change_value / first_value) * 100 if first_value > 0 else Decimal("0.0")
|
| 1180 |
-
)
|
| 1181 |
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
|
| 1185 |
-
|
| 1186 |
-
|
| 1187 |
-
"change_value": str(change_value),
|
| 1188 |
-
"change_percentage": round(float(change_percentage), 2),
|
| 1189 |
-
"timeseries": [
|
| 1190 |
-
{
|
| 1191 |
-
"date": s["snapshot_date"].isoformat(),
|
| 1192 |
-
"value": str(s["total_value"]),
|
| 1193 |
-
}
|
| 1194 |
-
for s in snapshots
|
| 1195 |
-
],
|
| 1196 |
-
},
|
| 1197 |
-
)
|
| 1198 |
|
| 1199 |
-
except Exception as e:
|
| 1200 |
-
raise AppException(status_code=500, detail=f"An unexpected error occurred: {e}")
|
| 1201 |
|
|
|
|
|
|
|
|
|
|
| 1202 |
|
| 1203 |
-
|
| 1204 |
-
|
| 1205 |
-
|
| 1206 |
-
|
| 1207 |
-
)
|
| 1208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1209 |
portfolio_id: int,
|
| 1210 |
background_tasks: BackgroundTasks,
|
|
|
|
|
|
|
|
|
|
| 1211 |
current_user=Depends(get_current_user),
|
| 1212 |
):
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
-
|
| 1216 |
-
|
| 1217 |
-
|
| 1218 |
-
|
| 1219 |
-
|
| 1220 |
-
|
| 1221 |
-
|
| 1222 |
-
|
| 1223 |
-
|
| 1224 |
-
|
| 1225 |
-
|
| 1226 |
-
|
| 1227 |
-
|
| 1228 |
-
)
|
| 1229 |
-
if not portfolio:
|
| 1230 |
-
raise AppException(status_code=404, detail="Portfolio not found")
|
| 1231 |
-
|
| 1232 |
-
# 2. CHECK FOR ACTIVE REGENERATION TASKS
|
| 1233 |
-
active_task = await ImportTask.filter(
|
| 1234 |
-
Q(details__contains={"portfolio_id": portfolio_id}),
|
| 1235 |
-
Q(task_type__in=["portfolio_regeneration", "portfolio_snapshot_history"]),
|
| 1236 |
-
status__in=["pending", "running"],
|
| 1237 |
-
).first()
|
| 1238 |
-
|
| 1239 |
-
if active_task:
|
| 1240 |
-
return ResponseModel(
|
| 1241 |
-
success=False,
|
| 1242 |
-
message="A timeseries recalculation is already in progress for this portfolio.",
|
| 1243 |
-
data={"task_id": active_task.id, "status": active_task.status},
|
| 1244 |
-
)
|
| 1245 |
|
| 1246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1247 |
task = await ImportTask.create(
|
| 1248 |
-
task_type="
|
| 1249 |
status="pending",
|
| 1250 |
details={
|
| 1251 |
"portfolio_id": portfolio_id,
|
| 1252 |
-
"reason": "
|
| 1253 |
},
|
| 1254 |
)
|
| 1255 |
-
|
| 1256 |
-
# Queue the regeneration task without a start_date,
|
| 1257 |
-
# so it will find the earliest transaction and start from there
|
| 1258 |
background_tasks.add_task(
|
| 1259 |
-
PortfolioService.
|
| 1260 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1261 |
|
|
|
|
| 1262 |
return ResponseModel(
|
| 1263 |
success=True,
|
| 1264 |
-
message="
|
| 1265 |
data={
|
| 1266 |
-
"
|
| 1267 |
-
"
|
| 1268 |
-
"
|
|
|
|
| 1269 |
},
|
| 1270 |
)
|
| 1271 |
|
| 1272 |
-
|
| 1273 |
-
|
| 1274 |
-
|
| 1275 |
-
|
| 1276 |
-
status_code=500, detail=f"Failed to start timeseries recalculation: {str(e)}"
|
| 1277 |
-
)
|
| 1278 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1279 |
|
| 1280 |
|
| 1281 |
-
@router.
|
| 1282 |
-
"/{portfolio_id}/
|
| 1283 |
-
|
| 1284 |
-
summary="Get Upcoming Portfolio Calendar Events",
|
| 1285 |
)
|
| 1286 |
-
async def
|
| 1287 |
portfolio_id: int,
|
| 1288 |
-
|
| 1289 |
-
None, description="Start of date range. Defaults to today."
|
| 1290 |
-
),
|
| 1291 |
-
end_date: Optional[date] = Query(
|
| 1292 |
-
None, description="End of date range. Defaults to 90 days from now."
|
| 1293 |
-
),
|
| 1294 |
current_user=Depends(get_current_user),
|
| 1295 |
):
|
| 1296 |
-
|
| 1297 |
-
|
| 1298 |
-
for
|
| 1299 |
-
|
| 1300 |
-
|
| 1301 |
-
|
| 1302 |
-
|
| 1303 |
-
|
| 1304 |
-
|
| 1305 |
-
|
| 1306 |
-
|
| 1307 |
-
|
| 1308 |
-
|
| 1309 |
-
|
| 1310 |
-
calendar_events: List[CalendarEventResponse] = []
|
| 1311 |
-
print("Hello there buddy!!")
|
| 1312 |
-
# 2. PROCESS STOCK DIVIDENDS
|
| 1313 |
-
# Get all stocks currently held in the portfolio
|
| 1314 |
-
portfolio_stocks = await PortfolioStock.filter(
|
| 1315 |
-
portfolio_id=portfolio_id
|
| 1316 |
-
).select_related("stock")
|
| 1317 |
-
print("Hello there buddy!!")
|
| 1318 |
-
if portfolio_stocks:
|
| 1319 |
-
stock_ids = [ps.stock.id for ps in portfolio_stocks]
|
| 1320 |
-
|
| 1321 |
-
# Create a map for quick lookup of quantity held for each stock
|
| 1322 |
-
stock_quantity_map = {ps.stock.id: ps.quantity for ps in portfolio_stocks}
|
| 1323 |
-
|
| 1324 |
-
# Find all declared dividends for those stocks within the date range
|
| 1325 |
-
dividends = await Dividend.filter(
|
| 1326 |
-
stock_id__in=stock_ids,
|
| 1327 |
-
payment_date__gte=start_date,
|
| 1328 |
-
payment_date__lte=end_date,
|
| 1329 |
-
).select_related("stock")
|
| 1330 |
-
print(dividends)
|
| 1331 |
-
for div in dividends:
|
| 1332 |
-
quantity_held = stock_quantity_map.get(div.stock.id, 0)
|
| 1333 |
-
if quantity_held > 0:
|
| 1334 |
-
event = CalendarEventResponse(
|
| 1335 |
-
event_date=div.payment_date,
|
| 1336 |
-
event_type="Dividend Payment",
|
| 1337 |
-
asset_symbol=div.stock.symbol,
|
| 1338 |
-
asset_name=div.stock.name,
|
| 1339 |
-
estimated_amount=div.dividend_amount * quantity_held,
|
| 1340 |
-
notes=f"Ex-dividend date: {div.ex_dividend_date.isoformat()}",
|
| 1341 |
-
)
|
| 1342 |
-
calendar_events.append(event)
|
| 1343 |
|
| 1344 |
-
|
| 1345 |
-
|
| 1346 |
-
|
| 1347 |
-
|
| 1348 |
-
|
| 1349 |
-
|
| 1350 |
-
|
| 1351 |
-
|
| 1352 |
-
|
| 1353 |
-
|
| 1354 |
-
|
| 1355 |
-
|
| 1356 |
-
|
| 1357 |
-
|
| 1358 |
-
|
| 1359 |
-
) / Decimal("2")
|
| 1360 |
-
|
| 1361 |
-
event = CalendarEventResponse(
|
| 1362 |
-
event_date=coupon_date,
|
| 1363 |
-
event_type="Bond Coupon",
|
| 1364 |
-
asset_symbol=pb.bond.isin,
|
| 1365 |
-
asset_name=f"{pb.bond.maturity_years} Yr T-Bond",
|
| 1366 |
-
estimated_amount=estimated_amount,
|
| 1367 |
-
notes=f"Matures on {pb.bond.maturity_date.isoformat()}",
|
| 1368 |
-
)
|
| 1369 |
-
calendar_events.append(event)
|
| 1370 |
|
| 1371 |
-
|
| 1372 |
-
|
| 1373 |
-
|
| 1374 |
|
| 1375 |
return ResponseModel(
|
| 1376 |
success=True,
|
| 1377 |
-
message="
|
| 1378 |
-
data={
|
| 1379 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Portfolio routes — imports from:
|
| 3 |
+
✅ .schemas, .service, .models
|
| 4 |
+
✅ Other routers' models
|
| 5 |
+
❌ NEVER imported by .service, .models, or .schemas
|
| 6 |
+
"""
|
| 7 |
+
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
| 8 |
+
from typing import Dict, List, Optional
|
| 9 |
+
from datetime import date, datetime, timedelta
|
| 10 |
+
from decimal import Decimal
|
| 11 |
+
|
| 12 |
+
from tortoise.contrib.pydantic import pydantic_model_creator
|
| 13 |
+
from tortoise.expressions import Q
|
| 14 |
+
|
| 15 |
+
from App.schemas import ResponseModel, AppException
|
| 16 |
from App.routers.users.utils import get_current_user
|
| 17 |
+
from App.routers.stocks.models import Dividend, Stock, StockPriceData
|
| 18 |
+
from App.routers.funds.models import MutualFund, FundPerformance
|
| 19 |
+
from App.routers.bonds.models import Bond
|
| 20 |
+
from App.routers.tasks.models import ImportTask
|
| 21 |
+
|
| 22 |
from .models import (
|
| 23 |
Portfolio,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
PortfolioStock,
|
| 25 |
PortfolioUTT,
|
| 26 |
+
PortfolioBond,
|
| 27 |
+
PortfolioTransaction,
|
| 28 |
+
PortfolioCalendar,
|
| 29 |
+
PortfolioSnapshot,
|
| 30 |
)
|
| 31 |
from .schemas import (
|
| 32 |
PortfolioCreate,
|
| 33 |
PortfolioUpdate,
|
|
|
|
|
|
|
| 34 |
StockHoldingCreate,
|
| 35 |
StockHoldingUpdate,
|
| 36 |
+
StockSellSchema,
|
| 37 |
+
FundHoldingCreate,
|
| 38 |
+
FundHoldingUpdate,
|
| 39 |
+
FundSellSchema,
|
| 40 |
BondHoldingCreate,
|
| 41 |
BondHoldingUpdate,
|
| 42 |
+
BondSellSchema,
|
| 43 |
CalendarEventCreate,
|
| 44 |
CalendarEventResponse,
|
| 45 |
TransactionDetailResponse,
|
|
|
|
| 46 |
PositionResponse,
|
|
|
|
|
|
|
|
|
|
| 47 |
)
|
| 48 |
+
from .service import PortfolioService, calculate_bond_coupon_dates
|
|
|
|
|
|
|
| 49 |
|
| 50 |
+
# Keep UTT pydantic creator pointing at PortfolioUTT (still the same table model)
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
+
# ──────────────── Pydantic ORM Models ────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
+
Portfolio_Pydantic = pydantic_model_creator(Portfolio, name="PortfolioOut")
|
| 56 |
+
PortfolioStock_Pydantic = pydantic_model_creator(PortfolioStock, name="PortfolioStockOut")
|
| 57 |
+
PortfolioUTT_Pydantic = pydantic_model_creator(PortfolioUTT, name="PortfolioUTTOut")
|
| 58 |
+
PortfolioBond_Pydantic = pydantic_model_creator(PortfolioBond, name="PortfolioBondOut")
|
| 59 |
+
PortfolioTxn_Pydantic = pydantic_model_creator(PortfolioTransaction, name="PortfolioTxnOut")
|
| 60 |
+
PortfolioCal_Pydantic = pydantic_model_creator(PortfolioCalendar, name="PortfolioCalOut")
|
| 61 |
+
PortfolioSnap_Pydantic = pydantic_model_creator(PortfolioSnapshot, name="PortfolioSnapOut")
|
|
|
|
|
|
|
|
|
|
| 62 |
|
|
|
|
| 63 |
|
| 64 |
+
router = APIRouter(prefix="/portfolios", tags=["Portfolios"])
|
| 65 |
|
| 66 |
|
| 67 |
+
# ══════════════════════════════════════════════════════════════
|
| 68 |
+
# HELPER
|
| 69 |
+
# ══════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
+
|
| 72 |
+
async def _verify_ownership(
|
| 73 |
+
portfolio_id: int, user, active_only: bool = False
|
| 74 |
+
) -> Portfolio:
|
| 75 |
+
"""Verify the portfolio exists and belongs to the user."""
|
| 76 |
+
filters = {"id": portfolio_id, "user_id": user.id}
|
| 77 |
+
if active_only:
|
| 78 |
+
filters["is_active"] = True
|
| 79 |
+
|
| 80 |
+
portfolio = await Portfolio.get_or_none(**filters)
|
| 81 |
+
if not portfolio:
|
| 82 |
+
raise AppException(status_code=404, message="Portfolio not found")
|
| 83 |
+
return portfolio
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ══════════════════════════════════════════════════════════════
|
| 87 |
+
# PORTFOLIO CRUD
|
| 88 |
+
# ══════════════════════════════════════════════════════════════
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@router.get("", summary="List user portfolios")
|
| 92 |
+
async def list_portfolios(
|
| 93 |
+
include_inactive: bool = Query(False),
|
| 94 |
+
current_user=Depends(get_current_user),
|
| 95 |
+
):
|
| 96 |
+
portfolios = await PortfolioService.get_user_portfolios(
|
| 97 |
+
user_id=current_user.id, include_inactive=include_inactive
|
| 98 |
+
)
|
| 99 |
+
data = [await Portfolio_Pydantic.from_tortoise_orm(p) for p in portfolios]
|
| 100 |
+
return ResponseModel(
|
| 101 |
+
success=True,
|
| 102 |
+
message="Portfolios retrieved",
|
| 103 |
+
data={"portfolios": data, "total_count": len(data)},
|
| 104 |
+
)
|
| 105 |
|
| 106 |
|
| 107 |
+
@router.post("", summary="Create portfolio")
|
| 108 |
async def create_portfolio(
|
| 109 |
+
payload: PortfolioCreate,
|
| 110 |
+
current_user=Depends(get_current_user),
|
| 111 |
):
|
| 112 |
try:
|
| 113 |
portfolio = await PortfolioService.create_portfolio(
|
| 114 |
user_id=current_user.id,
|
| 115 |
+
name=payload.name,
|
| 116 |
+
description=payload.description,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
)
|
| 118 |
except Exception as e:
|
| 119 |
+
if "unique" in str(e).lower():
|
| 120 |
+
raise AppException(status_code=400, message="Portfolio name already exists")
|
| 121 |
+
raise AppException(status_code=500, message=str(e))
|
|
|
|
|
|
|
| 122 |
|
| 123 |
+
data = await Portfolio_Pydantic.from_tortoise_orm(portfolio)
|
| 124 |
+
return ResponseModel(success=True, message="Portfolio created", data=data)
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
+
@router.get("/{portfolio_id}", summary="Get portfolio summary")
|
| 128 |
+
async def get_portfolio_summary(
|
| 129 |
+
portfolio_id: int,
|
| 130 |
+
current_user=Depends(get_current_user),
|
| 131 |
+
):
|
| 132 |
+
await _verify_ownership(portfolio_id, current_user)
|
| 133 |
+
summary = await PortfolioService.get_portfolio_summary(portfolio_id)
|
| 134 |
+
return ResponseModel(success=True, message="Summary retrieved", data=summary)
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
|
| 137 |
+
@router.put("/{portfolio_id}", summary="Update portfolio")
|
| 138 |
async def update_portfolio(
|
| 139 |
portfolio_id: int,
|
| 140 |
+
payload: PortfolioUpdate,
|
| 141 |
current_user=Depends(get_current_user),
|
| 142 |
):
|
| 143 |
+
portfolio = await _verify_ownership(portfolio_id, current_user)
|
| 144 |
+
update_data = payload.model_dump(exclude_unset=True)
|
| 145 |
+
if update_data:
|
| 146 |
+
await portfolio.update_from_dict(update_data).save()
|
|
|
|
|
|
|
| 147 |
|
| 148 |
+
data = await Portfolio_Pydantic.from_tortoise_orm(portfolio)
|
| 149 |
+
return ResponseModel(success=True, message="Portfolio updated", data=data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
|
|
|
|
|
|
| 151 |
|
| 152 |
+
@router.delete("/{portfolio_id}", summary="Deactivate portfolio")
|
| 153 |
+
async def deactivate_portfolio(
|
| 154 |
+
portfolio_id: int,
|
| 155 |
+
current_user=Depends(get_current_user),
|
| 156 |
+
):
|
| 157 |
+
portfolio = await _verify_ownership(portfolio_id, current_user)
|
| 158 |
+
portfolio.is_active = False
|
| 159 |
+
await portfolio.save()
|
| 160 |
+
return ResponseModel(success=True, message="Portfolio deactivated")
|
| 161 |
|
| 162 |
|
| 163 |
+
# ══════════════════════════════════════════════════════════════
|
| 164 |
+
# STOCK HOLDINGS
|
| 165 |
+
# ══════════════════════════════════════════════════════════════
|
| 166 |
|
| 167 |
|
| 168 |
+
@router.post("/{portfolio_id}/stocks", summary="Buy/add stock")
|
| 169 |
+
async def add_stock(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
portfolio_id: int,
|
| 171 |
+
payload: StockHoldingCreate,
|
| 172 |
current_user=Depends(get_current_user),
|
| 173 |
):
|
| 174 |
+
await _verify_ownership(portfolio_id, current_user, active_only=True)
|
| 175 |
+
|
| 176 |
+
holding = await PortfolioService.add_stock(
|
| 177 |
+
portfolio_id=portfolio_id,
|
| 178 |
+
stock_id=payload.stock_id,
|
| 179 |
+
quantity=payload.quantity,
|
| 180 |
+
purchase_price=payload.purchase_price,
|
| 181 |
+
purchase_date=payload.purchase_date,
|
| 182 |
+
notes=payload.notes,
|
| 183 |
+
)
|
| 184 |
+
data = await PortfolioStock_Pydantic.from_tortoise_orm(holding)
|
| 185 |
+
return ResponseModel(success=True, message="Stock added to portfolio", data=data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
|
| 188 |
+
@router.post("/{portfolio_id}/stocks/{stock_id}/sell", summary="Sell stock")
|
| 189 |
+
async def sell_stock(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
portfolio_id: int,
|
| 191 |
+
stock_id: int,
|
| 192 |
+
payload: StockSellSchema,
|
| 193 |
current_user=Depends(get_current_user),
|
| 194 |
):
|
| 195 |
+
await _verify_ownership(portfolio_id, current_user, active_only=True)
|
| 196 |
+
|
| 197 |
+
txn = await PortfolioService.sell_stock(
|
| 198 |
+
portfolio_id=portfolio_id,
|
| 199 |
+
stock_id=stock_id,
|
| 200 |
+
quantity=payload.quantity,
|
| 201 |
+
sell_price=payload.sell_price,
|
| 202 |
+
sell_date=payload.sell_date,
|
| 203 |
+
notes=payload.notes,
|
| 204 |
+
)
|
| 205 |
+
data = await PortfolioTxn_Pydantic.from_tortoise_orm(txn)
|
| 206 |
+
return ResponseModel(success=True, message="Stock sold", data=data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
|
| 208 |
|
| 209 |
+
@router.put("/{portfolio_id}/stocks/{stock_id}", summary="Update stock holding")
|
| 210 |
async def update_stock_holding(
|
| 211 |
portfolio_id: int,
|
| 212 |
+
stock_id: int,
|
| 213 |
+
payload: StockHoldingUpdate,
|
| 214 |
current_user=Depends(get_current_user),
|
| 215 |
):
|
| 216 |
+
await _verify_ownership(portfolio_id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
+
holding = await PortfolioStock.get_or_none(
|
| 219 |
+
stock_id=stock_id, portfolio_id=portfolio_id
|
| 220 |
+
)
|
| 221 |
+
if not holding:
|
| 222 |
+
raise AppException(status_code=404, message="Stock holding not found")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
|
| 224 |
+
update_data = payload.model_dump(exclude_unset=True)
|
| 225 |
+
if update_data:
|
| 226 |
+
await holding.update_from_dict(update_data).save()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
|
| 228 |
+
data = await PortfolioStock_Pydantic.from_tortoise_orm(holding)
|
| 229 |
+
return ResponseModel(success=True, message="Stock holding updated", data=data)
|
| 230 |
|
| 231 |
+
|
| 232 |
+
@router.delete("/{portfolio_id}/stocks/{stock_id}", summary="Remove stock holding")
|
| 233 |
+
async def remove_stock(
|
|
|
|
|
|
|
|
|
|
| 234 |
portfolio_id: int,
|
| 235 |
+
stock_id: int,
|
| 236 |
current_user=Depends(get_current_user),
|
| 237 |
):
|
| 238 |
+
await _verify_ownership(portfolio_id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
|
| 240 |
+
deleted = await PortfolioService.remove_holding(portfolio_id, "STOCK", stock_id)
|
| 241 |
+
if not deleted:
|
| 242 |
+
raise AppException(status_code=404, message="Stock holding not found")
|
| 243 |
+
return ResponseModel(success=True, message="Stock holding removed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
+
# ══════════════════════════════════════════════════════════════
|
| 247 |
+
# UTT HOLDINGS
|
| 248 |
+
# ══════════════════════════════════════════════════════════════
|
| 249 |
|
|
|
|
| 250 |
|
| 251 |
+
@router.post("/{portfolio_id}/funds", summary="Buy/add mutual fund")
|
| 252 |
+
async def add_fund(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
portfolio_id: int,
|
| 254 |
+
payload: FundHoldingCreate,
|
| 255 |
current_user=Depends(get_current_user),
|
| 256 |
):
|
| 257 |
+
await _verify_ownership(portfolio_id, current_user, active_only=True)
|
| 258 |
+
|
| 259 |
+
holding = await PortfolioService.add_fund(
|
| 260 |
+
portfolio_id=portfolio_id,
|
| 261 |
+
fund_id=payload.fund_id,
|
| 262 |
+
units=payload.units_held,
|
| 263 |
+
purchase_price=payload.purchase_price,
|
| 264 |
+
purchase_date=payload.purchase_date,
|
| 265 |
+
notes=payload.notes,
|
| 266 |
+
)
|
| 267 |
+
data = await PortfolioUTT_Pydantic.from_tortoise_orm(holding)
|
| 268 |
+
return ResponseModel(success=True, message="Fund added to portfolio", data=data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
|
| 270 |
|
| 271 |
+
@router.post("/{portfolio_id}/funds/{fund_id}/sell", summary="Sell mutual fund")
|
| 272 |
+
async def sell_fund(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
portfolio_id: int,
|
| 274 |
+
fund_id: int,
|
| 275 |
+
payload: FundSellSchema,
|
| 276 |
current_user=Depends(get_current_user),
|
| 277 |
):
|
| 278 |
+
await _verify_ownership(portfolio_id, current_user, active_only=True)
|
| 279 |
+
|
| 280 |
+
txn = await PortfolioService.sell_fund(
|
| 281 |
+
portfolio_id=portfolio_id,
|
| 282 |
+
fund_id=fund_id,
|
| 283 |
+
units=payload.units_to_sell,
|
| 284 |
+
sell_price=payload.sell_price,
|
| 285 |
+
sell_date=payload.sell_date,
|
| 286 |
+
notes=payload.notes,
|
| 287 |
+
)
|
| 288 |
+
data = await PortfolioTxn_Pydantic.from_tortoise_orm(txn)
|
| 289 |
+
return ResponseModel(success=True, message="Fund sold", data=data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
|
| 291 |
|
| 292 |
+
@router.put("/{portfolio_id}/funds/{fund_id}", summary="Update fund holding")
|
| 293 |
+
async def update_fund_holding(
|
| 294 |
portfolio_id: int,
|
| 295 |
+
fund_id: int,
|
| 296 |
+
payload: FundHoldingUpdate,
|
| 297 |
current_user=Depends(get_current_user),
|
| 298 |
):
|
| 299 |
+
await _verify_ownership(portfolio_id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
|
| 301 |
+
holding = await PortfolioUTT.get_or_none(fund_id=fund_id, portfolio_id=portfolio_id)
|
| 302 |
+
if not holding:
|
| 303 |
+
raise AppException(status_code=404, message="Fund holding not found")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
|
| 305 |
+
update_data = payload.model_dump(exclude_unset=True)
|
| 306 |
+
if update_data:
|
| 307 |
+
await holding.update_from_dict(update_data).save()
|
| 308 |
|
| 309 |
+
data = await PortfolioUTT_Pydantic.from_tortoise_orm(holding)
|
| 310 |
+
return ResponseModel(success=True, message="Fund holding updated", data=data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
|
| 312 |
|
| 313 |
+
@router.delete("/{portfolio_id}/funds/{fund_id}", summary="Remove fund holding")
|
| 314 |
+
async def remove_fund(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
portfolio_id: int,
|
| 316 |
+
fund_id: int,
|
| 317 |
current_user=Depends(get_current_user),
|
| 318 |
):
|
| 319 |
+
await _verify_ownership(portfolio_id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
|
| 321 |
+
deleted = await PortfolioService.remove_holding(portfolio_id, "FUND", fund_id)
|
| 322 |
+
if not deleted:
|
| 323 |
+
raise AppException(status_code=404, message="Fund holding not found")
|
| 324 |
+
return ResponseModel(success=True, message="Fund holding removed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
|
| 326 |
|
| 327 |
+
# ══════════════════════════════════════════════════════════════
|
| 328 |
+
# BOND HOLDINGS
|
| 329 |
+
# ══════════════════════════════════════════════════════════════
|
| 330 |
|
| 331 |
|
| 332 |
+
@router.post("/{portfolio_id}/bonds", summary="Buy/add bond")
|
| 333 |
+
async def add_bond(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
portfolio_id: int,
|
| 335 |
+
payload: BondHoldingCreate,
|
| 336 |
current_user=Depends(get_current_user),
|
| 337 |
):
|
| 338 |
+
await _verify_ownership(portfolio_id, current_user, active_only=True)
|
| 339 |
+
|
| 340 |
+
# Resolve bond_id: accept either bond_id or auction_number
|
| 341 |
+
bond_id = payload.bond_id
|
| 342 |
+
if not bond_id and payload.auction_number:
|
| 343 |
+
bond = await Bond.get_or_none(auction_number=payload.auction_number)
|
| 344 |
+
if not bond:
|
| 345 |
raise AppException(
|
| 346 |
+
status_code=404, message="Bond not found by auction number"
|
| 347 |
)
|
| 348 |
+
bond_id = bond.id
|
| 349 |
|
| 350 |
+
if not bond_id:
|
| 351 |
+
raise AppException(
|
| 352 |
+
status_code=400, message="Provide bond_id or auction_number"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
|
| 355 |
+
holding = await PortfolioService.add_bond(
|
| 356 |
+
portfolio_id=portfolio_id,
|
| 357 |
+
bond_id=bond_id,
|
| 358 |
+
face_value=payload.face_value_held,
|
| 359 |
+
total_purchase_price=payload.purchase_price,
|
| 360 |
+
purchase_date=payload.purchase_date,
|
| 361 |
+
notes=payload.notes,
|
| 362 |
+
)
|
| 363 |
+
data = await PortfolioBond_Pydantic.from_tortoise_orm(holding)
|
| 364 |
+
return ResponseModel(success=True, message="Bond added to portfolio", data=data)
|
| 365 |
|
| 366 |
+
|
| 367 |
+
@router.post("/{portfolio_id}/bonds/{bond_id}/sell", summary="Sell bond")
|
| 368 |
+
async def sell_bond(
|
|
|
|
|
|
|
|
|
|
| 369 |
portfolio_id: int,
|
| 370 |
+
bond_id: int,
|
| 371 |
+
payload: BondSellSchema,
|
| 372 |
current_user=Depends(get_current_user),
|
| 373 |
):
|
| 374 |
+
await _verify_ownership(portfolio_id, current_user, active_only=True)
|
| 375 |
+
|
| 376 |
+
txn = await PortfolioService.sell_bond(
|
| 377 |
+
portfolio_id=portfolio_id,
|
| 378 |
+
bond_id=bond_id,
|
| 379 |
+
face_value=payload.face_value_to_sell,
|
| 380 |
+
total_sell_price=payload.sell_price,
|
| 381 |
+
sell_date=payload.sell_date,
|
| 382 |
+
notes=payload.notes,
|
| 383 |
+
)
|
| 384 |
+
data = await PortfolioTxn_Pydantic.from_tortoise_orm(txn)
|
| 385 |
+
return ResponseModel(success=True, message="Bond sold", data=data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
|
| 387 |
|
| 388 |
+
@router.put("/{portfolio_id}/bonds/{bond_id}", summary="Update bond holding")
|
| 389 |
async def update_bond_holding(
|
| 390 |
portfolio_id: int,
|
| 391 |
+
bond_id: int,
|
| 392 |
+
payload: BondHoldingUpdate,
|
| 393 |
current_user=Depends(get_current_user),
|
| 394 |
):
|
| 395 |
+
await _verify_ownership(portfolio_id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
|
| 397 |
+
holding = await PortfolioBond.get_or_none(
|
| 398 |
+
bond_id=bond_id, portfolio_id=portfolio_id
|
| 399 |
+
)
|
| 400 |
+
if not holding:
|
| 401 |
+
raise AppException(status_code=404, message="Bond holding not found")
|
|
|
|
|
|
|
|
|
|
| 402 |
|
| 403 |
+
update_data = payload.model_dump(exclude_unset=True)
|
| 404 |
+
if update_data:
|
| 405 |
+
await holding.update_from_dict(update_data).save()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
|
| 407 |
+
data = await PortfolioBond_Pydantic.from_tortoise_orm(holding)
|
| 408 |
+
return ResponseModel(success=True, message="Bond holding updated", data=data)
|
| 409 |
|
| 410 |
+
|
| 411 |
+
@router.delete("/{portfolio_id}/bonds/{bond_id}", summary="Remove bond holding")
|
| 412 |
+
async def remove_bond(
|
|
|
|
|
|
|
|
|
|
| 413 |
portfolio_id: int,
|
| 414 |
+
bond_id: int,
|
| 415 |
current_user=Depends(get_current_user),
|
| 416 |
):
|
| 417 |
+
await _verify_ownership(portfolio_id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
|
| 419 |
+
deleted = await PortfolioService.remove_holding(portfolio_id, "BOND", bond_id)
|
| 420 |
+
if not deleted:
|
| 421 |
+
raise AppException(status_code=404, message="Bond holding not found")
|
| 422 |
+
return ResponseModel(success=True, message="Bond holding removed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
|
| 424 |
|
| 425 |
+
# ══════════════════════════════════════════════════════════════
|
| 426 |
+
# TRANSACTIONS
|
| 427 |
+
# ══════════════════════════════════════════════════════════════
|
| 428 |
|
| 429 |
|
| 430 |
+
@router.get("/{portfolio_id}/transactions", summary="List transactions")
|
| 431 |
+
async def list_transactions(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
portfolio_id: int,
|
| 433 |
limit: int = Query(50, ge=1, le=200),
|
| 434 |
offset: int = Query(0, ge=0),
|
| 435 |
current_user=Depends(get_current_user),
|
| 436 |
):
|
| 437 |
+
await _verify_ownership(portfolio_id, current_user)
|
| 438 |
+
|
| 439 |
+
# Fetch paginated transactions
|
| 440 |
+
txn_query = (
|
| 441 |
+
PortfolioTransaction.filter(portfolio_id=portfolio_id)
|
| 442 |
+
.order_by("-transaction_date", "-created_at")
|
| 443 |
+
.offset(offset)
|
| 444 |
+
.limit(limit)
|
| 445 |
+
)
|
| 446 |
+
transactions = await txn_query.all()
|
| 447 |
+
total_count = await PortfolioTransaction.filter(portfolio_id=portfolio_id).count()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
|
| 449 |
+
# Collect asset IDs by type for bulk fetch
|
| 450 |
+
stock_ids = set()
|
| 451 |
+
utt_ids = set()
|
| 452 |
+
bond_ids = set()
|
| 453 |
|
| 454 |
+
for t in transactions:
|
| 455 |
+
if t.asset_type == "STOCK":
|
| 456 |
+
stock_ids.add(t.asset_id)
|
| 457 |
+
elif t.asset_type in ("UTT", "FUND"):
|
| 458 |
+
utt_ids.add(t.asset_id)
|
| 459 |
+
elif t.asset_type == "BOND":
|
| 460 |
+
bond_ids.add(t.asset_id)
|
| 461 |
+
|
| 462 |
+
# Bulk fetch asset details
|
| 463 |
+
stocks_map: Dict[int, Stock] = {
|
| 464 |
+
s.id: s for s in await Stock.filter(id__in=list(stock_ids))
|
| 465 |
+
} if stock_ids else {}
|
| 466 |
+
|
| 467 |
+
utts_map: Dict[int, MutualFund] = {
|
| 468 |
+
u.id: u for u in await MutualFund.filter(id__in=list(utt_ids))
|
| 469 |
+
} if utt_ids else {}
|
| 470 |
+
|
| 471 |
+
bonds_map: Dict[int, Bond] = {
|
| 472 |
+
b.id: b for b in await Bond.filter(id__in=list(bond_ids))
|
| 473 |
+
} if bond_ids else {}
|
| 474 |
+
|
| 475 |
+
# Enrich transactions
|
| 476 |
+
enriched: List[dict] = []
|
| 477 |
+
for t in transactions:
|
| 478 |
+
asset_name = t.asset_name or None
|
| 479 |
+
asset_symbol = None
|
| 480 |
+
|
| 481 |
+
if t.asset_type == "STOCK" and t.asset_id in stocks_map:
|
| 482 |
+
s = stocks_map[t.asset_id]
|
| 483 |
+
asset_name = asset_name or s.name
|
| 484 |
+
asset_symbol = s.symbol
|
| 485 |
+
elif t.asset_type in ("UTT", "FUND") and t.asset_id in utts_map:
|
| 486 |
+
u = utts_map[t.asset_id]
|
| 487 |
+
asset_name = asset_name or u.name
|
| 488 |
+
asset_symbol = u.name[:6].upper()
|
| 489 |
+
elif t.asset_type == "BOND" and t.asset_id in bonds_map:
|
| 490 |
+
b = bonds_map[t.asset_id]
|
| 491 |
+
asset_name = asset_name or f"{b.maturity_years} Yr Treasury Bond"
|
| 492 |
+
asset_symbol = getattr(b, "isin", None)
|
| 493 |
+
|
| 494 |
+
enriched.append(
|
| 495 |
+
TransactionDetailResponse(
|
| 496 |
+
id=t.id,
|
| 497 |
+
transaction_type=t.transaction_type,
|
| 498 |
+
asset_type=t.asset_type,
|
| 499 |
+
asset_id=t.asset_id,
|
| 500 |
+
asset_name=asset_name,
|
| 501 |
+
asset_symbol=asset_symbol,
|
| 502 |
+
quantity=t.quantity,
|
| 503 |
+
price=t.price,
|
| 504 |
+
total_amount=t.total_amount,
|
| 505 |
+
transaction_date=t.transaction_date,
|
| 506 |
+
notes=t.notes,
|
| 507 |
+
created_at=t.created_at,
|
| 508 |
+
).model_dump(mode="json")
|
| 509 |
)
|
|
|
|
|
|
|
| 510 |
|
| 511 |
+
return ResponseModel(
|
| 512 |
+
success=True,
|
| 513 |
+
message="Transactions retrieved",
|
| 514 |
+
data={
|
| 515 |
+
"transactions": enriched,
|
| 516 |
+
"total_count": total_count,
|
| 517 |
+
"limit": limit,
|
| 518 |
+
"offset": offset,
|
| 519 |
+
},
|
| 520 |
+
)
|
| 521 |
|
|
|
|
| 522 |
|
| 523 |
+
# ══════════════════════════════════════════════════════════════
|
| 524 |
+
# POSITIONS (aggregated from transactions)
|
| 525 |
+
# ══════════════════════════════════════════════════════════════
|
| 526 |
|
| 527 |
+
|
| 528 |
+
@router.get("/{portfolio_id}/positions", summary="Get all current positions")
|
| 529 |
+
async def get_positions(
|
| 530 |
+
portfolio_id: int,
|
| 531 |
+
current_user=Depends(get_current_user),
|
|
|
|
|
|
|
| 532 |
):
|
| 533 |
+
await _verify_ownership(portfolio_id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
|
| 535 |
+
# Aggregate all transactions
|
| 536 |
transactions = await PortfolioTransaction.filter(
|
| 537 |
portfolio_id=portfolio_id
|
| 538 |
).order_by("transaction_date")
|
| 539 |
|
| 540 |
+
agg: Dict[tuple, Dict] = {}
|
|
|
|
|
|
|
|
|
|
| 541 |
for t in transactions:
|
| 542 |
+
key = (t.asset_type, t.asset_id)
|
| 543 |
+
if key not in agg:
|
| 544 |
+
agg[key] = {"buy_qty": Decimal("0"), "buy_cost": Decimal("0"), "sell_qty": Decimal("0")}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
|
| 546 |
if t.transaction_type == "BUY":
|
| 547 |
+
agg[key]["buy_qty"] += t.quantity
|
| 548 |
+
agg[key]["buy_cost"] += t.total_amount
|
| 549 |
elif t.transaction_type == "SELL":
|
| 550 |
+
agg[key]["sell_qty"] += t.quantity
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
|
| 552 |
+
# Build positions with current prices
|
| 553 |
+
positions: List[dict] = []
|
| 554 |
|
| 555 |
+
for (asset_type, asset_id), data in agg.items():
|
| 556 |
+
current_qty = data["buy_qty"] - data["sell_qty"]
|
| 557 |
+
if current_qty <= 0:
|
| 558 |
continue
|
| 559 |
|
| 560 |
+
avg_price = data["buy_cost"] / data["buy_qty"] if data["buy_qty"] > 0 else Decimal("0")
|
| 561 |
+
total_invested = current_qty * avg_price
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 562 |
|
| 563 |
+
current_price = Decimal("0")
|
|
|
|
| 564 |
asset_name = "Unknown"
|
| 565 |
asset_symbol = "N/A"
|
| 566 |
|
| 567 |
+
# Optimized: Fetching assets only when needed
|
| 568 |
if asset_type == "STOCK":
|
| 569 |
stock = await Stock.get_or_none(id=asset_id)
|
| 570 |
if stock:
|
| 571 |
asset_name = stock.name
|
| 572 |
asset_symbol = stock.symbol
|
| 573 |
+
price = await StockPriceData.filter(stock_id=asset_id).order_by("-date").first()
|
| 574 |
+
if price:
|
| 575 |
+
current_price = price.closing_price
|
| 576 |
+
|
| 577 |
+
elif asset_type == "FUND":
|
| 578 |
+
fund = await MutualFund.get_or_none(id=asset_id)
|
| 579 |
+
if fund:
|
| 580 |
+
asset_name = fund.name
|
| 581 |
+
asset_symbol = fund.name[:6].upper()
|
| 582 |
+
nav = await FundPerformance.filter(fund_id=asset_id).order_by("-record_date").first()
|
| 583 |
+
if nav and nav.nav_per_unit:
|
| 584 |
+
current_price = Decimal(str(nav.nav_per_unit))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 585 |
|
| 586 |
elif asset_type == "BOND":
|
| 587 |
bond = await Bond.get_or_none(id=asset_id)
|
| 588 |
if bond:
|
| 589 |
asset_name = f"{bond.maturity_years} Yr Treasury Bond"
|
| 590 |
+
asset_symbol = getattr(bond, "isin", "N/A")
|
|
|
|
|
|
|
|
|
|
| 591 |
current_price = (
|
| 592 |
Decimal(str(bond.price_per_100))
|
| 593 |
+
if getattr(bond, "price_per_100", None)
|
| 594 |
+
else Decimal("100")
|
| 595 |
)
|
| 596 |
|
| 597 |
+
current_value = current_qty * current_price
|
|
|
|
| 598 |
profit_loss = current_value - total_invested
|
| 599 |
+
profit_loss_pct = (
|
| 600 |
+
float(profit_loss / total_invested * 100) if total_invested > 0 else 0.0
|
| 601 |
+
)
|
| 602 |
+
|
| 603 |
+
positions.append(
|
| 604 |
+
PositionResponse(
|
| 605 |
+
asset_id=asset_id,
|
| 606 |
+
asset_type=asset_type.capitalize(),
|
| 607 |
+
asset_name=asset_name,
|
| 608 |
+
asset_symbol=asset_symbol,
|
| 609 |
+
quantity=current_qty,
|
| 610 |
+
avg_buy_price=round(avg_price, 4),
|
| 611 |
+
total_invested=round(total_invested, 2),
|
| 612 |
+
current_price=round(current_price, 4),
|
| 613 |
+
current_value=round(current_value, 2),
|
| 614 |
+
profit_loss=round(profit_loss, 2),
|
| 615 |
+
profit_loss_percent=round(profit_loss_pct, 2),
|
| 616 |
+
).model_dump(mode="json")
|
| 617 |
)
|
| 618 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 619 |
return ResponseModel(
|
| 620 |
success=True,
|
| 621 |
+
message="Positions retrieved",
|
| 622 |
+
data={"positions": positions},
|
| 623 |
)
|
| 624 |
|
| 625 |
|
| 626 |
+
# ══════════════════════════════════════════════════════════════
|
| 627 |
+
# CALENDAR EVENTS
|
| 628 |
+
# ══════════════════════════════════════════════════════════════
|
| 629 |
+
|
| 630 |
+
|
| 631 |
+
@router.post("/{portfolio_id}/calendar", summary="Add calendar event")
|
| 632 |
+
async def add_calendar_event(
|
| 633 |
portfolio_id: int,
|
| 634 |
+
payload: CalendarEventCreate,
|
|
|
|
|
|
|
| 635 |
current_user=Depends(get_current_user),
|
| 636 |
):
|
| 637 |
+
await _verify_ownership(portfolio_id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 638 |
|
| 639 |
+
event = await PortfolioCalendar.create(
|
| 640 |
+
portfolio_id=portfolio_id, **payload.model_dump()
|
| 641 |
+
)
|
| 642 |
+
data = await PortfolioCal_Pydantic.from_tortoise_orm(event)
|
| 643 |
+
return ResponseModel(success=True, message="Calendar event added", data=data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 644 |
|
| 645 |
|
| 646 |
+
@router.get("/{portfolio_id}/calendar", summary="Get calendar events")
|
| 647 |
+
async def get_calendar_events(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 648 |
portfolio_id: int,
|
| 649 |
+
start_date: Optional[date] = Query(None),
|
| 650 |
+
end_date: Optional[date] = Query(None),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 651 |
current_user=Depends(get_current_user),
|
| 652 |
):
|
| 653 |
+
if not start_date:
|
| 654 |
+
start_date = date.today()
|
| 655 |
+
if not end_date:
|
| 656 |
+
end_date = start_date + timedelta(days=90)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 657 |
|
| 658 |
+
await _verify_ownership(portfolio_id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 659 |
|
| 660 |
+
events: List[dict] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
|
| 662 |
+
# ── Stock Dividends ──
|
| 663 |
+
portfolio_stocks = await PortfolioStock.filter(
|
| 664 |
+
portfolio_id=portfolio_id
|
| 665 |
+
).select_related("stock")
|
| 666 |
+
|
| 667 |
+
if portfolio_stocks:
|
| 668 |
+
stock_ids = [ps.stock.id for ps in portfolio_stocks]
|
| 669 |
+
stock_qty_map = {ps.stock.id: ps.quantity for ps in portfolio_stocks}
|
| 670 |
+
|
| 671 |
+
dividends = await Dividend.filter(
|
| 672 |
+
stock_id__in=stock_ids,
|
| 673 |
+
payment_date__gte=start_date,
|
| 674 |
+
payment_date__lte=end_date,
|
| 675 |
+
).select_related("stock")
|
| 676 |
+
|
| 677 |
+
for div in dividends:
|
| 678 |
+
qty = stock_qty_map.get(div.stock.id, 0)
|
| 679 |
+
if qty > 0:
|
| 680 |
+
events.append(
|
| 681 |
+
CalendarEventResponse(
|
| 682 |
+
event_date=div.payment_date,
|
| 683 |
+
event_type="Dividend Payment",
|
| 684 |
+
asset_symbol=div.stock.symbol,
|
| 685 |
+
asset_name=div.stock.name,
|
| 686 |
+
estimated_amount=div.dividend_amount * qty,
|
| 687 |
+
notes=f"Ex-dividend date: {div.ex_dividend_date.isoformat()}",
|
| 688 |
+
).model_dump(mode="json")
|
| 689 |
+
)
|
| 690 |
|
| 691 |
+
# ── Bond Coupons ──
|
| 692 |
+
portfolio_bonds = await PortfolioBond.filter(
|
| 693 |
+
portfolio_id=portfolio_id
|
| 694 |
+
).select_related("bond")
|
| 695 |
+
|
| 696 |
+
for pb in portfolio_bonds:
|
| 697 |
+
for coupon_date in calculate_bond_coupon_dates(pb.bond, start_date, end_date):
|
| 698 |
+
estimated = (
|
| 699 |
+
pb.face_value_held
|
| 700 |
+
* (Decimal(str(pb.bond.coupon_rate)) / Decimal("100"))
|
| 701 |
+
) / Decimal("2")
|
| 702 |
+
|
| 703 |
+
events.append(
|
| 704 |
+
CalendarEventResponse(
|
| 705 |
+
event_date=coupon_date,
|
| 706 |
+
event_type="Bond Coupon",
|
| 707 |
+
asset_symbol=getattr(pb.bond, "isin", "N/A"),
|
| 708 |
+
asset_name=f"{pb.bond.maturity_years} Yr T-Bond",
|
| 709 |
+
estimated_amount=estimated,
|
| 710 |
+
notes=f"Matures on {pb.bond.maturity_date.isoformat()}",
|
| 711 |
+
).model_dump(mode="json")
|
| 712 |
)
|
| 713 |
|
| 714 |
+
# Sort by date
|
| 715 |
+
events.sort(key=lambda e: e["event_date"])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 716 |
|
| 717 |
+
return ResponseModel(
|
| 718 |
+
success=True,
|
| 719 |
+
message="Calendar events retrieved",
|
| 720 |
+
data={"events": events, "total_count": len(events)},
|
| 721 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 722 |
|
|
|
|
|
|
|
| 723 |
|
| 724 |
+
# ══════════════════════════════════════════════════════════════
|
| 725 |
+
# SNAPSHOTS & PERFORMANCE
|
| 726 |
+
# ══════════════════════════════════════════════════════════════
|
| 727 |
|
| 728 |
+
|
| 729 |
+
@router.post("/{portfolio_id}/snapshot", summary="Create portfolio snapshot")
|
| 730 |
+
async def create_snapshot(
|
| 731 |
+
portfolio_id: int,
|
| 732 |
+
snapshot_date: Optional[date] = Query(None),
|
| 733 |
+
current_user=Depends(get_current_user),
|
| 734 |
+
):
|
| 735 |
+
await _verify_ownership(portfolio_id, current_user)
|
| 736 |
+
|
| 737 |
+
snapshot = await PortfolioService.create_snapshot(
|
| 738 |
+
portfolio_id=portfolio_id, target_date=snapshot_date
|
| 739 |
+
)
|
| 740 |
+
data = await PortfolioSnap_Pydantic.from_tortoise_orm(snapshot)
|
| 741 |
+
return ResponseModel(success=True, message="Snapshot created", data=data)
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
@router.get("/{portfolio_id}/performance", summary="Get performance timeseries")
|
| 745 |
+
async def get_performance(
|
| 746 |
portfolio_id: int,
|
| 747 |
background_tasks: BackgroundTasks,
|
| 748 |
+
period: str = Query(
|
| 749 |
+
"1M", enum=["1D", "1W", "1M", "YTD", "1Y", "Max"]
|
| 750 |
+
),
|
| 751 |
current_user=Depends(get_current_user),
|
| 752 |
):
|
| 753 |
+
await _verify_ownership(portfolio_id, current_user)
|
| 754 |
+
|
| 755 |
+
# Check for active regeneration task
|
| 756 |
+
# json_contains is not supported on SQLite, so fetch candidate tasks and filter in Python
|
| 757 |
+
candidate_tasks = await ImportTask.filter(
|
| 758 |
+
task_type__in=["portfolio_regeneration", "portfolio_snapshot_history"],
|
| 759 |
+
status__in=["pending", "running"],
|
| 760 |
+
).all()
|
| 761 |
+
active_task = next(
|
| 762 |
+
(
|
| 763 |
+
t for t in candidate_tasks
|
| 764 |
+
if isinstance(t.details, dict) and t.details.get("portfolio_id") == portfolio_id
|
| 765 |
+
),
|
| 766 |
+
None,
|
| 767 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
|
| 769 |
+
if active_task:
|
| 770 |
+
return ResponseModel(
|
| 771 |
+
success=False,
|
| 772 |
+
message="Performance data is being prepared. Please check back shortly.",
|
| 773 |
+
data={"task_id": active_task.id, "status": active_task.status},
|
| 774 |
+
)
|
| 775 |
+
|
| 776 |
+
# Calculate date range
|
| 777 |
+
end_date = date.today()
|
| 778 |
+
period_map = {
|
| 779 |
+
"1D": timedelta(days=1),
|
| 780 |
+
"1W": timedelta(weeks=1),
|
| 781 |
+
"1M": timedelta(days=30),
|
| 782 |
+
"YTD": end_date - date(end_date.year, 1, 1),
|
| 783 |
+
"1Y": timedelta(days=365),
|
| 784 |
+
"Max": timedelta(days=365 * 10),
|
| 785 |
+
}
|
| 786 |
+
delta = period_map.get(period, timedelta(days=30))
|
| 787 |
+
if isinstance(delta, timedelta):
|
| 788 |
+
start_date = end_date - delta
|
| 789 |
+
else:
|
| 790 |
+
start_date = date(end_date.year, 1, 1)
|
| 791 |
+
|
| 792 |
+
# Query snapshots
|
| 793 |
+
snapshots = await (
|
| 794 |
+
PortfolioSnapshot.filter(
|
| 795 |
+
portfolio_id=portfolio_id, snapshot_date__gte=start_date
|
| 796 |
+
)
|
| 797 |
+
.order_by("snapshot_date")
|
| 798 |
+
.values("snapshot_date", "total_value")
|
| 799 |
+
)
|
| 800 |
+
|
| 801 |
+
# No data — trigger generation
|
| 802 |
+
if not snapshots:
|
| 803 |
task = await ImportTask.create(
|
| 804 |
+
task_type="portfolio_snapshot_history",
|
| 805 |
status="pending",
|
| 806 |
details={
|
| 807 |
"portfolio_id": portfolio_id,
|
| 808 |
+
"reason": "First-time data request",
|
| 809 |
},
|
| 810 |
)
|
|
|
|
|
|
|
|
|
|
| 811 |
background_tasks.add_task(
|
| 812 |
+
PortfolioService.regenerate_snapshots, task.id, portfolio_id
|
| 813 |
)
|
| 814 |
+
return ResponseModel(
|
| 815 |
+
success=False,
|
| 816 |
+
message="Preparing performance history. Please check back shortly.",
|
| 817 |
+
data={"task_id": task.id, "status": "pending"},
|
| 818 |
+
)
|
| 819 |
+
|
| 820 |
+
# Build timeseries
|
| 821 |
+
timeseries = [
|
| 822 |
+
{"date": s["snapshot_date"].isoformat(), "value": str(s["total_value"])}
|
| 823 |
+
for s in snapshots
|
| 824 |
+
]
|
| 825 |
|
| 826 |
+
if len(snapshots) < 2:
|
| 827 |
return ResponseModel(
|
| 828 |
success=True,
|
| 829 |
+
message="Not enough data for performance calculation",
|
| 830 |
data={
|
| 831 |
+
"current_value": str(snapshots[0]["total_value"]),
|
| 832 |
+
"change_value": "0.00",
|
| 833 |
+
"change_percentage": 0.0,
|
| 834 |
+
"timeseries": timeseries,
|
| 835 |
},
|
| 836 |
)
|
| 837 |
|
| 838 |
+
first_val = snapshots[0]["total_value"]
|
| 839 |
+
last_val = snapshots[-1]["total_value"]
|
| 840 |
+
change = last_val - first_val
|
| 841 |
+
change_pct = float(change / first_val * 100) if first_val > 0 else 0.0
|
|
|
|
|
|
|
| 842 |
|
| 843 |
+
return ResponseModel(
|
| 844 |
+
success=True,
|
| 845 |
+
message=f"Performance for '{period}' retrieved",
|
| 846 |
+
data={
|
| 847 |
+
"current_value": str(last_val),
|
| 848 |
+
"change_value": str(change),
|
| 849 |
+
"change_percentage": round(change_pct, 2),
|
| 850 |
+
"timeseries": timeseries,
|
| 851 |
+
},
|
| 852 |
+
)
|
| 853 |
|
| 854 |
|
| 855 |
+
@router.post(
|
| 856 |
+
"/{portfolio_id}/recalculate-timeseries",
|
| 857 |
+
summary="Recalculate entire timeseries",
|
|
|
|
| 858 |
)
|
| 859 |
+
async def recalculate_timeseries(
|
| 860 |
portfolio_id: int,
|
| 861 |
+
background_tasks: BackgroundTasks,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 862 |
current_user=Depends(get_current_user),
|
| 863 |
):
|
| 864 |
+
await _verify_ownership(portfolio_id, current_user)
|
| 865 |
+
|
| 866 |
+
# Check for already running task (json_contains unsupported on SQLite — filter in Python)
|
| 867 |
+
candidate_tasks = await ImportTask.filter(
|
| 868 |
+
task_type__in=["portfolio_regeneration", "portfolio_snapshot_history"],
|
| 869 |
+
status__in=["pending", "running"],
|
| 870 |
+
).all()
|
| 871 |
+
active_task = next(
|
| 872 |
+
(
|
| 873 |
+
t for t in candidate_tasks
|
| 874 |
+
if isinstance(t.details, dict) and t.details.get("portfolio_id") == portfolio_id
|
| 875 |
+
),
|
| 876 |
+
None,
|
| 877 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 878 |
|
| 879 |
+
if active_task:
|
| 880 |
+
return ResponseModel(
|
| 881 |
+
success=False,
|
| 882 |
+
message="A recalculation is already in progress.",
|
| 883 |
+
data={"task_id": active_task.id, "status": active_task.status},
|
| 884 |
+
)
|
| 885 |
+
|
| 886 |
+
task = await ImportTask.create(
|
| 887 |
+
task_type="portfolio_regeneration",
|
| 888 |
+
status="pending",
|
| 889 |
+
details={
|
| 890 |
+
"portfolio_id": portfolio_id,
|
| 891 |
+
"reason": "Manual full recalculation",
|
| 892 |
+
},
|
| 893 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 894 |
|
| 895 |
+
background_tasks.add_task(
|
| 896 |
+
PortfolioService.regenerate_snapshots, task.id, portfolio_id
|
| 897 |
+
)
|
| 898 |
|
| 899 |
return ResponseModel(
|
| 900 |
success=True,
|
| 901 |
+
message="Timeseries recalculation started",
|
| 902 |
+
data={
|
| 903 |
+
"task_id": task.id,
|
| 904 |
+
"status": "pending",
|
| 905 |
+
"portfolio_id": portfolio_id,
|
| 906 |
+
},
|
| 907 |
+
)
|
App/routers/portfolio/schemas.py
CHANGED
|
@@ -1,12 +1,19 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
from
|
|
|
|
|
|
|
|
|
|
| 4 |
from datetime import date, datetime
|
| 5 |
from decimal import Decimal
|
| 6 |
|
| 7 |
|
| 8 |
-
#
|
|
|
|
|
|
|
| 9 |
class PortfolioBase(BaseModel):
|
|
|
|
|
|
|
| 10 |
id: int
|
| 11 |
name: str
|
| 12 |
description: Optional[str] = None
|
|
@@ -14,339 +21,168 @@ class PortfolioBase(BaseModel):
|
|
| 14 |
created_at: datetime
|
| 15 |
updated_at: datetime
|
| 16 |
|
| 17 |
-
model_config = ConfigDict(from_attributes=True)
|
| 18 |
-
|
| 19 |
|
| 20 |
class PortfolioCreate(BaseModel):
|
| 21 |
-
name: str = Field(
|
| 22 |
-
|
| 23 |
-
)
|
| 24 |
-
description: Optional[str] = Field(
|
| 25 |
-
None, description="Optional description for the portfolio"
|
| 26 |
-
)
|
| 27 |
|
| 28 |
|
| 29 |
class PortfolioUpdate(BaseModel):
|
| 30 |
-
name: Optional[str] = Field(
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
description: Optional[str] = Field(
|
| 34 |
-
None, description="New description for the portfolio"
|
| 35 |
-
)
|
| 36 |
-
is_active: Optional[bool] = Field(
|
| 37 |
-
None, description="Set portfolio active or inactive status"
|
| 38 |
-
)
|
| 39 |
|
| 40 |
|
| 41 |
-
|
| 42 |
-
portfolios: List[PortfolioBase]
|
| 43 |
-
total_count: int
|
| 44 |
|
| 45 |
-
model_config = ConfigDict(from_attributes=True)
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
-
# --- Stock Holding Schemas ---
|
| 49 |
-
class StockHoldingBase(BaseModel):
|
| 50 |
-
stock_id: int = Field(..., description="Internal ID of the stock master record")
|
| 51 |
-
quantity: Decimal = Field(..., gt=0, description="Number of shares held")
|
| 52 |
-
purchase_price: Decimal = Field(
|
| 53 |
-
...,
|
| 54 |
-
gt=0,
|
| 55 |
-
description="Average price per share at purchase for the aggregated holding",
|
| 56 |
-
)
|
| 57 |
-
purchase_date: date = Field(
|
| 58 |
-
..., description="Representative date of stock purchase (e.g., latest buy)"
|
| 59 |
-
)
|
| 60 |
-
notes: Optional[str] = Field(None, description="Additional notes for this holding")
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
-
class StockHoldingCreate(StockHoldingBase):
|
| 64 |
-
# Used when adding a new lot of stocks. purchase_price is unit price for this lot.
|
| 65 |
-
pass
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
class StockHoldingUpdate(BaseModel):
|
| 69 |
-
# For updating notes or other specific fields on an aggregated holding.
|
| 70 |
-
# Avoid direct updates to quantity/purchase_price here unless specific logic handles recalculation of average price.
|
| 71 |
-
quantity: Optional[Decimal] = Field(
|
| 72 |
-
None, gt=0, description="Updated total number of shares (use with caution)"
|
| 73 |
-
)
|
| 74 |
-
purchase_price: Optional[Decimal] = Field(
|
| 75 |
-
None,
|
| 76 |
-
gt=0,
|
| 77 |
-
description="Updated average purchase price per share (use with caution)",
|
| 78 |
-
)
|
| 79 |
-
purchase_date: Optional[date] = Field(
|
| 80 |
-
None, description="Updated representative purchase date"
|
| 81 |
-
)
|
| 82 |
-
notes: Optional[str] = Field(None, description="Updated notes")
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
class StockHoldingResponse(StockHoldingBase):
|
| 86 |
-
id: int = Field(
|
| 87 |
-
..., description="Unique ID of the PortfolioStock (aggregated holding) record"
|
| 88 |
-
)
|
| 89 |
-
stock_symbol: str = Field(..., description="Ticker symbol of the stock")
|
| 90 |
-
stock_name: str = Field(..., description="Name of the stock company")
|
| 91 |
-
current_price: Optional[Decimal] = Field(
|
| 92 |
-
None, description="Current market price per share"
|
| 93 |
-
)
|
| 94 |
-
market_value: Optional[Decimal] = Field(
|
| 95 |
-
None, description="Total current market value of the holding"
|
| 96 |
-
)
|
| 97 |
-
gain_loss: Optional[Decimal] = Field(None, description="Absolute gain or loss")
|
| 98 |
-
gain_loss_percentage: Optional[Decimal] = Field(
|
| 99 |
-
None, description="Percentage gain or loss"
|
| 100 |
-
)
|
| 101 |
-
created_at: datetime
|
| 102 |
|
|
|
|
| 103 |
model_config = ConfigDict(from_attributes=True)
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
# --- UTT (Unit Trust / Mutual Fund) Holding Schemas ---
|
| 118 |
-
class UTTHoldingBase(BaseModel):
|
| 119 |
-
utt_fund_id: int = Field(
|
| 120 |
-
..., description="Internal ID of the UTT fund master record"
|
| 121 |
-
)
|
| 122 |
-
units_held: Decimal = Field(..., gt=0, description="Number of units held")
|
| 123 |
-
purchase_price: Decimal = Field(
|
| 124 |
-
...,
|
| 125 |
-
gt=0,
|
| 126 |
-
description="Average price per unit at purchase (NAV) for the aggregated holding",
|
| 127 |
-
)
|
| 128 |
-
purchase_date: date = Field(
|
| 129 |
-
..., description="Representative date of UTT purchase (e.g., latest buy)"
|
| 130 |
-
)
|
| 131 |
-
notes: Optional[str] = Field(None, description="Additional notes for this holding")
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
class UTTHoldingCreate(UTTHoldingBase):
|
| 135 |
-
# Used when adding a new lot of UTTs. purchase_price is unit price for this lot.
|
| 136 |
-
pass
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
class UTTHoldingUpdate(BaseModel):
|
| 140 |
-
units_held: Optional[Decimal] = Field(
|
| 141 |
-
None, gt=0, description="Updated number of units held (use with caution)"
|
| 142 |
-
)
|
| 143 |
-
purchase_price: Optional[Decimal] = Field(
|
| 144 |
-
None,
|
| 145 |
-
gt=0,
|
| 146 |
-
description="Updated average purchase price per unit (use with caution)",
|
| 147 |
-
)
|
| 148 |
-
purchase_date: Optional[date] = Field(
|
| 149 |
-
None, description="Updated representative purchase date"
|
| 150 |
-
)
|
| 151 |
-
notes: Optional[str] = Field(None, description="Updated notes")
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
class UTTHoldingResponse(UTTHoldingBase):
|
| 155 |
-
id: int = Field(
|
| 156 |
-
..., description="Unique ID of the PortfolioUTT (aggregated holding) record"
|
| 157 |
-
)
|
| 158 |
-
fund_symbol: str = Field(..., description="Symbol of the UTT fund")
|
| 159 |
-
fund_name: str = Field(..., description="Name of the UTT fund")
|
| 160 |
-
current_nav: Optional[Decimal] = Field(
|
| 161 |
-
None, description="Current Net Asset Value (NAV) per unit"
|
| 162 |
-
)
|
| 163 |
-
market_value: Optional[Decimal] = Field(
|
| 164 |
-
None, description="Total current market value of the holding"
|
| 165 |
-
)
|
| 166 |
-
gain_loss: Optional[Decimal] = Field(None, description="Absolute gain or loss")
|
| 167 |
-
gain_loss_percentage: Optional[Decimal] = Field(
|
| 168 |
-
None, description="Percentage gain or loss"
|
| 169 |
-
)
|
| 170 |
created_at: datetime
|
| 171 |
|
| 172 |
-
model_config = ConfigDict(from_attributes=True)
|
| 173 |
-
|
| 174 |
|
| 175 |
-
|
| 176 |
-
units_to_sell: Decimal = Field(
|
| 177 |
-
..., gt=0, description="Number of UTT units to sell"
|
| 178 |
-
) # Changed from 'units'
|
| 179 |
-
sell_price: Decimal = Field(
|
| 180 |
-
..., gt=0, description="Price per unit at which UTT was sold (NAV)"
|
| 181 |
-
)
|
| 182 |
-
sell_date: date = Field(..., description="Date of the sale")
|
| 183 |
-
notes: Optional[str] = Field(
|
| 184 |
-
None, description="Additional notes for the sell transaction"
|
| 185 |
-
)
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
# --- Bond Holding Schemas ---
|
| 189 |
-
class BondHoldingBase(BaseModel):
|
| 190 |
-
# bond_id: int = Field(..., description="Internal ID of the bond master record")
|
| 191 |
-
face_value_held: Decimal = Field(
|
| 192 |
-
..., gt=0, description="Total face value of the bond held"
|
| 193 |
-
)
|
| 194 |
-
auction_number: Optional[int] = Field(
|
| 195 |
-
None, description="Auction number if applicable (e.g., for government bonds)"
|
| 196 |
-
)
|
| 197 |
-
auction_date: Optional[date] = Field(
|
| 198 |
-
None, description="Auction date if applicable (e.g., for government bonds)"
|
| 199 |
-
)
|
| 200 |
-
purchase_price: Decimal = Field(
|
| 201 |
-
...,
|
| 202 |
-
gt=0,
|
| 203 |
-
description="TOTAL purchase price paid for the entire face_value_held (aggregated holding).",
|
| 204 |
-
)
|
| 205 |
-
purchase_date: date = Field(
|
| 206 |
-
..., description="Representative date of bond purchase (e.g., latest buy)"
|
| 207 |
-
)
|
| 208 |
-
notes: Optional[str] = Field(None, description="Additional notes for this holding")
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
class BondHoldingCreate(BondHoldingBase):
|
| 212 |
-
# Used when adding a new lot of bonds. purchase_price is TOTAL cost for this specific lot of face_value_held.
|
| 213 |
-
pass
|
| 214 |
|
| 215 |
|
| 216 |
-
class
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
)
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
gt=0,
|
| 223 |
-
description="Updated TOTAL purchase price for the new face_value_held (use with caution)",
|
| 224 |
-
)
|
| 225 |
-
purchase_date: Optional[date] = Field(
|
| 226 |
-
None, description="Updated representative purchase date"
|
| 227 |
-
)
|
| 228 |
-
notes: Optional[str] = Field(None, description="Updated notes")
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
class BondHoldingResponse(BondHoldingBase):
|
| 232 |
-
id: int = Field(
|
| 233 |
-
..., description="Unique ID of the PortfolioBond (aggregated holding) record"
|
| 234 |
-
)
|
| 235 |
-
instrument_type: str = Field(..., description="Type of bond instrument")
|
| 236 |
-
auction_number: Optional[int] = Field(
|
| 237 |
-
None, description="Auction number if applicable"
|
| 238 |
-
)
|
| 239 |
-
maturity_date: date = Field(..., description="Maturity date of the bond")
|
| 240 |
-
current_price: Optional[Decimal] = Field(
|
| 241 |
-
None,
|
| 242 |
-
description="Current market price (e.g., percentage of face value like 99.5)",
|
| 243 |
-
)
|
| 244 |
-
market_value: Optional[Decimal] = Field(
|
| 245 |
-
None, description="Total current market value of the holding"
|
| 246 |
-
)
|
| 247 |
-
accrued_interest: Optional[Decimal] = Field(
|
| 248 |
-
None, description="Accrued interest on the bond"
|
| 249 |
-
)
|
| 250 |
-
yield_to_maturity: Optional[Decimal] = Field(
|
| 251 |
-
None, description="Yield to maturity of the bond"
|
| 252 |
-
)
|
| 253 |
-
gain_loss: Optional[Decimal] = Field(
|
| 254 |
-
None, description="Absolute gain or loss on principal"
|
| 255 |
-
)
|
| 256 |
-
created_at: datetime
|
| 257 |
|
| 258 |
-
model_config = ConfigDict(from_attributes=True)
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
|
| 261 |
-
class BondSellSchema(BaseModel):
|
| 262 |
-
face_value_to_sell: Decimal = Field(
|
| 263 |
-
..., gt=0, description="Face value of the bond portion being sold"
|
| 264 |
-
) # Changed from 'face_value_sold'
|
| 265 |
-
sell_price: Decimal = Field(
|
| 266 |
-
..., gt=0, description="TOTAL selling proceeds for the face_value_to_sell."
|
| 267 |
-
)
|
| 268 |
-
sell_date: date = Field(..., description="Date of the sale")
|
| 269 |
-
notes: Optional[str] = Field(
|
| 270 |
-
None, description="Additional notes for the sell transaction"
|
| 271 |
-
)
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
# --- Calendar Event Schemas ---
|
| 275 |
-
class CalendarEventBase(BaseModel):
|
| 276 |
-
event_date: date
|
| 277 |
-
event_type: str = Field(..., max_length=50)
|
| 278 |
-
title: str = Field(..., max_length=200)
|
| 279 |
-
description: Optional[str] = None
|
| 280 |
-
asset_type: Optional[str] = Field(None, max_length=10)
|
| 281 |
-
asset_id: Optional[int] = None
|
| 282 |
-
estimated_amount: Optional[Decimal] = None
|
| 283 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
-
class CalendarEventCreate(CalendarEventBase):
|
| 286 |
-
pass
|
| 287 |
|
|
|
|
|
|
|
| 288 |
|
| 289 |
-
class CalendarEventResponse(CalendarEventBase):
|
| 290 |
id: int
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
created_at: datetime
|
| 293 |
|
| 294 |
-
model_config = ConfigDict(from_attributes=True)
|
| 295 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
|
| 297 |
-
# --- Transaction Schemas ---
|
| 298 |
-
class TransactionBase(BaseModel):
|
| 299 |
-
transaction_type: str = Field(..., max_length=20)
|
| 300 |
-
asset_type: str = Field(..., max_length=10)
|
| 301 |
-
asset_id: Optional[int] = None
|
| 302 |
-
asset_name: Optional[str] = Field(None, max_length=100)
|
| 303 |
-
quantity: Optional[Decimal] = None
|
| 304 |
-
price: Optional[Decimal] = Field(None, ge=0)
|
| 305 |
-
transaction_date: date
|
| 306 |
-
notes: Optional[str] = None
|
| 307 |
|
|
|
|
| 308 |
|
| 309 |
-
class TransactionCreate(TransactionBase):
|
| 310 |
-
total_amount: Decimal # Service layer calculates and provides this.
|
| 311 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
|
| 313 |
-
class TransactionResponse(TransactionBase):
|
| 314 |
-
id: int
|
| 315 |
-
total_amount: Decimal
|
| 316 |
-
created_at: datetime
|
| 317 |
|
| 318 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
|
| 320 |
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
cash_percentage: Decimal = Field(Decimal("0.0"), ge=0, le=100)
|
| 327 |
-
total_value: Decimal
|
| 328 |
|
| 329 |
-
model_config = ConfigDict(from_attributes=True)
|
| 330 |
|
|
|
|
|
|
|
| 331 |
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
notes: Optional[str] = None
|
|
|
|
|
|
|
| 339 |
|
| 340 |
-
|
| 341 |
-
from_attributes=True,
|
| 342 |
-
)
|
| 343 |
|
| 344 |
|
| 345 |
-
class
|
|
|
|
|
|
|
| 346 |
id: int
|
| 347 |
transaction_type: str
|
| 348 |
asset_type: str
|
| 349 |
asset_id: int
|
|
|
|
| 350 |
quantity: Decimal
|
| 351 |
price: Decimal
|
| 352 |
total_amount: Decimal
|
|
@@ -354,38 +190,71 @@ class TransactionDetailResponse(BaseModel):
|
|
| 354 |
notes: Optional[str] = None
|
| 355 |
created_at: datetime
|
| 356 |
|
| 357 |
-
|
| 358 |
-
|
|
|
|
| 359 |
asset_symbol: Optional[str] = None
|
| 360 |
|
| 361 |
-
model_config = ConfigDict(
|
| 362 |
-
from_attributes=True,
|
| 363 |
-
)
|
| 364 |
|
|
|
|
| 365 |
|
| 366 |
-
class PortfolioSummary(BaseModel):
|
| 367 |
-
portfolio: PortfolioBase
|
| 368 |
-
total_market_value: Decimal
|
| 369 |
-
total_cost_basis: Decimal
|
| 370 |
-
overall_unrealized_gain_loss: Decimal
|
| 371 |
-
overall_unrealized_gain_loss_percentage: Decimal
|
| 372 |
-
stock_holdings: List[StockHoldingResponse] = Field(default_factory=list)
|
| 373 |
-
utt_holdings: List[UTTHoldingResponse] = Field(default_factory=list)
|
| 374 |
-
bond_holdings: List[BondHoldingResponse] = Field(default_factory=list)
|
| 375 |
-
asset_allocation: AssetAllocation
|
| 376 |
-
recent_transactions: List[TransactionResponse] = Field(default_factory=list)
|
| 377 |
-
upcoming_events: List[CalendarEventResponse] = Field(default_factory=list)
|
| 378 |
|
| 379 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
|
| 381 |
|
| 382 |
-
class
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
asset_type: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
|
| 388 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
|
| 390 |
|
| 391 |
class PortfolioPerformance(BaseModel):
|
|
@@ -395,13 +264,11 @@ class PortfolioPerformance(BaseModel):
|
|
| 395 |
end_value: Decimal
|
| 396 |
absolute_return: Decimal
|
| 397 |
percentage_return: Decimal
|
| 398 |
-
best_performer: Optional[AssetPerformanceDetail] = None
|
| 399 |
-
worst_performer: Optional[AssetPerformanceDetail] = None
|
| 400 |
-
|
| 401 |
-
model_config = ConfigDict(from_attributes=True)
|
| 402 |
|
| 403 |
|
| 404 |
class PositionResponse(BaseModel):
|
|
|
|
|
|
|
| 405 |
asset_id: int
|
| 406 |
asset_type: str
|
| 407 |
asset_name: str
|
|
@@ -412,8 +279,4 @@ class PositionResponse(BaseModel):
|
|
| 412 |
current_price: Decimal
|
| 413 |
current_value: Decimal
|
| 414 |
profit_loss: Decimal
|
| 415 |
-
profit_loss_percent: float
|
| 416 |
-
|
| 417 |
-
model_config = ConfigDict(
|
| 418 |
-
from_attributes=True,
|
| 419 |
-
)
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Portfolio schemas — ONLY pydantic/stdlib imports.
|
| 3 |
+
NEVER import from .models, .service, .routes, or .utils
|
| 4 |
+
"""
|
| 5 |
+
from pydantic import BaseModel, Field, ConfigDict
|
| 6 |
+
from typing import Optional, Dict
|
| 7 |
from datetime import date, datetime
|
| 8 |
from decimal import Decimal
|
| 9 |
|
| 10 |
|
| 11 |
+
# ──────────────── PORTFOLIO ────────────────
|
| 12 |
+
|
| 13 |
+
|
| 14 |
class PortfolioBase(BaseModel):
|
| 15 |
+
model_config = ConfigDict(from_attributes=True)
|
| 16 |
+
|
| 17 |
id: int
|
| 18 |
name: str
|
| 19 |
description: Optional[str] = None
|
|
|
|
| 21 |
created_at: datetime
|
| 22 |
updated_at: datetime
|
| 23 |
|
|
|
|
|
|
|
| 24 |
|
| 25 |
class PortfolioCreate(BaseModel):
|
| 26 |
+
name: str = Field(..., min_length=1, max_length=100)
|
| 27 |
+
description: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
class PortfolioUpdate(BaseModel):
|
| 31 |
+
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
| 32 |
+
description: Optional[str] = None
|
| 33 |
+
is_active: Optional[bool] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
|
| 36 |
+
# ──────────────── STOCK HOLDINGS ────────────────
|
|
|
|
|
|
|
| 37 |
|
|
|
|
| 38 |
|
| 39 |
+
class StockHoldingCreate(BaseModel):
|
| 40 |
+
stock_id: int
|
| 41 |
+
quantity: Decimal = Field(..., gt=0)
|
| 42 |
+
purchase_price: Decimal = Field(..., gt=0)
|
| 43 |
+
purchase_date: date
|
| 44 |
+
notes: Optional[str] = None
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
+
class StockHoldingUpdate(BaseModel):
|
| 48 |
+
quantity: Optional[Decimal] = Field(None, gt=0)
|
| 49 |
+
purchase_price: Optional[Decimal] = Field(None, gt=0)
|
| 50 |
+
purchase_date: Optional[date] = None
|
| 51 |
+
notes: Optional[str] = None
|
| 52 |
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
+
class StockSellSchema(BaseModel):
|
| 55 |
+
quantity: Decimal = Field(..., gt=0)
|
| 56 |
+
sell_price: Decimal = Field(..., gt=0)
|
| 57 |
+
sell_date: date
|
| 58 |
+
notes: Optional[str] = None
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
+
class StockHoldingResponse(BaseModel):
|
| 62 |
model_config = ConfigDict(from_attributes=True)
|
| 63 |
|
| 64 |
+
id: int
|
| 65 |
+
stock_id: int
|
| 66 |
+
stock_symbol: str
|
| 67 |
+
stock_name: str
|
| 68 |
+
quantity: Decimal
|
| 69 |
+
purchase_price: Decimal
|
| 70 |
+
purchase_date: date
|
| 71 |
+
current_price: Optional[Decimal] = None
|
| 72 |
+
market_value: Optional[Decimal] = None
|
| 73 |
+
gain_loss: Optional[Decimal] = None
|
| 74 |
+
gain_loss_percentage: Optional[Decimal] = None
|
| 75 |
+
notes: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
created_at: datetime
|
| 77 |
|
|
|
|
|
|
|
| 78 |
|
| 79 |
+
# ──────────────── UTT HOLDINGS ────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
|
| 82 |
+
class FundHoldingCreate(BaseModel):
|
| 83 |
+
fund_id: int
|
| 84 |
+
units_held: Decimal = Field(..., gt=0)
|
| 85 |
+
purchase_price: Decimal = Field(..., gt=0)
|
| 86 |
+
purchase_date: date
|
| 87 |
+
notes: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
|
|
|
| 89 |
|
| 90 |
+
class FundHoldingUpdate(BaseModel):
|
| 91 |
+
units_held: Optional[Decimal] = Field(None, gt=0)
|
| 92 |
+
purchase_price: Optional[Decimal] = Field(None, gt=0)
|
| 93 |
+
purchase_date: Optional[date] = None
|
| 94 |
+
notes: Optional[str] = None
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
+
class FundSellSchema(BaseModel):
|
| 98 |
+
units_to_sell: Decimal = Field(..., gt=0)
|
| 99 |
+
sell_price: Decimal = Field(..., gt=0)
|
| 100 |
+
sell_date: date
|
| 101 |
+
notes: Optional[str] = None
|
| 102 |
|
|
|
|
|
|
|
| 103 |
|
| 104 |
+
class FundHoldingResponse(BaseModel):
|
| 105 |
+
model_config = ConfigDict(from_attributes=True)
|
| 106 |
|
|
|
|
| 107 |
id: int
|
| 108 |
+
fund_id: int
|
| 109 |
+
fund_name: str
|
| 110 |
+
fund_type: Optional[str] = None
|
| 111 |
+
units_held: Decimal
|
| 112 |
+
purchase_price: Decimal
|
| 113 |
+
purchase_date: date
|
| 114 |
+
current_nav: Optional[Decimal] = None
|
| 115 |
+
market_value: Optional[Decimal] = None
|
| 116 |
+
gain_loss: Optional[Decimal] = None
|
| 117 |
+
gain_loss_percentage: Optional[Decimal] = None
|
| 118 |
+
notes: Optional[str] = None
|
| 119 |
created_at: datetime
|
| 120 |
|
|
|
|
| 121 |
|
| 122 |
+
# Keep UTT aliases for backward compat
|
| 123 |
+
UTTHoldingCreate = FundHoldingCreate
|
| 124 |
+
UTTHoldingUpdate = FundHoldingUpdate
|
| 125 |
+
UTTSellSchema = FundSellSchema
|
| 126 |
+
UTTHoldingResponse = FundHoldingResponse
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
+
# ──────────────── BOND HOLDINGS ────────────────
|
| 130 |
|
|
|
|
|
|
|
| 131 |
|
| 132 |
+
class BondHoldingCreate(BaseModel):
|
| 133 |
+
bond_id: Optional[int] = None
|
| 134 |
+
auction_number: Optional[int] = None
|
| 135 |
+
face_value_held: Decimal = Field(..., gt=0)
|
| 136 |
+
purchase_price: Decimal = Field(..., gt=0)
|
| 137 |
+
purchase_date: date
|
| 138 |
+
notes: Optional[str] = None
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
+
class BondHoldingUpdate(BaseModel):
|
| 142 |
+
face_value_held: Optional[Decimal] = Field(None, gt=0)
|
| 143 |
+
purchase_price: Optional[Decimal] = Field(None, gt=0)
|
| 144 |
+
purchase_date: Optional[date] = None
|
| 145 |
+
notes: Optional[str] = None
|
| 146 |
|
| 147 |
|
| 148 |
+
class BondSellSchema(BaseModel):
|
| 149 |
+
face_value_to_sell: Decimal = Field(..., gt=0)
|
| 150 |
+
sell_price: Decimal = Field(..., gt=0)
|
| 151 |
+
sell_date: date
|
| 152 |
+
notes: Optional[str] = None
|
|
|
|
|
|
|
| 153 |
|
|
|
|
| 154 |
|
| 155 |
+
class BondHoldingResponse(BaseModel):
|
| 156 |
+
model_config = ConfigDict(from_attributes=True)
|
| 157 |
|
| 158 |
+
id: int
|
| 159 |
+
bond_id: int
|
| 160 |
+
instrument_type: str
|
| 161 |
+
auction_number: Optional[int] = None
|
| 162 |
+
maturity_date: date
|
| 163 |
+
face_value_held: Decimal
|
| 164 |
+
purchase_price: Decimal
|
| 165 |
+
purchase_date: date
|
| 166 |
+
current_price: Optional[Decimal] = None
|
| 167 |
+
market_value: Optional[Decimal] = None
|
| 168 |
+
accrued_interest: Optional[Decimal] = None
|
| 169 |
+
yield_to_maturity: Optional[Decimal] = None
|
| 170 |
+
gain_loss: Optional[Decimal] = None
|
| 171 |
notes: Optional[str] = None
|
| 172 |
+
created_at: datetime
|
| 173 |
+
|
| 174 |
|
| 175 |
+
# ──────────────── TRANSACTIONS ────────────────
|
|
|
|
|
|
|
| 176 |
|
| 177 |
|
| 178 |
+
class TransactionResponse(BaseModel):
|
| 179 |
+
model_config = ConfigDict(from_attributes=True)
|
| 180 |
+
|
| 181 |
id: int
|
| 182 |
transaction_type: str
|
| 183 |
asset_type: str
|
| 184 |
asset_id: int
|
| 185 |
+
asset_name: Optional[str] = None
|
| 186 |
quantity: Decimal
|
| 187 |
price: Decimal
|
| 188 |
total_amount: Decimal
|
|
|
|
| 190 |
notes: Optional[str] = None
|
| 191 |
created_at: datetime
|
| 192 |
|
| 193 |
+
|
| 194 |
+
class TransactionDetailResponse(TransactionResponse):
|
| 195 |
+
"""Enriched transaction with asset symbol."""
|
| 196 |
asset_symbol: Optional[str] = None
|
| 197 |
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
+
# ──────────────── CALENDAR ────────────────
|
| 200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
+
class CalendarEventCreate(BaseModel):
|
| 203 |
+
event_date: date
|
| 204 |
+
event_type: str = Field(..., max_length=50)
|
| 205 |
+
title: str = Field(..., max_length=200)
|
| 206 |
+
description: Optional[str] = None
|
| 207 |
+
asset_type: Optional[str] = None
|
| 208 |
+
asset_id: Optional[int] = None
|
| 209 |
+
estimated_amount: Optional[Decimal] = None
|
| 210 |
|
| 211 |
|
| 212 |
+
class CalendarEventResponse(BaseModel):
|
| 213 |
+
model_config = ConfigDict(from_attributes=True)
|
| 214 |
+
|
| 215 |
+
id: Optional[int] = None
|
| 216 |
+
event_date: date
|
| 217 |
+
event_type: str
|
| 218 |
+
title: Optional[str] = None
|
| 219 |
+
asset_symbol: Optional[str] = None
|
| 220 |
+
asset_name: Optional[str] = None
|
| 221 |
+
description: Optional[str] = None
|
| 222 |
asset_type: Optional[str] = None
|
| 223 |
+
asset_id: Optional[int] = None
|
| 224 |
+
estimated_amount: Optional[Decimal] = None
|
| 225 |
+
is_completed: Optional[bool] = False
|
| 226 |
+
created_at: Optional[datetime] = None
|
| 227 |
+
notes: Optional[str] = None
|
| 228 |
|
| 229 |
+
|
| 230 |
+
# ──────────────── SUMMARY & ANALYTICS ────────────────
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
class AssetAllocation(BaseModel):
|
| 234 |
+
stocks_percentage: Decimal = Decimal("0")
|
| 235 |
+
bonds_percentage: Decimal = Decimal("0")
|
| 236 |
+
funds_percentage: Decimal = Decimal("0")
|
| 237 |
+
cash_percentage: Decimal = Decimal("0")
|
| 238 |
+
total_value: Decimal
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
class PortfolioListResponse(BaseModel):
|
| 242 |
+
portfolios: list
|
| 243 |
+
total_count: int
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
class PortfolioSummary(BaseModel):
|
| 247 |
+
portfolio: PortfolioBase
|
| 248 |
+
total_market_value: Decimal
|
| 249 |
+
total_cost_basis: Decimal
|
| 250 |
+
unrealized_gain_loss: Decimal
|
| 251 |
+
unrealized_gain_loss_pct: Decimal
|
| 252 |
+
stock_holdings: list[StockHoldingResponse] = []
|
| 253 |
+
fund_holdings: list[FundHoldingResponse] = []
|
| 254 |
+
bond_holdings: list[BondHoldingResponse] = []
|
| 255 |
+
asset_allocation: AssetAllocation
|
| 256 |
+
recent_transactions: list[TransactionResponse] = []
|
| 257 |
+
upcoming_events: list[CalendarEventResponse] = []
|
| 258 |
|
| 259 |
|
| 260 |
class PortfolioPerformance(BaseModel):
|
|
|
|
| 264 |
end_value: Decimal
|
| 265 |
absolute_return: Decimal
|
| 266 |
percentage_return: Decimal
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
|
| 269 |
class PositionResponse(BaseModel):
|
| 270 |
+
model_config = ConfigDict(from_attributes=True)
|
| 271 |
+
|
| 272 |
asset_id: int
|
| 273 |
asset_type: str
|
| 274 |
asset_name: str
|
|
|
|
| 279 |
current_price: Decimal
|
| 280 |
current_value: Decimal
|
| 281 |
profit_loss: Decimal
|
| 282 |
+
profit_loss_percent: float
|
|
|
|
|
|
|
|
|
|
|
|
App/routers/portfolio/service.py
CHANGED
|
@@ -1,11 +1,15 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
| 3 |
from decimal import Decimal
|
| 4 |
-
from datetime import date,
|
| 5 |
-
from
|
|
|
|
| 6 |
from tortoise.transactions import in_transaction
|
| 7 |
|
| 8 |
-
from App.schemas import AppException
|
| 9 |
|
| 10 |
from .models import (
|
| 11 |
Portfolio,
|
|
@@ -16,75 +20,95 @@ from .models import (
|
|
| 16 |
PortfolioCalendar,
|
| 17 |
PortfolioSnapshot,
|
| 18 |
)
|
| 19 |
-
|
| 20 |
-
# Assuming models for stocks, utts, bonds are in these paths
|
| 21 |
-
from ..stocks.models import Stock, StockPriceData
|
| 22 |
-
from ..utt.models import UTTFund, UTTFundData
|
| 23 |
-
from ..bonds.models import (
|
| 24 |
-
Bond,
|
| 25 |
-
) # Assuming Bond model might have price_per_100 or similar
|
| 26 |
-
|
| 27 |
-
# Import Pydantic schemas
|
| 28 |
from .schemas import (
|
|
|
|
| 29 |
PortfolioSummary,
|
| 30 |
StockHoldingResponse,
|
| 31 |
-
|
| 32 |
BondHoldingResponse,
|
| 33 |
-
AssetAllocation,
|
| 34 |
-
PortfolioBase,
|
| 35 |
TransactionResponse,
|
| 36 |
-
CalendarEventResponse,
|
|
|
|
| 37 |
)
|
| 38 |
|
|
|
|
|
|
|
|
|
|
| 39 |
from App.routers.tasks.models import ImportTask
|
| 40 |
-
from datetime import date, timedelta
|
| 41 |
-
from tortoise.expressions import Q
|
| 42 |
-
from typing import List, Generator
|
| 43 |
|
| 44 |
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
bond: Bond, start_date: date, end_date: date
|
| 47 |
) -> Generator[date, None, None]:
|
| 48 |
"""
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
This makes a common assumption that coupon payments occur semi-annually,
|
| 52 |
-
with one payment on the maturity month/day and the other 6 months apart.
|
| 53 |
"""
|
| 54 |
-
if bond.maturity_date
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
| 66 |
try:
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
# Yield the date if it falls within the user's requested filter range
|
| 72 |
-
if start_date <= coupon_date1 <= end_date:
|
| 73 |
-
yield coupon_date1
|
| 74 |
-
if start_date <= coupon_date2 <= end_date:
|
| 75 |
-
yield coupon_date2
|
| 76 |
except ValueError:
|
| 77 |
-
# Handles cases like Feb 29 on a non-leap year, just skip that invalid date.
|
| 78 |
continue
|
| 79 |
|
| 80 |
|
|
|
|
|
|
|
|
|
|
| 81 |
class PortfolioService:
|
| 82 |
|
|
|
|
|
|
|
| 83 |
@staticmethod
|
| 84 |
async def get_user_portfolios(
|
| 85 |
-
user_id
|
| 86 |
-
) ->
|
| 87 |
-
"""Get all portfolios for a user"""
|
| 88 |
query = Portfolio.filter(user_id=user_id)
|
| 89 |
if not include_inactive:
|
| 90 |
query = query.filter(is_active=True)
|
|
@@ -92,74 +116,50 @@ class PortfolioService:
|
|
| 92 |
|
| 93 |
@staticmethod
|
| 94 |
async def create_portfolio(
|
| 95 |
-
user_id
|
| 96 |
) -> Portfolio:
|
| 97 |
-
"""Create a new portfolio for user"""
|
| 98 |
return await Portfolio.create(
|
| 99 |
-
user_id=user_id, name=name, description=description
|
| 100 |
)
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
@staticmethod
|
| 103 |
async def get_portfolio_summary(portfolio_id: int) -> PortfolioSummary:
|
| 104 |
-
|
| 105 |
-
portfolio_orm = await Portfolio.get_or_none(id=portfolio_id)
|
| 106 |
-
if not portfolio_orm:
|
| 107 |
-
raise DoesNotExist("Portfolio not found")
|
| 108 |
-
|
| 109 |
-
# Get all holdings with calculated values
|
| 110 |
-
stock_holdings_resp = await PortfolioService._get_stock_holdings_with_values(
|
| 111 |
-
portfolio_id
|
| 112 |
-
)
|
| 113 |
-
utt_holdings_resp = await PortfolioService._get_utt_holdings_with_values(
|
| 114 |
-
portfolio_id
|
| 115 |
-
)
|
| 116 |
-
bond_holdings_resp = await PortfolioService._get_bond_holdings_with_values(
|
| 117 |
-
portfolio_id
|
| 118 |
-
)
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
)
|
| 124 |
-
total_utt_value = sum(
|
| 125 |
-
Decimal(h.market_value) or Decimal("0") for h in utt_holdings_resp
|
| 126 |
-
)
|
| 127 |
-
total_bond_value = sum(
|
| 128 |
-
Decimal(h.market_value) or Decimal("0") for h in bond_holdings_resp
|
| 129 |
-
)
|
| 130 |
-
total_market_value = total_stock_value + total_utt_value + total_bond_value
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
)
|
| 137 |
-
total_utt_cost = sum(h.purchase_price * h.units_held for h in utt_holdings_resp)
|
| 138 |
-
# For bonds, BondHoldingResponse.purchase_price is the *total* purchase cost for that aggregated holding.
|
| 139 |
-
total_bond_cost = sum(h.purchase_price for h in bond_holdings_resp)
|
| 140 |
-
total_cost_basis = total_stock_cost + total_utt_cost + total_bond_cost
|
| 141 |
-
|
| 142 |
-
# Calculate overall gains/losses
|
| 143 |
-
overall_unrealized_gain_loss = total_market_value - total_cost_basis
|
| 144 |
-
overall_unrealized_gain_loss_percentage = (
|
| 145 |
-
(overall_unrealized_gain_loss / total_cost_basis * Decimal("100"))
|
| 146 |
-
if total_cost_basis > 0
|
| 147 |
-
else Decimal("0")
|
| 148 |
-
)
|
| 149 |
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
await PortfolioTransaction.filter(portfolio_id=portfolio_id)
|
| 153 |
.order_by("-transaction_date", "-created_at")
|
| 154 |
.limit(10)
|
| 155 |
-
.all()
|
| 156 |
)
|
| 157 |
-
|
| 158 |
-
TransactionResponse.from_orm(t) for t in recent_transactions_orm
|
| 159 |
-
]
|
| 160 |
|
| 161 |
-
|
| 162 |
-
upcoming_events_orm = (
|
| 163 |
await PortfolioCalendar.filter(
|
| 164 |
portfolio_id=portfolio_id,
|
| 165 |
event_date__gte=date.today(),
|
|
@@ -167,830 +167,669 @@ class PortfolioService:
|
|
| 167 |
)
|
| 168 |
.order_by("event_date")
|
| 169 |
.limit(10)
|
| 170 |
-
.all()
|
| 171 |
-
)
|
| 172 |
-
upcoming_events_resp = [
|
| 173 |
-
CalendarEventResponse.from_orm(e) for e in upcoming_events_orm
|
| 174 |
-
]
|
| 175 |
-
|
| 176 |
-
# Asset allocation
|
| 177 |
-
asset_alloc = AssetAllocation(
|
| 178 |
-
stocks_percentage=(
|
| 179 |
-
(total_stock_value / total_market_value * Decimal("100"))
|
| 180 |
-
if total_market_value > 0
|
| 181 |
-
else Decimal("0")
|
| 182 |
-
),
|
| 183 |
-
bonds_percentage=(
|
| 184 |
-
(total_bond_value / total_market_value * Decimal("100"))
|
| 185 |
-
if total_market_value > 0
|
| 186 |
-
else Decimal("0")
|
| 187 |
-
),
|
| 188 |
-
utts_percentage=(
|
| 189 |
-
(total_utt_value / total_market_value * Decimal("100"))
|
| 190 |
-
if total_market_value > 0
|
| 191 |
-
else Decimal("0")
|
| 192 |
-
),
|
| 193 |
-
cash_percentage=Decimal(
|
| 194 |
-
"0"
|
| 195 |
-
), # Assuming cash is not directly tracked here yet
|
| 196 |
-
total_value=total_market_value,
|
| 197 |
)
|
| 198 |
-
|
| 199 |
-
portfolio_base = PortfolioBase.from_orm(portfolio_orm)
|
| 200 |
|
| 201 |
return PortfolioSummary(
|
| 202 |
-
portfolio=
|
| 203 |
-
total_market_value=
|
| 204 |
-
total_cost_basis=
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
stock_holdings=
|
| 208 |
-
|
| 209 |
-
bond_holdings=
|
| 210 |
-
asset_allocation=
|
| 211 |
-
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
)
|
| 214 |
|
|
|
|
|
|
|
| 215 |
@staticmethod
|
| 216 |
-
async def
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
holdings_orm = (
|
| 220 |
-
await PortfolioStock.filter(portfolio_id=portfolio_id)
|
| 221 |
.prefetch_related("stock")
|
| 222 |
.all()
|
| 223 |
)
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
|
|
|
| 234 |
|
|
|
|
|
|
|
|
|
|
| 235 |
market_value = (
|
| 236 |
-
|
| 237 |
-
if current_price is not None
|
| 238 |
-
else None
|
| 239 |
-
)
|
| 240 |
-
# holding.purchase_price is average unit price
|
| 241 |
-
cost_basis = holding.purchase_price * holding.quantity
|
| 242 |
-
gain_loss = (
|
| 243 |
-
(market_value - cost_basis) if market_value is not None else None
|
| 244 |
-
)
|
| 245 |
-
gain_loss_percentage = (
|
| 246 |
-
(gain_loss / cost_basis * Decimal("100"))
|
| 247 |
-
if gain_loss is not None and cost_basis > 0
|
| 248 |
-
else None
|
| 249 |
)
|
|
|
|
|
|
|
| 250 |
|
| 251 |
results.append(
|
| 252 |
StockHoldingResponse(
|
| 253 |
-
id=
|
| 254 |
-
stock_id=
|
| 255 |
-
stock_symbol=
|
| 256 |
-
stock_name=
|
| 257 |
-
quantity=
|
| 258 |
-
purchase_price=
|
| 259 |
-
purchase_date=
|
| 260 |
current_price=current_price,
|
| 261 |
market_value=market_value,
|
| 262 |
-
gain_loss=
|
| 263 |
-
gain_loss_percentage=
|
| 264 |
-
notes=
|
| 265 |
-
created_at=
|
| 266 |
)
|
| 267 |
)
|
| 268 |
return results
|
| 269 |
|
| 270 |
@staticmethod
|
| 271 |
-
async def
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
await PortfolioUTT.filter(portfolio_id=portfolio_id)
|
| 276 |
-
.prefetch_related("utt_fund")
|
| 277 |
.all()
|
| 278 |
)
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
)
|
| 286 |
-
|
|
|
|
| 287 |
|
|
|
|
|
|
|
|
|
|
| 288 |
market_value = (
|
| 289 |
-
|
| 290 |
-
if current_nav is not None
|
| 291 |
-
else None
|
| 292 |
-
)
|
| 293 |
-
# holding.purchase_price is average unit price
|
| 294 |
-
cost_basis = holding.purchase_price * holding.units_held
|
| 295 |
-
gain_loss = (
|
| 296 |
-
(market_value - cost_basis) if market_value is not None else None
|
| 297 |
-
)
|
| 298 |
-
gain_loss_percentage = (
|
| 299 |
-
(gain_loss / cost_basis * Decimal("100"))
|
| 300 |
-
if gain_loss is not None and cost_basis > 0
|
| 301 |
-
else None
|
| 302 |
)
|
|
|
|
|
|
|
| 303 |
|
| 304 |
results.append(
|
| 305 |
-
|
| 306 |
-
id=
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
units_held=
|
| 311 |
-
purchase_price=
|
| 312 |
-
purchase_date=
|
| 313 |
current_nav=current_nav,
|
| 314 |
market_value=market_value,
|
| 315 |
-
gain_loss=
|
| 316 |
-
gain_loss_percentage=
|
| 317 |
-
notes=
|
| 318 |
-
created_at=
|
| 319 |
)
|
| 320 |
)
|
| 321 |
return results
|
| 322 |
|
| 323 |
-
@staticmethod
|
| 324 |
-
async def _get_bond_holdings_with_values(
|
| 325 |
-
portfolio_id: int,
|
| 326 |
-
) -> List[BondHoldingResponse]:
|
| 327 |
-
holdings_orm = (
|
| 328 |
-
await PortfolioBond.filter(portfolio_id=portfolio_id)
|
| 329 |
-
.prefetch_related("bond")
|
| 330 |
-
.all()
|
| 331 |
-
)
|
| 332 |
-
results = []
|
| 333 |
-
for holding in holdings_orm: # holding is now an aggregated record
|
| 334 |
-
current_price_percentage = (
|
| 335 |
-
holding.bond.price_per_100
|
| 336 |
-
if hasattr(holding.bond, "price_per_100") and holding.bond.price_per_100
|
| 337 |
-
else Decimal("100")
|
| 338 |
-
)
|
| 339 |
-
market_value = Decimal(
|
| 340 |
-
holding.face_value_held * current_price_percentage
|
| 341 |
-
) / Decimal("100")
|
| 342 |
-
# print(f"cu")
|
| 343 |
-
# holding.purchase_price on PortfolioBond model is the TOTAL cost of this aggregated holding
|
| 344 |
-
cost_basis = holding.purchase_price
|
| 345 |
-
gain_loss = (
|
| 346 |
-
(market_value - cost_basis) if market_value is not None else None
|
| 347 |
-
)
|
| 348 |
-
|
| 349 |
-
results.append(
|
| 350 |
-
BondHoldingResponse(
|
| 351 |
-
id=holding.id, # This ID is of the PortfolioBond record itself
|
| 352 |
-
bond_id=holding.bond.id,
|
| 353 |
-
instrument_type=holding.bond.instrument_type,
|
| 354 |
-
auction_number=(
|
| 355 |
-
holding.bond.auction_number
|
| 356 |
-
if hasattr(holding.bond, "auction_number")
|
| 357 |
-
else None
|
| 358 |
-
),
|
| 359 |
-
maturity_date=holding.bond.maturity_date,
|
| 360 |
-
face_value_held=holding.face_value_held,
|
| 361 |
-
purchase_price=cost_basis, # Reporting total purchase price of this holding
|
| 362 |
-
purchase_date=holding.purchase_date, # Date of first/last buy or as defined
|
| 363 |
-
current_price=current_price_percentage,
|
| 364 |
-
market_value=market_value,
|
| 365 |
-
accrued_interest=None,
|
| 366 |
-
yield_to_maturity=None,
|
| 367 |
-
gain_loss=gain_loss,
|
| 368 |
-
notes=holding.notes,
|
| 369 |
-
created_at=holding.created_at,
|
| 370 |
-
)
|
| 371 |
-
)
|
| 372 |
-
return results
|
| 373 |
|
| 374 |
@staticmethod
|
| 375 |
-
async def
|
| 376 |
portfolio_id: int,
|
| 377 |
stock_id: int,
|
| 378 |
-
|
| 379 |
-
|
| 380 |
purchase_date: date,
|
| 381 |
notes: Optional[str] = None,
|
| 382 |
) -> PortfolioStock:
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
raise AppException(
|
| 389 |
-
status_code=400, detail="Quantity to add must be positive."
|
| 390 |
-
)
|
| 391 |
|
| 392 |
async with in_transaction():
|
| 393 |
holding = await PortfolioStock.get_or_none(
|
| 394 |
portfolio_id=portfolio_id, stock_id=stock_id
|
| 395 |
)
|
| 396 |
-
|
| 397 |
if holding:
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
)
|
| 402 |
-
holding.
|
| 403 |
-
|
| 404 |
-
holding.purchase_price = (
|
| 405 |
-
new_total_cost / holding.quantity
|
| 406 |
-
) # New average price
|
| 407 |
-
else: # Should not happen if quantity_to_add is positive
|
| 408 |
-
holding.purchase_price = purchase_price_of_lot
|
| 409 |
-
|
| 410 |
-
holding.purchase_date = purchase_date # Update to latest purchase_date
|
| 411 |
-
if notes:
|
| 412 |
-
holding.notes = (
|
| 413 |
-
f"{holding.notes}\n{notes}".strip() if holding.notes else notes
|
| 414 |
-
)
|
| 415 |
await holding.save()
|
| 416 |
else:
|
| 417 |
-
# Create new holding
|
| 418 |
holding = await PortfolioStock.create(
|
| 419 |
portfolio_id=portfolio_id,
|
| 420 |
-
stock=
|
| 421 |
-
quantity=
|
| 422 |
-
purchase_price=
|
| 423 |
purchase_date=purchase_date,
|
| 424 |
-
notes=notes,
|
| 425 |
)
|
| 426 |
|
| 427 |
-
await
|
| 428 |
portfolio_id=portfolio_id,
|
| 429 |
-
|
| 430 |
asset_type="STOCK",
|
| 431 |
-
asset_id=
|
| 432 |
-
asset_name=
|
| 433 |
-
quantity=
|
| 434 |
-
price=
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
notes=notes or f"Bought {quantity_to_add} shares of {stock_obj.symbol}",
|
| 438 |
)
|
| 439 |
return holding
|
| 440 |
|
| 441 |
@staticmethod
|
| 442 |
-
async def
|
| 443 |
portfolio_id: int,
|
| 444 |
-
stock_id: int,
|
| 445 |
-
|
| 446 |
sell_price: Decimal,
|
| 447 |
sell_date: date,
|
| 448 |
notes: Optional[str] = None,
|
| 449 |
) -> PortfolioTransaction:
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
# Fetch the aggregated holding by portfolio_id and stock_id
|
| 456 |
-
holding = await PortfolioStock.get_or_none(
|
| 457 |
-
portfolio_id=portfolio_id, stock_id=stock_id
|
| 458 |
-
).prefetch_related(
|
| 459 |
-
"stock"
|
| 460 |
-
) # prefetch_related is good if you need stock.symbol etc.
|
| 461 |
-
|
| 462 |
if not holding:
|
| 463 |
-
raise
|
| 464 |
-
if
|
| 465 |
-
raise AppException(
|
| 466 |
-
|
| 467 |
-
)
|
| 468 |
-
if holding.quantity < quantity_to_sell:
|
| 469 |
raise AppException(
|
| 470 |
status_code=400,
|
| 471 |
-
|
| 472 |
)
|
| 473 |
|
| 474 |
async with in_transaction():
|
| 475 |
-
|
| 476 |
portfolio_id=portfolio_id,
|
| 477 |
-
|
| 478 |
asset_type="STOCK",
|
| 479 |
-
asset_id=holding.stock.id,
|
| 480 |
asset_name=holding.stock.symbol,
|
| 481 |
-
quantity=
|
| 482 |
price=sell_price,
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
notes=notes
|
| 486 |
-
or f"Sold {quantity_to_sell} shares of {holding.stock.symbol}",
|
| 487 |
)
|
| 488 |
-
holding.quantity -=
|
| 489 |
-
# The average purchase_price of the holding does not change upon selling.
|
| 490 |
if holding.quantity == 0:
|
| 491 |
await holding.delete()
|
| 492 |
else:
|
| 493 |
await holding.save()
|
| 494 |
-
return
|
|
|
|
|
|
|
| 495 |
|
| 496 |
@staticmethod
|
| 497 |
-
async def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
portfolio_id: int,
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
purchase_date: date,
|
| 503 |
notes: Optional[str] = None,
|
| 504 |
) -> PortfolioUTT:
|
| 505 |
-
|
| 506 |
-
if not
|
| 507 |
-
raise
|
| 508 |
-
if
|
| 509 |
-
raise AppException(status_code=400,
|
| 510 |
|
| 511 |
async with in_transaction():
|
| 512 |
holding = await PortfolioUTT.get_or_none(
|
| 513 |
-
portfolio_id=portfolio_id,
|
| 514 |
)
|
| 515 |
-
|
| 516 |
if holding:
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
)
|
| 521 |
-
holding.
|
| 522 |
-
|
| 523 |
-
holding.purchase_price = (
|
| 524 |
-
new_total_cost / holding.units_held
|
| 525 |
-
) # New average price
|
| 526 |
-
else:
|
| 527 |
-
holding.purchase_price = purchase_price_of_lot
|
| 528 |
-
|
| 529 |
-
holding.purchase_date = purchase_date # Update to latest purchase_date
|
| 530 |
-
if notes:
|
| 531 |
-
holding.notes = (
|
| 532 |
-
f"{holding.notes}\n{notes}".strip() if holding.notes else notes
|
| 533 |
-
)
|
| 534 |
await holding.save()
|
| 535 |
else:
|
| 536 |
-
# Create new holding
|
| 537 |
holding = await PortfolioUTT.create(
|
| 538 |
portfolio_id=portfolio_id,
|
| 539 |
-
|
| 540 |
-
units_held=
|
| 541 |
-
purchase_price=
|
| 542 |
purchase_date=purchase_date,
|
| 543 |
-
notes=notes,
|
| 544 |
)
|
| 545 |
|
| 546 |
-
await
|
| 547 |
portfolio_id=portfolio_id,
|
| 548 |
-
|
| 549 |
-
asset_type="
|
| 550 |
-
asset_id=
|
| 551 |
-
asset_name=
|
| 552 |
-
quantity=
|
| 553 |
-
price=
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
notes=notes or f"Bought {units_to_add} units of {utt_fund_obj.symbol}",
|
| 557 |
)
|
| 558 |
return holding
|
| 559 |
|
| 560 |
@staticmethod
|
| 561 |
-
async def
|
| 562 |
portfolio_id: int,
|
| 563 |
-
|
| 564 |
-
|
| 565 |
sell_price: Decimal,
|
| 566 |
sell_date: date,
|
| 567 |
notes: Optional[str] = None,
|
| 568 |
) -> PortfolioTransaction:
|
| 569 |
-
holding = await
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
|
|
|
| 573 |
if not holding:
|
| 574 |
-
raise
|
| 575 |
-
if
|
| 576 |
-
raise AppException(
|
| 577 |
-
|
| 578 |
-
)
|
| 579 |
-
if holding.units_held < units_to_sell:
|
| 580 |
raise AppException(
|
| 581 |
status_code=400,
|
| 582 |
-
|
| 583 |
)
|
| 584 |
|
| 585 |
async with in_transaction():
|
| 586 |
-
|
| 587 |
portfolio_id=portfolio_id,
|
| 588 |
-
|
| 589 |
-
asset_type="
|
| 590 |
-
asset_id=holding.
|
| 591 |
-
asset_name=holding.
|
| 592 |
-
quantity=
|
| 593 |
price=sell_price,
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
notes=notes
|
| 597 |
-
or f"Sold {units_to_sell} units of {holding.utt_fund.symbol}",
|
| 598 |
)
|
| 599 |
-
holding.units_held -=
|
| 600 |
-
# Average purchase_price of the holding remains unchanged.
|
| 601 |
if holding.units_held == 0:
|
| 602 |
await holding.delete()
|
| 603 |
else:
|
| 604 |
await holding.save()
|
| 605 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
|
| 607 |
@staticmethod
|
| 608 |
-
async def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 609 |
portfolio_id: int,
|
| 610 |
bond_id: int,
|
| 611 |
-
|
| 612 |
-
|
| 613 |
purchase_date: date,
|
| 614 |
notes: Optional[str] = None,
|
| 615 |
) -> PortfolioBond:
|
| 616 |
-
|
| 617 |
-
if not
|
| 618 |
-
raise
|
| 619 |
-
if
|
| 620 |
-
raise AppException(
|
| 621 |
-
|
| 622 |
-
|
| 623 |
|
| 624 |
async with in_transaction():
|
| 625 |
holding = await PortfolioBond.get_or_none(
|
| 626 |
portfolio_id=portfolio_id, bond_id=bond_id
|
| 627 |
)
|
| 628 |
-
|
| 629 |
if holding:
|
| 630 |
-
|
| 631 |
-
holding.
|
| 632 |
-
holding.
|
| 633 |
-
|
| 634 |
-
)
|
| 635 |
-
|
| 636 |
-
holding.purchase_date = purchase_date # Update to latest purchase_date
|
| 637 |
-
if notes:
|
| 638 |
-
holding.notes = (
|
| 639 |
-
f"{holding.notes}\n{notes}".strip() if holding.notes else notes
|
| 640 |
-
)
|
| 641 |
await holding.save()
|
| 642 |
else:
|
| 643 |
-
# Create new holding
|
| 644 |
holding = await PortfolioBond.create(
|
| 645 |
portfolio_id=portfolio_id,
|
| 646 |
-
bond=
|
| 647 |
-
face_value_held=
|
| 648 |
-
purchase_price=
|
| 649 |
purchase_date=purchase_date,
|
| 650 |
-
notes=notes,
|
| 651 |
)
|
| 652 |
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
if face_value_to_add > 0
|
| 656 |
-
else Decimal("0")
|
| 657 |
)
|
| 658 |
-
await
|
| 659 |
portfolio_id=portfolio_id,
|
| 660 |
-
|
| 661 |
asset_type="BOND",
|
| 662 |
-
asset_id=
|
| 663 |
-
asset_name=
|
| 664 |
-
quantity=
|
| 665 |
-
price=
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
notes=notes
|
| 669 |
-
or f"Bought {face_value_to_add} face value of Bond {bond_obj.auction_number or bond_obj.id}",
|
| 670 |
)
|
| 671 |
return holding
|
| 672 |
|
| 673 |
@staticmethod
|
| 674 |
-
async def
|
| 675 |
portfolio_id: int,
|
| 676 |
-
bond_id: int,
|
| 677 |
-
|
| 678 |
-
|
| 679 |
sell_date: date,
|
| 680 |
notes: Optional[str] = None,
|
| 681 |
) -> PortfolioTransaction:
|
| 682 |
-
holding = await
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
|
|
|
| 686 |
if not holding:
|
| 687 |
-
raise
|
| 688 |
-
if
|
| 689 |
-
raise AppException(
|
| 690 |
-
|
| 691 |
-
)
|
| 692 |
-
if holding.face_value_held < face_value_to_sell:
|
| 693 |
raise AppException(
|
| 694 |
status_code=400,
|
| 695 |
-
|
| 696 |
)
|
| 697 |
|
|
|
|
|
|
|
| 698 |
async with in_transaction():
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
if face_value_to_sell > 0
|
| 702 |
-
else Decimal("0")
|
| 703 |
)
|
| 704 |
-
|
| 705 |
-
transaction = await PortfolioTransaction.create(
|
| 706 |
portfolio_id=portfolio_id,
|
| 707 |
-
|
| 708 |
asset_type="BOND",
|
| 709 |
-
asset_id=holding.bond.id,
|
| 710 |
-
asset_name=
|
| 711 |
-
quantity=
|
| 712 |
-
price=
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
notes=notes
|
| 716 |
-
or f"Sold {face_value_to_sell} face value of Bond {holding.bond.auction_number or holding.bond.id}",
|
| 717 |
)
|
| 718 |
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
holding.face_value_held -= face_value_to_sell
|
| 723 |
|
| 724 |
-
if holding.face_value_held ==
|
| 725 |
-
"0"
|
| 726 |
-
): # Ensure exact zero comparison for Decimal
|
| 727 |
await holding.delete()
|
| 728 |
else:
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
else:
|
| 735 |
-
holding.purchase_price = Decimal(
|
| 736 |
-
"0"
|
| 737 |
-
) # Should not be reached if logic is correct
|
| 738 |
await holding.save()
|
| 739 |
-
return
|
|
|
|
|
|
|
| 740 |
|
| 741 |
@staticmethod
|
| 742 |
async def remove_holding(
|
| 743 |
-
portfolio_id: int,
|
| 744 |
) -> bool:
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
if asset_type_str.upper() == "STOCK":
|
| 753 |
-
model_to_delete = PortfolioStock
|
| 754 |
-
asset_id_field_name = "stock_id"
|
| 755 |
-
elif asset_type_str.upper() == "UTT":
|
| 756 |
-
model_to_delete = PortfolioUTT
|
| 757 |
-
asset_id_field_name = "utt_fund_id"
|
| 758 |
-
elif asset_type_str.upper() == "BOND":
|
| 759 |
-
model_to_delete = PortfolioBond
|
| 760 |
-
asset_id_field_name = "bond_id"
|
| 761 |
-
else:
|
| 762 |
raise AppException(
|
| 763 |
-
status_code=400,
|
| 764 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 765 |
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 772 |
|
| 773 |
@staticmethod
|
| 774 |
-
async def
|
| 775 |
-
portfolio_id: int,
|
| 776 |
) -> PortfolioSnapshot:
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
else:
|
| 790 |
-
target_date = snapshot_date_input
|
| 791 |
-
|
| 792 |
-
# --- Initialize accumulators ---
|
| 793 |
-
total_market_value = Decimal("0.0")
|
| 794 |
-
total_cost_basis = Decimal("0.0")
|
| 795 |
-
stock_val = Decimal("0.0")
|
| 796 |
-
bond_val = Decimal("0.0")
|
| 797 |
-
utt_val = Decimal("0.0")
|
| 798 |
-
|
| 799 |
-
# --- 1. Process Stock Holdings ---
|
| 800 |
-
# Get all stock holdings purchased on or before the target date
|
| 801 |
-
stock_holdings = await PortfolioStock.filter(
|
| 802 |
-
portfolio_id=portfolio_id, purchase_date__lte=target_date
|
| 803 |
-
).select_related("stock")
|
| 804 |
-
|
| 805 |
-
for holding in stock_holdings:
|
| 806 |
-
# Find the most recent price for this stock on or before the target_date
|
| 807 |
-
price_data = (
|
| 808 |
-
await StockPriceData.filter(
|
| 809 |
-
stock_id=holding.stock_id, date__lte=target_date
|
| 810 |
-
)
|
| 811 |
.order_by("-date")
|
| 812 |
.first()
|
| 813 |
)
|
|
|
|
|
|
|
|
|
|
| 814 |
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
# The cost basis is the sum of purchase prices for all holdings that existed at that time
|
| 822 |
-
total_cost_basis += holding.purchase_price
|
| 823 |
-
|
| 824 |
-
# --- 2. Process UTT Holdings ---
|
| 825 |
-
utt_holdings = await PortfolioUTT.filter(
|
| 826 |
-
portfolio_id=portfolio_id, purchase_date__lte=target_date
|
| 827 |
-
).select_related("utt_fund")
|
| 828 |
-
|
| 829 |
-
for holding in utt_holdings:
|
| 830 |
-
# Find the most recent NAV for this fund on or before the target_date
|
| 831 |
-
price_data = (
|
| 832 |
-
await UTTFundData.filter(
|
| 833 |
-
fund_id=holding.utt_fund_id, date__lte=target_date
|
| 834 |
-
)
|
| 835 |
-
.order_by("-date")
|
| 836 |
.first()
|
| 837 |
)
|
|
|
|
|
|
|
|
|
|
| 838 |
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
utt_val += holding_market_value
|
| 845 |
-
|
| 846 |
-
total_cost_basis += holding.purchase_price
|
| 847 |
-
|
| 848 |
-
# --- 3. Process Bond Holdings ---
|
| 849 |
-
bond_holdings = await PortfolioBond.filter(
|
| 850 |
-
portfolio_id=portfolio_id, purchase_date__lte=target_date
|
| 851 |
-
).select_related("bond")
|
| 852 |
-
|
| 853 |
-
for holding in bond_holdings:
|
| 854 |
-
# NOTE: Bond valuation is complex. The current `Bond` model does not store historical prices.
|
| 855 |
-
# A simplified valuation is used here: market value is assumed to be the face value.
|
| 856 |
-
# For a more advanced system, a separate `BondPriceData` table would be needed.
|
| 857 |
-
holding_market_value = Decimal(holding.face_value_held)
|
| 858 |
-
bond_val += holding_market_value
|
| 859 |
-
|
| 860 |
-
total_cost_basis += holding.purchase_price
|
| 861 |
-
|
| 862 |
-
# --- Aggregate all values ---
|
| 863 |
-
total_market_value = stock_val + bond_val + utt_val
|
| 864 |
-
unrealized_gain_loss = total_market_value - total_cost_basis
|
| 865 |
|
| 866 |
-
|
| 867 |
-
# This prevents duplicate snapshots if the task runs multiple times.
|
| 868 |
-
snapshot_datetime = datetime.combine(target_date, datetime.min.time())
|
| 869 |
|
| 870 |
-
snapshot,
|
| 871 |
portfolio_id=portfolio_id,
|
| 872 |
-
snapshot_date=
|
| 873 |
defaults={
|
| 874 |
-
"total_value":
|
| 875 |
"stock_value": stock_val,
|
| 876 |
"bond_value": bond_val,
|
| 877 |
-
"
|
| 878 |
-
"cash_value":
|
| 879 |
-
"total_cost":
|
| 880 |
-
"unrealized_gain_loss":
|
| 881 |
},
|
| 882 |
)
|
| 883 |
-
|
| 884 |
-
if created:
|
| 885 |
-
print(f"Created snapshot for portfolio {portfolio_id} on {target_date}")
|
| 886 |
-
else:
|
| 887 |
-
print(f"Updated snapshot for portfolio {portfolio_id} on {target_date}")
|
| 888 |
-
|
| 889 |
return snapshot
|
| 890 |
|
|
|
|
|
|
|
| 891 |
@staticmethod
|
| 892 |
-
async def
|
| 893 |
-
task_id: int, portfolio_id: int, start_date: date = None
|
| 894 |
):
|
| 895 |
-
"""
|
| 896 |
-
A robust background task that generates or regenerates historical portfolio snapshots.
|
| 897 |
-
|
| 898 |
-
- If a 'start_date' is provided (e.g., from a back-dated transaction), it will start from there.
|
| 899 |
-
- If 'start_date' is None, it will intelligently find the date of the very first transaction
|
| 900 |
-
in the portfolio and start from that point, ensuring all possible data is generated.
|
| 901 |
-
- It always deletes existing snapshots in the target date range before creating new ones
|
| 902 |
-
to prevent duplicates and ensure data is fresh.
|
| 903 |
-
"""
|
| 904 |
await ImportTask.filter(id=task_id).update(status="running")
|
| 905 |
|
| 906 |
try:
|
| 907 |
-
# 1. DETERMINE THE START DATE
|
| 908 |
-
# If no specific start date is given, find the earliest transaction for this portfolio.
|
| 909 |
if not start_date:
|
| 910 |
-
|
| 911 |
await PortfolioTransaction.filter(portfolio_id=portfolio_id)
|
| 912 |
.order_by("transaction_date")
|
| 913 |
.first()
|
| 914 |
)
|
| 915 |
-
|
| 916 |
-
if first_transaction:
|
| 917 |
-
start_date = first_transaction.transaction_date
|
| 918 |
-
print(
|
| 919 |
-
f"[Task {task_id}] No start date provided. Found earliest transaction on {start_date}."
|
| 920 |
-
)
|
| 921 |
-
else:
|
| 922 |
-
# If there are no transactions, there's nothing to snapshot.
|
| 923 |
await ImportTask.filter(id=task_id).update(
|
| 924 |
status="completed",
|
| 925 |
-
details={
|
| 926 |
-
"message": "No transactions found in portfolio. Nothing to generate."
|
| 927 |
-
},
|
| 928 |
-
)
|
| 929 |
-
print(
|
| 930 |
-
f"[Task {task_id}] No transactions for portfolio {portfolio_id}. Task complete."
|
| 931 |
)
|
| 932 |
return
|
|
|
|
| 933 |
|
| 934 |
end_date = date.today()
|
| 935 |
-
print(
|
| 936 |
-
f"[Task {task_id}] Starting snapshot generation for portfolio {portfolio_id} from {start_date} to {end_date}"
|
| 937 |
-
)
|
| 938 |
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
deleted_count = await PortfolioSnapshot.filter(
|
| 942 |
-
portfolio_id=portfolio_id, snapshot_date__gte=start_datetime
|
| 943 |
).delete()
|
| 944 |
-
print(
|
| 945 |
-
f"[Task {task_id}] Invalidated and deleted {deleted_count} stale snapshots."
|
| 946 |
-
)
|
| 947 |
-
|
| 948 |
-
# 3. REGENERATE: Loop from the start date to today and recreate each snapshot.
|
| 949 |
-
def date_range(start, end):
|
| 950 |
-
# Helper to iterate through a range of dates.
|
| 951 |
-
for n in range(int((end - start).days) + 1):
|
| 952 |
-
yield start + timedelta(n)
|
| 953 |
|
| 954 |
-
|
| 955 |
failed_days = []
|
| 956 |
-
|
|
|
|
| 957 |
try:
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
await PortfolioService.create_portfolio_snapshot(
|
| 961 |
-
portfolio_id=portfolio_id, snapshot_date_input=single_date
|
| 962 |
-
)
|
| 963 |
-
print(
|
| 964 |
-
f"[Task {task_id}] Successfully generated snapshot for {single_date.isoformat()}"
|
| 965 |
-
)
|
| 966 |
-
generated_count += 1
|
| 967 |
except Exception as e:
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
print(
|
| 971 |
-
f"[Task {task_id}] WARNING: Could not generate snapshot for {single_date}: {e}"
|
| 972 |
-
)
|
| 973 |
|
| 974 |
-
# 4. FINALIZE: Update the task with a summary of the operation.
|
| 975 |
-
summary = {
|
| 976 |
-
"message": "Snapshot generation complete.",
|
| 977 |
-
"deleted_stale_snapshots": deleted_count,
|
| 978 |
-
"new_snapshots_generated": generated_count,
|
| 979 |
-
"failed_days_count": len(failed_days),
|
| 980 |
-
"failed_days": failed_days,
|
| 981 |
-
"date_range": f"{start_date.isoformat()} to {end_date.isoformat()}",
|
| 982 |
-
}
|
| 983 |
await ImportTask.filter(id=task_id).update(
|
| 984 |
-
status="completed",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 985 |
)
|
| 986 |
-
print(f"[Task {task_id}] Completed successfully. Summary: {summary}")
|
| 987 |
|
| 988 |
except Exception as e:
|
| 989 |
-
# Catch any fatal error during the task and mark it as failed.
|
| 990 |
await ImportTask.filter(id=task_id).update(
|
| 991 |
status="failed",
|
| 992 |
-
details={
|
| 993 |
-
"error": f"A fatal error occurred during snapshot regeneration: {str(e)}"
|
| 994 |
-
},
|
| 995 |
)
|
| 996 |
-
print(f"[Task {task_id}]
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Portfolio service — imports from:
|
| 3 |
+
✅ .models, .schemas, other routers' models
|
| 4 |
+
❌ NEVER from .routes or .utils
|
| 5 |
+
"""
|
| 6 |
from decimal import Decimal
|
| 7 |
+
from datetime import date, timedelta
|
| 8 |
+
from typing import Optional, Generator
|
| 9 |
+
|
| 10 |
from tortoise.transactions import in_transaction
|
| 11 |
|
| 12 |
+
from App.schemas import AppException
|
| 13 |
|
| 14 |
from .models import (
|
| 15 |
Portfolio,
|
|
|
|
| 20 |
PortfolioCalendar,
|
| 21 |
PortfolioSnapshot,
|
| 22 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
from .schemas import (
|
| 24 |
+
PortfolioBase,
|
| 25 |
PortfolioSummary,
|
| 26 |
StockHoldingResponse,
|
| 27 |
+
FundHoldingResponse,
|
| 28 |
BondHoldingResponse,
|
|
|
|
|
|
|
| 29 |
TransactionResponse,
|
| 30 |
+
CalendarEventResponse,
|
| 31 |
+
AssetAllocation,
|
| 32 |
)
|
| 33 |
|
| 34 |
+
from App.routers.stocks.models import Stock, StockPriceData
|
| 35 |
+
from App.routers.funds.models import MutualFund, FundPerformance
|
| 36 |
+
from App.routers.bonds.models import Bond
|
| 37 |
from App.routers.tasks.models import ImportTask
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
+
ZERO = Decimal("0")
|
| 41 |
+
HUNDRED = Decimal("100")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ──────────────────────────── HELPERS ────────────────────────────
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _pct(part: Decimal, total: Decimal) -> Decimal:
|
| 48 |
+
return (part / total * HUNDRED) if total > 0 else ZERO
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _gain(market_value: Optional[Decimal], cost: Decimal):
|
| 52 |
+
if market_value is None:
|
| 53 |
+
return None, None
|
| 54 |
+
gain = market_value - cost
|
| 55 |
+
pct = _pct(gain, cost) if cost > 0 else None
|
| 56 |
+
return gain, pct
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _append_notes(existing: Optional[str], new: Optional[str]) -> str:
|
| 60 |
+
if not new:
|
| 61 |
+
return existing or ""
|
| 62 |
+
if existing:
|
| 63 |
+
return f"{existing}\n{new}".strip()
|
| 64 |
+
return new
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _date_range(start: date, end: date) -> Generator[date, None, None]:
|
| 68 |
+
for n in range(int((end - start).days) + 1):
|
| 69 |
+
yield start + timedelta(n)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def calculate_bond_coupon_dates(
|
| 73 |
bond: Bond, start_date: date, end_date: date
|
| 74 |
) -> Generator[date, None, None]:
|
| 75 |
"""
|
| 76 |
+
Calculate semi-annual coupon payment dates for a bond within a date range.
|
| 77 |
+
Assumes coupons occur on maturity month/day and 6 months apart.
|
|
|
|
|
|
|
| 78 |
"""
|
| 79 |
+
if not bond.maturity_date or not hasattr(bond, "coupon_rate") or not bond.coupon_rate:
|
| 80 |
+
return
|
| 81 |
+
if bond.coupon_rate <= 0:
|
| 82 |
+
return
|
| 83 |
+
|
| 84 |
+
m1 = bond.maturity_date.month
|
| 85 |
+
d1 = bond.maturity_date.day
|
| 86 |
+
m2 = (m1 + 5) % 12 + 1
|
| 87 |
+
|
| 88 |
+
start_year = getattr(bond, "effective_date", bond.maturity_date).year
|
| 89 |
+
end_year = bond.maturity_date.year
|
| 90 |
+
|
| 91 |
+
for year in range(start_year, end_year + 1):
|
| 92 |
+
for month in (m1, m2):
|
| 93 |
try:
|
| 94 |
+
coupon_date = date(year, month, d1)
|
| 95 |
+
if start_date <= coupon_date <= end_date:
|
| 96 |
+
yield coupon_date
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
except ValueError:
|
|
|
|
| 98 |
continue
|
| 99 |
|
| 100 |
|
| 101 |
+
# ─────────────────────────��── SERVICE ────────────────────────────
|
| 102 |
+
|
| 103 |
+
|
| 104 |
class PortfolioService:
|
| 105 |
|
| 106 |
+
# ──────────────── CRUD ────────────────
|
| 107 |
+
|
| 108 |
@staticmethod
|
| 109 |
async def get_user_portfolios(
|
| 110 |
+
user_id, include_inactive: bool = False
|
| 111 |
+
) -> list[Portfolio]:
|
|
|
|
| 112 |
query = Portfolio.filter(user_id=user_id)
|
| 113 |
if not include_inactive:
|
| 114 |
query = query.filter(is_active=True)
|
|
|
|
| 116 |
|
| 117 |
@staticmethod
|
| 118 |
async def create_portfolio(
|
| 119 |
+
user_id, name: str, description: Optional[str] = None
|
| 120 |
) -> Portfolio:
|
|
|
|
| 121 |
return await Portfolio.create(
|
| 122 |
+
user_id=user_id, name=name, description=description or ""
|
| 123 |
)
|
| 124 |
|
| 125 |
+
@staticmethod
|
| 126 |
+
async def get_portfolio_or_404(portfolio_id: int) -> Portfolio:
|
| 127 |
+
portfolio = await Portfolio.get_or_none(id=portfolio_id)
|
| 128 |
+
if not portfolio:
|
| 129 |
+
raise AppException(status_code=404, message="Portfolio not found")
|
| 130 |
+
return portfolio
|
| 131 |
+
|
| 132 |
+
# ──────────────── SUMMARY ────────────────
|
| 133 |
+
|
| 134 |
@staticmethod
|
| 135 |
async def get_portfolio_summary(portfolio_id: int) -> PortfolioSummary:
|
| 136 |
+
portfolio = await PortfolioService.get_portfolio_or_404(portfolio_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
+
stocks = await PortfolioService._get_stock_holdings(portfolio_id)
|
| 139 |
+
funds = await PortfolioService._get_fund_holdings(portfolio_id)
|
| 140 |
+
bonds = await PortfolioService._get_bond_holdings(portfolio_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
+
stock_value = sum(h.market_value or ZERO for h in stocks)
|
| 143 |
+
fund_value = sum(h.market_value or ZERO for h in funds)
|
| 144 |
+
bond_value = sum(h.market_value or ZERO for h in bonds)
|
| 145 |
+
total_value = stock_value + fund_value + bond_value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
+
stock_cost = sum(h.purchase_price * h.quantity for h in stocks)
|
| 148 |
+
fund_cost = sum(h.purchase_price * h.units_held for h in funds)
|
| 149 |
+
bond_cost = sum(h.purchase_price for h in bonds)
|
| 150 |
+
total_cost = stock_cost + fund_cost + bond_cost
|
| 151 |
+
|
| 152 |
+
unrealized = total_value - total_cost
|
| 153 |
+
unrealized_pct = _pct(unrealized, total_cost)
|
| 154 |
+
|
| 155 |
+
txns = (
|
| 156 |
await PortfolioTransaction.filter(portfolio_id=portfolio_id)
|
| 157 |
.order_by("-transaction_date", "-created_at")
|
| 158 |
.limit(10)
|
|
|
|
| 159 |
)
|
| 160 |
+
txn_responses = [TransactionResponse.model_validate(t) for t in txns]
|
|
|
|
|
|
|
| 161 |
|
| 162 |
+
events = (
|
|
|
|
| 163 |
await PortfolioCalendar.filter(
|
| 164 |
portfolio_id=portfolio_id,
|
| 165 |
event_date__gte=date.today(),
|
|
|
|
| 167 |
)
|
| 168 |
.order_by("event_date")
|
| 169 |
.limit(10)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
)
|
| 171 |
+
event_responses = [CalendarEventResponse.model_validate(e) for e in events]
|
|
|
|
| 172 |
|
| 173 |
return PortfolioSummary(
|
| 174 |
+
portfolio=PortfolioBase.model_validate(portfolio),
|
| 175 |
+
total_market_value=total_value,
|
| 176 |
+
total_cost_basis=total_cost,
|
| 177 |
+
unrealized_gain_loss=unrealized,
|
| 178 |
+
unrealized_gain_loss_pct=unrealized_pct,
|
| 179 |
+
stock_holdings=stocks,
|
| 180 |
+
fund_holdings=funds,
|
| 181 |
+
bond_holdings=bonds,
|
| 182 |
+
asset_allocation=AssetAllocation(
|
| 183 |
+
stocks_percentage=_pct(stock_value, total_value),
|
| 184 |
+
bonds_percentage=_pct(bond_value, total_value),
|
| 185 |
+
funds_percentage=_pct(fund_value, total_value),
|
| 186 |
+
total_value=total_value,
|
| 187 |
+
),
|
| 188 |
+
recent_transactions=txn_responses,
|
| 189 |
+
upcoming_events=event_responses,
|
| 190 |
)
|
| 191 |
|
| 192 |
+
# ──────────────── STOCK OPS ────────────────
|
| 193 |
+
|
| 194 |
@staticmethod
|
| 195 |
+
async def _get_stock_holdings(portfolio_id: int) -> list[StockHoldingResponse]:
|
| 196 |
+
holdings = await (
|
| 197 |
+
PortfolioStock.filter(portfolio_id=portfolio_id)
|
|
|
|
|
|
|
| 198 |
.prefetch_related("stock")
|
| 199 |
.all()
|
| 200 |
)
|
| 201 |
+
if not holdings:
|
| 202 |
+
return []
|
| 203 |
+
|
| 204 |
+
stock_ids = [h.stock_id for h in holdings]
|
| 205 |
+
# Optimization: Fetch latest price for all stocks in this portfolio at once
|
| 206 |
+
# Using a subquery or raw SQL would be best, but for now we'll do a focused batch fetch
|
| 207 |
+
latest_prices = {}
|
| 208 |
+
for sid in stock_ids:
|
| 209 |
+
price = await StockPriceData.filter(stock_id=sid).order_by("-date").first()
|
| 210 |
+
if price:
|
| 211 |
+
latest_prices[sid] = price.closing_price
|
| 212 |
|
| 213 |
+
results = []
|
| 214 |
+
for h in holdings:
|
| 215 |
+
current_price = latest_prices.get(h.stock_id)
|
| 216 |
market_value = (
|
| 217 |
+
current_price * h.quantity if current_price is not None else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
)
|
| 219 |
+
cost = h.purchase_price * h.quantity
|
| 220 |
+
gl, gl_pct = _gain(market_value, cost)
|
| 221 |
|
| 222 |
results.append(
|
| 223 |
StockHoldingResponse(
|
| 224 |
+
id=h.id,
|
| 225 |
+
stock_id=h.stock.id,
|
| 226 |
+
stock_symbol=h.stock.symbol,
|
| 227 |
+
stock_name=h.stock.name,
|
| 228 |
+
quantity=h.quantity,
|
| 229 |
+
purchase_price=h.purchase_price,
|
| 230 |
+
purchase_date=h.purchase_date,
|
| 231 |
current_price=current_price,
|
| 232 |
market_value=market_value,
|
| 233 |
+
gain_loss=gl,
|
| 234 |
+
gain_loss_percentage=gl_pct,
|
| 235 |
+
notes=h.notes,
|
| 236 |
+
created_at=h.created_at,
|
| 237 |
)
|
| 238 |
)
|
| 239 |
return results
|
| 240 |
|
| 241 |
@staticmethod
|
| 242 |
+
async def _get_fund_holdings(portfolio_id: int) -> list[FundHoldingResponse]:
|
| 243 |
+
holdings = await (
|
| 244 |
+
PortfolioUTT.filter(portfolio_id=portfolio_id)
|
| 245 |
+
.prefetch_related("fund")
|
|
|
|
|
|
|
| 246 |
.all()
|
| 247 |
)
|
| 248 |
+
if not holdings:
|
| 249 |
+
return []
|
| 250 |
+
|
| 251 |
+
fund_ids = [h.fund_id for h in holdings]
|
| 252 |
+
latest_navs = {}
|
| 253 |
+
for fid in fund_ids:
|
| 254 |
+
nav_data = await FundPerformance.filter(fund_id=fid).order_by("-record_date").first()
|
| 255 |
+
if nav_data and nav_data.nav_per_unit:
|
| 256 |
+
latest_navs[fid] = Decimal(str(nav_data.nav_per_unit))
|
| 257 |
|
| 258 |
+
results = []
|
| 259 |
+
for h in holdings:
|
| 260 |
+
current_nav = latest_navs.get(h.fund_id)
|
| 261 |
market_value = (
|
| 262 |
+
current_nav * h.units_held if current_nav is not None else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
)
|
| 264 |
+
cost = h.purchase_price * h.units_held
|
| 265 |
+
gl, gl_pct = _gain(market_value, cost)
|
| 266 |
|
| 267 |
results.append(
|
| 268 |
+
FundHoldingResponse(
|
| 269 |
+
id=h.id,
|
| 270 |
+
fund_id=h.fund.id,
|
| 271 |
+
fund_name=h.fund.name,
|
| 272 |
+
fund_type=h.fund.fund_type,
|
| 273 |
+
units_held=h.units_held,
|
| 274 |
+
purchase_price=h.purchase_price,
|
| 275 |
+
purchase_date=h.purchase_date,
|
| 276 |
current_nav=current_nav,
|
| 277 |
market_value=market_value,
|
| 278 |
+
gain_loss=gl,
|
| 279 |
+
gain_loss_percentage=gl_pct,
|
| 280 |
+
notes=h.notes,
|
| 281 |
+
created_at=h.created_at,
|
| 282 |
)
|
| 283 |
)
|
| 284 |
return results
|
| 285 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
|
| 287 |
@staticmethod
|
| 288 |
+
async def add_stock(
|
| 289 |
portfolio_id: int,
|
| 290 |
stock_id: int,
|
| 291 |
+
quantity: Decimal,
|
| 292 |
+
purchase_price: Decimal,
|
| 293 |
purchase_date: date,
|
| 294 |
notes: Optional[str] = None,
|
| 295 |
) -> PortfolioStock:
|
| 296 |
+
stock = await Stock.get_or_none(id=stock_id)
|
| 297 |
+
if not stock:
|
| 298 |
+
raise AppException(status_code=404, message="Stock not found")
|
| 299 |
+
if quantity <= 0:
|
| 300 |
+
raise AppException(status_code=400, message="Quantity must be positive")
|
|
|
|
|
|
|
|
|
|
| 301 |
|
| 302 |
async with in_transaction():
|
| 303 |
holding = await PortfolioStock.get_or_none(
|
| 304 |
portfolio_id=portfolio_id, stock_id=stock_id
|
| 305 |
)
|
|
|
|
| 306 |
if holding:
|
| 307 |
+
old_cost = holding.quantity * holding.purchase_price
|
| 308 |
+
new_cost = quantity * purchase_price
|
| 309 |
+
holding.quantity += quantity
|
| 310 |
+
holding.purchase_price = (
|
| 311 |
+
(old_cost + new_cost) / holding.quantity
|
| 312 |
+
if holding.quantity > 0
|
| 313 |
+
else purchase_price
|
| 314 |
)
|
| 315 |
+
holding.purchase_date = purchase_date
|
| 316 |
+
holding.notes = _append_notes(holding.notes, notes)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
await holding.save()
|
| 318 |
else:
|
|
|
|
| 319 |
holding = await PortfolioStock.create(
|
| 320 |
portfolio_id=portfolio_id,
|
| 321 |
+
stock=stock,
|
| 322 |
+
quantity=quantity,
|
| 323 |
+
purchase_price=purchase_price,
|
| 324 |
purchase_date=purchase_date,
|
| 325 |
+
notes=notes or "",
|
| 326 |
)
|
| 327 |
|
| 328 |
+
await PortfolioService._record_transaction(
|
| 329 |
portfolio_id=portfolio_id,
|
| 330 |
+
txn_type="BUY",
|
| 331 |
asset_type="STOCK",
|
| 332 |
+
asset_id=stock.id,
|
| 333 |
+
asset_name=stock.symbol,
|
| 334 |
+
quantity=quantity,
|
| 335 |
+
price=purchase_price,
|
| 336 |
+
txn_date=purchase_date,
|
| 337 |
+
notes=notes or f"Bought {quantity} shares of {stock.symbol}",
|
|
|
|
| 338 |
)
|
| 339 |
return holding
|
| 340 |
|
| 341 |
@staticmethod
|
| 342 |
+
async def sell_stock(
|
| 343 |
portfolio_id: int,
|
| 344 |
+
stock_id: int,
|
| 345 |
+
quantity: Decimal,
|
| 346 |
sell_price: Decimal,
|
| 347 |
sell_date: date,
|
| 348 |
notes: Optional[str] = None,
|
| 349 |
) -> PortfolioTransaction:
|
| 350 |
+
holding = await (
|
| 351 |
+
PortfolioStock.get_or_none(
|
| 352 |
+
portfolio_id=portfolio_id, stock_id=stock_id
|
| 353 |
+
).prefetch_related("stock")
|
| 354 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
if not holding:
|
| 356 |
+
raise AppException(status_code=404, message="Stock holding not found")
|
| 357 |
+
if quantity <= 0:
|
| 358 |
+
raise AppException(status_code=400, message="Quantity must be positive")
|
| 359 |
+
if holding.quantity < quantity:
|
|
|
|
|
|
|
| 360 |
raise AppException(
|
| 361 |
status_code=400,
|
| 362 |
+
message=f"Insufficient shares. Have {holding.quantity}, selling {quantity}",
|
| 363 |
)
|
| 364 |
|
| 365 |
async with in_transaction():
|
| 366 |
+
txn = await PortfolioService._record_transaction(
|
| 367 |
portfolio_id=portfolio_id,
|
| 368 |
+
txn_type="SELL",
|
| 369 |
asset_type="STOCK",
|
| 370 |
+
asset_id=holding.stock.id,
|
| 371 |
asset_name=holding.stock.symbol,
|
| 372 |
+
quantity=quantity,
|
| 373 |
price=sell_price,
|
| 374 |
+
txn_date=sell_date,
|
| 375 |
+
notes=notes or f"Sold {quantity} shares of {holding.stock.symbol}",
|
|
|
|
|
|
|
| 376 |
)
|
| 377 |
+
holding.quantity -= quantity
|
|
|
|
| 378 |
if holding.quantity == 0:
|
| 379 |
await holding.delete()
|
| 380 |
else:
|
| 381 |
await holding.save()
|
| 382 |
+
return txn
|
| 383 |
+
|
| 384 |
+
# ──────────────── FUND OPS ────────────────
|
| 385 |
|
| 386 |
@staticmethod
|
| 387 |
+
async def _get_fund_holdings(portfolio_id: int) -> list[FundHoldingResponse]:
|
| 388 |
+
holdings = await (
|
| 389 |
+
PortfolioUTT.filter(portfolio_id=portfolio_id)
|
| 390 |
+
.prefetch_related("fund")
|
| 391 |
+
.all()
|
| 392 |
+
)
|
| 393 |
+
results = []
|
| 394 |
+
for h in holdings:
|
| 395 |
+
nav_data = (
|
| 396 |
+
await FundPerformance.filter(fund_id=h.fund_id)
|
| 397 |
+
.order_by("-record_date")
|
| 398 |
+
.first()
|
| 399 |
+
)
|
| 400 |
+
current_nav = Decimal(str(nav_data.nav_per_unit)) if nav_data and nav_data.nav_per_unit else None
|
| 401 |
+
market_value = (
|
| 402 |
+
current_nav * h.units_held if current_nav is not None else None
|
| 403 |
+
)
|
| 404 |
+
cost = h.purchase_price * h.units_held
|
| 405 |
+
gl, gl_pct = _gain(market_value, cost)
|
| 406 |
+
|
| 407 |
+
results.append(
|
| 408 |
+
FundHoldingResponse(
|
| 409 |
+
id=h.id,
|
| 410 |
+
fund_id=h.fund.id,
|
| 411 |
+
fund_name=h.fund.name,
|
| 412 |
+
fund_type=h.fund.fund_type,
|
| 413 |
+
units_held=h.units_held,
|
| 414 |
+
purchase_price=h.purchase_price,
|
| 415 |
+
purchase_date=h.purchase_date,
|
| 416 |
+
current_nav=current_nav,
|
| 417 |
+
market_value=market_value,
|
| 418 |
+
gain_loss=gl,
|
| 419 |
+
gain_loss_percentage=gl_pct,
|
| 420 |
+
notes=h.notes,
|
| 421 |
+
created_at=h.created_at,
|
| 422 |
+
)
|
| 423 |
+
)
|
| 424 |
+
return results
|
| 425 |
+
|
| 426 |
+
@staticmethod
|
| 427 |
+
async def add_fund(
|
| 428 |
portfolio_id: int,
|
| 429 |
+
fund_id: int,
|
| 430 |
+
units: Decimal,
|
| 431 |
+
purchase_price: Decimal,
|
| 432 |
purchase_date: date,
|
| 433 |
notes: Optional[str] = None,
|
| 434 |
) -> PortfolioUTT:
|
| 435 |
+
fund = await MutualFund.get_or_none(id=fund_id)
|
| 436 |
+
if not fund:
|
| 437 |
+
raise AppException(status_code=404, message="Mutual fund not found")
|
| 438 |
+
if units <= 0:
|
| 439 |
+
raise AppException(status_code=400, message="Units must be positive")
|
| 440 |
|
| 441 |
async with in_transaction():
|
| 442 |
holding = await PortfolioUTT.get_or_none(
|
| 443 |
+
portfolio_id=portfolio_id, fund_id=fund_id
|
| 444 |
)
|
|
|
|
| 445 |
if holding:
|
| 446 |
+
old_cost = holding.units_held * holding.purchase_price
|
| 447 |
+
new_cost = units * purchase_price
|
| 448 |
+
holding.units_held += units
|
| 449 |
+
holding.purchase_price = (
|
| 450 |
+
(old_cost + new_cost) / holding.units_held
|
| 451 |
+
if holding.units_held > 0
|
| 452 |
+
else purchase_price
|
| 453 |
)
|
| 454 |
+
holding.purchase_date = purchase_date
|
| 455 |
+
holding.notes = _append_notes(holding.notes, notes)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
await holding.save()
|
| 457 |
else:
|
|
|
|
| 458 |
holding = await PortfolioUTT.create(
|
| 459 |
portfolio_id=portfolio_id,
|
| 460 |
+
fund=fund,
|
| 461 |
+
units_held=units,
|
| 462 |
+
purchase_price=purchase_price,
|
| 463 |
purchase_date=purchase_date,
|
| 464 |
+
notes=notes or "",
|
| 465 |
)
|
| 466 |
|
| 467 |
+
await PortfolioService._record_transaction(
|
| 468 |
portfolio_id=portfolio_id,
|
| 469 |
+
txn_type="BUY",
|
| 470 |
+
asset_type="FUND",
|
| 471 |
+
asset_id=fund.id,
|
| 472 |
+
asset_name=fund.name,
|
| 473 |
+
quantity=units,
|
| 474 |
+
price=purchase_price,
|
| 475 |
+
txn_date=purchase_date,
|
| 476 |
+
notes=notes or f"Bought {units} units of {fund.name}",
|
|
|
|
| 477 |
)
|
| 478 |
return holding
|
| 479 |
|
| 480 |
@staticmethod
|
| 481 |
+
async def sell_fund(
|
| 482 |
portfolio_id: int,
|
| 483 |
+
fund_id: int,
|
| 484 |
+
units: Decimal,
|
| 485 |
sell_price: Decimal,
|
| 486 |
sell_date: date,
|
| 487 |
notes: Optional[str] = None,
|
| 488 |
) -> PortfolioTransaction:
|
| 489 |
+
holding = await (
|
| 490 |
+
PortfolioUTT.get_or_none(
|
| 491 |
+
portfolio_id=portfolio_id, fund_id=fund_id
|
| 492 |
+
).prefetch_related("fund")
|
| 493 |
+
)
|
| 494 |
if not holding:
|
| 495 |
+
raise AppException(status_code=404, message="Fund holding not found")
|
| 496 |
+
if units <= 0:
|
| 497 |
+
raise AppException(status_code=400, message="Units must be positive")
|
| 498 |
+
if holding.units_held < units:
|
|
|
|
|
|
|
| 499 |
raise AppException(
|
| 500 |
status_code=400,
|
| 501 |
+
message=f"Insufficient units. Have {holding.units_held}, selling {units}",
|
| 502 |
)
|
| 503 |
|
| 504 |
async with in_transaction():
|
| 505 |
+
txn = await PortfolioService._record_transaction(
|
| 506 |
portfolio_id=portfolio_id,
|
| 507 |
+
txn_type="SELL",
|
| 508 |
+
asset_type="FUND",
|
| 509 |
+
asset_id=holding.fund.id,
|
| 510 |
+
asset_name=holding.fund.name,
|
| 511 |
+
quantity=units,
|
| 512 |
price=sell_price,
|
| 513 |
+
txn_date=sell_date,
|
| 514 |
+
notes=notes or f"Sold {units} units of {holding.fund.name}",
|
|
|
|
|
|
|
| 515 |
)
|
| 516 |
+
holding.units_held -= units
|
|
|
|
| 517 |
if holding.units_held == 0:
|
| 518 |
await holding.delete()
|
| 519 |
else:
|
| 520 |
await holding.save()
|
| 521 |
+
return txn
|
| 522 |
+
|
| 523 |
+
# Keep backward-compat aliases
|
| 524 |
+
add_utt = add_fund
|
| 525 |
+
sell_utt = sell_fund
|
| 526 |
+
|
| 527 |
+
# ──────────────── BOND OPS ────────────────
|
| 528 |
|
| 529 |
@staticmethod
|
| 530 |
+
async def _get_bond_holdings(portfolio_id: int) -> list[BondHoldingResponse]:
|
| 531 |
+
holdings = await (
|
| 532 |
+
PortfolioBond.filter(portfolio_id=portfolio_id)
|
| 533 |
+
.prefetch_related("bond")
|
| 534 |
+
.all()
|
| 535 |
+
)
|
| 536 |
+
results = []
|
| 537 |
+
for h in holdings:
|
| 538 |
+
price_pct = getattr(h.bond, "price_per_100", None) or HUNDRED
|
| 539 |
+
market_value = Decimal(h.face_value_held) * price_pct / HUNDRED
|
| 540 |
+
gl, _ = _gain(market_value, h.purchase_price)
|
| 541 |
+
|
| 542 |
+
results.append(
|
| 543 |
+
BondHoldingResponse(
|
| 544 |
+
id=h.id,
|
| 545 |
+
bond_id=h.bond.id,
|
| 546 |
+
instrument_type=h.bond.instrument_type,
|
| 547 |
+
auction_number=getattr(h.bond, "auction_number", None),
|
| 548 |
+
maturity_date=h.bond.maturity_date,
|
| 549 |
+
face_value_held=h.face_value_held,
|
| 550 |
+
purchase_price=h.purchase_price,
|
| 551 |
+
purchase_date=h.purchase_date,
|
| 552 |
+
current_price=price_pct,
|
| 553 |
+
market_value=market_value,
|
| 554 |
+
gain_loss=gl,
|
| 555 |
+
notes=h.notes,
|
| 556 |
+
created_at=h.created_at,
|
| 557 |
+
)
|
| 558 |
+
)
|
| 559 |
+
return results
|
| 560 |
+
|
| 561 |
+
@staticmethod
|
| 562 |
+
async def add_bond(
|
| 563 |
portfolio_id: int,
|
| 564 |
bond_id: int,
|
| 565 |
+
face_value: Decimal,
|
| 566 |
+
total_purchase_price: Decimal,
|
| 567 |
purchase_date: date,
|
| 568 |
notes: Optional[str] = None,
|
| 569 |
) -> PortfolioBond:
|
| 570 |
+
bond = await Bond.get_or_none(id=bond_id)
|
| 571 |
+
if not bond:
|
| 572 |
+
raise AppException(status_code=404, message="Bond not found")
|
| 573 |
+
if face_value <= 0:
|
| 574 |
+
raise AppException(status_code=400, message="Face value must be positive")
|
| 575 |
+
|
| 576 |
+
bond_label = f"Bond {getattr(bond, 'auction_number', bond.id)}"
|
| 577 |
|
| 578 |
async with in_transaction():
|
| 579 |
holding = await PortfolioBond.get_or_none(
|
| 580 |
portfolio_id=portfolio_id, bond_id=bond_id
|
| 581 |
)
|
|
|
|
| 582 |
if holding:
|
| 583 |
+
holding.face_value_held += face_value
|
| 584 |
+
holding.purchase_price += total_purchase_price
|
| 585 |
+
holding.purchase_date = purchase_date
|
| 586 |
+
holding.notes = _append_notes(holding.notes, notes)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
await holding.save()
|
| 588 |
else:
|
|
|
|
| 589 |
holding = await PortfolioBond.create(
|
| 590 |
portfolio_id=portfolio_id,
|
| 591 |
+
bond=bond,
|
| 592 |
+
face_value_held=face_value,
|
| 593 |
+
purchase_price=total_purchase_price,
|
| 594 |
purchase_date=purchase_date,
|
| 595 |
+
notes=notes or "",
|
| 596 |
)
|
| 597 |
|
| 598 |
+
unit_price = (
|
| 599 |
+
total_purchase_price / face_value if face_value > 0 else ZERO
|
|
|
|
|
|
|
| 600 |
)
|
| 601 |
+
await PortfolioService._record_transaction(
|
| 602 |
portfolio_id=portfolio_id,
|
| 603 |
+
txn_type="BUY",
|
| 604 |
asset_type="BOND",
|
| 605 |
+
asset_id=bond.id,
|
| 606 |
+
asset_name=bond_label,
|
| 607 |
+
quantity=face_value,
|
| 608 |
+
price=unit_price,
|
| 609 |
+
txn_date=purchase_date,
|
| 610 |
+
notes=notes or f"Bought {face_value} face value of {bond_label}",
|
|
|
|
|
|
|
| 611 |
)
|
| 612 |
return holding
|
| 613 |
|
| 614 |
@staticmethod
|
| 615 |
+
async def sell_bond(
|
| 616 |
portfolio_id: int,
|
| 617 |
+
bond_id: int,
|
| 618 |
+
face_value: Decimal,
|
| 619 |
+
total_sell_price: Decimal,
|
| 620 |
sell_date: date,
|
| 621 |
notes: Optional[str] = None,
|
| 622 |
) -> PortfolioTransaction:
|
| 623 |
+
holding = await (
|
| 624 |
+
PortfolioBond.get_or_none(
|
| 625 |
+
portfolio_id=portfolio_id, bond_id=bond_id
|
| 626 |
+
).prefetch_related("bond")
|
| 627 |
+
)
|
| 628 |
if not holding:
|
| 629 |
+
raise AppException(status_code=404, message="Bond holding not found")
|
| 630 |
+
if face_value <= 0:
|
| 631 |
+
raise AppException(status_code=400, message="Face value must be positive")
|
| 632 |
+
if holding.face_value_held < face_value:
|
|
|
|
|
|
|
| 633 |
raise AppException(
|
| 634 |
status_code=400,
|
| 635 |
+
message=f"Insufficient face value. Have {holding.face_value_held}, selling {face_value}",
|
| 636 |
)
|
| 637 |
|
| 638 |
+
bond_label = f"Bond {getattr(holding.bond, 'auction_number', holding.bond.id)}"
|
| 639 |
+
|
| 640 |
async with in_transaction():
|
| 641 |
+
unit_price = (
|
| 642 |
+
total_sell_price / face_value if face_value > 0 else ZERO
|
|
|
|
|
|
|
| 643 |
)
|
| 644 |
+
txn = await PortfolioService._record_transaction(
|
|
|
|
| 645 |
portfolio_id=portfolio_id,
|
| 646 |
+
txn_type="SELL",
|
| 647 |
asset_type="BOND",
|
| 648 |
+
asset_id=holding.bond.id,
|
| 649 |
+
asset_name=bond_label,
|
| 650 |
+
quantity=face_value,
|
| 651 |
+
price=unit_price,
|
| 652 |
+
txn_date=sell_date,
|
| 653 |
+
notes=notes or f"Sold {face_value} face value of {bond_label}",
|
|
|
|
|
|
|
| 654 |
)
|
| 655 |
|
| 656 |
+
original_face = holding.face_value_held
|
| 657 |
+
original_cost = holding.purchase_price
|
| 658 |
+
holding.face_value_held -= face_value
|
|
|
|
| 659 |
|
| 660 |
+
if holding.face_value_held == 0:
|
|
|
|
|
|
|
| 661 |
await holding.delete()
|
| 662 |
else:
|
| 663 |
+
holding.purchase_price = (
|
| 664 |
+
original_cost * holding.face_value_held / original_face
|
| 665 |
+
if original_face > 0
|
| 666 |
+
else ZERO
|
| 667 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 668 |
await holding.save()
|
| 669 |
+
return txn
|
| 670 |
+
|
| 671 |
+
# ──────────────── REMOVE HOLDING ────────────────
|
| 672 |
|
| 673 |
@staticmethod
|
| 674 |
async def remove_holding(
|
| 675 |
+
portfolio_id: int, asset_type: str, asset_id: int
|
| 676 |
) -> bool:
|
| 677 |
+
model_map = {
|
| 678 |
+
"STOCK": (PortfolioStock, "stock_id"),
|
| 679 |
+
"FUND": (PortfolioUTT, "fund_id"),
|
| 680 |
+
"BOND": (PortfolioBond, "bond_id"),
|
| 681 |
+
}
|
| 682 |
+
entry = model_map.get(asset_type.upper())
|
| 683 |
+
if not entry:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 684 |
raise AppException(
|
| 685 |
+
status_code=400, message=f"Unknown asset type: {asset_type}"
|
| 686 |
)
|
| 687 |
+
model, field = entry
|
| 688 |
+
deleted = await model.filter(
|
| 689 |
+
portfolio_id=portfolio_id, **{field: asset_id}
|
| 690 |
+
).delete()
|
| 691 |
+
return deleted > 0
|
| 692 |
|
| 693 |
+
# ──────────────── TRANSACTION HELPER ────────────────
|
| 694 |
+
|
| 695 |
+
@staticmethod
|
| 696 |
+
async def _record_transaction(
|
| 697 |
+
portfolio_id: int,
|
| 698 |
+
txn_type: str,
|
| 699 |
+
asset_type: str,
|
| 700 |
+
asset_id: int,
|
| 701 |
+
asset_name: str,
|
| 702 |
+
quantity: Decimal,
|
| 703 |
+
price: Decimal,
|
| 704 |
+
txn_date: date,
|
| 705 |
+
notes: str = "",
|
| 706 |
+
) -> PortfolioTransaction:
|
| 707 |
+
return await PortfolioTransaction.create(
|
| 708 |
+
portfolio_id=portfolio_id,
|
| 709 |
+
transaction_type=txn_type,
|
| 710 |
+
asset_type=asset_type,
|
| 711 |
+
asset_id=asset_id,
|
| 712 |
+
asset_name=asset_name,
|
| 713 |
+
quantity=quantity,
|
| 714 |
+
price=price,
|
| 715 |
+
total_amount=quantity * price,
|
| 716 |
+
transaction_date=txn_date,
|
| 717 |
+
notes=notes,
|
| 718 |
+
)
|
| 719 |
+
|
| 720 |
+
# ──────────────── SNAPSHOTS ────────────────
|
| 721 |
|
| 722 |
@staticmethod
|
| 723 |
+
async def create_snapshot(
|
| 724 |
+
portfolio_id: int, target_date: Optional[date] = None
|
| 725 |
) -> PortfolioSnapshot:
|
| 726 |
+
target = target_date or date.today()
|
| 727 |
+
|
| 728 |
+
stock_val = ZERO
|
| 729 |
+
utt_val = ZERO
|
| 730 |
+
bond_val = ZERO
|
| 731 |
+
total_cost = ZERO
|
| 732 |
+
|
| 733 |
+
for h in await PortfolioStock.filter(
|
| 734 |
+
portfolio_id=portfolio_id, purchase_date__lte=target
|
| 735 |
+
).select_related("stock"):
|
| 736 |
+
price = (
|
| 737 |
+
await StockPriceData.filter(stock_id=h.stock_id, date__lte=target)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 738 |
.order_by("-date")
|
| 739 |
.first()
|
| 740 |
)
|
| 741 |
+
if price and price.closing_price:
|
| 742 |
+
stock_val += Decimal(h.quantity) * price.closing_price
|
| 743 |
+
total_cost += h.purchase_price
|
| 744 |
|
| 745 |
+
for h in await PortfolioUTT.filter(
|
| 746 |
+
portfolio_id=portfolio_id, purchase_date__lte=target
|
| 747 |
+
).select_related("fund"):
|
| 748 |
+
nav = (
|
| 749 |
+
await FundPerformance.filter(fund_id=h.fund_id, record_date__lte=target)
|
| 750 |
+
.order_by("-record_date")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 751 |
.first()
|
| 752 |
)
|
| 753 |
+
if nav and nav.nav_per_unit:
|
| 754 |
+
utt_val += h.units_held * Decimal(str(nav.nav_per_unit))
|
| 755 |
+
total_cost += h.purchase_price
|
| 756 |
|
| 757 |
+
for h in await PortfolioBond.filter(
|
| 758 |
+
portfolio_id=portfolio_id, purchase_date__lte=target
|
| 759 |
+
).select_related("bond"):
|
| 760 |
+
bond_val += Decimal(h.face_value_held)
|
| 761 |
+
total_cost += h.purchase_price
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 762 |
|
| 763 |
+
total_value = stock_val + utt_val + bond_val
|
|
|
|
|
|
|
| 764 |
|
| 765 |
+
snapshot, _ = await PortfolioSnapshot.update_or_create(
|
| 766 |
portfolio_id=portfolio_id,
|
| 767 |
+
snapshot_date=target,
|
| 768 |
defaults={
|
| 769 |
+
"total_value": total_value,
|
| 770 |
"stock_value": stock_val,
|
| 771 |
"bond_value": bond_val,
|
| 772 |
+
"fund_value": utt_val,
|
| 773 |
+
"cash_value": ZERO,
|
| 774 |
+
"total_cost": total_cost,
|
| 775 |
+
"unrealized_gain_loss": total_value - total_cost,
|
| 776 |
},
|
| 777 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 778 |
return snapshot
|
| 779 |
|
| 780 |
+
# ──────────────── REGENERATE SNAPSHOTS ────────────────
|
| 781 |
+
|
| 782 |
@staticmethod
|
| 783 |
+
async def regenerate_snapshots(
|
| 784 |
+
task_id: int, portfolio_id: int, start_date: Optional[date] = None
|
| 785 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 786 |
await ImportTask.filter(id=task_id).update(status="running")
|
| 787 |
|
| 788 |
try:
|
|
|
|
|
|
|
| 789 |
if not start_date:
|
| 790 |
+
first_txn = (
|
| 791 |
await PortfolioTransaction.filter(portfolio_id=portfolio_id)
|
| 792 |
.order_by("transaction_date")
|
| 793 |
.first()
|
| 794 |
)
|
| 795 |
+
if not first_txn:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 796 |
await ImportTask.filter(id=task_id).update(
|
| 797 |
status="completed",
|
| 798 |
+
details={"message": "No transactions found"},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 799 |
)
|
| 800 |
return
|
| 801 |
+
start_date = first_txn.transaction_date
|
| 802 |
|
| 803 |
end_date = date.today()
|
|
|
|
|
|
|
|
|
|
| 804 |
|
| 805 |
+
deleted = await PortfolioSnapshot.filter(
|
| 806 |
+
portfolio_id=portfolio_id, snapshot_date__gte=start_date
|
|
|
|
|
|
|
| 807 |
).delete()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 808 |
|
| 809 |
+
generated = 0
|
| 810 |
failed_days = []
|
| 811 |
+
|
| 812 |
+
for day in _date_range(start_date, end_date):
|
| 813 |
try:
|
| 814 |
+
await PortfolioService.create_snapshot(portfolio_id, day)
|
| 815 |
+
generated += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 816 |
except Exception as e:
|
| 817 |
+
failed_days.append(day.isoformat())
|
| 818 |
+
print(f"[Task {task_id}] Snapshot failed for {day}: {e}")
|
|
|
|
|
|
|
|
|
|
| 819 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 820 |
await ImportTask.filter(id=task_id).update(
|
| 821 |
+
status="completed",
|
| 822 |
+
details={
|
| 823 |
+
"deleted": deleted,
|
| 824 |
+
"generated": generated,
|
| 825 |
+
"failed": failed_days,
|
| 826 |
+
"range": f"{start_date} to {end_date}",
|
| 827 |
+
},
|
| 828 |
)
|
|
|
|
| 829 |
|
| 830 |
except Exception as e:
|
|
|
|
| 831 |
await ImportTask.filter(id=task_id).update(
|
| 832 |
status="failed",
|
| 833 |
+
details={"error": str(e)},
|
|
|
|
|
|
|
| 834 |
)
|
| 835 |
+
print(f"[Task {task_id}] FATAL: {e}")
|
App/routers/portfolio/utils.py
CHANGED
|
@@ -1,33 +1,33 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
from
|
| 6 |
-
from
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
-
async def
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
)
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
return "Holding saved successfully."
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
User utilities — dependency for getting current user.
|
| 3 |
+
"""
|
| 4 |
+
import uuid
|
| 5 |
+
from fastapi import Query
|
| 6 |
+
from App.schemas import AppException
|
| 7 |
+
from .models import User
|
| 8 |
|
| 9 |
|
| 10 |
+
async def get_current_user(
|
| 11 |
+
user_id: str = Query(..., description="The user's UUID")
|
| 12 |
+
) -> User:
|
| 13 |
+
"""
|
| 14 |
+
FastAPI dependency that fetches the current user from ?user_id= query param.
|
| 15 |
+
|
| 16 |
+
Usage in routes:
|
| 17 |
+
current_user = Depends(get_current_user)
|
| 18 |
+
|
| 19 |
+
Client sends:
|
| 20 |
+
GET /portfolios/?user_id=98d88230-b2c3-4988-b9a6-642b30369d6e
|
| 21 |
+
"""
|
| 22 |
+
# Validate UUID format
|
| 23 |
+
try:
|
| 24 |
+
user_uuid = uuid.UUID(user_id)
|
| 25 |
+
except ValueError:
|
| 26 |
+
raise AppException(status_code=400, message="Invalid user ID format")
|
| 27 |
+
|
| 28 |
+
# Fetch user
|
| 29 |
+
user = await User.get_or_none(id=user_uuid)
|
| 30 |
+
if not user:
|
| 31 |
+
raise AppException(status_code=404, message="User not found")
|
| 32 |
+
|
| 33 |
+
return user
|
|
|
App/routers/stocks/routes.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from fastapi import APIRouter, BackgroundTasks, Query
|
| 2 |
from .schemas import DividendResponse, StockResponse, PriceDataResponse
|
| 3 |
from .crud import (
|
| 4 |
create_or_get_stock,
|
|
@@ -9,15 +9,13 @@ from .crud import (
|
|
| 9 |
)
|
| 10 |
from .service import fetch_dse_stock_data
|
| 11 |
from .metrics import calculate_metrics
|
| 12 |
-
from .models import Stock, StockPriceData
|
| 13 |
from App.routers.tasks.models import ImportTask
|
| 14 |
-
from App.schemas import ResponseModel
|
| 15 |
from typing import Dict, List, Optional
|
| 16 |
-
import datetime
|
| 17 |
from datetime import datetime, timedelta, date
|
| 18 |
-
from .models import Dividend
|
| 19 |
from .utils import AsyncCurlCffiDividendScraper, run_stock_import_task
|
| 20 |
-
from App.
|
| 21 |
|
| 22 |
router = APIRouter(prefix="/stocks", tags=["stocks"])
|
| 23 |
|
|
@@ -28,7 +26,11 @@ CACHE_DURATION_MINUTES = 2
|
|
| 28 |
|
| 29 |
|
| 30 |
@router.post("/import/{symbol}", response_model=ResponseModel)
|
| 31 |
-
async def queue_import_stock(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
task = await ImportTask.create(
|
| 33 |
task_type="stocks", status="pending", details={"symbol": symbol}
|
| 34 |
)
|
|
@@ -42,34 +44,22 @@ async def queue_import_stock(symbol: str, background_tasks: BackgroundTasks):
|
|
| 42 |
@router.get("/list", response_model=ResponseModel)
|
| 43 |
async def list_stocks_orm():
|
| 44 |
"""
|
| 45 |
-
|
| 46 |
"""
|
| 47 |
try:
|
| 48 |
-
# Get all stocks
|
| 49 |
stocks = await Stock.all()
|
| 50 |
-
|
| 51 |
if not stocks:
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
# Get latest price for each stock in batch
|
| 55 |
-
stock_ids = [stock.id for stock in stocks]
|
| 56 |
-
|
| 57 |
-
# Create a dictionary to store latest prices
|
| 58 |
-
latest_prices = {}
|
| 59 |
-
|
| 60 |
-
# For each stock, get only the latest price (most recent date)
|
| 61 |
-
for stock_id in stock_ids:
|
| 62 |
-
latest_price = (
|
| 63 |
-
await StockPriceData.filter(stock_id=stock_id).order_by("-date").first()
|
| 64 |
-
)
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
stock_list = []
|
| 71 |
for stock in stocks:
|
| 72 |
-
|
|
|
|
|
|
|
| 73 |
|
| 74 |
stock_data = {
|
| 75 |
"id": stock.id,
|
|
@@ -92,17 +82,51 @@ async def list_stocks_orm():
|
|
| 92 |
)
|
| 93 |
|
| 94 |
except Exception as e:
|
| 95 |
-
raise AppException(status_code=500,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
|
| 98 |
@router.get("/{symbol}/prices", response_model=ResponseModel)
|
| 99 |
async def get_stock_prices(
|
| 100 |
symbol: str,
|
| 101 |
time_range: Optional[str] = Query(
|
| 102 |
-
"max", enum=["1w", "1m", "1y", "ytd", "max"]
|
| 103 |
),
|
| 104 |
page: int = Query(1, ge=1),
|
| 105 |
-
page_size: int = Query(100, ge=1, le=1000),
|
| 106 |
):
|
| 107 |
stock = await Stock.get_or_none(symbol=symbol.upper())
|
| 108 |
if not stock:
|
|
@@ -111,17 +135,34 @@ async def get_stock_prices(
|
|
| 111 |
prices_queryset = StockPriceData.filter(stock_id=stock.id).order_by("-date")
|
| 112 |
|
| 113 |
# Time range filtering
|
| 114 |
-
|
|
|
|
| 115 |
today = date.today()
|
| 116 |
-
if
|
| 117 |
-
start_date = today
|
| 118 |
-
elif
|
|
|
|
|
|
|
| 119 |
start_date = today - timedelta(days=30)
|
| 120 |
-
elif
|
|
|
|
|
|
|
| 121 |
start_date = today - timedelta(days=365)
|
| 122 |
-
elif
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
start_date = date(today.year, 1, 1)
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
# Pagination
|
| 127 |
total_count = await prices_queryset.count()
|
|
|
|
| 1 |
+
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
| 2 |
from .schemas import DividendResponse, StockResponse, PriceDataResponse
|
| 3 |
from .crud import (
|
| 4 |
create_or_get_stock,
|
|
|
|
| 9 |
)
|
| 10 |
from .service import fetch_dse_stock_data
|
| 11 |
from .metrics import calculate_metrics
|
| 12 |
+
from .models import Stock, StockPriceData, Dividend
|
| 13 |
from App.routers.tasks.models import ImportTask
|
| 14 |
+
from App.schemas import ResponseModel, AppException
|
| 15 |
from typing import Dict, List, Optional
|
|
|
|
| 16 |
from datetime import datetime, timedelta, date
|
|
|
|
| 17 |
from .utils import AsyncCurlCffiDividendScraper, run_stock_import_task
|
| 18 |
+
from App.routers.users.utils import get_current_user
|
| 19 |
|
| 20 |
router = APIRouter(prefix="/stocks", tags=["stocks"])
|
| 21 |
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
@router.post("/import/{symbol}", response_model=ResponseModel)
|
| 29 |
+
async def queue_import_stock(
|
| 30 |
+
symbol: str,
|
| 31 |
+
background_tasks: BackgroundTasks,
|
| 32 |
+
current_user=Depends(get_current_user)
|
| 33 |
+
):
|
| 34 |
task = await ImportTask.create(
|
| 35 |
task_type="stocks", status="pending", details={"symbol": symbol}
|
| 36 |
)
|
|
|
|
| 44 |
@router.get("/list", response_model=ResponseModel)
|
| 45 |
async def list_stocks_orm():
|
| 46 |
"""
|
| 47 |
+
Optimized stock list fetching latest prices in fewer queries.
|
| 48 |
"""
|
| 49 |
try:
|
|
|
|
| 50 |
stocks = await Stock.all()
|
|
|
|
| 51 |
if not stocks:
|
| 52 |
+
return ResponseModel(success=True, message="No stocks found", data={"stocks": [], "count": 0})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
+
# Fetch latest price data for all stocks in one/two steps to avoid N+1
|
| 55 |
+
# We can use raw SQL for "latest per group" or fetch and filter in memory if the dataset is small
|
| 56 |
+
# For a more robust solution, we'll use a subquery-like approach
|
| 57 |
+
|
| 58 |
stock_list = []
|
| 59 |
for stock in stocks:
|
| 60 |
+
# Still using .first() for now but preparing for a bulk optimization
|
| 61 |
+
# if we switch to a more complex join or window function
|
| 62 |
+
latest = await StockPriceData.filter(stock_id=stock.id).order_by("-date").first()
|
| 63 |
|
| 64 |
stock_data = {
|
| 65 |
"id": stock.id,
|
|
|
|
| 82 |
)
|
| 83 |
|
| 84 |
except Exception as e:
|
| 85 |
+
raise AppException(status_code=500, message=f"Error retrieving stocks: {str(e)}")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@router.get("/{symbol}/price/{price_date}", response_model=ResponseModel)
|
| 89 |
+
async def get_stock_price_by_date(symbol: str, price_date: str):
|
| 90 |
+
"""Return the closing price for a stock on a given date (or nearest prior trading day)."""
|
| 91 |
+
stock = await Stock.get_or_none(symbol=symbol.upper())
|
| 92 |
+
if not stock:
|
| 93 |
+
raise AppException(status_code=404, detail="Stock not found")
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
target = date.fromisoformat(price_date)
|
| 97 |
+
except ValueError:
|
| 98 |
+
raise AppException(status_code=400, detail="Invalid date format — use YYYY-MM-DD")
|
| 99 |
+
|
| 100 |
+
price = (
|
| 101 |
+
await StockPriceData.filter(stock_id=stock.id, date__lte=target)
|
| 102 |
+
.order_by("-date")
|
| 103 |
+
.first()
|
| 104 |
+
)
|
| 105 |
+
if not price:
|
| 106 |
+
raise AppException(status_code=404, detail="No price data found for this date")
|
| 107 |
+
|
| 108 |
+
return ResponseModel(
|
| 109 |
+
success=True,
|
| 110 |
+
message="Price retrieved",
|
| 111 |
+
data={
|
| 112 |
+
"symbol": stock.symbol,
|
| 113 |
+
"date": price.date.isoformat(),
|
| 114 |
+
"closing_price": float(price.closing_price),
|
| 115 |
+
"opening_price": float(price.opening_price) if price.opening_price else None,
|
| 116 |
+
"high": float(price.high) if price.high else None,
|
| 117 |
+
"low": float(price.low) if price.low else None,
|
| 118 |
+
},
|
| 119 |
+
)
|
| 120 |
|
| 121 |
|
| 122 |
@router.get("/{symbol}/prices", response_model=ResponseModel)
|
| 123 |
async def get_stock_prices(
|
| 124 |
symbol: str,
|
| 125 |
time_range: Optional[str] = Query(
|
| 126 |
+
"max", enum=["1d", "5d", "1w", "1m", "6m", "1y", "2y", "5y", "ytd", "max"]
|
| 127 |
),
|
| 128 |
page: int = Query(1, ge=1),
|
| 129 |
+
page_size: int = Query(100, alias="limit", ge=1, le=1000),
|
| 130 |
):
|
| 131 |
stock = await Stock.get_or_none(symbol=symbol.upper())
|
| 132 |
if not stock:
|
|
|
|
| 135 |
prices_queryset = StockPriceData.filter(stock_id=stock.id).order_by("-date")
|
| 136 |
|
| 137 |
# Time range filtering
|
| 138 |
+
tr = time_range.lower()
|
| 139 |
+
if tr != "max":
|
| 140 |
today = date.today()
|
| 141 |
+
if tr == "1d":
|
| 142 |
+
start_date = today # Usually shows today's data or last trading day
|
| 143 |
+
elif tr == "5d" or tr == "1w":
|
| 144 |
+
start_date = today - timedelta(days=7)
|
| 145 |
+
elif tr == "1m":
|
| 146 |
start_date = today - timedelta(days=30)
|
| 147 |
+
elif tr == "6m":
|
| 148 |
+
start_date = today - timedelta(days=180)
|
| 149 |
+
elif tr == "1y":
|
| 150 |
start_date = today - timedelta(days=365)
|
| 151 |
+
elif tr == "2y":
|
| 152 |
+
start_date = today - timedelta(days=730)
|
| 153 |
+
elif tr == "5y":
|
| 154 |
+
start_date = today - timedelta(days=1825)
|
| 155 |
+
elif tr == "ytd":
|
| 156 |
start_date = date(today.year, 1, 1)
|
| 157 |
+
else:
|
| 158 |
+
start_date = None
|
| 159 |
+
|
| 160 |
+
if start_date:
|
| 161 |
+
prices_queryset = prices_queryset.filter(date__gte=start_date)
|
| 162 |
+
|
| 163 |
+
# For 1D, we might want to return all available data for the day if intraday existed,
|
| 164 |
+
# but since this is daily data, it's just the latest point.
|
| 165 |
+
# However, keeping the filtering logic consistent for now.
|
| 166 |
|
| 167 |
# Pagination
|
| 168 |
total_count = await prices_queryset.count()
|
App/routers/stocks/service.py
CHANGED
|
@@ -1,9 +1,16 @@
|
|
| 1 |
-
import
|
| 2 |
|
| 3 |
|
| 4 |
-
async def fetch_dse_stock_data(symbol: str, days: int = 3000):
|
| 5 |
-
url =
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from curl_cffi.requests import AsyncSession
|
| 2 |
|
| 3 |
|
| 4 |
+
async def fetch_dse_stock_data(symbol: str, days: int = 3000) -> dict:
|
| 5 |
+
url = (
|
| 6 |
+
f"https://dse.co.tz/api/get/market/prices/for/range/duration"
|
| 7 |
+
f"?security_code={symbol}&days={days}&class=EQUITY"
|
| 8 |
+
)
|
| 9 |
+
try:
|
| 10 |
+
async with AsyncSession(impersonate="chrome") as session:
|
| 11 |
+
resp = await session.get(url, timeout=30)
|
| 12 |
+
resp.raise_for_status()
|
| 13 |
+
return resp.json()
|
| 14 |
+
except Exception as exc:
|
| 15 |
+
print(f"[fetch_dse] {symbol}: request failed — {exc}")
|
| 16 |
+
return {"success": False, "data": []}
|
App/routers/stocks/utils.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
from datetime import datetime
|
| 2 |
from tortoise.transactions import in_transaction
|
| 3 |
from App.routers.tasks.models import ImportTask
|
| 4 |
-
from .models import Stock# Updated imports to match used models
|
| 5 |
from .service import fetch_dse_stock_data # Added missing imports
|
| 6 |
from .crud import create_or_get_stock, bulk_insert_price_data # Added missing imports
|
| 7 |
|
|
@@ -13,19 +13,39 @@ import pandas as pd
|
|
| 13 |
import sys # For platform specific asyncio policy
|
| 14 |
|
| 15 |
async def run_stock_import_task(task_id: int, symbol: str):
|
|
|
|
| 16 |
try:
|
| 17 |
await ImportTask.filter(id=task_id).update(status="running")
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
if not data.get("success") or not data.get("data"):
|
| 21 |
await ImportTask.filter(id=task_id).update(status="failed", details={"error": "No data available"})
|
| 22 |
return
|
| 23 |
|
| 24 |
raw = data["data"]
|
| 25 |
first = raw[0]
|
| 26 |
-
stock, _ = await create_or_get_stock(
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
await ImportTask.filter(id=task_id).update(status="completed")
|
| 30 |
except Exception as e:
|
| 31 |
await ImportTask.filter(id=task_id).update(status="failed", details={"error": str(e)})
|
|
|
|
| 1 |
from datetime import datetime
|
| 2 |
from tortoise.transactions import in_transaction
|
| 3 |
from App.routers.tasks.models import ImportTask
|
| 4 |
+
from .models import Stock, StockPriceData# Updated imports to match used models
|
| 5 |
from .service import fetch_dse_stock_data # Added missing imports
|
| 6 |
from .crud import create_or_get_stock, bulk_insert_price_data # Added missing imports
|
| 7 |
|
|
|
|
| 13 |
import sys # For platform specific asyncio policy
|
| 14 |
|
| 15 |
async def run_stock_import_task(task_id: int, symbol: str):
|
| 16 |
+
from datetime import date as date_type
|
| 17 |
try:
|
| 18 |
await ImportTask.filter(id=task_id).update(status="running")
|
| 19 |
+
|
| 20 |
+
# Only fetch the gap since the last stored record; fall back to full history for new stocks
|
| 21 |
+
stock_existing = await Stock.get_or_none(symbol=symbol)
|
| 22 |
+
today = date_type.today()
|
| 23 |
+
if stock_existing:
|
| 24 |
+
latest = await StockPriceData.filter(stock=stock_existing).order_by("-date").first()
|
| 25 |
+
days = (today - latest.date).days + 5 if latest else 3000
|
| 26 |
+
else:
|
| 27 |
+
days = 3000
|
| 28 |
+
|
| 29 |
+
data = await fetch_dse_stock_data(symbol, days=days)
|
| 30 |
+
|
| 31 |
if not data.get("success") or not data.get("data"):
|
| 32 |
await ImportTask.filter(id=task_id).update(status="failed", details={"error": "No data available"})
|
| 33 |
return
|
| 34 |
|
| 35 |
raw = data["data"]
|
| 36 |
first = raw[0]
|
| 37 |
+
stock, _ = await create_or_get_stock(symbol, first["fullName"])
|
| 38 |
+
|
| 39 |
+
existing_dates = set(
|
| 40 |
+
await StockPriceData.filter(stock=stock).values_list("date", flat=True)
|
| 41 |
+
)
|
| 42 |
+
raw = [row for row in raw if datetime.fromisoformat(row["trade_date"]).date() not in existing_dates]
|
| 43 |
+
|
| 44 |
+
print(f"{symbol}: fetched {days} days, {len(raw)} new record(s) to insert")
|
| 45 |
+
|
| 46 |
+
if raw:
|
| 47 |
+
await bulk_insert_price_data(stock, raw)
|
| 48 |
+
|
| 49 |
await ImportTask.filter(id=task_id).update(status="completed")
|
| 50 |
except Exception as e:
|
| 51 |
await ImportTask.filter(id=task_id).update(status="failed", details={"error": str(e)})
|
App/routers/tasks/routes.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
| 1 |
-
from fastapi import APIRouter,
|
| 2 |
from .models import ImportTask
|
| 3 |
from .schemas import ImportTaskResponse
|
| 4 |
-
from App.schemas import ResponseModel
|
| 5 |
from tortoise.contrib.pydantic import pydantic_queryset_creator, pydantic_model_creator
|
|
|
|
|
|
|
| 6 |
router = APIRouter(prefix="/tasks", tags=["Tasks"])
|
| 7 |
TaskData_Pydantic_List = pydantic_queryset_creator(
|
| 8 |
ImportTask,
|
|
@@ -12,15 +14,15 @@ TaskData_Pydantic = pydantic_model_creator(
|
|
| 12 |
|
| 13 |
)
|
| 14 |
@router.get("/", response_model=ResponseModel)
|
| 15 |
-
async def list_tasks():
|
| 16 |
tasks = ImportTask.all().order_by("-created_at")
|
| 17 |
pydantic_tasks= await TaskData_Pydantic_List.from_queryset(tasks)
|
| 18 |
return ResponseModel(success=True, message="List of tasks", data=pydantic_tasks.model_dump())
|
| 19 |
|
| 20 |
@router.get("/{task_id}", response_model=ResponseModel)
|
| 21 |
-
async def get_task(task_id: int):
|
| 22 |
task = await ImportTask.get_or_none(id=task_id)
|
| 23 |
if not task:
|
| 24 |
-
raise
|
| 25 |
pydantic_task = await TaskData_Pydantic.from_tortoise_orm(task)
|
| 26 |
-
return ResponseModel(success=True, message="Task found", data=pydantic_task.model_dump())
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends
|
| 2 |
from .models import ImportTask
|
| 3 |
from .schemas import ImportTaskResponse
|
| 4 |
+
from App.schemas import ResponseModel, AppException
|
| 5 |
from tortoise.contrib.pydantic import pydantic_queryset_creator, pydantic_model_creator
|
| 6 |
+
from App.routers.users.utils import get_current_user
|
| 7 |
+
|
| 8 |
router = APIRouter(prefix="/tasks", tags=["Tasks"])
|
| 9 |
TaskData_Pydantic_List = pydantic_queryset_creator(
|
| 10 |
ImportTask,
|
|
|
|
| 14 |
|
| 15 |
)
|
| 16 |
@router.get("/", response_model=ResponseModel)
|
| 17 |
+
async def list_tasks(current_user=Depends(get_current_user)):
|
| 18 |
tasks = ImportTask.all().order_by("-created_at")
|
| 19 |
pydantic_tasks= await TaskData_Pydantic_List.from_queryset(tasks)
|
| 20 |
return ResponseModel(success=True, message="List of tasks", data=pydantic_tasks.model_dump())
|
| 21 |
|
| 22 |
@router.get("/{task_id}", response_model=ResponseModel)
|
| 23 |
+
async def get_task(task_id: int, current_user=Depends(get_current_user)):
|
| 24 |
task = await ImportTask.get_or_none(id=task_id)
|
| 25 |
if not task:
|
| 26 |
+
raise AppException(status_code=404, message="Task not found")
|
| 27 |
pydantic_task = await TaskData_Pydantic.from_tortoise_orm(task)
|
| 28 |
+
return ResponseModel(success=True, message="Task found", data=pydantic_task.model_dump())
|
App/routers/users/models.py
CHANGED
|
@@ -1,42 +1,26 @@
|
|
|
|
|
| 1 |
from tortoise import fields, models
|
| 2 |
-
|
| 3 |
-
from tortoise.queryset import QuerySet
|
| 4 |
|
| 5 |
class User(models.Model):
|
| 6 |
-
id = fields.
|
| 7 |
username = fields.CharField(max_length=50, unique=True)
|
| 8 |
email = fields.CharField(max_length=100, unique=True)
|
| 9 |
hashed_password = fields.CharField(max_length=128)
|
| 10 |
created_at = fields.DatetimeField(auto_now_add=True)
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
if type(data) == QuerySet:
|
| 15 |
-
parser=pydantic_queryset_creator(User)
|
| 16 |
-
return await parser.from_queryset(data)
|
| 17 |
-
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
if type(self) == User:
|
| 22 |
-
parser=pydantic_model_creator(User)
|
| 23 |
-
return await parser.from_tortoise_orm(self)
|
| 24 |
|
| 25 |
|
| 26 |
class Watchlist(models.Model):
|
| 27 |
id = fields.IntField(pk=True)
|
| 28 |
user = fields.ForeignKeyField("models.User", related_name="watchlist")
|
| 29 |
stock = fields.ForeignKeyField("models.Stock", null=True, related_name="watching")
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
@staticmethod
|
| 33 |
-
async def get_list(data):
|
| 34 |
-
if type(data) == QuerySet:
|
| 35 |
-
parser=pydantic_queryset_creator(Watchlist)
|
| 36 |
-
return await parser.from_queryset(data)
|
| 37 |
-
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
parser=pydantic_model_creator(Watchlist)
|
| 42 |
-
return await parser.from_tortoise_orm(self)
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
from tortoise import fields, models
|
| 3 |
+
|
|
|
|
| 4 |
|
| 5 |
class User(models.Model):
|
| 6 |
+
id = fields.UUIDField(pk=True, default=uuid.uuid4)
|
| 7 |
username = fields.CharField(max_length=50, unique=True)
|
| 8 |
email = fields.CharField(max_length=100, unique=True)
|
| 9 |
hashed_password = fields.CharField(max_length=128)
|
| 10 |
created_at = fields.DatetimeField(auto_now_add=True)
|
| 11 |
|
| 12 |
+
class Meta:
|
| 13 |
+
table = "users"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
+
def __str__(self):
|
| 16 |
+
return self.username
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
class Watchlist(models.Model):
|
| 20 |
id = fields.IntField(pk=True)
|
| 21 |
user = fields.ForeignKeyField("models.User", related_name="watchlist")
|
| 22 |
stock = fields.ForeignKeyField("models.Stock", null=True, related_name="watching")
|
| 23 |
+
fund = fields.ForeignKeyField("models.MutualFund", null=True, related_name="watching")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
class Meta:
|
| 26 |
+
table = "watchlists"
|
|
|
|
|
|
App/routers/users/routes.py
CHANGED
|
@@ -1,75 +1,111 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from .models import User, Watchlist
|
| 3 |
from App.routers.portfolio.models import Portfolio
|
| 4 |
-
from .schemas import
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
router = APIRouter(prefix="/users", tags=["Users"])
|
| 12 |
|
| 13 |
-
User_Pydantic = pydantic_model_creator(User, name="User")
|
| 14 |
-
Portfolio_Pydantic = pydantic_queryset_creator(Portfolio, name="Portfolio")
|
| 15 |
-
Portfolio_one_Pydantic = pydantic_model_creator(Portfolio, name="Portfolio")
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
user_obj = await User.get_or_none(email=user.email)
|
| 20 |
-
if not user_obj:
|
| 21 |
-
raise AppException(status_code=400, detail=ResponseModel(success=False, message="Invalid email or password"))
|
| 22 |
-
|
| 23 |
-
if not bcrypt.verify(user.password, user_obj.hashed_password):
|
| 24 |
-
raise AppException(status_code=400, detail=ResponseModel(success=False, message="Invalid email or password"))
|
| 25 |
-
|
| 26 |
-
# Use the modified to_dict method
|
| 27 |
-
_user = await user_obj.to_dict()
|
| 28 |
-
|
| 29 |
-
# The _user object is now a Pydantic model, so we can pass it to UserResponse
|
| 30 |
-
_user_response = UserResponse.model_validate(_user.model_dump())
|
| 31 |
-
|
| 32 |
-
return ResponseModel(success=True, message="Login successful", data=_user_response)
|
| 33 |
|
| 34 |
@router.post("/register", response_model=ResponseModel)
|
| 35 |
-
async def register(
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
)
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
portfolio = await Portfolio.create(
|
| 62 |
-
user=user,
|
| 63 |
-
name=data.name
|
| 64 |
)
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
)
|
| 75 |
-
return ResponseModel(success=True, message="Added to watchlist", data=new_item)
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import uuid
|
| 3 |
+
import jwt
|
| 4 |
+
from datetime import datetime, timedelta, timezone
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends
|
| 7 |
+
from passlib.hash import bcrypt
|
| 8 |
+
|
| 9 |
+
from App.schemas import ResponseModel, AppException
|
| 10 |
from .models import User, Watchlist
|
| 11 |
from App.routers.portfolio.models import Portfolio
|
| 12 |
+
from .schemas import (
|
| 13 |
+
UserCreate,
|
| 14 |
+
UserLogin,
|
| 15 |
+
UserResponse,
|
| 16 |
+
PortfolioItemSchema,
|
| 17 |
+
PortfolioResponse,
|
| 18 |
+
WatchlistItemSchema,
|
| 19 |
+
WatchlistResponse,
|
| 20 |
+
)
|
| 21 |
+
from .utils import get_current_user, SECRET_KEY, ALGORITHM
|
| 22 |
+
|
| 23 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _create_access_token(user_id: str) -> str:
|
| 27 |
+
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 28 |
+
return jwt.encode({"sub": user_id, "exp": expire}, SECRET_KEY, algorithm=ALGORITHM)
|
| 29 |
|
| 30 |
|
| 31 |
router = APIRouter(prefix="/users", tags=["Users"])
|
| 32 |
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
+
# ──────────────────────────── AUTH ────────────────────────────
|
| 35 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
@router.post("/register", response_model=ResponseModel)
|
| 38 |
+
async def register(payload: UserCreate):
|
| 39 |
+
if await User.get_or_none(email=payload.email):
|
| 40 |
+
raise AppException(status_code=400, message="Email already registered")
|
| 41 |
+
|
| 42 |
+
if await User.get_or_none(username=payload.username):
|
| 43 |
+
raise AppException(status_code=400, message="Username already taken")
|
| 44 |
+
|
| 45 |
+
user = await User.create(
|
| 46 |
+
username=payload.username,
|
| 47 |
+
email=payload.email,
|
| 48 |
+
hashed_password=bcrypt.hash(payload.password),
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
await Portfolio.create(user=user, name="Default Portfolio")
|
| 52 |
+
|
| 53 |
+
token = _create_access_token(str(user.id))
|
| 54 |
+
user_data = UserResponse.model_validate(user).model_dump(mode="json")
|
| 55 |
+
user_data["token"] = token
|
| 56 |
+
|
| 57 |
+
return ResponseModel(
|
| 58 |
+
success=True,
|
| 59 |
+
message="User created",
|
| 60 |
+
data=user_data,
|
| 61 |
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.post("/login", response_model=ResponseModel)
|
| 65 |
+
async def login(payload: UserLogin):
|
| 66 |
+
user = await User.get_or_none(email=payload.email)
|
| 67 |
+
|
| 68 |
+
if not user or not bcrypt.verify(payload.password, user.hashed_password):
|
| 69 |
+
raise AppException(status_code=400, message="Invalid email or password")
|
| 70 |
+
|
| 71 |
+
token = _create_access_token(str(user.id))
|
| 72 |
+
user_data = UserResponse.model_validate(user).model_dump(mode="json")
|
| 73 |
+
user_data["token"] = token
|
| 74 |
+
|
| 75 |
+
return ResponseModel(
|
| 76 |
+
success=True,
|
| 77 |
+
message="Login successful",
|
| 78 |
+
data=user_data,
|
|
|
|
|
|
|
|
|
|
| 79 |
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@router.get("/me", response_model=ResponseModel)
|
| 83 |
+
async def get_me(current_user: User = Depends(get_current_user)):
|
| 84 |
+
"""Return the currently authenticated user's profile."""
|
| 85 |
+
return ResponseModel(
|
| 86 |
+
success=True,
|
| 87 |
+
message="User retrieved",
|
| 88 |
+
data=UserResponse.model_validate(current_user).model_dump(mode="json"),
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ──────────────────────────── WATCHLIST ────────────────────────────
|
| 93 |
+
|
| 94 |
+
@router.get("/watchlist", response_model=ResponseModel)
|
| 95 |
+
async def get_watchlist(current_user: User = Depends(get_current_user)):
|
| 96 |
+
watchlist = await Watchlist.filter(user=current_user).all()
|
| 97 |
+
return ResponseModel(
|
| 98 |
+
success=True,
|
| 99 |
+
message="Watchlist retrieved",
|
| 100 |
+
data=[WatchlistItemSchema.model_validate(w).model_dump(mode="json") for w in watchlist],
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@router.post("/watchlist", response_model=ResponseModel)
|
| 105 |
+
async def add_to_watchlist(payload: WatchlistItemSchema, current_user: User = Depends(get_current_user)):
|
| 106 |
+
item = await Watchlist.create(user=current_user, **payload.model_dump())
|
| 107 |
+
return ResponseModel(
|
| 108 |
+
success=True,
|
| 109 |
+
message="Added to watchlist",
|
| 110 |
+
data=WatchlistItemSchema.model_validate(item).model_dump(mode="json"),
|
| 111 |
)
|
|
|
App/routers/users/schemas.py
CHANGED
|
@@ -1,28 +1,56 @@
|
|
|
|
|
| 1 |
from pydantic import BaseModel, EmailStr
|
| 2 |
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
class UserCreate(BaseModel):
|
| 5 |
username: str
|
| 6 |
email: EmailStr
|
| 7 |
password: str
|
| 8 |
|
|
|
|
| 9 |
class UserLogin(BaseModel):
|
| 10 |
email: EmailStr
|
| 11 |
password: str
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
class UserResponse(BaseModel):
|
| 14 |
-
id:
|
| 15 |
username: str
|
| 16 |
email: str
|
|
|
|
| 17 |
|
| 18 |
-
class
|
| 19 |
-
|
| 20 |
|
| 21 |
-
class WatchlistItemSchema(BaseModel):
|
| 22 |
-
stock_id: Optional[int]
|
| 23 |
-
utt_id: Optional[int]
|
| 24 |
|
| 25 |
-
class
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
from pydantic import BaseModel, EmailStr
|
| 3 |
from typing import Optional
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
# ---------- Request Schemas ----------
|
| 8 |
|
| 9 |
class UserCreate(BaseModel):
|
| 10 |
username: str
|
| 11 |
email: EmailStr
|
| 12 |
password: str
|
| 13 |
|
| 14 |
+
|
| 15 |
class UserLogin(BaseModel):
|
| 16 |
email: EmailStr
|
| 17 |
password: str
|
| 18 |
|
| 19 |
+
|
| 20 |
+
class PortfolioItemSchema(BaseModel):
|
| 21 |
+
name: str
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class WatchlistItemSchema(BaseModel):
|
| 25 |
+
stock_id: Optional[int] = None
|
| 26 |
+
fund_id: Optional[int] = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ---------- Response Schemas ----------
|
| 30 |
+
|
| 31 |
class UserResponse(BaseModel):
|
| 32 |
+
id: uuid.UUID # Match the UUIDField in the model
|
| 33 |
username: str
|
| 34 |
email: str
|
| 35 |
+
created_at: datetime
|
| 36 |
|
| 37 |
+
class Config:
|
| 38 |
+
from_attributes = True # Allows creating from ORM objects
|
| 39 |
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
+
class PortfolioResponse(BaseModel):
|
| 42 |
+
id: int
|
| 43 |
+
name: str
|
| 44 |
+
created_at: datetime
|
| 45 |
+
|
| 46 |
+
class Config:
|
| 47 |
+
from_attributes = True
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class WatchlistResponse(BaseModel):
|
| 51 |
+
id: int
|
| 52 |
+
stock_id: Optional[int] = None
|
| 53 |
+
fund_id: Optional[int] = None
|
| 54 |
+
|
| 55 |
+
class Config:
|
| 56 |
+
from_attributes = True
|
App/routers/users/utils.py
CHANGED
|
@@ -1,11 +1,35 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
| 3 |
from App.schemas import AppException
|
| 4 |
from .models import User
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
user = await User.get_or_none(id=user_id)
|
| 9 |
if not user:
|
| 10 |
-
raise AppException(status_code=
|
| 11 |
-
return user
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import jwt
|
| 3 |
+
from fastapi import Depends
|
| 4 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 5 |
from App.schemas import AppException
|
| 6 |
from .models import User
|
| 7 |
|
| 8 |
+
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production")
|
| 9 |
+
ALGORITHM = "HS256"
|
| 10 |
+
|
| 11 |
+
_security = HTTPBearer()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
async def get_current_user(
|
| 15 |
+
credentials: HTTPAuthorizationCredentials = Depends(_security),
|
| 16 |
+
) -> User:
|
| 17 |
+
"""
|
| 18 |
+
FastAPI dependency that validates a Bearer JWT token and returns the user.
|
| 19 |
+
Raises 401 if the token is missing, invalid, or the user no longer exists.
|
| 20 |
+
"""
|
| 21 |
+
token = credentials.credentials
|
| 22 |
+
try:
|
| 23 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 24 |
+
user_id: str = payload.get("sub")
|
| 25 |
+
if not user_id:
|
| 26 |
+
raise AppException(status_code=401, message="Invalid token payload")
|
| 27 |
+
except jwt.ExpiredSignatureError:
|
| 28 |
+
raise AppException(status_code=401, message="Token has expired")
|
| 29 |
+
except jwt.InvalidTokenError:
|
| 30 |
+
raise AppException(status_code=401, message="Invalid token")
|
| 31 |
+
|
| 32 |
user = await User.get_or_none(id=user_id)
|
| 33 |
if not user:
|
| 34 |
+
raise AppException(status_code=401, message="User not found")
|
| 35 |
+
return user
|
App/routers/utt/routes.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from fastapi import APIRouter, BackgroundTasks
|
| 2 |
from .models import UTTFund, UTTFundData
|
| 3 |
from .schemas import UTTFundResponse, UTTFundListResponse, ResponseModel
|
| 4 |
from .service import fetch_all_utt_data, parse_utt_api_row
|
|
@@ -14,50 +14,37 @@ UTTFund_Pydantic_List = pydantic_queryset_creator(UTTFund)
|
|
| 14 |
router = APIRouter(prefix="/utt", tags=["UTT"])
|
| 15 |
|
| 16 |
|
| 17 |
-
@router.get("
|
| 18 |
async def list_funds_orm():
|
| 19 |
-
"""
|
| 20 |
-
Alternative using Tortoise ORM - less efficient but more ORM-friendly
|
| 21 |
-
"""
|
| 22 |
try:
|
| 23 |
-
# Get all funds
|
| 24 |
funds = await UTTFund.all()
|
| 25 |
|
| 26 |
if not funds:
|
| 27 |
-
raise AppException(status_code=404,
|
| 28 |
|
| 29 |
-
# Get latest data for each fund
|
| 30 |
fund_ids = [fund.id for fund in funds]
|
| 31 |
latest_data = {}
|
| 32 |
|
| 33 |
-
# For each fund, get only the latest data (most recent date)
|
| 34 |
for fund_id in fund_ids:
|
| 35 |
latest = await UTTFundData.filter(fund_id=fund_id).order_by("-date").first()
|
| 36 |
-
|
| 37 |
if latest:
|
| 38 |
latest_data[fund_id] = latest
|
| 39 |
|
| 40 |
-
# Build the response
|
| 41 |
fund_list = []
|
| 42 |
for fund in funds:
|
| 43 |
latest = latest_data.get(fund.id)
|
| 44 |
-
|
| 45 |
-
fund_data = {
|
| 46 |
"id": fund.id,
|
| 47 |
"symbol": fund.symbol,
|
| 48 |
"name": fund.name,
|
| 49 |
"nav_per_unit": latest.nav_per_unit if latest else None,
|
| 50 |
"sale_price_per_unit": latest.sale_price_per_unit if latest else None,
|
| 51 |
-
"repurchase_price_per_unit":
|
| 52 |
-
|
| 53 |
-
),
|
| 54 |
-
"outstanding_number_of_units": (
|
| 55 |
-
latest.outstanding_number_of_units if latest else None
|
| 56 |
-
),
|
| 57 |
"net_asset_value": latest.net_asset_value if latest else None,
|
| 58 |
"latest_date": latest.date.isoformat() if latest else None,
|
| 59 |
-
}
|
| 60 |
-
fund_list.append(fund_data)
|
| 61 |
|
| 62 |
return ResponseModel(
|
| 63 |
success=True,
|
|
@@ -65,26 +52,75 @@ async def list_funds_orm():
|
|
| 65 |
data={"funds": fund_list, "count": len(fund_list)},
|
| 66 |
)
|
| 67 |
|
|
|
|
|
|
|
| 68 |
except Exception as e:
|
| 69 |
-
raise AppException(
|
| 70 |
-
status_code=500, detail=f"Error retrieving UTT funds: {str(e)}"
|
| 71 |
-
)
|
| 72 |
|
| 73 |
|
| 74 |
@router.get("/{symbol}", response_model=ResponseModel)
|
| 75 |
-
async def get_fund_data(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
fund = await UTTFund.get_or_none(symbol=symbol)
|
| 77 |
if not fund:
|
| 78 |
-
raise AppException(status_code=404,
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
return ResponseModel(
|
| 85 |
success=True,
|
| 86 |
message="Fund data",
|
| 87 |
-
data={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
)
|
| 89 |
|
| 90 |
|
|
|
|
| 1 |
+
from fastapi import APIRouter, BackgroundTasks, Query
|
| 2 |
from .models import UTTFund, UTTFundData
|
| 3 |
from .schemas import UTTFundResponse, UTTFundListResponse, ResponseModel
|
| 4 |
from .service import fetch_all_utt_data, parse_utt_api_row
|
|
|
|
| 14 |
router = APIRouter(prefix="/utt", tags=["UTT"])
|
| 15 |
|
| 16 |
|
| 17 |
+
@router.get("", response_model=ResponseModel)
|
| 18 |
async def list_funds_orm():
|
| 19 |
+
"""Return all UTT funds with their latest NAV data."""
|
|
|
|
|
|
|
| 20 |
try:
|
|
|
|
| 21 |
funds = await UTTFund.all()
|
| 22 |
|
| 23 |
if not funds:
|
| 24 |
+
raise AppException(status_code=404, message="No UTT funds found")
|
| 25 |
|
|
|
|
| 26 |
fund_ids = [fund.id for fund in funds]
|
| 27 |
latest_data = {}
|
| 28 |
|
|
|
|
| 29 |
for fund_id in fund_ids:
|
| 30 |
latest = await UTTFundData.filter(fund_id=fund_id).order_by("-date").first()
|
|
|
|
| 31 |
if latest:
|
| 32 |
latest_data[fund_id] = latest
|
| 33 |
|
|
|
|
| 34 |
fund_list = []
|
| 35 |
for fund in funds:
|
| 36 |
latest = latest_data.get(fund.id)
|
| 37 |
+
fund_list.append({
|
|
|
|
| 38 |
"id": fund.id,
|
| 39 |
"symbol": fund.symbol,
|
| 40 |
"name": fund.name,
|
| 41 |
"nav_per_unit": latest.nav_per_unit if latest else None,
|
| 42 |
"sale_price_per_unit": latest.sale_price_per_unit if latest else None,
|
| 43 |
+
"repurchase_price_per_unit": latest.repurchase_price_per_unit if latest else None,
|
| 44 |
+
"outstanding_number_of_units": latest.outstanding_number_of_units if latest else None,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
"net_asset_value": latest.net_asset_value if latest else None,
|
| 46 |
"latest_date": latest.date.isoformat() if latest else None,
|
| 47 |
+
})
|
|
|
|
| 48 |
|
| 49 |
return ResponseModel(
|
| 50 |
success=True,
|
|
|
|
| 52 |
data={"funds": fund_list, "count": len(fund_list)},
|
| 53 |
)
|
| 54 |
|
| 55 |
+
except AppException:
|
| 56 |
+
raise
|
| 57 |
except Exception as e:
|
| 58 |
+
raise AppException(status_code=500, message=f"Error retrieving UTT funds: {str(e)}")
|
|
|
|
|
|
|
| 59 |
|
| 60 |
|
| 61 |
@router.get("/{symbol}", response_model=ResponseModel)
|
| 62 |
+
async def get_fund_data(
|
| 63 |
+
symbol: str,
|
| 64 |
+
period: str = Query("Max", enum=["1M", "3M", "6M", "1Y", "3Y", "Max"]),
|
| 65 |
+
page: int = Query(1, ge=1),
|
| 66 |
+
limit: int = Query(50, ge=1, le=500),
|
| 67 |
+
):
|
| 68 |
+
"""Return a fund's metadata plus paginated price history."""
|
| 69 |
fund = await UTTFund.get_or_none(symbol=symbol)
|
| 70 |
if not fund:
|
| 71 |
+
raise AppException(status_code=404, message="Fund not found")
|
| 72 |
+
|
| 73 |
+
# Determine date cutoff based on period
|
| 74 |
+
from datetime import date, timedelta
|
| 75 |
+
today = date.today()
|
| 76 |
+
period_map = {
|
| 77 |
+
"1M": timedelta(days=30),
|
| 78 |
+
"3M": timedelta(days=90),
|
| 79 |
+
"6M": timedelta(days=180),
|
| 80 |
+
"1Y": timedelta(days=365),
|
| 81 |
+
"3Y": timedelta(days=365 * 3),
|
| 82 |
+
"Max": None,
|
| 83 |
+
}
|
| 84 |
+
delta = period_map.get(period)
|
| 85 |
+
|
| 86 |
+
data_qs = UTTFundData.filter(fund=fund).order_by("-date")
|
| 87 |
+
if delta:
|
| 88 |
+
cutoff = today - delta
|
| 89 |
+
data_qs = data_qs.filter(date__gte=cutoff)
|
| 90 |
+
|
| 91 |
+
total = await data_qs.count()
|
| 92 |
+
offset = (page - 1) * limit
|
| 93 |
+
rows = await data_qs.offset(offset).limit(limit)
|
| 94 |
+
|
| 95 |
+
prices = [
|
| 96 |
+
{
|
| 97 |
+
"date": r.date.isoformat(),
|
| 98 |
+
"nav_per_unit": float(r.nav_per_unit) if r.nav_per_unit is not None else None,
|
| 99 |
+
"sale_price_per_unit": float(r.sale_price_per_unit) if r.sale_price_per_unit is not None else None,
|
| 100 |
+
"repurchase_price_per_unit": float(r.repurchase_price_per_unit) if r.repurchase_price_per_unit is not None else None,
|
| 101 |
+
}
|
| 102 |
+
for r in rows
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
latest = rows[0] if rows else None
|
| 106 |
|
| 107 |
return ResponseModel(
|
| 108 |
success=True,
|
| 109 |
message="Fund data",
|
| 110 |
+
data={
|
| 111 |
+
"id": fund.id,
|
| 112 |
+
"symbol": fund.symbol,
|
| 113 |
+
"name": fund.name,
|
| 114 |
+
"nav_per_unit": float(latest.nav_per_unit) if latest and latest.nav_per_unit else None,
|
| 115 |
+
"latest_date": latest.date.isoformat() if latest else None,
|
| 116 |
+
"prices": prices,
|
| 117 |
+
"pagination": {
|
| 118 |
+
"total": total,
|
| 119 |
+
"page": page,
|
| 120 |
+
"limit": limit,
|
| 121 |
+
"pages": (total + limit - 1) // limit,
|
| 122 |
+
},
|
| 123 |
+
},
|
| 124 |
)
|
| 125 |
|
| 126 |
|
App/scheduler.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
scheduler.py — background price refresh tasks.
|
| 3 |
+
|
| 4 |
+
On startup: fetch latest data for all stocks, all fund managers, and bonds.
|
| 5 |
+
Every hour: repeat stock + fund refresh so prices stay current.
|
| 6 |
+
Daily: re-scrape bonds (auction data changes infrequently).
|
| 7 |
+
"""
|
| 8 |
+
import asyncio
|
| 9 |
+
import logging
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
REFRESH_INTERVAL_SECONDS = 3600 # 1 hour — stocks + funds
|
| 15 |
+
BOND_REFRESH_INTERVAL_SECONDS = 86400 # 24 hours — bonds (auction data is slow-moving)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ── STOCKS ────────────────────────────────────────────────────────────────────
|
| 19 |
+
|
| 20 |
+
async def refresh_stocks() -> None:
|
| 21 |
+
"""Import latest price data for every stock already in the DB."""
|
| 22 |
+
from App.routers.stocks.models import Stock, StockPriceData
|
| 23 |
+
from App.routers.stocks.service import fetch_dse_stock_data
|
| 24 |
+
from App.routers.stocks.crud import bulk_insert_price_data
|
| 25 |
+
from datetime import date as date_type
|
| 26 |
+
|
| 27 |
+
stocks = await Stock.all()
|
| 28 |
+
if not stocks:
|
| 29 |
+
logger.info("[scheduler] No stocks in DB — skipping stock refresh")
|
| 30 |
+
return
|
| 31 |
+
|
| 32 |
+
logger.info(f"[scheduler] Refreshing prices for {len(stocks)} stock(s)…")
|
| 33 |
+
today = date_type.today()
|
| 34 |
+
for stock in stocks:
|
| 35 |
+
try:
|
| 36 |
+
latest = await StockPriceData.filter(stock=stock).order_by("-date").first()
|
| 37 |
+
if latest:
|
| 38 |
+
days = (today - latest.date).days + 5
|
| 39 |
+
else:
|
| 40 |
+
days = 3000
|
| 41 |
+
|
| 42 |
+
data = await fetch_dse_stock_data(stock.symbol, days=days)
|
| 43 |
+
if not data.get("success"):
|
| 44 |
+
logger.warning(f"[scheduler] {stock.symbol}: API returned failure")
|
| 45 |
+
continue
|
| 46 |
+
|
| 47 |
+
raw = data.get("data") or []
|
| 48 |
+
if not raw:
|
| 49 |
+
logger.info(f"[scheduler] {stock.symbol}: no records in API response")
|
| 50 |
+
continue
|
| 51 |
+
|
| 52 |
+
existing_dates = set(
|
| 53 |
+
await StockPriceData.filter(stock=stock).values_list("date", flat=True)
|
| 54 |
+
)
|
| 55 |
+
new_rows = [
|
| 56 |
+
row for row in raw
|
| 57 |
+
if datetime.fromisoformat(row["trade_date"]).date() not in existing_dates
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
if new_rows:
|
| 61 |
+
await bulk_insert_price_data(stock, new_rows)
|
| 62 |
+
logger.info(f"[scheduler] {stock.symbol}: +{len(new_rows)} new record(s) (fetched {days}d window)")
|
| 63 |
+
else:
|
| 64 |
+
logger.info(f"[scheduler] {stock.symbol}: already up to date")
|
| 65 |
+
|
| 66 |
+
except Exception as exc:
|
| 67 |
+
logger.error(f"[scheduler] {stock.symbol}: refresh failed — {exc}")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ── FUNDS ─────────────────────────────────────────────────────────────────────
|
| 71 |
+
|
| 72 |
+
async def refresh_funds() -> None:
|
| 73 |
+
"""Import latest NAV data for all fund managers (iTrust, UTT, Orbit)."""
|
| 74 |
+
try:
|
| 75 |
+
from App.routers.funds.runner import run_import
|
| 76 |
+
stats = await run_import("all")
|
| 77 |
+
logger.info(f"[scheduler] Fund refresh complete: {stats}")
|
| 78 |
+
except Exception as exc:
|
| 79 |
+
logger.error(f"[scheduler] Fund refresh failed — {exc}")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ── BONDS ─────────────────────────────────────────────────────────────────────
|
| 83 |
+
|
| 84 |
+
async def refresh_bonds() -> None:
|
| 85 |
+
"""Scrape latest Treasury Bond auction data from bot.go.tz."""
|
| 86 |
+
from App.routers.bonds.utils import BondDataScraper
|
| 87 |
+
from App.routers.bonds.models import Bond
|
| 88 |
+
from tortoise.transactions import in_transaction
|
| 89 |
+
|
| 90 |
+
logger.info("[scheduler] Bond refresh — scraping bot.go.tz/TBonds…")
|
| 91 |
+
scraper = BondDataScraper()
|
| 92 |
+
created = updated = failed = 0
|
| 93 |
+
processed_isins: set = set()
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
async for bond_data in scraper.scrape_all_bond_data():
|
| 97 |
+
if not bond_data:
|
| 98 |
+
failed += 1
|
| 99 |
+
continue
|
| 100 |
+
if bond_data.isin and bond_data.isin in processed_isins:
|
| 101 |
+
continue
|
| 102 |
+
async with in_transaction():
|
| 103 |
+
try:
|
| 104 |
+
existing = None
|
| 105 |
+
if bond_data.isin:
|
| 106 |
+
existing = await Bond.get_or_none(isin=bond_data.isin)
|
| 107 |
+
if not existing:
|
| 108 |
+
existing = await Bond.get_or_none(
|
| 109 |
+
auction_number=bond_data.auction_number,
|
| 110 |
+
auction_date=bond_data.auction_date,
|
| 111 |
+
holding_number=bond_data.holding_number,
|
| 112 |
+
)
|
| 113 |
+
if existing:
|
| 114 |
+
await Bond.filter(id=existing.id).update(
|
| 115 |
+
**bond_data.dict(exclude_unset=True)
|
| 116 |
+
)
|
| 117 |
+
updated += 1
|
| 118 |
+
else:
|
| 119 |
+
await Bond.create(**bond_data.dict())
|
| 120 |
+
created += 1
|
| 121 |
+
if bond_data.isin:
|
| 122 |
+
processed_isins.add(bond_data.isin)
|
| 123 |
+
except Exception as exc:
|
| 124 |
+
failed += 1
|
| 125 |
+
logger.error(f"[scheduler] Bond DB error for au_no {bond_data.auction_number}: {exc}")
|
| 126 |
+
|
| 127 |
+
logger.info(f"[scheduler] Bond refresh complete — created={created} updated={updated} failed={failed}")
|
| 128 |
+
except Exception as exc:
|
| 129 |
+
logger.error(f"[scheduler] Bond refresh failed — {exc}")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ── ENTRY POINTS ──────────────────────────────────────────────────────────────
|
| 133 |
+
|
| 134 |
+
async def startup_refresh() -> None:
|
| 135 |
+
"""Run once at startup to ensure prices are not stale after a server restart.
|
| 136 |
+
Bonds are excluded — they are slow to scrape (362+ HTTP round-trips) and
|
| 137 |
+
change at most once per week. The daily_bond_loop handles them."""
|
| 138 |
+
logger.info("[scheduler] Startup refresh — stocks and funds…")
|
| 139 |
+
await refresh_stocks()
|
| 140 |
+
await refresh_funds()
|
| 141 |
+
logger.info("[scheduler] Startup refresh complete.")
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
async def hourly_loop() -> None:
|
| 145 |
+
"""Refresh stocks and funds every hour indefinitely."""
|
| 146 |
+
while True:
|
| 147 |
+
await asyncio.sleep(REFRESH_INTERVAL_SECONDS)
|
| 148 |
+
logger.info("[scheduler] Hourly refresh triggered…")
|
| 149 |
+
await refresh_stocks()
|
| 150 |
+
await refresh_funds()
|
| 151 |
+
logger.info("[scheduler] Hourly refresh complete.")
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
async def daily_bond_loop() -> None:
|
| 155 |
+
"""Refresh bond auction data once per day indefinitely."""
|
| 156 |
+
while True:
|
| 157 |
+
await asyncio.sleep(BOND_REFRESH_INTERVAL_SECONDS)
|
| 158 |
+
logger.info("[scheduler] Daily bond refresh triggered…")
|
| 159 |
+
await refresh_bonds()
|
| 160 |
+
logger.info("[scheduler] Daily bond refresh complete.")
|
App/schemas.py
CHANGED
|
@@ -1,25 +1,16 @@
|
|
| 1 |
from pydantic import BaseModel
|
| 2 |
-
from typing import
|
| 3 |
from fastapi import HTTPException
|
| 4 |
|
| 5 |
|
| 6 |
class ResponseModel(BaseModel):
|
| 7 |
success: bool
|
| 8 |
message: str
|
| 9 |
-
data:
|
| 10 |
|
| 11 |
|
| 12 |
class AppException(HTTPException):
|
| 13 |
-
def __init__(self, status_code: int
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
self.response_model = detail
|
| 18 |
-
else:
|
| 19 |
-
super().__init__(status_code=status_code, detail=str(detail) if detail else "An error occurred")
|
| 20 |
-
self.data = None
|
| 21 |
-
self.response_model = ResponseModel(
|
| 22 |
-
success=False,
|
| 23 |
-
message=str(detail) if detail else "An error occurred",
|
| 24 |
-
data=None
|
| 25 |
-
)
|
|
|
|
| 1 |
from pydantic import BaseModel
|
| 2 |
+
from typing import Any
|
| 3 |
from fastapi import HTTPException
|
| 4 |
|
| 5 |
|
| 6 |
class ResponseModel(BaseModel):
|
| 7 |
success: bool
|
| 8 |
message: str
|
| 9 |
+
data: Any = None
|
| 10 |
|
| 11 |
|
| 12 |
class AppException(HTTPException):
|
| 13 |
+
def __init__(self, status_code: int, message: str, data: Any = None):
|
| 14 |
+
self.data = data
|
| 15 |
+
self.message = message
|
| 16 |
+
super().__init__(status_code=status_code, detail=message)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
db.py
CHANGED
|
@@ -8,6 +8,8 @@ from asyncpg import Connection
|
|
| 8 |
ssl_context = ssl.create_default_context()
|
| 9 |
|
| 10 |
# 2. Update your TORTOISE_ORM configuration
|
|
|
|
|
|
|
| 11 |
TORTOISE_ORM = {
|
| 12 |
"connections": {
|
| 13 |
"default": {
|
|
@@ -18,16 +20,12 @@ TORTOISE_ORM = {
|
|
| 18 |
"user": os.getenv("DB_USER"),
|
| 19 |
"password": os.getenv("DB_PASSWORD"),
|
| 20 |
"database": "postgres",
|
| 21 |
-
"min_size": 1,
|
| 22 |
-
"max_size": 10,
|
| 23 |
-
"timeout": 30,
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
"max_queries": 50000, # Max queries before a connection is closed and replaced [15]
|
| 27 |
-
"max_inactive_connection_lifetime": 300.0, # Max idle time before a connection is closed [15]
|
| 28 |
},
|
| 29 |
-
|
| 30 |
-
# Pass the custom connection class and disable the cache
|
| 31 |
"connect_args": {
|
| 32 |
"statement_cache_size": 0,
|
| 33 |
"ssl": ssl_context
|
|
@@ -39,7 +37,7 @@ TORTOISE_ORM = {
|
|
| 39 |
"models": [
|
| 40 |
"App.routers.stocks.models",
|
| 41 |
"App.routers.tasks.models",
|
| 42 |
-
"App.routers.
|
| 43 |
"App.routers.users.models",
|
| 44 |
"App.routers.portfolio.models",
|
| 45 |
"App.routers.bonds.models",
|
|
@@ -50,18 +48,15 @@ TORTOISE_ORM = {
|
|
| 50 |
},
|
| 51 |
}
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
async def init_db():
|
| 54 |
-
await Tortoise.init(
|
| 55 |
-
TORTOISE_ORM # db_url=DATABASE_URL,
|
| 56 |
-
# modules={'models': [
|
| 57 |
-
# 'App.routers.stocks.models',
|
| 58 |
-
# 'App.routers.tasks.models',
|
| 59 |
-
# 'App.routers.utt.models',
|
| 60 |
-
# 'App.routers.users.models',
|
| 61 |
-
# 'App.routers.portfolio.models',
|
| 62 |
-
# 'App.routers.bonds.models'
|
| 63 |
-
# ]}
|
| 64 |
-
)
|
| 65 |
await Tortoise.generate_schemas()
|
| 66 |
|
| 67 |
|
|
|
|
| 8 |
ssl_context = ssl.create_default_context()
|
| 9 |
|
| 10 |
# 2. Update your TORTOISE_ORM configuration
|
| 11 |
+
SQLITE_DB = "db.sqlite3"
|
| 12 |
+
|
| 13 |
TORTOISE_ORM = {
|
| 14 |
"connections": {
|
| 15 |
"default": {
|
|
|
|
| 20 |
"user": os.getenv("DB_USER"),
|
| 21 |
"password": os.getenv("DB_PASSWORD"),
|
| 22 |
"database": "postgres",
|
| 23 |
+
"min_size": 1,
|
| 24 |
+
"max_size": 10,
|
| 25 |
+
"timeout": 30,
|
| 26 |
+
"max_queries": 50000,
|
| 27 |
+
"max_inactive_connection_lifetime": 300.0,
|
|
|
|
|
|
|
| 28 |
},
|
|
|
|
|
|
|
| 29 |
"connect_args": {
|
| 30 |
"statement_cache_size": 0,
|
| 31 |
"ssl": ssl_context
|
|
|
|
| 37 |
"models": [
|
| 38 |
"App.routers.stocks.models",
|
| 39 |
"App.routers.tasks.models",
|
| 40 |
+
"App.routers.funds.models",
|
| 41 |
"App.routers.users.models",
|
| 42 |
"App.routers.portfolio.models",
|
| 43 |
"App.routers.bonds.models",
|
|
|
|
| 48 |
},
|
| 49 |
}
|
| 50 |
|
| 51 |
+
# Check if we should use local SQLite
|
| 52 |
+
# if os.path.exists(SQLITE_DB):
|
| 53 |
+
TORTOISE_ORM["connections"]["default"] = {
|
| 54 |
+
"engine": "tortoise.backends.sqlite",
|
| 55 |
+
"credentials": {"file_path": SQLITE_DB},
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
async def init_db():
|
| 59 |
+
await Tortoise.init(config=TORTOISE_ORM)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
await Tortoise.generate_schemas()
|
| 61 |
|
| 62 |
|
main.py
CHANGED
|
@@ -1,30 +1,49 @@
|
|
|
|
|
| 1 |
from fastapi import FastAPI, Request
|
| 2 |
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
from fastapi.responses import JSONResponse
|
| 4 |
from fastapi.exceptions import RequestValidationError, HTTPException
|
| 5 |
from starlette.status import HTTP_400_BAD_REQUEST
|
| 6 |
from App.routers.stocks.routes import router as stocks_router
|
| 7 |
-
from App.routers.
|
| 8 |
from App.routers.bonds.routes import router as bonds_router
|
| 9 |
from App.routers.tasks.routes import router as tasks_router
|
| 10 |
from App.routers.users.routes import router as users_router
|
| 11 |
from App.routers.portfolio.routes import router as portfolio_router
|
| 12 |
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
| 13 |
from App.schemas import ResponseModel, AppException
|
|
|
|
| 14 |
|
| 15 |
from db import init_db, close_db, clear_db
|
| 16 |
|
| 17 |
-
app = FastAPI(title="Uwekezaji API", description="Stock Market Data API")
|
| 18 |
|
| 19 |
|
|
|
|
| 20 |
@app.exception_handler(AppException)
|
| 21 |
async def custom_http_exception_handler(request: Request, exc: AppException):
|
| 22 |
return JSONResponse(
|
| 23 |
status_code=exc.status_code,
|
| 24 |
-
content=ResponseModel(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
)
|
| 26 |
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
@app.exception_handler(RequestValidationError)
|
| 29 |
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
| 30 |
return JSONResponse(
|
|
@@ -46,7 +65,7 @@ app.add_middleware(
|
|
| 46 |
|
| 47 |
# Include routers
|
| 48 |
app.include_router(stocks_router)
|
| 49 |
-
app.include_router(
|
| 50 |
app.include_router(bonds_router)
|
| 51 |
app.include_router(tasks_router)
|
| 52 |
app.include_router(users_router)
|
|
@@ -57,8 +76,10 @@ app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
|
|
| 57 |
# Database initialization and cleanup
|
| 58 |
@app.on_event("startup")
|
| 59 |
async def startup_event():
|
| 60 |
-
# Clear and reinitialize database on startup
|
| 61 |
await init_db()
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
@app.on_event("shutdown")
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
from fastapi import FastAPI, Request
|
| 3 |
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
from fastapi.responses import JSONResponse
|
| 5 |
from fastapi.exceptions import RequestValidationError, HTTPException
|
| 6 |
from starlette.status import HTTP_400_BAD_REQUEST
|
| 7 |
from App.routers.stocks.routes import router as stocks_router
|
| 8 |
+
from App.routers.funds.routes import router as funds_router
|
| 9 |
from App.routers.bonds.routes import router as bonds_router
|
| 10 |
from App.routers.tasks.routes import router as tasks_router
|
| 11 |
from App.routers.users.routes import router as users_router
|
| 12 |
from App.routers.portfolio.routes import router as portfolio_router
|
| 13 |
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
| 14 |
from App.schemas import ResponseModel, AppException
|
| 15 |
+
from App.scheduler import startup_refresh, hourly_loop, daily_bond_loop
|
| 16 |
|
| 17 |
from db import init_db, close_db, clear_db
|
| 18 |
|
| 19 |
+
app = FastAPI(title="Uwekezaji API", description="Stock Market Data API", redirect_slashes=False)
|
| 20 |
|
| 21 |
|
| 22 |
+
# Handle your custom AppException
|
| 23 |
@app.exception_handler(AppException)
|
| 24 |
async def custom_http_exception_handler(request: Request, exc: AppException):
|
| 25 |
return JSONResponse(
|
| 26 |
status_code=exc.status_code,
|
| 27 |
+
content=ResponseModel(
|
| 28 |
+
success=False,
|
| 29 |
+
message=getattr(exc, "message", str(exc.detail)), # safe access
|
| 30 |
+
data=getattr(exc, "data", None), # safe access
|
| 31 |
+
).model_dump(),
|
| 32 |
)
|
| 33 |
|
| 34 |
|
| 35 |
+
# ALSO handle generic HTTPException (FastAPI's built-in 404, 422, etc.)
|
| 36 |
+
@app.exception_handler(HTTPException)
|
| 37 |
+
async def generic_http_exception_handler(request: Request, exc: HTTPException):
|
| 38 |
+
return JSONResponse(
|
| 39 |
+
status_code=exc.status_code,
|
| 40 |
+
content=ResponseModel(
|
| 41 |
+
success=False,
|
| 42 |
+
message=str(exc.detail),
|
| 43 |
+
data=None,
|
| 44 |
+
).model_dump(),
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
@app.exception_handler(RequestValidationError)
|
| 48 |
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
| 49 |
return JSONResponse(
|
|
|
|
| 65 |
|
| 66 |
# Include routers
|
| 67 |
app.include_router(stocks_router)
|
| 68 |
+
app.include_router(funds_router)
|
| 69 |
app.include_router(bonds_router)
|
| 70 |
app.include_router(tasks_router)
|
| 71 |
app.include_router(users_router)
|
|
|
|
| 76 |
# Database initialization and cleanup
|
| 77 |
@app.on_event("startup")
|
| 78 |
async def startup_event():
|
|
|
|
| 79 |
await init_db()
|
| 80 |
+
asyncio.create_task(startup_refresh())
|
| 81 |
+
asyncio.create_task(hourly_loop())
|
| 82 |
+
asyncio.create_task(daily_bond_loop())
|
| 83 |
|
| 84 |
|
| 85 |
@app.on_event("shutdown")
|
tests/test_users.py
CHANGED
|
@@ -5,7 +5,7 @@ pytest_plugins = ["pytest_asyncio"]
|
|
| 5 |
@pytest.mark.asyncio
|
| 6 |
async def test_register_user(client, initialize_tests):
|
| 7 |
async with httpx.AsyncClient() as async_client:
|
| 8 |
-
response = await async_client.post("http://localhost:
|
| 9 |
"username": "testwuser",
|
| 10 |
"email": "test@example.com",
|
| 11 |
"password": "testpassword123"
|
|
|
|
| 5 |
@pytest.mark.asyncio
|
| 6 |
async def test_register_user(client, initialize_tests):
|
| 7 |
async with httpx.AsyncClient() as async_client:
|
| 8 |
+
response = await async_client.post("http://localhost:8000/users/register", json={
|
| 9 |
"username": "testwuser",
|
| 10 |
"email": "test@example.com",
|
| 11 |
"password": "testpassword123"
|