Mbonea Claude Sonnet 4.6 commited on
Commit
74645f6
·
1 Parent(s): 69a2051

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 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 App.routers.bonds.models import Bond # Adjust import path
5
- from App.routers.tasks.models import ImportTask # Adjust import path
6
- from App.routers.bonds.schemas import BondCreate, BondResponse # Adjust import path
7
- from App.routers.bonds.utils import BondDataScraper # Adjust import path
8
- from App.schemas import ResponseModel # Assuming you have this general response model
 
9
  from typing import List
10
 
11
 
12
  router = APIRouter(prefix="/bonds", tags=["Bonds"])
13
 
14
- # --- CRUD for Bond (example, you might have these elsewhere) ---
15
- @router.post("/", response_model=ResponseModel)
16
- async def create_bond_entry(payload: BondCreate):
 
 
 
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 # or bond_auction_number if that's more unique
27
  )
28
 
29
  if existing_bond:
30
  # Update existing bond
31
- await Bond.filter(id=existing_bond.id).update(**payload.dict(exclude_unset=True))
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.dict())
37
  message = "Bond created successfully"
38
 
39
  return ResponseModel(success=True, message=message, data=await BondResponse.from_tortoise_orm(bond))
40
 
41
- @router.get("/", response_model=ResponseModel)
42
  async def list_bonds_entries():
43
-
44
  _bonds = await Bond.all()
45
- print(_bonds)
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(background_tasks: BackgroundTasks):
 
 
 
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 HTTPException(status_code=404, detail="Import task not found")
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
- # models.py
 
 
 
2
  from tortoise import fields, models
3
- from typing import Optional
4
- from datetime import datetime
5
-
6
- from tortoise.contrib.pydantic.creator import pydantic_model_creator, pydantic_queryset_creator
7
  from tortoise.queryset import QuerySet
8
- class Portfolio(models.Model):
9
- id = fields.IntField(pk=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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") # User can't have duplicate portfolio names
 
 
 
 
 
 
29
 
30
- class PortfolioStock(models.Model):
31
- id = fields.IntField(pk=True)
32
  portfolio = fields.ForeignKeyField("models.Portfolio", related_name="stocks")
33
  stock = fields.ForeignKeyField("models.Stock", related_name="portfolio_holdings")
34
- quantity = fields.IntField()
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
- class PortfolioUTT(models.Model):
53
- id = fields.IntField(pk=True)
54
  portfolio = fields.ForeignKeyField("models.Portfolio", related_name="utts")
55
- utt_fund = fields.ForeignKeyField("models.UTTFund", related_name="portfolio_holdings")
 
 
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 = "portfolio_utts"
 
 
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
- class PortfolioTransaction(models.Model):
97
- """Track all portfolio transactions for audit and reporting"""
98
- id = fields.IntField(pk=True)
99
- portfolio = fields.ForeignKeyField("models.Portfolio", related_name="transactions")
100
- transaction_type = fields.CharField(max_length=20) # BUY, SELL, DIVIDEND, COUPON
101
- asset_type = fields.CharField(max_length=10) # STOCK, BOND, UTT
102
- asset_id = fields.IntField() # Generic reference to stock/bond/utt ID
 
 
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
- @staticmethod
111
- async def get_list(data):
112
- if type(data) == QuerySet:
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
- id = fields.IntField(pk=True)
127
- portfolio = fields.ForeignKeyField("models.Portfolio", related_name="calendar_events")
 
128
  event_date = fields.DateField()
129
- event_type = fields.CharField(max_length=50) # COUPON, DIVIDEND, MATURITY, EARNINGS
130
  title = fields.CharField(max_length=200)
131
- description = fields.TextField(null=True)
132
- asset_type = fields.CharField(max_length=10, null=True) # STOCK, BOND, UTT
133
  asset_id = fields.IntField(null=True)
134
- estimated_amount = fields.DecimalField(max_digits=15, decimal_places=2, null=True)
 
 
135
  is_completed = fields.BooleanField(default=False)
136
  created_at = fields.DatetimeField(auto_now_add=True)
137
 
138
- @staticmethod
139
- async def get_list(data):
140
- if type(data) == QuerySet:
141
- parser = pydantic_queryset_creator(PortfolioCalendar)
142
- return await parser.from_queryset(data)
143
 
144
 
145
- async def to_dict(self):
146
- if type(self) == models.Model:
147
- parser = pydantic_model_creator(PortfolioCalendar)
148
- return await parser.from_tortoise_orm(self)
149
 
150
 
151
- class Meta:
152
- table = "portfolio_calendar"
153
-
154
- class PortfolioSnapshot(models.Model):
155
- """Daily snapshots for performance tracking"""
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
- utt_value = fields.DecimalField(max_digits=20, decimal_places=2, default=0)
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
- # routes.py
2
- from fastapi import APIRouter, Depends, HTTPException, Query
3
- from typing import List, Optional
4
- from datetime import date
5
- from App.routers.bonds.models import Bond # Import Bond model
6
- from .service import _calculate_bond_coupon_dates # Import our
7
- from decimal import Decimal # Import Decimal for type hints if necessary
8
- from tortoise.exceptions import DoesNotExist
9
- from App.routers.utt.models import UTTFundData
 
 
 
 
 
 
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
- StockHoldingResponse,
28
- UTTHoldingCreate,
29
- UTTHoldingUpdate,
30
- UTTHoldingResponse,
31
  BondHoldingCreate,
32
  BondHoldingUpdate,
33
- BondHoldingResponse,
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
- Portfolio_Pydantic = pydantic_model_creator(Portfolio, name="Portfolio")
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
- Portfolio_Pydantic_List = pydantic_queryset_creator(Portfolio, name="PortfolioList")
73
- # Not strictly needed if manually converting list items, but good for consistency
74
- # PortfolioStock_Pydantic_List = pydantic_queryset_creator(PortfolioStock, name="PortfolioStockList")
75
- # PortfolioUTT_Pydantic_List = pydantic_queryset_creator(PortfolioUTT, name="PortfolioUTTList")
76
- # PortfolioBond_Pydantic_List = pydantic_queryset_creator(PortfolioBond, name="PortfolioBondList")
77
- # PortfolioTransaction_Pydantic_List = pydantic_queryset_creator(PortfolioTransaction, name="PortfolioTransactionList")
78
- # PortfolioCalendar_Pydantic_List = pydantic_queryset_creator(PortfolioCalendar, name="PortfolioCalendarList")
79
- PortfolioSnapshotPydantic = pydantic_model_creator(
80
- PortfolioSnapshot, name="PortfolioSnapshotResponse"
81
- ) # Renamed for clarity
82
 
83
- router = APIRouter(prefix="/portfolios", tags=["portfolios"])
84
 
85
- # Portfolio Management Routes
86
 
87
 
88
- @router.get("/", response_model=ResponseModel)
89
- async def get_user_portfolios(
90
- include_inactive: bool = Query(False), current_user=Depends(get_current_user)
91
- ):
92
- try:
93
- portfolios = await PortfolioService.get_user_portfolios(
94
- user_id=current_user.id, include_inactive=include_inactive
95
- )
96
 
97
- return ResponseModel(
98
- success=True,
99
- message="Portfolios retrieved successfully",
100
- data={
101
- "portfolios": [
102
- await Portfolio_Pydantic.from_tortoise_orm(p) for p in portfolios
103
- ],
104
- "total_count": len(portfolios),
105
- },
106
- )
107
- except Exception as e:
108
- raise AppException(status_code=500, detail=str(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
 
111
- @router.post("/", response_model=ResponseModel)
112
  async def create_portfolio(
113
- portfolio_data: PortfolioCreate, current_user=Depends(get_current_user)
 
114
  ):
115
  try:
116
  portfolio = await PortfolioService.create_portfolio(
117
  user_id=current_user.id,
118
- name=portfolio_data.name,
119
- description=portfolio_data.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 constraint" in str(e).lower() or "UNIQUE constraint failed" in str(
129
- e
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
- summary = await PortfolioService.get_portfolio_summary(portfolio_id)
147
-
148
- return ResponseModel(
149
- success=True,
150
- message="Portfolio summary retrieved successfully",
151
- data=summary,
152
- )
153
- except AppException:
154
- raise
155
- except Exception as e:
156
- raise AppException(status_code=500, detail=str(e))
157
 
158
 
159
- @router.put("/{portfolio_id}", response_model=ResponseModel)
160
  async def update_portfolio(
161
  portfolio_id: int,
162
- portfolio_data: PortfolioUpdate,
163
  current_user=Depends(get_current_user),
164
  ):
165
- try:
166
- portfolio = await Portfolio.get_or_none(
167
- id=portfolio_id, user_id=current_user.id
168
- )
169
- if not portfolio:
170
- raise AppException(status_code=404, detail="Portfolio not found")
171
 
172
- update_data = portfolio_data.dict(exclude_unset=True)
173
- if update_data:
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
- return ResponseModel(
201
- success=True,
202
- message="Portfolio deleted successfully (set to inactive)",
203
- data=None,
204
- )
205
- except AppException:
206
- raise
207
- except Exception as e:
208
- raise AppException(status_code=500, detail=str(e))
209
 
210
 
211
- # Stock Holdings Routes
 
 
212
 
213
 
214
- @router.post(
215
- "/{portfolio_id}/stocks",
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
- stock_data: StockHoldingCreate,
222
  current_user=Depends(get_current_user),
223
  ):
224
- try:
225
- portfolio = await Portfolio.get_or_none(
226
- id=portfolio_id, user_id=current_user.id, is_active=True
227
- )
228
- if not portfolio:
229
- raise AppException(
230
- status_code=404, detail="Active portfolio not found or access denied"
231
- )
232
-
233
- holding = await PortfolioService.add_stock_to_portfolio(
234
- portfolio_id=portfolio_id,
235
- stock_id=stock_data.stock_id,
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
- "/{portfolio_id}/stocks/{stock_id}/sell",
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, # stock_id identifies the asset
266
- sell_data: StockSellSchema,
267
  current_user=Depends(get_current_user),
268
  ):
269
- try:
270
- portfolio = await Portfolio.get_or_none(
271
- id=portfolio_id, user_id=current_user.id, is_active=True
272
- )
273
- if not portfolio:
274
- raise AppException(
275
- status_code=404, detail="Active portfolio not found or access denied"
276
- )
277
-
278
- transaction = await PortfolioService.sell_stock_holding(
279
- portfolio_id=portfolio_id,
280
- stock_id=stock_id, # Pass stock_id from path
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}", response_model=ResponseModel)
303
  async def update_stock_holding(
304
  portfolio_id: int,
305
- stock_id: int, # Changed from holding_id to stock_id
306
- stock_data: StockHoldingUpdate, # Be cautious with fields updated here for aggregated holdings
307
  current_user=Depends(get_current_user),
308
  ):
309
- try:
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
- # Fetch aggregated holding by stock_id and portfolio_id
317
- holding = await PortfolioStock.get_or_none(
318
- stock_id=stock_id, portfolio_id=portfolio_id
319
- )
320
- if not holding:
321
- raise AppException(
322
- status_code=404,
323
- detail="Stock holding for this stock not found in portfolio.",
324
- )
325
 
326
- update_data = stock_data.dict(exclude_unset=True)
327
- # Warning: Updating quantity/purchase_price/purchase_date directly on aggregated holding
328
- # might lead to inconsistencies if not handled with proper recalculation logic.
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
- @router.delete(
356
- "/{portfolio_id}/stocks/{stock_id}",
357
- response_model=ResponseModel,
358
- summary="Delete Stock Holding",
359
- )
360
- async def remove_stock_from_portfolio(
361
  portfolio_id: int,
362
- stock_id: int, # Changed from holding_id to stock_id
363
  current_user=Depends(get_current_user),
364
  ):
365
- try:
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
- success = await PortfolioService.remove_holding(
373
- portfolio_id=portfolio_id,
374
- asset_type_str="STOCK",
375
- asset_id_value=stock_id, # Use stock_id as asset_id_value
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
- @router.post(
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
- utt_data: UTTHoldingCreate,
405
  current_user=Depends(get_current_user),
406
  ):
407
- try:
408
- portfolio = await Portfolio.get_or_none(
409
- id=portfolio_id, user_id=current_user.id, is_active=True
410
- )
411
- if not portfolio:
412
- raise AppException(
413
- status_code=404, detail="Active portfolio not found or access denied"
414
- )
415
-
416
- holding = await PortfolioService.add_utt_to_portfolio(
417
- portfolio_id=portfolio_id,
418
- utt_fund_id=utt_data.utt_fund_id,
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
- "/{portfolio_id}/utts/{utt_fund_id}/sell",
440
- response_model=ResponseModel,
441
- summary="Sell UTT from Portfolio",
442
- )
443
- async def sell_utt_from_portfolio(
444
  portfolio_id: int,
445
- utt_fund_id: int, # Changed from holding_id to utt_fund_id
446
- sell_data: UTTSellSchema,
447
  current_user=Depends(get_current_user),
448
  ):
449
- try:
450
- portfolio = await Portfolio.get_or_none(
451
- id=portfolio_id, user_id=current_user.id, is_active=True
452
- )
453
- if not portfolio:
454
- raise AppException(
455
- status_code=404, detail="Active portfolio not found or access denied"
456
- )
457
-
458
- transaction = await PortfolioService.sell_utt_holding(
459
- portfolio_id=portfolio_id,
460
- utt_fund_id=utt_fund_id, # Use utt_fund_id from path
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}/utts/{utt_fund_id}", response_model=ResponseModel)
483
- async def update_utt_holding(
484
  portfolio_id: int,
485
- utt_fund_id: int, # Changed from holding_id to utt_fund_id
486
- utt_data: UTTHoldingUpdate,
487
  current_user=Depends(get_current_user),
488
  ):
489
- try:
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
- update_data = utt_data.dict(exclude_unset=True)
506
- # Similar caution as with stock update for critical fields.
507
- if (
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
- if update_data:
515
- await holding.update_from_dict(update_data).save()
 
516
 
517
- holding_pydantic_data = await PortfolioUTT_Pydantic.from_tortoise_orm(holding)
518
- return ResponseModel(
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
- "/{portfolio_id}/utts/{utt_fund_id}",
533
- response_model=ResponseModel,
534
- summary="Delete UTT Holding",
535
- )
536
- async def remove_utt_from_portfolio(
537
  portfolio_id: int,
538
- utt_fund_id: int, # Changed from holding_id to utt_fund_id
539
  current_user=Depends(get_current_user),
540
  ):
541
- try:
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
- success = await PortfolioService.remove_holding(
549
- portfolio_id=portfolio_id,
550
- asset_type_str="UTT",
551
- asset_id_value=utt_fund_id, # Use utt_fund_id
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
- # Bond Holdings Routes
 
 
570
 
571
 
572
- @router.post(
573
- "/{portfolio_id}/bonds",
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
- bond_data: BondHoldingCreate, # Assumes bond_data.purchase_price is TOTAL cost
580
  current_user=Depends(get_current_user),
581
  ):
582
- try:
583
- portfolio = await Portfolio.get_or_none(
584
- id=portfolio_id, user_id=current_user.id, is_active=True
585
- )
586
- if not portfolio:
 
 
587
  raise AppException(
588
- status_code=404, detail="Active portfolio not found or access denied"
589
  )
 
590
 
591
- _bond = await Bond.get_or_none(auction_number=bond_data.auction_number)
592
- holding = await PortfolioService.add_bond_to_portfolio(
593
- portfolio_id=portfolio_id,
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
- @router.post(
615
- "/{portfolio_id}/bonds/{bond_id}/sell",
616
- response_model=ResponseModel,
617
- summary="Sell Bond from Portfolio",
618
- )
619
- async def sell_bond_from_portfolio(
620
  portfolio_id: int,
621
- bond_id: int, # Changed from holding_id to bond_id
622
- sell_data: BondSellSchema, # Assumes sell_data.sell_price is TOTAL proceeds
623
  current_user=Depends(get_current_user),
624
  ):
625
- try:
626
- portfolio = await Portfolio.get_or_none(
627
- id=portfolio_id, user_id=current_user.id, is_active=True
628
- )
629
- if not portfolio:
630
- raise AppException(
631
- status_code=404, detail="Active portfolio not found or access denied"
632
- )
633
-
634
- transaction = await PortfolioService.sell_bond_holding(
635
- portfolio_id=portfolio_id,
636
- bond_id=bond_id, # Use bond_id from path
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}", response_model=ResponseModel)
659
  async def update_bond_holding(
660
  portfolio_id: int,
661
- bond_id: int, # Changed from holding_id to bond_id
662
- bond_data: BondHoldingUpdate,
663
  current_user=Depends(get_current_user),
664
  ):
665
- try:
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
- holding = await PortfolioBond.get_or_none(
673
- bond_id=bond_id, portfolio_id=portfolio_id
674
- )
675
- if not holding:
676
- raise AppException(
677
- status_code=404,
678
- detail="Bond holding for this bond not found in portfolio.",
679
- )
680
 
681
- update_data = bond_data.dict(exclude_unset=True)
682
- # Caution: Updating face_value_held or purchase_price (total cost) directly
683
- # should be done carefully. If face_value_held changes, purchase_price (total)
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
- @router.delete(
717
- "/{portfolio_id}/bonds/{bond_id}",
718
- response_model=ResponseModel,
719
- summary="Delete Bond Holding",
720
- )
721
- async def remove_bond_from_portfolio(
722
  portfolio_id: int,
723
- bond_id: int, # Changed from holding_id to bond_id
724
  current_user=Depends(get_current_user),
725
  ):
726
- try:
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
- success = await PortfolioService.remove_holding(
734
- portfolio_id=portfolio_id,
735
- asset_type_str="BOND",
736
- asset_id_value=bond_id, # Use bond_id
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
- # Calendar and Transaction Routes (No changes related to holding_id vs asset_id here)
 
 
755
 
756
 
757
- @router.post("/{portfolio_id}/calendar", response_model=ResponseModel)
758
- async def add_calendar_event(
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
- try:
793
- # 1. VALIDATION AND INITIAL QUERY (Same as before)
794
- portfolio = await Portfolio.get_or_none(
795
- id=portfolio_id, user_id=current_user.id
796
- )
797
- if not portfolio:
798
- raise AppException(status_code=404, detail="Portfolio not found")
799
-
800
- transactions_query = (
801
- PortfolioTransaction.filter(portfolio_id=portfolio_id)
802
- .order_by("-transaction_date", "-created_at")
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
- total_count = await PortfolioTransaction.filter(
864
- portfolio_id=portfolio_id
865
- ).count()
 
866
 
867
- return ResponseModel(
868
- success=True,
869
- message="Transactions retrieved successfully",
870
- data={
871
- # Use the new enriched list
872
- "transactions": [et.model_dump() for et in enriched_transactions],
873
- "total_count": total_count,
874
- "limit": limit,
875
- "offset": offset,
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
- @router.get(
886
- "/{portfolio_id}/positions",
887
- response_model=ResponseModel,
888
- summary="Get All Current Portfolio Positions",
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
- # 2. FETCH AND AGGREGATE TRANSACTIONS
904
  transactions = await PortfolioTransaction.filter(
905
  portfolio_id=portfolio_id
906
  ).order_by("transaction_date")
907
 
908
- # This dictionary will hold the aggregated data for each asset
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
- asset_key = (t.asset_type, t.asset_id)
914
- if asset_key not in aggregated_data:
915
- aggregated_data[asset_key] = {
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
- aggregated_data[asset_key]["buy_qty"] += t.quantity
923
- aggregated_data[asset_key]["buy_cost"] += t.total_amount
924
  elif t.transaction_type == "SELL":
925
- aggregated_data[asset_key]["sell_qty"] += t.quantity
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
- current_quantity = data["buy_qty"] - data["sell_qty"]
 
934
 
935
- # If the asset has been completely sold, skip it.
936
- if current_quantity <= 0:
 
937
  continue
938
 
939
- # Calculate cost basis for the currently held units
940
- avg_buy_price = (
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
- # Fetch current price and asset details based on type
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
- price_data = (
958
- await StockPriceData.filter(stock_id=asset_id)
959
- .order_by("-date")
960
- .first()
961
- )
962
- if price_data:
963
- current_price = price_data.closing_price
964
-
965
- elif asset_type == "UTT":
966
- utt = await UTTFund.get_or_none(id=asset_id)
967
- if utt:
968
- asset_name = utt.name
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.isin
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.price_per_100
987
- else Decimal("100.0")
988
  )
989
 
990
- # Calculate final metrics
991
- current_value = current_quantity * current_price
992
  profit_loss = current_value - total_invested
993
- profit_loss_percent = (
994
- (profit_loss / total_invested) * 100 if total_invested > 0 else 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 successfully.",
1017
- data={"positions": position_responses},
1018
  )
1019
 
1020
 
1021
- @router.post("/{portfolio_id}/snapshot", response_model=ResponseModel)
1022
- async def create_portfolio_snapshot_route( # Renamed
 
 
 
 
 
1023
  portfolio_id: int,
1024
- snapshot_date: Optional[date] = Query(
1025
- None, description="Date for the snapshot. Defaults to today if None."
1026
- ),
1027
  current_user=Depends(get_current_user),
1028
  ):
1029
- try:
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
- snapshot_pydantic_data = await PortfolioSnapshotPydantic.from_tortoise_orm(
1041
- snapshot_orm
1042
- )
1043
-
1044
- return ResponseModel(
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
- "/{portfolio_id}/performance",
1065
- response_model=ResponseModel,
1066
- summary="Get Portfolio Performance Timeseries",
1067
- )
1068
- async def get_portfolio_performance(
1069
  portfolio_id: int,
1070
- background_tasks: BackgroundTasks,
1071
- period: str = Query(
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
- Retrieves time-series performance data for a portfolio.
1080
- If data is missing, it automatically queues a background task to generate
1081
- all historical data and informs the user to wait.
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
- # 3. DEFINE TIME PERIOD & QUERY EXISTING DATA
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
- #### delete snapshots ####
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
- return ResponseModel(
1150
- success=False,
1151
- message="We're preparing your performance history for the first time. This may take a moment.",
1152
- data={"task_id": task.id, "status": "pending"},
1153
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1154
 
1155
- # 5. SUCCESS PATH: This code is only reached if snapshots WERE found.
1156
- if len(snapshots) < 2:
1157
- current_value = snapshots[0]["total_value"]
1158
- return ResponseModel(
1159
- success=True,
1160
- message="Not enough historical data to calculate performance change.",
1161
- data={
1162
- "current_value": str(current_value),
1163
- "change_value": "0.00",
1164
- "change_percentage": 0.0,
1165
- "timeseries": [
1166
- {
1167
- "date": s["snapshot_date"].isoformat(),
1168
- "value": str(s["total_value"]),
1169
- }
1170
- for s in snapshots
1171
- ],
1172
- },
 
 
 
1173
  )
1174
 
1175
- first_value = snapshots[0]["total_value"]
1176
- last_value = snapshots[-1]["total_value"]
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
- return ResponseModel(
1183
- success=True,
1184
- message=f"Performance data for period '{period}' retrieved successfully.",
1185
- data={
1186
- "current_value": str(last_value),
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
- @router.post(
1204
- "/{portfolio_id}/recalculate-timeseries",
1205
- response_model=ResponseModel,
1206
- summary="Recalculate Entire Portfolio Timeseries",
1207
- )
1208
- async def recalculate_portfolio_timeseries(
 
 
 
 
 
 
 
 
 
 
 
 
1209
  portfolio_id: int,
1210
  background_tasks: BackgroundTasks,
 
 
 
1211
  current_user=Depends(get_current_user),
1212
  ):
1213
- """
1214
- Recalculates the entire portfolio timeseries by regenerating all historical snapshots
1215
- from the first transaction date to today. This endpoint:
1216
-
1217
- 1. Validates the portfolio exists and belongs to the user
1218
- 2. Checks if a regeneration task is already running
1219
- 3. Deletes all existing snapshots for the portfolio
1220
- 4. Queues a background task to regenerate snapshots from the earliest transaction
1221
-
1222
- Returns task_id for polling the regeneration status.
1223
- """
1224
- try:
1225
- # 1. AUTHENTICATION & VALIDATION
1226
- portfolio = await Portfolio.get_or_none(
1227
- id=portfolio_id, user_id=current_user.id
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
- # 3. CREATE A NEW TASK AND QUEUE BACKGROUND WORK
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1247
  task = await ImportTask.create(
1248
- task_type="portfolio_regeneration",
1249
  status="pending",
1250
  details={
1251
  "portfolio_id": portfolio_id,
1252
- "reason": "Manual full timeseries recalculation requested by user.",
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.regenerate_snapshots_task, task.id, portfolio_id
1260
  )
 
 
 
 
 
 
 
 
 
 
 
1261
 
 
1262
  return ResponseModel(
1263
  success=True,
1264
- message="Timeseries recalculation started. This may take a few moments.",
1265
  data={
1266
- "task_id": task.id,
1267
- "status": "pending",
1268
- "portfolio_id": portfolio_id,
 
1269
  },
1270
  )
1271
 
1272
- except AppException:
1273
- raise
1274
- except Exception as e:
1275
- raise AppException(
1276
- status_code=500, detail=f"Failed to start timeseries recalculation: {str(e)}"
1277
- )
1278
 
 
 
 
 
 
 
 
 
 
 
1279
 
1280
 
1281
- @router.get(
1282
- "/{portfolio_id}/calendar",
1283
- response_model=ResponseModel,
1284
- summary="Get Upcoming Portfolio Calendar Events",
1285
  )
1286
- async def get_portfolio_calendar_events(
1287
  portfolio_id: int,
1288
- start_date: Optional[date] = Query(
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
- Generates a dynamic calendar of expected income events (dividends and coupons)
1298
- for a user's portfolio within a given date range.
1299
- """
1300
- # 1. SETUP & AUTHENTICATION
1301
- if start_date is None:
1302
- start_date = date.today()
1303
- if end_date is None:
1304
- end_date = start_date + timedelta(days=90)
1305
-
1306
- portfolio = await Portfolio.get_or_none(id=portfolio_id, user_id=current_user.id)
1307
- if not portfolio:
1308
- raise AppException(status_code=404, detail="Portfolio not found")
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
- # 3. PROCESS BOND COUPONS
1345
- # Get all bonds currently held in the portfolio
1346
- portfolio_bonds = await PortfolioBond.filter(
1347
- portfolio_id=portfolio_id
1348
- ).select_related("bond")
1349
- if portfolio_bonds:
1350
- for pb in portfolio_bonds:
1351
- # Use our helper function to calculate coupon dates in the range
1352
- coupon_dates = _calculate_bond_coupon_dates(pb.bond, start_date, end_date)
1353
-
1354
- for coupon_date in coupon_dates:
1355
- # Coupon amount is based on face value and semi-annual rate
1356
- estimated_amount = (
1357
- pb.face_value_held
1358
- * (Decimal(str(pb.bond.coupon_rate)) / Decimal("100"))
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
- # 4. SORT AND RETURN
1372
- # Sort all collected events by date
1373
- sorted_events = sorted(calendar_events, key=lambda x: x.event_date)
1374
 
1375
  return ResponseModel(
1376
  success=True,
1377
- message="Portfolio calendar events retrieved successfully.",
1378
- data={"events": sorted_events, "total_count": len(sorted_events)},
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
- # schemas.py
2
- from pydantic import BaseModel, Field, ConfigDict # Use ConfigDict for Pydantic V2
3
- from typing import Optional, List
 
 
 
4
  from datetime import date, datetime
5
  from decimal import Decimal
6
 
7
 
8
- # --- Portfolio Schemas ---
 
 
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
- ..., min_length=1, max_length=100, description="Name of the portfolio"
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
- None, min_length=1, max_length=100, description="New name for the portfolio"
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
- class PortfolioListResponse(BaseModel):
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
- class StockSellSchema(BaseModel):
107
- quantity: Decimal = Field(..., gt=0, description="Number of shares to sell")
108
- sell_price: Decimal = Field(
109
- ..., gt=0, description="Price per share at which stock was sold"
110
- )
111
- sell_date: date = Field(..., description="Date of the sale")
112
- notes: Optional[str] = Field(
113
- None, description="Additional notes for the sell transaction"
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
- class UTTSellSchema(BaseModel):
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 BondHoldingUpdate(BaseModel):
217
- face_value_held: Optional[Decimal] = Field(
218
- None, gt=0, description="Updated total face value held"
219
- )
220
- purchase_price: Optional[Decimal] = Field(
221
- None,
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
- is_completed: bool = Field(False)
 
 
 
 
 
 
 
 
 
 
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
- model_config = ConfigDict(from_attributes=True)
 
 
 
 
319
 
320
 
321
- # --- Portfolio Analytics & Summary Schemas ---
322
- class AssetAllocation(BaseModel):
323
- stocks_percentage: Decimal = Field(Decimal("0.0"), ge=0, le=100)
324
- bonds_percentage: Decimal = Field(Decimal("0.0"), ge=0, le=100)
325
- utts_percentage: Decimal = Field(Decimal("0.0"), ge=0, le=100)
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
- class CalendarEventResponse(BaseModel):
333
- event_date: date
334
- event_type: str # e.g., "Dividend Payment", "Bond Coupon"
335
- asset_symbol: str
336
- asset_name: str
337
- estimated_amount: Decimal
 
 
 
 
 
 
 
338
  notes: Optional[str] = None
 
 
339
 
340
- model_config = ConfigDict(
341
- from_attributes=True,
342
- )
343
 
344
 
345
- class TransactionDetailResponse(BaseModel):
 
 
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
- # New fields to be added
358
- asset_name: Optional[str] = None
 
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
- model_config = ConfigDict(from_attributes=True)
 
 
 
 
 
 
 
380
 
381
 
382
- class AssetPerformanceDetail(BaseModel):
383
- asset_id: Optional[int] = None
384
- name: str
385
- return_value: Decimal
 
 
 
 
 
 
386
  asset_type: Optional[str] = None
 
 
 
 
 
387
 
388
- model_config = ConfigDict(from_attributes=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # service.py
2
- from typing import List, Optional, Dict, Any
 
 
 
3
  from decimal import Decimal
4
- from datetime import date, datetime, timezone # Added timezone
5
- from tortoise.exceptions import DoesNotExist
 
6
  from tortoise.transactions import in_transaction
7
 
8
- from App.schemas import AppException # Assuming AppException is in App.schemas
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
- UTTHoldingResponse,
32
  BondHoldingResponse,
33
- AssetAllocation,
34
- PortfolioBase,
35
  TransactionResponse,
36
- CalendarEventResponse, # Added PortfolioBase and other response schemas
 
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
- def _calculate_bond_coupon_dates(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  bond: Bond, start_date: date, end_date: date
47
  ) -> Generator[date, None, None]:
48
  """
49
- Calculates the semi-annual coupon payment dates for a bond within a given date range.
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 and bond.coupon_rate > 0:
55
- # First coupon payment month and day
56
- month1 = bond.maturity_date.month
57
- day1 = bond.maturity_date.day
58
-
59
- # Second coupon payment is 6 months from the first
60
- month2 = (
61
- month1 + 5
62
- ) % 12 + 1 # +5 then %12 handles the 6-month offset correctly
63
-
64
- # Iterate through years from the bond's issue to maturity
65
- for year in range(bond.effective_date.year, bond.maturity_date.year + 1):
 
 
66
  try:
67
- # Construct the two potential coupon dates for the year
68
- coupon_date1 = date(year, month1, day1)
69
- coupon_date2 = date(year, month2, day1) # Day is assumed the same
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: int, include_inactive: bool = False
86
- ) -> List[Portfolio]:
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: int, name: str, description: Optional[str] = None
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
- """Get comprehensive portfolio summary with all holdings and calculations"""
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
- # Calculate total market values
121
- total_stock_value = sum(
122
- h.market_value or Decimal("0") for h in stock_holdings_resp
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
- # Calculate total cost basis
133
- # For stocks/UTTs, purchase_price is average unit price on the aggregated holding.
134
- total_stock_cost = sum(
135
- h.purchase_price * h.quantity for h in stock_holdings_resp
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
- # Get recent transactions
151
- recent_transactions_orm = (
 
 
 
 
 
 
 
152
  await PortfolioTransaction.filter(portfolio_id=portfolio_id)
153
  .order_by("-transaction_date", "-created_at")
154
  .limit(10)
155
- .all()
156
  )
157
- recent_transactions_resp = [
158
- TransactionResponse.from_orm(t) for t in recent_transactions_orm
159
- ]
160
 
161
- # Get upcoming events
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=portfolio_base,
203
- total_market_value=total_market_value,
204
- total_cost_basis=total_cost_basis,
205
- overall_unrealized_gain_loss=overall_unrealized_gain_loss,
206
- overall_unrealized_gain_loss_percentage=overall_unrealized_gain_loss_percentage,
207
- stock_holdings=stock_holdings_resp,
208
- utt_holdings=utt_holdings_resp,
209
- bond_holdings=bond_holdings_resp,
210
- asset_allocation=asset_alloc,
211
- recent_transactions=recent_transactions_resp,
212
- upcoming_events=upcoming_events_resp,
 
 
 
 
 
213
  )
214
 
 
 
215
  @staticmethod
216
- async def _get_stock_holdings_with_values(
217
- portfolio_id: int,
218
- ) -> List[StockHoldingResponse]:
219
- holdings_orm = (
220
- await PortfolioStock.filter(portfolio_id=portfolio_id)
221
  .prefetch_related("stock")
222
  .all()
223
  )
224
- results = []
225
- for holding in holdings_orm: # holding is now an aggregated record
226
- latest_price_data = (
227
- await StockPriceData.filter(stock_id=holding.stock_id)
228
- .order_by("-date")
229
- .first()
230
- )
231
- current_price = (
232
- latest_price_data.closing_price if latest_price_data else None
233
- )
 
234
 
 
 
 
235
  market_value = (
236
- (current_price * holding.quantity)
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=holding.id, # This ID is of the PortfolioStock record itself
254
- stock_id=holding.stock.id,
255
- stock_symbol=holding.stock.symbol,
256
- stock_name=holding.stock.name,
257
- quantity=holding.quantity,
258
- purchase_price=holding.purchase_price, # Average unit purchase price
259
- purchase_date=holding.purchase_date, # Date of first/last buy or as defined
260
  current_price=current_price,
261
  market_value=market_value,
262
- gain_loss=gain_loss,
263
- gain_loss_percentage=gain_loss_percentage,
264
- notes=holding.notes,
265
- created_at=holding.created_at,
266
  )
267
  )
268
  return results
269
 
270
  @staticmethod
271
- async def _get_utt_holdings_with_values(
272
- portfolio_id: int,
273
- ) -> List[UTTHoldingResponse]:
274
- holdings_orm = (
275
- await PortfolioUTT.filter(portfolio_id=portfolio_id)
276
- .prefetch_related("utt_fund")
277
  .all()
278
  )
279
- results = []
280
- for holding in holdings_orm: # holding is now an aggregated record
281
- latest_nav_data = (
282
- await UTTFundData.filter(fund_id=holding.utt_fund_id)
283
- .order_by("-date")
284
- .first()
285
- )
286
- current_nav = latest_nav_data.nav_per_unit if latest_nav_data else None
 
287
 
 
 
 
288
  market_value = (
289
- (Decimal(current_nav) * holding.units_held)
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
- UTTHoldingResponse(
306
- id=holding.id, # This ID is of the PortfolioUTT record itself
307
- utt_fund_id=holding.utt_fund.id,
308
- fund_symbol=holding.utt_fund.symbol,
309
- fund_name=holding.utt_fund.name,
310
- units_held=holding.units_held,
311
- purchase_price=holding.purchase_price, # Average unit purchase price
312
- purchase_date=holding.purchase_date, # Date of first/last buy or as defined
313
  current_nav=current_nav,
314
  market_value=market_value,
315
- gain_loss=gain_loss,
316
- gain_loss_percentage=gain_loss_percentage,
317
- notes=holding.notes,
318
- created_at=holding.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 add_stock_to_portfolio(
376
  portfolio_id: int,
377
  stock_id: int,
378
- quantity_to_add: Decimal, # Quantity for this specific purchase
379
- purchase_price_of_lot: Decimal, # Unit price for this specific purchase
380
  purchase_date: date,
381
  notes: Optional[str] = None,
382
  ) -> PortfolioStock:
383
- stock_obj = await Stock.get_or_none(id=stock_id)
384
-
385
- if not stock_obj:
386
- raise DoesNotExist("Stock not found")
387
- if quantity_to_add <= 0:
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
- # Update existing aggregated holding
399
- new_total_cost = (holding.quantity * holding.purchase_price) + (
400
- quantity_to_add * purchase_price_of_lot
 
 
 
 
401
  )
402
- holding.quantity += quantity_to_add
403
- if holding.quantity > 0:
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=stock_obj,
421
- quantity=quantity_to_add,
422
- purchase_price=purchase_price_of_lot, # Initial average price is this lot's price
423
  purchase_date=purchase_date,
424
- notes=notes,
425
  )
426
 
427
- await PortfolioTransaction.create(
428
  portfolio_id=portfolio_id,
429
- transaction_type="BUY",
430
  asset_type="STOCK",
431
- asset_id=stock_obj.id,
432
- asset_name=stock_obj.symbol,
433
- quantity=quantity_to_add,
434
- price=purchase_price_of_lot,
435
- total_amount=quantity_to_add * purchase_price_of_lot,
436
- transaction_date=purchase_date,
437
- notes=notes or f"Bought {quantity_to_add} shares of {stock_obj.symbol}",
438
  )
439
  return holding
440
 
441
  @staticmethod
442
- async def sell_stock_holding(
443
  portfolio_id: int,
444
- stock_id: int, # This is the asset_id
445
- quantity_to_sell: Decimal,
446
  sell_price: Decimal,
447
  sell_date: date,
448
  notes: Optional[str] = None,
449
  ) -> PortfolioTransaction:
450
- # Fetch the stock object to ensure it exists (optional, but good practice)
451
- # stock_obj = await Stock.get_or_none(id=stock_id)
452
- # if not stock_obj:
453
- # raise DoesNotExist("Stock definition not found.")
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 DoesNotExist("Stock holding not found in this portfolio.")
464
- if quantity_to_sell <= 0:
465
- raise AppException(
466
- status_code=400, detail="Quantity to sell must be positive."
467
- )
468
- if holding.quantity < quantity_to_sell:
469
  raise AppException(
470
  status_code=400,
471
- detail=f"Not enough shares to sell. Currently hold {holding.quantity}, trying to sell {quantity_to_sell}.",
472
  )
473
 
474
  async with in_transaction():
475
- transaction = await PortfolioTransaction.create(
476
  portfolio_id=portfolio_id,
477
- transaction_type="SELL",
478
  asset_type="STOCK",
479
- asset_id=holding.stock.id, # stock_id
480
  asset_name=holding.stock.symbol,
481
- quantity=quantity_to_sell,
482
  price=sell_price,
483
- total_amount=quantity_to_sell * sell_price,
484
- transaction_date=sell_date,
485
- notes=notes
486
- or f"Sold {quantity_to_sell} shares of {holding.stock.symbol}",
487
  )
488
- holding.quantity -= quantity_to_sell
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 transaction
 
 
495
 
496
  @staticmethod
497
- async def add_utt_to_portfolio(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
  portfolio_id: int,
499
- utt_fund_id: int,
500
- units_to_add: Decimal, # Units for this specific purchase
501
- purchase_price_of_lot: Decimal, # Unit price for this specific purchase
502
  purchase_date: date,
503
  notes: Optional[str] = None,
504
  ) -> PortfolioUTT:
505
- utt_fund_obj = await UTTFund.get_or_none(id=utt_fund_id)
506
- if not utt_fund_obj:
507
- raise DoesNotExist("UTT Fund not found")
508
- if units_to_add <= 0:
509
- raise AppException(status_code=400, detail="Units to add must be positive.")
510
 
511
  async with in_transaction():
512
  holding = await PortfolioUTT.get_or_none(
513
- portfolio_id=portfolio_id, utt_fund_id=utt_fund_id
514
  )
515
-
516
  if holding:
517
- # Update existing aggregated holding
518
- new_total_cost = (holding.units_held * holding.purchase_price) + (
519
- units_to_add * purchase_price_of_lot
 
 
 
 
520
  )
521
- holding.units_held += units_to_add
522
- if holding.units_held > 0:
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
- utt_fund=utt_fund_obj,
540
- units_held=units_to_add,
541
- purchase_price=purchase_price_of_lot, # Initial average price
542
  purchase_date=purchase_date,
543
- notes=notes,
544
  )
545
 
546
- await PortfolioTransaction.create(
547
  portfolio_id=portfolio_id,
548
- transaction_type="BUY",
549
- asset_type="UTT",
550
- asset_id=utt_fund_obj.id,
551
- asset_name=utt_fund_obj.symbol,
552
- quantity=units_to_add,
553
- price=purchase_price_of_lot,
554
- total_amount=units_to_add * purchase_price_of_lot,
555
- transaction_date=purchase_date,
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 sell_utt_holding(
562
  portfolio_id: int,
563
- utt_fund_id: int, # Changed from holding_id to asset_id
564
- units_to_sell: Decimal,
565
  sell_price: Decimal,
566
  sell_date: date,
567
  notes: Optional[str] = None,
568
  ) -> PortfolioTransaction:
569
- holding = await PortfolioUTT.get_or_none(
570
- portfolio_id=portfolio_id, utt_fund_id=utt_fund_id
571
- ).prefetch_related("utt_fund")
572
-
 
573
  if not holding:
574
- raise DoesNotExist("UTT holding not found for this fund in the portfolio.")
575
- if units_to_sell <= 0:
576
- raise AppException(
577
- status_code=400, detail="Units to sell must be positive."
578
- )
579
- if holding.units_held < units_to_sell:
580
  raise AppException(
581
  status_code=400,
582
- detail=f"Not enough units to sell. Currently hold {holding.units_held}, trying to sell {units_to_sell}.",
583
  )
584
 
585
  async with in_transaction():
586
- transaction = await PortfolioTransaction.create(
587
  portfolio_id=portfolio_id,
588
- transaction_type="SELL",
589
- asset_type="UTT",
590
- asset_id=holding.utt_fund.id, # This is utt_fund_id
591
- asset_name=holding.utt_fund.symbol,
592
- quantity=units_to_sell,
593
  price=sell_price,
594
- total_amount=units_to_sell * sell_price,
595
- transaction_date=sell_date,
596
- notes=notes
597
- or f"Sold {units_to_sell} units of {holding.utt_fund.symbol}",
598
  )
599
- holding.units_held -= units_to_sell
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 transaction
 
 
 
 
 
 
606
 
607
  @staticmethod
608
- async def add_bond_to_portfolio(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
  portfolio_id: int,
610
  bond_id: int,
611
- face_value_to_add: Decimal, # Face value for this specific purchase
612
- total_purchase_price_of_lot: Decimal, # TOTAL purchase price for this face_value_to_add
613
  purchase_date: date,
614
  notes: Optional[str] = None,
615
  ) -> PortfolioBond:
616
- bond_obj = await Bond.get_or_none(id=bond_id)
617
- if not bond_obj:
618
- raise DoesNotExist("Bond not found")
619
- if face_value_to_add <= 0:
620
- raise AppException(
621
- status_code=400, detail="Face value to add must be positive."
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
- # Update existing aggregated holding
631
- holding.face_value_held += face_value_to_add
632
- holding.purchase_price += (
633
- total_purchase_price_of_lot # Add total cost to existing total cost
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=bond_obj,
647
- face_value_held=face_value_to_add,
648
- purchase_price=total_purchase_price_of_lot, # Storing total cost for this initial lot
649
  purchase_date=purchase_date,
650
- notes=notes,
651
  )
652
 
653
- unit_price_for_transaction = (
654
- total_purchase_price_of_lot / face_value_to_add
655
- if face_value_to_add > 0
656
- else Decimal("0")
657
  )
658
- await PortfolioTransaction.create(
659
  portfolio_id=portfolio_id,
660
- transaction_type="BUY",
661
  asset_type="BOND",
662
- asset_id=bond_obj.id,
663
- asset_name=f"Bond {bond_obj.auction_number or bond_obj.id}",
664
- quantity=face_value_to_add,
665
- price=unit_price_for_transaction,
666
- total_amount=total_purchase_price_of_lot,
667
- transaction_date=purchase_date,
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 sell_bond_holding(
675
  portfolio_id: int,
676
- bond_id: int, # Changed from holding_id to asset_id
677
- face_value_to_sell: Decimal,
678
- sell_price_total: Decimal, # This is TOTAL proceeds for the face_value_to_sell
679
  sell_date: date,
680
  notes: Optional[str] = None,
681
  ) -> PortfolioTransaction:
682
- holding = await PortfolioBond.get_or_none(
683
- portfolio_id=portfolio_id, bond_id=bond_id
684
- ).prefetch_related("bond")
685
-
 
686
  if not holding:
687
- raise DoesNotExist("Bond holding not found for this bond in the portfolio.")
688
- if face_value_to_sell <= 0:
689
- raise AppException(
690
- status_code=400, detail="Face value to sell must be positive."
691
- )
692
- if holding.face_value_held < face_value_to_sell:
693
  raise AppException(
694
  status_code=400,
695
- detail=f"Not enough face value to sell. Currently hold {holding.face_value_held}, trying to sell {face_value_to_sell}.",
696
  )
697
 
 
 
698
  async with in_transaction():
699
- unit_sell_price = (
700
- sell_price_total / face_value_to_sell
701
- if face_value_to_sell > 0
702
- else Decimal("0")
703
  )
704
-
705
- transaction = await PortfolioTransaction.create(
706
  portfolio_id=portfolio_id,
707
- transaction_type="SELL",
708
  asset_type="BOND",
709
- asset_id=holding.bond.id, # This is bond_id
710
- asset_name=f"Bond {holding.bond.auction_number or holding.bond.id}",
711
- quantity=face_value_to_sell,
712
- price=unit_sell_price,
713
- total_amount=sell_price_total,
714
- transaction_date=sell_date,
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
- original_face_value_held = holding.face_value_held
720
- original_total_purchase_price = holding.purchase_price
721
-
722
- holding.face_value_held -= face_value_to_sell
723
 
724
- if holding.face_value_held == Decimal(
725
- "0"
726
- ): # Ensure exact zero comparison for Decimal
727
  await holding.delete()
728
  else:
729
- # Update the total purchase_price proportionally for the remaining face_value_held
730
- if original_face_value_held > 0:
731
- holding.purchase_price = (
732
- holding.face_value_held / original_face_value_held
733
- ) * original_total_purchase_price
734
- else:
735
- holding.purchase_price = Decimal(
736
- "0"
737
- ) # Should not be reached if logic is correct
738
  await holding.save()
739
- return transaction
 
 
740
 
741
  @staticmethod
742
  async def remove_holding(
743
- portfolio_id: int, asset_type_str: str, asset_id_value: int
744
  ) -> bool:
745
- """
746
- Remove an aggregated holding from portfolio. This is a hard delete.
747
- asset_id_value corresponds to stock_id, utt_fund_id, or bond_id.
748
- """
749
- model_to_delete = None
750
- asset_id_field_name = None
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, detail=f"Unknown asset type: {asset_type_str}"
764
  )
 
 
 
 
 
765
 
766
- filter_kwargs = {
767
- "portfolio_id": portfolio_id,
768
- asset_id_field_name: asset_id_value,
769
- }
770
- deleted_count = await model_to_delete.filter(**filter_kwargs).delete()
771
- return deleted_count > 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772
 
773
  @staticmethod
774
- async def create_portfolio_snapshot(
775
- portfolio_id: int, snapshot_date_input: Optional[date] = None
776
  ) -> PortfolioSnapshot:
777
- """
778
- Creates or updates a daily snapshot of portfolio performance for a specific date.
779
-
780
- This function correctly calculates historical values by:
781
- 1. Determining the holdings that existed in the portfolio on the target_date.
782
- 2. Fetching the last known market price for each of those holdings as of the target_date.
783
- 3. Aggregating the values to create a point-in-time snapshot.
784
- """
785
- target_date: date = date.today()
786
- if snapshot_date_input:
787
- if isinstance(snapshot_date_input, datetime):
788
- target_date = snapshot_date_input.date()
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
- if price_data and price_data.closing_price is not None:
816
- holding_market_value = (
817
- Decimal(holding.quantity) * price_data.closing_price
818
- )
819
- stock_val += holding_market_value
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
- if price_data and price_data.nav_per_unit is not None:
840
- # Safely convert float to Decimal
841
- holding_market_value = holding.units_held * Decimal(
842
- str(price_data.nav_per_unit)
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
- # --- Create or Update the snapshot for the target_date ---
867
- # This prevents duplicate snapshots if the task runs multiple times.
868
- snapshot_datetime = datetime.combine(target_date, datetime.min.time())
869
 
870
- snapshot, created = await PortfolioSnapshot.update_or_create(
871
  portfolio_id=portfolio_id,
872
- snapshot_date=snapshot_datetime,
873
  defaults={
874
- "total_value": total_market_value,
875
  "stock_value": stock_val,
876
  "bond_value": bond_val,
877
- "utt_value": utt_val,
878
- "cash_value": Decimal("0.0"), # Assuming cash isn't tracked yet
879
- "total_cost": total_cost_basis,
880
- "unrealized_gain_loss": 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 regenerate_snapshots_task(
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
- first_transaction = (
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
- # 2. INVALIDATE: Delete all stale snapshots in the date range to ensure a clean slate.
940
- start_datetime = datetime.combine(start_date, datetime.min.time())
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
- generated_count = 0
955
  failed_days = []
956
- for single_date in date_range(start_date, end_date):
 
957
  try:
958
- # This calls the other service method responsible for calculating and saving
959
- # a single day's snapshot.
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
- # If one day fails (e.g., missing price data), log it and continue.
969
- failed_days.append(single_date.isoformat())
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", details=summary
 
 
 
 
 
 
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}] FAILED with a fatal error: {e}")
 
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
- # Add or ensure these imports are present
2
- from fastapi import BackgroundTasks
3
- from .service import PortfolioService # Ensure service is imported
4
- from App.routers.tasks.models import ImportTask
5
- from tortoise.expressions import Q # For querying JSON fields
6
- from datetime import date
 
7
 
8
 
9
- async def trigger_regeneration_if_needed(
10
- background_tasks: BackgroundTasks,
11
- portfolio_id: int,
12
- transaction_date: date,
13
- reason: str,
14
- ):
15
- """Checks if a transaction is back-dated and queues the regeneration task."""
16
- if transaction_date < date.today():
17
- task = await ImportTask.create(
18
- task_type="portfolio_regeneration",
19
- status="pending",
20
- details={
21
- "portfolio_id": portfolio_id,
22
- "reason": reason,
23
- "start_date": transaction_date.isoformat(),
24
- },
25
- )
26
- background_tasks.add_task(
27
- PortfolioService.regenerate_snapshots_task,
28
- task.id,
29
- portfolio_id,
30
- transaction_date,
31
- )
32
- return "Holding saved. Historical performance data is being updated in the background."
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.schemas import AppException
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(symbol: str, background_tasks: BackgroundTasks):
 
 
 
 
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
- Alternative using Tortoise ORM - less efficient but more ORM-friendly
46
  """
47
  try:
48
- # Get all stocks
49
  stocks = await Stock.all()
50
-
51
  if not stocks:
52
- raise AppException(status_code=404, detail="No stocks found")
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
- if latest_price:
67
- latest_prices[stock_id] = latest_price
68
-
69
- # Build the response
70
  stock_list = []
71
  for stock in stocks:
72
- latest = latest_prices.get(stock.id)
 
 
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, detail=f"Error retrieving stocks: {str(e)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if time_range != "max":
 
115
  today = date.today()
116
- if time_range == "1w":
117
- start_date = today - timedelta(weeks=1)
118
- elif time_range == "1m":
 
 
119
  start_date = today - timedelta(days=30)
120
- elif time_range == "1y":
 
 
121
  start_date = today - timedelta(days=365)
122
- elif time_range == "ytd":
 
 
 
 
123
  start_date = date(today.year, 1, 1)
124
- prices_queryset = prices_queryset.filter(date__gte=start_date)
 
 
 
 
 
 
 
 
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 httpx
2
 
3
 
4
- async def fetch_dse_stock_data(symbol: str, days: int = 3000):
5
- url = f"https://dse.co.tz/api/get/market/prices/for/range/duration?security_code={symbol}&days={days}&class=EQUITY"
6
- print(url)
7
- async with httpx.AsyncClient() as client:
8
- resp = await client.get(url)
9
- return resp.json()
 
 
 
 
 
 
 
 
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
- data = await fetch_dse_stock_data(symbol)
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(first["company"], first["fullName"])
27
- await bulk_insert_price_data(stock, raw)
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, HTTPException
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 HTTPException(status_code=404, detail="Task not found")
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
- from tortoise.contrib.pydantic.creator import pydantic_model_creator, pydantic_queryset_creator
3
- from tortoise.queryset import QuerySet
4
 
5
  class User(models.Model):
6
- id = fields.IntField(pk=True)
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
- @staticmethod
13
- async def get_list(data):
14
- if type(data) == QuerySet:
15
- parser=pydantic_queryset_creator(User)
16
- return await parser.from_queryset(data)
17
-
18
 
19
-
20
- async def to_dict(self):
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
- utt = fields.ForeignKeyField("models.UTTFund", null=True, related_name="watching")
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
- async def to_dict(self):
40
- if type(self) == models.Model:
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
- from fastapi import APIRouter, HTTPException, Depends
 
 
 
 
 
 
 
 
2
  from .models import User, Watchlist
3
  from App.routers.portfolio.models import Portfolio
4
- from .schemas import UserCreate, UserResponse, PortfolioItemSchema, WatchlistItemSchema, UserLogin
5
- from App.schemas import ResponseModel
6
- from tortoise.contrib.pydantic import pydantic_model_creator, pydantic_queryset_creator
7
- from passlib.hash import bcrypt
8
- from App.schemas import AppException
 
 
 
 
 
 
 
 
 
 
 
 
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
- @router.post("/login", response_model=ResponseModel)
18
- async def login(user: UserLogin):
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(user: UserCreate):
36
- existing = await User.get_or_none(email=user.email)
37
- if existing:
38
- raise AppException(status_code=400, detail=ResponseModel(success=False, message="Email already registered"))
39
- user_obj = await User.create(
40
- username=user.username,
41
- email=user.email,
42
- hashed_password=bcrypt.hash(user.password)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  )
44
- await Portfolio.create(user=user_obj, name="Default Portfolio")
45
- return ResponseModel(success=True, message="User created", data=await User_Pydantic.from_tortoise_orm(user_obj))
46
-
47
- @router.get("/{user_id}/portfolio", response_model=ResponseModel)
48
- async def get_portfolio(user_id: int):
49
- portfolios = Portfolio.filter(user=user_id).all()
50
- _portfolios = await Portfolio_Pydantic.from_queryset(portfolios)
51
- return ResponseModel(success=True, message="Portfolio retrieved", data=_portfolios.model_dump())
52
-
53
- @router.post("/{user_id}/portfolio", response_model=ResponseModel)
54
- async def create_portfolio(user_id: int, data: PortfolioItemSchema):
55
- # Check if user exists
56
- user = await User.get_or_none(id=user_id)
57
- if not user:
58
- raise HTTPException(status_code=404, detail="User not found")
59
-
60
- # Create a new portfolio
61
- portfolio = await Portfolio.create(
62
- user=user,
63
- name=data.name
64
  )
65
- _portfolio=Portfolio_one_Pydantic.from_orm(portfolio)
66
- return ResponseModel(success=True, message="Portfolio created", data=_portfolio.model_dump())
67
-
68
- @router.post("/{user_id}/watchlist/add", response_model=ResponseModel)
69
- async def add_to_watchlist(user_id: int, item: WatchlistItemSchema):
70
- new_item = await Watchlist.create(
71
- user_id=user_id,
72
- stock_id=item.stock_id,
73
- utt_id=item.utt_id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: int
15
  username: str
16
  email: str
 
17
 
18
- class PortfolioItemSchema(BaseModel):
19
- name:str
20
 
21
- class WatchlistItemSchema(BaseModel):
22
- stock_id: Optional[int]
23
- utt_id: Optional[int]
24
 
25
- class ResponseModel(BaseModel):
26
- success: bool
27
- message: str
28
- data: Optional[dict] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- async def get_current_user(user_id: int):
7
- """Get current user by ID"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  user = await User.get_or_none(id=user_id)
9
  if not user:
10
- raise AppException(status_code=404, detail="User not found")
11
- return user ## can you implement this function
 
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("/", response_model=ResponseModel)
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, detail="No UTT funds found")
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
- latest.repurchase_price_per_unit if latest else None
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(symbol: str):
 
 
 
 
 
 
76
  fund = await UTTFund.get_or_none(symbol=symbol)
77
  if not fund:
78
- raise AppException(status_code=404, detail="Fund not found")
79
- data_queryset = UTTFundData.filter(fund=fund).order_by("-date").limit(100)
80
- utt_fund_data_pydantic = await UTTFundData_Pydantic_List.from_queryset(
81
- data_queryset
82
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
  return ResponseModel(
85
  success=True,
86
  message="Fund data",
87
- data={"data": utt_fund_data_pydantic.model_dump()},
 
 
 
 
 
 
 
 
 
 
 
 
 
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 Optional, Any
3
  from fastapi import HTTPException
4
 
5
 
6
  class ResponseModel(BaseModel):
7
  success: bool
8
  message: str
9
- data: Optional[Any] = None
10
 
11
 
12
  class AppException(HTTPException):
13
- def __init__(self, status_code: int = 400, detail: str | ResponseModel = None):
14
- if isinstance(detail, ResponseModel):
15
- super().__init__(status_code=status_code, detail=detail.message)
16
- self.data = detail.data
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, # Start with a small pool, e.g., 1-5 connections
22
- "max_size": 10, # Adjust based on expected load and Supabase limits. Common values: 10-50
23
- "timeout": 30, # Connection timeout in seconds [16]
24
- # "ssl": True, # Enable SSL if required by Supabase for production
25
- # "statement_cache_size": 0, # Optional: Keep for completeness if other issues arise, but primary fix is connection mode
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.utt.models",
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.utt.routes import router as utt_router
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(success=False, message=exc.detail, data=exc.data).dict(),
 
 
 
 
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(utt_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:8001/users/register", json={
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"