diff --git a/frontend/src/api/stock.ts b/frontend/src/api/stock.ts index 5b374399b..b6b06921b 100644 --- a/frontend/src/api/stock.ts +++ b/frontend/src/api/stock.ts @@ -33,7 +33,7 @@ export const useAddStockToWatchlist = () => { return useMutation({ mutationFn: (ticker: Pick) => - apiClient.post>("watchlist/stocks", ticker), + apiClient.post>("watchlist/asset", ticker), onSuccess: () => { // invalidate watchlist query cache to trigger re-fetch queryClient.invalidateQueries({ diff --git a/python/valuecell/server/api/routers/watchlist.py b/python/valuecell/server/api/routers/watchlist.py index e38489850..eae6d6ac5 100644 --- a/python/valuecell/server/api/routers/watchlist.py +++ b/python/valuecell/server/api/routers/watchlist.py @@ -1,10 +1,10 @@ """Watchlist related API routes.""" -from datetime import datetime, timedelta from typing import List, Optional from fastapi import APIRouter, HTTPException, Path, Query +from ....utils.i18n_utils import parse_and_validate_utc_dates from ...db.repositories.watchlist_repository import get_watchlist_repository from ...services.assets.asset_service import get_asset_service from ..schemas import ( @@ -301,7 +301,7 @@ async def get_watchlist( description="Create a new watchlist", ) async def create_watchlist( - request: CreateWatchlistRequest = None, + request: CreateWatchlistRequest, ): """Create a new watchlist.""" try: @@ -341,7 +341,7 @@ async def create_watchlist( summary="Add asset to watchlist", description="Add a asset to a watchlist", ) - async def add_asset_to_watchlist(request: AddAssetRequest = None): + async def add_asset_to_watchlist(request: AddAssetRequest): """Add a asset to a watchlist.""" try: success = watchlist_repo.add_asset_to_watchlist( @@ -453,8 +453,8 @@ async def delete_watchlist( description="Update notes for a asset in a watchlist", ) async def update_asset_notes( + request: UpdateAssetNotesRequest, ticker: str = Path(..., description="Asset ticker"), - request: UpdateAssetNotesRequest = None, watchlist_name: Optional[str] = Query( None, description="Watchlist name (uses default if not provided)" ), @@ -499,10 +499,12 @@ async def update_asset_notes( async def get_asset_historical_prices( ticker: str = Path(..., description="Asset ticker"), start_date: Optional[str] = Query( - None, description="Start date (YYYY-MM-DD), defaults to 30 days ago" + None, + description="Start date in UTC format (YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DDTHH:MM:SS.fffZ), defaults to 30 days ago", ), end_date: Optional[str] = Query( - None, description="End date (YYYY-MM-DD), defaults to today" + None, + description="End date in UTC format (YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DDTHH:MM:SS.fffZ), defaults to now", ), interval: str = Query("1d", description="Data interval (1d, 1h, 5m, etc.)"), language: Optional[str] = Query( @@ -511,22 +513,8 @@ async def get_asset_historical_prices( ): """Get historical prices for a asset.""" try: - # Parse dates - if end_date: - end_dt = datetime.strptime(end_date, "%Y-%m-%d") - else: - end_dt = datetime.now() - - if start_date: - start_dt = datetime.strptime(start_date, "%Y-%m-%d") - else: - start_dt = end_dt - timedelta(days=30) - - # Validate date range - if start_dt >= end_dt: - raise HTTPException( - status_code=400, detail="Start date must be before end date" - ) + # Parse and validate UTC dates using i18n_utils + start_dt, end_dt = parse_and_validate_utc_dates(start_date, end_date) # Get historical price data result = asset_service.get_historical_prices( @@ -569,7 +557,7 @@ async def get_asset_historical_prices( raise except ValueError as e: raise HTTPException( - status_code=400, detail=f"Invalid date format: {str(e)}" + status_code=400, detail=f"Invalid UTC datetime format: {str(e)}" ) except Exception as e: raise HTTPException( diff --git a/python/valuecell/utils/i18n_utils.py b/python/valuecell/utils/i18n_utils.py index 80d0231c5..0936f79df 100644 --- a/python/valuecell/utils/i18n_utils.py +++ b/python/valuecell/utils/i18n_utils.py @@ -1,7 +1,7 @@ """Internationalization utility functions for ValueCell application.""" import re -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from typing import Any, Dict, List, Optional @@ -165,6 +165,111 @@ def convert_timezone(dt: datetime, from_tz: str, to_tz: str) -> datetime: return dt +def parse_utc_datetime(date_str: str) -> datetime: + """Parse UTC datetime string into timezone-aware datetime object. + + Args: + date_str: UTC datetime string in ISO format (YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DDTHH:MM:SS.fffZ) + + Returns: + Timezone-aware datetime object in UTC + + Raises: + ValueError: If date string format is invalid + """ + # Try different UTC datetime formats + formats = [ + "%Y-%m-%dT%H:%M:%SZ", # 2023-12-01T10:30:00Z + "%Y-%m-%dT%H:%M:%S.%fZ", # 2023-12-01T10:30:00.123Z + "%Y-%m-%dT%H:%M:%S.%f", # 2023-12-01T10:30:00.123 (without Z) + "%Y-%m-%dT%H:%M:%S", # 2023-12-01T10:30:00 (without Z) + "%Y-%m-%d", # 2023-12-01 (date only) + ] + + for fmt in formats: + try: + dt = datetime.strptime(date_str, fmt) + # If no timezone info, assume UTC + if dt.tzinfo is None: + dt = pytz.UTC.localize(dt) + return dt + except ValueError: + continue + + raise ValueError( + f"Invalid UTC datetime format: {date_str}. Expected formats: YYYY-MM-DDTHH:MM:SSZ, YYYY-MM-DDTHH:MM:SS.fffZ, or YYYY-MM-DD" + ) + + +def format_utc_datetime(dt: datetime, format_type: str = "iso") -> str: + """Format datetime as UTC string. + + Args: + dt: Datetime object to format + format_type: Format type ('iso', 'date', 'time', 'datetime') + + Returns: + Formatted UTC datetime string + """ + # Ensure datetime is in UTC + if dt.tzinfo is None: + dt = pytz.UTC.localize(dt) + elif dt.tzinfo != pytz.UTC: + dt = dt.astimezone(pytz.UTC) + + if format_type == "iso": + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + elif format_type == "date": + return dt.strftime("%Y-%m-%d") + elif format_type == "time": + return dt.strftime("%H:%M:%SZ") + else: # datetime + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def get_utc_now() -> datetime: + """Get current UTC datetime. + + Returns: + Current datetime in UTC timezone + """ + return datetime.now(pytz.UTC) + + +def parse_and_validate_utc_dates( + start_date: Optional[str], end_date: Optional[str] +) -> tuple[datetime, datetime]: + """Parse and validate UTC date parameters for API endpoints. + + Args: + start_date: Start date string in UTC format (optional) + end_date: End date string in UTC format (optional) + + Returns: + Tuple of (start_datetime, end_datetime) in UTC + + Raises: + ValueError: If date format is invalid or start_date >= end_date + """ + # Parse end date + if end_date: + end_dt = parse_utc_datetime(end_date) + else: + end_dt = get_utc_now() + + # Parse start date + if start_date: + start_dt = parse_utc_datetime(start_date) + else: + start_dt = end_dt - timedelta(days=30) + + # Validate date range + if start_dt >= end_dt: + raise ValueError("Start date must be before end date") + + return start_dt, end_dt + + def format_file_size(size_bytes: int, language: Optional[str] = None) -> str: """Format file size according to language settings.