diff --git a/tradingagents/dataflows/china_data.py b/tradingagents/dataflows/china_data.py new file mode 100644 index 00000000..fecfcd14 --- /dev/null +++ b/tradingagents/dataflows/china_data.py @@ -0,0 +1,16 @@ +""" +china_data vendor for TradingAgents dataflows. + +NOTE: This stub exists because the actual china_data implementation (akshare-based) +lives in web_dashboard/backend/china_data.py, not here. The tradingagents package +does not currently ship with a china_data vendor implementation. + +To use china_data functionality, run analysis through the web dashboard where +akshare is available as a data source. +""" +from typing import Optional + +def __getattr__(name: str): + # Return None for all china_data imports so interface.py can handle them gracefully + return None + diff --git a/tradingagents/dataflows/interface.py b/tradingagents/dataflows/interface.py index 0caf4b68..82a9bcb1 100644 --- a/tradingagents/dataflows/interface.py +++ b/tradingagents/dataflows/interface.py @@ -24,6 +24,42 @@ from .alpha_vantage import ( ) from .alpha_vantage_common import AlphaVantageRateLimitError +# Lazy china_data import — only fails at runtime if akshare is missing and china_data vendor is selected +try: + from .china_data import ( + get_china_data_online, + get_indicators_china, + get_china_stock_info, + get_china_financials, + get_china_news, + get_china_market_news, + # Wrappers matching caller signatures: + get_china_fundamentals, + get_china_balance_sheet, + get_china_cashflow, + get_china_income_statement, + get_china_news_wrapper, + get_china_global_news_wrapper, + get_china_insider_transactions, + ) + _china_data_available = True +except (ImportError, AttributeError): + _china_data_available = False + get_china_data_online = None + get_indicators_china = None + get_china_stock_info = None + get_china_financials = None + get_china_news = None + get_china_market_news = None + get_china_fundamentals = None + get_china_balance_sheet = None + get_china_cashflow = None + get_china_income_statement = None + get_china_news_wrapper = None + get_china_global_news_wrapper = None + get_china_insider_transactions = None + + # Configuration and routing logic from .config import get_config @@ -31,15 +67,11 @@ from .config import get_config TOOLS_CATEGORIES = { "core_stock_apis": { "description": "OHLCV stock price data", - "tools": [ - "get_stock_data" - ] + "tools": ["get_stock_data"], }, "technical_indicators": { "description": "Technical analysis indicators", - "tools": [ - "get_indicators" - ] + "tools": ["get_indicators"], }, "fundamental_data": { "description": "Company fundamentals", @@ -47,8 +79,8 @@ TOOLS_CATEGORIES = { "get_fundamentals", "get_balance_sheet", "get_cashflow", - "get_income_statement" - ] + "get_income_statement", + ], }, "news_data": { "description": "News and insider data", @@ -56,17 +88,19 @@ TOOLS_CATEGORIES = { "get_news", "get_global_news", "get_insider_transactions", - ] - } + ], + }, } VENDOR_LIST = [ "yfinance", "alpha_vantage", + *(["china_data"] if _china_data_available else []), ] # Mapping of methods to their vendor-specific implementations -VENDOR_METHODS = { +# china_data entries are only present if akshare is installed (_china_data_available) +_base_vendor_methods = { # core_stock_apis "get_stock_data": { "alpha_vantage": get_alpha_vantage_stock, @@ -109,6 +143,22 @@ VENDOR_METHODS = { }, } +# Conditionally add china_data vendor only if akshare is available +if _china_data_available: + _base_vendor_methods["get_stock_data"]["china_data"] = get_china_data_online + _base_vendor_methods["get_indicators"]["china_data"] = get_indicators_china + _base_vendor_methods["get_fundamentals"]["china_data"] = get_china_fundamentals + _base_vendor_methods["get_balance_sheet"]["china_data"] = get_china_balance_sheet + _base_vendor_methods["get_cashflow"]["china_data"] = get_china_cashflow + _base_vendor_methods["get_income_statement"]["china_data"] = get_china_income_statement + _base_vendor_methods["get_news"]["china_data"] = get_china_news_wrapper + _base_vendor_methods["get_global_news"]["china_data"] = get_china_global_news_wrapper + _base_vendor_methods["get_insider_transactions"]["china_data"] = get_china_insider_transactions + +VENDOR_METHODS = _base_vendor_methods +del _base_vendor_methods + + def get_category_for_method(method: str) -> str: """Get the category that contains the specified method.""" for category, info in TOOLS_CATEGORIES.items(): @@ -116,6 +166,7 @@ def get_category_for_method(method: str) -> str: return category raise ValueError(f"Method '{method}' not found in any category") + def get_vendor(category: str, method: str = None) -> str: """Get the configured vendor for a data category or specific tool method. Tool-level configuration takes precedence over category-level. @@ -131,11 +182,12 @@ def get_vendor(category: str, method: str = None) -> str: # Fall back to category-level configuration return config.get("data_vendors", {}).get(category, "default") + def route_to_vendor(method: str, *args, **kwargs): """Route method calls to appropriate vendor implementation with fallback support.""" category = get_category_for_method(method) vendor_config = get_vendor(category, method) - primary_vendors = [v.strip() for v in vendor_config.split(',')] + primary_vendors = [v.strip() for v in vendor_config.split(",")] if method not in VENDOR_METHODS: raise ValueError(f"Method '{method}' not supported") @@ -159,4 +211,4 @@ def route_to_vendor(method: str, *args, **kwargs): except AlphaVantageRateLimitError: continue # Only rate limits trigger fallback - raise RuntimeError(f"No available vendor for '{method}'") \ No newline at end of file + raise RuntimeError(f"No available vendor for '{method}'") diff --git a/web_dashboard/backend/api/portfolio.py b/web_dashboard/backend/api/portfolio.py index caa03df4..ce23590b 100644 --- a/web_dashboard/backend/api/portfolio.py +++ b/web_dashboard/backend/api/portfolio.py @@ -136,7 +136,8 @@ def delete_account(account_name: str) -> bool: # ============== Positions ============= # Semaphore to limit concurrent yfinance requests (avoid rate limiting) -_yfinance_semaphore: asyncio.Semaphore = asyncio.Semaphore(5) +MAX_CONCURRENT_YFINANCE_REQUESTS = 5 +_yfinance_semaphore: asyncio.Semaphore = asyncio.Semaphore(MAX_CONCURRENT_YFINANCE_REQUESTS) def _fetch_price(ticker: str) -> float | None: @@ -241,6 +242,8 @@ def add_position(ticker: str, shares: float, cost_price: float, def remove_position(ticker: str, position_id: str, account: Optional[str]) -> bool: + if not position_id: + return False # Require explicit position_id to prevent mass deletion with open(POSITIONS_LOCK, "w") as lf: fcntl.flock(lf.fileno(), fcntl.LOCK_EX) try: @@ -300,9 +303,19 @@ def get_recommendations(date: Optional[str] = None) -> list: def get_recommendation(date: str, ticker: str) -> Optional[dict]: + # Validate inputs to prevent path traversal + if ".." in ticker or "/" in ticker or "\\" in ticker: + return None + if ".." in date or "/" in date or "\\" in date: + return None path = RECOMMENDATIONS_DIR / date / f"{ticker}.json" if not path.exists(): return None + # Ensure resolved path is within RECOMMENDATIONS_DIR (strict traversal check) + try: + path.resolve().relative_to(RECOMMENDATIONS_DIR.resolve()) + except ValueError: + return None return json.loads(path.read_text()) diff --git a/web_dashboard/backend/main.py b/web_dashboard/backend/main.py index 5390613a..bb4b054f 100644 --- a/web_dashboard/backend/main.py +++ b/web_dashboard/backend/main.py @@ -79,6 +79,9 @@ class ScreenRequest(BaseModel): CACHE_DIR = Path(__file__).parent.parent / "cache" CACHE_TTL_SECONDS = 300 # 5 minutes +MAX_RETRY_COUNT = 2 +RETRY_BASE_DELAY_SECS = 1 +MAX_CONCURRENT_YFINANCE = 5 def _get_cache_path(mode: str) -> Path: @@ -545,7 +548,17 @@ def get_reports_list(): def get_report_content(ticker: str, date: str) -> Optional[dict]: """Get report content for a specific ticker and date""" + # Validate inputs to prevent path traversal + if ".." in ticker or "/" in ticker or "\\" in ticker: + return None + if ".." in date or "/" in date or "\\" in date: + return None report_dir = get_results_dir() / ticker / date + # Strict traversal check: resolved path must be within get_results_dir() + try: + report_dir.resolve().relative_to(get_results_dir().resolve()) + except ValueError: + return None if not report_dir.exists(): return None content = {} @@ -883,12 +896,12 @@ async def start_portfolio_analysis(): await broadcast_progress(task_id, app.state.task_results[task_id]) async def run_portfolio_analysis(): - MAX_RETRIES = 2 + max_retries = MAX_RETRY_COUNT async def run_single_analysis(ticker: str, stock: dict) -> tuple[bool, str, dict | None]: """Run analysis for one ticker. Returns (success, decision, rec_or_error).""" last_error = None - for attempt in range(MAX_RETRIES + 1): + for attempt in range(max_retries + 1): script_path = None try: fd, script_path_str = tempfile.mkstemp(suffix=".py", prefix=f"analysis_{task_id}_{stock['_idx']}_") @@ -941,8 +954,8 @@ async def start_portfolio_analysis(): script_path.unlink() except Exception: pass - if attempt < MAX_RETRIES: - await asyncio.sleep(2 ** attempt) # exponential backoff: 1s, 2s + if attempt < max_retries: + await asyncio.sleep(RETRY_BASE_DELAY_SECS ** attempt) # exponential backoff: 1s, 2s return False, "HOLD", None