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
32 changes: 32 additions & 0 deletions frontend/src/api/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ import type {
SystemInfo,
} from "@/types/system";

export interface DefaultTicker {
ticker: string;
symbol: string;
name: string;
}

export interface DefaultTickersResponse {
region: string;
tickers: DefaultTicker[];
}

export const useBackendHealth = () => {
return useQuery({
queryKey: ["backend-health"],
Expand Down Expand Up @@ -102,3 +113,24 @@ export const usePublishStrategy = () => {
},
});
};

/**
* Get region-aware default tickers for homepage display.
* Returns A-share indices for China mainland users,
* global indices for other regions.
*
* @param region - Optional region override for testing (e.g., "cn" or "default").
* In development, you can set this to test different regions.
*/
export const useGetDefaultTickers = (region?: string) =>
useQuery({
queryKey: ["system", "default-tickers", region],
queryFn: () => {
const params = region ? `?region=${region}` : "";
return apiClient.get<ApiResponse<DefaultTickersResponse>>(
`system/default-tickers${params}`,
);
},
select: (data) => data.data,
staleTime: 1000 * 60 * 60, // Cache for 1 hour, region doesn't change frequently
});
21 changes: 19 additions & 2 deletions frontend/src/app/home/home.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { useAllPollTaskList } from "@/api/conversation";
import { useGetDefaultTickers } from "@/api/system";
import ScrollContainer from "@/components/valuecell/scroll/scroll-container";
import { HOME_STOCK_SHOW } from "@/constants/stock";
import { agentSuggestions } from "@/mock/agent-data";
Expand All @@ -17,7 +18,23 @@ function Home() {
const [inputValue, setInputValue] = useState<string>("");

const { data: allPollTaskList } = useAllPollTaskList();
const { sparklineStocks } = useSparklineStocks(HOME_STOCK_SHOW);

// Get region-aware default tickers from API
const { data: defaultTickersData } = useGetDefaultTickers();

// Use API-returned tickers, fallback to hardcoded values if API fails
const stockConfig = useMemo(() => {
if (defaultTickersData?.tickers) {
return defaultTickersData.tickers.map((t) => ({
ticker: t.ticker,
symbol: t.symbol,
}));
}
// Fallback to hardcoded values
return [...HOME_STOCK_SHOW];
}, [defaultTickersData]);

const { sparklineStocks } = useSparklineStocks(stockConfig);

const handleAgentClick = (agentId: string) => {
navigate(`/agent/${agentId}`);
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/constants/stock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ export const RED_BADGE = { bg: "#FFEAEA", text: "#E25C5C" };
export const NEUTRAL_BADGE = { bg: "#F5F5F5", text: "#707070" };

/**
* Stock configurations for home page display
* Stock configurations for home page display.
* Used as fallback when /api/v1/system/default-tickers API fails.
* The API returns region-appropriate stocks based on user's IP location.
*/
export const HOME_STOCK_SHOW = [
{
Expand Down
18 changes: 18 additions & 0 deletions python/valuecell/config/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,21 @@
"zh-Hans": {"decimal": ".", "thousands": ","},
"zh-Hant": {"decimal": ".", "thousands": ","},
}

# Region-based default tickers for homepage display
# 'cn' for China mainland users (A-share indices via akshare/baostock)
# 'default' for other regions (global indices via yfinance)
REGION_DEFAULT_TICKERS: Dict[str, List[Dict[str, str]]] = {
# China mainland users - A-share indices only
"cn": [
{"ticker": "SSE:000001", "symbol": "上证指数", "name": "上证指数"},
{"ticker": "SZSE:399001", "symbol": "深证成指", "name": "深证成指"},
{"ticker": "SSE:000300", "symbol": "沪深300", "name": "沪深300指数"},
],
# Default for other regions - global mixed indices
"default": [
{"ticker": "NASDAQ:IXIC", "symbol": "NASDAQ", "name": "NASDAQ Composite"},
{"ticker": "HKEX:HSI", "symbol": "HSI", "name": "Hang Seng Index"},
{"ticker": "SSE:000001", "symbol": "SSE", "name": "Shanghai Composite"},
],
}
66 changes: 65 additions & 1 deletion python/valuecell/server/api/routers/system.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,36 @@
"""System related API routes."""

from datetime import datetime
from typing import Optional

from fastapi import APIRouter
from fastapi import APIRouter, Request

from ....config.constants import REGION_DEFAULT_TICKERS
from ....utils.i18n_utils import detect_user_region_async
from ...config.settings import get_settings
from ..schemas import AppInfoData, HealthCheckData, SuccessResponse


def _get_client_ip(request: Request) -> Optional[str]:
"""Extract client IP from request, considering reverse proxy headers."""
# Check X-Forwarded-For header (common for reverse proxies)
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
# Take the first IP in the chain (original client)
return forwarded_for.split(",")[0].strip()

# Check X-Real-IP header (nginx)
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip

# Fallback to direct client
if request.client:
return request.client.host

return None


def create_system_router() -> APIRouter:
"""Create system related routes."""
router = APIRouter(prefix="/system", tags=["System"])
Expand Down Expand Up @@ -45,4 +68,45 @@ async def health_check():
data=health_data, msg="Service is running normally"
)

@router.get(
"/default-tickers",
response_model=SuccessResponse[dict],
summary="Get default tickers for homepage",
description="Get region-aware default tickers based on user's IP location",
)
async def get_default_tickers(
request: Request,
region: Optional[str] = None,
):
"""Get default tickers for homepage based on user region.

Returns region-appropriate stock tickers that the user can access.
For China mainland users, returns A-share indices (accessible via akshare/baostock).
For other regions, returns globally accessible indices.

Args:
request: FastAPI request object for extracting client IP.
region: Optional region override for testing (cn or default).
"""
# If region is manually specified and valid, use it directly
if region and region in REGION_DEFAULT_TICKERS:
detected_region = region
else:
# Get client IP and detect region
client_ip = _get_client_ip(request)
detected_region = await detect_user_region_async(client_ip)

# Get default tickers for the detected region
tickers = REGION_DEFAULT_TICKERS.get(
detected_region, REGION_DEFAULT_TICKERS["default"]
)

return SuccessResponse.create(
data={
"region": detected_region,
"tickers": tickers,
},
msg="Default tickers retrieved successfully",
)

return router
18 changes: 18 additions & 0 deletions python/valuecell/server/db/init_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,9 @@ def initialize_assets_with_service(self) -> bool:
"NASDAQ:IXIC", # NASDAQ Composite Index
"HKEX:HSI", # Hang Seng Index
"SSE:000001", # Shanghai Composite Index
"SZSE:399001", # Shenzhen Composite Index
"SZSE:399006", # ChiNext Index
"SSE:000300", # Science and Technology Innovation Board Index
]

try:
Expand Down Expand Up @@ -399,6 +402,21 @@ def _get_fallback_asset_data(self, ticker: str) -> Optional[dict]:
"asset_type": "index",
"exchange": "SSE",
},
"SZSE:399001": {
"name": "Shenzhen Composite Index",
"asset_type": "index",
"exchange": "SZSE",
},
"SZSE:399006": {
"name": "ChiNext Index",
"asset_type": "index",
"exchange": "SZSE",
},
"SSE:000300": {
"name": "CSI 300 Index",
"asset_type": "index",
"exchange": "SSE",
},
"HKEX:HSI": {
"name": "Hang Seng Index",
"asset_type": "index",
Expand Down
53 changes: 48 additions & 5 deletions python/valuecell/utils/i18n_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,23 +65,34 @@ def detect_browser_language(accept_language_header: str) -> str:
return DEFAULT_LANGUAGE


def detect_user_region() -> str:
def detect_user_region(client_ip: Optional[str] = None) -> str:
"""Detect user region based on IP geolocation.

Args:
client_ip: Optional client IP address. If None, uses server's IP for detection.

Returns:
Region code: 'us' for United States, 'default' for others
Region code: 'cn' for China mainland, 'default' for others
"""

try:
import httpx

with httpx.Client(timeout=3.0) as client:
resp = client.get("https://ipapi.co/json/")
# Use client IP if provided, otherwise detect server's IP
url = (
f"https://ipapi.co/{client_ip}/json/"
if client_ip
else "https://ipapi.co/json/"
)
resp = client.get(url)
if resp.status_code == 200:
data = resp.json()
country_code = data.get("country_code", "").upper()

if country_code == "US":
return "us"
# China mainland
if country_code == "CN":
return "cn"

return "default"
return "default"
Expand All @@ -90,6 +101,38 @@ def detect_user_region() -> str:
return "default"


async def detect_user_region_async(client_ip: Optional[str] = None) -> str:
"""Async version of detect_user_region for use in FastAPI routes.

Args:
client_ip: Optional client IP address. If None, uses server's IP for detection.

Returns:
Region code: 'cn' for China mainland, 'default' for others
"""
try:
import httpx

async with httpx.AsyncClient(timeout=3.0) as client:
url = (
f"https://ipapi.co/{client_ip}/json/"
if client_ip
else "https://ipapi.co/json/"
)
resp = await client.get(url)
if resp.status_code == 200:
data = resp.json()
country_code = data.get("country_code", "").upper()

if country_code == "CN":
return "cn"

return "default"
return "default"
except Exception:
return "default"


def get_timezone_for_language(language: str) -> str:
"""Get default timezone for a language.

Expand Down