diff --git a/cli/main.py b/cli/main.py index 054202af..4a09e374 100644 --- a/cli/main.py +++ b/cli/main.py @@ -646,9 +646,9 @@ def get_analysis_date(): ) -def save_report_to_disk(final_state, ticker: str, save_path: Path): +def save_report_to_disk(final_state, ticker: str, save_path: Path, *, language: str = "English"): """Save complete analysis report to disk with organized subfolders.""" - return save_report_bundle(final_state, ticker, save_path) + return save_report_bundle(final_state, ticker, save_path, language=language) def display_complete_report(final_state): @@ -1114,7 +1114,12 @@ def run_analysis(): ).strip() save_path = Path(save_path_str) try: - report_file = save_report_to_disk(final_state, selections["ticker"], save_path) + report_file = save_report_to_disk( + final_state, + selections["ticker"], + save_path, + language=selections.get("output_language", "English"), + ) console.print(f"\n[green]✓ Report saved to:[/green] {save_path.resolve()}") console.print(f" [dim]Complete report:[/dim] {report_file.name}") except Exception as e: diff --git a/tests/test_model_validation.py b/tests/test_model_validation.py index 50f26318..fde2a663 100644 --- a/tests/test_model_validation.py +++ b/tests/test_model_validation.py @@ -50,3 +50,6 @@ class ModelValidationTests(unittest.TestCase): client.get_llm() self.assertEqual(caught, []) + + def test_validator_accepts_known_model_with_surrounding_whitespace(self): + self.assertTrue(validate_model(" openai ", " gpt-5.4 ")) diff --git a/tests/test_report_localization.py b/tests/test_report_localization.py new file mode 100644 index 00000000..6782e31b --- /dev/null +++ b/tests/test_report_localization.py @@ -0,0 +1,98 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.reporting import save_report_bundle + + +class ReportLocalizationTests(unittest.TestCase): + def test_save_report_bundle_uses_korean_labels(self): + final_state = { + "market_report": "시장 보고서 본문", + "sentiment_report": "소셜 보고서 본문", + "news_report": "뉴스 보고서 본문", + "fundamentals_report": "펀더멘털 보고서 본문", + "investment_debate_state": { + "bull_history": "강세 의견", + "bear_history": "약세 의견", + "judge_decision": "리서치 매니저 판단", + }, + "trader_investment_plan": "트레이딩 계획", + "risk_debate_state": { + "aggressive_history": "공격적 의견", + "conservative_history": "보수적 의견", + "neutral_history": "중립 의견", + "judge_decision": "포트폴리오 최종 판단", + }, + } + + with tempfile.TemporaryDirectory() as tmpdir: + report_path = save_report_bundle( + final_state, + "GOOGL", + Path(tmpdir), + language="Korean", + ) + report_text = report_path.read_text(encoding="utf-8") + + self.assertIn("트레이딩 분석 리포트", report_text) + self.assertIn("생성 시각", report_text) + self.assertIn("애널리스트 팀 리포트", report_text) + self.assertIn("포트폴리오 매니저 최종 판단", report_text) + self.assertIn("시장 애널리스트", report_text) + + def test_localize_final_state_rewrites_user_facing_fields(self): + graph = TradingAgentsGraph.__new__(TradingAgentsGraph) + graph.quick_thinking_llm = object() + final_state = { + "market_report": "market", + "sentiment_report": "social", + "news_report": "news", + "fundamentals_report": "fundamentals", + "investment_plan": "investment plan", + "trader_investment_plan": "trader plan", + "final_trade_decision": "final decision", + "investment_debate_state": { + "bull_history": "bull", + "bear_history": "bear", + "history": "debate history", + "current_response": "latest debate", + "judge_decision": "manager decision", + }, + "risk_debate_state": { + "aggressive_history": "aggressive", + "conservative_history": "conservative", + "neutral_history": "neutral", + "history": "risk history", + "current_aggressive_response": "aggr latest", + "current_conservative_response": "cons latest", + "current_neutral_response": "neutral latest", + "judge_decision": "portfolio decision", + }, + } + + with ( + patch("tradingagents.graph.trading_graph.get_output_language", return_value="Korean"), + patch( + "tradingagents.graph.trading_graph.rewrite_in_output_language", + side_effect=lambda llm, content, content_type="report": f"KO::{content_type}::{content}", + ), + ): + localized = graph._localize_final_state(final_state) + + self.assertEqual(localized["market_report"], "KO::market analyst report::market") + self.assertEqual(localized["investment_plan"], "KO::research manager investment plan::investment plan") + self.assertEqual( + localized["investment_debate_state"]["judge_decision"], + "KO::research manager decision::manager decision", + ) + self.assertEqual( + localized["risk_debate_state"]["current_neutral_response"], + "KO::neutral risk analyst latest response::neutral latest", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tradingagents/agents/utils/agent_utils.py b/tradingagents/agents/utils/agent_utils.py index 4ba40a80..b935d9db 100644 --- a/tradingagents/agents/utils/agent_utils.py +++ b/tradingagents/agents/utils/agent_utils.py @@ -1,4 +1,5 @@ from langchain_core.messages import HumanMessage, RemoveMessage +import re # Import tools from separate utility files from tradingagents.agents.utils.core_stock_tools import ( @@ -27,11 +28,87 @@ def get_language_instruction() -> str: Only applied to user-facing agents (analysts, portfolio manager). Internal debate agents stay in English for reasoning quality. """ - from tradingagents.dataflows.config import get_config - lang = get_config().get("output_language", "English") + lang = get_output_language() if lang.strip().lower() == "english": return "" - return f" Write your entire response in {lang}." + return ( + f" Write your entire response in {lang}. " + f"Do not mix in English for headings, summaries, recommendations, table labels, or narrative text. " + f"Keep only ticker symbols, company names, dates, and raw numeric values unchanged when needed." + ) + + +def get_output_language() -> str: + from tradingagents.dataflows.config import get_config + + return str(get_config().get("output_language", "English")).strip() or "English" + + +def rewrite_in_output_language(llm, content: str, *, content_type: str = "report") -> str: + """Rewrite already-generated content into the configured output language. + + This lets the graph keep English-centric reasoning prompts where useful while + ensuring the persisted user-facing report is consistently localized. + """ + if not content: + return content + + lang = get_output_language() + if lang.lower() == "english": + return content + + messages = [ + ( + "system", + "You are a financial editor rewriting existing analysis for end users. " + f"Rewrite the user's {content_type} entirely in {lang}. " + "Requirements: preserve the original meaning, preserve markdown structure, preserve tables, preserve ticker symbols, preserve dates, preserve numbers, and preserve factual details. " + "Translate all headings, labels, bullet text, narrative prose, recommendations, quoted headlines, and English source titles so the output reads naturally and consistently in the target language. " + "Do not leave English article titles or English section names in the output unless they are unavoidable proper nouns or acronyms. " + "Keep only unavoidable Latin-script proper nouns or acronyms such as ticker symbols, company names, product names, RSI, MACD, ATR, EBITDA, and CAPEX. " + "If the source contains English control phrases or analyst role labels, rewrite them into natural user-facing target-language labels. " + "Output only the rewritten content.", + ), + ("human", content), + ] + + rewritten = llm.invoke(messages).content + if not isinstance(rewritten, str) or not rewritten.strip(): + return content + return _normalize_localized_finance_terms(rewritten, lang) + + +def _normalize_localized_finance_terms(content: str, language: str) -> str: + if language.strip().lower() != "korean": + return content + + replacements = { + "FINAL TRANSACTION PROPOSAL": "최종 거래 제안", + "**BUY**": "**매수**", + "**HOLD**": "**보유**", + "**SELL**": "**매도**", + "**OVERWEIGHT**": "**비중 확대**", + "**UNDERWEIGHT**": "**비중 축소**", + } + + normalized = content + for source, target in replacements.items(): + normalized = normalized.replace(source, target) + regex_replacements = ( + (r"\bBuy\b", "매수"), + (r"\bHold\b", "보유"), + (r"\bSell\b", "매도"), + (r"\bOverweight\b", "비중 확대"), + (r"\bUnderweight\b", "비중 축소"), + (r"\bBUY\b", "매수"), + (r"\bHOLD\b", "보유"), + (r"\bSELL\b", "매도"), + (r"\bOVERWEIGHT\b", "비중 확대"), + (r"\bUNDERWEIGHT\b", "비중 축소"), + ) + for pattern, replacement in regex_replacements: + normalized = re.sub(pattern, replacement, normalized) + return normalized def build_instrument_context(ticker: str) -> str: diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 6bdc0dfd..0723ebf5 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -30,7 +30,9 @@ from tradingagents.agents.utils.agent_utils import ( get_income_statement, get_news, get_insider_transactions, - get_global_news + get_global_news, + get_output_language, + rewrite_in_output_language, ) from .conditional_logic import ConditionalLogic @@ -226,6 +228,9 @@ class TradingAgentsGraph: # Standard mode without tracing final_state = self.graph.invoke(init_agent_state, **args) + signal = self.process_signal(final_state["final_trade_decision"]) + final_state = self._localize_final_state(final_state) + # Store current state for reflection self.curr_state = final_state @@ -233,7 +238,7 @@ class TradingAgentsGraph: self._log_state(trade_date, final_state) # Return decision and processed signal - return final_state, self.process_signal(final_state["final_trade_decision"]) + return final_state, signal def _log_state(self, trade_date, final_state): """Log the final state to a JSON file.""" @@ -296,3 +301,61 @@ class TradingAgentsGraph: def process_signal(self, full_signal): """Process a signal to extract the core decision.""" return self.signal_processor.process_signal(full_signal) + + def _localize_final_state(self, final_state: Dict[str, Any]) -> Dict[str, Any]: + """Rewrite persisted user-facing outputs into the configured output language.""" + language = get_output_language() + if language.lower() == "english": + return final_state + + localized = dict(final_state) + + for field_name, content_type in ( + ("market_report", "market analyst report"), + ("sentiment_report", "social sentiment report"), + ("news_report", "news analyst report"), + ("fundamentals_report", "fundamentals analyst report"), + ("investment_plan", "research manager investment plan"), + ("trader_investment_plan", "trader plan"), + ("final_trade_decision", "portfolio manager final decision"), + ): + localized[field_name] = rewrite_in_output_language( + self.quick_thinking_llm, + localized.get(field_name, ""), + content_type=content_type, + ) + + investment_debate = dict(localized.get("investment_debate_state") or {}) + for field_name, content_type in ( + ("bull_history", "bull researcher debate history"), + ("bear_history", "bear researcher debate history"), + ("history", "investment debate transcript"), + ("current_response", "investment debate latest response"), + ("judge_decision", "research manager decision"), + ): + investment_debate[field_name] = rewrite_in_output_language( + self.quick_thinking_llm, + investment_debate.get(field_name, ""), + content_type=content_type, + ) + localized["investment_debate_state"] = investment_debate + + risk_debate = dict(localized.get("risk_debate_state") or {}) + for field_name, content_type in ( + ("aggressive_history", "aggressive risk analyst debate history"), + ("conservative_history", "conservative risk analyst debate history"), + ("neutral_history", "neutral risk analyst debate history"), + ("history", "risk debate transcript"), + ("current_aggressive_response", "aggressive risk analyst latest response"), + ("current_conservative_response", "conservative risk analyst latest response"), + ("current_neutral_response", "neutral risk analyst latest response"), + ("judge_decision", "portfolio manager decision"), + ): + risk_debate[field_name] = rewrite_in_output_language( + self.quick_thinking_llm, + risk_debate.get(field_name, ""), + content_type=content_type, + ) + localized["risk_debate_state"] = risk_debate + + return localized diff --git a/tradingagents/llm_clients/validators.py b/tradingagents/llm_clients/validators.py index 4e6d457b..94e93626 100644 --- a/tradingagents/llm_clients/validators.py +++ b/tradingagents/llm_clients/validators.py @@ -15,7 +15,8 @@ def validate_model(provider: str, model: str) -> bool: For ollama, openrouter - any model is accepted. """ - provider_lower = provider.lower() + provider_lower = provider.lower().strip() + model_name = model.strip() if provider_lower in ("ollama", "openrouter"): return True @@ -23,4 +24,4 @@ def validate_model(provider: str, model: str) -> bool: if provider_lower not in VALID_MODELS: return True - return model in VALID_MODELS[provider_lower] + return model_name in VALID_MODELS[provider_lower] diff --git a/tradingagents/reporting.py b/tradingagents/reporting.py index 877cef59..f0b829e7 100644 --- a/tradingagents/reporting.py +++ b/tradingagents/reporting.py @@ -11,22 +11,24 @@ def save_report_bundle( save_path: Path, *, generated_at: dt.datetime | None = None, + language: str = "English", ) -> Path: """Persist a complete TradingAgents report bundle to disk.""" generated_at = generated_at or dt.datetime.now() save_path = Path(save_path) save_path.mkdir(parents=True, exist_ok=True) + labels = _labels_for(language) sections: list[str] = [] analysts_dir = save_path / "1_analysts" analyst_parts: list[tuple[str, str]] = [] for file_name, title, key in ( - ("market.md", "Market Analyst", "market_report"), - ("sentiment.md", "Social Analyst", "sentiment_report"), - ("news.md", "News Analyst", "news_report"), - ("fundamentals.md", "Fundamentals Analyst", "fundamentals_report"), + ("market.md", labels["market_analyst"], "market_report"), + ("sentiment.md", labels["social_analyst"], "sentiment_report"), + ("news.md", labels["news_analyst"], "news_report"), + ("fundamentals.md", labels["fundamentals_analyst"], "fundamentals_report"), ): content = _coerce_text(final_state.get(key)) if not content: @@ -37,7 +39,7 @@ def save_report_bundle( if analyst_parts: sections.append( - "## I. Analyst Team Reports\n\n" + f"## {labels['section_analysts']}\n\n" + "\n\n".join(f"### {title}\n{content}" for title, content in analyst_parts) ) @@ -45,9 +47,9 @@ def save_report_bundle( research_dir = save_path / "2_research" research_parts: list[tuple[str, str]] = [] for file_name, title, key in ( - ("bull.md", "Bull Researcher", "bull_history"), - ("bear.md", "Bear Researcher", "bear_history"), - ("manager.md", "Research Manager", "judge_decision"), + ("bull.md", labels["bull_researcher"], "bull_history"), + ("bear.md", labels["bear_researcher"], "bear_history"), + ("manager.md", labels["research_manager"], "judge_decision"), ): content = _coerce_text(debate.get(key)) if not content: @@ -58,7 +60,7 @@ def save_report_bundle( if research_parts: sections.append( - "## II. Research Team Decision\n\n" + f"## {labels['section_research']}\n\n" + "\n\n".join(f"### {title}\n{content}" for title, content in research_parts) ) @@ -67,15 +69,17 @@ def save_report_bundle( trading_dir = save_path / "3_trading" trading_dir.mkdir(exist_ok=True) _write_text(trading_dir / "trader.md", trader_plan) - sections.append(f"## III. Trading Team Plan\n\n### Trader\n{trader_plan}") + sections.append( + f"## {labels['section_trading']}\n\n### {labels['trader']}\n{trader_plan}" + ) risk = final_state.get("risk_debate_state") or {} risk_dir = save_path / "4_risk" risk_parts: list[tuple[str, str]] = [] for file_name, title, key in ( - ("aggressive.md", "Aggressive Analyst", "aggressive_history"), - ("conservative.md", "Conservative Analyst", "conservative_history"), - ("neutral.md", "Neutral Analyst", "neutral_history"), + ("aggressive.md", labels["aggressive_analyst"], "aggressive_history"), + ("conservative.md", labels["conservative_analyst"], "conservative_history"), + ("neutral.md", labels["neutral_analyst"], "neutral_history"), ): content = _coerce_text(risk.get(key)) if not content: @@ -86,7 +90,7 @@ def save_report_bundle( if risk_parts: sections.append( - "## IV. Risk Management Team Decision\n\n" + f"## {labels['section_risk']}\n\n" + "\n\n".join(f"### {title}\n{content}" for title, content in risk_parts) ) @@ -96,13 +100,13 @@ def save_report_bundle( portfolio_dir.mkdir(exist_ok=True) _write_text(portfolio_dir / "decision.md", portfolio_decision) sections.append( - "## V. Portfolio Manager Decision\n\n" - f"### Portfolio Manager\n{portfolio_decision}" + f"## {labels['section_portfolio']}\n\n" + f"### {labels['portfolio_manager']}\n{portfolio_decision}" ) header = ( - f"# Trading Analysis Report: {ticker}\n\n" - f"Generated: {generated_at.strftime('%Y-%m-%d %H:%M:%S')}\n\n" + f"# {labels['report_title']}: {ticker}\n\n" + f"{labels['generated_at']}: {generated_at.strftime('%Y-%m-%d %H:%M:%S')}\n\n" ) complete_report = save_path / "complete_report.md" _write_text(complete_report, header + "\n\n".join(sections)) @@ -121,3 +125,50 @@ def _coerce_text(value: Any) -> str: def _write_text(path: Path, content: str) -> None: path.write_text(content, encoding="utf-8") + + +def _labels_for(language: str) -> dict[str, str]: + if str(language).strip().lower() == "korean": + return { + "report_title": "트레이딩 분석 리포트", + "generated_at": "생성 시각", + "section_analysts": "I. 애널리스트 팀 리포트", + "section_research": "II. 리서치 팀 판단", + "section_trading": "III. 트레이딩 팀 계획", + "section_risk": "IV. 리스크 관리 팀 판단", + "section_portfolio": "V. 포트폴리오 매니저 최종 판단", + "market_analyst": "시장 애널리스트", + "social_analyst": "소셜 심리 애널리스트", + "news_analyst": "뉴스 애널리스트", + "fundamentals_analyst": "펀더멘털 애널리스트", + "bull_researcher": "강세 리서처", + "bear_researcher": "약세 리서처", + "research_manager": "리서치 매니저", + "trader": "트레이더", + "aggressive_analyst": "공격적 리스크 애널리스트", + "conservative_analyst": "보수적 리스크 애널리스트", + "neutral_analyst": "중립 리스크 애널리스트", + "portfolio_manager": "포트폴리오 매니저", + } + + return { + "report_title": "Trading Analysis Report", + "generated_at": "Generated", + "section_analysts": "I. Analyst Team Reports", + "section_research": "II. Research Team Decision", + "section_trading": "III. Trading Team Plan", + "section_risk": "IV. Risk Management Team Decision", + "section_portfolio": "V. Portfolio Manager Decision", + "market_analyst": "Market Analyst", + "social_analyst": "Social Analyst", + "news_analyst": "News Analyst", + "fundamentals_analyst": "Fundamentals Analyst", + "bull_researcher": "Bull Researcher", + "bear_researcher": "Bear Researcher", + "research_manager": "Research Manager", + "trader": "Trader", + "aggressive_analyst": "Aggressive Analyst", + "conservative_analyst": "Conservative Analyst", + "neutral_analyst": "Neutral Analyst", + "portfolio_manager": "Portfolio Manager", + } diff --git a/tradingagents/scheduled/runner.py b/tradingagents/scheduled/runner.py index eac894dd..7ccb3f5f 100644 --- a/tradingagents/scheduled/runner.py +++ b/tradingagents/scheduled/runner.py @@ -172,7 +172,13 @@ def _run_single_ticker( final_state, decision = graph.propagate(ticker, trade_date) report_dir = ticker_dir / "report" - report_file = save_report_bundle(final_state, ticker, report_dir, generated_at=ticker_started) + report_file = save_report_bundle( + final_state, + ticker, + report_dir, + generated_at=ticker_started, + language=config.run.output_language, + ) final_state_path = ticker_dir / "final_state.json" _write_json(final_state_path, _serialize_final_state(final_state))