Mbonea commited on
Commit
a4e9550
·
1 Parent(s): 7972dcf

Add curated news and bulk position APIs

Browse files
App/routers/portfolio/routes.py CHANGED
@@ -47,6 +47,7 @@ from .schemas import (
47
  BondHoldingCreate,
48
  BondHoldingUpdate,
49
  BondSellSchema,
 
50
  CalendarEventCreate,
51
  CalendarEventResponse,
52
  TransactionDetailResponse,
@@ -961,6 +962,96 @@ async def add_bond(
961
  )
962
 
963
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
964
  @router.post("/{portfolio_id}/bonds/{bond_id}/sell", summary="Sell bond")
965
  async def sell_bond(
966
  portfolio_id: int,
 
47
  BondHoldingCreate,
48
  BondHoldingUpdate,
49
  BondSellSchema,
50
+ BulkPortfolioPositionsUpload,
51
  CalendarEventCreate,
52
  CalendarEventResponse,
53
  TransactionDetailResponse,
 
962
  )
963
 
964
 
965
+ @router.post("/{portfolio_id}/positions/bulk", summary="Bulk upload portfolio positions")
966
+ async def bulk_upload_positions(
967
+ portfolio_id: int,
968
+ payload: BulkPortfolioPositionsUpload,
969
+ background_tasks: BackgroundTasks,
970
+ current_user=Depends(get_current_user),
971
+ ):
972
+ await _verify_ownership(portfolio_id, current_user, active_only=True)
973
+ results = []
974
+ earliest_date = None
975
+
976
+ for index, item in enumerate(payload.positions, start=1):
977
+ asset_type = item.asset_type.upper()
978
+ try:
979
+ if asset_type == "STOCK":
980
+ stock_id = item.asset_id
981
+ if not stock_id and item.symbol:
982
+ stock = await Stock.get_or_none(symbol=item.symbol.upper())
983
+ if not stock:
984
+ stock = await Stock.create(symbol=item.symbol.upper(), name=item.name or item.symbol.upper())
985
+ stock_id = stock.id
986
+ if not stock_id or not item.quantity:
987
+ raise AppException(status_code=400, message="Stock rows require asset_id or symbol, plus quantity")
988
+ holding = await PortfolioService.add_stock(
989
+ portfolio_id=portfolio_id,
990
+ stock_id=stock_id,
991
+ quantity=item.quantity,
992
+ purchase_price=item.purchase_price,
993
+ purchase_date=item.purchase_date,
994
+ notes=item.notes,
995
+ )
996
+ result = {"index": index, "asset_type": asset_type, "status": "created", "holding_id": holding.id}
997
+
998
+ elif asset_type in {"FUND", "UTT"}:
999
+ fund_id = item.asset_id
1000
+ if not fund_id and item.name:
1001
+ fund = await MutualFund.get_or_none(name__iexact=item.name)
1002
+ fund_id = fund.id if fund else None
1003
+ units = item.units_held or item.quantity
1004
+ if not fund_id or not units:
1005
+ raise AppException(status_code=400, message="Fund rows require asset_id or exact name, plus units_held")
1006
+ holding = await PortfolioService.add_fund(
1007
+ portfolio_id=portfolio_id,
1008
+ fund_id=fund_id,
1009
+ units=units,
1010
+ purchase_price=item.purchase_price,
1011
+ purchase_date=item.purchase_date,
1012
+ notes=item.notes,
1013
+ )
1014
+ result = {"index": index, "asset_type": asset_type, "status": "created", "holding_id": holding.id}
1015
+
1016
+ elif asset_type == "BOND":
1017
+ if not item.asset_id or not item.face_value_held:
1018
+ raise AppException(status_code=400, message="Bond rows require asset_id and face_value_held")
1019
+ total_price = item.face_value_held * (item.price_per_100 or item.purchase_price) / Decimal("100")
1020
+ holding = await PortfolioService.add_bond(
1021
+ portfolio_id=portfolio_id,
1022
+ bond_id=item.asset_id,
1023
+ face_value=item.face_value_held,
1024
+ total_purchase_price=total_price,
1025
+ purchase_date=item.purchase_date,
1026
+ notes=item.notes,
1027
+ holding_number=item.holding_number,
1028
+ holding_status=item.holding_status,
1029
+ )
1030
+ result = {"index": index, "asset_type": asset_type, "status": "created", "holding_id": holding.id}
1031
+
1032
+ else:
1033
+ raise AppException(status_code=400, message=f"Unsupported asset_type {item.asset_type}")
1034
+
1035
+ if earliest_date is None or item.purchase_date < earliest_date:
1036
+ earliest_date = item.purchase_date
1037
+ results.append(result)
1038
+ except Exception as exc:
1039
+ error = getattr(exc, "message", str(exc))
1040
+ results.append({"index": index, "asset_type": asset_type, "status": "failed", "error": error})
1041
+ if not payload.continue_on_error:
1042
+ break
1043
+
1044
+ recalc = None
1045
+ if earliest_date:
1046
+ recalc = await _queue_snapshot_regeneration(portfolio_id, earliest_date, background_tasks)
1047
+
1048
+ return ResponseModel(
1049
+ success=not any(row["status"] == "failed" for row in results),
1050
+ message="Bulk position upload completed",
1051
+ data={"results": results, "recalculation": recalc},
1052
+ )
1053
+
1054
+
1055
  @router.post("/{portfolio_id}/bonds/{bond_id}/sell", summary="Sell bond")
1056
  async def sell_bond(
1057
  portfolio_id: int,
App/routers/portfolio/schemas.py CHANGED
@@ -192,6 +192,27 @@ class BondSellSchema(BaseModel):
192
  notes: Optional[str] = None
193
 
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  class BondHoldingResponse(BaseModel):
196
  model_config = ConfigDict(from_attributes=True)
197
 
 
192
  notes: Optional[str] = None
193
 
194
 
195
+ class BulkPortfolioPositionItem(BaseModel):
196
+ asset_type: str = Field(..., pattern="^(STOCK|FUND|UTT|BOND|stock|fund|utt|bond)$")
197
+ asset_id: Optional[int] = None
198
+ symbol: Optional[str] = None
199
+ name: Optional[str] = None
200
+ quantity: Optional[Decimal] = Field(None, gt=0)
201
+ units_held: Optional[Decimal] = Field(None, gt=0)
202
+ face_value_held: Optional[Decimal] = Field(None, gt=0)
203
+ purchase_price: Decimal = Field(..., gt=0)
204
+ price_per_100: Optional[Decimal] = Field(None, gt=0)
205
+ purchase_date: date
206
+ notes: Optional[str] = None
207
+ holding_number: Optional[int] = None
208
+ holding_status: Optional[str] = None
209
+
210
+
211
+ class BulkPortfolioPositionsUpload(BaseModel):
212
+ positions: list[BulkPortfolioPositionItem] = Field(..., min_length=1, max_length=500)
213
+ continue_on_error: bool = True
214
+
215
+
216
  class BondHoldingResponse(BaseModel):
217
  model_config = ConfigDict(from_attributes=True)
218
 
App/routers/stocks/routes.py CHANGED
@@ -6,6 +6,8 @@ from .schemas import (
6
  StockCompanyInfoResponse,
7
  StockFundamentalSnapshotResponse,
8
  StockNewsArticleResponse,
 
 
9
  PriceDataResponse,
10
  StockReferenceSyncPayload,
11
  CorporateActionResponse,
@@ -24,10 +26,11 @@ from .crud import (
24
  get_stock_company_info,
25
  get_cached_stock_news,
26
  replace_stock_news,
 
27
  )
28
  from .service import fetch_dse_stock_data
29
  from .metrics import calculate_metrics
30
- from .models import Stock, StockFundamentalSnapshot, StockPriceData, Dividend, StockProfile
31
  from .fundamentals import refresh_simplywallst_fundamentals
32
  from .news_service import (
33
  build_news_queries,
@@ -379,18 +382,15 @@ async def get_stock_news(
379
  row
380
  for row in cached_articles
381
  if row.section == "industry_news"
382
- or is_relevant_company_article(
383
- symbol=stock.symbol,
384
  stock_name=stock_name,
385
  headline=row.headline,
386
  article_text=row.summary or row.article_excerpt or "",
387
  article_excerpt=row.article_excerpt,
388
  source_url=row.source_url,
389
  canonical_url=row.canonical_url,
390
- country=country,
391
- industry=industry,
392
- sector=sector,
393
- website=website,
394
  )
395
  ]
396
  latest_cached_at = cached_articles[0].updated_at if cached_articles else None
@@ -598,6 +598,193 @@ async def get_stock_news(
598
  )
599
 
600
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
601
  # --- New Dividend Route ---
602
  @router.get("/{symbol}/dividends", response_model=ResponseModel)
603
  async def get_stock_dividends(symbol: str):
 
6
  StockCompanyInfoResponse,
7
  StockFundamentalSnapshotResponse,
8
  StockNewsArticleResponse,
9
+ StockNewsManualCreate,
10
+ StockNewsCleanupRequest,
11
  PriceDataResponse,
12
  StockReferenceSyncPayload,
13
  CorporateActionResponse,
 
26
  get_stock_company_info,
27
  get_cached_stock_news,
28
  replace_stock_news,
29
+ upsert_stock_news_article,
30
  )
31
  from .service import fetch_dse_stock_data
32
  from .metrics import calculate_metrics
33
+ from .models import Stock, StockFundamentalSnapshot, StockPriceData, Dividend, StockProfile, StockNewsArticle
34
  from .fundamentals import refresh_simplywallst_fundamentals
35
  from .news_service import (
36
  build_news_queries,
 
382
  row
383
  for row in cached_articles
384
  if row.section == "industry_news"
385
+ or _is_company_news_related(
386
+ stock=stock,
387
  stock_name=stock_name,
388
  headline=row.headline,
389
  article_text=row.summary or row.article_excerpt or "",
390
  article_excerpt=row.article_excerpt,
391
  source_url=row.source_url,
392
  canonical_url=row.canonical_url,
393
+ company=company,
 
 
 
394
  )
395
  ]
396
  latest_cached_at = cached_articles[0].updated_at if cached_articles else None
 
598
  )
599
 
600
 
601
+ def _clean_manual_news_text(text: str) -> str:
602
+ replacements = {
603
+ "¡": "@",
604
+ " ": " ",
605
+ }
606
+ cleaned = str(text or "").strip()
607
+ for old, new in replacements.items():
608
+ cleaned = cleaned.replace(old, new)
609
+ return " ".join(cleaned.split())
610
+
611
+
612
+ def _is_company_news_related(
613
+ *,
614
+ stock: Stock,
615
+ stock_name: str,
616
+ headline: str,
617
+ article_text: str,
618
+ article_excerpt: Optional[str],
619
+ source_url: Optional[str],
620
+ canonical_url: Optional[str],
621
+ company: Optional[dict],
622
+ ) -> bool:
623
+ combined = " ".join(
624
+ str(part or "")
625
+ for part in [headline, article_text, article_excerpt, source_url, canonical_url]
626
+ ).lower()
627
+ symbol = stock.symbol.upper()
628
+ if symbol == "TPCC":
629
+ headline_text = str(headline or "").lower()
630
+ if "tpcc" in headline_text and not any(token in headline_text for token in ["twiga", "cement", "tanzania portland"]):
631
+ return False
632
+ return any(token in combined for token in ["twiga", "tanzania portland", "portland cement", "cement"])
633
+ if symbol == "TCCL":
634
+ headline_text = str(headline or "").lower()
635
+ if "tccl" in headline_text and not any(token in headline_text for token in ["tanga", "simba", "cement"]):
636
+ return False
637
+ return any(token in combined for token in ["tanga cement", "simba cement", "cement"])
638
+ return is_relevant_company_article(
639
+ symbol=stock.symbol,
640
+ stock_name=stock_name,
641
+ headline=headline,
642
+ article_text=article_text,
643
+ article_excerpt=article_excerpt,
644
+ source_url=source_url,
645
+ canonical_url=canonical_url,
646
+ country=(company or {}).get("country"),
647
+ industry=(company or {}).get("industry"),
648
+ sector=(company or {}).get("sector"),
649
+ website=(company or {}).get("website"),
650
+ )
651
+
652
+
653
+ def _manual_news_summary(symbol: str, stock_name: str, body: str) -> str:
654
+ lower = body.lower()
655
+ if any(word in lower for word in ["mkutano mkuu", "agm", "annual general"]):
656
+ return (
657
+ f"{stock_name} has issued an annual general meeting notice. "
658
+ "Shareholders should review attendance, identification, and proxy deadlines because governance events can affect investor decisions."
659
+ )
660
+ return body[:500]
661
+
662
+
663
+ @router.post("/{symbol}/news/manual", response_model=ResponseModel)
664
+ async def create_manual_stock_news(
665
+ symbol: str,
666
+ payload: StockNewsManualCreate,
667
+ current_user=Depends(get_current_user),
668
+ ):
669
+ stock = await Stock.get_or_none(symbol=symbol.upper())
670
+ if not stock:
671
+ raise AppException(status_code=404, message="Stock not found")
672
+
673
+ company = await get_stock_company_info(symbol)
674
+ stock_name = (company or {}).get("legal_name") or stock.name or stock.symbol
675
+ body = _clean_manual_news_text(payload.body)
676
+ headline = _clean_manual_news_text(payload.headline or "")
677
+ if not headline:
678
+ headline = f"{stock.symbol}: Shareholder notice"
679
+
680
+ relevant = payload.section == "industry_news" or payload.force or _is_company_news_related(
681
+ stock=stock,
682
+ stock_name=stock_name,
683
+ headline=headline,
684
+ article_text=body,
685
+ article_excerpt=body,
686
+ source_url=payload.source_url,
687
+ canonical_url=payload.source_url,
688
+ company=company,
689
+ )
690
+ if not relevant:
691
+ raise AppException(
692
+ status_code=400,
693
+ message="News does not look related to this company. Use force=true only for verified manual corrections.",
694
+ )
695
+
696
+ if payload.replace_existing:
697
+ await StockNewsArticle.filter(stock=stock).delete()
698
+
699
+ created = await upsert_stock_news_article(
700
+ stock=stock,
701
+ query_type=payload.query_type,
702
+ section=payload.section,
703
+ headline=headline,
704
+ source_name=payload.source_name,
705
+ source_url=payload.source_url,
706
+ canonical_url=payload.source_url,
707
+ published_at=payload.published_at or datetime.utcnow(),
708
+ summary=_manual_news_summary(stock.symbol, stock_name, body),
709
+ article_excerpt=body,
710
+ tags=list(dict.fromkeys([*payload.tags, "manual", "company_notice"])),
711
+ sentiment_label=payload.sentiment_label or "neutral",
712
+ sentiment_score=payload.sentiment_score if payload.sentiment_score is not None else 0,
713
+ summary_provider="manual_cleaner",
714
+ raw_payload={"manual": True, "body": body, "created_by": getattr(current_user, "id", None)},
715
+ )
716
+ articles = await get_cached_stock_news(stock, limit=10)
717
+ data = [
718
+ StockNewsArticleResponse.model_validate(
719
+ {
720
+ "id": row.id,
721
+ "headline": row.headline,
722
+ "section": row.section,
723
+ "query_type": row.query_type,
724
+ "source_name": row.source_name,
725
+ "source_url": row.source_url,
726
+ "canonical_url": row.canonical_url,
727
+ "published_at": row.published_at,
728
+ "summary": row.summary,
729
+ "article_excerpt": row.article_excerpt,
730
+ "image_url": row.image_url,
731
+ "extracted_images": row.extracted_images or [],
732
+ "tags": row.tags or [],
733
+ "sentiment_label": row.sentiment_label,
734
+ "sentiment_score": row.sentiment_score,
735
+ "summary_provider": row.summary_provider,
736
+ }
737
+ ).model_dump(mode="json")
738
+ for row in articles
739
+ ]
740
+ return ResponseModel(
741
+ success=True,
742
+ message="Manual stock news saved" if created else "Manual stock news updated",
743
+ data={"articles": data, "created": created},
744
+ )
745
+
746
+
747
+ @router.post("/{symbol}/news/cleanup", response_model=ResponseModel)
748
+ async def cleanup_stock_news(
749
+ symbol: str,
750
+ payload: StockNewsCleanupRequest,
751
+ current_user=Depends(get_current_user),
752
+ ):
753
+ stock = await Stock.get_or_none(symbol=symbol.upper())
754
+ if not stock:
755
+ raise AppException(status_code=404, message="Stock not found")
756
+
757
+ company = await get_stock_company_info(symbol)
758
+ stock_name = (company or {}).get("legal_name") or stock.name or stock.symbol
759
+ rows = await StockNewsArticle.filter(stock=stock).all()
760
+ remove_ids = set(payload.article_ids)
761
+ if payload.remove_unrelated:
762
+ for row in rows:
763
+ if (row.raw_payload or {}).get("manual"):
764
+ continue
765
+ related = row.section == "industry_news" or _is_company_news_related(
766
+ stock=stock,
767
+ stock_name=stock_name,
768
+ headline=row.headline,
769
+ article_text=row.summary or row.article_excerpt or "",
770
+ article_excerpt=row.article_excerpt,
771
+ source_url=row.source_url,
772
+ canonical_url=row.canonical_url,
773
+ company=company,
774
+ )
775
+ if not related:
776
+ remove_ids.add(row.id)
777
+
778
+ if not payload.dry_run and remove_ids:
779
+ await StockNewsArticle.filter(stock=stock, id__in=list(remove_ids)).delete()
780
+
781
+ return ResponseModel(
782
+ success=True,
783
+ message="Stock news cleanup completed",
784
+ data={"removed_ids": sorted(remove_ids), "dry_run": payload.dry_run},
785
+ )
786
+
787
+
788
  # --- New Dividend Route ---
789
  @router.get("/{symbol}/dividends", response_model=ResponseModel)
790
  async def get_stock_dividends(symbol: str):
App/routers/stocks/schemas.py CHANGED
@@ -76,6 +76,7 @@ class StockFundamentalSnapshotResponse(BaseModel):
76
 
77
 
78
  class StockNewsArticleResponse(BaseModel):
 
79
  headline: str
80
  section: str
81
  query_type: str
@@ -93,6 +94,27 @@ class StockNewsArticleResponse(BaseModel):
93
  summary_provider: Optional[str] = None
94
 
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  class PriceDataResponse(BaseModel):
97
  date: date
98
  opening_price: float
 
76
 
77
 
78
  class StockNewsArticleResponse(BaseModel):
79
+ id: Optional[int] = None
80
  headline: str
81
  section: str
82
  query_type: str
 
94
  summary_provider: Optional[str] = None
95
 
96
 
97
+ class StockNewsManualCreate(BaseModel):
98
+ headline: Optional[str] = None
99
+ body: str = Field(..., min_length=5)
100
+ source_name: Optional[str] = "Manual"
101
+ source_url: Optional[str] = None
102
+ published_at: Optional[dt] = None
103
+ section: str = Field("company_news", pattern="^(company_news|industry_news)$")
104
+ query_type: str = Field("manual", max_length=30)
105
+ sentiment_label: Optional[str] = Field(None, pattern="^(positive|negative|neutral)$")
106
+ sentiment_score: Optional[float] = Field(None, ge=-1, le=1)
107
+ tags: List[str] = Field(default_factory=list)
108
+ replace_existing: bool = False
109
+ force: bool = False
110
+
111
+
112
+ class StockNewsCleanupRequest(BaseModel):
113
+ article_ids: List[int] = Field(default_factory=list)
114
+ remove_unrelated: bool = True
115
+ dry_run: bool = False
116
+
117
+
118
  class PriceDataResponse(BaseModel):
119
  date: date
120
  opening_price: float