Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/src/api/stock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const useAddStockToWatchlist = () => {

return useMutation({
mutationFn: (ticker: Pick<Stock, "ticker">) =>
apiClient.post<ApiResponse<null>>("watchlist/stocks", ticker),
apiClient.post<ApiResponse<null>>("watchlist/asset", ticker),
onSuccess: () => {
// invalidate watchlist query cache to trigger re-fetch
queryClient.invalidateQueries({
Expand Down
34 changes: 11 additions & 23 deletions python/valuecell/server/api/routers/watchlist.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)"
),
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
107 changes: 106 additions & 1 deletion python/valuecell/utils/i18n_utils.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.

Expand Down