Mbonea commited on
Commit
9d4bd7c
·
0 Parent(s):

initial commit

Browse files
Files changed (49) hide show
  1. .gitignore +32 -0
  2. App/__init__.py +0 -0
  3. App/requirements.txt +45 -0
  4. App/routers/bonds/__init__.py +3 -0
  5. App/routers/bonds/models.py +44 -0
  6. App/routers/bonds/routes.py +126 -0
  7. App/routers/bonds/schemas.py +24 -0
  8. App/routers/bonds/service.py +23 -0
  9. App/routers/bonds/utils.py +258 -0
  10. App/routers/portfolio/models.py +183 -0
  11. App/routers/portfolio/routes.py +1301 -0
  12. App/routers/portfolio/schemas.py +419 -0
  13. App/routers/portfolio/service.py +996 -0
  14. App/routers/portfolio/utils.py +33 -0
  15. App/routers/stocks/crud.py +134 -0
  16. App/routers/stocks/metrics.py +47 -0
  17. App/routers/stocks/models.py +97 -0
  18. App/routers/stocks/routes.py +232 -0
  19. App/routers/stocks/schemas.py +48 -0
  20. App/routers/stocks/service.py +9 -0
  21. App/routers/stocks/utils.py +258 -0
  22. App/routers/tasks/models.py +19 -0
  23. App/routers/tasks/routes.py +26 -0
  24. App/routers/tasks/schemas.py +23 -0
  25. App/routers/users/models.py +42 -0
  26. App/routers/users/routes.py +70 -0
  27. App/routers/users/schemas.py +28 -0
  28. App/routers/users/utils.py +11 -0
  29. App/routers/utt/models.py +48 -0
  30. App/routers/utt/routes.py +97 -0
  31. App/routers/utt/schemas.py +21 -0
  32. App/routers/utt/service.py +127 -0
  33. App/routers/utt/utils.py +27 -0
  34. App/schemas.py +25 -0
  35. Dockerfile +77 -0
  36. db.py +43 -0
  37. main.py +73 -0
  38. migrations/models/0_20250525140513_init.py +174 -0
  39. pyproject.toml +4 -0
  40. pytest.ini +3 -0
  41. readme.md +8 -0
  42. requirements.txt +25 -0
  43. structure.txt +388 -0
  44. tests/conftest.py +45 -0
  45. tests/test_portfolio.py +193 -0
  46. tests/test_stocks.py +51 -0
  47. tests/test_users.py +69 -0
  48. tests/test_utt.py +48 -0
  49. vercel.json +15 -0
.gitignore ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .vercel
2
+ *.pyc
3
+ venv/
4
+ __pycache__/
5
+ # Ignore Python bytecode files
6
+ *.pyo
7
+ # Ignore Python cache directories
8
+ __pycache__/
9
+ # Ignore virtual environment directories
10
+ .env
11
+ # Ignore Jupyter Notebook checkpoints
12
+ .ipynb_checkpoints/
13
+ # Ignore log files
14
+ *.log
15
+ # Ignore coverage reports
16
+ .coverage
17
+ # Ignore build directories
18
+ build/
19
+ dist/
20
+ # Ignore package distribution files
21
+ *.egg-info/
22
+ # Ignore IDE/editor specific files
23
+ .vscode/
24
+ .idea/
25
+ # Ignore system files
26
+ .DS_Store
27
+ # Ignore environment variable files
28
+ .env.local
29
+ # Ignore Python egg files
30
+ *.egg
31
+ *.sql*
32
+ bash.exe.stackdump
App/__init__.py ADDED
File without changes
App/requirements.txt ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ aiosqlite==0.17.0
2
+ anyio==3.7.1
3
+ asgiref==3.8.1
4
+ asynctest==0.13.0
5
+ bcrypt==4.0.1
6
+ beautifulsoup4==4.13.4
7
+ certifi==2025.1.31
8
+ cffi==1.17.1
9
+ charset-normalizer==3.4.1
10
+ click==8.1.8
11
+ colorama==0.4.6
12
+ curl_cffi==0.11.1
13
+ dnspython==2.7.0
14
+ email_validator==2.2.0
15
+ fastapi==0.95.2
16
+ h11==0.12.0
17
+ httpcore==0.15.0
18
+ httpx==0.23.0
19
+ idna==3.10
20
+ iniconfig==2.1.0
21
+ iso8601==1.1.0
22
+ numpy==1.24.3
23
+ packaging==24.2
24
+ pandas==2.2.3
25
+ passlib==1.7.4
26
+ pluggy==1.5.0
27
+ pycparser==2.22
28
+ pydantic==1.10.7
29
+ pypika-tortoise==0.1.6
30
+ pytest==7.3.1
31
+ pytest-asyncio==0.21.0
32
+ python-dateutil==2.9.0.post0
33
+ python-multipart==0.0.5
34
+ pytz==2025.2
35
+ requests==2.31.0
36
+ rfc3986==1.5.0
37
+ six==1.17.0
38
+ sniffio==1.3.1
39
+ soupsieve==2.7
40
+ starlette==0.27.0
41
+ tortoise-orm==0.19.3
42
+ typing_extensions==4.13.1
43
+ tzdata==2025.2
44
+ urllib3==2.3.0
45
+ uvicorn==0.22.0
App/routers/bonds/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .routes import router
2
+
3
+ __all__ = ["router"]
App/routers/bonds/models.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from tortoise import fields, models
2
+ from tortoise.contrib.pydantic import pydantic_model_creator
3
+ from tortoise.contrib.pydantic.creator import pydantic_queryset_creator
4
+ from tortoise.queryset import QuerySet
5
+
6
+ class Bond(models.Model):
7
+ id = fields.IntField(pk=True)
8
+ instrument_type = fields.CharField(max_length=50)
9
+ auction_number = fields.IntField()
10
+ auction_date = fields.DateField()
11
+ maturity_years = fields.CharField(max_length=10)
12
+ maturity_date = fields.DateField()
13
+ effective_date = fields.DateField()
14
+ dtm = fields.IntField()
15
+ bond_auction_number = fields.IntField()
16
+ holding_number = fields.IntField()
17
+ face_value = fields.BigIntField()
18
+ price_per_100 = fields.FloatField()
19
+ coupon_rate = fields.FloatField()
20
+ isin = fields.CharField(max_length=12, unique=True)
21
+
22
+ @staticmethod
23
+ async def get_list(data):
24
+
25
+ if type(data) == QuerySet:
26
+ parser=pydantic_queryset_creator(Bond)
27
+ return await parser.from_queryset(data)
28
+
29
+ if type(data) == type([Bond]):
30
+ return [ await i.to_dict() for i in data]
31
+
32
+
33
+ async def to_dict(self):
34
+ if type(self) == Bond:
35
+ parser=pydantic_model_creator(Bond)
36
+ return await parser.from_tortoise_orm(self)
37
+
38
+
39
+
40
+
41
+
42
+ class Meta:
43
+ table = "bonds"
44
+ unique_together = ("auction_number", "auction_date")
App/routers/bonds/routes.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:
20
+ existing_bond = await Bond.get_or_none(isin=payload.isin)
21
+
22
+ if not existing_bond:
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 ---
52
+ async def run_bond_import_task(task_id: int):
53
+ await ImportTask.filter(id=task_id).update(status="running")
54
+ scraper = BondDataScraper()
55
+ created_count = 0
56
+ updated_count = 0
57
+ failed_count = 0
58
+ processed_isins = set()
59
+
60
+ try:
61
+ async for bond_data in scraper.scrape_all_bond_data():
62
+ if not bond_data:
63
+ failed_count += 1
64
+ continue
65
+
66
+ if bond_data.isin and bond_data.isin in processed_isins:
67
+ print(f"Skipping duplicate ISIN in current scrape: {bond_data.isin}")
68
+ continue
69
+
70
+ async with in_transaction(): # Ensure atomicity for each bond
71
+ try:
72
+ # Use ISIN as primary unique key if available, otherwise fallback
73
+ existing_bond = None
74
+ if bond_data.isin:
75
+ existing_bond = await Bond.get_or_none(isin=bond_data.isin)
76
+
77
+ if not existing_bond: # Fallback check
78
+ existing_bond = await Bond.get_or_none(
79
+ auction_number=bond_data.auction_number,
80
+ auction_date=bond_data.auction_date,
81
+ # Add holding_number or bond_auction_number if they form part of a unique key
82
+ holding_number=bond_data.holding_number
83
+ )
84
+
85
+ if existing_bond:
86
+ await Bond.filter(id=existing_bond.id).update(**bond_data.dict(exclude_unset=True))
87
+ updated_count += 1
88
+ print(f"Updated bond: ISIN {bond_data.isin}, AuNo {bond_data.auction_number}")
89
+ else:
90
+ await Bond.create(**bond_data.dict())
91
+ created_count += 1
92
+ print(f"Created bond: ISIN {bond_data.isin}, AuNo {bond_data.auction_number}")
93
+
94
+ if bond_data.isin:
95
+ processed_isins.add(bond_data.isin)
96
+
97
+ except Exception as db_e:
98
+ failed_count += 1
99
+ print(f"Database error for bond au_no {bond_data.auction_number}: {db_e}")
100
+
101
+ summary = {
102
+ "created": created_count,
103
+ "updated": updated_count,
104
+ "failed_during_processing": failed_count,
105
+ "message": "Bond import process finished."
106
+ }
107
+ await ImportTask.filter(id=task_id).update(status="completed", details=summary)
108
+ print(f"Bond import task {task_id} completed. Summary: {summary}")
109
+
110
+ except Exception as e:
111
+ print(f"Fatal error in bond import task {task_id}: {e}")
112
+ await ImportTask.filter(id=task_id).update(status="failed", details={"error": str(e)})
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)
App/routers/bonds/schemas.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional
3
+ from datetime import date
4
+
5
+ class BondCreate(BaseModel):
6
+ instrument_type: str
7
+ auction_number: int
8
+ auction_date: date
9
+ maturity_years: Optional[str]
10
+ maturity_date: date
11
+ effective_date: date
12
+ dtm: int
13
+ bond_auction_number: int
14
+ holding_number: int
15
+ face_value: int
16
+ price_per_100: float
17
+ coupon_rate: Optional[float]
18
+ isin:str
19
+
20
+ class BondResponse(BondCreate):
21
+ id: int
22
+
23
+ class Config:
24
+ orm_mode = True
App/routers/bonds/service.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta
2
+ from typing import List
3
+
4
+ def calculate_coupon_dates(start_date: datetime, maturity_date: datetime, interval_months: int = 6) -> List[datetime]:
5
+ """Calculate coupon payment dates for a bond.
6
+
7
+ Args:
8
+ start_date: The effective date of the bond
9
+ maturity_date: The maturity date of the bond
10
+ interval_months: Interval between coupon payments in months (default 6)
11
+
12
+ Returns:
13
+ List of coupon payment dates
14
+ """
15
+ dates = []
16
+ current_date = start_date
17
+
18
+ while current_date < maturity_date:
19
+ if current_date.weekday() < 5: # Skip weekends
20
+ dates.append(current_date)
21
+ current_date += timedelta(days=interval_months * 30.44) # Approximate months
22
+
23
+ return dates
App/routers/bonds/utils.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from curl_cffi.requests import AsyncSession,RequestsError
3
+ from bs4 import BeautifulSoup
4
+ from App.routers.bonds.schemas import BondCreate # Adjust import path
5
+ # from .bond_utils import parse_bond_title_details, parse_date_flexible, get_summary_item_value # If in separate file
6
+ from typing import List, Dict, Any, Optional, AsyncGenerator
7
+
8
+ import re
9
+ from datetime import datetime as dt
10
+ from typing import Tuple, Optional, List, Dict, Any
11
+
12
+ def parse_bond_title_details(title_str: str) -> Tuple[Optional[float], Optional[str], str, Optional[int]]:
13
+ coupon_rate_val = None
14
+ maturity_years_val = None
15
+ instrument_type_val = "TREASURY BOND"
16
+ issue_number_val = None
17
+
18
+ if not title_str:
19
+ return coupon_rate_val, maturity_years_val, instrument_type_val, issue_number_val
20
+
21
+ # 1. Coupon Rate
22
+ coupon_match = re.search(r'(\d+\.?\d*)%', title_str)
23
+ if coupon_match:
24
+ coupon_rate_val = float(coupon_match.group(1))
25
+ remaining_after_coupon = title_str.split(coupon_match.group(0), 1)[-1].strip()
26
+ else:
27
+ remaining_after_coupon = title_str
28
+
29
+ # 2. Maturity Years
30
+ maturity_match = re.search(r'(\d+)-YEAR', remaining_after_coupon, re.IGNORECASE)
31
+ if maturity_match:
32
+ maturity_years_val = f"{maturity_match.group(1)}-YEAR"
33
+ remaining_after_year = remaining_after_coupon.split(maturity_match.group(0), 1)[-1].strip()
34
+ else:
35
+ remaining_after_year = remaining_after_coupon
36
+
37
+ # 3. Instrument Type (base part)
38
+ # Remove "NUMBER ..." and "ISSUE ..." parts for cleaner type detection
39
+ cleaner_remaining = re.split(r'\s+NUMBER\s+\d+', remaining_after_year, flags=re.IGNORECASE)[0]
40
+ cleaner_remaining = re.split(r'\s+ISSUE\s+\d+', cleaner_remaining, flags=re.IGNORECASE)[0].strip()
41
+
42
+ if cleaner_remaining:
43
+ instrument_type_val = cleaner_remaining
44
+ if maturity_years_val and maturity_years_val not in instrument_type_val : # Prepend year if not already part of it
45
+ instrument_type_val = f"{maturity_years_val} {instrument_type_val}"
46
+ elif maturity_years_val:
47
+ instrument_type_val = f"{maturity_years_val} TREASURY BOND"
48
+
49
+ # 4. Issue Number (search in original title for issue)
50
+ issue_match = re.search(r'ISSUE\s+(\d+)', title_str, re.IGNORECASE)
51
+ if issue_match:
52
+ issue_number_val = int(issue_match.group(1))
53
+
54
+ if not instrument_type_val.strip() and title_str.strip(): # Fallback
55
+ instrument_type_val = "TREASURY BOND"
56
+ if maturity_years_val:
57
+ instrument_type_val = f"{maturity_years_val} {instrument_type_val}"
58
+
59
+
60
+ return coupon_rate_val, maturity_years_val, instrument_type_val.strip(), issue_number_val
61
+
62
+
63
+ def parse_date_flexible(date_str: str, default=None) -> Optional[dt]:
64
+ if not date_str:
65
+ return default
66
+ # Handle cases like "27-DEC-2012"
67
+ date_str = date_str.replace("-", " ").title() if len(date_str.split('-')) == 3 else date_str
68
+
69
+ formats_to_try = ["%d %b %Y", "%B %d, %Y", "%d/%m/%Y"]
70
+ for fmt in formats_to_try:
71
+ try:
72
+ return dt.strptime(date_str, fmt).date()
73
+ except ValueError:
74
+ continue
75
+ print(f"Warning: Could not parse date string: {date_str}")
76
+ return default
77
+
78
+ def get_summary_item_value(summary_list: List[Dict[str, Any]], item_desc_key: str, default=None) -> Optional[str]:
79
+ for item in summary_list:
80
+ if item.get("itemDesc", "").strip().upper() == item_desc_key.upper():
81
+ value_str = item.get("itemValue")
82
+ if value_str:
83
+ return value_str.strip()
84
+ return default
85
+
86
+
87
+ class BondDataScraper:
88
+ BASE_URL = "https://www.bot.go.tz"
89
+ TBONDS_URL = f"{BASE_URL}/TBonds"
90
+ AUCTION_SUMMARY_URL = f"{BASE_URL}/TBonds/AuctionSummaries"
91
+ IMPERSONATE_PROFILE = "chrome110" # Or another recent Chrome version
92
+
93
+ def __init__(self):
94
+ self.headers = {
95
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36',
96
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
97
+ 'Accept-Language': 'en-US,en;q=0.9',
98
+ }
99
+ self.ajax_headers = {
100
+ **self.headers,
101
+ 'Accept': 'application/json, text/javascript, */*; q=0.01', # Adjusted for AJAX
102
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
103
+ 'X-Requested-With': 'XMLHttpRequest',
104
+ 'Origin': self.BASE_URL,
105
+ 'Referer': self.TBONDS_URL,
106
+ }
107
+
108
+ async def _fetch_content(self, session: AsyncSession, url: str, method: str = "GET", data: Optional[Dict] = None, is_json: bool = False):
109
+ try:
110
+ if method.upper() == "POST":
111
+ response = await session.post(url, headers=self.ajax_headers if data else self.headers, data=data, impersonate=self.IMPERSONATE_PROFILE, timeout=20)
112
+ else:
113
+ response = await session.get(url, headers=self.headers, impersonate=self.IMPERSONATE_PROFILE, timeout=60*5)
114
+
115
+
116
+ response.raise_for_status()
117
+ return response.json() if is_json else response.text
118
+ except RequestsError as e: # Updated exception type for curl_cffi
119
+ print(f"HTTP error fetching {url}: {e}") # curl_cffi errors might not have response.status_code directly
120
+ except Exception as e:
121
+ print(f"Unexpected error fetching {url}: {e}")
122
+ return None
123
+
124
+ async def _parse_main_tbonds_page(self, html_content: str) -> List[Dict[str, Any]]:
125
+ if not html_content:
126
+ return []
127
+
128
+ soup = BeautifulSoup(html_content, 'html.parser')
129
+ table = soup.find('table', class_='tbond-table') # Or id="DataTables_Table_0"
130
+ if not table:
131
+ print("Main T-Bonds table not found.")
132
+ return []
133
+
134
+ parsed_rows = []
135
+ tbody = table.find('tbody')
136
+ if not tbody:
137
+ print("Tbody not found in main T-Bonds table.")
138
+ return []
139
+
140
+ for row in tbody.find_all('tr'):
141
+ cols = row.find_all(['th', 'td']) # First col might be th
142
+ if len(cols) < 5:
143
+ continue
144
+
145
+ try:
146
+ # Sn. is cols[0]
147
+ auction_number_text = cols[1].get_text(strip=True)
148
+ auction_title = cols[2].get_text(strip=True)
149
+ auction_date_str = cols[3].get_text(strip=True)
150
+
151
+ view_button = cols[4].find('button', id='showSummaryDetails')
152
+ if not view_button or not view_button.get('value'):
153
+ print(f"Skipping row, view button or value not found for auction: {auction_number_text}")
154
+ continue
155
+
156
+ button_value_parts = view_button['value'].split('_')
157
+ au_no_str = button_value_parts[0]
158
+ au_days_part = button_value_parts[1] # Usually '1'
159
+
160
+ parsed_rows.append({
161
+ 'table_auction_number_text': auction_number_text, # This is the au_no
162
+ 'table_auction_title': auction_title,
163
+ 'table_auction_date_str': auction_date_str,
164
+ 'au_no': int(au_no_str),
165
+ 'au_days_part': au_days_part,
166
+ })
167
+ except Exception as e:
168
+ print(f"Error parsing main table row: {e}. Row: {[c.get_text(strip=True) for c in cols]}")
169
+ return parsed_rows
170
+
171
+ async def _fetch_bond_details(self, session: AsyncSession, au_no: int, au_days_part: str) -> Optional[Dict[str, Any]]:
172
+ payload = {'au_no': str(au_no), 'au_days': au_days_part}
173
+ return await self._fetch_content(session, self.AUCTION_SUMMARY_URL, method="POST", data=payload, is_json=True)
174
+
175
+ def _parse_bond_details_json(self, json_data: Dict[str, Any], initial_data: Dict[str, Any]) -> Optional[BondCreate]:
176
+ if not json_data or json_data.get("message") != "SUCCESS":
177
+ print(f"Bond details JSON invalid or not successful for au_no: {initial_data.get('au_no')}")
178
+ return None
179
+
180
+ summary_list = json_data.get("tbondSummary", [])
181
+
182
+ coupon_rate, maturity_years, instrument_type, issue_number = parse_bond_title_details(json_data.get("bondTitle", ""))
183
+
184
+ auction_date_obj = parse_date_flexible(initial_data['table_auction_date_str'])
185
+ if not auction_date_obj: # Critical, skip if no valid auction date
186
+ print(f"Critical: Could not parse auction_date for au_no: {initial_data.get('au_no')}")
187
+ return None
188
+
189
+
190
+ maturity_date_str = get_summary_item_value(summary_list, "REDEMPTION DATE")
191
+ maturity_date_obj = parse_date_flexible(maturity_date_str)
192
+
193
+ dtm_val = None
194
+ if maturity_date_obj and auction_date_obj:
195
+ dtm_val = (maturity_date_obj - auction_date_obj).days
196
+
197
+ face_value_str = get_summary_item_value(summary_list, "AMOUNT OFFERED TZS(000,000)")
198
+ face_value_val = None
199
+ if face_value_str:
200
+ try:
201
+ face_value_val = int(float(face_value_str.replace(",", "")) * 1_000_000)
202
+ except ValueError:
203
+ print(f"Could not parse face_value: {face_value_str} for au_no: {initial_data.get('au_no')}")
204
+
205
+ price_per_100_str = get_summary_item_value(summary_list, "MINIMUM SUCCESSFUL PRICE / 100") # Or WAP?
206
+ price_per_100_val = None
207
+ if price_per_100_str:
208
+ try:
209
+ price_per_100_val = float(price_per_100_str)
210
+ except ValueError:
211
+ print(f"Could not parse price_per_100: {price_per_100_str} for au_no: {initial_data.get('au_no')}")
212
+
213
+
214
+ holding_number_str = json_data.get("auctionNumber") # This is the "1" from "AUCTION NUMBER 1 HELD ON..."
215
+ holding_number_val = int(holding_number_str) if holding_number_str and holding_number_str.isdigit() else None
216
+
217
+ return BondCreate(
218
+ instrument_type=instrument_type if instrument_type else "TREASURY BOND",
219
+ auction_number=initial_data['au_no'], # This is the main identifier from table
220
+ auction_date=auction_date_obj,
221
+ maturity_years=maturity_years,
222
+ maturity_date=maturity_date_obj,
223
+ effective_date=auction_date_obj, # Assuming effective date is auction date
224
+ dtm=dtm_val,
225
+ bond_auction_number=issue_number, # Issue number from title
226
+ holding_number=holding_number_val, # From JSON details header auctionNumber
227
+ face_value=face_value_val,
228
+ price_per_100=price_per_100_val,
229
+ coupon_rate=coupon_rate,
230
+ isin=json_data.get("ISIN")
231
+ )
232
+
233
+ async def scrape_all_bond_data(self) -> AsyncGenerator[BondCreate, None]:
234
+ async with AsyncSession() as session:
235
+ # First GET request to establish session cookies if necessary
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
243
+
244
+ initial_bond_rows = await self._parse_main_tbonds_page(main_page_html)
245
+
246
+ print(f"Found {len(initial_bond_rows)} initial bond rows from main table.")
247
+
248
+ for row_data in initial_bond_rows:
249
+ print(f"Fetching details for au_no: {row_data['au_no']}...")
250
+ await asyncio.sleep(0.5) # Small delay to be polite
251
+
252
+ details_json = await self._fetch_bond_details(session, row_data['au_no'], row_data['au_days_part'])
253
+ if details_json:
254
+ bond_create_obj = self._parse_bond_details_json(details_json, row_data)
255
+ if bond_create_obj:
256
+ yield bond_create_obj
257
+ else:
258
+ print(f"Failed to fetch or parse details for au_no: {row_data['au_no']}")
App/routers/portfolio/models.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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")
App/routers/portfolio/routes.py ADDED
@@ -0,0 +1,1301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.get(
1204
+ "/{portfolio_id}/calendar",
1205
+ response_model=ResponseModel,
1206
+ summary="Get Upcoming Portfolio Calendar Events",
1207
+ )
1208
+ async def get_portfolio_calendar_events(
1209
+ portfolio_id: int,
1210
+ start_date: Optional[date] = Query(
1211
+ None, description="Start of date range. Defaults to today."
1212
+ ),
1213
+ end_date: Optional[date] = Query(
1214
+ None, description="End of date range. Defaults to 90 days from now."
1215
+ ),
1216
+ current_user=Depends(get_current_user),
1217
+ ):
1218
+ """
1219
+ Generates a dynamic calendar of expected income events (dividends and coupons)
1220
+ for a user's portfolio within a given date range.
1221
+ """
1222
+ # 1. SETUP & AUTHENTICATION
1223
+ if start_date is None:
1224
+ start_date = date.today()
1225
+ if end_date is None:
1226
+ end_date = start_date + timedelta(days=90)
1227
+
1228
+ portfolio = await Portfolio.get_or_none(id=portfolio_id, user_id=current_user.id)
1229
+ if not portfolio:
1230
+ raise AppException(status_code=404, detail="Portfolio not found")
1231
+
1232
+ calendar_events: List[CalendarEventResponse] = []
1233
+ print("Hello there buddy!!")
1234
+ # 2. PROCESS STOCK DIVIDENDS
1235
+ # Get all stocks currently held in the portfolio
1236
+ portfolio_stocks = await PortfolioStock.filter(
1237
+ portfolio_id=portfolio_id
1238
+ ).select_related("stock")
1239
+ print("Hello there buddy!!")
1240
+ if portfolio_stocks:
1241
+ stock_ids = [ps.stock.id for ps in portfolio_stocks]
1242
+
1243
+ # Create a map for quick lookup of quantity held for each stock
1244
+ stock_quantity_map = {ps.stock.id: ps.quantity for ps in portfolio_stocks}
1245
+
1246
+ # Find all declared dividends for those stocks within the date range
1247
+ dividends = await Dividend.filter(
1248
+ stock_id__in=stock_ids,
1249
+ payment_date__gte=start_date,
1250
+ payment_date__lte=end_date,
1251
+ ).select_related("stock")
1252
+ print(dividends)
1253
+ for div in dividends:
1254
+ quantity_held = stock_quantity_map.get(div.stock.id, 0)
1255
+ if quantity_held > 0:
1256
+ event = CalendarEventResponse(
1257
+ event_date=div.payment_date,
1258
+ event_type="Dividend Payment",
1259
+ asset_symbol=div.stock.symbol,
1260
+ asset_name=div.stock.name,
1261
+ estimated_amount=div.dividend_amount * quantity_held,
1262
+ notes=f"Ex-dividend date: {div.ex_dividend_date.isoformat()}",
1263
+ )
1264
+ calendar_events.append(event)
1265
+
1266
+ # 3. PROCESS BOND COUPONS
1267
+ # Get all bonds currently held in the portfolio
1268
+ portfolio_bonds = await PortfolioBond.filter(
1269
+ portfolio_id=portfolio_id
1270
+ ).select_related("bond")
1271
+ if portfolio_bonds:
1272
+ for pb in portfolio_bonds:
1273
+ # Use our helper function to calculate coupon dates in the range
1274
+ coupon_dates = _calculate_bond_coupon_dates(pb.bond, start_date, end_date)
1275
+
1276
+ for coupon_date in coupon_dates:
1277
+ # Coupon amount is based on face value and semi-annual rate
1278
+ estimated_amount = (
1279
+ pb.face_value_held
1280
+ * (Decimal(str(pb.bond.coupon_rate)) / Decimal("100"))
1281
+ ) / Decimal("2")
1282
+
1283
+ event = CalendarEventResponse(
1284
+ event_date=coupon_date,
1285
+ event_type="Bond Coupon",
1286
+ asset_symbol=pb.bond.isin,
1287
+ asset_name=f"{pb.bond.maturity_years} Yr T-Bond",
1288
+ estimated_amount=estimated_amount,
1289
+ notes=f"Matures on {pb.bond.maturity_date.isoformat()}",
1290
+ )
1291
+ calendar_events.append(event)
1292
+
1293
+ # 4. SORT AND RETURN
1294
+ # Sort all collected events by date
1295
+ sorted_events = sorted(calendar_events, key=lambda x: x.event_date)
1296
+
1297
+ return ResponseModel(
1298
+ success=True,
1299
+ message="Portfolio calendar events retrieved successfully.",
1300
+ data={"events": sorted_events, "total_count": len(sorted_events)},
1301
+ )
App/routers/portfolio/schemas.py ADDED
@@ -0,0 +1,419 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
13
+ is_active: bool
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
353
+ transaction_date: date
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):
392
+ portfolio_id: int
393
+ period: str
394
+ start_value: Decimal
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
408
+ asset_symbol: str
409
+ quantity: Decimal
410
+ avg_buy_price: Decimal
411
+ total_invested: Decimal
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
+ )
App/routers/portfolio/service.py ADDED
@@ -0,0 +1,996 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,
12
+ PortfolioStock,
13
+ PortfolioUTT,
14
+ PortfolioBond,
15
+ PortfolioTransaction,
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)
91
+ return await query.order_by("-created_at").all()
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(),
166
+ is_completed=False,
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}")
App/routers/portfolio/utils.py ADDED
@@ -0,0 +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."
App/routers/stocks/crud.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from .models import Stock, StockPriceData
3
+ from datetime import datetime, timedelta
4
+ from tortoise.transactions import in_transaction
5
+ from tortoise.queryset import QuerySet
6
+ from .models import Stock, StockPriceData, Dividend # Import Dividend model
7
+ from typing import List, Dict, Optional
8
+ from datetime import datetime as dt # Alias to avoid conflict with date objects
9
+ from decimal import Decimal, InvalidOperation
10
+ from tortoise.exceptions import DoesNotExist, IntegrityError
11
+
12
+
13
+
14
+ async def create_or_get_stock(symbol: str, name: str):
15
+ """Create or get existing stock by symbol"""
16
+ return await Stock.get_or_create(symbol=symbol, defaults={"name": name})
17
+
18
+
19
+ async def bulk_insert_price_data(stock, data: list[dict]):
20
+ """Bulk insert price data for a stock"""
21
+ records = []
22
+ for row in data:
23
+ date = datetime.fromisoformat(row["trade_date"]).date()
24
+ records.append(
25
+ StockPriceData(
26
+ stock=stock,
27
+ date=date,
28
+ opening_price=row["opening_price"],
29
+ closing_price=row["closing_price"],
30
+ high=row["high"],
31
+ low=row["low"],
32
+ volume=row["volume"],
33
+ turnover=row["turnover"],
34
+ shares_in_issue=row["shares_in_issue"],
35
+ market_cap=row["market_cap"]
36
+ )
37
+ )
38
+
39
+ async with in_transaction():
40
+ await StockPriceData.bulk_create(records, ignore_conflicts=True)
41
+
42
+
43
+ async def get_stock_price_history(stock_id: int, days: int = 365) -> QuerySet:
44
+ """Get stock price history as a QuerySet (not materialized list)"""
45
+ current_date = datetime.now().date()
46
+ start_date = current_date - timedelta(days=days)
47
+
48
+ # Return QuerySet, not materialized results
49
+ return StockPriceData.filter(
50
+ stock_id=stock_id,
51
+ date__gte=start_date
52
+ ).order_by("-date")
53
+
54
+
55
+ async def get_dividends_by_stock_id(stock_id: int) -> List[Dividend]:
56
+ """Fetches all dividends for a given stock_id, ordered by ex_dividend_date descending."""
57
+ return await Dividend.filter(stock_id=stock_id).order_by("-ex_dividend_date").all()
58
+
59
+ async def bulk_upsert_dividends(stock_id: int, scraped_dividends: List[Dict]) -> List[Dividend]:
60
+ """
61
+ Upserts dividend data. If a record with the same stock_id, ex_dividend_date,
62
+ dividend_amount and type exists, it updates it. Otherwise, creates a new one.
63
+ """
64
+ saved_or_updated_dividends = []
65
+ for div_data in scraped_dividends:
66
+ try:
67
+ ex_date_str = div_data.get("Ex-Dividend Date")
68
+ pay_date_str = div_data.get("Payment Date")
69
+
70
+ # Robust date parsing
71
+ ex_date = None
72
+ if ex_date_str:
73
+ try:
74
+ ex_date = dt.strptime(ex_date_str, "%b %d, %Y").date()
75
+ except ValueError:
76
+ print(f"Could not parse ex_dividend_date: {ex_date_str}")
77
+ continue # Skip this record
78
+
79
+ pay_date = None
80
+ if pay_date_str:
81
+ try:
82
+ pay_date = dt.strptime(pay_date_str, "%b %d, %Y").date()
83
+ except ValueError:
84
+ print(f"Could not parse payment_date: {pay_date_str}")
85
+ # We might still want to save if ex_date and amount are present
86
+ # For now, let's require payment_date for simplicity of example
87
+ continue
88
+
89
+
90
+ if not ex_date or not pay_date: # Ensure essential dates are present
91
+ print(f"Skipping dividend due to missing/invalid essential dates: {div_data}")
92
+ continue
93
+
94
+ amount_str = str(div_data.get("Dividend", "0")).replace(',', '')
95
+ yield_str = str(div_data.get("Yield", "0%")).replace('%', '').replace(',', '')
96
+
97
+ dividend_amount = Decimal(amount_str) if amount_str else Decimal("0")
98
+
99
+ yield_percentage = None
100
+ if yield_str and yield_str.lower() != 'n/a' and yield_str.strip() != '-':
101
+ try:
102
+ yield_percentage = Decimal(yield_str)
103
+ except InvalidOperation:
104
+ print(f"Could not parse yield: {yield_str}")
105
+
106
+
107
+ dividend_type = div_data.get("Type")
108
+ if dividend_type and len(dividend_type) > 50: # Truncate if too long
109
+ dividend_type = dividend_type[:50]
110
+
111
+ defaults = {
112
+ "payment_date": pay_date,
113
+ "yield_percentage": yield_percentage,
114
+ # ensure updated_at is handled by Tortoise auto_now=True
115
+ }
116
+ # Use update_or_create to handle existing records based on unique_together constraint
117
+ # or a similar logic if unique_together is not fully covering all cases
118
+ dividend_obj, created = await Dividend.update_or_create(
119
+ stock_id=stock_id,
120
+ ex_dividend_date=ex_date,
121
+ dividend_amount=dividend_amount,
122
+ dividend_type=dividend_type, # Include type in uniqueness check
123
+ defaults=defaults
124
+ )
125
+ saved_or_updated_dividends.append(dividend_obj)
126
+
127
+ except (ValueError, InvalidOperation, TypeError) as e:
128
+ print(f"Error processing or saving dividend data '{div_data}': {e}")
129
+ continue
130
+ except IntegrityError as e: # Handles cases where unique_together might be violated if not using update_or_create
131
+ print(f"Integrity error for dividend data '{div_data}': {e}. Might be a duplicate.")
132
+ continue
133
+
134
+ return saved_or_updated_dividends
App/routers/stocks/metrics.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from .models import StockPriceData
3
+
4
+ import numpy as np
5
+ from .models import StockPriceData
6
+
7
+ async def calculate_metrics(stock):
8
+ data = await StockPriceData.filter(stock=stock).order_by("date").values(
9
+ "date", "closing_price", "market_cap", "shares_in_issue", "volume"
10
+ )
11
+
12
+ if not data:
13
+ return None
14
+
15
+ closing_prices = [d["closing_price"] for d in data if d["closing_price"]]
16
+ market_caps = [d["market_cap"] for d in data if d["market_cap"]]
17
+ volumes = [d["volume"] for d in data if d["volume"]]
18
+
19
+ avg_price = float(np.mean(closing_prices))
20
+ std_dev = float(np.std(closing_prices))
21
+ return_pct = ((closing_prices[-1] - closing_prices[0]) / closing_prices[0]) * 100 if closing_prices[0] else 0
22
+ avg_market_cap = float(np.mean(market_caps)) if market_caps else 0
23
+ avg_volume = float(np.mean(volumes)) if volumes else 0
24
+
25
+ # Standard formulas (placeholders until model supports them)
26
+ eps = None # stock.eps (Earnings per share)
27
+ book_value_per_share = None
28
+ dividend_per_share = None
29
+
30
+ pe_ratio = avg_price / eps if eps else "N/A"
31
+ earnings_yield = (eps / avg_price) * 100 if eps else "N/A"
32
+ dividend_yield = (dividend_per_share / avg_price) * 100 if dividend_per_share else "N/A"
33
+ pb_ratio = avg_price / book_value_per_share if book_value_per_share else "N/A"
34
+
35
+ return {
36
+ "symbol": stock.symbol,
37
+ "average_price": round(avg_price, 2),
38
+ "return_percentage": round(return_pct, 2),
39
+ "volatility": round(std_dev, 2),
40
+ "average_market_cap": round(avg_market_cap),
41
+ "average_volume": round(avg_volume),
42
+ "pe_ratio": pe_ratio,
43
+ "earnings_yield": earnings_yield,
44
+ "dividend_yield": dividend_yield,
45
+ "price_to_book": pb_ratio,
46
+ "notes": "Add EPS, dividend, and book value to enable full fundamental metrics"
47
+ }
App/routers/stocks/models.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from tortoise.contrib.pydantic.creator import pydantic_queryset_creator
2
+ from tortoise.models import Model
3
+ from tortoise import fields
4
+ from tortoise.contrib.pydantic import pydantic_model_creator
5
+ from tortoise.queryset import QuerySet
6
+
7
+
8
+ class Stock(Model):
9
+ id = fields.IntField(primary_key=True)
10
+ symbol = fields.CharField(max_length=10, unique=True)
11
+ name = fields.CharField(max_length=200)
12
+ created_at = fields.DatetimeField(auto_now_add=True)
13
+ updated_at = fields.DatetimeField(auto_now=True)
14
+
15
+ @staticmethod
16
+ async def get_list(data):
17
+ if type(data) == list(Stock):
18
+ parser = pydantic_queryset_creator(Stock)
19
+ return await parser.from_queryset(data)
20
+
21
+ async def to_dict(self):
22
+ if type(self) == Stock:
23
+ parser = pydantic_model_creator(Stock)
24
+ return await parser.from_tortoise_orm(self)
25
+
26
+ class Meta:
27
+ table = "stocks"
28
+
29
+
30
+ class StockPriceData(Model):
31
+ id = fields.IntField(primary_key=True)
32
+ stock = fields.ForeignKeyField("models.Stock", related_name="price_data")
33
+ date = fields.DateField()
34
+ opening_price = fields.DecimalField(max_digits=15, decimal_places=2)
35
+ closing_price = fields.DecimalField(max_digits=15, decimal_places=2)
36
+ high = fields.DecimalField(max_digits=15, decimal_places=2)
37
+ low = fields.DecimalField(max_digits=15, decimal_places=2)
38
+ volume = fields.BigIntField() # Use BigIntField for large numbers
39
+ turnover = fields.BigIntField() # Use BigIntField instead of IntField
40
+ shares_in_issue = fields.BigIntField() # Use BigIntField for large numbers
41
+ market_cap = fields.BigIntField() # Use BigIntField for large numbers
42
+ created_at = fields.DatetimeField(auto_now_add=True)
43
+
44
+ @staticmethod
45
+ async def get_list(data):
46
+ if type(data) == QuerySet:
47
+ parser = pydantic_queryset_creator(StockPriceData)
48
+ return await parser.from_queryset(data)
49
+
50
+ async def to_dict(self):
51
+ if type(self) == Model:
52
+ parser = pydantic_model_creator(StockPriceData)
53
+ return await parser.from_tortoise_orm(self)
54
+
55
+ class Meta:
56
+ table = "stock_price_data"
57
+ unique_together = (
58
+ ("stock", "date"),
59
+ ) # Prevent duplicate entries for same stock/date
60
+
61
+
62
+ class Dividend(Model):
63
+ id = fields.IntField(primary_key=True)
64
+ stock = fields.ForeignKeyField("models.Stock", related_name="dividends")
65
+ ex_dividend_date = fields.DateField()
66
+ dividend_amount = fields.DecimalField(max_digits=15, decimal_places=4)
67
+ dividend_type = fields.CharField(max_length=50, null=True, blank=True)
68
+ payment_date = fields.DateField()
69
+ yield_percentage = fields.DecimalField(
70
+ max_digits=10, decimal_places=4, null=True, blank=True
71
+ )
72
+ created_at = fields.DatetimeField(auto_now_add=True)
73
+ updated_at = fields.DatetimeField(auto_now=True)
74
+
75
+ @staticmethod
76
+ async def get_list(data):
77
+ if type(data) == QuerySet:
78
+ parser = pydantic_queryset_creator(Dividend)
79
+ return await parser.from_queryset(data)
80
+
81
+ async def to_dict(self):
82
+ if type(self) == Model:
83
+ parser = pydantic_model_creator(Dividend)
84
+ return await parser.from_tortoise_orm(self)
85
+ if type(self) == QuerySet:
86
+ parser = pydantic_queryset_creator(Dividend)
87
+ return await parser.from_queryset(self)
88
+
89
+ class Meta:
90
+ table = "dividends"
91
+ ordering = [
92
+ "-ex_dividend_date",
93
+ "-payment_date",
94
+ ] # Order by dates descending by default
95
+
96
+ def __str__(self):
97
+ return f"{self.stock.symbol} Dividend: {self.dividend_amount} on {self.ex_dividend_date}"
App/routers/stocks/routes.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, BackgroundTasks
2
+ from .schemas import DividendResponse, StockResponse, PriceDataResponse
3
+ from .crud import (
4
+ create_or_get_stock,
5
+ bulk_insert_price_data,
6
+ get_stock_price_history,
7
+ get_dividends_by_stock_id,
8
+ bulk_upsert_dividends,
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
16
+ import datetime
17
+ from datetime import datetime, timedelta
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
+
24
+
25
+ # --- Caching Mechanism (Simple in-memory for example) ---
26
+ dividend_cache: Dict[str, Dict] = {}
27
+ 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
+ )
35
+ background_tasks.add_task(run_stock_import_task, task.id, symbol)
36
+ return ResponseModel(
37
+ success=True, message="Stock import task queued", data={"task_id": task.id}
38
+ )
39
+
40
+
41
+ # Alternative Method: Using Tortoise ORM with GROUP BY (if raw SQL not preferred)
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,
76
+ "symbol": stock.symbol,
77
+ "name": stock.name,
78
+ "latest_price": float(latest.closing_price) if latest else None,
79
+ "opening_price": float(latest.opening_price) if latest else None,
80
+ "high": float(latest.high) if latest else None,
81
+ "low": float(latest.low) if latest else None,
82
+ "volume": latest.volume if latest else None,
83
+ "market_cap": latest.market_cap if latest else None,
84
+ "latest_date": latest.date.isoformat() if latest else None,
85
+ }
86
+ stock_list.append(stock_data)
87
+
88
+ return ResponseModel(
89
+ success=True,
90
+ message=f"Retrieved {len(stock_list)} stocks with latest prices",
91
+ data={"stocks": stock_list, "count": len(stock_list)},
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(symbol: str):
100
+ stock = await Stock.get_or_none(symbol=symbol.upper())
101
+ if not stock:
102
+ raise AppException(status_code=404, detail="Stock not found")
103
+
104
+ prices = await get_stock_price_history(stock.id)
105
+ prices_pydantic = await StockPriceData.get_list(prices)
106
+ return ResponseModel(
107
+ success=True,
108
+ message="Stock prices retrieved",
109
+ data={"prices": prices_pydantic.model_dump()},
110
+ )
111
+
112
+
113
+ @router.get("/{symbol}/metrics", response_model=ResponseModel)
114
+ async def get_stock_metrics(symbol: str):
115
+ stock = await Stock.get_or_none(symbol=symbol.upper())
116
+ if not stock:
117
+ raise AppException(status_code=404, detail="Stock not found")
118
+
119
+ metrics = await calculate_metrics(stock)
120
+ print(
121
+ metrics
122
+ ) # Debugging print statement to check the metrics returned by calculate_metrics()
123
+ if not metrics:
124
+ raise AppException(
125
+ status_code=404, detail="No price data available for metrics calculation"
126
+ )
127
+
128
+ return ResponseModel(
129
+ success=True, message="Stock metrics calculated", data={"metrics": metrics}
130
+ )
131
+
132
+
133
+ # --- New Dividend Route ---
134
+ @router.get("/{symbol}/dividends", response_model=ResponseModel)
135
+ async def get_stock_dividends(symbol: str):
136
+ stock_symbol_upper = symbol.upper()
137
+ stock = await Stock.get_or_none(symbol=stock_symbol_upper)
138
+
139
+ if not stock:
140
+ raise AppException(
141
+ status_code=404,
142
+ detail=f"Stock '{stock_symbol_upper}' not found and could not be auto-created.",
143
+ )
144
+
145
+ cache_key = f"dividends_{stock_symbol_upper}"
146
+ current_time = datetime.now()
147
+
148
+ # 1. Check cache
149
+ if cache_key in dividend_cache:
150
+ cached_item = dividend_cache[cache_key]
151
+ if current_time - cached_item["timestamp"] < timedelta(
152
+ minutes=CACHE_DURATION_MINUTES
153
+ ):
154
+ print(f"Returning cached dividend data for {stock_symbol_upper}")
155
+ return ResponseModel(
156
+ success=True,
157
+ message="Cached dividend data retrieved",
158
+ data={"dividends": cached_item["data"]},
159
+ )
160
+ else:
161
+ print(f"Cache expired for {stock_symbol_upper}")
162
+ del dividend_cache[cache_key]
163
+
164
+ # 2. If not in valid cache, try DB
165
+ db_dividends_orm = await get_dividends_by_stock_id(stock.id)
166
+
167
+ # Prepare data for Pydantic response if found in DB
168
+ if db_dividends_orm:
169
+ response_data = [
170
+ DividendResponse.model_validate(div).model_dump(mode="json")
171
+ for div in db_dividends_orm
172
+ ]
173
+ dividend_cache[cache_key] = {"timestamp": current_time, "data": response_data}
174
+ print(f"Returning dividend data from DB for {stock_symbol_upper}")
175
+ return ResponseModel(
176
+ success=True,
177
+ message="Dividend data retrieved from database",
178
+ data={"dividends": response_data},
179
+ )
180
+
181
+ # 3. If not in DB (or cache expired and we decide to force refresh, though current logic only scrapes if DB empty)
182
+ # Scrape fresh data
183
+ print(f"No dividends in DB for {stock_symbol_upper}. Scraping fresh data...")
184
+ scraper = AsyncCurlCffiDividendScraper()
185
+
186
+ # The scraper's search_company method implicitly checks for DSE.
187
+ # We might not need to call _search_company_async separately if fetch_and_extract handles it.
188
+ # Let's assume fetch_and_extract_dividends_async uses _search_company_async internally to get the URL.
189
+ scraped_data_list: List[Dict] = await scraper.fetch_and_extract_dividends_async(
190
+ stock_symbol_upper
191
+ )
192
+
193
+ if not scraped_data_list:
194
+ # Cache the fact that no data was found to prevent re-scraping immediately
195
+ dividend_cache[cache_key] = {"timestamp": current_time, "data": []}
196
+ print(f"Scraper returned no dividend data for {stock_symbol_upper}")
197
+ return ResponseModel(
198
+ success=True,
199
+ message="No dividend data found from scraper",
200
+ data={"dividends": []},
201
+ )
202
+
203
+ # Save scraped data to DB
204
+ # bulk_upsert_dividends should convert dicts to ORM objects and save them
205
+ # and return the list of saved/updated ORM objects.
206
+ saved_dividends_orm: List[Dividend] = await bulk_upsert_dividends(
207
+ stock.id, scraped_data_list
208
+ )
209
+
210
+ if not saved_dividends_orm:
211
+ return ResponseModel(
212
+ success=False,
213
+ message="Data scraped but failed to save to DB.",
214
+ data={"dividends": []},
215
+ )
216
+
217
+ # Convert saved ORM objects to Pydantic response models
218
+ response_data_after_scrape = [
219
+ DividendResponse.model_validate(div).model_dump(mode="json")
220
+ for div in saved_dividends_orm
221
+ ]
222
+
223
+ dividend_cache[cache_key] = {
224
+ "timestamp": current_time,
225
+ "data": response_data_after_scrape,
226
+ }
227
+ print(f"Successfully scraped and saved dividend data for {stock_symbol_upper}")
228
+ return ResponseModel(
229
+ success=True,
230
+ message="Dividend data scraped and saved",
231
+ data={"dividends": response_data_after_scrape},
232
+ )
App/routers/stocks/schemas.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from datetime import date
3
+ from typing import List
4
+ from datetime import date, datetime as dt # Alias to avoid
5
+ from decimal import Decimal
6
+ from typing import Optional
7
+
8
+
9
+ class DividendBase(BaseModel):
10
+ ex_dividend_date: date
11
+ dividend_amount: Decimal
12
+ dividend_type: Optional[str] = None
13
+ payment_date: date
14
+ yield_percentage: Optional[Decimal] = None
15
+
16
+ class Config:
17
+ from_attributes = True # Pydantic v2: use from_attributes = True
18
+ # orm_mode = True # Pydantic v1
19
+
20
+ class DividendCreate(DividendBase):
21
+ pass
22
+
23
+ class DividendResponse(DividendBase):
24
+ id: int
25
+ stock_id: int # Useful to confirm which stock it belongs to
26
+ created_at: dt
27
+ updated_at: dt
28
+
29
+
30
+ class StockCreate(BaseModel):
31
+ symbol: str
32
+ name: str
33
+
34
+
35
+ class StockResponse(BaseModel):
36
+ id: int
37
+ symbol: str
38
+ name: str
39
+
40
+
41
+ class PriceDataResponse(BaseModel):
42
+ date: date
43
+ opening_price: float
44
+ closing_price: float
45
+ high: float
46
+ low: float
47
+ volume: int
48
+ turnover: int
App/routers/stocks/service.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
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()
App/routers/stocks/utils.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
8
+ import asyncio
9
+ from curl_cffi.requests import AsyncSession # For requests-like error handling
10
+ from curl_cffi import CurlError # For lower-level cURL errors
11
+ from bs4 import BeautifulSoup
12
+ 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)})
32
+
33
+
34
+
35
+
36
+ class AsyncCurlCffiDividendScraper:
37
+ BASE_URL = "https://www.investing.com"
38
+ SEARCH_API_URL = "https://api.investing.com/api/search/v2/search"
39
+ # Choose an impersonation profile. "chrome" is a good default.
40
+ # You can also use specific versions like "chrome124"
41
+ IMPERSONATE_PROFILE = "chrome124"
42
+
43
+ def __init__(self):
44
+ self.headers = {
45
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', # Example, curl_cffi handles this mostly
46
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
47
+ 'Accept-Language': 'en-US,en;q=0.9',
48
+ 'Domain-Id': 'www' # Crucial for the Investing.com API
49
+ }
50
+
51
+ async def _search_company_async(self, session: AsyncSession, ticker_symbol: str):
52
+ """
53
+ Asynchronously searches for a company by its ticker symbol using curl_cffi.
54
+ Returns its URL suffix if found on the Dar Es Salaam stock exchange.
55
+ """
56
+ params = {'q': ticker_symbol}
57
+ url_suffix = None
58
+ print(f"Searching for ticker with curl_cffi: {ticker_symbol}...")
59
+ try:
60
+ response = await session.get(
61
+ self.SEARCH_API_URL,
62
+ params=params,
63
+ headers=self.headers,
64
+ impersonate=self.IMPERSONATE_PROFILE,
65
+ timeout=10
66
+ )
67
+
68
+ response.raise_for_status()
69
+ data = response.json()
70
+
71
+ if not data or 'quotes' not in data:
72
+ print(f"No data or quotes found for ticker: {ticker_symbol}")
73
+ return None
74
+
75
+ for quote in data.get('quotes', []):
76
+ if quote.get('symbol', '').upper() == ticker_symbol.upper() and \
77
+ quote.get('exchange', '').lower() == 'dar es salaam':
78
+ print(f"Found '{quote.get('description')}' ({quote.get('symbol')}) on {quote.get('exchange')}")
79
+ url_suffix = quote.get('url')
80
+ break
81
+
82
+ if not url_suffix:
83
+ print(f"Ticker {ticker_symbol} not found on Dar Es Salaam stock exchange or no matching symbol.")
84
+ return url_suffix
85
+
86
+ except Exception as e:
87
+ print(f"HTTP error during async company search for {ticker_symbol}: {e.response.status_code} - {e.response.text[:200]}")
88
+ except CurlError as e:
89
+ print(f"cURL error during async company search API call for {ticker_symbol}: {e}")
90
+ except asyncio.TimeoutError:
91
+ print(f"Timeout during async company search for {ticker_symbol}")
92
+ except ValueError as e:
93
+ print(f"Error decoding JSON from async search API for {ticker_symbol}: {e}")
94
+ except Exception as e:
95
+ print(f"An unexpected error occurred during search for {ticker_symbol}: {e}")
96
+ return None
97
+
98
+
99
+ async def get_dividend_history_async(self, session: AsyncSession, company_url_suffix: str):
100
+ """
101
+ Asynchronously fetches and parses the dividend history table using curl_cffi.
102
+ """
103
+ if not company_url_suffix:
104
+ return []
105
+
106
+ if not company_url_suffix.startswith('/'):
107
+ company_url_suffix = '/' + company_url_suffix
108
+
109
+ dividend_url = f"{self.BASE_URL}{company_url_suffix}-dividends"
110
+ print(f"Fetching dividend data async with curl_cffi from: {dividend_url}")
111
+
112
+ try:
113
+ response = await session.get(
114
+ dividend_url,
115
+ headers=self.headers,
116
+ impersonate=self.IMPERSONATE_PROFILE,
117
+ timeout=15
118
+ )
119
+ # print(f"Dividend Page Status for {dividend_url}: {response.status_code}") # Debug
120
+ response.raise_for_status()
121
+ html_content = response.text # .text is a property in curl_cffi.requests.Response
122
+ except Exception as e:
123
+ print(f"HTTP error fetching dividend page {dividend_url}: {e.response.status_code} - {e.response.text[:200]}")
124
+ return []
125
+ except CurlError as e:
126
+ print(f"cURL error fetching dividend page async {dividend_url}: {e}")
127
+ return []
128
+ except asyncio.TimeoutError:
129
+ print(f"Timeout fetching dividend page async {dividend_url}")
130
+ return []
131
+ except Exception as e:
132
+ print(f"An unexpected error occurred fetching dividend page {dividend_url}: {e}")
133
+ return []
134
+
135
+ soup = BeautifulSoup(html_content, 'html.parser')
136
+
137
+ target_table = None
138
+ # Try to find table by specific class or structure
139
+ # The original HTML's table has class "freeze-column-w-1 w-full overflow-x-auto text-xs leading-4"
140
+ # A more reliable way if classes are stable is a more specific selector.
141
+ # Let's try to find a table that looks like the one in the sample
142
+ all_tables = soup.find_all('table')
143
+ for table_candidate in all_tables:
144
+ # Check for characteristic headers
145
+ ths = table_candidate.select('thead th')
146
+ header_texts = [th.get_text(strip=True) for th in ths]
147
+ if "Ex-Dividend Date" in header_texts and "Dividend" in header_texts and "Payment Date" in header_texts:
148
+ # Further check: does it have the 'freeze-column-w-1' class which seems distinctive
149
+ if 'freeze-column-w-1' in table_candidate.get('class', []):
150
+ target_table = table_candidate
151
+ break
152
+
153
+ if not target_table: # Fallback: if the specific class isn't there, but headers match
154
+ for table_candidate in all_tables:
155
+ ths = table_candidate.select('thead th')
156
+ header_texts = [th.get_text(strip=True) for th in ths]
157
+ if "Ex-Dividend Date" in header_texts and "Dividend" in header_texts:
158
+ target_table = table_candidate
159
+ print(f"Found dividend table using fallback (header check) on {dividend_url}")
160
+ break
161
+
162
+ if not target_table:
163
+ print(f"Dividend table not found on the page: {dividend_url}")
164
+ # with open(f"debug_dividend_page_{company_url_suffix.replace('/', '_')}.html", "w", encoding="utf-8") as f:
165
+ # f.write(soup.prettify())
166
+ # print(f"Debug HTML saved for {dividend_url}")
167
+ return []
168
+
169
+ dividends_data = []
170
+ tbody = target_table.find('tbody')
171
+ if not tbody:
172
+ print(f"Table body (tbody) not found in the dividend table on {dividend_url}.")
173
+ return []
174
+
175
+ rows = tbody.find_all('tr')
176
+ if not rows:
177
+ print(f"No rows found in the dividend table body on {dividend_url}.")
178
+ return []
179
+
180
+ for row_idx, row in enumerate(rows):
181
+ cols = row.find_all('td')
182
+ if len(cols) >= 5:
183
+ try:
184
+ ex_dividend_date_tag = cols[0].find('time')
185
+ ex_dividend_date = ex_dividend_date_tag.get_text(strip=True) if ex_dividend_date_tag else cols[0].get_text(strip=True)
186
+
187
+ dividend_val = cols[1].get_text(strip=True)
188
+
189
+ type_cell = cols[2]
190
+ # The structure for 'Type' in the NMB example has the value in a div after an svg
191
+ # e.g., <div class="absolute top-[7px] ...">12M</div>
192
+ type_specific_div = type_cell.select_one('div > svg + div') # div containing svg and then the target div
193
+ if not type_specific_div: # Try another common pattern from the HTML
194
+ type_specific_div = type_cell.select_one('div > div[class*="absolute"]')
195
+
196
+ if type_specific_div:
197
+ dividend_type = type_specific_div.get_text(strip=True)
198
+ else: # Fallback
199
+ dividend_type = type_cell.get_text(strip=True).replace('\n', ' ').strip()
200
+
201
+ payment_date_tag = cols[3].find('time')
202
+ payment_date = payment_date_tag.get_text(strip=True) if payment_date_tag else cols[3].get_text(strip=True)
203
+
204
+ yield_val = cols[4].get_text(strip=True)
205
+
206
+ dividends_data.append({
207
+ "Ex-Dividend Date": ex_dividend_date,
208
+ "Dividend": dividend_val,
209
+ "Type": dividend_type,
210
+ "Payment Date": payment_date,
211
+ "Yield": yield_val
212
+ })
213
+ except Exception as e:
214
+ print(f"Error parsing row {row_idx} on {dividend_url}: {e}. Row content: {[c.get_text(strip=True) for c in cols]}")
215
+ else:
216
+ print(f"Skipping row {row_idx} with insufficient columns ({len(cols)}) on {dividend_url}: {[c.get_text(strip=True) for c in cols]}")
217
+
218
+ return dividends_data
219
+
220
+ async def fetch_and_extract_dividends_async(self, ticker_symbol: str):
221
+ """
222
+ Main async method to search for a company and extract its dividend history.
223
+ Manages its own curl_cffi.AsyncSession.
224
+ """
225
+ async with AsyncSession() as session: # session is created here
226
+ company_url_suffix = await self._search_company_async(session, ticker_symbol)
227
+ if company_url_suffix:
228
+ return await self.get_dividend_history_async(session, company_url_suffix)
229
+ return []
230
+
231
+ # --- Example Async Usage ---
232
+ async def main():
233
+ scraper = AsyncCurlCffiDividendScraper()
234
+
235
+ tickers_to_scrape = ["NMB", "VODA"]
236
+
237
+ tasks = [scraper.fetch_and_extract_dividends_async(ticker) for ticker in tickers_to_scrape]
238
+
239
+ results = await asyncio.gather(*tasks, return_exceptions=True)
240
+
241
+ for ticker, result in zip(tickers_to_scrape, results):
242
+ print(f"\n--- Results for {ticker} using curl_cffi ---")
243
+ if isinstance(result, Exception):
244
+ print(f"An error occurred while fetching data for {ticker}: {result}")
245
+ elif result:
246
+ if result:
247
+ df = pd.DataFrame(result)
248
+ print(df.to_string())
249
+ else:
250
+ print(f"No dividend data extracted for {ticker} (table might be empty or parsing failed after finding the table).")
251
+ else:
252
+ print(f"Could not fetch/extract dividend data for {ticker} (company likely not found on DSE or critical error).")
253
+
254
+ # if __name__ == "__main__":
255
+ # if sys.platform == "win32" and sys.version_info >= (3, 8):
256
+ # asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
257
+
258
+ # asyncio.run(main())
App/routers/tasks/models.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from tortoise import fields, models
2
+ from enum import Enum
3
+
4
+ class TaskStatus(str, Enum):
5
+ PENDING = "pending"
6
+ RUNNING = "running"
7
+ COMPLETED = "completed"
8
+ FAILED = "failed"
9
+
10
+ class ImportTask(models.Model):
11
+ id = fields.IntField(pk=True)
12
+ task_type = fields.CharField(max_length=50)
13
+ status = fields.CharEnumField(TaskStatus, default=TaskStatus.PENDING)
14
+ details = fields.JSONField(null=True)
15
+ created_at = fields.DatetimeField(auto_now_add=True)
16
+ updated_at = fields.DatetimeField(auto_now=True)
17
+
18
+ class Meta:
19
+ table = "import_tasks"
App/routers/tasks/routes.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,
9
+ )
10
+ TaskData_Pydantic = pydantic_model_creator(
11
+ ImportTask,
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())
App/routers/tasks/schemas.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from datetime import datetime
3
+ from enum import Enum
4
+ from pydantic import ConfigDict
5
+ class TaskStatus(str, Enum):
6
+ PENDING = "pending"
7
+ RUNNING = "running"
8
+ COMPLETED = "completed"
9
+ FAILED = "failed"
10
+
11
+ class ImportTaskResponse(BaseModel):
12
+ id: int
13
+ task_type: str
14
+ status: TaskStatus
15
+ details: dict | None
16
+ created_at: datetime
17
+ updated_at: datetime
18
+
19
+ class ResponseModel(BaseModel):
20
+ success: bool
21
+ message: str
22
+ data: dict | list | None = None
23
+ model_config = ConfigDict(from_attributes=True)
App/routers/users/models.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
App/routers/users/routes.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ _user = await user_obj.to_dict()
26
+ _user = UserResponse.model_validate(_user.model_dump())
27
+ return ResponseModel(success=True, message="Login successful", data=_user)
28
+
29
+ @router.post("/register", response_model=ResponseModel)
30
+ async def register(user: UserCreate):
31
+ existing = await User.get_or_none(email=user.email)
32
+ if existing:
33
+ raise AppException(status_code=400, detail=ResponseModel(success=False, message="Email already registered"))
34
+ user_obj = await User.create(
35
+ username=user.username,
36
+ email=user.email,
37
+ hashed_password=bcrypt.hash(user.password)
38
+ )
39
+ await Portfolio.create(user=user_obj, name="Default Portfolio")
40
+ return ResponseModel(success=True, message="User created", data=await User_Pydantic.from_tortoise_orm(user_obj))
41
+
42
+ @router.get("/{user_id}/portfolio", response_model=ResponseModel)
43
+ async def get_portfolio(user_id: int):
44
+ portfolios = Portfolio.filter(user=user_id).all()
45
+ _portfolios = await Portfolio_Pydantic.from_queryset(portfolios)
46
+ return ResponseModel(success=True, message="Portfolio retrieved", data=_portfolios.model_dump())
47
+
48
+ @router.post("/{user_id}/portfolio", response_model=ResponseModel)
49
+ async def create_portfolio(user_id: int, data: PortfolioItemSchema):
50
+ # Check if user exists
51
+ user = await User.get_or_none(id=user_id)
52
+ if not user:
53
+ raise HTTPException(status_code=404, detail="User not found")
54
+
55
+ # Create a new portfolio
56
+ portfolio = await Portfolio.create(
57
+ user=user,
58
+ name=data.name
59
+ )
60
+ _portfolio=Portfolio_one_Pydantic.from_orm(portfolio)
61
+ return ResponseModel(success=True, message="Portfolio created", data=_portfolio.model_dump())
62
+
63
+ @router.post("/{user_id}/watchlist/add", response_model=ResponseModel)
64
+ async def add_to_watchlist(user_id: int, item: WatchlistItemSchema):
65
+ new_item = await Watchlist.create(
66
+ user_id=user_id,
67
+ stock_id=item.stock_id,
68
+ utt_id=item.utt_id
69
+ )
70
+ return ResponseModel(success=True, message="Added to watchlist", data=new_item)
App/routers/users/schemas.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
App/routers/users/utils.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
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
App/routers/utt/models.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from tortoise import fields, models
2
+ from pydantic import BaseModel, ConfigDict
3
+ from tortoise.contrib.pydantic.creator import pydantic_model_creator, pydantic_queryset_creator
4
+ from tortoise.queryset import QuerySet
5
+
6
+ class UTTFund(models.Model):
7
+ id = fields.IntField(pk=True)
8
+ symbol = fields.CharField(max_length=20, unique=True)
9
+ name = fields.CharField(max_length=100)
10
+
11
+ @staticmethod
12
+ async def get_list(data):
13
+ if type(data) == type([UTTFund]):
14
+ parser=pydantic_queryset_creator(UTTFund)
15
+ return await parser.from_queryset(data)
16
+
17
+ async def to_dict(self):
18
+ if type(self) == UTTFund:
19
+ parser=pydantic_model_creator(UTTFund)
20
+ return await parser.from_tortoise_orm(self)
21
+
22
+
23
+ class UTTFundData(models.Model):
24
+ id = fields.IntField(pk=True)
25
+ fund = fields.ForeignKeyField("models.UTTFund", related_name="data")
26
+ date = fields.DateField()
27
+ nav_per_unit = fields.FloatField()
28
+ sale_price_per_unit = fields.FloatField()
29
+ repurchase_price_per_unit = fields.FloatField()
30
+ outstanding_number_of_units = fields.BigIntField()
31
+ net_asset_value = fields.BigIntField()
32
+
33
+ @classmethod
34
+ async def get_list(data):
35
+ if type(data) == QuerySet:
36
+ parser=pydantic_queryset_creator(UTTFundData)
37
+ return await parser.from_queryset(data)
38
+
39
+
40
+ async def to_dict(self):
41
+ if type(self) == models.Model:
42
+ parser=pydantic_model_creator(UTTFundData)
43
+ return await parser.from_tortoise_orm(self)
44
+
45
+
46
+
47
+ class Meta:
48
+ unique_together = ("fund", "date")
App/routers/utt/routes.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
5
+ from App.routers.tasks.models import ImportTask
6
+ from App.routers.utt.utils import run_utt_import_task
7
+ from App.schemas import AppException
8
+ from tortoise.contrib.pydantic import pydantic_queryset_creator
9
+
10
+
11
+ UTTFundData_Pydantic_List = pydantic_queryset_creator(UTTFundData)
12
+ UTTFund_Pydantic_List = pydantic_queryset_creator(UTTFund)
13
+
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,
64
+ message=f"Retrieved {len(fund_list)} UTT funds with latest data",
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
+
91
+ @router.post("/import-all", response_model=ResponseModel)
92
+ async def queue_import_utt(background_tasks: BackgroundTasks):
93
+ task = await ImportTask.create(task_type="utt", status="pending", details={})
94
+ background_tasks.add_task(run_utt_import_task, task.id)
95
+ return ResponseModel(
96
+ success=True, message="UTT import task queued", data={"task_id": task.id}
97
+ )
App/routers/utt/schemas.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from datetime import date
3
+ from typing import List
4
+ from App.routers.tasks.schemas import ResponseModel
5
+
6
+ class UTTFundResponse(BaseModel):
7
+ id: int
8
+ symbol: str
9
+ name: str
10
+
11
+ class UTTFundDataResponse(BaseModel):
12
+ date: date
13
+ nav_per_unit: float
14
+ sale_price_per_unit: float
15
+ repurchase_price_per_unit: float
16
+ outstanding_number_of_units: int
17
+ net_asset_value: int
18
+
19
+ class UTTFundListResponse(BaseModel):
20
+ fund: UTTFundResponse
21
+ data: List[UTTFundDataResponse]
App/routers/utt/service.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import re
3
+ from datetime import datetime
4
+ import httpx
5
+
6
+ async def fetch_all_utt_data():
7
+ base_headers = {
8
+ 'Accept': 'application/json, text/javascript, */*; q=0.01',
9
+ 'Accept-Language': 'en-US,en;q=0.9',
10
+ 'Connection': 'keep-alive',
11
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
12
+ 'Origin': 'https://www.uttamis.co.tz',
13
+ 'Referer': 'https://www.uttamis.co.tz/fund-performance',
14
+ 'Sec-Fetch-Dest': 'empty',
15
+ 'Sec-Fetch-Mode': 'cors',
16
+ 'Sec-Fetch-Site': 'same-origin',
17
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
18
+ 'X-Requested-With': 'XMLHttpRequest',
19
+ 'sec-ch-ua': '"Google Chrome";v="135", "Not-A.Brand";v="8", "Chromium";v="135"',
20
+ 'sec-ch-ua-mobile': '?0',
21
+ 'sec-ch-ua-platform': '"Windows"',
22
+ }
23
+
24
+ all_data = []
25
+ start = 0
26
+ length = 4000
27
+ max_records = 5000
28
+
29
+ async with httpx.AsyncClient(timeout=30.0) as client:
30
+ # Fetch the initial page to get CSRF token and cookies
31
+ get_response = await client.get("https://www.uttamis.co.tz/fund-performance", headers=base_headers)
32
+ if get_response.status_code != 200:
33
+ print(f"Failed to fetch CSRF token: {get_response.status_code}")
34
+ return []
35
+
36
+ html_content = get_response.text
37
+ print(html_content[1000:2000])
38
+ csrf_token_match = re.search(r'<meta name="csrf-token" content="([^"]+)"', html_content)
39
+ if not csrf_token_match:
40
+ print("CSRF token not found.")
41
+ return []
42
+ csrf_token = csrf_token_match.group(1)
43
+
44
+ # Prepare POST headers with CSRF token
45
+ post_headers = base_headers.copy()
46
+ post_headers['X-CSRF-TOKEN'] = csrf_token
47
+
48
+ while start < max_records:
49
+ payload = {
50
+ "csrf-token": csrf_token,
51
+ "draw": "1",
52
+ "start": str(start),
53
+ "length": str(length),
54
+ "search[value]": "",
55
+ "search[regex]": "false",
56
+ }
57
+
58
+ # Columns configuration
59
+ for i, col in enumerate([
60
+ ("DT_RowIndex", False),
61
+ ("sname.name", True),
62
+ ("net_asset_value", True),
63
+ ("outstanding_number_of_units", True),
64
+ ("nav_per_unit", True),
65
+ ("sale_price_per_unit", True),
66
+ ("repurchase_price_per_unit", True),
67
+ ("date_valued", True),
68
+ ]):
69
+ data_key = col[0].split(".")[0]
70
+ payload[f"columns[{i}][data]"] = data_key
71
+ payload[f"columns[{i}][name]"] = col[0]
72
+ payload[f"columns[{i}][searchable]"] = str(col[1]).lower()
73
+ payload[f"columns[{i}][orderable]"] = str(col[1]).lower()
74
+ payload[f"columns[{i}][search][value]"] = ""
75
+ payload[f"columns[{i}][search][regex]"] = "false"
76
+
77
+ # Make the POST request
78
+ response = await client.post(
79
+ "https://www.uttamis.co.tz/navs",
80
+ data=payload,
81
+ headers=post_headers
82
+ )
83
+
84
+ if response.status_code != 200:
85
+ print(f"Request failed at offset {start}: {response.status_code}")
86
+ break
87
+
88
+ json_data = response.json()
89
+ rows = json_data.get("data", [])
90
+ if not rows:
91
+ break
92
+ all_data.extend(rows)
93
+
94
+ # Check if there are more records
95
+ if len(rows) < length:
96
+ break
97
+ start += length
98
+
99
+ return all_data
100
+
101
+ def parse_utt_api_row(row: dict) -> dict:
102
+ def clean_number(value: str) -> float:
103
+ return float(value.replace(',', ''))
104
+
105
+ date_str = row.get("date_valued")
106
+ try:
107
+ date = datetime.strptime(date_str, "%d-%b-%Y").date()
108
+ except ValueError:
109
+ date = datetime.strptime(date_str, "%d-%m-%Y").date()
110
+
111
+ return {
112
+ "date": date,
113
+ "nav_per_unit": clean_number(row["nav_per_unit"]),
114
+ "sale_price_per_unit": clean_number(row["sale_price_per_unit"]),
115
+ "repurchase_price_per_unit": clean_number(row["repurchase_price_per_unit"]),
116
+ "outstanding_number_of_units": int(clean_number(row["outstanding_number_of_units"])),
117
+ "net_asset_value": int(clean_number(row["net_asset_value"]))
118
+ }
119
+
120
+ # Example usage
121
+ async def main():
122
+ data = await fetch_all_utt_data()
123
+ parsed_data = [parse_utt_api_row(row) for row in data]
124
+ print(parsed_data)
125
+
126
+ if __name__ == "__main__":
127
+ asyncio.run(main())
App/routers/utt/utils.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from tortoise.transactions import in_transaction
3
+ from App.routers.tasks.models import ImportTask
4
+ from .models import UTTFund, UTTFundData
5
+ from .service import fetch_all_utt_data, parse_utt_api_row
6
+
7
+ async def run_utt_import_task(task_id: int):
8
+ try:
9
+ await ImportTask.filter(id=task_id).update(status="running")
10
+ raw_data = await fetch_all_utt_data()
11
+ if not raw_data:
12
+ await ImportTask.filter(id=task_id).update(status="failed", details={"error": "No data"})
13
+ return
14
+
15
+ for row in raw_data:
16
+ symbol = row["internal_name"]
17
+ name = row["scheme_name"]
18
+ fund, _ = await UTTFund.get_or_create(symbol=symbol, defaults={"name": name})
19
+
20
+ parsed = parse_utt_api_row(row)
21
+ exists = await UTTFundData.exists(fund=fund, date=parsed["date"])
22
+ if not exists:
23
+ await UTTFundData.create(fund=fund, **parsed)
24
+
25
+ await ImportTask.filter(id=task_id).update(status="completed")
26
+ except Exception as e:
27
+ await ImportTask.filter(id=task_id).update(status="failed", details={"error": str(e)})
App/schemas.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ )
Dockerfile ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -----------------------------------------------------------------------------
2
+ # Stage 1: Builder Stage
3
+ # This stage installs build-time dependencies and compiles Python packages
4
+ # into wheels, which can be installed in the final stage without build tools.
5
+ # -----------------------------------------------------------------------------
6
+ FROM python:3.11-slim-bullseye AS builder
7
+
8
+ # Set environment variables
9
+ ENV PYTHONDONTWRITEBYTECODE 1
10
+ ENV PYTHONUNBUFFERED 1
11
+
12
+ WORKDIR /app
13
+
14
+ # Install system dependencies required for building some of the Python packages
15
+ # (e.g., pandas, curl_cffi)
16
+ RUN apt-get update && apt-get install -y --no-install-recommends \
17
+ build-essential \
18
+ libcurl4-openssl-dev \
19
+ && apt-get clean \
20
+ && rm -rf /var/lib/apt/lists/*
21
+
22
+ # Install Python dependencies
23
+ COPY requirements.txt .
24
+ RUN pip install --upgrade pip
25
+ # Create a wheelhouse for all dependencies
26
+ RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
27
+
28
+
29
+ # -----------------------------------------------------------------------------
30
+ # Stage 2: Final Stage
31
+ # This is the final, lean image. It copies the pre-built wheels from the
32
+ # builder stage and runs the application.
33
+ # -----------------------------------------------------------------------------
34
+ FROM python:3.11-slim-bullseye
35
+
36
+ # Set environment variables
37
+ ENV PYTHONDONTWRITEBYTECODE 1
38
+ ENV PYTHONUNBUFFERED 1
39
+
40
+ WORKDIR /app
41
+
42
+ # Install only the runtime system dependencies needed
43
+ # libcurl4 is the runtime library for curl_cffi
44
+ RUN apt-get update && apt-get install -y --no-install-recommends \
45
+ libcurl4 \
46
+ && apt-get clean \
47
+ && rm -rf /var/lib/apt/lists/*
48
+
49
+ # Copy the pre-built wheels from the builder stage
50
+ COPY --from=builder /wheels /wheels
51
+
52
+ # Install the Python dependencies from the wheels
53
+ # This is much faster and doesn't require build tools in the final image
54
+ RUN pip install --no-cache /wheels/*
55
+
56
+ # Create a non-root user for security
57
+ RUN useradd -m -U -d /home/appuser appuser
58
+ USER appuser
59
+ WORKDIR /home/appuser
60
+
61
+ # Copy the application code into the container
62
+ # NOTE: The path 'my_app' should match your application's directory name
63
+ COPY --chown=appuser:appuser ./my_app .
64
+
65
+ # Expose the port the app runs on
66
+ EXPOSE 8000
67
+
68
+ # --- Database & Migrations Note ---
69
+ # The SQLite DB will be created inside the container.
70
+ # For persistence, mount a volume, e.g., -v ./my_data:/home/appuser/my_data
71
+ # Migrations should be run manually after starting the container.
72
+ # Example: docker exec <container_name> aerich upgrade
73
+
74
+ # Command to run the application
75
+ # Assumes your main file is `main.py` and your FastAPI instance is `app`.
76
+ # Change `main:app` if your file/variable names are different.
77
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
db.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from tortoise import Tortoise
2
+
3
+ DATABASE_URL = "sqlite://db.sqlite3"
4
+
5
+ TORTOISE_ORM = {
6
+ "connections": {"default": DATABASE_URL},
7
+ "apps": {
8
+ "models": {
9
+ "models": [
10
+ 'App.routers.stocks.models',
11
+ 'App.routers.tasks.models',
12
+ 'App.routers.utt.models',
13
+ 'App.routers.users.models',
14
+ 'App.routers.portfolio.models',
15
+ 'App.routers.bonds.models',
16
+ "aerich.models"
17
+ ],
18
+ "default_connection": "default",
19
+ }
20
+ }
21
+ }
22
+
23
+ async def init_db():
24
+ await Tortoise.init(
25
+ db_url=DATABASE_URL,
26
+ modules={'models': [
27
+ 'App.routers.stocks.models',
28
+ 'App.routers.tasks.models',
29
+ 'App.routers.utt.models',
30
+ 'App.routers.users.models',
31
+ 'App.routers.portfolio.models',
32
+ 'App.routers.bonds.models'
33
+ ]}
34
+ )
35
+ await Tortoise.generate_schemas()
36
+
37
+ async def close_db():
38
+ await Tortoise.close_connections()
39
+
40
+
41
+ async def clear_db():
42
+ for model in Tortoise.apps.get('models').values():
43
+ await model.all().delete()
main.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 App.schemas import ResponseModel,AppException
13
+
14
+ from db import init_db, close_db,clear_db
15
+
16
+ app = FastAPI(title="Uwekezaji API", description="Stock Market Data API")
17
+
18
+
19
+
20
+ @app.exception_handler(AppException)
21
+ async def custom_http_exception_handler(request: Request, exc: AppException):
22
+ return JSONResponse(
23
+ status_code=exc.status_code,
24
+ content=ResponseModel(
25
+ success=False,
26
+ message=exc.detail,
27
+ data=exc.data
28
+ ).dict()
29
+ )
30
+
31
+ @app.exception_handler(RequestValidationError)
32
+ async def validation_exception_handler(request: Request, exc: RequestValidationError):
33
+ return JSONResponse(
34
+ status_code=HTTP_400_BAD_REQUEST,
35
+ content=ResponseModel(
36
+ success=False,
37
+ message="Validation error",
38
+ data={"errors": exc.errors()}
39
+ ).dict()
40
+ )
41
+
42
+ # Configure CORS
43
+ app.add_middleware(
44
+ CORSMiddleware,
45
+ allow_origins=["*"],
46
+ allow_credentials=True,
47
+ allow_methods=["*"],
48
+ allow_headers=["*"],
49
+ )
50
+
51
+ # Include routers
52
+ app.include_router(stocks_router)
53
+ app.include_router(utt_router)
54
+ app.include_router(bonds_router)
55
+ app.include_router(tasks_router)
56
+ app.include_router(users_router)
57
+ app.include_router(portfolio_router)
58
+
59
+ # Database initialization and cleanup
60
+ @app.on_event("startup")
61
+ async def startup_event():
62
+ # Clear and reinitialize database on startup
63
+ await init_db()
64
+
65
+ @app.on_event("shutdown")
66
+ async def shutdown_event():
67
+ # await clear_db()
68
+ await close_db()
69
+
70
+ # Root endpoint
71
+ @app.get("/")
72
+ async def root():
73
+ return {"message": "Welcome to Uwekezaji API"}
migrations/models/0_20250525140513_init.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from tortoise import BaseDBAsyncClient
2
+
3
+
4
+ async def upgrade(db: BaseDBAsyncClient) -> str:
5
+ return """
6
+ CREATE TABLE IF NOT EXISTS "stocks" (
7
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
8
+ "symbol" VARCHAR(10) NOT NULL UNIQUE,
9
+ "name" VARCHAR(200) NOT NULL,
10
+ "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
11
+ "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
12
+ );
13
+ CREATE TABLE IF NOT EXISTS "stock_price_data" (
14
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
15
+ "date" DATE NOT NULL,
16
+ "opening_price" VARCHAR(40) NOT NULL,
17
+ "closing_price" VARCHAR(40) NOT NULL,
18
+ "high" VARCHAR(40) NOT NULL,
19
+ "low" VARCHAR(40) NOT NULL,
20
+ "volume" BIGINT NOT NULL,
21
+ "turnover" BIGINT NOT NULL,
22
+ "shares_in_issue" BIGINT NOT NULL,
23
+ "market_cap" BIGINT NOT NULL,
24
+ "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
25
+ "stock_id" INT NOT NULL REFERENCES "stocks" ("id") ON DELETE CASCADE,
26
+ CONSTRAINT "uid_stock_price_stock_i_1f3075" UNIQUE ("stock_id", "date")
27
+ );
28
+ CREATE TABLE IF NOT EXISTS "import_tasks" (
29
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
30
+ "task_type" VARCHAR(50) NOT NULL,
31
+ "status" VARCHAR(9) NOT NULL DEFAULT 'pending' /* PENDING: pending\nRUNNING: running\nCOMPLETED: completed\nFAILED: failed */,
32
+ "details" JSON,
33
+ "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
34
+ "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
35
+ );
36
+ CREATE TABLE IF NOT EXISTS "uttfund" (
37
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
38
+ "symbol" VARCHAR(20) NOT NULL UNIQUE,
39
+ "name" VARCHAR(100) NOT NULL
40
+ );
41
+ CREATE TABLE IF NOT EXISTS "uttfunddata" (
42
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
43
+ "date" DATE NOT NULL,
44
+ "nav_per_unit" REAL NOT NULL,
45
+ "sale_price_per_unit" REAL NOT NULL,
46
+ "repurchase_price_per_unit" REAL NOT NULL,
47
+ "outstanding_number_of_units" BIGINT NOT NULL,
48
+ "net_asset_value" BIGINT NOT NULL,
49
+ "fund_id" INT NOT NULL REFERENCES "uttfund" ("id") ON DELETE CASCADE,
50
+ CONSTRAINT "uid_uttfunddata_fund_id_4fe3c3" UNIQUE ("fund_id", "date")
51
+ );
52
+ CREATE TABLE IF NOT EXISTS "user" (
53
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
54
+ "username" VARCHAR(50) NOT NULL UNIQUE,
55
+ "email" VARCHAR(100) NOT NULL UNIQUE,
56
+ "hashed_password" VARCHAR(128) NOT NULL,
57
+ "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
58
+ );
59
+ CREATE TABLE IF NOT EXISTS "watchlist" (
60
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
61
+ "stock_id" INT REFERENCES "stocks" ("id") ON DELETE CASCADE,
62
+ "user_id" INT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
63
+ "utt_id" INT REFERENCES "uttfund" ("id") ON DELETE CASCADE
64
+ );
65
+ CREATE TABLE IF NOT EXISTS "portfolios" (
66
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
67
+ "name" VARCHAR(100) NOT NULL,
68
+ "description" TEXT,
69
+ "is_active" INT NOT NULL DEFAULT 1,
70
+ "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
71
+ "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
72
+ "user_id" INT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
73
+ CONSTRAINT "uid_portfolios_user_id_d03ed2" UNIQUE ("user_id", "name")
74
+ );
75
+ CREATE TABLE IF NOT EXISTS "portfolio_calendar" (
76
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
77
+ "event_date" DATE NOT NULL,
78
+ "event_type" VARCHAR(50) NOT NULL,
79
+ "title" VARCHAR(200) NOT NULL,
80
+ "description" TEXT,
81
+ "asset_type" VARCHAR(10),
82
+ "asset_id" INT,
83
+ "estimated_amount" VARCHAR(40),
84
+ "is_completed" INT NOT NULL DEFAULT 0,
85
+ "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
86
+ "portfolio_id" INT NOT NULL REFERENCES "portfolios" ("id") ON DELETE CASCADE
87
+ );
88
+ CREATE TABLE IF NOT EXISTS "portfolio_snapshots" (
89
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
90
+ "snapshot_date" DATE NOT NULL,
91
+ "total_value" VARCHAR(40) NOT NULL,
92
+ "stock_value" VARCHAR(40) NOT NULL DEFAULT 0,
93
+ "bond_value" VARCHAR(40) NOT NULL DEFAULT 0,
94
+ "utt_value" VARCHAR(40) NOT NULL DEFAULT 0,
95
+ "cash_value" VARCHAR(40) NOT NULL DEFAULT 0,
96
+ "total_cost" VARCHAR(40) NOT NULL,
97
+ "unrealized_gain_loss" VARCHAR(40) NOT NULL,
98
+ "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
99
+ "portfolio_id" INT NOT NULL REFERENCES "portfolios" ("id") ON DELETE CASCADE,
100
+ CONSTRAINT "uid_portfolio_s_portfol_dc81b0" UNIQUE ("portfolio_id", "snapshot_date")
101
+ ) /* Daily snapshots for performance tracking */;
102
+ CREATE TABLE IF NOT EXISTS "portfolio_stocks" (
103
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
104
+ "quantity" INT NOT NULL,
105
+ "purchase_price" VARCHAR(40) NOT NULL,
106
+ "purchase_date" DATE NOT NULL,
107
+ "notes" TEXT,
108
+ "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
109
+ "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
110
+ "portfolio_id" INT NOT NULL REFERENCES "portfolios" ("id") ON DELETE CASCADE,
111
+ "stock_id" INT NOT NULL REFERENCES "stocks" ("id") ON DELETE CASCADE
112
+ );
113
+ CREATE TABLE IF NOT EXISTS "portfolio_transactions" (
114
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
115
+ "transaction_type" VARCHAR(20) NOT NULL,
116
+ "asset_type" VARCHAR(10) NOT NULL,
117
+ "asset_id" INT NOT NULL,
118
+ "quantity" VARCHAR(40) NOT NULL,
119
+ "price" VARCHAR(40) NOT NULL,
120
+ "total_amount" VARCHAR(40) NOT NULL,
121
+ "transaction_date" DATE NOT NULL,
122
+ "notes" TEXT,
123
+ "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
124
+ "portfolio_id" INT NOT NULL REFERENCES "portfolios" ("id") ON DELETE CASCADE
125
+ ) /* Track all portfolio transactions for audit and reporting */;
126
+ CREATE TABLE IF NOT EXISTS "portfolio_utts" (
127
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
128
+ "units_held" VARCHAR(40) NOT NULL,
129
+ "purchase_price" VARCHAR(40) NOT NULL,
130
+ "purchase_date" DATE NOT NULL,
131
+ "notes" TEXT,
132
+ "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
133
+ "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
134
+ "portfolio_id" INT NOT NULL REFERENCES "portfolios" ("id") ON DELETE CASCADE,
135
+ "utt_fund_id" INT NOT NULL REFERENCES "uttfund" ("id") ON DELETE CASCADE
136
+ );
137
+ CREATE TABLE IF NOT EXISTS "bonds" (
138
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
139
+ "instrument_type" VARCHAR(50) NOT NULL,
140
+ "auction_number" INT NOT NULL,
141
+ "auction_date" DATE NOT NULL,
142
+ "maturity_years" VARCHAR(10) NOT NULL,
143
+ "maturity_date" DATE NOT NULL,
144
+ "effective_date" DATE NOT NULL,
145
+ "dtm" INT NOT NULL,
146
+ "bond_auction_number" INT NOT NULL,
147
+ "holding_number" INT NOT NULL,
148
+ "face_value" BIGINT NOT NULL,
149
+ "price_per_100" REAL NOT NULL,
150
+ "coupon_rate" REAL NOT NULL,
151
+ CONSTRAINT "uid_bonds_auction_2fb1f0" UNIQUE ("auction_number", "auction_date")
152
+ );
153
+ CREATE TABLE IF NOT EXISTS "portfolio_bonds" (
154
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
155
+ "face_value_held" BIGINT NOT NULL,
156
+ "purchase_price" VARCHAR(40) NOT NULL,
157
+ "purchase_date" DATE NOT NULL,
158
+ "notes" TEXT,
159
+ "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
160
+ "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
161
+ "bond_id" INT NOT NULL REFERENCES "bonds" ("id") ON DELETE CASCADE,
162
+ "portfolio_id" INT NOT NULL REFERENCES "portfolios" ("id") ON DELETE CASCADE
163
+ );
164
+ CREATE TABLE IF NOT EXISTS "aerich" (
165
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
166
+ "version" VARCHAR(255) NOT NULL,
167
+ "app" VARCHAR(100) NOT NULL,
168
+ "content" JSON NOT NULL
169
+ );"""
170
+
171
+
172
+ async def downgrade(db: BaseDBAsyncClient) -> str:
173
+ return """
174
+ """
pyproject.toml ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ [tool.aerich]
2
+ tortoise_orm = "db.TORTOISE_ORM"
3
+ location = "./migrations"
4
+ src_folder = "./."
pytest.ini ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ [pytest]
2
+ pythonpath = .
3
+ addopts = -v
readme.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: XYHLF
3
+ emoji: 🚀
4
+ colorFrom: purple
5
+ colorTo: gray
6
+ sdk: docker
7
+ app_port: 7860
8
+ ---
requirements.txt ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core FastAPI Framework
2
+ fastapi==0.105.0
3
+ uvicorn==0.21.1
4
+ starlette==0.27.0
5
+ pydantic==2.10.2
6
+
7
+ # Database (Asyncio ORM and SQLite Driver)
8
+ tortoise-orm==0.21.7
9
+ aiosqlite==0.18.0
10
+
11
+ # Database Migrations
12
+ aerich==0.8.0
13
+
14
+ # Web Scraping & Data Handling
15
+ curl_cffi==0.11.1
16
+ pandas==2.1.4
17
+
18
+ # Key Dependencies
19
+ anyio==3.7.1
20
+ click==8.1.7
21
+ h11==0.14.0
22
+ numpy==1.26.4
23
+ python-dateutil==2.9.0.post0
24
+ pytz==2023.3.post1
25
+ typing_extensions==4.12.2
structure.txt ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ project/
2
+ ├── main.py
3
+ ├── routers/
4
+ │ ├── stocks/
5
+ │ │ ├── __init__.py
6
+ │ │ ├── models.py
7
+ │ │ ├── schemas.py
8
+ │ │ ├── service.py
9
+ │ │ └── routes.py
10
+ │ ├── utt/
11
+ │ │ ├── __init__.py
12
+ │ │ ├── models.py
13
+ │ │ ├── schemas.py
14
+ │ │ ├── service.py
15
+ │ │ └── routes.py
16
+ │ └── tasks/
17
+ │ ├── __init__.py
18
+ │ ├── models.py
19
+ │ ├── schemas.py
20
+ │ └── routes.py
21
+
22
+ # main.py
23
+ from fastapi import FastAPI, Request, HTTPException
24
+ from fastapi.responses import JSONResponse
25
+ fromApp.routers.stocks.routes import router as stocks_router
26
+ fromApp.routers.utt.routes import router as utt_router
27
+ fromApp.routers.tasks.routes import router as tasks_router
28
+
29
+ app = FastAPI()
30
+
31
+ @app.exception_handler(HTTPException)
32
+ async def http_exception_handler(request: Request, exc: HTTPException):
33
+ return JSONResponse(status_code=exc.status_code, content={"success": False, "message": exc.detail})
34
+
35
+ @app.exception_handler(Exception)
36
+ async def generic_exception_handler(request: Request, exc: Exception):
37
+ return JSONResponse(status_code=500, content={"success": False, "message": str(exc)})
38
+
39
+ app.include_router(stocks_router)
40
+ app.include_router(utt_router)
41
+ app.include_router(tasks_router)
42
+
43
+ # routers/tasks/models.py
44
+ from tortoise import fields, models
45
+ from enum import Enum
46
+
47
+ class TaskStatus(str, Enum):
48
+ PENDING = "pending"
49
+ RUNNING = "running"
50
+ COMPLETED = "completed"
51
+ FAILED = "failed"
52
+
53
+ class ImportTask(models.Model):
54
+ id = fields.IntField(pk=True)
55
+ task_type = fields.CharField(max_length=50)
56
+ status = fields.CharEnumField(TaskStatus, default=TaskStatus.PENDING)
57
+ details = fields.JSONField(null=True)
58
+ created_at = fields.DatetimeField(auto_now_add=True)
59
+ updated_at = fields.DatetimeField(auto_now=True)
60
+
61
+ class Meta:
62
+ table = "import_tasks"
63
+
64
+ # routers/tasks/schemas.py
65
+ from pydantic import BaseModel
66
+ from datetime import datetime
67
+ from enum import Enum
68
+
69
+ class TaskStatus(str, Enum):
70
+ PENDING = "pending"
71
+ RUNNING = "running"
72
+ COMPLETED = "completed"
73
+ FAILED = "failed"
74
+
75
+ class ImportTaskResponse(BaseModel):
76
+ id: int
77
+ task_type: str
78
+ status: TaskStatus
79
+ details: dict | None
80
+ created_at: datetime
81
+ updated_at: datetime
82
+
83
+ class ResponseModel(BaseModel):
84
+ success: bool
85
+ message: str
86
+ data: dict | list | None = None
87
+
88
+ # routers/tasks/routes.py
89
+ from fastapi import APIRouter, HTTPException
90
+ from .models import ImportTask
91
+ from .schemas import ImportTaskResponse, ResponseModel
92
+
93
+ router = APIRouter(prefix="/tasks", tags=["Tasks"])
94
+
95
+ @router.get("/", response_model=ResponseModel)
96
+ async def list_tasks():
97
+ tasks = await ImportTask.all().order_by("-created_at")
98
+ return ResponseModel(success=True, message="List of tasks", data=[task for task in tasks])
99
+
100
+ @router.get("/{task_id}", response_model=ResponseModel)
101
+ async def get_task(task_id: int):
102
+ task = await ImportTask.get_or_none(id=task_id)
103
+ if not task:
104
+ raise HTTPException(status_code=404, detail="Task not found")
105
+ return ResponseModel(success=True, message="Task found", data=task)
106
+
107
+ # You would follow the same modular structure for stocks and utt
108
+ # Including their own models.py, schemas.py, routes.py, service.py
109
+ # The routes would queue background tasks and use task_id for status tracking.
110
+
111
+
112
+
113
+ project/
114
+ ├── main.py
115
+ ├── routers/
116
+ │ ├── stocks/
117
+ │ │ ├── __init__.py
118
+ │ │ ├── models.py
119
+ │ │ ├── schemas.py
120
+ │ │ ├── service.py
121
+ │ │ └── routes.py
122
+ │ ├── utt/
123
+ │ │ ├── __init__.py
124
+ │ │ ├── models.py
125
+ │ │ ├── schemas.py
126
+ │ │ ├── service.py
127
+ │ │ └── routes.py
128
+ │ └── tasks/
129
+ │ ├── __init__.py
130
+ │ ├── models.py
131
+ │ ├── schemas.py
132
+ │ └── routes.py
133
+
134
+ # routers/stocks/models.py
135
+ from tortoise import fields, models
136
+
137
+ class Stock(models.Model):
138
+ id = fields.IntField(pk=True)
139
+ symbol = fields.CharField(max_length=10, unique=True)
140
+ name = fields.CharField(max_length=100)
141
+ sector = fields.CharField(max_length=100, null=True)
142
+
143
+ class StockPriceData(models.Model):
144
+ id = fields.IntField(pk=True)
145
+ stock = fields.ForeignKeyField("models.Stock", related_name="prices")
146
+ date = fields.DateField()
147
+ opening_price = fields.FloatField()
148
+ closing_price = fields.FloatField()
149
+ high = fields.FloatField()
150
+ low = fields.FloatField()
151
+ volume = fields.IntField()
152
+ turnover = fields.BigIntField()
153
+ shares_in_issue = fields.BigIntField()
154
+ market_cap = fields.BigIntField()
155
+
156
+ class Meta:
157
+ unique_together = ("stock", "date")
158
+
159
+ # routers/stocks/schemas.py
160
+ from pydantic import BaseModel
161
+ from datetime import date
162
+ from typing import List, Optional
163
+ fromApp.routers.tasks.schemas import ResponseModel
164
+
165
+ class StockBase(BaseModel):
166
+ symbol: str
167
+ name: str
168
+ sector: Optional[str] = None
169
+
170
+ class StockResponse(StockBase):
171
+ id: int
172
+
173
+ class StockPriceResponse(BaseModel):
174
+ date: date
175
+ opening_price: float
176
+ closing_price: float
177
+ high: float
178
+ low: float
179
+ volume: int
180
+ turnover: int
181
+ shares_in_issue: int
182
+ market_cap: int
183
+
184
+ class StockPriceListResponse(BaseModel):
185
+ stock: StockResponse
186
+ prices: List[StockPriceResponse]
187
+
188
+ # routers/stocks/service.py
189
+ from datetime import datetime
190
+
191
+ async def fetch_stock_data(symbol: str):
192
+ import httpx
193
+ url = f"https://dse.co.tz/api/get/market/prices/for/range/duration?security_code={symbol}&days=5000&class=EQUITY"
194
+ async with httpx.AsyncClient() as client:
195
+ response = await client.get(url)
196
+ if response.status_code == 200:
197
+ return response.json().get("data", [])
198
+ return []
199
+
200
+ def parse_stock_api_row(row: dict) -> dict:
201
+ return {
202
+ "date": datetime.fromisoformat(row["trade_date"]).date(),
203
+ "opening_price": row["opening_price"],
204
+ "closing_price": row["closing_price"],
205
+ "high": row["high"],
206
+ "low": row["low"],
207
+ "volume": row["volume"],
208
+ "turnover": row["turnover"],
209
+ "shares_in_issue": row["shares_in_issue"],
210
+ "market_cap": row["market_cap"],
211
+ }
212
+
213
+ # routers/stocks/routes.py
214
+ from fastapi import APIRouter, HTTPException, BackgroundTasks
215
+ from tortoise.transactions import in_transaction
216
+ from .models import Stock, StockPriceData
217
+ from .schemas import StockResponse, StockPriceListResponse, ResponseModel
218
+ from .service import fetch_stock_data, parse_stock_api_row
219
+ fromApp.routers.tasks.models import ImportTask
220
+ from typing import List
221
+
222
+ router = APIRouter(prefix="/stocks", tags=["Stocks"])
223
+
224
+ @router.get("/", response_model=ResponseModel)
225
+ async def list_stocks():
226
+ stocks = await Stock.all()
227
+ return ResponseModel(success=True, message="List of stocks", data=stocks)
228
+
229
+ @router.get("/{symbol}", response_model=ResponseModel)
230
+ async def get_stock_prices(symbol: str):
231
+ stock = await Stock.get_or_none(symbol=symbol)
232
+ if not stock:
233
+ raise HTTPException(status_code=404, detail="Stock not found")
234
+ prices = await StockPriceData.filter(stock=stock).order_by("-date")
235
+ return ResponseModel(success=True, message="Stock price data", data={"stock": stock, "prices": prices})
236
+
237
+ @router.post("/import/{symbol}", response_model=ResponseModel)
238
+ async def queue_import_stock(symbol: str, background_tasks: BackgroundTasks):
239
+ task = await ImportTask.create(task_type="stocks", status="pending", details={"symbol": symbol})
240
+ background_tasks.add_task(run_stock_import_task, task.id, symbol)
241
+ return ResponseModel(success=True, message="Stock import task queued", data={"task_id": task.id})
242
+
243
+ async def run_stock_import_task(task_id: int, symbol: str):
244
+ try:
245
+ await ImportTask.filter(id=task_id).update(status="running")
246
+ raw_data = await fetch_stock_data(symbol)
247
+ if not raw_data:
248
+ await ImportTask.filter(id=task_id).update(status="failed", details={"error": "No data"})
249
+ return
250
+
251
+ first = raw_data[0]
252
+ stock, _ = await Stock.get_or_create(symbol=first["company"], defaults={"name": first["fullName"]})
253
+
254
+ existing_dates = set(await StockPriceData.filter(stock=stock).values_list("date", flat=True))
255
+
256
+ records = []
257
+ for row in raw_data:
258
+ parsed = parse_stock_api_row(row)
259
+ if parsed["date"] not in existing_dates:
260
+ records.append(StockPriceData(stock=stock, **parsed))
261
+
262
+ async with in_transaction():
263
+ await StockPriceData.bulk_create(records, ignore_conflicts=True)
264
+
265
+ await ImportTask.filter(id=task_id).update(status="completed")
266
+ except Exception as e:
267
+ await ImportTask.filter(id=task_id).update(status="failed", details={"error": str(e)})
268
+
269
+
270
+ # routers/utt/models.py
271
+ from tortoise import fields, models
272
+
273
+ class UTTFund(models.Model):
274
+ id = fields.IntField(pk=True)
275
+ symbol = fields.CharField(max_length=20, unique=True)
276
+ name = fields.CharField(max_length=100)
277
+
278
+ class UTTFundData(models.Model):
279
+ id = fields.IntField(pk=True)
280
+ fund = fields.ForeignKeyField("models.UTTFund", related_name="data")
281
+ date = fields.DateField()
282
+ nav_per_unit = fields.FloatField()
283
+ sale_price_per_unit = fields.FloatField()
284
+ repurchase_price_per_unit = fields.FloatField()
285
+ outstanding_number_of_units = fields.BigIntField()
286
+ net_asset_value = fields.BigIntField()
287
+
288
+ class Meta:
289
+ unique_together = ("fund", "date")
290
+
291
+ # routers/utt/schemas.py
292
+ from pydantic import BaseModel
293
+ from datetime import date
294
+ from typing import List
295
+ fromApp.routers.tasks.schemas import ResponseModel
296
+
297
+ class UTTFundResponse(BaseModel):
298
+ id: int
299
+ symbol: str
300
+ name: str
301
+
302
+ class UTTFundDataResponse(BaseModel):
303
+ date: date
304
+ nav_per_unit: float
305
+ sale_price_per_unit: float
306
+ repurchase_price_per_unit: float
307
+ outstanding_number_of_units: int
308
+ net_asset_value: int
309
+
310
+ class UTTFundListResponse(BaseModel):
311
+ fund: UTTFundResponse
312
+ data: List[UTTFundDataResponse]
313
+
314
+ # routers/utt/service.py
315
+ from datetime import datetime
316
+ import httpx
317
+
318
+ async def fetch_all_utt_data():
319
+ url = "https://example.com/utt/api" # Placeholder
320
+ async with httpx.AsyncClient() as client:
321
+ response = await client.get(url)
322
+ if response.status_code == 200:
323
+ return response.json().get("data", [])
324
+ return []
325
+
326
+ def parse_utt_api_row(row: dict) -> dict:
327
+ return {
328
+ "date": datetime.strptime(row["date_valued"], "%d-%m-%Y").date(),
329
+ "nav_per_unit": float(row["nav_per_unit"]),
330
+ "sale_price_per_unit": float(row["sale_price_per_unit"]),
331
+ "repurchase_price_per_unit": float(row["repurchase_price_per_unit"]),
332
+ "outstanding_number_of_units": int(float(row["outstanding_number_of_units"])),
333
+ "net_asset_value": int(float(row["net_asset_value"]))
334
+ }
335
+
336
+ # routers/utt/routes.py
337
+ from fastapi import APIRouter, BackgroundTasks, HTTPException
338
+ from .models import UTTFund, UTTFundData
339
+ from .schemas import UTTFundResponse, UTTFundListResponse, ResponseModel
340
+ from .service import fetch_all_utt_data, parse_utt_api_row
341
+ fromApp.routers.tasks.models import ImportTask
342
+
343
+ router = APIRouter(prefix="/utt", tags=["UTT"])
344
+
345
+ @router.get("/", response_model=ResponseModel)
346
+ async def list_funds():
347
+ funds = await UTTFund.all()
348
+ return ResponseModel(success=True, message="List of UTT funds", data=funds)
349
+
350
+ @router.get("/{symbol}", response_model=ResponseModel)
351
+ async def get_fund_data(symbol: str):
352
+ fund = await UTTFund.get_or_none(symbol=symbol)
353
+ if not fund:
354
+ raise HTTPException(status_code=404, detail="Fund not found")
355
+ data = await UTTFundData.filter(fund=fund).order_by("-date")
356
+ return ResponseModel(success=True, message="Fund data", data={"fund": fund, "data": data})
357
+
358
+ @router.post("/import-all", response_model=ResponseModel)
359
+ async def queue_import_utt(background_tasks: BackgroundTasks):
360
+ task = await ImportTask.create(task_type="utt", status="pending", details={})
361
+ background_tasks.add_task(run_utt_import_task, task.id)
362
+ return ResponseModel(success=True, message="UTT import task queued", data={"task_id": task.id})
363
+
364
+ async def run_utt_import_task(task_id: int):
365
+ from tortoise.transactions import in_transaction
366
+ try:
367
+ await ImportTask.filter(id=task_id).update(status="running")
368
+ raw_data = await fetch_all_utt_data()
369
+ if not raw_data:
370
+ await ImportTask.filter(id=task_id).update(status="failed", details={"error": "No data"})
371
+ return
372
+
373
+ for row in raw_data:
374
+ symbol = row["internal_name"]
375
+ name = row["scheme_name"]
376
+ fund, _ = await UTTFund.get_or_create(symbol=symbol, defaults={"name": name})
377
+
378
+ parsed = parse_utt_api_row(row)
379
+ exists = await UTTFundData.exists(fund=fund, date=parsed["date"])
380
+ if not exists:
381
+ await UTTFundData.create(fund=fund, **parsed)
382
+
383
+ await ImportTask.filter(id=task_id).update(status="completed")
384
+ except Exception as e:
385
+ await ImportTask.filter(id=task_id).update(status="failed", details={"error": str(e)})
386
+
387
+
388
+
tests/conftest.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from tortoise import Tortoise
3
+
4
+ @pytest.fixture(scope="session")
5
+ async def initialize_tests(request):
6
+ """Initialize test database"""
7
+ db_config = {
8
+ 'connections': {
9
+ 'default': {
10
+ 'engine': 'tortoise.backends.sqlite',
11
+ 'credentials': {
12
+ 'file_path': ':memory:',
13
+ }
14
+ },
15
+ },
16
+ 'apps': {
17
+ 'models': {
18
+ 'models': [
19
+ 'App.routers.stocks.models',
20
+ 'App.routers.tasks.models',
21
+ 'App.routers.utt.models',
22
+ 'App.routers.users.models',
23
+ 'App.routers.portfolio.models',
24
+ 'App.routers.bonds.models'
25
+ ],
26
+ 'default_connection': 'default',
27
+ }
28
+ }
29
+ }
30
+
31
+ await Tortoise.init(config=db_config)
32
+ await Tortoise.generate_schemas()
33
+
34
+ yield
35
+
36
+ await Tortoise.close_connections()
37
+
38
+ @pytest.fixture
39
+ async def client():
40
+ """Create a test client"""
41
+ from httpx import AsyncClient
42
+ from main import app
43
+
44
+ async with AsyncClient(app=app, base_url="http://test") as client:
45
+ yield client
tests/test_portfolio.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import httpx
3
+ pytest_plugins = ["pytest_asyncio"]
4
+
5
+ @pytest.mark.asyncio
6
+ async def test_create_portfolio(client, initialize_tests):
7
+ async with httpx.AsyncClient() as async_client:
8
+ # Test successful creation
9
+ response = await async_client.post("http://localhost:8001/portfolio/", json={
10
+ "name": "Test Portfolio",
11
+ "description": "Test portfolio description"
12
+ }, params={"user_id":33 })
13
+
14
+ assert response.status_code == 200
15
+ data = response.json()
16
+ assert data["success"] == True
17
+ assert data["message"] == "Portfolio created successfully"
18
+ assert data["data"]["name"] == "Test Portfolio"
19
+ assert data["data"]["description"] == "Test portfolio description"
20
+ assert "id" in data["data"]
21
+ assert "user_id" in data["data"]
22
+
23
+ @pytest.mark.asyncio
24
+ async def test_get_portfolio(client, initialize_tests):
25
+ async with httpx.AsyncClient() as async_client:
26
+ # Create a portfolio first
27
+ create_response = await async_client.post("http://localhost:8001/portfolio/", json={
28
+ "name": "Test Portfolio"
29
+ }, params={"user_id": 33})
30
+ portfolio_id = create_response.json()["data"]["id"]
31
+
32
+ # Test successful retrieval
33
+ response = await async_client.get(f"http://localhost:8001/portfolio/{portfolio_id}")
34
+ assert response.status_code == 200
35
+ data = response.json()
36
+ assert data["success"] == True
37
+ assert data["message"] == "Portfolio retrieved successfully"
38
+
39
+ # Test not found
40
+ response = await async_client.get("http://localhost:8001/portfolio/99999")
41
+ assert response.status_code == 404
42
+ data = response.json()
43
+ assert data["success"] == False
44
+ assert "Portfolio not found" in data["detail"]
45
+
46
+ @pytest.mark.asyncio
47
+ async def test_add_stock(client, initialize_tests):
48
+ async with httpx.AsyncClient() as async_client:
49
+ # Create portfolio first
50
+ create_response = await async_client.post("http://localhost:8001/portfolio/", json={
51
+ "name": "Test Portfolio"
52
+ }, params={"user_id": 33})
53
+ portfolio_id = create_response.json()["data"]["id"]
54
+
55
+ # Import a test stock
56
+ await async_client.post("http://localhost:8001/stocks/import/CRDB")
57
+
58
+ # Test successful addition
59
+ response = await async_client.post(f"http://localhost:8001/portfolio/{portfolio_id}/stocks", json={
60
+ "stock_id": 1,
61
+ "quantity": 100,
62
+ "purchase_price": 1000,
63
+ "purchase_date": "2023-01-01"
64
+ })
65
+
66
+ assert response.status_code == 200
67
+ data = response.json()
68
+ assert data["success"] == True
69
+ assert data["message"] == "Stock added to portfolio successfully"
70
+ assert data["data"]["quantity"] == 100
71
+
72
+ # Test invalid portfolio
73
+ response = await async_client.post("http://localhost:8001/portfolio/99999/stocks", json={
74
+ "stock_id": 1,
75
+ "quantity": 100,
76
+ "purchase_price": 1000,
77
+ "purchase_date": "2023-01-01"
78
+ })
79
+ assert response.status_code == 404
80
+ assert "Portfolio not found" in response.json()["detail"]
81
+
82
+ # Test invalid stock
83
+ response = await async_client.post(f"http://localhost:8001/portfolio/{portfolio_id}/stocks", json={
84
+ "stock_id": 99999,
85
+ "quantity": 100,
86
+ "purchase_price": 1000,
87
+ "purchase_date": "2023-01-01"
88
+ })
89
+ assert response.status_code == 404
90
+ assert "Stock not found" in response.json()["detail"]
91
+
92
+ @pytest.mark.asyncio
93
+ async def test_add_utt(client, initialize_tests):
94
+ async with httpx.AsyncClient() as async_client:
95
+ # Create portfolio first
96
+ create_response = await async_client.post("http://localhost:8001/portfolio/", json={
97
+ "name": "Test Portfolio"
98
+ }, params={"user_id": 33})
99
+ portfolio_id = create_response.json()["data"]["id"]
100
+
101
+ # Import UTT funds
102
+ await async_client.post("http://localhost:8001/utt/import-all")
103
+
104
+ # Test successful addition
105
+ response = await async_client.post(f"http://localhost:8001/portfolio/{portfolio_id}/utts", json={
106
+ "utt_fund_id": 1,
107
+ "units": 100,
108
+ "purchase_price": 1000,
109
+ "purchase_date": "2023-01-01"
110
+ })
111
+
112
+ assert response.status_code == 200
113
+ data = response.json()
114
+ assert data["success"] == True
115
+ assert data["message"] == "UTT added to portfolio successfully"
116
+ assert data["data"]["units"] == 100
117
+ assert "id" in data["data"]
118
+
119
+ @pytest.mark.asyncio
120
+ async def test_add_bond(client, initialize_tests):
121
+ async with httpx.AsyncClient() as async_client:
122
+ # Create portfolio first
123
+ create_response = await async_client.post("http://localhost:8001/portfolio/", json={
124
+ "name": "Test Portfolio"
125
+ }, params={"user_id": 33})
126
+ portfolio_id = create_response.json()["data"]["id"]
127
+
128
+ # Import bonds
129
+ await async_client.post("http://localhost:8001/bonds/import-all")
130
+
131
+ # Test successful addition
132
+ response = await async_client.post(f"http://localhost:8001/portfolio/{portfolio_id}/bonds", json={
133
+ "bond_id": 1,
134
+ "face_value": 10000,
135
+ "purchase_date": "2023-01-01",
136
+ "maturity_date": "2024-01-01"
137
+ })
138
+
139
+ assert response.status_code == 200
140
+ data = response.json()
141
+ assert "face_value" in data
142
+ assert data["face_value"] == 10000
143
+
144
+ @pytest.mark.asyncio
145
+ async def test_add_calendar_event(client, initialize_tests):
146
+ async with httpx.AsyncClient() as async_client:
147
+ # Create portfolio first
148
+ create_response = await async_client.post("http://localhost:8001/portfolio/", json={
149
+ "name": "Test Portfolio"
150
+ }, params={"user_id": 33})
151
+ portfolio_id = create_response.json()["data"]["id"]
152
+
153
+ # Test successful addition
154
+ response = await async_client.post(f"http://localhost:8001/portfolio/{portfolio_id}/calendar", json={
155
+ "title": "Test Event",
156
+ "description": "Test event description",
157
+ "event_date": "2023-01-01",
158
+ "event_type": "dividend"
159
+ })
160
+
161
+ assert response.status_code == 200
162
+ data = response.json()
163
+ assert "title" in data
164
+ assert data["title"] == "Test Event"
165
+
166
+ @pytest.mark.asyncio
167
+ async def test_remove_items(client, initialize_tests):
168
+ async with httpx.AsyncClient() as async_client:
169
+ # Create portfolio and add items first
170
+ create_response = await async_client.post("http://localhost:8001/portfolio/", json={
171
+ "name": "Test Portfolio"
172
+ }, params={"user_id": 33})
173
+ portfolio_id = create_response.json()["data"]["id"]
174
+
175
+ # Add a stock first
176
+ await async_client.post("http://localhost:8001/stocks/import/CRDB")
177
+ stock_response = await async_client.post(f"http://localhost:8001/portfolio/{portfolio_id}/stocks", json={
178
+ "stock_id": 1,
179
+ "quantity": 100,
180
+ "purchase_price": 1000,
181
+ "purchase_date": "2023-01-01"
182
+ })
183
+ stock_id = stock_response.json()["data"]["id"]
184
+
185
+ # Test remove stock
186
+ response = await async_client.delete(f"http://localhost:8001/portfolio/{portfolio_id}/stocks/{stock_id}")
187
+ assert response.status_code == 200
188
+ assert response.json()["message"] == "Stock removed from portfolio"
189
+
190
+ # Test remove with invalid IDs
191
+ response = await async_client.delete(f"http://localhost:8001/portfolio/{portfolio_id}/stocks/99999")
192
+ assert response.status_code == 404
193
+ assert "Stock not found in portfolio" in response.json()["detail"]
tests/test_stocks.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import httpx
3
+ pytest_plugins = ["pytest_asyncio"]
4
+
5
+ @pytest.mark.asyncio
6
+ async def test_queue_import_stock(client, initialize_tests):
7
+ async with httpx.AsyncClient() as async_client:
8
+ response = await async_client.post("http://localhost:8001/stocks/import/CRDB")
9
+ assert response.status_code == 200
10
+ data = response.json()
11
+ assert data["success"] == True
12
+ assert data["message"] == "Stock import task queued"
13
+ assert "task_id" in data["data"]
14
+
15
+ @pytest.mark.asyncio
16
+ async def test_get_stock_prices_not_found(client, initialize_tests):
17
+ async with httpx.AsyncClient() as async_client:
18
+ response = await async_client.get("http://localhost:8001/stocks/INVALID/prices")
19
+ assert response.status_code == 404
20
+ data = response.json()
21
+ assert data["success"] == False
22
+ assert "Stock not found" in data["message"]
23
+
24
+ @pytest.mark.asyncio
25
+ async def test_get_stock_prices(client, initialize_tests):
26
+ async with httpx.AsyncClient() as async_client:
27
+ # First import a stock
28
+ await async_client.post("http://localhost:8001/stocks/import/CRDB")
29
+
30
+ # Then get its prices
31
+ response = await async_client.get("http://localhost:8001/stocks/CRDB/prices")
32
+ assert response.status_code == 200
33
+ data = response.json()
34
+ assert data["success"] == True
35
+ assert data["message"] == "Stock prices retrieved"
36
+ assert "prices" in data["data"]
37
+
38
+ @pytest.mark.asyncio
39
+ async def test_get_stock_metrics(client, initialize_tests):
40
+ async with httpx.AsyncClient() as async_client:
41
+ # First import a stock
42
+ await async_client.post("http://localhost:8001/stocks/import/CRDB")
43
+
44
+ # Then get its metrics
45
+ response = await async_client.get("http://localhost:8001/stocks/CRDB/metrics")
46
+ assert response.status_code == 200
47
+ data = response.json()
48
+ print(data)
49
+ assert data["success"] == True
50
+ assert data["message"] == "Stock metrics calculated"
51
+ assert "metrics" in data["data"]
tests/test_users.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import httpx
3
+ pytest_plugins = ["pytest_asyncio"]
4
+
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"
12
+ })
13
+
14
+ assert response.status_code == 200
15
+ data = response.json()
16
+ assert data["success"] == True
17
+ assert data["data"]["username"] == "testuser"
18
+ assert data["data"]["email"] == "test@example.com"
19
+
20
+ @pytest.mark.asyncio
21
+ async def test_register_duplicate_email(client, initialize_tests):
22
+ # First registration
23
+ async with httpx.AsyncClient() as async_client:
24
+ await async_client.post("http://localhost:8001/users/register", json={
25
+ "username": "existing",
26
+ "email": "existing@example.com",
27
+ "password": "password123"
28
+ })
29
+
30
+ # Attempt duplicate registration
31
+ response = await async_client.post("http://localhost:8001/users/register", json={
32
+ "username": "testwuser",
33
+ "email": "existing@example.com",
34
+ "password": "testpassword123"
35
+ })
36
+
37
+ assert response.status_code == 400
38
+ data = response.json()
39
+ print(data)
40
+ assert data["success"] == False
41
+ assert "Email already registered" in data["message"]
42
+
43
+
44
+ @pytest.mark.asyncio
45
+ async def test_get_portfolio(client, initialize_tests):
46
+ async with httpx.AsyncClient() as async_client:
47
+ # First create a user
48
+ register_response = await async_client.post("http://localhost:8001/users/register", json={
49
+ "username": "portfoliouser",
50
+ "email": "portfolio@example.com",
51
+ "password": "password123"
52
+ })
53
+ user_id = register_response.json()["data"]["id"]
54
+
55
+ # Create a portfolio for the user
56
+ portfolio_response = await async_client.post(f"http://localhost:8001/users/{user_id}/portfolio", json={
57
+ "name": "Test Portfolio"
58
+ })
59
+ assert portfolio_response.status_code == 200
60
+ assert portfolio_response.json()["success"] == True
61
+
62
+ # Get the portfolio
63
+ get_response = await async_client.get(f"http://localhost:8001/users/{user_id}/portfolio")
64
+ assert get_response.status_code == 200
65
+ data = get_response.json()
66
+ assert data["success"] == True
67
+ assert "data" in data
68
+
69
+
tests/test_utt.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import httpx
3
+ pytest_plugins = ["pytest_asyncio"]
4
+
5
+ # @pytest.mark.asyncio
6
+ # async def test_list_funds(client, initialize_tests):
7
+ # async with httpx.AsyncClient() as async_client:
8
+ # # Import funds first
9
+ # await async_client.post("http://localhost:8001/utt/import-all")
10
+
11
+ # response = await async_client.get("http://localhost:8001/utt/")
12
+ # assert response.status_code == 200
13
+ # data = response.json()
14
+ # assert data["success"] == True
15
+ # assert "data" in data
16
+ # assert len(data["data"]) > 0
17
+
18
+ @pytest.mark.asyncio
19
+ async def test_get_fund_data(client, initialize_tests):
20
+ async with httpx.AsyncClient() as async_client:
21
+ # Import funds first
22
+ # await async_client.post("http://localhost:8001/utt/import-all")
23
+
24
+ # Get specific fund data
25
+ response = await async_client.get("http://localhost:8001/utt/umoja")
26
+ assert response.status_code == 200
27
+ data = response.json()
28
+ assert data["success"] == True
29
+ assert data["data"]["fund"]["symbol"] == "umoja"
30
+ assert "data" in data["data"]
31
+
32
+ @pytest.mark.asyncio
33
+ async def test_get_fund_data_not_found(client, initialize_tests):
34
+ async with httpx.AsyncClient() as async_client:
35
+ response = await async_client.get("http://localhost:8001/utt/INVALID")
36
+ assert response.status_code == 404
37
+ data = response.json()
38
+ assert data["success"] == False
39
+ assert "Fund not found" in data["message"]
40
+
41
+ # @pytest.mark.asyncio
42
+ # async def test_queue_import_utt(client, initialize_tests):
43
+ # async with httpx.AsyncClient() as async_client:
44
+ # response = await async_client.post("http://localhost:8001/utt/import-all")
45
+ # assert response.status_code == 200
46
+ # data = response.json()
47
+ # assert data["success"] == True
48
+ # assert "task_id" in data["data"]
vercel.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "builds": [
3
+ {
4
+ "src": "main.py",
5
+ "use": "@vercel/python"
6
+ }
7
+ ],
8
+ "routes": [
9
+ {
10
+ "src": "/(.*)",
11
+ "dest": "main.py"
12
+ }
13
+ ]
14
+ }
15
+