Spaces:
Sleeping
Sleeping
Commit ·
9d4bd7c
0
Parent(s):
initial commit
Browse files- .gitignore +32 -0
- App/__init__.py +0 -0
- App/requirements.txt +45 -0
- App/routers/bonds/__init__.py +3 -0
- App/routers/bonds/models.py +44 -0
- App/routers/bonds/routes.py +126 -0
- App/routers/bonds/schemas.py +24 -0
- App/routers/bonds/service.py +23 -0
- App/routers/bonds/utils.py +258 -0
- App/routers/portfolio/models.py +183 -0
- App/routers/portfolio/routes.py +1301 -0
- App/routers/portfolio/schemas.py +419 -0
- App/routers/portfolio/service.py +996 -0
- App/routers/portfolio/utils.py +33 -0
- App/routers/stocks/crud.py +134 -0
- App/routers/stocks/metrics.py +47 -0
- App/routers/stocks/models.py +97 -0
- App/routers/stocks/routes.py +232 -0
- App/routers/stocks/schemas.py +48 -0
- App/routers/stocks/service.py +9 -0
- App/routers/stocks/utils.py +258 -0
- App/routers/tasks/models.py +19 -0
- App/routers/tasks/routes.py +26 -0
- App/routers/tasks/schemas.py +23 -0
- App/routers/users/models.py +42 -0
- App/routers/users/routes.py +70 -0
- App/routers/users/schemas.py +28 -0
- App/routers/users/utils.py +11 -0
- App/routers/utt/models.py +48 -0
- App/routers/utt/routes.py +97 -0
- App/routers/utt/schemas.py +21 -0
- App/routers/utt/service.py +127 -0
- App/routers/utt/utils.py +27 -0
- App/schemas.py +25 -0
- Dockerfile +77 -0
- db.py +43 -0
- main.py +73 -0
- migrations/models/0_20250525140513_init.py +174 -0
- pyproject.toml +4 -0
- pytest.ini +3 -0
- readme.md +8 -0
- requirements.txt +25 -0
- structure.txt +388 -0
- tests/conftest.py +45 -0
- tests/test_portfolio.py +193 -0
- tests/test_stocks.py +51 -0
- tests/test_users.py +69 -0
- tests/test_utt.py +48 -0
- 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 |
+
|