Spaces:
Sleeping
Sleeping
Add curated news and bulk position APIs
Browse files- App/routers/portfolio/routes.py +91 -0
- App/routers/portfolio/schemas.py +21 -0
- App/routers/stocks/routes.py +194 -7
- App/routers/stocks/schemas.py +22 -0
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
|
| 383 |
-
|
| 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 |
-
|
| 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
|