From 50865711e398d7991da454b31f93520750aa5d75 Mon Sep 17 00:00:00 2001 From: nornen0202 Date: Sun, 5 Apr 2026 07:06:11 +0900 Subject: [PATCH 01/21] Add local Codex provider support --- Docs/codex_prompts_tradingagents.md | 163 ++++ ...agents_codex_bridge_implementation_plan.md | 750 ++++++++++++++++++ README.ko.md | 213 +++++ README.md | 89 ++- cli/main.py | 11 + cli/utils.py | 6 + tests/test_codex_provider.py | 499 ++++++++++++ tradingagents/default_config.py | 9 + tradingagents/graph/trading_graph.py | 9 + tradingagents/llm_clients/__init__.py | 8 +- tradingagents/llm_clients/codex_app_server.py | 337 ++++++++ tradingagents/llm_clients/codex_binary.py | 69 ++ tradingagents/llm_clients/codex_chat_model.py | 407 ++++++++++ tradingagents/llm_clients/codex_client.py | 40 + .../llm_clients/codex_message_codec.py | 236 ++++++ tradingagents/llm_clients/codex_preflight.py | 72 ++ tradingagents/llm_clients/codex_schema.py | 118 +++ tradingagents/llm_clients/factory.py | 18 +- tradingagents/llm_clients/model_catalog.py | 14 + 19 files changed, 3061 insertions(+), 7 deletions(-) create mode 100644 Docs/codex_prompts_tradingagents.md create mode 100644 Docs/tradingagents_codex_bridge_implementation_plan.md create mode 100644 README.ko.md create mode 100644 tests/test_codex_provider.py create mode 100644 tradingagents/llm_clients/codex_app_server.py create mode 100644 tradingagents/llm_clients/codex_binary.py create mode 100644 tradingagents/llm_clients/codex_chat_model.py create mode 100644 tradingagents/llm_clients/codex_client.py create mode 100644 tradingagents/llm_clients/codex_message_codec.py create mode 100644 tradingagents/llm_clients/codex_preflight.py create mode 100644 tradingagents/llm_clients/codex_schema.py diff --git a/Docs/codex_prompts_tradingagents.md b/Docs/codex_prompts_tradingagents.md new file mode 100644 index 00000000..ef3d7922 --- /dev/null +++ b/Docs/codex_prompts_tradingagents.md @@ -0,0 +1,163 @@ +# Codex 작업 프롬프트 모음 + +## 프롬프트 1 — 구현 메인 프롬프트 + +You are working inside the local TradingAgents repository. + +Goal: +Implement a new LLM provider named `codex` so TradingAgents can use the local Codex CLI/app-server authenticated with ChatGPT/Codex login instead of an OpenAI API key. + +High-level constraints: +1. Do NOT build an OpenAI-compatible HTTP proxy. +2. Do NOT call raw OAuth endpoints yourself. +3. Do NOT depend on Codex dynamicTools for TradingAgents tool execution. +4. Keep TradingAgents’ existing LangGraph / ToolNode flow intact. +5. The integration must work for both: + - analyst nodes that use `prompt | llm.bind_tools(tools)` + - non-tool nodes that call `llm.invoke(...)` directly +6. Prefer minimal, coherent changes over broad refactors. +7. Add tests and documentation. +8. No unrelated cleanup. + +Architecture to implement: +- Add a new provider `codex` in `tradingagents/llm_clients/factory.py`. +- Add a `CodexClient` implementing the existing BaseLLMClient contract. +- Add a custom LangChain chat model that talks to `codex app-server` over stdio JSONL. +- Reuse a long-lived app-server process per model instance, but create a fresh Codex thread per model invocation to avoid context bleed across agents. +- After each invocation, `thread/unsubscribe`. +- Use `initialize` / `initialized` on startup. +- Add a preflight helper that checks: + - `codex` binary exists + - app-server starts + - `account/read` succeeds + - requested models are available from `model/list` +- Do not require API keys for the `codex` provider. + +Authentication assumptions: +- The supported user path is `codex login` or `codex login --device-auth`. +- If file-backed auth is used, Codex-managed credentials may be stored in `~/.codex/auth.json`. +- Do not implement direct OAuth token refresh. +- If auth is missing, fail with a clear actionable message telling the user to run `codex login`. + +Important implementation choice: +Do NOT use app-server dynamic tools. +Instead, emulate tool calling at the model boundary with strict structured output: +- For plain non-tool calls, request JSON schema: `{ "answer": string }` +- For tool-capable calls, request a root `oneOf` schema: + - final: + `{ "mode": "final", "content": string, "tool_calls": [] }` + - tool batch: + `{ "mode": "tool_calls", "content": string, "tool_calls": [ ... ] }` +- For `tool_calls[].items`, use `oneOf` with one branch per tool so each tool name has its own exact arguments JSON schema. +- This is required so TradingAgents’ ToolNode can execute the selected tool calls after receiving an `AIMessage.tool_calls`. + +Files to add: +- `tradingagents/llm_clients/codex_client.py` +- `tradingagents/llm_clients/codex_chat_model.py` +- `tradingagents/llm_clients/codex_app_server.py` +- `tradingagents/llm_clients/codex_schema.py` +- `tradingagents/llm_clients/codex_message_codec.py` +- `tradingagents/llm_clients/codex_preflight.py` + +Files to modify: +- `tradingagents/llm_clients/factory.py` +- `tradingagents/default_config.py` +- `tradingagents/llm_clients/__init__.py` +- CLI / UI config surfaces if present +- README and/or docs + +Model behavior requirements: +- Normalize input from: + - `str` + - LangChain `BaseMessage` sequences + - OpenAI-style dict message sequences +- The custom model must support `bind_tools()`. +- `bind_tools()` should preserve LangChain semantics by binding tool schemas into `_generate(...)`. +- Return `AIMessage` objects. +- If tool calls are requested, populate `AIMessage.tool_calls` with stable ids like `call_`. + +Safety / hardening requirements: +- Default to a neutral dedicated workspace directory for Codex, not the repo root. +- Add config knobs for: + - `codex_binary` + - `codex_reasoning_effort` + - `codex_summary` + - `codex_personality` + - `codex_workspace_dir` + - `codex_request_timeout` + - `codex_max_retries` + - `codex_cleanup_threads` +- Document a recommended `.codex/config.toml` with: + - `approval_policy = "never"` + - `sandbox_mode = "read-only"` + - `web_search = "disabled"` + - `personality = "none"` + - `cli_auth_credentials_store = "file"` + +Testing requirements: +1. Unit tests for message normalization. +2. Unit tests for output schema construction. +3. Unit tests for plain final response parsing. +4. Unit tests for tool-call response parsing. +5. Unit tests for malformed JSON retry / error reporting. +6. Integration smoke test for provider `codex`. +7. Preflight test for missing auth / missing binary. + +Acceptance criteria: +- `llm_provider="codex"` works without API keys after `codex login`. +- At least one analyst node using `bind_tools()` works. +- At least one non-tool node using `llm.invoke(...)` works. +- A minimal smoke run can produce a final report / final decision. +- Documentation explains installation, auth, usage, and limitations. + +Implementation style: +- Read the existing code first and align with project style. +- Make the smallest set of clean, composable changes. +- Include comments only where they add real value. +- Avoid speculative abstractions. +- Keep the code production-oriented and debuggable. + +Working method: +1. Inspect the current LLM client factory and how agents call `bind_tools()` vs `invoke()`. +2. Implement the connection layer. +3. Implement the chat model. +4. Wire the provider. +5. Add preflight + docs. +6. Add tests. +7. Run the relevant tests / smoke checks. +8. Summarize exactly what changed and any limitations that remain. + +Do the work now. + +--- + +## 프롬프트 2 — 검증/수정 프롬프트 + +Review the `codex` provider implementation you just added to TradingAgents. + +Your job: +1. Find correctness bugs, interface mismatches, race conditions, and integration gaps. +2. Pay special attention to: + - LangChain `bind_tools()` semantics + - `AIMessage.tool_calls` structure + - support for `llm.invoke(str)`, `llm.invoke(list[BaseMessage])`, and `llm.invoke(list[dict])` + - app-server request/response matching + - thread cleanup with `thread/unsubscribe` + - malformed JSON retries + - missing auth / missing binary / missing model diagnostics +3. Run or update tests as needed. +4. Fix only what is necessary; do not refactor unrelated code. +5. Update docs if behavior changed. + +Definition of done: +- the provider is internally consistent, +- tests pass, +- smoke run works, +- error messages are actionable, +- no obvious context-bleed or tool-calling contract issues remain. + +Return: +- a concise changelog, +- exact files modified, +- exact commands/tests run, +- any remaining known limitations. \ No newline at end of file diff --git a/Docs/tradingagents_codex_bridge_implementation_plan.md b/Docs/tradingagents_codex_bridge_implementation_plan.md new file mode 100644 index 00000000..06518fc3 --- /dev/null +++ b/Docs/tradingagents_codex_bridge_implementation_plan.md @@ -0,0 +1,750 @@ +# TradingAgents × Codex 브리지 구현 설계서 + +## 1. 목표 + +TradingAgents가 요구하는 LLM 호출을 OpenAI API key 대신 **로컬 Codex app-server + ChatGPT/Codex 인증**으로 처리한다. +핵심 목표는 다음과 같다. + +1. TradingAgents의 **기존 LangGraph / ToolNode 구조를 유지**한다. +2. OpenAI 호환 프록시를 억지로 에뮬레이션하지 않고, **새 provider(`codex`)를 추가**한다. +3. ChatGPT Pro 사용자는 **Codex 로그인(`codex login` / `codex login --device-auth`) 또는 Codex의 managed auth cache(`~/.codex/auth.json`)**를 통해 인증한다. +4. `bind_tools()`가 필요한 analyst 노드와 plain `invoke()`만 필요한 debate / manager / trader 노드가 모두 동작해야 한다. +5. Codex의 자체 shell/web/tool 생태계에 의존하지 않고, TradingAgents가 이미 가진 도구 실행 루프를 그대로 사용한다. + +--- + +## 2. 왜 이 방식이 최선인가 + +### 채택할 방식 +**권장안:** `codex app-server`를 로컬에 띄우고, Python에서 stdio(JSONL)로 통신하는 **Custom LangChain ChatModel**을 만든다. + +### 채택하지 않을 방식 + +#### A. OpenAI-compatible `/v1/responses` 프록시 +비추천. TradingAgents는 현재 `openai` provider에서 `langchain_openai.ChatOpenAI`를 사용하고 native OpenAI일 때 `use_responses_api=True`를 켠다. +즉 `/v1/responses`와 tool-calling semantics를 꽤 정확히 흉내 내야 한다. 구현 난도가 높고 유지보수 비용이 크다. + +#### B. Codex dynamic tools 직접 사용 +비추천. app-server의 `dynamicTools`와 `item/tool/call`은 **experimental** 이다. +TradingAgents는 이미 `ToolNode`로 툴 실행을 잘 처리하므로, 여기까지 Codex에 넘길 이유가 없다. + +#### C. Codex SDK 직접 내장 +부분적으로 가능하지만 비권장. SDK는 TypeScript 중심이다. Python 프로젝트인 TradingAgents에선 app-server stdio 브리지가 더 단순하다. + +### 설계 핵심 +Codex는 **모델 추론만 담당**하고, 실제 툴 실행은 여전히 TradingAgents/LangGraph가 담당한다. +따라서 Codex 쪽에는 tool schema를 설명하고, 응답은 **엄격한 JSON schema**로만 받는다. + +- 툴이 필요하면: `{"mode":"tool_calls", ...}` +- 툴이 더 이상 필요 없으면: `{"mode":"final", ...}` + +이렇게 하면 analyst 노드의 `bind_tools()` 요구사항을 만족시키면서도 Codex의 experimental dynamic tool API를 피할 수 있다. + +--- + +## 3. 구현 아키텍처 + +## 3.1 새 provider 추가 + +### 수정 파일 +- `tradingagents/llm_clients/factory.py` +- `tradingagents/default_config.py` +- `tradingagents/llm_clients/__init__.py` +- CLI/UI 관련 파일(선택 사항이 아니라 사실상 권장) + +### 추가 파일 +- `tradingagents/llm_clients/codex_client.py` +- `tradingagents/llm_clients/codex_chat_model.py` +- `tradingagents/llm_clients/codex_app_server.py` +- `tradingagents/llm_clients/codex_schema.py` +- `tradingagents/llm_clients/codex_message_codec.py` +- `tradingagents/llm_clients/codex_preflight.py` +- `tests/llm_clients/test_codex_chat_model.py` +- `tests/llm_clients/test_codex_app_server.py` +- `tests/integration/test_codex_provider_smoke.py` + +--- + +## 3.2 런타임 구성 + +### TradingAgents 측 +`TradingAgentsGraph.__init__()`는 deep/quick 두 개 LLM을 한 번 생성해 재사용한다. +따라서 `CodexChatModel`도 **모델 인스턴스당 app-server 세션 1개**를 유지하는 것이 적절하다. + +- quick_thinking_llm → Codex app-server session A +- deep_thinking_llm → Codex app-server session B + +### 중요 원칙 +- **세션은 재사용** +- **thread는 per-invoke 새로 생성** +- 이유: 여러 analyst / debate agent가 같은 LLM 인스턴스를 공유하므로 thread까지 재사용하면 문맥 오염이 발생한다. + +즉: +- app-server process: 재사용 +- Codex thread: 매 호출마다 새로 생성 후 `thread/unsubscribe` + +--- + +## 3.3 인증 전략 + +### 기본/권장 +사용자가 먼저 로컬에서: + +```bash +codex login +``` + +브라우저 callback이 막히거나 headless면: + +```bash +codex login --device-auth +``` + +### headless / container / 원격 머신 +- `cli_auth_credentials_store = "file"` 로 설정해서 `~/.codex/auth.json`을 사용 +- 신뢰 가능한 머신에서 생성한 `auth.json`을 복사 +- refresh는 직접 구현하지 말고 Codex가 하게 둔다 +- `auth.json`은 절대 커밋 금지 + +### 고급 옵션: OAuth URL helper +원한다면 Python helper에서 app-server로 아래를 호출해 브라우저 login URL을 직접 받아 출력할 수 있다. + +- `account/read` +- `account/login/start` with `type="chatgpt"` + +하지만 **v1 구현은 이 helper 없이도 충분**하다. 실제 운영에서는 `codex login`이 더 단순하고 안정적이다. + +--- + +## 3.4 보안 / 하드닝 + +Codex를 “코딩 에이전트”가 아니라 “모델 백엔드”로만 쓰기 위해 다음을 권장한다. + +### `.codex/config.toml` 예시 +```toml +model = "gpt-5.4" +model_reasoning_effort = "medium" +approval_policy = "never" +sandbox_mode = "read-only" +web_search = "disabled" +personality = "none" +log_dir = ".codex-log" +cli_auth_credentials_store = "file" +``` + +### 선택적 하드닝 +```toml +[features] +apps = false +shell_tool = false +multi_agent = false +``` + +### 추가 권장 +`cwd`를 프로젝트 루트가 아니라 **비어 있는 전용 workspace**로 준다. + +예: +- `~/.cache/tradingagents/codex_workspace` +- 또는 repo 내 `./.tradingagents_codex_workspace` + +이렇게 해야 Codex가 리포지토리를 뒤지거나 파일을 읽는 쪽으로 샐 가능성을 낮출 수 있다. + +--- + +## 4. 메시지/툴 호출 설계 + +## 4.1 입력 정규화 + +`CodexChatModel`은 아래 입력을 모두 받아야 한다. + +1. `str` +2. `list[BaseMessage]` +3. `list[dict(role=..., content=...)]` + +이유: +- analyst 체인은 prompt pipeline 때문에 `BaseMessage` 시퀀스를 넘길 가능성이 높다 +- trader / manager 쪽은 OpenAI-style dict list를 직접 `llm.invoke(messages)`로 넘긴다 + +### 내부 정규화 포맷 예시 +```text +[SYSTEM] +... + +[USER] +... + +[ASSISTANT] +... + +[ASSISTANT_TOOL_CALL] +name=get_news +args={"query":"AAPL",...} + +[TOOL_RESULT] +name=get_news +call_id=call_xxx +content=... +``` + +--- + +## 4.2 bind_tools 처리 + +TradingAgents analyst 노드는 다음 패턴을 사용한다. + +```python +chain = prompt | llm.bind_tools(tools) +result = chain.invoke(state["messages"]) +``` + +따라서 `CodexChatModel.bind_tools()`는 반드시 구현해야 한다. + +### 구현 방식 +- LangChain tool 객체를 OpenAI-style tool schema로 변환 +- 내부적으로 `self.bind(tools=formatted_tools, tool_choice=...)` 형태로 바인딩 +- `_generate(..., tools=..., tool_choice=...)`에서 그 schema를 읽어 사용 + +### tool schema 변환 +가능한 한 LangChain의 표준 helper(`convert_to_openai_tool` 계열)를 사용한다. +각 tool에 대해 다음 정보를 확보한다. + +- `name` +- `description` +- `parameters` JSON schema + +--- + +## 4.3 output schema 설계 + +### plain invoke용 +```json +{ + "type": "object", + "properties": { + "answer": { "type": "string" } + }, + "required": ["answer"], + "additionalProperties": false +} +``` + +### tool-capable invoke용 +루트는 **final** 또는 **tool_calls** 중 하나가 되도록 강제한다. + +```json +{ + "oneOf": [ + { + "type": "object", + "properties": { + "mode": { "const": "final" }, + "content": { "type": "string" }, + "tool_calls": { + "type": "array", + "maxItems": 0 + } + }, + "required": ["mode", "content", "tool_calls"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { "const": "tool_calls" }, + "content": { "type": "string" }, + "tool_calls": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "name": { "const": "get_news" }, + "arguments": { "...": "get_news parameters schema" } + }, + "required": ["name", "arguments"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["mode", "content", "tool_calls"], + "additionalProperties": false + } + ] +} +``` + +### 중요한 포인트 +`tool_calls.items.oneOf` 안에 **툴별 arguments schema**를 넣는다. +그래야 Codex가 tool 이름과 인자를 아무렇게나 생성하지 못한다. + +--- + +## 4.4 tool-call 정책 + +Codex에게 항상 다음 규칙을 준다. + +1. 지금 당장 필요한 **다음 단계 툴 호출만** 요청할 것 +2. speculative call 금지 +3. tool result를 아직 보지 않은 상태에서 downstream tool을 미리 호출하지 말 것 +4. 툴이 필요 없으면 final로 답할 것 +5. 응답은 output schema에 맞는 JSON만 낼 것 + +### 왜 필요한가 +예를 들어 market analyst는 `get_stock_data` 이후에 `get_indicators`가 자연스럽다. +하지만 CSV 생성/캐시 같은 간접 의존성이 있으므로 한 번에 여러 단계를 추측 호출하게 두는 것보다 **최소 다음 호출만** 받는 편이 안전하다. + +--- + +## 5. Codex app-server 통신 계층 설계 + +## 5.1 `CodexAppServerConnection` + +책임: +- `codex app-server` subprocess 시작/종료 +- `initialize` / `initialized` +- request/response correlation (`id`) +- stdout JSONL reader thread +- notifications 수집 +- timeout / error propagation +- graceful shutdown + +### 핵심 메서드 +- `start()` +- `close()` +- `request(method, params, timeout)` +- `wait_for_turn_completion(thread_id, turn_id, timeout)` +- `read_account()` +- `read_models()` +- `read_rate_limits()` + +### transport +- **stdio(JSONL)** 사용 +- websocket transport는 실익이 적으므로 v1에서 제외 + +--- + +## 5.2 초기 handshake + +시작 직후: +1. subprocess spawn: `codex app-server` +2. `initialize` +3. `initialized` +4. `account/read` +5. 필요 시 `model/list` + +### `initialize` 예시 +```json +{ + "method": "initialize", + "id": 1, + "params": { + "clientInfo": { + "name": "tradingagents_codex_bridge", + "title": "TradingAgents Codex Bridge", + "version": "0.1.0" + } + } +} +``` + +--- + +## 5.3 preflight 체크 + +`codex_preflight.py` 또는 helper 함수에서: + +1. `codex` binary 존재 여부 확인 +2. app-server 시작 가능 여부 확인 +3. `account/read(refreshToken=false)` 실행 +4. `account.type == "chatgpt"` 또는 `"apiKey"`인지 확인 +5. 가능하면 `planType == "pro"` 확인 +6. `model/list`에서 `deep_think_llm`, `quick_think_llm` 가용성 확인 +7. `account/rateLimits/read` 가능하면 출력 + +### 실패 시 메시지 예시 +- `Codex not installed. Install with npm i -g @openai/codex` +- `No ChatGPT/API auth found. Run codex login` +- `Requested model gpt-5.4-mini is not available under current Codex account` + +--- + +## 6. LangChain 커스텀 모델 설계 + +## 6.1 `CodexChatModel` + +상속: +- `langchain_core.language_models.chat_models.BaseChatModel` + +필수 구현: +- `_generate(...)` +- `_llm_type` +- `bind_tools(...)` + +권장 추가: +- `_identifying_params` +- `invoke(...)` 입력 정규화 보강 +- 에러 래핑 + +### 내부 필드 예시 +- `model` +- `reasoning_effort` +- `summary` +- `personality` +- `request_timeout` +- `max_retries` +- `server: CodexAppServerConnection` +- `workspace_dir` +- `cleanup_threads` +- `service_name` + +--- + +## 6.2 `_generate()` 동작 + +### tools 없는 경우 +1. 입력 messages 정규화 +2. plain schema 생성 (`answer`) +3. thread/start +4. turn/start with `outputSchema` +5. 최종 agent message JSON 파싱 +6. `AIMessage(content=answer)` 반환 + +### tools 있는 경우 +1. 입력 messages 정규화 +2. tool schema 생성 +3. root oneOf output schema 생성 +4. thread/start +5. turn/start with `outputSchema` +6. 최종 agent message JSON 파싱 +7. `mode == "tool_calls"` 면: + - 각 call에 `id = "call_" + uuid` + - `AIMessage(content=content or "", tool_calls=[...])` +8. `mode == "final"` 면: + - `AIMessage(content=content, tool_calls=[])` + +### 종료 처리 +- `thread/unsubscribe` +- reader queue cleanup +- 필요 시 thread archive는 선택 옵션 + +--- + +## 6.3 app-server 호출 파라미터 + +### thread/start +```json +{ + "method": "thread/start", + "params": { + "model": "gpt-5.4", + "cwd": "/abs/path/to/.tradingagents_codex_workspace", + "approvalPolicy": "never", + "serviceName": "tradingagents_codex_bridge" + } +} +``` + +### turn/start +```json +{ + "method": "turn/start", + "params": { + "threadId": "...", + "input": [ + { "type": "text", "text": "" } + ], + "model": "gpt-5.4", + "effort": "medium", + "summary": "concise", + "personality": "none", + "sandboxPolicy": { + "type": "readOnly", + "access": { "type": "fullAccess" } + }, + "outputSchema": { ... } + } +} +``` + +--- + +## 6.4 프롬프트 래퍼 템플릿 + +### plain invoke wrapper +```text +You are the language model backend for a LangGraph-based financial multi-agent system. + +Rules: +1. Answer only from the provided conversation transcript. +2. Do not inspect files. +3. Do not run commands. +4. Do not use web search. +5. Return ONLY JSON that matches the provided schema. + +Conversation transcript: +<...serialized messages...> +``` + +### tool-capable wrapper +```text +You are the language model backend for a LangGraph-based financial multi-agent system. + +You may either: +- request the next necessary tool call(s), or +- provide the final assistant response. + +Hard rules: +1. Use only the allowed tools listed below. +2. Arguments must conform exactly to the JSON schema for that tool. +3. Request only the next required tool call batch. +4. Do not speculate past missing tool results. +5. Do not inspect files. +6. Do not run commands. +7. Do not use web search. +8. Return ONLY JSON that matches the provided schema. + +Allowed tools: + + +Conversation transcript: +<...serialized messages...> +``` + +### 안정화 팁 +- tool schema를 pretty JSON으로 포함 +- 1~2개의 few-shot example을 포함할 수 있음 +- 단, prompt를 너무 길게 만들어 토큰 낭비하지 않도록 주의 + +--- + +## 7. TradingAgents 코드 변경 체크리스트 + +## 7.1 `default_config.py` +추가 권장 key: + +```python +"llm_provider": "openai", +"codex_binary": "codex", +"codex_reasoning_effort": "medium", +"codex_summary": "concise", +"codex_personality": "none", +"codex_workspace_dir": os.getenv("TRADINGAGENTS_CODEX_WORKSPACE", "./.tradingagents_codex_workspace"), +"codex_request_timeout": 120, +"codex_max_retries": 2, +"codex_cleanup_threads": True, +``` + +호환성 위해: +- `openai_reasoning_effort`가 설정돼 있고 `codex_reasoning_effort`가 비어 있으면 fallback 하도록 해도 좋다. + +--- + +## 7.2 `factory.py` +대략: + +```python +if provider_lower == "codex": + return CodexClient(model, base_url, **kwargs) +``` + +--- + +## 7.3 `codex_client.py` +책임: +- `BaseLLMClient` 구현 +- kwargs를 `CodexChatModel` 생성자에 전달 +- `validate_model()`에서 preflight/model list 확인 + +--- + +## 7.4 CLI / UI +반드시 추가할 항목: +- provider 목록에 `codex` +- backend_url 입력은 codex일 때 숨기거나 무시 +- advanced options: + - `codex_reasoning_effort` + - `codex_summary` + - `codex_personality` + - `codex_workspace_dir` + +--- + +## 7.5 README / docs +반드시 문서화: +1. ChatGPT Pro/Codex auth와 API key의 차이 +2. `codex login` +3. headless auth cache 사용법 +4. `.codex/config.toml` 예시 +5. provider 선택 방법 +6. known limitations + +--- + +## 8. 테스트 전략 + +## 8.1 단위 테스트 + +### `test_codex_message_codec.py` +- `str` 입력 정규화 +- `BaseMessage` 시퀀스 정규화 +- dict message 시퀀스 정규화 +- `ToolMessage` 직렬화 + +### `test_codex_schema.py` +- plain schema 생성 +- tool oneOf schema 생성 +- tool args const / required / additionalProperties 검증 + +### `test_codex_chat_model.py` +mock app-server 응답으로: +- plain final answer +- tool_calls answer +- malformed JSON retry +- timeout +- unsupported model error + +### `test_codex_app_server.py` +- initialize handshake +- request/response correlation +- notification draining +- turn completed / failed 처리 + +--- + +## 8.2 통합 테스트 + +### smoke +- provider=`codex` +- analyst=`news` 한 개만 선택 +- ticker=`AAPL` +- research depth=1 +- 최종 리포트 파일 생성 확인 + +### tool loop +- market analyst만 실행 +- 첫 응답이 `get_stock_data` tool call +- tool result 후 다음 응답이 `get_indicators` 또는 final + +### multi-agent +- `market + news` +- graph 전체 완주 +- `final_trade_decision` 비어 있지 않음 + +### auth preflight +- 로그인 안 된 환경 → 친절한 실패 +- 로그인 된 환경 → account/read 성공 + +--- + +## 8.3 운영 검증 +실제 실행 전 아래 순서 권장: + +```bash +codex login +python -m tradingagents.llm_clients.codex_preflight +python main.py +``` + +또는 CLI/UI에서 provider를 `codex`로 선택. + +--- + +## 9. 장애 대응 + +## 9.1 malformed JSON +대응: +- 1회 재시도 +- 재시도 prompt: + - “Your previous output was invalid JSON. Return valid JSON matching the schema only.” +- 그래도 실패하면 예외 raise + +## 9.2 app-server 시작 실패 +대응: +- binary path 재확인 +- `codex --version` 확인 +- PATH 문제면 `codex_binary` 절대경로 사용 + +## 9.3 로그인/권한 문제 +대응: +- `codex login` +- headless면 `codex login --device-auth` +- `cli_auth_credentials_store="file"` 설정 +- `~/.codex/auth.json` 존재 여부 확인 + +## 9.4 rate limit +대응: +- `account/rateLimits/read` 노출 +- 재시도(backoff) +- 긴 배치 작업은 serialized run +- 필요 시 Codex credits 사용 고려 + +## 9.5 thread log 과다 생성 +대응: +- `thread/unsubscribe` 기본 수행 +- `.codex-log` 별도 디렉터리 사용 +- 오래된 로그 cleanup script 추가 + +--- + +## 10. 권장 구현 순서 + +### Phase 1 +- provider 추가 +- app-server connection 추가 +- plain invoke만 먼저 연결 +- preflight 추가 + +### Phase 2 +- `bind_tools()` + tool schema oneOf 구현 +- analyst nodes smoke test + +### Phase 3 +- CLI/UI 옵션 추가 +- README/docs 작성 +- 통합 테스트 보강 + +### Phase 4 +- malformed JSON retry +- rate limit/backoff +- log cleanup / diagnostics + +--- + +## 11. 최종 권장안 요약 + +### 가장 좋은 구현 방식 +**TradingAgents에 `codex` provider를 새로 추가하고, 내부에서 `codex app-server`와 stdio(JSONL)로 통신하는 LangChain 커스텀 ChatModel을 구현한다.** +tool calling은 Codex dynamicTools를 쓰지 말고, **outputSchema + JSON oneOf** 방식으로 모델 응답을 `final` 또는 `tool_calls` 형태로 강제한다. + +### 이 방식의 장점 +- OpenAI API key 불필요 +- ChatGPT Pro / Codex 로그인 재사용 가능 +- TradingAgents의 기존 ToolNode / graph 구조 유지 +- Python 프로젝트에 자연스럽게 통합 가능 +- dynamicTools 실험 API 의존 최소화 +- 추후 유지보수 포인트가 명확함 + +### 반드시 지켜야 할 운영 원칙 +- 직접 OAuth refresh 구현 금지 +- `auth.json`은 비밀 취급 +- `codex login` 또는 device-auth 우선 +- one auth cache per trusted runner / serialized workflow +- Codex를 모델 백엔드로만 쓰고 shell/web 기능은 최대한 비활성화 + +--- + +## 12. 최소 수용 기준(Acceptance Criteria) + +아래가 모두 충족되면 구현 성공으로 간주한다. + +1. `llm_provider="codex"` 설정으로 TradingAgents가 실행된다. +2. API key 없이 `codex login` 상태에서 동작한다. +3. analyst 노드가 `bind_tools()`를 통해 tool call을 생성하고 ToolNode가 이를 실행한다. +4. manager/trader/risk nodes가 plain `invoke()`로 정상 응답한다. +5. `AAPL` 또는 `SPY`에 대해 최소 1개 analyst + 전체 graph smoke run이 성공한다. +6. malformed JSON, auth missing, binary missing, model missing에 대한 에러 메시지가 명확하다. +7. README와 preflight가 포함된다. \ No newline at end of file diff --git a/README.ko.md b/README.ko.md new file mode 100644 index 00000000..977a3272 --- /dev/null +++ b/README.ko.md @@ -0,0 +1,213 @@ +# TradingAgents: 멀티 에이전트 LLM 금융 트레이딩 프레임워크 + +영문 문서: [README.md](README.md) + +## 개요 + +TradingAgents는 실제 트레이딩 조직의 협업 흐름을 반영한 멀티 에이전트 프레임워크입니다. 펀더멘털 분석가, 뉴스 분석가, 시장 분석가, 리서처, 트레이더, 리스크 관리 팀이 역할별로 나뉘어 시장을 분석하고, 토론을 거쳐 최종 매매 결정을 도출합니다. + +이 프로젝트는 연구 목적입니다. 결과는 사용한 모델, 데이터 품질, 분석 기간, 프롬프트, 외부 환경에 따라 달라질 수 있으며 투자 자문 용도가 아닙니다. + +## 팀 구성 + +### 애널리스트 팀 +- 펀더멘털 분석가: 기업 재무 상태와 성과 지표를 평가합니다. +- 센티먼트 분석가: 소셜 미디어와 대중 심리를 분석합니다. +- 뉴스 분석가: 뉴스와 거시경제 이벤트의 영향을 해석합니다. +- 시장 분석가: 기술적 지표와 가격 흐름을 분석합니다. + +### 리서처 팀 +- 강세 관점과 약세 관점의 리서처가 애널리스트 보고서를 바탕으로 토론합니다. + +### 트레이더 +- 애널리스트와 리서처의 결과를 종합해 매매 타이밍과 비중을 판단합니다. + +### 리스크 관리 및 포트폴리오 매니저 +- 리스크를 평가하고 최종 거래 제안을 승인하거나 거절합니다. + +## 설치 + +### 저장소 클론 + +```powershell +git clone https://github.com/TauricResearch/TradingAgents.git +Set-Location TradingAgents +``` + +### Windows PowerShell 빠른 시작 + +이 저장소에서 실제로 검증한 설치 절차입니다. + +```powershell +Set-Location C:\Projects\TradingAgents +py -3.13 -m venv .venv-codex +.\.venv-codex\Scripts\Activate.ps1 +python -m pip install --upgrade pip +python -m pip install -e . --no-cache-dir +tradingagents --help +``` + +참고: +- 현재 환경에서는 `.venv-codex`를 기본 가상환경으로 사용하는 흐름을 검증했습니다. +- `tradingagents` 명령이 없으면 대개 패키지가 아직 가상환경에 설치되지 않은 상태입니다. + +### Docker + +```powershell +Copy-Item .env.example .env +notepad .env +docker compose run --rm tradingagents +``` + +Ollama 프로필: + +```powershell +docker compose --profile ollama run --rm tradingagents-ollama +``` + +## API 및 인증 + +TradingAgents는 여러 LLM 제공자를 지원합니다. + +### 일반 제공자용 환경 변수 + +```powershell +$env:OPENAI_API_KEY = "..." +$env:GOOGLE_API_KEY = "..." +$env:ANTHROPIC_API_KEY = "..." +$env:XAI_API_KEY = "..." +$env:OPENROUTER_API_KEY = "..." +$env:ALPHA_VANTAGE_API_KEY = "..." +``` + +### Codex 제공자 + +`codex` 제공자는 OpenAI API 키가 필요 없습니다. 대신 Codex CLI 로그인만 되어 있으면 됩니다. + +```powershell +where.exe codex +codex --version +codex login +``` + +또는: + +```powershell +codex login --device-auth +``` + +TradingAgents는 `codex app-server`와 stdio로 직접 통신하며, Codex가 관리하는 인증 정보를 사용합니다. 파일 기반 인증을 쓰는 경우 보통 `~/.codex/auth.json`이 사용될 수 있습니다. + +권장 `~/.codex/config.toml`: + +```toml +approval_policy = "never" +sandbox_mode = "read-only" +web_search = "disabled" +personality = "none" +cli_auth_credentials_store = "file" +``` + +중요한 점: +- TradingAgents는 자체 LangGraph `ToolNode`를 유지합니다. +- Codex dynamic tools는 사용하지 않습니다. +- 에이전트 간 컨텍스트 오염을 막기 위해 호출마다 새로운 ephemeral Codex thread를 사용합니다. +- 기본 Codex 작업 디렉터리는 `~/.codex/tradingagents-workspace`입니다. + +VS Code 터미널에서 `codex`가 인식되지 않으면: +- `where.exe codex`로 경로를 확인합니다. +- VS Code 창을 다시 로드합니다. +- 필요하면 `where.exe codex`가 반환한 전체 경로로 `codex.exe`를 직접 실행합니다. + +TradingAgents는 Windows에서 VS Code OpenAI 확장 설치 경로 같은 일반적인 위치의 `codex.exe`도 자동 탐지합니다. 자동 탐지를 덮어쓰고 싶다면: + +```powershell +$env:CODEX_BINARY = "C:\full\path\to\codex.exe" +``` + +## CLI 실행 + +설치 후 인터랙티브 CLI 실행: + +```powershell +Set-Location C:\Projects\TradingAgents +.\.venv-codex\Scripts\Activate.ps1 +tradingagents +``` + +대안: + +```powershell +Set-Location C:\Projects\TradingAgents +.\.venv-codex\Scripts\Activate.ps1 +python -m cli.main +``` + +도움말 확인: + +```powershell +Set-Location C:\Projects\TradingAgents +.\.venv-codex\Scripts\Activate.ps1 +tradingagents --help +``` + +## Python 패키지로 사용 + +### 기본 예시 + +```python +from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.default_config import DEFAULT_CONFIG + +ta = TradingAgentsGraph(debug=True, config=DEFAULT_CONFIG.copy()) +_, decision = ta.propagate("NVDA", "2026-01-15") +print(decision) +``` + +### 설정 예시 + +```python +from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.default_config import DEFAULT_CONFIG + +config = DEFAULT_CONFIG.copy() +config["llm_provider"] = "codex" +config["quick_think_llm"] = "gpt-5.4-mini" +config["deep_think_llm"] = "gpt-5.4-mini" +config["max_debate_rounds"] = 1 + +ta = TradingAgentsGraph(debug=True, config=config) +_, decision = ta.propagate("NVDA", "2026-01-15") +print(decision) +``` + +`llm_provider = "codex"`에서 추가로 조정할 수 있는 설정: +- `codex_binary` +- `codex_reasoning_effort` +- `codex_summary` +- `codex_personality` +- `codex_workspace_dir` +- `codex_request_timeout` +- `codex_max_retries` +- `codex_cleanup_threads` + +## 이번 검증에서 확인한 항목 + +실제 Windows PowerShell 환경에서 다음 항목을 검증했습니다. + +- `.venv-codex`에 패키지 설치 +- `tradingagents --help` 실행 +- 로그인된 Codex 계정으로 plain `llm.invoke(...)` 호출 +- OpenAI 스타일 `list[dict]` 입력 경로 +- `bind_tools()` 기반 tool-call 경로 +- 최소 `TradingAgentsGraph(...).propagate(...)` smoke run으로 final decision 생성 + +최소 그래프 smoke run에서는 `FINAL_DECISION= HOLD`가 반환되는 것을 확인했습니다. + +## 기여 + +버그 수정, 문서 개선, 기능 제안 등 모든 형태의 기여를 환영합니다. + +## 인용 + +인용 정보는 [README.md](README.md)의 citation 섹션을 참고해 주세요. diff --git a/README.md b/README.md index 9a92bff9..544e3770 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ # TradingAgents: Multi-Agents LLM Financial Trading Framework +Korean documentation: [README.ko.md](README.ko.md) + ## News - [2026-03] **TradingAgents v0.2.3** released with multi-language support, GPT-5.4 family models, unified model catalog, backtesting date fidelity, and proxy support. - [2026-03] **TradingAgents v0.2.2** released with GPT-5.4/Gemini 3.1/Claude 4.6 model coverage, five-tier rating scale, OpenAI Responses API, Anthropic effort control, and cross-platform stability. @@ -118,6 +120,16 @@ Install the package and its dependencies: pip install . ``` +Windows PowerShell quickstart (validated in this repository): +```powershell +Set-Location C:\Projects\TradingAgents +py -3.13 -m venv .venv-codex +.\.venv-codex\Scripts\Activate.ps1 +python -m pip install --upgrade pip +python -m pip install -e . --no-cache-dir +tradingagents --help +``` + ### Docker Alternatively, run with Docker: @@ -146,6 +158,42 @@ export ALPHA_VANTAGE_API_KEY=... # Alpha Vantage For local models, configure Ollama with `llm_provider: "ollama"` in your config. +For the local `codex` provider, no API key is required. Authenticate once with Codex instead: +```bash +codex login +# or +codex login --device-auth +``` + +TradingAgents talks directly to `codex app-server` over stdio and relies on Codex-managed credentials (for example `~/.codex/auth.json` when file-backed auth is enabled). If auth is missing, the provider fails with a message telling you to run `codex login`. + +Recommended `~/.codex/config.toml` for TradingAgents: +```toml +approval_policy = "never" +sandbox_mode = "read-only" +web_search = "disabled" +personality = "none" +cli_auth_credentials_store = "file" +``` + +Important notes for `codex`: +- TradingAgents keeps its own LangGraph `ToolNode` execution. It does not use Codex dynamic tools. +- Each model invocation uses a fresh ephemeral Codex thread to avoid context bleed across agents. +- The default Codex workspace is a dedicated neutral directory under `~/.codex/tradingagents-workspace`, not your repo root. + +Windows PowerShell notes for `codex`: +```powershell +where.exe codex +codex --version +codex login +``` + +If `codex` is not recognized in the VS Code terminal, reload the VS Code window after updating your terminal PATH or use the full `codex.exe` path returned by `where.exe codex`. +TradingAgents also tries to auto-discover `codex.exe` from common Windows locations such as the VS Code OpenAI extension install path. You can override detection explicitly with: +```powershell +$env:CODEX_BINARY = "C:\full\path\to\codex.exe" +``` + Alternatively, copy `.env.example` to `.env` and fill in your keys: ```bash cp .env.example .env @@ -160,6 +208,33 @@ python -m cli.main # alternative: run directly from source ``` You will see a screen where you can select your desired tickers, analysis date, LLM provider, research depth, and more. +Windows PowerShell run commands: +```powershell +Set-Location C:\Projects\TradingAgents +.\.venv-codex\Scripts\Activate.ps1 +tradingagents +``` + +Alternative: +```powershell +Set-Location C:\Projects\TradingAgents +.\.venv-codex\Scripts\Activate.ps1 +python -m cli.main +``` + +Validated Codex smoke checks: +```powershell +Set-Location C:\Projects\TradingAgents +.\.venv-codex\Scripts\Activate.ps1 +tradingagents --help +``` + +The local Codex provider was also validated with: +- a plain `llm.invoke(...)` call +- an OpenAI-style `list[dict]` invoke path +- a `bind_tools()` tool-call path +- a minimal `TradingAgentsGraph(...).propagate(...)` smoke run that returned a final decision +

@@ -178,7 +253,7 @@ An interface will appear showing results as they load, letting you track the age ### Implementation Details -We built TradingAgents with LangGraph to ensure flexibility and modularity. The framework supports multiple LLM providers: OpenAI, Google, Anthropic, xAI, OpenRouter, and Ollama. +We built TradingAgents with LangGraph to ensure flexibility and modularity. The framework supports multiple LLM providers: OpenAI, Codex, Google, Anthropic, xAI, OpenRouter, and Ollama. ### Python Usage @@ -202,7 +277,7 @@ from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.default_config import DEFAULT_CONFIG config = DEFAULT_CONFIG.copy() -config["llm_provider"] = "openai" # openai, google, anthropic, xai, openrouter, ollama +config["llm_provider"] = "openai" # openai, codex, google, anthropic, xai, openrouter, ollama config["deep_think_llm"] = "gpt-5.4" # Model for complex reasoning config["quick_think_llm"] = "gpt-5.4-mini" # Model for quick tasks config["max_debate_rounds"] = 2 @@ -214,6 +289,16 @@ print(decision) See `tradingagents/default_config.py` for all configuration options. +When using `llm_provider = "codex"`, these extra config knobs are available: +- `codex_binary` +- `codex_reasoning_effort` +- `codex_summary` +- `codex_personality` +- `codex_workspace_dir` +- `codex_request_timeout` +- `codex_max_retries` +- `codex_cleanup_threads` + ## Contributing We welcome contributions from the community! Whether it's fixing a bug, improving documentation, or suggesting a new feature, your input helps make this project better. If you are interested in this line of research, please consider joining our open-source financial AI research community [Tauric Research](https://tauric.ai/). diff --git a/cli/main.py b/cli/main.py index 29294d8d..d921bc66 100644 --- a/cli/main.py +++ b/cli/main.py @@ -568,6 +568,7 @@ def get_user_selections(): thinking_level = None reasoning_effort = None anthropic_effort = None + codex_reasoning_effort = None provider_lower = selected_llm_provider.lower() if provider_lower == "google": @@ -594,6 +595,14 @@ def get_user_selections(): ) ) anthropic_effort = ask_anthropic_effort() + elif provider_lower == "codex": + console.print( + create_question_box( + "Step 8: Reasoning Effort", + "Configure Codex reasoning effort level" + ) + ) + codex_reasoning_effort = ask_codex_reasoning_effort() return { "ticker": selected_ticker, @@ -607,6 +616,7 @@ def get_user_selections(): "google_thinking_level": thinking_level, "openai_reasoning_effort": reasoning_effort, "anthropic_effort": anthropic_effort, + "codex_reasoning_effort": codex_reasoning_effort, "output_language": output_language, } @@ -941,6 +951,7 @@ def run_analysis(): config["google_thinking_level"] = selections.get("google_thinking_level") config["openai_reasoning_effort"] = selections.get("openai_reasoning_effort") config["anthropic_effort"] = selections.get("anthropic_effort") + config["codex_reasoning_effort"] = selections.get("codex_reasoning_effort") config["output_language"] = selections.get("output_language", "English") # Create stats callback handler for tracking LLM/tool calls diff --git a/cli/utils.py b/cli/utils.py index e071ce06..b72f8fc7 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -237,6 +237,7 @@ def select_llm_provider() -> tuple[str, str | None]: """Select the LLM provider and its API endpoint.""" BASE_URLS = [ ("OpenAI", "https://api.openai.com/v1"), + ("Codex", None), ("Google", None), # google-genai SDK manages its own endpoint ("Anthropic", "https://api.anthropic.com/"), ("xAI", "https://api.x.ai/v1"), @@ -288,6 +289,11 @@ def ask_openai_reasoning_effort() -> str: ).ask() +def ask_codex_reasoning_effort() -> str: + """Ask for Codex reasoning effort level.""" + return ask_openai_reasoning_effort() + + def ask_anthropic_effort() -> str | None: """Ask for Anthropic effort level. diff --git a/tests/test_codex_provider.py b/tests/test_codex_provider.py new file mode 100644 index 00000000..dd1c8a04 --- /dev/null +++ b/tests/test_codex_provider.py @@ -0,0 +1,499 @@ +import re +import unittest +from collections import deque +from pathlib import Path +from unittest.mock import patch + +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage +from langchain_core.prompts import ChatPromptTemplate + +from tradingagents.llm_clients.codex_app_server import ( + CodexAppServerAuthError, + CodexAppServerBinaryError, + CodexInvocationResult, + CodexStructuredOutputError, +) +from tradingagents.llm_clients.codex_message_codec import normalize_input_messages +from tradingagents.llm_clients.codex_binary import resolve_codex_binary +from tradingagents.llm_clients.codex_preflight import run_codex_preflight +from tradingagents.llm_clients.codex_schema import ( + build_plain_response_schema, + build_tool_response_schema, + normalize_tools_for_codex, +) +from tradingagents.llm_clients.factory import create_llm_client + + +def lookup_price(ticker: str) -> str: + """Return the latest price snapshot for a ticker.""" + + +def lookup_volume(ticker: str) -> str: + """Return the latest volume snapshot for a ticker.""" + + +class FakeCodexSession: + def __init__( + self, + *, + codex_binary=None, + request_timeout=0, + workspace_dir="", + cleanup_threads=True, + responses=None, + account_payload=None, + models_payload=None, + ): + self.codex_binary = codex_binary + self.request_timeout = request_timeout + self.workspace_dir = workspace_dir + self.cleanup_threads = cleanup_threads + self.responses = deque(responses or []) + self.account_payload = account_payload or { + "account": {"type": "chatgpt"}, + "requiresOpenaiAuth": False, + } + self.models_payload = models_payload or { + "data": [{"id": "gpt-5.4", "model": "gpt-5.4"}] + } + self.started = 0 + self.closed = 0 + self.invocations = [] + + def start(self): + self.started += 1 + + def close(self): + self.closed += 1 + + def account_read(self): + return self.account_payload + + def model_list(self, include_hidden=True): + return self.models_payload + + def invoke( + self, + *, + prompt, + model, + output_schema, + reasoning_effort, + summary, + personality, + ): + self.invocations.append( + { + "prompt": prompt, + "model": model, + "output_schema": output_schema, + "reasoning_effort": reasoning_effort, + "summary": summary, + "personality": personality, + } + ) + if not self.responses: + raise AssertionError("No fake Codex responses left.") + return CodexInvocationResult(final_text=self.responses.popleft(), notifications=[]) + + +class CodexProviderTests(unittest.TestCase): + def test_resolve_codex_binary_uses_windows_vscode_fallback(self): + fake_home = Path("C:/Users/tester") + candidate = fake_home / ".vscode/extensions/openai.chatgpt-1.0.0/bin/windows-x86_64/codex.exe" + + with ( + patch("tradingagents.llm_clients.codex_binary.os.name", "nt"), + patch("tradingagents.llm_clients.codex_binary.Path.home", return_value=fake_home), + patch("tradingagents.llm_clients.codex_binary.shutil.which", return_value=None), + patch( + "tradingagents.llm_clients.codex_binary.Path.glob", + return_value=[candidate], + ), + patch("pathlib.Path.is_file", return_value=True), + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.stat") as mocked_stat, + ): + mocked_stat.return_value.st_mtime = 1 + resolved = resolve_codex_binary(None) + + self.assertEqual(resolved, str(candidate)) + + def test_resolve_codex_binary_uses_env_override(self): + with ( + patch("tradingagents.llm_clients.codex_binary.shutil.which", return_value=None), + patch.dict("os.environ", {"CODEX_BINARY": "C:/custom/codex.exe"}, clear=False), + patch("pathlib.Path.is_file", return_value=True), + ): + resolved = resolve_codex_binary(None) + + self.assertEqual(Path(resolved), Path("C:/custom/codex.exe")) + + def test_message_normalization_supports_str_messages_and_openai_dicts(self): + normalized = normalize_input_messages( + [ + {"role": "system", "content": "system"}, + {"role": "user", "content": "user"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_123", + "type": "function", + "function": { + "name": "lookup_price", + "arguments": '{"ticker":"NVDA"}', + }, + } + ], + }, + {"role": "tool", "tool_call_id": "call_123", "content": "42"}, + ] + ) + + self.assertIsInstance(normalized[0], SystemMessage) + self.assertIsInstance(normalized[1], HumanMessage) + self.assertIsInstance(normalized[2], AIMessage) + self.assertEqual(normalized[2].tool_calls[0]["name"], "lookup_price") + self.assertEqual(normalized[2].tool_calls[0]["args"], {"ticker": "NVDA"}) + self.assertIsInstance(normalized[3], ToolMessage) + + def test_output_schema_construction_builds_exact_tool_branches(self): + tool_schemas = normalize_tools_for_codex([lookup_price]) + schema = build_tool_response_schema(tool_schemas) + required_schema = build_tool_response_schema(tool_schemas, allow_final=False) + plain_schema = build_plain_response_schema() + + self.assertEqual(plain_schema["required"], ["answer"]) + self.assertEqual(schema["properties"]["mode"]["enum"], ["final", "tool_calls"]) + tool_branch = schema["properties"]["tool_calls"]["items"] + self.assertEqual(tool_branch["properties"]["name"]["const"], "lookup_price") + self.assertIn("arguments", tool_branch["required"]) + self.assertEqual(required_schema["properties"]["mode"]["const"], "tool_calls") + + generic_schema = build_tool_response_schema( + normalize_tools_for_codex([lookup_price, lookup_volume]) + ) + generic_items = generic_schema["properties"]["tool_calls"]["items"] + self.assertEqual(generic_items["properties"]["name"]["type"], "string") + self.assertIn("enum", generic_items["properties"]["name"]) + self.assertEqual(generic_items["properties"]["arguments_json"]["type"], "string") + + def test_plain_final_response_parsing(self): + session = FakeCodexSession( + responses=['{"answer":"Final decision"}'], + ) + llm = create_llm_client( + "codex", + "gpt-5.4", + codex_binary="C:/fake/codex", + codex_workspace_dir="C:/tmp/codex-workspace", + session_factory=lambda **kwargs: session, + preflight_runner=lambda **kwargs: None, + ).get_llm() + + result = llm.invoke("Give me the final answer.") + + self.assertEqual(result.content, "Final decision") + self.assertEqual(session.started, 1) + + def test_invoke_accepts_openai_style_message_dicts(self): + session = FakeCodexSession( + responses=['{"answer":"From dict transcript"}'], + ) + llm = create_llm_client( + "codex", + "gpt-5.4", + codex_binary="C:/fake/codex", + codex_workspace_dir="C:/tmp/codex-workspace", + session_factory=lambda **kwargs: session, + preflight_runner=lambda **kwargs: None, + ).get_llm() + + result = llm.invoke( + [ + {"role": "system", "content": "system"}, + {"role": "user", "content": "user"}, + ] + ) + + self.assertEqual(result.content, "From dict transcript") + self.assertIn("[System]\nsystem", session.invocations[0]["prompt"]) + self.assertIn("[Human]\nuser", session.invocations[0]["prompt"]) + + def test_invoke_accepts_langchain_message_sequences(self): + session = FakeCodexSession( + responses=['{"answer":"From BaseMessage transcript"}'], + ) + llm = create_llm_client( + "codex", + "gpt-5.4", + codex_binary="C:/fake/codex", + codex_workspace_dir="C:/tmp/codex-workspace", + session_factory=lambda **kwargs: session, + preflight_runner=lambda **kwargs: None, + ).get_llm() + + result = llm.invoke( + [ + SystemMessage(content="system"), + HumanMessage(content="user"), + ] + ) + + self.assertEqual(result.content, "From BaseMessage transcript") + self.assertIn("[System]\nsystem", session.invocations[0]["prompt"]) + self.assertIn("[Human]\nuser", session.invocations[0]["prompt"]) + + def test_tool_call_response_parsing_populates_ai_message_tool_calls(self): + session = FakeCodexSession( + responses=[ + '{"mode":"tool_calls","content":"Need data first","tool_calls":[{"name":"lookup_price","arguments":{"ticker":"NVDA"}}]}' + ], + ) + llm = create_llm_client( + "codex", + "gpt-5.4", + codex_binary="C:/fake/codex", + codex_workspace_dir="C:/tmp/codex-workspace", + session_factory=lambda **kwargs: session, + preflight_runner=lambda **kwargs: None, + ).get_llm() + + prompt = ChatPromptTemplate.from_messages( + [("system", "Use tools if needed."), ("human", "Analyze NVDA")] + ) + result = (prompt | llm.bind_tools([lookup_price])).invoke({}) + + self.assertEqual(result.content, "Need data first") + self.assertEqual(result.tool_calls[0]["name"], "lookup_price") + self.assertEqual(result.tool_calls[0]["args"], {"ticker": "NVDA"}) + self.assertRegex(result.tool_calls[0]["id"], r"^call_[0-9a-f]{32}$") + + def test_multi_tool_response_parses_arguments_json(self): + session = FakeCodexSession( + responses=[ + '{"mode":"tool_calls","content":"","tool_calls":[{"name":"lookup_price","arguments_json":"{\\"ticker\\":\\"NVDA\\"}"}]}' + ], + ) + llm = create_llm_client( + "codex", + "gpt-5.4", + codex_binary="C:/fake/codex", + codex_workspace_dir="C:/tmp/codex-workspace", + session_factory=lambda **kwargs: session, + preflight_runner=lambda **kwargs: None, + ).get_llm() + + result = llm.bind_tools([lookup_price, lookup_volume]).invoke("Analyze NVDA") + + self.assertEqual(result.tool_calls[0]["name"], "lookup_price") + self.assertEqual(result.tool_calls[0]["args"], {"ticker": "NVDA"}) + + def test_bind_tools_honors_required_and_named_tool_choice(self): + required_session = FakeCodexSession( + responses=[ + '{"mode":"tool_calls","content":"Calling tool","tool_calls":[{"name":"lookup_price","arguments":{"ticker":"NVDA"}}]}' + ], + ) + required_llm = create_llm_client( + "codex", + "gpt-5.4", + codex_binary="C:/fake/codex", + codex_workspace_dir="C:/tmp/codex-workspace", + session_factory=lambda **kwargs: required_session, + preflight_runner=lambda **kwargs: None, + ).get_llm() + + required_result = required_llm.bind_tools([lookup_price], tool_choice="required").invoke( + "Analyze NVDA" + ) + self.assertTrue(required_result.tool_calls) + self.assertEqual( + required_session.invocations[0]["output_schema"]["properties"]["mode"]["const"], + "tool_calls", + ) + self.assertIn( + "must respond with one or more tool calls", + required_session.invocations[0]["prompt"].lower(), + ) + + named_session = FakeCodexSession( + responses=[ + '{"mode":"tool_calls","content":"Calling named tool","tool_calls":[{"name":"lookup_price","arguments":{"ticker":"MSFT"}}]}' + ], + ) + named_llm = create_llm_client( + "codex", + "gpt-5.4", + codex_binary="C:/fake/codex", + codex_workspace_dir="C:/tmp/codex-workspace", + session_factory=lambda **kwargs: named_session, + preflight_runner=lambda **kwargs: None, + ).get_llm() + + named_result = named_llm.bind_tools( + [lookup_price], + tool_choice={"type": "function", "function": {"name": "lookup_price"}}, + ).invoke("Analyze MSFT") + self.assertEqual(named_result.tool_calls[0]["name"], "lookup_price") + tool_item = named_session.invocations[0]["output_schema"]["properties"]["tool_calls"]["items"] + self.assertEqual(tool_item["properties"]["name"]["const"], "lookup_price") + self.assertIn( + "must call the tool named `lookup_price`", + named_session.invocations[0]["prompt"].lower(), + ) + + def test_malformed_json_retries_and_surfaces_error_when_exhausted(self): + session = FakeCodexSession( + responses=["not json", '{"answer":"Recovered"}'], + ) + llm = create_llm_client( + "codex", + "gpt-5.4", + codex_binary="C:/fake/codex", + codex_workspace_dir="C:/tmp/codex-workspace", + codex_max_retries=1, + session_factory=lambda **kwargs: session, + preflight_runner=lambda **kwargs: None, + ).get_llm() + + result = llm.invoke("Recover after malformed JSON.") + self.assertEqual(result.content, "Recovered") + self.assertEqual(len(session.invocations), 2) + self.assertIn( + "previous response did not satisfy tradingagents validation", + session.invocations[1]["prompt"].lower(), + ) + + failing_session = FakeCodexSession( + responses=["still bad", "still bad again"], + ) + failing_llm = create_llm_client( + "codex", + "gpt-5.4", + codex_binary="C:/fake/codex", + codex_workspace_dir="C:/tmp/codex-workspace", + codex_max_retries=1, + session_factory=lambda **kwargs: failing_session, + preflight_runner=lambda **kwargs: None, + ).get_llm() + + with self.assertRaises(CodexStructuredOutputError): + failing_llm.invoke("This should fail.") + + def test_runtime_errors_do_not_retry_as_json_failures(self): + class FailingSession(FakeCodexSession): + def invoke(self, **kwargs): + raise RuntimeError("transport exploded") + + session = FailingSession() + llm = create_llm_client( + "codex", + "gpt-5.4", + codex_binary="C:/fake/codex", + codex_workspace_dir="C:/tmp/codex-workspace", + codex_max_retries=2, + session_factory=lambda **kwargs: session, + preflight_runner=lambda **kwargs: None, + ).get_llm() + + with self.assertRaisesRegex(RuntimeError, "transport exploded"): + llm.invoke("fail fast") + + def test_provider_codex_smoke_covers_bind_tools_and_direct_invoke_paths(self): + session = FakeCodexSession( + responses=[ + '{"mode":"tool_calls","content":"Fetching market data","tool_calls":[{"name":"lookup_price","arguments":{"ticker":"NVDA"}}]}', + '{"answer":"Rating: Buy\\nExecutive Summary: Add gradually."}', + ], + ) + llm = create_llm_client( + "codex", + "gpt-5.4", + codex_binary="C:/fake/codex", + codex_workspace_dir="C:/tmp/codex-workspace", + session_factory=lambda **kwargs: session, + preflight_runner=lambda **kwargs: None, + ).get_llm() + + analyst_prompt = ChatPromptTemplate.from_messages( + [("system", "Use tools when you need extra data."), ("human", "Analyze NVDA.")] + ) + market_result = (analyst_prompt | llm.bind_tools([lookup_price])).invoke({}) + self.assertTrue(market_result.tool_calls) + self.assertEqual(market_result.tool_calls[0]["name"], "lookup_price") + + decision = llm.invoke("Produce the final trade decision.") + self.assertIn("Rating: Buy", decision.content) + self.assertEqual(len(session.invocations), 2) + + def test_preflight_detects_missing_auth_and_missing_binary(self): + valid_factory = lambda **kwargs: FakeCodexSession( + account_payload={ + "account": {"type": "chatgpt", "email": "user@example.com"}, + "requiresOpenaiAuth": True, + } + ) + result = run_codex_preflight( + codex_binary="C:\\fake\\codex.exe", + model="gpt-5.4", + request_timeout=10.0, + workspace_dir="C:/tmp/codex-workspace", + cleanup_threads=True, + session_factory=valid_factory, + ) + self.assertEqual(result.account["type"], "chatgpt") + + authless_factory = lambda **kwargs: FakeCodexSession( + account_payload={"account": None, "requiresOpenaiAuth": True} + ) + with self.assertRaises(CodexAppServerAuthError): + run_codex_preflight( + codex_binary="C:\\fake\\codex.exe", + model="gpt-5.4", + request_timeout=10.0, + workspace_dir="C:/tmp/codex-workspace", + cleanup_threads=True, + session_factory=authless_factory, + ) + + with patch( + "tradingagents.llm_clients.codex_preflight.resolve_codex_binary", + return_value=None, + ): + with self.assertRaises(CodexAppServerBinaryError): + run_codex_preflight( + codex_binary="definitely-missing-codex-binary", + model="gpt-5.4", + request_timeout=10.0, + workspace_dir="C:/tmp/codex-workspace", + cleanup_threads=True, + ) + + def test_preflight_uses_resolved_binary_path(self): + captured = {} + + def factory(**kwargs): + captured["codex_binary"] = kwargs["codex_binary"] + return FakeCodexSession(**kwargs) + + with patch( + "tradingagents.llm_clients.codex_preflight.resolve_codex_binary", + return_value="C:/resolved/codex.exe", + ): + run_codex_preflight( + codex_binary=None, + model="gpt-5.4", + request_timeout=10.0, + workspace_dir="C:/tmp/codex-workspace", + cleanup_threads=True, + session_factory=factory, + ) + + self.assertEqual(captured["codex_binary"], "C:/resolved/codex.exe") + + +if __name__ == "__main__": + unittest.main() diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index 26a4e4d2..8fc4dafd 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -1,4 +1,5 @@ import os +from pathlib import Path DEFAULT_CONFIG = { "project_dir": os.path.abspath(os.path.join(os.path.dirname(__file__), ".")), @@ -16,6 +17,14 @@ DEFAULT_CONFIG = { "google_thinking_level": None, # "high", "minimal", etc. "openai_reasoning_effort": None, # "medium", "high", "low" "anthropic_effort": None, # "high", "medium", "low" + "codex_binary": os.getenv("CODEX_BINARY"), + "codex_reasoning_effort": "medium", + "codex_summary": "none", + "codex_personality": "none", + "codex_workspace_dir": str(Path.home() / ".codex" / "tradingagents-workspace"), + "codex_request_timeout": 120.0, + "codex_max_retries": 2, + "codex_cleanup_threads": True, # Output language for analyst reports and final decision # Internal agent debate stays in English for reasoning quality "output_language": "English", diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 8e18f9c4..6bdc0dfd 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -152,6 +152,15 @@ class TradingAgentsGraph: effort = self.config.get("anthropic_effort") if effort: kwargs["effort"] = effort + elif provider == "codex": + kwargs["codex_binary"] = self.config.get("codex_binary") + kwargs["codex_reasoning_effort"] = self.config.get("codex_reasoning_effort") + kwargs["codex_summary"] = self.config.get("codex_summary") + kwargs["codex_personality"] = self.config.get("codex_personality") + kwargs["codex_workspace_dir"] = self.config.get("codex_workspace_dir") + kwargs["codex_request_timeout"] = self.config.get("codex_request_timeout") + kwargs["codex_max_retries"] = self.config.get("codex_max_retries") + kwargs["codex_cleanup_threads"] = self.config.get("codex_cleanup_threads") return kwargs diff --git a/tradingagents/llm_clients/__init__.py b/tradingagents/llm_clients/__init__.py index e528eabe..9ff69f71 100644 --- a/tradingagents/llm_clients/__init__.py +++ b/tradingagents/llm_clients/__init__.py @@ -1,4 +1,10 @@ from .base_client import BaseLLMClient -from .factory import create_llm_client + + +def create_llm_client(*args, **kwargs): + from .factory import create_llm_client as _create_llm_client + + return _create_llm_client(*args, **kwargs) + __all__ = ["BaseLLMClient", "create_llm_client"] diff --git a/tradingagents/llm_clients/codex_app_server.py b/tradingagents/llm_clients/codex_app_server.py new file mode 100644 index 00000000..a2d44c2d --- /dev/null +++ b/tradingagents/llm_clients/codex_app_server.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +import json +import queue +import subprocess +import threading +import uuid +from collections import deque +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .codex_binary import codex_binary_error_message, resolve_codex_binary + + +class CodexAppServerError(RuntimeError): + """Raised when the Codex app-server request cycle fails.""" + + +class CodexAppServerAuthError(CodexAppServerError): + """Raised when Codex login is missing or unusable.""" + + +class CodexAppServerBinaryError(CodexAppServerError): + """Raised when the Codex binary cannot be started.""" + + +class CodexStructuredOutputError(CodexAppServerError): + """Raised when Codex does not honor the requested structured output.""" + + +@dataclass(slots=True) +class CodexInvocationResult: + final_text: str + notifications: list[dict[str, Any]] + + +class CodexAppServerSession: + """Minimal JSON-RPC client for `codex app-server` over stdio JSONL.""" + + def __init__( + self, + *, + codex_binary: str | None, + request_timeout: float, + workspace_dir: str, + cleanup_threads: bool, + client_name: str = "tradingagents_codex", + client_title: str = "TradingAgents Codex Provider", + client_version: str = "0.2.3", + ) -> None: + self.codex_binary = codex_binary + self.request_timeout = request_timeout + self.workspace_dir = str(Path(workspace_dir).expanduser()) + self.cleanup_threads = cleanup_threads + self.client_name = client_name + self.client_title = client_title + self.client_version = client_version + self._proc: subprocess.Popen[str] | None = None + self._stdout_queue: queue.Queue[dict[str, Any] | None] = queue.Queue() + self._pending: deque[dict[str, Any]] = deque() + self._stderr_lines: deque[str] = deque(maxlen=200) + self._lock = threading.RLock() + self._request_lock = threading.RLock() + self._reader_thread: threading.Thread | None = None + self._stderr_thread: threading.Thread | None = None + + def start(self) -> None: + with self._lock: + if self._proc is not None: + return + + Path(self.workspace_dir).mkdir(parents=True, exist_ok=True) + + binary = resolve_codex_binary(self.codex_binary) + if not binary: + raise CodexAppServerBinaryError(codex_binary_error_message(self.codex_binary)) + self.codex_binary = binary + + try: + self._proc = subprocess.Popen( + [binary, "app-server", "--listen", "stdio://"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + cwd=self.workspace_dir, + bufsize=1, + ) + except OSError as exc: + raise CodexAppServerBinaryError( + f"Failed to start Codex app-server with binary '{binary}': {exc}" + ) from exc + + self._start_reader_threads() + self._initialize() + + def close(self) -> None: + with self._lock: + proc = self._proc + self._proc = None + if proc is None: + return + + try: + if proc.stdin: + proc.stdin.close() + except OSError: + pass + + try: + proc.terminate() + proc.wait(timeout=2) + except Exception: + proc.kill() + + def account_read(self) -> dict[str, Any]: + return self.request("account/read", {"refreshToken": False}) + + def model_list(self, *, include_hidden: bool = True) -> dict[str, Any]: + return self.request("model/list", {"includeHidden": include_hidden}) + + def invoke( + self, + *, + prompt: str, + model: str, + output_schema: dict[str, Any], + reasoning_effort: str | None, + summary: str | None, + personality: str | None, + ) -> CodexInvocationResult: + with self._request_lock: + self.start() + thread_id = None + try: + thread = self.request( + "thread/start", + { + "approvalPolicy": "never", + "cwd": self.workspace_dir, + "ephemeral": True, + "model": model, + "personality": personality, + "sandbox": "read-only", + "serviceName": "tradingagents_codex", + }, + ) + thread_id = thread["thread"]["id"] + started = self.request( + "turn/start", + { + "threadId": thread_id, + "input": [{"type": "text", "text": prompt}], + "model": model, + "effort": reasoning_effort, + "summary": summary, + "outputSchema": output_schema, + }, + ) + turn_id = started["turn"]["id"] + final_text, notifications = self._collect_turn(turn_id) + return CodexInvocationResult(final_text=final_text, notifications=notifications) + finally: + if thread_id and self.cleanup_threads: + try: + self.request("thread/unsubscribe", {"threadId": thread_id}) + except CodexAppServerError: + pass + + def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + request_id = str(uuid.uuid4()) + self._write({"id": request_id, "method": method, "params": params or {}}) + deferred: list[dict[str, Any]] = [] + + while True: + message = self._next_message(self.request_timeout) + if message.get("id") == request_id: + self._restore_deferred(deferred) + if "error" in message: + error = message["error"] or {} + code = error.get("code") + text = error.get("message", "unknown Codex app-server error") + raise CodexAppServerError( + f"{method} failed ({code}): {text}. stderr_tail={self._stderr_tail()}" + ) + result = message.get("result") + if not isinstance(result, dict): + raise CodexAppServerError(f"{method} returned a non-object result: {result!r}") + return result + + if "method" in message and "id" in message: + self._handle_server_request(message) + continue + + deferred.append(message) + + def _initialize(self) -> None: + response = self.request( + "initialize", + { + "clientInfo": { + "name": self.client_name, + "title": self.client_title, + "version": self.client_version, + } + }, + ) + if not response.get("userAgent"): + raise CodexAppServerError("Codex initialize response did not include userAgent.") + self._write({"method": "initialized", "params": {}}) + + def _collect_turn(self, turn_id: str) -> tuple[str, list[dict[str, Any]]]: + notifications: list[dict[str, Any]] = [] + final_messages: list[str] = [] + fallback_messages: list[str] = [] + + while True: + message = self._next_message(self.request_timeout) + + if "method" in message and "id" in message: + self._handle_server_request(message) + continue + + if "method" not in message: + self._pending.append(message) + continue + + method = message["method"] + params = message.get("params", {}) + notifications.append(message) + + if ( + method == "item/completed" + and isinstance(params, dict) + and params.get("turnId") == turn_id + ): + item = params.get("item", {}) + if isinstance(item, dict) and item.get("type") == "agentMessage": + text = str(item.get("text", "")) + if item.get("phase") == "final_answer": + final_messages.append(text) + else: + fallback_messages.append(text) + continue + + if method == "turn/completed" and isinstance(params, dict): + turn = params.get("turn", {}) + if isinstance(turn, dict) and turn.get("id") == turn_id: + status = turn.get("status") + if status == "failed": + error = turn.get("error", {}) + message_text = error.get("message") if isinstance(error, dict) else None + raise CodexAppServerError( + message_text or f"Codex turn {turn_id} failed without an error message." + ) + break + + if final_messages: + return final_messages[-1], notifications + if fallback_messages: + return fallback_messages[-1], notifications + raise CodexStructuredOutputError("Codex turn completed without an assistant message.") + + def _handle_server_request(self, message: dict[str, Any]) -> None: + try: + self._write({"id": message["id"], "result": {}}) + except Exception: + pass + + def _write(self, payload: dict[str, Any]) -> None: + if self._proc is None or self._proc.stdin is None: + raise CodexAppServerError("Codex app-server is not running.") + try: + self._proc.stdin.write(json.dumps(payload) + "\n") + self._proc.stdin.flush() + except OSError as exc: + raise CodexAppServerError( + f"Failed to write to Codex app-server: {exc}. stderr_tail={self._stderr_tail()}" + ) from exc + + def _next_message(self, timeout: float) -> dict[str, Any]: + if self._pending: + return self._pending.popleft() + + try: + message = self._stdout_queue.get(timeout=timeout) + except queue.Empty as exc: + raise CodexAppServerError( + f"Timed out waiting for Codex app-server after {timeout}s. stderr_tail={self._stderr_tail()}" + ) from exc + + if message is None: + raise CodexAppServerError( + f"Codex app-server closed unexpectedly. stderr_tail={self._stderr_tail()}" + ) + return message + + def _start_reader_threads(self) -> None: + assert self._proc is not None + assert self._proc.stdout is not None + assert self._proc.stderr is not None + + def _read_stdout() -> None: + stdout = self._proc.stdout + assert stdout is not None + for line in stdout: + line = line.strip() + if not line: + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + self._stderr_lines.append(f"invalid_json_stdout={line}") + continue + if isinstance(payload, dict): + self._stdout_queue.put(payload) + self._stdout_queue.put(None) + + def _read_stderr() -> None: + stderr = self._proc.stderr + assert stderr is not None + for line in stderr: + self._stderr_lines.append(line.rstrip()) + + self._reader_thread = threading.Thread(target=_read_stdout, daemon=True) + self._stderr_thread = threading.Thread(target=_read_stderr, daemon=True) + self._reader_thread.start() + self._stderr_thread.start() + + def _stderr_tail(self) -> str: + return "\n".join(list(self._stderr_lines)[-40:]) + + def _restore_deferred(self, deferred: list[dict[str, Any]]) -> None: + for message in reversed(deferred): + self._pending.appendleft(message) diff --git a/tradingagents/llm_clients/codex_binary.py b/tradingagents/llm_clients/codex_binary.py new file mode 100644 index 00000000..71d0588f --- /dev/null +++ b/tradingagents/llm_clients/codex_binary.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import os +import shutil +from pathlib import Path + + +def resolve_codex_binary(codex_binary: str | None) -> str | None: + explicit = _normalize_explicit_binary(codex_binary) + if explicit: + return explicit + + env_value = _normalize_explicit_binary(os.getenv("CODEX_BINARY")) + if env_value: + return env_value + + path_binary = shutil.which("codex") + if path_binary: + return path_binary + + for candidate in _windows_codex_candidates(): + if candidate.is_file(): + return str(candidate) + + return None + + +def codex_binary_error_message(codex_binary: str | None) -> str: + requested = codex_binary or os.getenv("CODEX_BINARY") or "codex" + message = ( + f"Could not find Codex binary '{requested}'. Install Codex, ensure it is on PATH, " + "set the `CODEX_BINARY` environment variable, or configure `codex_binary` with the full executable path." + ) + discovered = [str(path) for path in _windows_codex_candidates() if path.is_file()] + if discovered: + message += f" Detected candidate: {discovered[0]}" + return message + + +def _normalize_explicit_binary(value: str | None) -> str | None: + if not value: + return None + + expanded = str(Path(value).expanduser()) + has_separator = any(sep and sep in expanded for sep in (os.path.sep, os.path.altsep)) + if has_separator: + return expanded if Path(expanded).is_file() else None + + found = shutil.which(expanded) + return found or None + + +def _windows_codex_candidates() -> list[Path]: + if os.name != "nt": + return [] + + home = Path.home() + candidates = sorted( + home.glob(r".vscode/extensions/openai.chatgpt-*/bin/windows-x86_64/codex.exe"), + key=lambda path: path.stat().st_mtime if path.exists() else 0, + reverse=True, + ) + candidates.extend( + [ + home / ".codex" / "bin" / "codex.exe", + home / "AppData" / "Local" / "Programs" / "Codex" / "codex.exe", + ] + ) + return candidates diff --git a/tradingagents/llm_clients/codex_chat_model.py b/tradingagents/llm_clients/codex_chat_model.py new file mode 100644 index 00000000..721dedcb --- /dev/null +++ b/tradingagents/llm_clients/codex_chat_model.py @@ -0,0 +1,407 @@ +from __future__ import annotations + +import json +import threading +import uuid +from typing import Any, Callable, Sequence + +from pydantic import ConfigDict, Field, PrivateAttr + +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage +from langchain_core.outputs import ChatGeneration, ChatResult + +from .codex_app_server import CodexAppServerSession, CodexStructuredOutputError +from .codex_message_codec import ( + format_messages_for_codex, + normalize_input_messages, + strip_json_fence, +) +from .codex_preflight import run_codex_preflight +from .codex_schema import ( + build_plain_response_schema, + build_tool_response_schema, + normalize_tools_for_codex, +) + + +class CodexChatModel(BaseChatModel): + """LangChain chat model that talks to `codex app-server` over stdio.""" + + model: str + codex_binary: str | None = None + codex_reasoning_effort: str | None = None + codex_summary: str | None = None + codex_personality: str | None = None + codex_workspace_dir: str + codex_request_timeout: float = 120.0 + codex_max_retries: int = 2 + codex_cleanup_threads: bool = True + session_factory: Callable[..., CodexAppServerSession] | None = Field( + default=None, exclude=True, repr=False + ) + preflight_runner: Callable[..., Any] | None = Field( + default=None, exclude=True, repr=False + ) + + model_config = ConfigDict(arbitrary_types_allowed=True) + + _session: CodexAppServerSession | None = PrivateAttr(default=None) + _session_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock) + _preflight_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock) + _preflight_done: bool = PrivateAttr(default=False) + + @property + def _llm_type(self) -> str: + return "codex" + + @property + def _identifying_params(self) -> dict[str, Any]: + return { + "model": self.model, + "codex_binary": self.codex_binary, + "codex_reasoning_effort": self.codex_reasoning_effort, + "codex_summary": self.codex_summary, + "codex_personality": self.codex_personality, + } + + def preflight(self) -> None: + with self._preflight_lock: + if self._preflight_done: + return + runner = self.preflight_runner or run_codex_preflight + runner( + codex_binary=self.codex_binary, + model=self.model, + request_timeout=self.codex_request_timeout, + workspace_dir=self.codex_workspace_dir, + cleanup_threads=self.codex_cleanup_threads, + session_factory=self.session_factory or CodexAppServerSession, + ) + self._preflight_done = True + + def bind_tools( + self, + tools: Sequence[dict[str, Any] | type | Callable | Any], + *, + tool_choice: str | bool | dict[str, Any] | None = None, + **kwargs: Any, + ): + normalized_tools = normalize_tools_for_codex(tools) + return self.bind(tools=normalized_tools, tool_choice=tool_choice, **kwargs) + + def close(self) -> None: + with self._session_lock: + if self._session is not None: + self._session.close() + self._session = None + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager=None, + **kwargs: Any, + ) -> ChatResult: + self.preflight() + + normalized_messages = normalize_input_messages(messages) + tools = kwargs.get("tools") or [] + tool_choice = kwargs.get("tool_choice") + tool_binding = self._resolve_tool_binding(tools, tool_choice) + tools = tool_binding["tools"] + effective_tool_choice = tool_binding["tool_choice"] + output_schema = tool_binding["output_schema"] + tool_arguments_as_json_string = tool_binding["tool_arguments_as_json_string"] + + raw_response: str | None = None + last_error: Exception | None = None + for attempt in range(self.codex_max_retries + 1): + retry_message = None + if attempt: + previous_error = str(last_error) if last_error is not None else "unknown schema mismatch" + retry_message = ( + "The previous response did not satisfy TradingAgents validation: " + f"{previous_error}. Return only valid JSON that exactly matches the requested " + "schema and tool argument requirements." + ) + + prompt = format_messages_for_codex( + normalized_messages, + tool_names=[tool["function"]["name"] for tool in tools], + tool_schemas=tools, + tool_choice=effective_tool_choice, + tool_arguments_as_json_string=tool_arguments_as_json_string, + retry_message=retry_message, + ) + result = self._session_or_create().invoke( + prompt=prompt, + model=self.model, + output_schema=output_schema, + reasoning_effort=self.codex_reasoning_effort, + summary=self.codex_summary, + personality=self.codex_personality, + ) + raw_response = result.final_text + + if run_manager is not None: + for notification in result.notifications: + if notification.get("method") != "item/agentMessage/delta": + continue + params = notification.get("params", {}) + if isinstance(params, dict): + delta = params.get("delta") + if isinstance(delta, str) and delta: + run_manager.on_llm_new_token(delta) + + try: + ai_message = ( + self._parse_tool_response( + raw_response, + tools, + tool_arguments_as_json_string=tool_arguments_as_json_string, + ) + if tools + else self._parse_plain_response(raw_response) + ) + return ChatResult(generations=[ChatGeneration(message=ai_message)]) + except (json.JSONDecodeError, CodexStructuredOutputError, ValueError) as exc: + last_error = exc + continue + + raise CodexStructuredOutputError( + "Codex returned malformed structured output after " + f"{self.codex_max_retries + 1} attempt(s): {last_error}. " + f"Last response: {raw_response!r}" + ) + + def _parse_plain_response(self, raw_response: str) -> AIMessage: + payload = json.loads(strip_json_fence(raw_response)) + if not isinstance(payload, dict) or not isinstance(payload.get("answer"), str): + raise CodexStructuredOutputError( + f"Expected plain response JSON with string `answer`, got: {payload!r}" + ) + return AIMessage(content=payload["answer"]) + + def _parse_tool_response( + self, + raw_response: str, + tools: Sequence[dict[str, Any]], + *, + tool_arguments_as_json_string: bool, + ) -> AIMessage: + payload = json.loads(strip_json_fence(raw_response)) + if not isinstance(payload, dict): + raise CodexStructuredOutputError(f"Expected JSON object, got: {payload!r}") + + mode = payload.get("mode") + content = payload.get("content", "") + if not isinstance(content, str): + raise CodexStructuredOutputError("Structured response `content` must be a string.") + + if mode == "final": + tool_calls = payload.get("tool_calls", []) + if tool_calls not in ([], None): + raise CodexStructuredOutputError( + f"`mode=final` must not include tool calls, got: {tool_calls!r}" + ) + return AIMessage(content=content) + + if mode != "tool_calls": + raise CodexStructuredOutputError(f"Unknown structured response mode: {mode!r}") + + raw_tool_calls = payload.get("tool_calls") + if not isinstance(raw_tool_calls, list) or not raw_tool_calls: + raise CodexStructuredOutputError("`mode=tool_calls` requires a non-empty tool_calls array.") + + tool_calls: list[dict[str, Any]] = [] + tool_parameters = { + tool.get("function", {}).get("name"): tool.get("function", {}).get("parameters", {}) + for tool in tools + } + for item in raw_tool_calls: + if not isinstance(item, dict): + raise CodexStructuredOutputError(f"Tool call entries must be objects, got: {item!r}") + name = item.get("name") + arguments = self._extract_tool_arguments( + item, + tool_arguments_as_json_string=tool_arguments_as_json_string, + ) + if not isinstance(name, str) or not isinstance(arguments, dict): + raise CodexStructuredOutputError( + f"Tool call entries must include string name and object arguments, got: {item!r}" + ) + if name not in tool_parameters: + raise CodexStructuredOutputError( + f"Tool call name '{name}' is not in the bound tool set." + ) + self._validate_tool_arguments(name, arguments, tool_parameters[name]) + tool_calls.append( + { + "name": name, + "args": arguments, + "id": f"call_{uuid.uuid4().hex}", + } + ) + + return AIMessage(content=content, tool_calls=tool_calls) + + def _extract_tool_arguments( + self, + item: dict[str, Any], + *, + tool_arguments_as_json_string: bool, + ) -> dict[str, Any]: + if tool_arguments_as_json_string: + raw_arguments = item.get("arguments_json") + if not isinstance(raw_arguments, str): + raise CodexStructuredOutputError( + f"Tool call entries must include string arguments_json, got: {item!r}" + ) + try: + parsed = json.loads(raw_arguments) + except json.JSONDecodeError as exc: + raise CodexStructuredOutputError( + f"Tool call arguments_json must contain valid JSON, got: {raw_arguments!r}" + ) from exc + if not isinstance(parsed, dict): + raise CodexStructuredOutputError( + f"Tool call arguments_json must decode to an object, got: {parsed!r}" + ) + return parsed + + arguments = item.get("arguments") + if not isinstance(arguments, dict): + raise CodexStructuredOutputError( + f"Tool call entries must include object arguments, got: {item!r}" + ) + return arguments + + def _validate_tool_arguments( + self, + tool_name: str, + arguments: dict[str, Any], + schema: dict[str, Any] | None, + ) -> None: + if not isinstance(schema, dict): + return + + properties = schema.get("properties") + if properties is not None and not isinstance(properties, dict): + raise CodexStructuredOutputError( + f"Tool schema for '{tool_name}' has invalid properties metadata." + ) + + required = schema.get("required") or [] + if isinstance(required, list): + missing = [name for name in required if name not in arguments] + if missing: + raise CodexStructuredOutputError( + f"Tool call '{tool_name}' is missing required arguments: {', '.join(missing)}" + ) + + if properties and schema.get("additionalProperties") is False: + unexpected = [name for name in arguments if name not in properties] + if unexpected: + raise CodexStructuredOutputError( + f"Tool call '{tool_name}' included unexpected arguments: {', '.join(unexpected)}" + ) + + def _session_or_create(self) -> CodexAppServerSession: + with self._session_lock: + if self._session is None: + factory = self.session_factory or CodexAppServerSession + self._session = factory( + codex_binary=self.codex_binary, + request_timeout=self.codex_request_timeout, + workspace_dir=self.codex_workspace_dir, + cleanup_threads=self.codex_cleanup_threads, + ) + self._session.start() + return self._session + + def _resolve_tool_binding( + self, + tools: Sequence[dict[str, Any]], + tool_choice: Any, + ) -> dict[str, Any]: + tool_list = list(tools) + if not tool_list: + return { + "tools": [], + "tool_choice": None, + "output_schema": build_plain_response_schema(), + "tool_arguments_as_json_string": False, + } + + if tool_choice in (None, "auto"): + return { + "tools": tool_list, + "tool_choice": None if tool_choice is None else "auto", + "output_schema": build_tool_response_schema(tool_list, allow_final=True), + "tool_arguments_as_json_string": len(tool_list) > 1, + } + + if tool_choice in (False, "none"): + return { + "tools": [], + "tool_choice": "none", + "output_schema": build_plain_response_schema(), + "tool_arguments_as_json_string": False, + } + + if tool_choice in (True, "any", "required"): + normalized_choice = "required" if tool_choice in (True, "required") else "any" + return { + "tools": tool_list, + "tool_choice": normalized_choice, + "output_schema": build_tool_response_schema(tool_list, allow_final=False), + "tool_arguments_as_json_string": len(tool_list) > 1, + } + + selected_tool_name = self._extract_named_tool_choice(tool_choice) + if selected_tool_name is None: + raise CodexStructuredOutputError( + f"Unsupported Codex tool_choice value: {tool_choice!r}" + ) + + selected_tools = [ + tool + for tool in tool_list + if tool.get("function", {}).get("name") == selected_tool_name + ] + if not selected_tools: + available = ", ".join( + tool.get("function", {}).get("name", "") + for tool in tool_list + ) + raise CodexStructuredOutputError( + f"Requested tool_choice '{selected_tool_name}' is not in the bound tool set. " + f"Available tools: {available}" + ) + + return { + "tools": selected_tools, + "tool_choice": selected_tool_name, + "output_schema": build_tool_response_schema(selected_tools, allow_final=False), + "tool_arguments_as_json_string": False, + } + + def _extract_named_tool_choice(self, tool_choice: Any) -> str | None: + if isinstance(tool_choice, str): + return tool_choice + + if not isinstance(tool_choice, dict): + return None + + function = tool_choice.get("function") + if isinstance(function, dict): + name = function.get("name") + if isinstance(name, str) and name: + return name + + name = tool_choice.get("name") + if isinstance(name, str) and name: + return name + + return None diff --git a/tradingagents/llm_clients/codex_client.py b/tradingagents/llm_clients/codex_client.py new file mode 100644 index 00000000..dcd4c015 --- /dev/null +++ b/tradingagents/llm_clients/codex_client.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Optional + +from .base_client import BaseLLMClient +from .codex_chat_model import CodexChatModel +from .validators import validate_model + + +class CodexClient(BaseLLMClient): + """Client wrapper for the local Codex app-server provider.""" + + def __init__(self, model: str, base_url: Optional[str] = None, **kwargs): + super().__init__(model, base_url, **kwargs) + + def get_llm(self) -> Any: + self.warn_if_unknown_model() + llm = CodexChatModel( + model=self.model, + codex_binary=self.kwargs.get("codex_binary"), + codex_reasoning_effort=self.kwargs.get("codex_reasoning_effort"), + codex_summary=self.kwargs.get("codex_summary"), + codex_personality=self.kwargs.get("codex_personality"), + codex_workspace_dir=self.kwargs.get( + "codex_workspace_dir", + str(Path.home() / ".codex" / "tradingagents-workspace"), + ), + codex_request_timeout=self.kwargs.get("codex_request_timeout", 120.0), + codex_max_retries=self.kwargs.get("codex_max_retries", 2), + codex_cleanup_threads=self.kwargs.get("codex_cleanup_threads", True), + session_factory=self.kwargs.get("session_factory"), + preflight_runner=self.kwargs.get("preflight_runner"), + callbacks=self.kwargs.get("callbacks"), + ) + llm.preflight() + return llm + + def validate_model(self) -> bool: + return validate_model("codex", self.model) diff --git a/tradingagents/llm_clients/codex_message_codec.py b/tradingagents/llm_clients/codex_message_codec.py new file mode 100644 index 00000000..f6cf4033 --- /dev/null +++ b/tradingagents/llm_clients/codex_message_codec.py @@ -0,0 +1,236 @@ +import json +from typing import Any, Iterable, Mapping, Sequence + +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage + + +class CodexMessageCodecError(ValueError): + """Raised when TradingAgents inputs cannot be normalized for Codex.""" + + +def normalize_input_messages( + value: str | Sequence[BaseMessage | Mapping[str, Any]], +) -> list[BaseMessage]: + """Normalize TradingAgents model inputs into LangChain messages.""" + if isinstance(value, str): + return [HumanMessage(content=value)] + + normalized: list[BaseMessage] = [] + for item in value: + if isinstance(item, BaseMessage): + normalized.append(item) + continue + + if not isinstance(item, Mapping): + raise CodexMessageCodecError( + f"Unsupported message input type: {type(item).__name__}" + ) + + normalized.append(_message_from_dict(item)) + + return normalized + + +def format_messages_for_codex( + messages: Sequence[BaseMessage], + *, + tool_names: Iterable[str] = (), + tool_schemas: Sequence[Mapping[str, Any]] = (), + tool_choice: str | None = None, + tool_arguments_as_json_string: bool = False, + retry_message: str | None = None, +) -> str: + """Render a chat transcript into a single text prompt for Codex.""" + tool_list = list(tool_names) + lines = [ + "You are answering on behalf of TradingAgents.", + "The conversation transcript is provided below.", + "Treat tool outputs as authoritative execution results from the host application.", + ] + if tool_list: + lines.append( + "If external data is still needed, respond with tool calls using only these tools: " + + ", ".join(tool_list) + + "." + ) + else: + lines.append("No host tools are available for this turn.") + if tool_choice == "none": + lines.append("Do not request tool calls for this turn.") + elif tool_choice in {"any", "required"}: + lines.append("You must respond with one or more tool calls for this turn.") + elif tool_choice and tool_choice != "auto": + lines.append(f"You must call the tool named `{tool_choice}` for this turn.") + elif tool_choice == "auto": + lines.append("Use tool calls only if they are necessary to answer correctly.") + if tool_arguments_as_json_string: + lines.append( + "When returning tool calls, encode each tool argument object as a JSON string in `arguments_json`." + ) + schema_lines = _format_tool_schema_lines(tool_schemas) + if schema_lines: + lines.append("Tool argument requirements:") + lines.extend(schema_lines) + lines.append("Respond only with JSON that matches the requested output schema.") + if retry_message: + lines.append(retry_message) + + transcript: list[str] = [] + for message in messages: + transcript.append(_format_message(message)) + + return "\n\n".join(lines + ["Conversation transcript:", *transcript]) + + +def strip_json_fence(text: str) -> str: + stripped = text.strip() + if stripped.startswith("```"): + parts = stripped.split("```") + if len(parts) >= 3: + candidate = parts[1] + if candidate.lstrip().startswith("json"): + candidate = candidate.lstrip()[4:] + return candidate.strip() + return stripped + + +def _message_from_dict(message: Mapping[str, Any]) -> BaseMessage: + role = str(message.get("role", "")).lower() + content = _content_to_text(message.get("content", "")) + + if role == "system": + return SystemMessage(content=content) + if role == "user": + return HumanMessage(content=content) + if role == "tool": + tool_call_id = str(message.get("tool_call_id") or message.get("toolCallId") or "") + if not tool_call_id: + raise CodexMessageCodecError("Tool messages require tool_call_id.") + return ToolMessage(content=content, tool_call_id=tool_call_id) + if role == "assistant": + raw_tool_calls = message.get("tool_calls") or message.get("toolCalls") or [] + tool_calls = _normalize_tool_calls(raw_tool_calls) + return AIMessage(content=content, tool_calls=tool_calls) + + raise CodexMessageCodecError(f"Unsupported message role: {role!r}") + + +def _normalize_tool_calls(raw_tool_calls: Any) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + if not raw_tool_calls: + return normalized + + if not isinstance(raw_tool_calls, Sequence): + raise CodexMessageCodecError("assistant.tool_calls must be a sequence") + + for item in raw_tool_calls: + if not isinstance(item, Mapping): + raise CodexMessageCodecError("assistant.tool_calls items must be objects") + + if "function" in item: + function = item.get("function") + if not isinstance(function, Mapping): + raise CodexMessageCodecError("assistant.tool_calls.function must be an object") + raw_args = function.get("arguments", {}) + if isinstance(raw_args, str): + try: + args = json.loads(raw_args) + except json.JSONDecodeError as exc: + raise CodexMessageCodecError( + f"assistant tool arguments must be valid JSON: {raw_args!r}" + ) from exc + else: + args = raw_args + if not isinstance(args, Mapping): + raise CodexMessageCodecError("assistant tool arguments must decode to an object") + normalized.append( + { + "name": str(function.get("name", "")), + "args": dict(args), + "id": str(item.get("id") or ""), + } + ) + continue + + args = item.get("args", {}) + if not isinstance(args, Mapping): + raise CodexMessageCodecError("assistant tool args must be an object") + normalized.append( + { + "name": str(item.get("name", "")), + "args": dict(args), + "id": str(item.get("id") or ""), + } + ) + + return normalized + + +def _format_message(message: BaseMessage) -> str: + role = type(message).__name__.replace("Message", "") or "Message" + body = _content_to_text(message.content) + + if isinstance(message, AIMessage) and message.tool_calls: + tool_call_json = json.dumps( + [ + { + "id": tool_call.get("id"), + "name": tool_call.get("name"), + "args": tool_call.get("args", {}), + } + for tool_call in message.tool_calls + ], + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + return f"[{role}]\n{body}\nTool calls:\n{tool_call_json}".strip() + + if isinstance(message, ToolMessage): + return f"[Tool:{message.tool_call_id}]\n{body}".strip() + + return f"[{role}]\n{body}".strip() + + +def _content_to_text(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, Mapping): + text = item.get("text") + if isinstance(text, str): + parts.append(text) + else: + parts.append(json.dumps(dict(item), ensure_ascii=False)) + else: + parts.append(str(item)) + return "\n".join(part for part in parts if part) + if content is None: + return "" + return str(content) + + +def _format_tool_schema_lines(tool_schemas: Sequence[Mapping[str, Any]]) -> list[str]: + lines: list[str] = [] + for tool_schema in tool_schemas: + function = tool_schema.get("function") + if not isinstance(function, Mapping): + continue + name = function.get("name") + parameters = function.get("parameters") or {} + if not isinstance(name, str) or not isinstance(parameters, Mapping): + continue + required = parameters.get("required") or [] + properties = parameters.get("properties") or {} + summary = { + "required": required if isinstance(required, list) else [], + "properties": properties if isinstance(properties, Mapping) else {}, + } + lines.append( + f"- {name}: {json.dumps(summary, ensure_ascii=False, sort_keys=True)}" + ) + return lines diff --git a/tradingagents/llm_clients/codex_preflight.py b/tradingagents/llm_clients/codex_preflight.py new file mode 100644 index 00000000..04927db9 --- /dev/null +++ b/tradingagents/llm_clients/codex_preflight.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from .codex_app_server import ( + CodexAppServerAuthError, + CodexAppServerBinaryError, + CodexAppServerSession, +) +from .codex_binary import codex_binary_error_message, resolve_codex_binary + + +@dataclass(slots=True) +class CodexPreflightResult: + account: dict + models: list[str] + + +def run_codex_preflight( + *, + codex_binary: str | None, + model: str, + request_timeout: float, + workspace_dir: str, + cleanup_threads: bool, + session_factory: Callable[..., CodexAppServerSession] = CodexAppServerSession, +) -> CodexPreflightResult: + binary = resolve_codex_binary(codex_binary) + if not binary: + raise CodexAppServerBinaryError(codex_binary_error_message(codex_binary)) + + session = session_factory( + codex_binary=binary, + request_timeout=request_timeout, + workspace_dir=workspace_dir, + cleanup_threads=cleanup_threads, + ) + + try: + session.start() + account_payload = session.account_read() + account = account_payload.get("account") + if not account: + raise CodexAppServerAuthError( + "Codex authentication is not available for TradingAgents. " + "Run `codex login` or `codex login --device-auth`, then retry." + ) + + models_payload = session.model_list(include_hidden=True) + models = _collect_model_names(models_payload) + if model not in models: + preview = ", ".join(models[:8]) if models else "no models reported" + raise CodexAppServerBinaryError( + f"Codex model '{model}' is not available from `model/list`. Available models: {preview}" + ) + + return CodexPreflightResult(account=account, models=models) + finally: + session.close() + + +def _collect_model_names(payload: dict) -> list[str]: + names: list[str] = [] + for entry in payload.get("data", []) or []: + if not isinstance(entry, dict): + continue + for key in ("model", "id"): + value = entry.get(key) + if isinstance(value, str) and value not in names: + names.append(value) + return names diff --git a/tradingagents/llm_clients/codex_schema.py b/tradingagents/llm_clients/codex_schema.py new file mode 100644 index 00000000..bbe3d922 --- /dev/null +++ b/tradingagents/llm_clients/codex_schema.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from typing import Any, Callable, Sequence + +from langchain_core.tools import BaseTool +from langchain_core.utils.function_calling import convert_to_openai_tool + + +def normalize_tools_for_codex( + tools: Sequence[dict[str, Any] | type | Callable | BaseTool], +) -> list[dict[str, Any]]: + """Normalize LangChain tool definitions into OpenAI-style schemas.""" + normalized: list[dict[str, Any]] = [] + for tool in tools: + normalized.append(convert_to_openai_tool(tool, strict=True)) + return normalized + + +def build_plain_response_schema() -> dict[str, Any]: + return { + "type": "object", + "properties": { + "answer": {"type": "string"}, + }, + "required": ["answer"], + "additionalProperties": False, + } + + +def build_tool_response_schema( + tool_schemas: Sequence[dict[str, Any]], + *, + allow_final: bool = True, +) -> dict[str, Any]: + tool_items_schema = _tool_items_schema(tool_schemas) + if not allow_final: + return { + "type": "object", + "properties": { + "mode": {"const": "tool_calls", "type": "string"}, + "content": {"type": "string"}, + "tool_calls": { + "type": "array", + "minItems": 1, + "items": tool_items_schema, + }, + }, + "required": ["mode", "content", "tool_calls"], + "additionalProperties": False, + } + + return { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["final", "tool_calls"], + }, + "content": {"type": "string"}, + "tool_calls": { + "type": "array", + "items": tool_items_schema, + }, + }, + "required": ["mode", "content", "tool_calls"], + "additionalProperties": False, + } + + +def _tool_items_schema(tool_schemas: Sequence[dict[str, Any]]) -> dict[str, Any]: + if len(tool_schemas) == 1: + return _tool_call_variant(tool_schemas[0]) + + tool_names = [ + tool_schema.get("function", {}).get("name") + for tool_schema in tool_schemas + if tool_schema.get("function", {}).get("name") + ] + argument_properties: dict[str, Any] = {} + for tool_schema in tool_schemas: + parameters = tool_schema.get("function", {}).get("parameters") or {} + properties = parameters.get("properties") or {} + if not isinstance(properties, dict): + continue + for name, schema in properties.items(): + if name not in argument_properties: + argument_properties[name] = schema + return { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": tool_names, + }, + "arguments_json": { + "type": "string", + }, + }, + "required": ["name", "arguments_json"], + "additionalProperties": False, + } + + +def _tool_call_variant(tool_schema: dict[str, Any]) -> dict[str, Any]: + function = tool_schema.get("function", {}) + parameters = function.get("parameters") or {"type": "object", "properties": {}} + return { + "type": "object", + "properties": { + "name": { + "const": function["name"], + "type": "string", + }, + "arguments": parameters, + }, + "required": ["name", "arguments"], + "additionalProperties": False, + } diff --git a/tradingagents/llm_clients/factory.py b/tradingagents/llm_clients/factory.py index 93c2a7d3..acdea373 100644 --- a/tradingagents/llm_clients/factory.py +++ b/tradingagents/llm_clients/factory.py @@ -1,9 +1,6 @@ from typing import Optional from .base_client import BaseLLMClient -from .openai_client import OpenAIClient -from .anthropic_client import AnthropicClient -from .google_client import GoogleClient def create_llm_client( @@ -15,7 +12,7 @@ def create_llm_client( """Create an LLM client for the specified provider. Args: - provider: LLM provider (openai, anthropic, google, xai, ollama, openrouter) + provider: LLM provider (openai, anthropic, google, xai, ollama, openrouter, codex) model: Model name/identifier base_url: Optional base URL for API endpoint **kwargs: Additional provider-specific arguments @@ -35,15 +32,28 @@ def create_llm_client( provider_lower = provider.lower() if provider_lower in ("openai", "ollama", "openrouter"): + from .openai_client import OpenAIClient + return OpenAIClient(model, base_url, provider=provider_lower, **kwargs) if provider_lower == "xai": + from .openai_client import OpenAIClient + return OpenAIClient(model, base_url, provider="xai", **kwargs) if provider_lower == "anthropic": + from .anthropic_client import AnthropicClient + return AnthropicClient(model, base_url, **kwargs) if provider_lower == "google": + from .google_client import GoogleClient + return GoogleClient(model, base_url, **kwargs) + if provider_lower == "codex": + from .codex_client import CodexClient + + return CodexClient(model, base_url, **kwargs) + raise ValueError(f"Unsupported LLM provider: {provider}") diff --git a/tradingagents/llm_clients/model_catalog.py b/tradingagents/llm_clients/model_catalog.py index fd91c66d..340115a0 100644 --- a/tradingagents/llm_clients/model_catalog.py +++ b/tradingagents/llm_clients/model_catalog.py @@ -23,6 +23,20 @@ MODEL_OPTIONS: ProviderModeOptions = { ("GPT-5.4 Pro - Most capable, expensive ($30/$180 per 1M tokens)", "gpt-5.4-pro"), ], }, + "codex": { + "quick": [ + ("GPT-5.4 Mini - Local Codex session, fast tool use", "gpt-5.4-mini"), + ("GPT-5.4 Nano - Lowest-cost Codex model", "gpt-5.4-nano"), + ("GPT-5.4 - Frontier Codex model", "gpt-5.4"), + ("GPT-4.1 - Strong non-reasoning fallback", "gpt-4.1"), + ], + "deep": [ + ("GPT-5.4 - Frontier Codex model", "gpt-5.4"), + ("GPT-5.2 - Strong Codex reasoning", "gpt-5.2"), + ("GPT-5.4 Mini - Faster Codex alternative", "gpt-5.4-mini"), + ("GPT-5.4 Pro - Highest capability Codex model", "gpt-5.4-pro"), + ], + }, "anthropic": { "quick": [ ("Claude Sonnet 4.6 - Best speed and intelligence balance", "claude-sonnet-4-6"), From 8c24dcce61410fb82c53aac43ace348f19e08ea0 Mon Sep 17 00:00:00 2001 From: nornen0202 Date: Mon, 6 Apr 2026 08:28:48 +0900 Subject: [PATCH 02/21] Add scheduled Codex report automation --- .github/workflows/daily-codex-analysis.yml | 123 ++++++ Docs/daily_codex_reports_setup_ko.md | 316 ++++++++++++++ cli/main.py | 96 +--- config/scheduled_analysis.example.toml | 36 ++ config/scheduled_analysis.toml | 34 ++ pyproject.toml | 1 + tests/test_cli_unicode_logging.py | 93 ++++ tests/test_codex_provider.py | 44 ++ tests/test_scheduled_analysis.py | 211 +++++++++ tradingagents/llm_clients/codex_binary.py | 68 ++- tradingagents/reporting.py | 123 ++++++ tradingagents/scheduled/__init__.py | 11 + tradingagents/scheduled/__main__.py | 5 + tradingagents/scheduled/config.py | 220 ++++++++++ tradingagents/scheduled/runner.py | 346 +++++++++++++++ tradingagents/scheduled/site.py | 483 +++++++++++++++++++++ 16 files changed, 2108 insertions(+), 102 deletions(-) create mode 100644 .github/workflows/daily-codex-analysis.yml create mode 100644 Docs/daily_codex_reports_setup_ko.md create mode 100644 config/scheduled_analysis.example.toml create mode 100644 config/scheduled_analysis.toml create mode 100644 tests/test_cli_unicode_logging.py create mode 100644 tests/test_scheduled_analysis.py create mode 100644 tradingagents/reporting.py create mode 100644 tradingagents/scheduled/__init__.py create mode 100644 tradingagents/scheduled/__main__.py create mode 100644 tradingagents/scheduled/config.py create mode 100644 tradingagents/scheduled/runner.py create mode 100644 tradingagents/scheduled/site.py diff --git a/.github/workflows/daily-codex-analysis.yml b/.github/workflows/daily-codex-analysis.yml new file mode 100644 index 00000000..0fbf6b23 --- /dev/null +++ b/.github/workflows/daily-codex-analysis.yml @@ -0,0 +1,123 @@ +name: Daily Codex Analysis + +on: + schedule: + # 00:13 UTC = 09:13 Asia/Seoul + - cron: "13 0 * * *" + workflow_dispatch: + inputs: + tickers: + description: "Optional comma-separated tickers override" + required: false + type: string + trade_date: + description: "Optional YYYY-MM-DD trade date override" + required: false + type: string + site_only: + description: "Only rebuild GitHub Pages from archived runs" + required: false + type: boolean + default: false + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: daily-codex-analysis + cancel-in-progress: false + +jobs: + analyze: + runs-on: [self-hosted, Windows] + timeout-minutes: 240 + env: + PYTHONUTF8: "1" + PIP_DISABLE_PIP_VERSION_CHECK: "1" + TRADINGAGENTS_SITE_DIR: ${{ github.workspace }}\site + TRADINGAGENTS_ARCHIVE_DIR: ${{ vars.TRADINGAGENTS_ARCHIVE_DIR }} + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Configure GitHub Pages + uses: actions/configure-pages@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install TradingAgents + shell: pwsh + run: | + python -m pip install --upgrade pip + python -m pip install -e . + + - name: Verify Codex login and model availability + shell: pwsh + run: | + $workspaceDir = Join-Path $env:GITHUB_WORKSPACE ".codex-preflight" + @" +from tradingagents.llm_clients.codex_preflight import run_codex_preflight +result = run_codex_preflight( + codex_binary=None, + model="gpt-5.4", + request_timeout=30.0, + workspace_dir=r"$workspaceDir", + cleanup_threads=True, +) +print("Codex account:", result.account) +print("First available models:", ", ".join(result.models[:8])) +"@ | python - + + - name: Run scheduled analysis and build site + shell: pwsh + run: | + $configPath = "config/scheduled_analysis.toml" + if (-not (Test-Path $configPath)) { + throw "Missing config/scheduled_analysis.toml. Copy config/scheduled_analysis.example.toml, set your real tickers, and commit the file before enabling the schedule." + } + + $args = @("-m", "tradingagents.scheduled", "--config", $configPath, "--site-dir", $env:TRADINGAGENTS_SITE_DIR, "--label", "github-actions") + + if (-not [string]::IsNullOrWhiteSpace($env:TRADINGAGENTS_ARCHIVE_DIR)) { + $args += @("--archive-dir", $env:TRADINGAGENTS_ARCHIVE_DIR) + } else { + Write-Warning "TRADINGAGENTS_ARCHIVE_DIR is not set. Run history will live under the repository checkout unless the config overrides it." + } + + $manualTickers = "${{ github.event.inputs.tickers }}" + if (-not [string]::IsNullOrWhiteSpace($manualTickers)) { + $args += @("--tickers", $manualTickers) + } + + $manualTradeDate = "${{ github.event.inputs.trade_date }}" + if (-not [string]::IsNullOrWhiteSpace($manualTradeDate)) { + $args += @("--trade-date", $manualTradeDate) + } + + $siteOnly = "${{ github.event.inputs.site_only }}" + if ($siteOnly -eq "true") { + $args += "--site-only" + } + + python @args + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + needs: analyze + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/Docs/daily_codex_reports_setup_ko.md b/Docs/daily_codex_reports_setup_ko.md new file mode 100644 index 00000000..a5581ef1 --- /dev/null +++ b/Docs/daily_codex_reports_setup_ko.md @@ -0,0 +1,316 @@ +# TradingAgents 일일 자동 리포트 설정 가이드 + +이 문서는 현재 저장소에 추가된 `self-hosted runner + Codex + GitHub Actions + GitHub Pages` 자동화 구성을 실제로 운영하는 방법을 처음부터 끝까지 정리한 문서입니다. + +적용 대상: +- 저장소: `nornen0202/TradingAgents` +- 기본 티커: `GOOGL`, `NVDA` +- LLM 제공자: `codex` +- 모델: `gpt-5.4` +- analyst 구성: `market`, `social`, `news`, `fundamentals` +- 결과 언어: `Korean` + +관련 파일: +- 설정 파일: [config/scheduled_analysis.toml](/C:/Projects/TradingAgents/config/scheduled_analysis.toml) +- 예시 설정: [config/scheduled_analysis.example.toml](/C:/Projects/TradingAgents/config/scheduled_analysis.example.toml) +- 실행 엔트리포인트: [tradingagents/scheduled/runner.py](/C:/Projects/TradingAgents/tradingagents/scheduled/runner.py) +- 정적 사이트 생성기: [tradingagents/scheduled/site.py](/C:/Projects/TradingAgents/tradingagents/scheduled/site.py) +- GitHub Actions 워크플로: [.github/workflows/daily-codex-analysis.yml](/C:/Projects/TradingAgents/.github/workflows/daily-codex-analysis.yml) + +## 1. 지금 이미 준비된 것 + +이 저장소에는 다음이 이미 구현되어 있습니다. + +- 비대화식 스케줄 실행기 + - 여러 티커를 순차 실행합니다. + - `latest_available` 기준으로 최근 거래일을 자동 해석합니다. + - 실패한 티커가 있어도 다른 티커를 계속 실행할 수 있습니다. +- 결과 아카이브 + - 각 실행마다 run manifest, final state, markdown report, graph log를 저장합니다. +- 웹 리포트 생성 + - GitHub Pages에 바로 올릴 수 있는 정적 HTML/CSS/JSON을 생성합니다. +- GitHub Actions 워크플로 + - 매일 `09:13 KST` 기준으로 실행되도록 cron이 잡혀 있습니다. + - 수동 실행도 가능합니다. + +## 2. 당신이 반드시 해야 하는 작업 + +이 부분은 제가 대신할 수 없습니다. + +### 2-1. GitHub 저장소에 변경 반영 + +제가 로컬 저장소에는 구현을 끝냈지만, 원격 GitHub 저장소에 반영하려면 당신이 아래 둘 중 하나를 해야 합니다. + +1. 직접 commit / push +2. 다음 턴에서 저에게 commit 메시지와 push/PR 작업까지 맡기기 + +### 2-2. self-hosted runner 준비 + +Codex 로그인 상태를 유지해야 하므로 GitHub-hosted runner가 아니라 self-hosted runner가 필요합니다. + +권장: +- 항상 켜져 있거나, 최소한 스케줄 시간 전에 켜져 있는 Windows 머신 1대 +- 이 저장소가 체크아웃된 경로 유지 +- Python 3.13 사용 +- `codex` 실행 가능 + +### 2-3. Codex 로그인 + +runner 머신에서 한 번 로그인해야 합니다. + +PowerShell: + +```powershell +where.exe codex +codex login +``` + +브라우저 기반 로그인이 어려우면: + +```powershell +codex login --device-auth +``` + +확인: + +```powershell +codex --version +``` + +참고: +- 이 환경에서는 `codex --version`이 WindowsApps alias 때문에 바로 실패했지만, TradingAgents preflight는 실제 Codex 바이너리를 자동 탐지해서 정상 통과했습니다. +- 즉 `codex` alias가 애매해도 TradingAgents 자체는 동작할 수 있습니다. +- 그래도 runner 머신에서는 가능하면 `where.exe codex`와 실제 `codex login`이 확실히 동작하도록 맞추는 편이 안전합니다. + +### 2-4. GitHub Pages 설정 + +GitHub 저장소 설정에서 아래 작업이 필요합니다. + +1. 저장소 `Settings`로 이동 +2. 왼쪽 `Pages` 선택 +3. `Build and deployment`의 `Source`를 `GitHub Actions`로 선택 + +이 단계는 GitHub UI 권한이 필요해서 당신이 해야 합니다. + +### 2-5. self-hosted runner 등록 + +저장소 `Settings > Actions > Runners`에서 runner를 등록해야 합니다. + +일반 순서: +1. 저장소 `Settings` +2. `Actions` +3. `Runners` +4. `New self-hosted runner` +5. Windows 선택 +6. GitHub가 보여주는 등록 스크립트를 runner 머신에서 실행 + +runner label은 워크플로가 현재 아래를 요구합니다. + +```yaml +runs-on: [self-hosted, Windows] +``` + +즉 `self-hosted`, `Windows` 라벨이 붙은 러너면 됩니다. + +### 2-6. 선택이지만 강력 권장: 아카이브 경로 영속화 + +지금 기본 설정은 저장소 내부의 `./.runtime/tradingagents-archive`를 쓰게 되어 있습니다. +더 안정적인 운영을 원하면 GitHub repository variable에 아래 값을 넣는 것을 권장합니다. + +- 이름: `TRADINGAGENTS_ARCHIVE_DIR` +- 예시 값: `D:\TradingAgentsData\archive` + +이렇게 하면 저장소를 새로 checkout해도 이력 데이터가 유지됩니다. + +저장소 변수 위치: +- `Settings > Secrets and variables > Actions > Variables` + +## 3. 빠른 실행 순서 + +### 3-1. 로컬 확인 + +```powershell +Set-Location C:\Projects\TradingAgents +.\.venv-codex\Scripts\Activate.ps1 +python -m pip install -e . +python -m tradingagents.scheduled --config config/scheduled_analysis.toml --label manual-local +``` + +실행 후 확인 경로: +- 아카이브: [config/scheduled_analysis.toml](/C:/Projects/TradingAgents/config/scheduled_analysis.toml)의 `archive_dir` +- 사이트: [site](/C:/Projects/TradingAgents/site) + +### 3-2. GitHub Actions 수동 실행 + +1. GitHub 저장소의 `Actions` 탭 이동 +2. `Daily Codex Analysis` 선택 +3. `Run workflow` 클릭 +4. 필요 시: + - `tickers`: 예: `GOOGL,NVDA,MSFT` + - `trade_date`: 예: `2026-04-04` + - `site_only`: `true` 또는 `false` +5. 실행 + +입력 의미: +- `tickers`: 설정 파일의 티커를 일회성으로 덮어씁니다. +- `trade_date`: `latest_available` 대신 특정 날짜를 강제합니다. +- `site_only`: 새 분석 없이 기존 아카이브만 다시 Pages로 재배포합니다. + +## 4. 매일 자동 실행 방식 + +현재 워크플로 cron: + +```yaml +- cron: "13 0 * * *" +``` + +이 값은 UTC 기준이므로 한국 시간으로는 매일 `09:13`입니다. + +왜 `09:00`이 아니라 `09:13`인가: +- GitHub 문서상 scheduled workflow는 부하가 높은 시각, 특히 정각 부근에서 지연되거나 드롭될 수 있습니다. +- 그래서 정각보다 몇 분 비켜서 잡는 편이 안전합니다. + +## 5. 산출물 구조 + +예시 run 디렉터리: + +```text +archive/ + latest-run.json + runs/ + 2026/ + 20260405T080047_real-smoke/ + run.json + tickers/ + GOOGL/ + NVDA/ + engine-results/ +``` + +티커별 주요 파일: +- `analysis.json`: 실행 요약 +- `final_state.json`: TradingAgents 최종 상태 +- `report/complete_report.md`: 통합 마크다운 리포트 +- `full_states_log_.json`: graph 상태 로그 +- 실패 시 `error.json` + +사이트 구조: + +```text +site/ + index.html + feed.json + runs//index.html + runs//.html + downloads///* +``` + +## 6. 설정 변경 방법 + +기본 티커를 바꾸려면 [config/scheduled_analysis.toml](/C:/Projects/TradingAgents/config/scheduled_analysis.toml)에서 이 부분만 수정하면 됩니다. + +```toml +[run] +tickers = ["GOOGL", "NVDA"] +``` + +연구 깊이를 올리려면: + +```toml +max_debate_rounds = 3 +max_risk_discuss_rounds = 3 +``` + +주의: +- 값이 커질수록 실행 시간과 Codex 사용량이 늘어납니다. + +## 7. 가장 추천하는 운영 형태 + +### 최소 운영 + +- runner 머신 1대 +- `codex login` 1회 +- GitHub Pages 공개 배포 +- `GOOGL`, `NVDA` 일일 실행 + +### 안정 운영 + +- runner 머신 1대 고정 +- `TRADINGAGENTS_ARCHIVE_DIR`를 저장소 밖 영속 경로로 지정 +- Windows 부팅 시 runner 자동 시작 +- 주 1회 정도 Actions 실행 기록 점검 + +## 8. 트러블슈팅 + +### `Missing config/scheduled_analysis.toml` + +원인: +- 실제 설정 파일이 아직 저장소에 없음 + +해결: +- 현재는 이미 [config/scheduled_analysis.toml](/C:/Projects/TradingAgents/config/scheduled_analysis.toml)을 추가해 두었습니다. + +### Codex 인증 오류 + +원인: +- runner 머신에서 로그인 안 됨 + +해결: + +```powershell +codex login +``` + +또는: + +```powershell +codex login --device-auth +``` + +### Pages가 비어 있음 + +확인 순서: +1. `Actions` 탭에서 `Daily Codex Analysis` 실행 성공 여부 확인 +2. `Settings > Pages`에서 Source가 `GitHub Actions`인지 확인 +3. workflow의 `deploy` job 성공 여부 확인 + +### 스케줄이 안 뜸 + +확인 순서: +1. workflow 파일이 default branch에 있는지 확인 +2. 저장소에 최근 60일 내 활동이 있었는지 확인 +3. cron이 UTC 기준임을 확인 + +## 9. 제가 이미 직접 검증한 것 + +이 저장소 로컬 환경에서 아래를 확인했습니다. + +- `Codex preflight` 성공 +- Codex 계정 읽기 성공 +- 모델 목록에서 `gpt-5.4` 확인 +- mock 기반 자동화 테스트 통과 +- 실제 `SPY` 1티커 end-to-end 스모크 런 성공 + - 시작: `2026-04-05 08:00:47 +09:00` + - 종료: `2026-04-05 08:06:24 +09:00` + - 거래일 해석: `2026-04-02` + - 최종 decision: `SELL` + +## 10. 이번 요청 기준 정리 + +현재 상태에서 당신이 해야 하는 최소 작업은 아래입니다. + +1. 변경사항을 원격 GitHub 저장소에 반영 +2. self-hosted runner 등록 +3. runner 머신에서 `codex login` +4. GitHub Pages Source를 `GitHub Actions`로 설정 +5. 필요하면 `TRADINGAGENTS_ARCHIVE_DIR` repository variable 추가 + +그 외의 저장소 코드, 설정 파일, 워크플로, 문서는 지금 이 저장소에 이미 준비되어 있습니다. + +## 참고 링크 + +- GitHub Actions `schedule` 이벤트: https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows +- GitHub Pages custom workflow: https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages +- GitHub Pages publishing source: https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site +- OpenAI Codex cloud/docs: https://developers.openai.com/codex/cloud +- OpenAI Codex app announcement: https://openai.com/index/introducing-the-codex-app/ diff --git a/cli/main.py b/cli/main.py index d921bc66..054202af 100644 --- a/cli/main.py +++ b/cli/main.py @@ -25,6 +25,7 @@ from rich.rule import Rule from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.default_config import DEFAULT_CONFIG +from tradingagents.reporting import save_report_bundle from cli.models import AnalystType from cli.utils import * from cli.announcements import fetch_announcements, display_announcements @@ -462,7 +463,7 @@ def update_display(layout, spinner_text=None, stats_handler=None, start_time=Non def get_user_selections(): """Get all user selections before starting the analysis display.""" # Display ASCII art welcome message - with open(Path(__file__).parent / "static" / "welcome.txt", "r") as f: + with open(Path(__file__).parent / "static" / "welcome.txt", "r", encoding="utf-8") as f: welcome_ascii = f.read() # Create welcome box content @@ -647,92 +648,7 @@ def get_analysis_date(): def save_report_to_disk(final_state, ticker: str, save_path: Path): """Save complete analysis report to disk with organized subfolders.""" - save_path.mkdir(parents=True, exist_ok=True) - sections = [] - - # 1. Analysts - analysts_dir = save_path / "1_analysts" - analyst_parts = [] - if final_state.get("market_report"): - analysts_dir.mkdir(exist_ok=True) - (analysts_dir / "market.md").write_text(final_state["market_report"]) - analyst_parts.append(("Market Analyst", final_state["market_report"])) - if final_state.get("sentiment_report"): - analysts_dir.mkdir(exist_ok=True) - (analysts_dir / "sentiment.md").write_text(final_state["sentiment_report"]) - analyst_parts.append(("Social Analyst", final_state["sentiment_report"])) - if final_state.get("news_report"): - analysts_dir.mkdir(exist_ok=True) - (analysts_dir / "news.md").write_text(final_state["news_report"]) - analyst_parts.append(("News Analyst", final_state["news_report"])) - if final_state.get("fundamentals_report"): - analysts_dir.mkdir(exist_ok=True) - (analysts_dir / "fundamentals.md").write_text(final_state["fundamentals_report"]) - analyst_parts.append(("Fundamentals Analyst", final_state["fundamentals_report"])) - if analyst_parts: - content = "\n\n".join(f"### {name}\n{text}" for name, text in analyst_parts) - sections.append(f"## I. Analyst Team Reports\n\n{content}") - - # 2. Research - if final_state.get("investment_debate_state"): - research_dir = save_path / "2_research" - debate = final_state["investment_debate_state"] - research_parts = [] - if debate.get("bull_history"): - research_dir.mkdir(exist_ok=True) - (research_dir / "bull.md").write_text(debate["bull_history"]) - research_parts.append(("Bull Researcher", debate["bull_history"])) - if debate.get("bear_history"): - research_dir.mkdir(exist_ok=True) - (research_dir / "bear.md").write_text(debate["bear_history"]) - research_parts.append(("Bear Researcher", debate["bear_history"])) - if debate.get("judge_decision"): - research_dir.mkdir(exist_ok=True) - (research_dir / "manager.md").write_text(debate["judge_decision"]) - research_parts.append(("Research Manager", debate["judge_decision"])) - if research_parts: - content = "\n\n".join(f"### {name}\n{text}" for name, text in research_parts) - sections.append(f"## II. Research Team Decision\n\n{content}") - - # 3. Trading - if final_state.get("trader_investment_plan"): - trading_dir = save_path / "3_trading" - trading_dir.mkdir(exist_ok=True) - (trading_dir / "trader.md").write_text(final_state["trader_investment_plan"]) - sections.append(f"## III. Trading Team Plan\n\n### Trader\n{final_state['trader_investment_plan']}") - - # 4. Risk Management - if final_state.get("risk_debate_state"): - risk_dir = save_path / "4_risk" - risk = final_state["risk_debate_state"] - risk_parts = [] - if risk.get("aggressive_history"): - risk_dir.mkdir(exist_ok=True) - (risk_dir / "aggressive.md").write_text(risk["aggressive_history"]) - risk_parts.append(("Aggressive Analyst", risk["aggressive_history"])) - if risk.get("conservative_history"): - risk_dir.mkdir(exist_ok=True) - (risk_dir / "conservative.md").write_text(risk["conservative_history"]) - risk_parts.append(("Conservative Analyst", risk["conservative_history"])) - if risk.get("neutral_history"): - risk_dir.mkdir(exist_ok=True) - (risk_dir / "neutral.md").write_text(risk["neutral_history"]) - risk_parts.append(("Neutral Analyst", risk["neutral_history"])) - if risk_parts: - content = "\n\n".join(f"### {name}\n{text}" for name, text in risk_parts) - sections.append(f"## IV. Risk Management Team Decision\n\n{content}") - - # 5. Portfolio Manager - if risk.get("judge_decision"): - portfolio_dir = save_path / "5_portfolio" - portfolio_dir.mkdir(exist_ok=True) - (portfolio_dir / "decision.md").write_text(risk["judge_decision"]) - sections.append(f"## V. Portfolio Manager Decision\n\n### Portfolio Manager\n{risk['judge_decision']}") - - # Write consolidated report - header = f"# Trading Analysis Report: {ticker}\n\nGenerated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" - (save_path / "complete_report.md").write_text(header + "\n\n".join(sections)) - return save_path / "complete_report.md" + return save_report_bundle(final_state, ticker, save_path) def display_complete_report(final_state): @@ -990,7 +906,7 @@ def run_analysis(): func(*args, **kwargs) timestamp, message_type, content = obj.messages[-1] content = content.replace("\n", " ") # Replace newlines with spaces - with open(log_file, "a") as f: + with open(log_file, "a", encoding="utf-8") as f: f.write(f"{timestamp} [{message_type}] {content}\n") return wrapper @@ -1001,7 +917,7 @@ def run_analysis(): func(*args, **kwargs) timestamp, tool_name, args = obj.tool_calls[-1] args_str = ", ".join(f"{k}={v}" for k, v in args.items()) - with open(log_file, "a") as f: + with open(log_file, "a", encoding="utf-8") as f: f.write(f"{timestamp} [Tool Call] {tool_name}({args_str})\n") return wrapper @@ -1015,7 +931,7 @@ def run_analysis(): if content: file_name = f"{section_name}.md" text = "\n".join(str(item) for item in content) if isinstance(content, list) else content - with open(report_dir / file_name, "w") as f: + with open(report_dir / file_name, "w", encoding="utf-8") as f: f.write(text) return wrapper diff --git a/config/scheduled_analysis.example.toml b/config/scheduled_analysis.example.toml new file mode 100644 index 00000000..6b0672fa --- /dev/null +++ b/config/scheduled_analysis.example.toml @@ -0,0 +1,36 @@ +# Copy this file to `config/scheduled_analysis.toml` and adjust the values for your runner. + +[run] +tickers = ["NVDA", "MSFT", "TSLA"] +analysts = ["market", "social", "news", "fundamentals"] +output_language = "Korean" +trade_date_mode = "latest_available" +timezone = "Asia/Seoul" +max_debate_rounds = 1 +max_risk_discuss_rounds = 1 +latest_market_data_lookback_days = 14 +continue_on_ticker_error = true + +[llm] +provider = "codex" +# TradingAgents' current Codex provider path uses the frontier model id `gpt-5.4` +# for Codex 5.4 sessions. +quick_model = "gpt-5.4" +deep_model = "gpt-5.4" +codex_reasoning_effort = "medium" +codex_summary = "none" +codex_personality = "none" +codex_request_timeout = 180.0 +codex_max_retries = 2 +codex_cleanup_threads = true + +[storage] +# For stable run history on a self-hosted runner, prefer a persistent path outside the repo checkout. +# Example on Windows: "C:/TradingAgentsData/archive" +archive_dir = "./.runtime/tradingagents-archive" +site_dir = "./site" + +[site] +title = "TradingAgents Daily Reports" +subtitle = "Self-hosted Codex automation for scheduled multi-ticker analysis" +max_runs_on_homepage = 30 diff --git a/config/scheduled_analysis.toml b/config/scheduled_analysis.toml new file mode 100644 index 00000000..fc0e5a1d --- /dev/null +++ b/config/scheduled_analysis.toml @@ -0,0 +1,34 @@ +[run] +tickers = ["GOOGL", "NVDA"] +analysts = ["market", "social", "news", "fundamentals"] +output_language = "Korean" +trade_date_mode = "latest_available" +timezone = "Asia/Seoul" +max_debate_rounds = 1 +max_risk_discuss_rounds = 1 +latest_market_data_lookback_days = 14 +continue_on_ticker_error = true + +[llm] +provider = "codex" +# TradingAgents' current Codex provider path uses the frontier model id `gpt-5.4` +# for Codex 5.4 sessions. +quick_model = "gpt-5.4" +deep_model = "gpt-5.4" +codex_reasoning_effort = "medium" +codex_summary = "none" +codex_personality = "none" +codex_request_timeout = 180.0 +codex_max_retries = 2 +codex_cleanup_threads = true + +[storage] +# For a self-hosted runner, it is better to override this to a persistent absolute path +# via the TRADINGAGENTS_ARCHIVE_DIR repository variable or by editing this file. +archive_dir = "./.runtime/tradingagents-archive" +site_dir = "./site" + +[site] +title = "TradingAgents Daily Reports" +subtitle = "Self-hosted Codex automation for scheduled multi-ticker analysis" +max_runs_on_homepage = 30 diff --git a/pyproject.toml b/pyproject.toml index 98385e32..5ed48f50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ [project.scripts] tradingagents = "cli.main:app" +tradingagents-scheduled = "tradingagents.scheduled.runner:main" [tool.setuptools.packages.find] include = ["tradingagents*", "cli*"] diff --git a/tests/test_cli_unicode_logging.py b/tests/test_cli_unicode_logging.py new file mode 100644 index 00000000..29fbdadb --- /dev/null +++ b/tests/test_cli_unicode_logging.py @@ -0,0 +1,93 @@ +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from cli.main import run_analysis +from cli.models import AnalystType + + +class _DummyLive: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + +class _FakePropagator: + def create_initial_state(self, ticker, analysis_date): + return {"ticker": ticker, "analysis_date": analysis_date} + + def get_graph_args(self, callbacks=None): + return {} + + +class _FakeGraphRunner: + def stream(self, init_state, **kwargs): + yield { + "messages": [SimpleNamespace(id="msg-1", tool_calls=[])], + "market_report": "시장 보고서 — 한글 검증", + "final_trade_decision": "HOLD — 포지션 유지", + } + + +class _FakeTradingAgentsGraph: + def __init__(self, *args, **kwargs): + self.propagator = _FakePropagator() + self.graph = _FakeGraphRunner() + + def process_signal(self, signal): + return signal + + +class CliUnicodeLoggingTests(unittest.TestCase): + def test_run_analysis_writes_logs_and_reports_as_utf8(self): + with tempfile.TemporaryDirectory() as tmpdir: + results_dir = Path(tmpdir) / "results" + selections = { + "ticker": "GOOGL", + "analysis_date": "2026-04-05", + "output_language": "Korean", + "analysts": [AnalystType.MARKET], + "research_depth": 1, + "llm_provider": "codex", + "backend_url": None, + "shallow_thinker": "gpt-5.4", + "deep_thinker": "gpt-5.4", + "codex_reasoning_effort": "medium", + } + + with ( + patch("cli.main.get_user_selections", return_value=selections), + patch("cli.main.DEFAULT_CONFIG", {"results_dir": str(results_dir)}), + patch("cli.main.TradingAgentsGraph", _FakeTradingAgentsGraph), + patch("cli.main.StatsCallbackHandler", return_value=SimpleNamespace()), + patch("cli.main.Live", _DummyLive), + patch("cli.main.create_layout", return_value=object()), + patch("cli.main.update_display"), + patch("cli.main.update_analyst_statuses"), + patch( + "cli.main.classify_message_type", + return_value=("Agent", "유니코드 메시지 — 로그 저장 검증"), + ), + patch("cli.main.typer.prompt", side_effect=["N", "N"]), + patch("cli.main.console.print"), + ): + run_analysis() + + log_file = results_dir / "GOOGL" / "2026-04-05" / "message_tool.log" + report_file = results_dir / "GOOGL" / "2026-04-05" / "reports" / "market_report.md" + + self.assertTrue(log_file.exists()) + self.assertTrue(report_file.exists()) + self.assertIn("유니코드 메시지 — 로그 저장 검증", log_file.read_text(encoding="utf-8")) + self.assertIn("시장 보고서 — 한글 검증", report_file.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_codex_provider.py b/tests/test_codex_provider.py index dd1c8a04..7fbcaf28 100644 --- a/tests/test_codex_provider.py +++ b/tests/test_codex_provider.py @@ -119,16 +119,60 @@ class CodexProviderTests(unittest.TestCase): self.assertEqual(resolved, str(candidate)) + def test_resolve_codex_binary_skips_unusable_path_alias_on_windows(self): + fake_home = Path("C:/Users/tester") + alias_path = "C:/Program Files/WindowsApps/OpenAI.Codex/app/resources/codex.exe" + candidate = fake_home / ".vscode/extensions/openai.chatgpt-1.0.0/bin/windows-x86_64/codex.exe" + + with ( + patch("tradingagents.llm_clients.codex_binary.os.name", "nt"), + patch("tradingagents.llm_clients.codex_binary.Path.home", return_value=fake_home), + patch("tradingagents.llm_clients.codex_binary.shutil.which", return_value=alias_path), + patch( + "tradingagents.llm_clients.codex_binary.Path.glob", + return_value=[candidate], + ), + patch("pathlib.Path.is_file", return_value=True), + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.stat") as mocked_stat, + patch( + "tradingagents.llm_clients.codex_binary._is_usable_codex_binary", + side_effect=lambda path: path != alias_path, + ), + ): + mocked_stat.return_value.st_mtime = 1 + resolved = resolve_codex_binary(None) + + self.assertEqual(resolved, str(candidate)) + def test_resolve_codex_binary_uses_env_override(self): with ( + patch("tradingagents.llm_clients.codex_binary.os.name", "nt"), patch("tradingagents.llm_clients.codex_binary.shutil.which", return_value=None), patch.dict("os.environ", {"CODEX_BINARY": "C:/custom/codex.exe"}, clear=False), patch("pathlib.Path.is_file", return_value=True), + patch( + "tradingagents.llm_clients.codex_binary._is_usable_codex_binary", + return_value=True, + ), ): resolved = resolve_codex_binary(None) self.assertEqual(Path(resolved), Path("C:/custom/codex.exe")) + def test_resolve_codex_binary_checks_explicit_binary_usability(self): + with ( + patch("tradingagents.llm_clients.codex_binary.os.name", "nt"), + patch("pathlib.Path.is_file", return_value=True), + patch( + "tradingagents.llm_clients.codex_binary._is_usable_codex_binary", + return_value=False, + ), + ): + resolved = resolve_codex_binary("C:/custom/codex.exe") + + self.assertEqual(Path(resolved), Path("C:/custom/codex.exe")) + def test_message_normalization_supports_str_messages_and_openai_dicts(self): normalized = normalize_input_messages( [ diff --git a/tests/test_scheduled_analysis.py b/tests/test_scheduled_analysis.py new file mode 100644 index 00000000..6adadcc5 --- /dev/null +++ b/tests/test_scheduled_analysis.py @@ -0,0 +1,211 @@ +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from tradingagents.scheduled.runner import execute_scheduled_run, load_scheduled_config, main + + +class _FakeStatsHandler: + def get_stats(self): + return { + "llm_calls": 12, + "tool_calls": 7, + "tokens_in": 1024, + "tokens_out": 2048, + } + + +class _FakeTradingAgentsGraph: + def __init__(self, selected_analysts, debug=False, config=None, callbacks=None): + self.selected_analysts = selected_analysts + self.debug = debug + self.config = config or {} + self.callbacks = callbacks or [] + + def propagate(self, ticker, trade_date): + if ticker == "FAIL": + raise RuntimeError("synthetic failure") + + final_state = { + "company_of_interest": ticker, + "trade_date": trade_date, + "market_report": f"## Market\n{ticker} market analysis", + "sentiment_report": f"## Sentiment\n{ticker} sentiment analysis", + "news_report": f"## News\n{ticker} news analysis", + "fundamentals_report": f"## Fundamentals\n{ticker} fundamentals analysis", + "investment_debate_state": { + "bull_history": f"{ticker} bull case", + "bear_history": f"{ticker} bear case", + "history": "debate transcript", + "current_response": "", + "judge_decision": f"{ticker} research manager decision", + }, + "trader_investment_plan": f"{ticker} trading plan", + "investment_plan": f"{ticker} investment plan", + "risk_debate_state": { + "aggressive_history": f"{ticker} aggressive case", + "conservative_history": f"{ticker} conservative case", + "neutral_history": f"{ticker} neutral case", + "history": "risk transcript", + "judge_decision": f"{ticker} final portfolio decision", + }, + "final_trade_decision": f"{ticker} final trade decision", + } + return final_state, "BUY" + + +class ScheduledAnalysisTests(unittest.TestCase): + def test_execute_scheduled_run_archives_outputs_and_builds_site(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + config_path = root / "scheduled_analysis.toml" + archive_dir = root / "archive" + site_dir = root / "site" + config_path.write_text( + f""" +[run] +tickers = ["NVDA", "FAIL"] +analysts = ["market", "social", "news", "fundamentals"] +output_language = "Korean" +trade_date_mode = "latest_available" +timezone = "Asia/Seoul" +continue_on_ticker_error = true + +[llm] +provider = "codex" +quick_model = "gpt-5.4" +deep_model = "gpt-5.4" +codex_reasoning_effort = "medium" + +[storage] +archive_dir = "{archive_dir.as_posix()}" +site_dir = "{site_dir.as_posix()}" + +[site] +title = "Daily Reports" +subtitle = "Automated" +""", + encoding="utf-8", + ) + + config = load_scheduled_config(config_path) + with ( + patch("tradingagents.scheduled.runner.TradingAgentsGraph", _FakeTradingAgentsGraph), + patch("tradingagents.scheduled.runner.StatsCallbackHandler", _FakeStatsHandler), + patch("tradingagents.scheduled.runner.resolve_trade_date", return_value="2026-04-04"), + ): + manifest = execute_scheduled_run(config, run_label="test") + + self.assertEqual(manifest["status"], "partial_failure") + self.assertEqual(manifest["summary"]["successful_tickers"], 1) + self.assertEqual(manifest["summary"]["failed_tickers"], 1) + self.assertEqual(manifest["settings"]["provider"], "codex") + self.assertEqual(manifest["settings"]["deep_model"], "gpt-5.4") + self.assertEqual(manifest["settings"]["quick_model"], "gpt-5.4") + + run_dir = archive_dir / "runs" / manifest["started_at"][:4] / manifest["run_id"] + self.assertTrue((run_dir / "run.json").exists()) + self.assertTrue((run_dir / "tickers" / "NVDA" / "report" / "complete_report.md").exists()) + self.assertTrue((run_dir / "tickers" / "FAIL" / "error.json").exists()) + + index_html = (site_dir / "index.html").read_text(encoding="utf-8") + run_html = (site_dir / "runs" / manifest["run_id"] / "index.html").read_text(encoding="utf-8") + ticker_html = (site_dir / "runs" / manifest["run_id"] / "NVDA.html").read_text(encoding="utf-8") + + self.assertIn("Daily Reports", index_html) + self.assertIn("partial failure", index_html) + self.assertIn("NVDA", run_html) + self.assertIn("Rendered report", ticker_html) + self.assertTrue((site_dir / "downloads" / manifest["run_id"] / "NVDA" / "complete_report.md").exists()) + + def test_main_site_only_rebuilds_from_existing_archive(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + archive_dir = root / "archive" + site_dir = root / "site" + run_dir = archive_dir / "runs" / "2026" / "20260405T091300_seed" + ticker_dir = run_dir / "tickers" / "NVDA" / "report" + ticker_dir.mkdir(parents=True, exist_ok=True) + (ticker_dir / "complete_report.md").write_text("# Test report", encoding="utf-8") + analysis_dir = run_dir / "tickers" / "NVDA" + (analysis_dir / "analysis.json").write_text("{}", encoding="utf-8") + (analysis_dir / "final_state.json").write_text("{}", encoding="utf-8") + (run_dir / "run.json").write_text( + json.dumps( + { + "version": 1, + "run_id": "20260405T091300_seed", + "label": "seed", + "status": "success", + "started_at": "2026-04-05T09:13:00+09:00", + "finished_at": "2026-04-05T09:20:00+09:00", + "timezone": "Asia/Seoul", + "settings": { + "provider": "codex", + "quick_model": "gpt-5.4", + "deep_model": "gpt-5.4", + "codex_reasoning_effort": "medium", + "output_language": "Korean", + "analysts": ["market", "social", "news", "fundamentals"], + "trade_date_mode": "latest_available", + "max_debate_rounds": 1, + "max_risk_discuss_rounds": 1, + }, + "summary": { + "total_tickers": 1, + "successful_tickers": 1, + "failed_tickers": 0, + }, + "tickers": [ + { + "ticker": "NVDA", + "status": "success", + "trade_date": "2026-04-04", + "decision": "BUY", + "started_at": "2026-04-05T09:13:00+09:00", + "finished_at": "2026-04-05T09:20:00+09:00", + "duration_seconds": 420.0, + "metrics": { + "llm_calls": 10, + "tool_calls": 7, + "tokens_in": 1000, + "tokens_out": 2000, + }, + "artifacts": { + "analysis_json": "tickers/NVDA/analysis.json", + "report_markdown": "tickers/NVDA/report/complete_report.md", + "final_state_json": "tickers/NVDA/final_state.json", + "graph_log_json": None, + }, + } + ], + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + config_path = root / "scheduled_analysis.toml" + config_path.write_text( + f""" +[run] +tickers = ["NVDA"] + +[storage] +archive_dir = "{archive_dir.as_posix()}" +site_dir = "{site_dir.as_posix()}" +""", + encoding="utf-8", + ) + + exit_code = main(["--config", str(config_path), "--site-only"]) + + self.assertEqual(exit_code, 0) + self.assertTrue((site_dir / "index.html").exists()) + self.assertIn("NVDA", (site_dir / "runs" / "20260405T091300_seed" / "NVDA.html").read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tradingagents/llm_clients/codex_binary.py b/tradingagents/llm_clients/codex_binary.py index 71d0588f..743c4e7d 100644 --- a/tradingagents/llm_clients/codex_binary.py +++ b/tradingagents/llm_clients/codex_binary.py @@ -2,27 +2,40 @@ from __future__ import annotations import os import shutil +import subprocess from pathlib import Path def resolve_codex_binary(codex_binary: str | None) -> str | None: - explicit = _normalize_explicit_binary(codex_binary) - if explicit: - return explicit - - env_value = _normalize_explicit_binary(os.getenv("CODEX_BINARY")) - if env_value: - return env_value + requested_candidates = [ + _normalize_explicit_binary(codex_binary), + _normalize_explicit_binary(os.getenv("CODEX_BINARY")), + ] + for candidate in requested_candidates: + if candidate and _is_usable_codex_binary(candidate): + return candidate + discovered_candidates = [] path_binary = shutil.which("codex") if path_binary: - return path_binary + discovered_candidates.append(path_binary) - for candidate in _windows_codex_candidates(): - if candidate.is_file(): - return str(candidate) + discovered_candidates.extend(str(candidate) for candidate in _windows_codex_candidates()) - return None + first_existing = None + for candidate in _dedupe_candidates(discovered_candidates): + if not Path(candidate).is_file(): + continue + if first_existing is None: + first_existing = candidate + if _is_usable_codex_binary(candidate): + return candidate + + for candidate in requested_candidates: + if candidate: + return candidate + + return first_existing def codex_binary_error_message(codex_binary: str | None) -> str: @@ -62,8 +75,39 @@ def _windows_codex_candidates() -> list[Path]: ) candidates.extend( [ + home / ".codex" / ".sandbox-bin" / "codex.exe", home / ".codex" / "bin" / "codex.exe", home / "AppData" / "Local" / "Programs" / "Codex" / "codex.exe", ] ) return candidates + + +def _dedupe_candidates(candidates: list[str]) -> list[str]: + unique = [] + seen = set() + for candidate in candidates: + normalized = os.path.normcase(os.path.normpath(candidate)) + if normalized in seen: + continue + seen.add(normalized) + unique.append(candidate) + return unique + + +def _is_usable_codex_binary(binary: str) -> bool: + if os.name != "nt": + return True + + try: + completed = subprocess.run( + [binary, "--version"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return False + + return completed.returncode == 0 diff --git a/tradingagents/reporting.py b/tradingagents/reporting.py new file mode 100644 index 00000000..877cef59 --- /dev/null +++ b/tradingagents/reporting.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import datetime as dt +from pathlib import Path +from typing import Any, Mapping + + +def save_report_bundle( + final_state: Mapping[str, Any], + ticker: str, + save_path: Path, + *, + generated_at: dt.datetime | None = None, +) -> 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) + + 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"), + ): + content = _coerce_text(final_state.get(key)) + if not content: + continue + analysts_dir.mkdir(exist_ok=True) + _write_text(analysts_dir / file_name, content) + analyst_parts.append((title, content)) + + if analyst_parts: + sections.append( + "## I. Analyst Team Reports\n\n" + + "\n\n".join(f"### {title}\n{content}" for title, content in analyst_parts) + ) + + debate = final_state.get("investment_debate_state") or {} + 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"), + ): + content = _coerce_text(debate.get(key)) + if not content: + continue + research_dir.mkdir(exist_ok=True) + _write_text(research_dir / file_name, content) + research_parts.append((title, content)) + + if research_parts: + sections.append( + "## II. Research Team Decision\n\n" + + "\n\n".join(f"### {title}\n{content}" for title, content in research_parts) + ) + + trader_plan = _coerce_text(final_state.get("trader_investment_plan")) + if trader_plan: + 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}") + + 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"), + ): + content = _coerce_text(risk.get(key)) + if not content: + continue + risk_dir.mkdir(exist_ok=True) + _write_text(risk_dir / file_name, content) + risk_parts.append((title, content)) + + if risk_parts: + sections.append( + "## IV. Risk Management Team Decision\n\n" + + "\n\n".join(f"### {title}\n{content}" for title, content in risk_parts) + ) + + portfolio_decision = _coerce_text(risk.get("judge_decision")) + if portfolio_decision: + portfolio_dir = save_path / "5_portfolio" + 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}" + ) + + header = ( + f"# Trading Analysis Report: {ticker}\n\n" + f"Generated: {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)) + return complete_report + + +def _coerce_text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, list): + return "\n".join(str(item) for item in value) + return str(value) + + +def _write_text(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") diff --git a/tradingagents/scheduled/__init__.py b/tradingagents/scheduled/__init__.py new file mode 100644 index 00000000..87872b38 --- /dev/null +++ b/tradingagents/scheduled/__init__.py @@ -0,0 +1,11 @@ +from .config import ScheduledAnalysisConfig, load_scheduled_config +from .runner import execute_scheduled_run, main +from .site import build_site + +__all__ = [ + "ScheduledAnalysisConfig", + "build_site", + "execute_scheduled_run", + "load_scheduled_config", + "main", +] diff --git a/tradingagents/scheduled/__main__.py b/tradingagents/scheduled/__main__.py new file mode 100644 index 00000000..468eb154 --- /dev/null +++ b/tradingagents/scheduled/__main__.py @@ -0,0 +1,5 @@ +from .runner import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tradingagents/scheduled/config.py b/tradingagents/scheduled/config.py new file mode 100644 index 00000000..27243d21 --- /dev/null +++ b/tradingagents/scheduled/config.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +import os +import tomllib +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import Iterable +from zoneinfo import ZoneInfo + +from cli.utils import normalize_ticker_symbol + + +ALL_ANALYSTS = ("market", "social", "news", "fundamentals") +VALID_TRADE_DATE_MODES = {"latest_available", "today", "previous_business_day", "explicit"} + + +@dataclass(frozen=True) +class RunSettings: + tickers: list[str] + analysts: list[str] = field(default_factory=lambda: list(ALL_ANALYSTS)) + output_language: str = "Korean" + trade_date_mode: str = "latest_available" + explicit_trade_date: str | None = None + timezone: str = "Asia/Seoul" + max_debate_rounds: int = 1 + max_risk_discuss_rounds: int = 1 + latest_market_data_lookback_days: int = 14 + continue_on_ticker_error: bool = True + + +@dataclass(frozen=True) +class LLMSettings: + provider: str = "codex" + deep_model: str = "gpt-5.4" + quick_model: str = "gpt-5.4" + codex_reasoning_effort: str = "medium" + codex_summary: str = "none" + codex_personality: str = "none" + codex_request_timeout: float = 180.0 + codex_max_retries: int = 2 + codex_cleanup_threads: bool = True + codex_workspace_dir: str | None = None + codex_binary: str | None = None + + +@dataclass(frozen=True) +class StorageSettings: + archive_dir: Path + site_dir: Path + + +@dataclass(frozen=True) +class SiteSettings: + title: str = "TradingAgents Daily Reports" + subtitle: str = "Automated multi-agent market analysis powered by Codex" + max_runs_on_homepage: int = 30 + + +@dataclass(frozen=True) +class ScheduledAnalysisConfig: + run: RunSettings + llm: LLMSettings + storage: StorageSettings + site: SiteSettings + config_path: Path + + +def load_scheduled_config(path: str | Path) -> ScheduledAnalysisConfig: + config_path = Path(path).resolve() + with config_path.open("rb") as handle: + raw = tomllib.load(handle) + + run_raw = raw.get("run") or {} + llm_raw = raw.get("llm") or {} + storage_raw = raw.get("storage") or {} + site_raw = raw.get("site") or {} + + tickers = _normalize_tickers(run_raw.get("tickers") or []) + if not tickers: + raise ValueError("Scheduled analysis config must declare at least one ticker in [run].tickers.") + + analysts = _normalize_analysts(run_raw.get("analysts") or list(ALL_ANALYSTS)) + + trade_date_mode = str(run_raw.get("trade_date_mode", "latest_available")).strip().lower() + explicit_trade_date = None + if run_raw.get("trade_date"): + trade_date_mode = "explicit" + explicit_trade_date = _validate_trade_date(str(run_raw["trade_date"])) + elif trade_date_mode == "explicit": + explicit_trade_date = _validate_trade_date(str(run_raw.get("explicit_trade_date", "")).strip()) + + if trade_date_mode not in VALID_TRADE_DATE_MODES: + raise ValueError( + f"Unsupported trade_date_mode '{trade_date_mode}'. " + f"Expected one of: {', '.join(sorted(VALID_TRADE_DATE_MODES))}." + ) + + timezone_name = str(run_raw.get("timezone", "Asia/Seoul")).strip() + ZoneInfo(timezone_name) + + base_dir = config_path.parent + archive_dir = _resolve_path(storage_raw.get("archive_dir", ".tradingagents-scheduled/archive"), base_dir) + site_dir = _resolve_path(storage_raw.get("site_dir", "site"), base_dir) + + return ScheduledAnalysisConfig( + run=RunSettings( + tickers=tickers, + analysts=analysts, + output_language=str(run_raw.get("output_language", "Korean")).strip() or "Korean", + trade_date_mode=trade_date_mode, + explicit_trade_date=explicit_trade_date, + timezone=timezone_name, + max_debate_rounds=int(run_raw.get("max_debate_rounds", 1)), + max_risk_discuss_rounds=int(run_raw.get("max_risk_discuss_rounds", 1)), + latest_market_data_lookback_days=int(run_raw.get("latest_market_data_lookback_days", 14)), + continue_on_ticker_error=bool(run_raw.get("continue_on_ticker_error", True)), + ), + llm=LLMSettings( + provider=str(llm_raw.get("provider", "codex")).strip().lower() or "codex", + deep_model=str(llm_raw.get("deep_model", "gpt-5.4")).strip() or "gpt-5.4", + quick_model=str(llm_raw.get("quick_model", "gpt-5.4")).strip() or "gpt-5.4", + codex_reasoning_effort=str(llm_raw.get("codex_reasoning_effort", "medium")).strip() or "medium", + codex_summary=str(llm_raw.get("codex_summary", "none")).strip() or "none", + codex_personality=str(llm_raw.get("codex_personality", "none")).strip() or "none", + codex_request_timeout=float(llm_raw.get("codex_request_timeout", 180.0)), + codex_max_retries=int(llm_raw.get("codex_max_retries", 2)), + codex_cleanup_threads=bool(llm_raw.get("codex_cleanup_threads", True)), + codex_workspace_dir=_optional_string(llm_raw.get("codex_workspace_dir")), + codex_binary=_optional_string(llm_raw.get("codex_binary")), + ), + storage=StorageSettings( + archive_dir=archive_dir, + site_dir=site_dir, + ), + site=SiteSettings( + title=str(site_raw.get("title", "TradingAgents Daily Reports")).strip() or "TradingAgents Daily Reports", + subtitle=str( + site_raw.get( + "subtitle", + "Automated multi-agent market analysis powered by Codex", + ) + ).strip() + or "Automated multi-agent market analysis powered by Codex", + max_runs_on_homepage=int(site_raw.get("max_runs_on_homepage", 30)), + ), + config_path=config_path, + ) + + +def with_overrides( + config: ScheduledAnalysisConfig, + *, + archive_dir: str | Path | None = None, + site_dir: str | Path | None = None, + tickers: Iterable[str] | None = None, + trade_date: str | None = None, +) -> ScheduledAnalysisConfig: + run = config.run + storage = config.storage + + if tickers is not None: + run = replace(run, tickers=_normalize_tickers(tickers)) + if trade_date: + run = replace(run, trade_date_mode="explicit", explicit_trade_date=_validate_trade_date(trade_date)) + if archive_dir: + storage = replace(storage, archive_dir=Path(archive_dir).expanduser().resolve()) + if site_dir: + storage = replace(storage, site_dir=Path(site_dir).expanduser().resolve()) + + return replace(config, run=run, storage=storage) + + +def _normalize_tickers(values: Iterable[str]) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for value in values: + ticker = normalize_ticker_symbol(str(value)) + if not ticker or ticker in seen: + continue + seen.add(ticker) + normalized.append(ticker) + return normalized + + +def _normalize_analysts(values: Iterable[str]) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for value in values: + analyst = str(value).strip().lower() + if analyst not in ALL_ANALYSTS: + raise ValueError( + f"Unsupported analyst '{analyst}'. Expected only: {', '.join(ALL_ANALYSTS)}." + ) + if analyst in seen: + continue + seen.add(analyst) + normalized.append(analyst) + return normalized or list(ALL_ANALYSTS) + + +def _resolve_path(value: str | os.PathLike[str], base_dir: Path) -> Path: + expanded = os.path.expanduser(os.path.expandvars(str(value))) + path = Path(expanded) + if not path.is_absolute(): + path = (base_dir / path).resolve() + return path + + +def _optional_string(value: object) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +def _validate_trade_date(value: str) -> str: + text = value.strip() + if len(text) != 10 or text[4] != "-" or text[7] != "-": + raise ValueError(f"Invalid trade date '{value}'. Expected YYYY-MM-DD.") + return text diff --git a/tradingagents/scheduled/runner.py b/tradingagents/scheduled/runner.py new file mode 100644 index 00000000..eac894dd --- /dev/null +++ b/tradingagents/scheduled/runner.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import argparse +import json +import traceback +from datetime import date, datetime, timedelta +from pathlib import Path +from time import perf_counter +from typing import Any +from zoneinfo import ZoneInfo + +import yfinance as yf + +from cli.stats_handler import StatsCallbackHandler +from tradingagents.default_config import DEFAULT_CONFIG +from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.reporting import save_report_bundle + +from .config import ScheduledAnalysisConfig, load_scheduled_config, with_overrides +from .site import build_site + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Run a non-interactive scheduled TradingAgents analysis and build a static report site." + ) + parser.add_argument("--config", default="config/scheduled_analysis.toml", help="Path to scheduled analysis TOML config.") + parser.add_argument("--archive-dir", help="Override archive directory for run history.") + parser.add_argument("--site-dir", help="Override generated site output directory.") + parser.add_argument("--tickers", help="Comma-separated ticker override.") + parser.add_argument("--trade-date", help="Optional YYYY-MM-DD override for all tickers.") + parser.add_argument("--site-only", action="store_true", help="Only rebuild the static site from archived runs.") + parser.add_argument("--strict", action="store_true", help="Return a non-zero exit code if any ticker fails.") + parser.add_argument("--label", default="github-actions", help="Run label for archived metadata.") + args = parser.parse_args(argv) + + config = with_overrides( + load_scheduled_config(args.config), + archive_dir=args.archive_dir, + site_dir=args.site_dir, + tickers=_parse_ticker_override(args.tickers), + trade_date=args.trade_date, + ) + + if args.site_only: + manifests = build_site(config.storage.archive_dir, config.storage.site_dir, config.site) + print( + f"Rebuilt static site at {config.storage.site_dir} from {len(manifests)} archived run(s)." + ) + return 0 + + manifest = execute_scheduled_run(config, run_label=args.label) + print( + f"Completed run {manifest['run_id']} with status {manifest['status']} " + f"({manifest['summary']['successful_tickers']} success / {manifest['summary']['failed_tickers']} failed)." + ) + return 1 if args.strict and manifest["summary"]["failed_tickers"] else 0 + + +def execute_scheduled_run( + config: ScheduledAnalysisConfig, + *, + run_label: str = "manual", +) -> dict[str, Any]: + tz = ZoneInfo(config.run.timezone) + started_at = datetime.now(tz) + run_id = _build_run_id(started_at, run_label) + run_dir = config.storage.archive_dir / "runs" / started_at.strftime("%Y") / run_id + run_dir.mkdir(parents=True, exist_ok=True) + + ticker_summaries: list[dict[str, Any]] = [] + engine_results_dir = run_dir / "engine-results" + + for ticker in config.run.tickers: + ticker_summary = _run_single_ticker( + config=config, + ticker=ticker, + run_dir=run_dir, + engine_results_dir=engine_results_dir, + ) + ticker_summaries.append(ticker_summary) + + if ticker_summary["status"] != "success" and not config.run.continue_on_ticker_error: + break + + finished_at = datetime.now(tz) + failures = sum(1 for item in ticker_summaries if item["status"] != "success") + successes = len(ticker_summaries) - failures + status = "success" + if failures and successes: + status = "partial_failure" + elif failures: + status = "failed" + + manifest = { + "version": 1, + "run_id": run_id, + "label": run_label, + "status": status, + "started_at": started_at.isoformat(), + "finished_at": finished_at.isoformat(), + "timezone": config.run.timezone, + "settings": _settings_snapshot(config), + "summary": { + "total_tickers": len(ticker_summaries), + "successful_tickers": successes, + "failed_tickers": failures, + }, + "tickers": ticker_summaries, + } + + _write_json(run_dir / "run.json", manifest) + _write_json(config.storage.archive_dir / "latest-run.json", manifest) + build_site(config.storage.archive_dir, config.storage.site_dir, config.site) + return manifest + + +def resolve_trade_date( + ticker: str, + config: ScheduledAnalysisConfig, +) -> str: + mode = config.run.trade_date_mode + if mode == "explicit" and config.run.explicit_trade_date: + return config.run.explicit_trade_date + + now = datetime.now(ZoneInfo(config.run.timezone)) + if mode == "today": + return now.date().isoformat() + if mode == "previous_business_day": + return _previous_business_day(now.date()).isoformat() + + history = yf.Ticker(ticker).history( + period=f"{config.run.latest_market_data_lookback_days}d", + interval="1d", + auto_adjust=False, + ) + if history.empty: + raise RuntimeError( + f"Could not resolve the latest available trade date for {ticker}; yfinance returned no rows." + ) + + last_index = history.index[-1] + last_value = getattr(last_index, "to_pydatetime", lambda: last_index)() + last_date = last_value.date() if hasattr(last_value, "date") else last_value + if not isinstance(last_date, date): + raise RuntimeError(f"Unexpected trade date index value for {ticker}: {last_index!r}") + return last_date.isoformat() + + +def _run_single_ticker( + *, + config: ScheduledAnalysisConfig, + ticker: str, + run_dir: Path, + engine_results_dir: Path, +) -> dict[str, Any]: + ticker_dir = run_dir / "tickers" / ticker + ticker_dir.mkdir(parents=True, exist_ok=True) + + ticker_started = datetime.now(ZoneInfo(config.run.timezone)) + timer_start = perf_counter() + + try: + trade_date = resolve_trade_date(ticker, config) + stats_handler = StatsCallbackHandler() + graph = TradingAgentsGraph( + config.run.analysts, + debug=False, + config=_graph_config(config, engine_results_dir), + callbacks=[stats_handler], + ) + 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) + final_state_path = ticker_dir / "final_state.json" + _write_json(final_state_path, _serialize_final_state(final_state)) + + graph_log = ( + engine_results_dir + / ticker + / "TradingAgentsStrategy_logs" + / f"full_states_log_{trade_date}.json" + ) + copied_graph_log = None + if graph_log.exists(): + copied_graph_log = ticker_dir / graph_log.name + copied_graph_log.write_text(graph_log.read_text(encoding="utf-8"), encoding="utf-8") + + metrics = stats_handler.get_stats() + analysis_payload = { + "ticker": ticker, + "status": "success", + "trade_date": trade_date, + "decision": str(decision), + "started_at": ticker_started.isoformat(), + "finished_at": datetime.now(ZoneInfo(config.run.timezone)).isoformat(), + "duration_seconds": round(perf_counter() - timer_start, 2), + "metrics": metrics, + "provider": config.llm.provider, + "models": { + "quick_model": config.llm.quick_model, + "deep_model": config.llm.deep_model, + }, + } + analysis_path = ticker_dir / "analysis.json" + _write_json(analysis_path, analysis_payload) + + return { + "ticker": ticker, + "status": "success", + "trade_date": trade_date, + "decision": str(decision), + "started_at": ticker_started.isoformat(), + "finished_at": analysis_payload["finished_at"], + "duration_seconds": analysis_payload["duration_seconds"], + "metrics": metrics, + "artifacts": { + "analysis_json": _relative_to_run(run_dir, analysis_path), + "report_markdown": _relative_to_run(run_dir, report_file), + "final_state_json": _relative_to_run(run_dir, final_state_path), + "graph_log_json": _relative_to_run(run_dir, copied_graph_log) if copied_graph_log else None, + }, + } + except Exception as exc: + error_payload = { + "ticker": ticker, + "status": "failed", + "error": str(exc), + "traceback": traceback.format_exc(), + "started_at": ticker_started.isoformat(), + "finished_at": datetime.now(ZoneInfo(config.run.timezone)).isoformat(), + "duration_seconds": round(perf_counter() - timer_start, 2), + } + error_path = ticker_dir / "error.json" + _write_json(error_path, error_payload) + + return { + "ticker": ticker, + "status": "failed", + "trade_date": None, + "decision": None, + "error": str(exc), + "started_at": error_payload["started_at"], + "finished_at": error_payload["finished_at"], + "duration_seconds": error_payload["duration_seconds"], + "metrics": {"llm_calls": 0, "tool_calls": 0, "tokens_in": 0, "tokens_out": 0}, + "artifacts": { + "error_json": _relative_to_run(run_dir, error_path), + }, + } + + +def _graph_config(config: ScheduledAnalysisConfig, engine_results_dir: Path) -> dict[str, Any]: + graph_config = DEFAULT_CONFIG.copy() + graph_config["results_dir"] = str(engine_results_dir) + graph_config["llm_provider"] = config.llm.provider + graph_config["quick_think_llm"] = config.llm.quick_model + graph_config["deep_think_llm"] = config.llm.deep_model + graph_config["max_debate_rounds"] = config.run.max_debate_rounds + graph_config["max_risk_discuss_rounds"] = config.run.max_risk_discuss_rounds + graph_config["output_language"] = config.run.output_language + graph_config["codex_reasoning_effort"] = config.llm.codex_reasoning_effort + graph_config["codex_summary"] = config.llm.codex_summary + graph_config["codex_personality"] = config.llm.codex_personality + graph_config["codex_request_timeout"] = config.llm.codex_request_timeout + graph_config["codex_max_retries"] = config.llm.codex_max_retries + graph_config["codex_cleanup_threads"] = config.llm.codex_cleanup_threads + if config.llm.codex_workspace_dir: + graph_config["codex_workspace_dir"] = config.llm.codex_workspace_dir + if config.llm.codex_binary: + graph_config["codex_binary"] = config.llm.codex_binary + return graph_config + + +def _serialize_final_state(final_state: dict[str, Any]) -> dict[str, Any]: + investment_debate = final_state.get("investment_debate_state") or {} + risk_debate = final_state.get("risk_debate_state") or {} + return { + "company_of_interest": final_state.get("company_of_interest"), + "trade_date": final_state.get("trade_date"), + "market_report": final_state.get("market_report"), + "sentiment_report": final_state.get("sentiment_report"), + "news_report": final_state.get("news_report"), + "fundamentals_report": final_state.get("fundamentals_report"), + "investment_debate_state": { + "bull_history": investment_debate.get("bull_history", ""), + "bear_history": investment_debate.get("bear_history", ""), + "history": investment_debate.get("history", ""), + "current_response": investment_debate.get("current_response", ""), + "judge_decision": investment_debate.get("judge_decision", ""), + }, + "trader_investment_plan": final_state.get("trader_investment_plan", ""), + "investment_plan": final_state.get("investment_plan", ""), + "risk_debate_state": { + "aggressive_history": risk_debate.get("aggressive_history", ""), + "conservative_history": risk_debate.get("conservative_history", ""), + "neutral_history": risk_debate.get("neutral_history", ""), + "history": risk_debate.get("history", ""), + "judge_decision": risk_debate.get("judge_decision", ""), + }, + "final_trade_decision": final_state.get("final_trade_decision", ""), + } + + +def _settings_snapshot(config: ScheduledAnalysisConfig) -> dict[str, Any]: + return { + "provider": config.llm.provider, + "quick_model": config.llm.quick_model, + "deep_model": config.llm.deep_model, + "codex_reasoning_effort": config.llm.codex_reasoning_effort, + "output_language": config.run.output_language, + "analysts": list(config.run.analysts), + "trade_date_mode": config.run.trade_date_mode, + "max_debate_rounds": config.run.max_debate_rounds, + "max_risk_discuss_rounds": config.run.max_risk_discuss_rounds, + } + + +def _build_run_id(started_at: datetime, run_label: str) -> str: + clean_label = "".join(ch if ch.isalnum() or ch in ("-", "_") else "-" for ch in run_label.strip()) or "run" + return f"{started_at.strftime('%Y%m%dT%H%M%S')}_{clean_label}" + + +def _parse_ticker_override(value: str | None) -> list[str] | None: + if not value: + return None + return [item.strip() for item in value.split(",") if item.strip()] + + +def _previous_business_day(current: date) -> date: + candidate = current - timedelta(days=1) + while candidate.weekday() >= 5: + candidate -= timedelta(days=1) + return candidate + + +def _relative_to_run(run_dir: Path, path: Path | None) -> str | None: + if path is None: + return None + return path.relative_to(run_dir).as_posix() + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") diff --git a/tradingagents/scheduled/site.py b/tradingagents/scheduled/site.py new file mode 100644 index 00000000..48261c2d --- /dev/null +++ b/tradingagents/scheduled/site.py @@ -0,0 +1,483 @@ +from __future__ import annotations + +import html +import json +import shutil +from datetime import datetime +from pathlib import Path +from typing import Any + +from .config import SiteSettings + +try: + from markdown_it import MarkdownIt +except ImportError: # pragma: no cover + MarkdownIt = None + + +_MARKDOWN = ( + MarkdownIt("commonmark", {"html": False, "linkify": True}).enable(["table", "strikethrough"]) + if MarkdownIt + else None +) + + +def build_site(archive_dir: Path, site_dir: Path, settings: SiteSettings) -> list[dict[str, Any]]: + archive_dir = Path(archive_dir) + site_dir = Path(site_dir) + manifests = _load_run_manifests(archive_dir) + + if site_dir.exists(): + shutil.rmtree(site_dir) + (site_dir / "assets").mkdir(parents=True, exist_ok=True) + _write_text(site_dir / "assets" / "style.css", _STYLE_CSS) + + for manifest in manifests: + run_dir = Path(manifest["_run_dir"]) + _copy_artifacts(site_dir, run_dir, manifest) + _write_text( + site_dir / "runs" / manifest["run_id"] / "index.html", + _render_run_page(manifest, settings), + ) + for ticker_summary in manifest.get("tickers", []): + _write_text( + site_dir / "runs" / manifest["run_id"] / f"{ticker_summary['ticker']}.html", + _render_ticker_page(manifest, ticker_summary, settings), + ) + + _write_text(site_dir / "index.html", _render_index_page(manifests, settings)) + _write_json( + site_dir / "feed.json", + { + "generated_at": datetime.now().isoformat(), + "runs": [ + {key: value for key, value in manifest.items() if key != "_run_dir"} + for manifest in manifests + ], + }, + ) + return manifests + + +def _load_run_manifests(archive_dir: Path) -> list[dict[str, Any]]: + manifests: list[dict[str, Any]] = [] + runs_root = archive_dir / "runs" + if not runs_root.exists(): + return manifests + + for path in runs_root.rglob("run.json"): + payload = json.loads(path.read_text(encoding="utf-8")) + payload["_run_dir"] = str(path.parent) + manifests.append(payload) + + manifests.sort(key=lambda item: item.get("started_at", ""), reverse=True) + return manifests + + +def _copy_artifacts(site_dir: Path, run_dir: Path, manifest: dict[str, Any]) -> None: + for ticker_summary in manifest.get("tickers", []): + download_dir = site_dir / "downloads" / manifest["run_id"] / ticker_summary["ticker"] + download_dir.mkdir(parents=True, exist_ok=True) + for relative_path in (ticker_summary.get("artifacts") or {}).values(): + if not relative_path: + continue + source = run_dir / relative_path + if source.is_file(): + shutil.copy2(source, download_dir / source.name) + + +def _render_index_page(manifests: list[dict[str, Any]], settings: SiteSettings) -> str: + latest = manifests[0] if manifests else None + latest_html = ( + f""" +
+
+

Latest automated run

+

{_escape(settings.title)}

+

{_escape(settings.subtitle)}

+
+
+
{_escape(latest['status'].replace('_', ' '))}
+

Run ID{_escape(latest['run_id'])}

+

Started{_escape(latest['started_at'])}

+

Tickers{latest['summary']['total_tickers']}

+

Success{latest['summary']['successful_tickers']}

+

Failed{latest['summary']['failed_tickers']}

+ Open latest run +
+
+ """ + if latest + else f""" +
+
+

Waiting for first run

+

{_escape(settings.title)}

+

{_escape(settings.subtitle)}

+
+
+
no data yet
+

The scheduled workflow has not produced an archived run yet.

+
+
+ """ + ) + + cards = [] + for manifest in manifests[: settings.max_runs_on_homepage]: + cards.append( + f""" +
+
+ {_escape(manifest['run_id'])} + {_escape(manifest['status'].replace('_', ' '))} +
+

{_escape(manifest['started_at'])}

+

{manifest['summary']['successful_tickers']} succeeded, {manifest['summary']['failed_tickers']} failed

+

{_escape(manifest['settings']['provider'])} / {_escape(manifest['settings']['deep_model'])}

+
+ """ + ) + + body = latest_html + f""" +
+
+

Recent runs

+

{len(manifests)} archived run(s)

+
+
+ {''.join(cards) if cards else '

No archived runs were found.

'} +
+
+ """ + return _page_template(settings.title, body, prefix="") + + +def _render_run_page(manifest: dict[str, Any], settings: SiteSettings) -> str: + ticker_cards = [] + for ticker_summary in manifest.get("tickers", []): + ticker_cards.append( + f""" +
+
+ {_escape(ticker_summary['ticker'])} + {_escape(ticker_summary['status'])} +
+

Trade date{_escape(ticker_summary.get('trade_date') or '-')}

+

Duration{ticker_summary.get('duration_seconds', 0):.1f}s

+

Decision{_escape(ticker_summary.get('decision') or ticker_summary.get('error') or '-')}

+
+ """ + ) + + body = f""" + +
+
+

Run detail

+

{_escape(manifest['run_id'])}

+

{_escape(manifest['started_at'])}

+
+
+
{_escape(manifest['status'].replace('_', ' '))}
+

Provider{_escape(manifest['settings']['provider'])}

+

Deep model{_escape(manifest['settings']['deep_model'])}

+

Quick model{_escape(manifest['settings']['quick_model'])}

+

Language{_escape(manifest['settings']['output_language'])}

+
+
+
+
+

Tickers

+

{manifest['summary']['successful_tickers']} success / {manifest['summary']['failed_tickers']} failed

+
+
+ {''.join(ticker_cards)} +
+
+ """ + return _page_template(f"{manifest['run_id']} | {settings.title}", body, prefix="../../") + + +def _render_ticker_page( + manifest: dict[str, Any], + ticker_summary: dict[str, Any], + settings: SiteSettings, +) -> str: + run_dir = Path(manifest["_run_dir"]) + report_html = "

No report markdown was generated for this ticker.

" + report_relative = (ticker_summary.get("artifacts") or {}).get("report_markdown") + if report_relative: + report_path = run_dir / report_relative + if report_path.exists(): + report_html = _render_markdown(report_path.read_text(encoding="utf-8")) + + download_links = [] + for relative_path in (ticker_summary.get("artifacts") or {}).values(): + if not relative_path: + continue + artifact_name = Path(relative_path).name + download_links.append( + f"{_escape(artifact_name)}" + ) + + failure_html = "" + if ticker_summary["status"] != "success": + failure_html = ( + "
" + "

Failure

" + f"
{_escape(ticker_summary.get('error') or 'Unknown error')}
" + "
" + ) + + body = f""" + +
+
+

Ticker report

+

{_escape(ticker_summary['ticker'])}

+

{_escape(ticker_summary.get('trade_date') or '-')} / {_escape(ticker_summary['status'])}

+
+
+
{_escape(ticker_summary['status'])}
+

Decision{_escape(ticker_summary.get('decision') or '-')}

+

Duration{ticker_summary.get('duration_seconds', 0):.1f}s

+

LLM calls{ticker_summary.get('metrics', {}).get('llm_calls', 0)}

+

Tool calls{ticker_summary.get('metrics', {}).get('tool_calls', 0)}

+
+
+
+
+

Artifacts

+
+
+ {''.join(download_links) if download_links else "No downloadable artifacts"} +
+
+ {failure_html} +
+
+

Rendered report

+
+ {report_html} +
+ """ + return _page_template( + f"{ticker_summary['ticker']} | {settings.title}", + body, + prefix="../../", + ) + + +def _page_template(title: str, body: str, *, prefix: str) -> str: + return f""" + + + + + {_escape(title)} + + + +
+ {body} +
+ + +""" + + +def _render_markdown(content: str) -> str: + if _MARKDOWN is None: + return f"
{_escape(content)}
" + return _MARKDOWN.render(content) + + +def _write_text(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + + +def _escape(value: object) -> str: + return html.escape(str(value)) + + +_STYLE_CSS = """ +:root { + --bg: #f4efe7; + --paper: rgba(255, 255, 255, 0.84); + --ink: #132238; + --muted: #5d6c7d; + --line: rgba(19, 34, 56, 0.12); + --accent: #0f7c82; + --success: #1f7a4d; + --warning: #c46a1c; + --danger: #b23b3b; + --shadow: 0 18px 45px rgba(17, 34, 51, 0.12); +} + +* { box-sizing: border-box; } + +body { + margin: 0; + color: var(--ink); + font-family: Aptos, "Segoe UI", "Noto Sans KR", sans-serif; + background: + radial-gradient(circle at top right, rgba(15, 124, 130, 0.16), transparent 34%), + radial-gradient(circle at top left, rgba(196, 106, 28, 0.16), transparent 28%), + linear-gradient(180deg, #f8f3eb 0%, #eef4f5 100%); +} + +a { color: inherit; } + +.shell { + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 24px 0 56px; +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.9fr); + gap: 20px; + padding: 28px; + border: 1px solid var(--line); + border-radius: 28px; + background: linear-gradient(135deg, rgba(255,255,255,0.9), rgba(248,251,252,0.9)); + box-shadow: var(--shadow); +} + +.hero h1, .section h2 { + margin: 0; + font-family: Georgia, "Times New Roman", serif; + letter-spacing: -0.03em; +} + +.hero h1 { + font-size: clamp(2.1rem, 4vw, 3.4rem); + line-height: 0.95; +} + +.subtitle, .section-head p, .hero-card p, .run-card p, .ticker-card p, .breadcrumbs, .empty { + color: var(--muted); +} + +.eyebrow { + margin: 0 0 14px; + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.78rem; + color: var(--accent); +} + +.hero-card, .run-card, .ticker-card, .section, .error-block, .prose pre { + border: 1px solid var(--line); + border-radius: 22px; + background: var(--paper); + box-shadow: var(--shadow); +} + +.hero-card, .run-card, .ticker-card, .section { padding: 18px 20px; } + +.hero-card p, .ticker-card p { + display: flex; + justify-content: space-between; + gap: 12px; + margin: 10px 0; +} + +.status { + display: inline-flex; + align-items: center; + padding: 8px 12px; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 12px; +} + +.status.success { background: rgba(31, 122, 77, 0.12); color: var(--success); } +.status.partial_failure, .status.pending { background: rgba(196, 106, 28, 0.14); color: var(--warning); } +.status.failed { background: rgba(178, 59, 59, 0.12); color: var(--danger); } + +.button, .pill { + display: inline-flex; + align-items: center; + text-decoration: none; + border-radius: 999px; + padding: 10px 16px; + font-weight: 600; + border: 1px solid rgba(15, 124, 130, 0.22); + background: rgba(15, 124, 130, 0.12); +} + +.section { margin-top: 20px; } + +.section-head, .run-card-header, .ticker-card-header { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: baseline; +} + +.run-grid, .ticker-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; +} + +.breadcrumbs { + display: flex; + gap: 12px; + margin: 0 0 12px; +} + +.breadcrumbs a::after { + content: "/"; + margin-left: 12px; + opacity: 0.4; +} + +.breadcrumbs a:last-child::after { display: none; } + +.pill-row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.prose { line-height: 1.65; } +.prose h1, .prose h2, .prose h3 { font-family: Georgia, "Times New Roman", serif; } +.prose pre, .error-block { + padding: 16px; + overflow: auto; + white-space: pre-wrap; + font-family: Consolas, "Courier New", monospace; +} + +.prose table { + width: 100%; + border-collapse: collapse; +} + +.prose th, .prose td { + border: 1px solid var(--line); + padding: 10px; + text-align: left; +} + +@media (max-width: 840px) { + .hero { grid-template-columns: 1fr; } + .shell { width: min(100% - 20px, 1180px); } +} +""" From 6114f1145df8d0031d1d09272b70726264127eb0 Mon Sep 17 00:00:00 2001 From: nornen0202 Date: Mon, 6 Apr 2026 08:52:55 +0900 Subject: [PATCH 03/21] Fix scheduled workflow YAML --- .github/workflows/daily-codex-analysis.yml | 25 +++++++++++----------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/workflows/daily-codex-analysis.yml b/.github/workflows/daily-codex-analysis.yml index 0fbf6b23..28460dac 100644 --- a/.github/workflows/daily-codex-analysis.yml +++ b/.github/workflows/daily-codex-analysis.yml @@ -60,18 +60,19 @@ jobs: shell: pwsh run: | $workspaceDir = Join-Path $env:GITHUB_WORKSPACE ".codex-preflight" - @" -from tradingagents.llm_clients.codex_preflight import run_codex_preflight -result = run_codex_preflight( - codex_binary=None, - model="gpt-5.4", - request_timeout=30.0, - workspace_dir=r"$workspaceDir", - cleanup_threads=True, -) -print("Codex account:", result.account) -print("First available models:", ", ".join(result.models[:8])) -"@ | python - + $script = @( + "from tradingagents.llm_clients.codex_preflight import run_codex_preflight", + "result = run_codex_preflight(", + " codex_binary=None,", + " model='gpt-5.4',", + " request_timeout=30.0,", + " workspace_dir=r'$workspaceDir',", + " cleanup_threads=True,", + ")", + "print('Codex account:', result.account)", + "print('First available models:', ', '.join(result.models[:8]))" + ) -join \"`n\" + $script | python - - name: Run scheduled analysis and build site shell: pwsh From 52a5b1c79203eb0119f6875f8407834badab1d2e Mon Sep 17 00:00:00 2001 From: nornen0202 Date: Mon, 6 Apr 2026 08:56:23 +0900 Subject: [PATCH 04/21] Use Windows PowerShell in scheduled workflow --- .github/workflows/daily-codex-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/daily-codex-analysis.yml b/.github/workflows/daily-codex-analysis.yml index 28460dac..5107d3e5 100644 --- a/.github/workflows/daily-codex-analysis.yml +++ b/.github/workflows/daily-codex-analysis.yml @@ -51,13 +51,13 @@ jobs: python-version: "3.13" - name: Install TradingAgents - shell: pwsh + shell: powershell run: | python -m pip install --upgrade pip python -m pip install -e . - name: Verify Codex login and model availability - shell: pwsh + shell: powershell run: | $workspaceDir = Join-Path $env:GITHUB_WORKSPACE ".codex-preflight" $script = @( @@ -75,7 +75,7 @@ jobs: $script | python - - name: Run scheduled analysis and build site - shell: pwsh + shell: powershell run: | $configPath = "config/scheduled_analysis.toml" if (-not (Test-Path $configPath)) { From 9d75fa2d6e2bf5d70fbb01f06f69cbaf459db9bd Mon Sep 17 00:00:00 2001 From: nornen0202 Date: Mon, 6 Apr 2026 09:00:32 +0900 Subject: [PATCH 05/21] Fix PowerShell preflight join syntax --- .github/workflows/daily-codex-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/daily-codex-analysis.yml b/.github/workflows/daily-codex-analysis.yml index 5107d3e5..d967705c 100644 --- a/.github/workflows/daily-codex-analysis.yml +++ b/.github/workflows/daily-codex-analysis.yml @@ -71,7 +71,7 @@ jobs: ")", "print('Codex account:', result.account)", "print('First available models:', ', '.join(result.models[:8]))" - ) -join \"`n\" + ) -join "`n" $script | python - - name: Run scheduled analysis and build site From 51e494fabcdaf3eded5d6c3272879530770f2c87 Mon Sep 17 00:00:00 2001 From: nornen0202 Date: Mon, 6 Apr 2026 09:15:15 +0900 Subject: [PATCH 06/21] Add Git Bash path for Pages upload --- .github/workflows/daily-codex-analysis.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/daily-codex-analysis.yml b/.github/workflows/daily-codex-analysis.yml index d967705c..325b5d74 100644 --- a/.github/workflows/daily-codex-analysis.yml +++ b/.github/workflows/daily-codex-analysis.yml @@ -107,6 +107,24 @@ jobs: python @args + - name: Add Git Bash to PATH for Pages packaging + shell: powershell + run: | + $gitBinCandidates = @( + "C:\Program Files\Git\bin", + "C:\Program Files\Git\usr\bin" + ) + + foreach ($candidate in $gitBinCandidates) { + if (Test-Path (Join-Path $candidate "bash.exe")) { + Add-Content -Path $env:GITHUB_PATH -Value $candidate + Write-Host "Added Git Bash path: $candidate" + exit 0 + } + } + + throw "bash.exe was not found under the expected Git for Windows directories." + - name: Upload GitHub Pages artifact uses: actions/upload-pages-artifact@v3 with: From 666778aa7881f41ec11d778bd96516cacf3ee64c Mon Sep 17 00:00:00 2001 From: nornen0202 Date: Mon, 6 Apr 2026 09:31:21 +0900 Subject: [PATCH 07/21] Refresh Korean setup guide with verified steps --- Docs/daily_codex_reports_setup_ko.md | 682 ++++++++++++++++----------- 1 file changed, 415 insertions(+), 267 deletions(-) diff --git a/Docs/daily_codex_reports_setup_ko.md b/Docs/daily_codex_reports_setup_ko.md index a5581ef1..df399942 100644 --- a/Docs/daily_codex_reports_setup_ko.md +++ b/Docs/daily_codex_reports_setup_ko.md @@ -1,316 +1,464 @@ -# TradingAgents 일일 자동 리포트 설정 가이드 +# TradingAgents 일일 Codex 리포트 운영 가이드 -이 문서는 현재 저장소에 추가된 `self-hosted runner + Codex + GitHub Actions + GitHub Pages` 자동화 구성을 실제로 운영하는 방법을 처음부터 끝까지 정리한 문서입니다. +이 문서는 `self-hosted Windows runner + Codex + GitHub Actions + GitHub Pages` 조합으로 TradingAgents를 매일 자동 실행하고, 웹페이지에서 결과를 확인하는 전체 절차를 정리한 문서입니다. -적용 대상: -- 저장소: `nornen0202/TradingAgents` -- 기본 티커: `GOOGL`, `NVDA` -- LLM 제공자: `codex` -- 모델: `gpt-5.4` -- analyst 구성: `market`, `social`, `news`, `fundamentals` -- 결과 언어: `Korean` +기준 저장소: +- `https://github.com/nornen0202/TradingAgents` + +기본 분석 설정: +- 티커: `GOOGL`, `NVDA` +- provider: `codex` +- model: `gpt-5.4` +- analyst: `market`, `social`, `news`, `fundamentals` +- 출력 언어: `Korean` 관련 파일: - 설정 파일: [config/scheduled_analysis.toml](/C:/Projects/TradingAgents/config/scheduled_analysis.toml) - 예시 설정: [config/scheduled_analysis.example.toml](/C:/Projects/TradingAgents/config/scheduled_analysis.example.toml) -- 실행 엔트리포인트: [tradingagents/scheduled/runner.py](/C:/Projects/TradingAgents/tradingagents/scheduled/runner.py) -- 정적 사이트 생성기: [tradingagents/scheduled/site.py](/C:/Projects/TradingAgents/tradingagents/scheduled/site.py) -- GitHub Actions 워크플로: [.github/workflows/daily-codex-analysis.yml](/C:/Projects/TradingAgents/.github/workflows/daily-codex-analysis.yml) +- 스케줄 러너: [runner.py](/C:/Projects/TradingAgents/tradingagents/scheduled/runner.py) +- 정적 사이트 생성기: [site.py](/C:/Projects/TradingAgents/tradingagents/scheduled/site.py) +- GitHub Actions 워크플로: [daily-codex-analysis.yml](/C:/Projects/TradingAgents/.github/workflows/daily-codex-analysis.yml) -## 1. 지금 이미 준비된 것 +## 1. 현재 완료된 상태 -이 저장소에는 다음이 이미 구현되어 있습니다. +2026-04-06 기준으로 아래 항목은 이미 완료되었습니다. -- 비대화식 스케줄 실행기 - - 여러 티커를 순차 실행합니다. - - `latest_available` 기준으로 최근 거래일을 자동 해석합니다. - - 실패한 티커가 있어도 다른 티커를 계속 실행할 수 있습니다. -- 결과 아카이브 - - 각 실행마다 run manifest, final state, markdown report, graph log를 저장합니다. -- 웹 리포트 생성 - - GitHub Pages에 바로 올릴 수 있는 정적 HTML/CSS/JSON을 생성합니다. -- GitHub Actions 워크플로 - - 매일 `09:13 KST` 기준으로 실행되도록 cron이 잡혀 있습니다. - - 수동 실행도 가능합니다. +- self-hosted Windows runner 설치 및 저장소 등록 완료 +- runner 이름: `desktop-gheeibb-codex` +- runner 현재 상태: `online` +- GitHub Pages 소스: `GitHub Actions`로 설정 완료 +- Actions 변수 `TRADINGAGENTS_ARCHIVE_DIR` 설정 완료 +- 값: `C:\TradingAgentsData\archive` +- `GOOGL`, `NVDA`용 설정 파일 작성 완료 +- 실제 원격 GitHub Actions 실행 성공 완료 -## 2. 당신이 반드시 해야 하는 작업 +검증된 원격 실행: +- run URL: `https://github.com/nornen0202/TradingAgents/actions/runs/24013668241` +- 상태: `success` +- 실행 시작: `2026-04-06 09:15:42 KST` +- 분석 단계 완료: `2026-04-06 09:28:35 KST` +- Pages 배포 완료: `2026-04-06 09:28:47 KST` -이 부분은 제가 대신할 수 없습니다. +검증된 산출물: +- archive manifest: `C:\TradingAgentsData\archive\latest-run.json` +- Pages URL: `https://nornen0202.github.io/TradingAgents/` -### 2-1. GitHub 저장소에 변경 반영 +이번 성공 실행 결과: +- `GOOGL`: `BUY` +- `NVDA`: `SELL` +- trade date: 두 티커 모두 `2026-04-02` -제가 로컬 저장소에는 구현을 끝냈지만, 원격 GitHub 저장소에 반영하려면 당신이 아래 둘 중 하나를 해야 합니다. +## 2. 가장 중요한 개념 3가지 -1. 직접 commit / push -2. 다음 턴에서 저에게 commit 메시지와 push/PR 작업까지 맡기기 +### 2-1. runner token은 무엇인가 -### 2-2. self-hosted runner 준비 +runner token은 self-hosted runner를 GitHub 저장소에 등록할 때 한 번 쓰는 짧은 수명의 등록 토큰입니다. -Codex 로그인 상태를 유지해야 하므로 GitHub-hosted runner가 아니라 self-hosted runner가 필요합니다. +중요: +- 영구 토큰이 아닙니다. +- 보통 1시간 안쪽의 짧은 만료 시간을 가집니다. +- runner를 새로 등록하거나 다시 등록할 때마다 새로 발급받으면 됩니다. -권장: -- 항상 켜져 있거나, 최소한 스케줄 시간 전에 켜져 있는 Windows 머신 1대 -- 이 저장소가 체크아웃된 경로 유지 -- Python 3.13 사용 -- `codex` 실행 가능 +### 2-2. `codex login`은 어디에서 해야 하나 -### 2-3. Codex 로그인 +`codex login`은 GitHub가 아니라, 실제로 workflow를 실행할 self-hosted runner 머신에서 해야 합니다. -runner 머신에서 한 번 로그인해야 합니다. +즉 이 구성에서는: +- 이 로컬 Windows PC에서 로그인해야 합니다. +- GitHub-hosted runner에서는 이 로그인 상태를 유지할 수 없습니다. + +### 2-3. `TRADINGAGENTS_ARCHIVE_DIR`는 어떤 경로여야 하나 + +이 변수는 GitHub 저장소 경로나 GitHub Pages URL이 아니라, self-hosted runner가 돌아가는 로컬 PC의 절대 경로여야 합니다. + +올바른 예: +- `C:\TradingAgentsData\archive` +- `D:\TradingAgents\archive` + +권장하지 않는 예: +- 저장소 체크아웃 폴더 내부 임시 경로 +- GitHub URL +- 상대 경로 + +이유: +- runner는 매 실행마다 저장소를 다시 checkout할 수 있습니다. +- archive는 저장소 밖의 고정 경로에 있어야 이전 실행 이력이 계속 누적됩니다. + +## 3. 관리자 PowerShell에서 새 runner token 발급받는 방법 + +관리자 PowerShell이 꼭 필요한 것은 아닙니다. 토큰 발급 자체는 GitHub UI 또는 `gh` CLI로 하면 됩니다. + +### 방법 A. GitHub 웹 UI에서 발급 + +1. 저장소 [TradingAgents](https://github.com/nornen0202/TradingAgents)로 이동 +2. `Settings` +3. `Actions` +4. `Runners` +5. `New self-hosted runner` +6. 운영체제 `Windows` 선택 +7. 화면에 표시되는 `config.cmd --token ...` 명령의 토큰 부분을 사용 + +설명: +- 이 방법이 가장 직관적입니다. +- 토큰은 화면에 잠깐 보이는 등록용 토큰입니다. +- 만료되면 다시 같은 화면에서 새로 받으면 됩니다. + +### 방법 B. GitHub CLI로 발급 PowerShell: +```powershell +gh auth status +gh api -X POST repos/nornen0202/TradingAgents/actions/runners/registration-token +``` + +응답 예시는 아래와 비슷합니다. + +```json +{ + "token": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "expires_at": "2026-04-06T00:49:20Z" +} +``` + +이 세션에서 실제 검증한 결과: +- 토큰 발급 API 호출 성공 +- 토큰 길이 확인 완료 +- 만료 시각 응답 확인 완료 + +### 언제 새 token이 필요한가 + +아래 경우 새 token이 필요합니다. + +- runner를 처음 등록할 때 +- `config.cmd remove` 후 다시 등록할 때 +- 이름이나 labels를 바꿔서 재등록할 때 +- 서비스 모드로 새로 등록할 때 + +## 4. runner 머신에서 `codex login` 또는 `codex login --device-auth` 하는 방법 + +### 4-1. 먼저 확인할 것 + +PowerShell에서 아래를 확인합니다. + ```powershell where.exe codex +codex --help +``` + +만약 `codex` alias가 애매하게 잡히거나 바로 실행이 안 되면, 실제 바이너리를 직접 실행하면 됩니다. + +이 PC에서 확인된 실제 Codex 바이너리: + +```powershell +C:\Users\JY\.vscode\extensions\openai.chatgpt-26.325.31654-win32-x64\bin\windows-x86_64\codex.exe +``` + +### 4-2. 브라우저 로그인 방식 + +PowerShell: + +```powershell codex login ``` -브라우저 기반 로그인이 어려우면: +또는 실제 경로 직접 실행: + +```powershell +& 'C:\Users\JY\.vscode\extensions\openai.chatgpt-26.325.31654-win32-x64\bin\windows-x86_64\codex.exe' login +``` + +동작: +- 브라우저 인증 창이 열리거나 +- 브라우저 인증 링크가 표시됩니다. +- ChatGPT/OpenAI 계정으로 로그인하면 됩니다. + +### 4-3. 디바이스 인증 방식 + +브라우저 팝업이 어려우면 아래를 사용합니다. ```powershell codex login --device-auth ``` -확인: - -```powershell -codex --version -``` - -참고: -- 이 환경에서는 `codex --version`이 WindowsApps alias 때문에 바로 실패했지만, TradingAgents preflight는 실제 Codex 바이너리를 자동 탐지해서 정상 통과했습니다. -- 즉 `codex` alias가 애매해도 TradingAgents 자체는 동작할 수 있습니다. -- 그래도 runner 머신에서는 가능하면 `where.exe codex`와 실제 `codex login`이 확실히 동작하도록 맞추는 편이 안전합니다. - -### 2-4. GitHub Pages 설정 - -GitHub 저장소 설정에서 아래 작업이 필요합니다. - -1. 저장소 `Settings`로 이동 -2. 왼쪽 `Pages` 선택 -3. `Build and deployment`의 `Source`를 `GitHub Actions`로 선택 - -이 단계는 GitHub UI 권한이 필요해서 당신이 해야 합니다. - -### 2-5. self-hosted runner 등록 - -저장소 `Settings > Actions > Runners`에서 runner를 등록해야 합니다. - -일반 순서: -1. 저장소 `Settings` -2. `Actions` -3. `Runners` -4. `New self-hosted runner` -5. Windows 선택 -6. GitHub가 보여주는 등록 스크립트를 runner 머신에서 실행 - -runner label은 워크플로가 현재 아래를 요구합니다. - -```yaml -runs-on: [self-hosted, Windows] -``` - -즉 `self-hosted`, `Windows` 라벨이 붙은 러너면 됩니다. - -### 2-6. 선택이지만 강력 권장: 아카이브 경로 영속화 - -지금 기본 설정은 저장소 내부의 `./.runtime/tradingagents-archive`를 쓰게 되어 있습니다. -더 안정적인 운영을 원하면 GitHub repository variable에 아래 값을 넣는 것을 권장합니다. - -- 이름: `TRADINGAGENTS_ARCHIVE_DIR` -- 예시 값: `D:\TradingAgentsData\archive` - -이렇게 하면 저장소를 새로 checkout해도 이력 데이터가 유지됩니다. - -저장소 변수 위치: -- `Settings > Secrets and variables > Actions > Variables` - -## 3. 빠른 실행 순서 - -### 3-1. 로컬 확인 - -```powershell -Set-Location C:\Projects\TradingAgents -.\.venv-codex\Scripts\Activate.ps1 -python -m pip install -e . -python -m tradingagents.scheduled --config config/scheduled_analysis.toml --label manual-local -``` - -실행 후 확인 경로: -- 아카이브: [config/scheduled_analysis.toml](/C:/Projects/TradingAgents/config/scheduled_analysis.toml)의 `archive_dir` -- 사이트: [site](/C:/Projects/TradingAgents/site) - -### 3-2. GitHub Actions 수동 실행 - -1. GitHub 저장소의 `Actions` 탭 이동 -2. `Daily Codex Analysis` 선택 -3. `Run workflow` 클릭 -4. 필요 시: - - `tickers`: 예: `GOOGL,NVDA,MSFT` - - `trade_date`: 예: `2026-04-04` - - `site_only`: `true` 또는 `false` -5. 실행 - -입력 의미: -- `tickers`: 설정 파일의 티커를 일회성으로 덮어씁니다. -- `trade_date`: `latest_available` 대신 특정 날짜를 강제합니다. -- `site_only`: 새 분석 없이 기존 아카이브만 다시 Pages로 재배포합니다. - -## 4. 매일 자동 실행 방식 - -현재 워크플로 cron: - -```yaml -- cron: "13 0 * * *" -``` - -이 값은 UTC 기준이므로 한국 시간으로는 매일 `09:13`입니다. - -왜 `09:00`이 아니라 `09:13`인가: -- GitHub 문서상 scheduled workflow는 부하가 높은 시각, 특히 정각 부근에서 지연되거나 드롭될 수 있습니다. -- 그래서 정각보다 몇 분 비켜서 잡는 편이 안전합니다. - -## 5. 산출물 구조 - -예시 run 디렉터리: - -```text -archive/ - latest-run.json - runs/ - 2026/ - 20260405T080047_real-smoke/ - run.json - tickers/ - GOOGL/ - NVDA/ - engine-results/ -``` - -티커별 주요 파일: -- `analysis.json`: 실행 요약 -- `final_state.json`: TradingAgents 최종 상태 -- `report/complete_report.md`: 통합 마크다운 리포트 -- `full_states_log_.json`: graph 상태 로그 -- 실패 시 `error.json` - -사이트 구조: - -```text -site/ - index.html - feed.json - runs//index.html - runs//.html - downloads///* -``` - -## 6. 설정 변경 방법 - -기본 티커를 바꾸려면 [config/scheduled_analysis.toml](/C:/Projects/TradingAgents/config/scheduled_analysis.toml)에서 이 부분만 수정하면 됩니다. - -```toml -[run] -tickers = ["GOOGL", "NVDA"] -``` - -연구 깊이를 올리려면: - -```toml -max_debate_rounds = 3 -max_risk_discuss_rounds = 3 -``` - -주의: -- 값이 커질수록 실행 시간과 Codex 사용량이 늘어납니다. - -## 7. 가장 추천하는 운영 형태 - -### 최소 운영 - -- runner 머신 1대 -- `codex login` 1회 -- GitHub Pages 공개 배포 -- `GOOGL`, `NVDA` 일일 실행 - -### 안정 운영 - -- runner 머신 1대 고정 -- `TRADINGAGENTS_ARCHIVE_DIR`를 저장소 밖 영속 경로로 지정 -- Windows 부팅 시 runner 자동 시작 -- 주 1회 정도 Actions 실행 기록 점검 - -## 8. 트러블슈팅 - -### `Missing config/scheduled_analysis.toml` - -원인: -- 실제 설정 파일이 아직 저장소에 없음 - -해결: -- 현재는 이미 [config/scheduled_analysis.toml](/C:/Projects/TradingAgents/config/scheduled_analysis.toml)을 추가해 두었습니다. - -### Codex 인증 오류 - -원인: -- runner 머신에서 로그인 안 됨 - -해결: - -```powershell -codex login -``` - 또는: ```powershell -codex login --device-auth +& 'C:\Users\JY\.vscode\extensions\openai.chatgpt-26.325.31654-win32-x64\bin\windows-x86_64\codex.exe' login --device-auth ``` -### Pages가 비어 있음 +동작: +- 터미널에 코드와 인증 URL이 나옵니다. +- 브라우저에서 해당 URL을 열고 코드를 입력해 인증합니다. -확인 순서: -1. `Actions` 탭에서 `Daily Codex Analysis` 실행 성공 여부 확인 -2. `Settings > Pages`에서 Source가 `GitHub Actions`인지 확인 -3. workflow의 `deploy` job 성공 여부 확인 +### 4-4. 로그인 확인 -### 스케줄이 안 뜸 +```powershell +codex login status +``` -확인 순서: -1. workflow 파일이 default branch에 있는지 확인 -2. 저장소에 최근 60일 내 활동이 있었는지 확인 -3. cron이 UTC 기준임을 확인 +또는: -## 9. 제가 이미 직접 검증한 것 +```powershell +& 'C:\Users\JY\.vscode\extensions\openai.chatgpt-26.325.31654-win32-x64\bin\windows-x86_64\codex.exe' login status +``` -이 저장소 로컬 환경에서 아래를 확인했습니다. +이 세션에서 실제 확인된 상태: -- `Codex preflight` 성공 -- Codex 계정 읽기 성공 -- 모델 목록에서 `gpt-5.4` 확인 -- mock 기반 자동화 테스트 통과 -- 실제 `SPY` 1티커 end-to-end 스모크 런 성공 - - 시작: `2026-04-05 08:00:47 +09:00` - - 종료: `2026-04-05 08:06:24 +09:00` - - 거래일 해석: `2026-04-02` - - 최종 decision: `SELL` +```text +Logged in using ChatGPT +``` -## 10. 이번 요청 기준 정리 +즉 현재 이 runner 머신은 Codex 로그인 상태가 이미 유효합니다. -현재 상태에서 당신이 해야 하는 최소 작업은 아래입니다. +## 5. `TRADINGAGENTS_ARCHIVE_DIR`에 어떤 경로를 넣어야 하나 -1. 변경사항을 원격 GitHub 저장소에 반영 -2. self-hosted runner 등록 -3. runner 머신에서 `codex login` -4. GitHub Pages Source를 `GitHub Actions`로 설정 -5. 필요하면 `TRADINGAGENTS_ARCHIVE_DIR` repository variable 추가 +질문에 대한 짧은 답: -그 외의 저장소 코드, 설정 파일, 워크플로, 문서는 지금 이 저장소에 이미 준비되어 있습니다. +네. 이 self-hosted runner가 돌아가는 로컬 PC 기준의 절대 경로를 넣는 것이 맞습니다. -## 참고 링크 +다만 정확히는: +- "프로젝트 경로"를 넣는 것이 아니라 +- "프로젝트 바깥의 영속 보관 폴더"를 넣는 것이 더 좋습니다. -- GitHub Actions `schedule` 이벤트: https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows -- GitHub Pages custom workflow: https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages -- GitHub Pages publishing source: https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site -- OpenAI Codex cloud/docs: https://developers.openai.com/codex/cloud -- OpenAI Codex app announcement: https://openai.com/index/introducing-the-codex-app/ +### 왜 프로젝트 경로 자체는 권장하지 않나 + +예를 들어 아래 경로는 권장하지 않습니다. + +```text +C:\Projects\TradingAgents +``` + +이유: +- 저장소 작업 폴더와 결과 보관 폴더가 섞입니다. +- checkout/clean 동작과 결과 보존이 충돌할 수 있습니다. +- 리포트 이력 관리가 지저분해집니다. + +### 권장 경로 + +이 세션에서 이미 설정해둔 값: + +```text +C:\TradingAgentsData\archive +``` + +이 경로가 좋은 이유: +- 저장소 바깥 경로입니다. +- runner가 같은 PC에서 실행되므로 항상 접근 가능합니다. +- 실행 이력이 계속 누적됩니다. + +### 현재 설정 상태 확인 방법 + +```powershell +gh variable list --repo nornen0202/TradingAgents +``` + +현재 실제 설정값: + +```text +TRADINGAGENTS_ARCHIVE_DIR C:\TradingAgentsData\archive +``` + +## 6. self-hosted runner 등록 방법 + +### 현재 상태 + +이미 이 PC에서 등록 완료되어 있습니다. + +- runner name: `desktop-gheeibb-codex` +- labels: `self-hosted`, `Windows`, `X64`, `codex` + +현재 워크플로의 대상: + +```yaml +runs-on: [self-hosted, Windows] +``` + +### 새로 등록해야 할 때 전체 절차 + +PowerShell: + +```powershell +mkdir C:\actions-runner +Set-Location C:\actions-runner +Invoke-WebRequest -Uri https://github.com/actions/runner/releases/download/v2.333.1/actions-runner-win-x64-2.333.1.zip -OutFile actions-runner-win-x64-2.333.1.zip +Add-Type -AssemblyName System.IO.Compression.FileSystem +[System.IO.Compression.ZipFile]::ExtractToDirectory("$PWD/actions-runner-win-x64-2.333.1.zip", "$PWD") +``` + +그 다음 GitHub에서 받은 token으로 등록: + +```powershell +.\config.cmd --url https://github.com/nornen0202/TradingAgents --token +``` + +실행: + +```powershell +.\run.cmd +``` + +## 7. 관리자 권한이 필요한 경우와 아닌 경우 + +### 관리자 권한이 없어도 되는 것 + +- runner token 발급 +- `config.cmd`로 일반 runner 등록 +- `run.cmd`로 foreground 실행 +- `codex login` +- GitHub Actions 실행 + +### 관리자 권한이 필요한 것 + +- Windows 서비스로 runner 등록 +- 시스템 전체 실행 정책 변경 + +현재 상태: +- 일반 runner 등록은 완료 +- 로그인 시 자동 시작되도록 작업 스케줄러 등록 완료 +- 따라서 "사용자가 로그인된 상태"에서는 정상 동작 + +주의: +- PC가 꺼져 있거나 +- Windows에 로그인되어 있지 않으면 +- 현재 구성에서는 runner가 잡을 받지 못할 수 있습니다. + +## 8. 진짜 항상 돌게 하려면 + +현재도 자동화는 동작합니다. 다만 가장 안정적인 운영을 원하면 나중에 관리자 PowerShell에서 서비스 모드로 전환하는 것이 좋습니다. + +서비스 모드 재등록 예시: + +```powershell +Set-Location C:\actions-runner +.\config.cmd remove +.\config.cmd --unattended --url https://github.com/nornen0202/TradingAgents --token --name desktop-gheeibb-codex --work _work --replace --labels codex --runasservice +``` + +설명: +- 이 방식은 로그아웃 상태에서도 계속 동작하게 만드는 방향입니다. +- 새 token이 필요합니다. + +## 9. GitHub Pages 설정 확인 방법 + +웹 UI: + +1. 저장소 `Settings` +2. `Pages` +3. `Build and deployment` +4. `Source = GitHub Actions` + +현재 실제 확인 상태: +- Pages URL: `https://nornen0202.github.io/TradingAgents/` +- build type: `workflow` +- 공개 상태: `public` + +## 10. 수동 실행 방법 + +### 로컬에서 바로 실행 + +PowerShell: + +```powershell +Set-Location C:\Projects\TradingAgents +python -m pip install -e . +python -m tradingagents.scheduled --config config/scheduled_analysis.toml --label manual-local +``` + +### GitHub Actions에서 수동 실행 + +1. 저장소 `Actions` +2. `Daily Codex Analysis` +3. `Run workflow` +4. 필요하면 입력값 지정 + +입력값 예시: +- `tickers`: `GOOGL,NVDA` +- `trade_date`: `2026-04-02` +- `site_only`: `false` + +## 11. 매일 자동 실행 방식 + +현재 cron: + +```yaml +- cron: "13 0 * * *" +``` + +이 의미: +- UTC 기준 `00:13` +- 한국 시간 기준 매일 `09:13` + +즉 지금은 매일 오전 9시 13분에 자동 실행되도록 설정되어 있습니다. + +## 12. 결과는 어디서 보나 + +### 웹페이지 + +- [https://nornen0202.github.io/TradingAgents/](https://nornen0202.github.io/TradingAgents/) + +### 로컬 archive + +- `C:\TradingAgentsData\archive\latest-run.json` +- `C:\TradingAgentsData\archive\runs\...` + +### runner 작업 폴더에서 생성된 site + +- `C:\actions-runner\_work\TradingAgents\TradingAgents\site\index.html` + +## 13. 이번에 실제로 검증한 항목 + +이번 세션에서 아래를 직접 검증했습니다. + +- `gh` 인증 상태 정상 +- self-hosted runner 온라인 상태 확인 +- Codex 로그인 상태 확인 +- `TRADINGAGENTS_ARCHIVE_DIR` 변수 설정 확인 +- GitHub Pages 설정 확인 +- 원격 workflow dispatch 실행 성공 +- `GOOGL`, `NVDA` 실제 분석 성공 +- Pages artifact 업로드 성공 +- GitHub Pages 배포 성공 +- 실제 Pages URL HTTP 200 응답 확인 +- 실제 Pages HTML에 최신 run ID 노출 확인 + +실제 성공 run: +- [GitHub Actions run](https://github.com/nornen0202/TradingAgents/actions/runs/24013668241) +- [GitHub Pages](https://nornen0202.github.io/TradingAgents/) + +## 14. 당신이 지금 꼭 해야 하는 일 + +즉시 사용 기준으로는 추가 필수 작업이 없습니다. + +이미 완료된 것: +- 설정 파일 작성 +- runner 등록 +- Codex 로그인 확인 +- Actions 변수 설정 +- Pages 설정 +- 원격 실실행 검증 + +다만 아래 상황이면 당신이 직접 해야 합니다. + +- 로그아웃 상태에서도 항상 돌게 만들고 싶다 + - 관리자 PowerShell로 서비스 등록 필요 +- 티커를 바꾸고 싶다 + - [scheduled_analysis.toml](/C:/Projects/TradingAgents/config/scheduled_analysis.toml) 수정 필요 +- 다른 PC로 runner를 옮기고 싶다 + - 그 PC에서 다시 `codex login`과 runner 등록 필요 + +## 15. 자주 쓰는 확인 명령 + +```powershell +gh auth status +gh variable list --repo nornen0202/TradingAgents +gh run list --repo nornen0202/TradingAgents --workflow daily-codex-analysis.yml --limit 5 +gh run view 24013668241 --repo nornen0202/TradingAgents +codex login status +``` + +실제 Codex 바이너리 직접 확인이 필요하면: + +```powershell +& 'C:\Users\JY\.vscode\extensions\openai.chatgpt-26.325.31654-win32-x64\bin\windows-x86_64\codex.exe' login status +``` From f598476a105a69d70d2a51092766a18eea6ee491 Mon Sep 17 00:00:00 2001 From: nornen0202 Date: Tue, 7 Apr 2026 00:32:29 +0900 Subject: [PATCH 08/21] Update Codex operations guide and FAQ --- Docs/daily_codex_reports_setup_ko.md | 558 ++++++++++++--------------- 1 file changed, 253 insertions(+), 305 deletions(-) diff --git a/Docs/daily_codex_reports_setup_ko.md b/Docs/daily_codex_reports_setup_ko.md index df399942..6dd0ef37 100644 --- a/Docs/daily_codex_reports_setup_ko.md +++ b/Docs/daily_codex_reports_setup_ko.md @@ -1,6 +1,6 @@ # TradingAgents 일일 Codex 리포트 운영 가이드 -이 문서는 `self-hosted Windows runner + Codex + GitHub Actions + GitHub Pages` 조합으로 TradingAgents를 매일 자동 실행하고, 웹페이지에서 결과를 확인하는 전체 절차를 정리한 문서입니다. +이 문서는 `self-hosted Windows runner + Codex + GitHub Actions + GitHub Pages` 조합으로 TradingAgents를 매일 자동 실행하고, 웹페이지에서 결과를 확인하는 운영 절차를 정리한 문서입니다. 기준 저장소: - `https://github.com/nornen0202/TradingAgents` @@ -19,144 +19,136 @@ - 정적 사이트 생성기: [site.py](/C:/Projects/TradingAgents/tradingagents/scheduled/site.py) - GitHub Actions 워크플로: [daily-codex-analysis.yml](/C:/Projects/TradingAgents/.github/workflows/daily-codex-analysis.yml) -## 1. 현재 완료된 상태 +## 1. 현재 운영 상태 -2026-04-06 기준으로 아래 항목은 이미 완료되었습니다. +2026-04-07 기준 현재 상태는 아래와 같습니다. -- self-hosted Windows runner 설치 및 저장소 등록 완료 +- self-hosted Windows runner 등록 완료 - runner 이름: `desktop-gheeibb-codex` -- runner 현재 상태: `online` -- GitHub Pages 소스: `GitHub Actions`로 설정 완료 +- runner 상태: `online` +- GitHub Pages 소스: `GitHub Actions` - Actions 변수 `TRADINGAGENTS_ARCHIVE_DIR` 설정 완료 -- 값: `C:\TradingAgentsData\archive` -- `GOOGL`, `NVDA`용 설정 파일 작성 완료 -- 실제 원격 GitHub Actions 실행 성공 완료 +- 변수 값: `C:\TradingAgentsData\archive` +- `GOOGL`, `NVDA` 설정 파일 작성 완료 +- 실제 원격 GitHub Actions 실행 성공 검증 완료 -검증된 원격 실행: +검증된 성공 실행: - run URL: `https://github.com/nornen0202/TradingAgents/actions/runs/24013668241` - 상태: `success` - 실행 시작: `2026-04-06 09:15:42 KST` -- 분석 단계 완료: `2026-04-06 09:28:35 KST` +- 분석 완료: `2026-04-06 09:28:35 KST` - Pages 배포 완료: `2026-04-06 09:28:47 KST` -검증된 산출물: +검증된 결과: - archive manifest: `C:\TradingAgentsData\archive\latest-run.json` - Pages URL: `https://nornen0202.github.io/TradingAgents/` - -이번 성공 실행 결과: -- `GOOGL`: `BUY` -- `NVDA`: `SELL` +- 이번 성공 실행 결과: `GOOGL = BUY`, `NVDA = SELL` - trade date: 두 티커 모두 `2026-04-02` -## 2. 가장 중요한 개념 3가지 +중요: +- 현재 runner는 정상 동작 중입니다. +- 서비스 모드 전환은 아직 완료된 상태로 가정하지 않습니다. +- 지금도 PC가 켜져 있고 로그인된 상태라면 자동 실행은 가능합니다. -### 2-1. runner token은 무엇인가 +## 2. 전체 동작 구조 -runner token은 self-hosted runner를 GitHub 저장소에 등록할 때 한 번 쓰는 짧은 수명의 등록 토큰입니다. +동작 흐름은 아래와 같습니다. + +1. GitHub Actions가 매일 `09:13 KST`에 `daily-codex-analysis.yml`을 실행합니다. +2. self-hosted Windows runner가 잡을 받아 TradingAgents를 실행합니다. +3. Codex `gpt-5.4`로 4개 analyst 조합 분석을 수행합니다. +4. 결과를 `TRADINGAGENTS_ARCHIVE_DIR` 아래에 누적 저장합니다. +5. 정적 사이트를 생성합니다. +6. GitHub Pages로 배포합니다. + +사용자가 보는 위치: +- 웹: [https://nornen0202.github.io/TradingAgents/](https://nornen0202.github.io/TradingAgents/) +- 로컬 archive: `C:\TradingAgentsData\archive` + +## 3. 가장 중요한 개념 3가지 + +### 3-1. runner token + +runner token은 self-hosted runner를 GitHub 저장소에 등록할 때 쓰는 짧은 수명 토큰입니다. 중요: - 영구 토큰이 아닙니다. -- 보통 1시간 안쪽의 짧은 만료 시간을 가집니다. -- runner를 새로 등록하거나 다시 등록할 때마다 새로 발급받으면 됩니다. +- 보통 1시간 내외로 만료됩니다. +- runner를 새로 등록하거나 재등록할 때만 사용합니다. -### 2-2. `codex login`은 어디에서 해야 하나 +### 3-2. Codex 로그인 위치 -`codex login`은 GitHub가 아니라, 실제로 workflow를 실행할 self-hosted runner 머신에서 해야 합니다. +`codex login`은 GitHub가 아니라 실제 self-hosted runner가 돌아가는 로컬 Windows PC에서 해야 합니다. 즉 이 구성에서는: -- 이 로컬 Windows PC에서 로그인해야 합니다. +- 이 로컬 PC에서 로그인해야 합니다. - GitHub-hosted runner에서는 이 로그인 상태를 유지할 수 없습니다. -### 2-3. `TRADINGAGENTS_ARCHIVE_DIR`는 어떤 경로여야 하나 +### 3-3. `TRADINGAGENTS_ARCHIVE_DIR` -이 변수는 GitHub 저장소 경로나 GitHub Pages URL이 아니라, self-hosted runner가 돌아가는 로컬 PC의 절대 경로여야 합니다. +이 변수는 GitHub 경로나 저장소 경로가 아니라, self-hosted runner가 실행되는 로컬 PC의 절대 경로여야 합니다. -올바른 예: +권장 예: - `C:\TradingAgentsData\archive` - `D:\TradingAgents\archive` 권장하지 않는 예: -- 저장소 체크아웃 폴더 내부 임시 경로 +- `C:\Projects\TradingAgents` - GitHub URL - 상대 경로 이유: -- runner는 매 실행마다 저장소를 다시 checkout할 수 있습니다. -- archive는 저장소 밖의 고정 경로에 있어야 이전 실행 이력이 계속 누적됩니다. +- 결과 이력을 저장소 checkout 폴더와 분리해야 안전합니다. +- archive는 저장소 밖의 영속 경로에 있어야 이전 실행 이력이 누적됩니다. -## 3. 관리자 PowerShell에서 새 runner token 발급받는 방법 +## 4. runner token 발급 방법 -관리자 PowerShell이 꼭 필요한 것은 아닙니다. 토큰 발급 자체는 GitHub UI 또는 `gh` CLI로 하면 됩니다. - -### 방법 A. GitHub 웹 UI에서 발급 +### 방법 A. GitHub 웹 UI 1. 저장소 [TradingAgents](https://github.com/nornen0202/TradingAgents)로 이동 2. `Settings` 3. `Actions` 4. `Runners` 5. `New self-hosted runner` -6. 운영체제 `Windows` 선택 -7. 화면에 표시되는 `config.cmd --token ...` 명령의 토큰 부분을 사용 +6. `Windows` 선택 +7. 화면에 표시되는 명령의 `--token` 값을 사용 -설명: -- 이 방법이 가장 직관적입니다. -- 토큰은 화면에 잠깐 보이는 등록용 토큰입니다. -- 만료되면 다시 같은 화면에서 새로 받으면 됩니다. +### 방법 B. GitHub CLI -### 방법 B. GitHub CLI로 발급 - -PowerShell: +등록용 token: ```powershell gh auth status gh api -X POST repos/nornen0202/TradingAgents/actions/runners/registration-token ``` -응답 예시는 아래와 비슷합니다. +삭제용 token: -```json -{ - "token": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - "expires_at": "2026-04-06T00:49:20Z" -} +```powershell +gh api -X POST repos/nornen0202/TradingAgents/actions/runners/remove-token ``` -이 세션에서 실제 검증한 결과: -- 토큰 발급 API 호출 성공 -- 토큰 길이 확인 완료 -- 만료 시각 응답 확인 완료 +중요: +- `registration token`과 `remove token`은 서로 다릅니다. +- `.\config.cmd remove`에는 `remove token`이 필요합니다. +- `.\config.cmd --token ...` 등록에는 `registration token`이 필요합니다. -### 언제 새 token이 필요한가 +## 5. Codex 로그인 방법 -아래 경우 새 token이 필요합니다. - -- runner를 처음 등록할 때 -- `config.cmd remove` 후 다시 등록할 때 -- 이름이나 labels를 바꿔서 재등록할 때 -- 서비스 모드로 새로 등록할 때 - -## 4. runner 머신에서 `codex login` 또는 `codex login --device-auth` 하는 방법 - -### 4-1. 먼저 확인할 것 - -PowerShell에서 아래를 확인합니다. +먼저 확인: ```powershell where.exe codex codex --help ``` -만약 `codex` alias가 애매하게 잡히거나 바로 실행이 안 되면, 실제 바이너리를 직접 실행하면 됩니다. - 이 PC에서 확인된 실제 Codex 바이너리: ```powershell C:\Users\JY\.vscode\extensions\openai.chatgpt-26.325.31654-win32-x64\bin\windows-x86_64\codex.exe ``` -### 4-2. 브라우저 로그인 방식 - -PowerShell: +브라우저 로그인: ```powershell codex login @@ -168,296 +160,252 @@ codex login & 'C:\Users\JY\.vscode\extensions\openai.chatgpt-26.325.31654-win32-x64\bin\windows-x86_64\codex.exe' login ``` -동작: -- 브라우저 인증 창이 열리거나 -- 브라우저 인증 링크가 표시됩니다. -- ChatGPT/OpenAI 계정으로 로그인하면 됩니다. - -### 4-3. 디바이스 인증 방식 - -브라우저 팝업이 어려우면 아래를 사용합니다. +디바이스 인증: ```powershell codex login --device-auth ``` -또는: - -```powershell -& 'C:\Users\JY\.vscode\extensions\openai.chatgpt-26.325.31654-win32-x64\bin\windows-x86_64\codex.exe' login --device-auth -``` - -동작: -- 터미널에 코드와 인증 URL이 나옵니다. -- 브라우저에서 해당 URL을 열고 코드를 입력해 인증합니다. - -### 4-4. 로그인 확인 +상태 확인: ```powershell codex login status ``` -또는: - -```powershell -& 'C:\Users\JY\.vscode\extensions\openai.chatgpt-26.325.31654-win32-x64\bin\windows-x86_64\codex.exe' login status -``` - -이 세션에서 실제 확인된 상태: +현재 이 PC에서 실제 확인된 상태: ```text Logged in using ChatGPT ``` -즉 현재 이 runner 머신은 Codex 로그인 상태가 이미 유효합니다. +## 6. 서비스 모드 이해 -## 5. `TRADINGAGENTS_ARCHIVE_DIR`에 어떤 경로를 넣어야 하나 +### 서비스 모드가 의미하는 것 -질문에 대한 짧은 답: +서비스 모드로 전환하면: +- Windows에 로그인하지 않아도 runner가 자동 시작될 수 있습니다. +- 로그아웃 상태에서도 GitHub Actions 잡을 받을 수 있습니다. -네. 이 self-hosted runner가 돌아가는 로컬 PC 기준의 절대 경로를 넣는 것이 맞습니다. +### 서비스 모드로도 안 되는 것 -다만 정확히는: -- "프로젝트 경로"를 넣는 것이 아니라 -- "프로젝트 바깥의 영속 보관 폴더"를 넣는 것이 더 좋습니다. +서비스 모드여도 아래 상태에서는 동작하지 않습니다. -### 왜 프로젝트 경로 자체는 권장하지 않나 +- PC 전원이 꺼져 있음 +- 절전 또는 최대 절전 상태 +- 네트워크 끊김 -예를 들어 아래 경로는 권장하지 않습니다. +즉 핵심은: +- 서비스 모드 = 로그아웃 상태 대응 +- 전원 꺼짐 대응은 아님 -```text -C:\Projects\TradingAgents -``` +### Codex 로그인 유지 여부 -이유: -- 저장소 작업 폴더와 결과 보관 폴더가 섞입니다. -- checkout/clean 동작과 결과 보존이 충돌할 수 있습니다. -- 리포트 이력 관리가 지저분해집니다. +보통 같은 PC, 같은 사용자 환경이라면 Codex 로그인은 유지됩니다. -### 권장 경로 +다만 아래 경우에는 재로그인이 필요할 수 있습니다. -이 세션에서 이미 설정해둔 값: +- 인증 만료 +- Codex 앱/CLI 업데이트 후 인증 재요구 +- runner를 다른 사용자 계정으로 실행 +- 인증 파일 삭제 -```text -C:\TradingAgentsData\archive -``` +## 7. 서비스 모드 전환 절차 -이 경로가 좋은 이유: -- 저장소 바깥 경로입니다. -- runner가 같은 PC에서 실행되므로 항상 접근 가능합니다. -- 실행 이력이 계속 누적됩니다. +현재 질문 흐름상 아직 서비스 모드 전환은 완료하지 않은 상태를 기준으로 설명합니다. -### 현재 설정 상태 확인 방법 - -```powershell -gh variable list --repo nornen0202/TradingAgents -``` - -현재 실제 설정값: - -```text -TRADINGAGENTS_ARCHIVE_DIR C:\TradingAgentsData\archive -``` - -## 6. self-hosted runner 등록 방법 - -### 현재 상태 - -이미 이 PC에서 등록 완료되어 있습니다. - -- runner name: `desktop-gheeibb-codex` -- labels: `self-hosted`, `Windows`, `X64`, `codex` - -현재 워크플로의 대상: - -```yaml -runs-on: [self-hosted, Windows] -``` - -### 새로 등록해야 할 때 전체 절차 +### 7-1. 기존 등록 제거 PowerShell: ```powershell -mkdir C:\actions-runner -Set-Location C:\actions-runner -Invoke-WebRequest -Uri https://github.com/actions/runner/releases/download/v2.333.1/actions-runner-win-x64-2.333.1.zip -OutFile actions-runner-win-x64-2.333.1.zip -Add-Type -AssemblyName System.IO.Compression.FileSystem -[System.IO.Compression.ZipFile]::ExtractToDirectory("$PWD/actions-runner-win-x64-2.333.1.zip", "$PWD") -``` - -그 다음 GitHub에서 받은 token으로 등록: - -```powershell -.\config.cmd --url https://github.com/nornen0202/TradingAgents --token -``` - -실행: - -```powershell -.\run.cmd -``` - -## 7. 관리자 권한이 필요한 경우와 아닌 경우 - -### 관리자 권한이 없어도 되는 것 - -- runner token 발급 -- `config.cmd`로 일반 runner 등록 -- `run.cmd`로 foreground 실행 -- `codex login` -- GitHub Actions 실행 - -### 관리자 권한이 필요한 것 - -- Windows 서비스로 runner 등록 -- 시스템 전체 실행 정책 변경 - -현재 상태: -- 일반 runner 등록은 완료 -- 로그인 시 자동 시작되도록 작업 스케줄러 등록 완료 -- 따라서 "사용자가 로그인된 상태"에서는 정상 동작 - -주의: -- PC가 꺼져 있거나 -- Windows에 로그인되어 있지 않으면 -- 현재 구성에서는 runner가 잡을 받지 못할 수 있습니다. - -## 8. 진짜 항상 돌게 하려면 - -현재도 자동화는 동작합니다. 다만 가장 안정적인 운영을 원하면 나중에 관리자 PowerShell에서 서비스 모드로 전환하는 것이 좋습니다. - -서비스 모드 재등록 예시: - -```powershell +gh api -X POST repos/nornen0202/TradingAgents/actions/runners/remove-token Set-Location C:\actions-runner .\config.cmd remove -.\config.cmd --unattended --url https://github.com/nornen0202/TradingAgents --token --name desktop-gheeibb-codex --work _work --replace --labels codex --runasservice ``` -설명: -- 이 방식은 로그아웃 상태에서도 계속 동작하게 만드는 방향입니다. -- 새 token이 필요합니다. +프롬프트가 뜨면: +- 방금 받은 `remove token` 값을 입력합니다. -## 9. GitHub Pages 설정 확인 방법 +주의: +- `registration token`을 넣으면 안 됩니다. -웹 UI: +### 7-2. 서비스 모드 재등록 -1. 저장소 `Settings` -2. `Pages` -3. `Build and deployment` -4. `Source = GitHub Actions` - -현재 실제 확인 상태: -- Pages URL: `https://nornen0202.github.io/TradingAgents/` -- build type: `workflow` -- 공개 상태: `public` - -## 10. 수동 실행 방법 - -### 로컬에서 바로 실행 - -PowerShell: +관리자 PowerShell: ```powershell -Set-Location C:\Projects\TradingAgents -python -m pip install -e . -python -m tradingagents.scheduled --config config/scheduled_analysis.toml --label manual-local +gh api -X POST repos/nornen0202/TradingAgents/actions/runners/registration-token +Set-Location C:\actions-runner +.\config.cmd --unattended --url https://github.com/nornen0202/TradingAgents --token --name desktop-gheeibb-codex --work _work --replace --labels codex --runasservice ``` -### GitHub Actions에서 수동 실행 +### 7-3. 확인 -1. 저장소 `Actions` -2. `Daily Codex Analysis` -3. `Run workflow` -4. 필요하면 입력값 지정 +확인 항목: +- GitHub `Settings > Actions > Runners`에서 `online` +- `services.msc`에서 runner 서비스 확인 +- `gh api repos/nornen0202/TradingAgents/actions/runners` -입력값 예시: -- `tickers`: `GOOGL,NVDA` -- `trade_date`: `2026-04-02` -- `site_only`: `false` +## 8. 운영 체크리스트 -## 11. 매일 자동 실행 방식 +### 매일 자동 실행 전제 조건 -현재 cron: +- PC 전원이 켜져 있음 +- 인터넷 연결 정상 +- runner가 `online` +- `codex login status`가 정상 +- `TRADINGAGENTS_ARCHIVE_DIR` 경로 존재 -```yaml -- cron: "13 0 * * *" -``` - -이 의미: -- UTC 기준 `00:13` -- 한국 시간 기준 매일 `09:13` - -즉 지금은 매일 오전 9시 13분에 자동 실행되도록 설정되어 있습니다. - -## 12. 결과는 어디서 보나 - -### 웹페이지 - -- [https://nornen0202.github.io/TradingAgents/](https://nornen0202.github.io/TradingAgents/) - -### 로컬 archive - -- `C:\TradingAgentsData\archive\latest-run.json` -- `C:\TradingAgentsData\archive\runs\...` - -### runner 작업 폴더에서 생성된 site - -- `C:\actions-runner\_work\TradingAgents\TradingAgents\site\index.html` - -## 13. 이번에 실제로 검증한 항목 - -이번 세션에서 아래를 직접 검증했습니다. - -- `gh` 인증 상태 정상 -- self-hosted runner 온라인 상태 확인 -- Codex 로그인 상태 확인 -- `TRADINGAGENTS_ARCHIVE_DIR` 변수 설정 확인 -- GitHub Pages 설정 확인 -- 원격 workflow dispatch 실행 성공 -- `GOOGL`, `NVDA` 실제 분석 성공 -- Pages artifact 업로드 성공 -- GitHub Pages 배포 성공 -- 실제 Pages URL HTTP 200 응답 확인 -- 실제 Pages HTML에 최신 run ID 노출 확인 - -실제 성공 run: -- [GitHub Actions run](https://github.com/nornen0202/TradingAgents/actions/runs/24013668241) -- [GitHub Pages](https://nornen0202.github.io/TradingAgents/) - -## 14. 당신이 지금 꼭 해야 하는 일 - -즉시 사용 기준으로는 추가 필수 작업이 없습니다. - -이미 완료된 것: -- 설정 파일 작성 -- runner 등록 -- Codex 로그인 확인 -- Actions 변수 설정 -- Pages 설정 -- 원격 실실행 검증 - -다만 아래 상황이면 당신이 직접 해야 합니다. - -- 로그아웃 상태에서도 항상 돌게 만들고 싶다 - - 관리자 PowerShell로 서비스 등록 필요 -- 티커를 바꾸고 싶다 - - [scheduled_analysis.toml](/C:/Projects/TradingAgents/config/scheduled_analysis.toml) 수정 필요 -- 다른 PC로 runner를 옮기고 싶다 - - 그 PC에서 다시 `codex login`과 runner 등록 필요 - -## 15. 자주 쓰는 확인 명령 +### 수동 점검 체크리스트 ```powershell gh auth status gh variable list --repo nornen0202/TradingAgents gh run list --repo nornen0202/TradingAgents --workflow daily-codex-analysis.yml --limit 5 -gh run view 24013668241 --repo nornen0202/TradingAgents +gh api repos/nornen0202/TradingAgents/actions/runners +codex login status +Test-Path C:\TradingAgentsData\archive +``` + +### 수동 실행 체크리스트 + +1. 저장소 `Actions` +2. `Daily Codex Analysis` +3. `Run workflow` +4. 필요시 입력: + - `tickers`: `GOOGL,NVDA` + - `trade_date`: `2026-04-02` + - `site_only`: `false` + +## 9. 최근 질의응답 정리 + +### Q. `registration token`의 `expires_at`은 서비스 모드 만료 시간인가 + +A. 아닙니다. + +- `expires_at`은 토큰 만료 시각입니다. +- 서비스 모드 자체의 만료 시각이 아닙니다. +- 만료 전에 등록만 완료하면 이후 서비스는 계속 동작합니다. + +### Q. 서비스 모드로 바꾸면 PC를 꺼도 동작하나 + +A. 아닙니다. + +- 서비스 모드는 로그아웃 상태 대응입니다. +- PC 전원이 꺼져 있으면 동작하지 않습니다. + +### Q. 서비스 모드로 바꾸면 Codex 로그인은 유지되나 + +A. 보통 유지됩니다. + +- 같은 PC와 같은 사용자 기준이면 대체로 유지됩니다. +- 다만 인증 만료나 사용자 계정 변경 시 재로그인이 필요할 수 있습니다. + +### Q. `.\config.cmd remove`에서 무엇을 입력해야 하나 + +A. `remove token`을 입력해야 합니다. + +명령: + +```powershell +gh api -X POST repos/nornen0202/TradingAgents/actions/runners/remove-token +``` + +중요: +- `registration token`이 아닙니다. +- `remove token`과 `registration token`은 별개입니다. + +### Q. `TRADINGAGENTS_ARCHIVE_DIR`는 프로젝트 경로인가 + +A. 아니고, 이 로컬 PC의 영속 archive 폴더 경로입니다. + +현재 설정값: + +```text +C:\TradingAgentsData\archive +``` + +## 10. 티커 변경 방법 + +수정 파일: +- [config/scheduled_analysis.toml](/C:/Projects/TradingAgents/config/scheduled_analysis.toml) + +예시: + +```toml +[run] +tickers = ["GOOGL", "NVDA"] +``` + +다른 티커로 바꾸려면: + +```toml +[run] +tickers = ["AAPL", "MSFT", "TSLA"] +``` + +일회성 테스트는 GitHub Actions 수동 실행에서 `tickers` 입력칸으로 덮어쓸 수 있습니다. + +## 11. 장애 대응 순서 + +문제가 생기면 아래 순서로 확인합니다. + +1. runner 온라인 여부 확인 +2. Codex 로그인 상태 확인 +3. archive 경로 존재 여부 확인 +4. 최근 Actions run 로그 확인 +5. GitHub Pages 최신 페이지 반영 확인 + +자주 발생하는 원인: +- runner 오프라인 +- Windows 로그아웃 또는 전원 꺼짐 +- Codex 로그인 만료 +- archive 경로 권한 문제 +- workflow 수정 후 미푸시 + +## 12. 실제 검증 완료 항목 + +이번 작업에서 직접 검증한 항목: + +- GitHub CLI 인증 정상 +- self-hosted runner 온라인 확인 +- Codex 로그인 상태 확인 +- Actions 변수 설정 확인 +- GitHub Pages 설정 확인 +- 원격 workflow dispatch 성공 +- `GOOGL`, `NVDA` 분석 성공 +- Pages artifact 업로드 성공 +- GitHub Pages 배포 성공 +- 실제 Pages URL HTTP 200 확인 + +성공 링크: +- [GitHub Actions run](https://github.com/nornen0202/TradingAgents/actions/runs/24013668241) +- [GitHub Pages](https://nornen0202.github.io/TradingAgents/) + +## 13. 지금 꼭 해야 하는 일 + +즉시 사용 기준으로는 추가 필수 작업이 없습니다. + +다만 아래 상황이면 추가 작업이 필요합니다. + +- 로그아웃 상태에서도 항상 돌리고 싶다 + - 관리자 PowerShell에서 서비스 모드 전환 필요 +- 티커를 바꾸고 싶다 + - [config/scheduled_analysis.toml](/C:/Projects/TradingAgents/config/scheduled_analysis.toml) 수정 +- 다른 PC로 runner를 옮기고 싶다 + - 그 PC에서 다시 `codex login`과 runner 등록 필요 + +## 14. 자주 쓰는 명령 + +```powershell +gh auth status +gh variable list --repo nornen0202/TradingAgents +gh run list --repo nornen0202/TradingAgents --workflow daily-codex-analysis.yml --limit 5 +gh api repos/nornen0202/TradingAgents/actions/runners codex login status ``` -실제 Codex 바이너리 직접 확인이 필요하면: +실제 Codex 바이너리 직접 실행: ```powershell & 'C:\Users\JY\.vscode\extensions\openai.chatgpt-26.325.31654-win32-x64\bin\windows-x86_64\codex.exe' login status From 6050c25bb20e4192c7e9c6fd0452df469a77775d Mon Sep 17 00:00:00 2001 From: nornen0202 Date: Tue, 7 Apr 2026 00:33:58 +0900 Subject: [PATCH 09/21] Update scheduled ticker list --- config/scheduled_analysis.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/scheduled_analysis.toml b/config/scheduled_analysis.toml index fc0e5a1d..226ba156 100644 --- a/config/scheduled_analysis.toml +++ b/config/scheduled_analysis.toml @@ -1,5 +1,5 @@ [run] -tickers = ["GOOGL", "NVDA"] +tickers = ["GOOGL", "NVDA", "TSM", "APPL", "ETN", "LLY", "GLDM", "VRT", "TSLA", "GEV", "VXUS", "RSP", "FANG", "ETHU", "ORCL", "MU"] analysts = ["market", "social", "news", "fundamentals"] output_language = "Korean" trade_date_mode = "latest_available" From e17db7bd35946262527e6f96befe46c17c2a79a5 Mon Sep 17 00:00:00 2001 From: nornen0202 Date: Tue, 7 Apr 2026 01:30:12 +0900 Subject: [PATCH 10/21] Improve Korean report localization --- cli/main.py | 11 ++- tests/test_report_localization.py | 98 +++++++++++++++++++++++ tradingagents/agents/utils/agent_utils.py | 83 ++++++++++++++++++- tradingagents/graph/trading_graph.py | 67 +++++++++++++++- tradingagents/reporting.py | 87 +++++++++++++++----- tradingagents/scheduled/runner.py | 8 +- 6 files changed, 327 insertions(+), 27 deletions(-) create mode 100644 tests/test_report_localization.py 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_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/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)) From 83ad742decb43f20e11fe7500c739da8d751b2f7 Mon Sep 17 00:00:00 2001 From: nornen0202 Date: Tue, 7 Apr 2026 01:32:42 +0900 Subject: [PATCH 11/21] Normalize model validation inputs --- tests/test_model_validation.py | 3 +++ tradingagents/llm_clients/validators.py | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) 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/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] From 151969e377c090d22840f17cb4fb564596707656 Mon Sep 17 00:00:00 2001 From: nornen0202 Date: Tue, 7 Apr 2026 02:12:56 +0900 Subject: [PATCH 12/21] Fix scheduled news coverage and run date reporting --- tests/test_report_localization.py | 4 + tests/test_scheduled_analysis.py | 6 +- tests/test_yfinance_news.py | 68 +++++++++ tradingagents/agents/analysts/news_analyst.py | 2 +- .../agents/analysts/social_media_analyst.py | 2 +- tradingagents/agents/utils/agent_states.py | 1 + tradingagents/dataflows/yfinance_news.py | 143 +++++++++++++++--- tradingagents/graph/propagation.py | 6 +- tradingagents/graph/trading_graph.py | 5 +- tradingagents/reporting.py | 17 ++- tradingagents/scheduled/runner.py | 12 +- tradingagents/scheduled/site.py | 5 +- 12 files changed, 237 insertions(+), 34 deletions(-) create mode 100644 tests/test_yfinance_news.py diff --git a/tests/test_report_localization.py b/tests/test_report_localization.py index 6782e31b..7f0274e1 100644 --- a/tests/test_report_localization.py +++ b/tests/test_report_localization.py @@ -10,6 +10,8 @@ from tradingagents.reporting import save_report_bundle class ReportLocalizationTests(unittest.TestCase): def test_save_report_bundle_uses_korean_labels(self): final_state = { + "analysis_date": "2026-04-06", + "trade_date": "2026-04-02", "market_report": "시장 보고서 본문", "sentiment_report": "소셜 보고서 본문", "news_report": "뉴스 보고서 본문", @@ -39,6 +41,8 @@ class ReportLocalizationTests(unittest.TestCase): self.assertIn("트레이딩 분석 리포트", report_text) self.assertIn("생성 시각", report_text) + self.assertIn("분석 기준일: 2026-04-06", report_text) + self.assertIn("시장 데이터 기준일: 2026-04-02", report_text) self.assertIn("애널리스트 팀 리포트", report_text) self.assertIn("포트폴리오 매니저 최종 판단", report_text) self.assertIn("시장 애널리스트", report_text) diff --git a/tests/test_scheduled_analysis.py b/tests/test_scheduled_analysis.py index 6adadcc5..ff854fca 100644 --- a/tests/test_scheduled_analysis.py +++ b/tests/test_scheduled_analysis.py @@ -24,13 +24,14 @@ class _FakeTradingAgentsGraph: self.config = config or {} self.callbacks = callbacks or [] - def propagate(self, ticker, trade_date): + def propagate(self, ticker, trade_date, analysis_date=None): if ticker == "FAIL": raise RuntimeError("synthetic failure") final_state = { "company_of_interest": ticker, "trade_date": trade_date, + "analysis_date": analysis_date or trade_date, "market_report": f"## Market\n{ticker} market analysis", "sentiment_report": f"## Sentiment\n{ticker} sentiment analysis", "news_report": f"## News\n{ticker} news analysis", @@ -104,6 +105,7 @@ subtitle = "Automated" self.assertEqual(manifest["settings"]["provider"], "codex") self.assertEqual(manifest["settings"]["deep_model"], "gpt-5.4") self.assertEqual(manifest["settings"]["quick_model"], "gpt-5.4") + self.assertEqual(manifest["tickers"][0]["analysis_date"], manifest["started_at"][:10]) run_dir = archive_dir / "runs" / manifest["started_at"][:4] / manifest["run_id"] self.assertTrue((run_dir / "run.json").exists()) @@ -118,6 +120,7 @@ subtitle = "Automated" self.assertIn("partial failure", index_html) self.assertIn("NVDA", run_html) self.assertIn("Rendered report", ticker_html) + self.assertIn("Analysis date", ticker_html) self.assertTrue((site_dir / "downloads" / manifest["run_id"] / "NVDA" / "complete_report.md").exists()) def test_main_site_only_rebuilds_from_existing_archive(self): @@ -162,6 +165,7 @@ subtitle = "Automated" { "ticker": "NVDA", "status": "success", + "analysis_date": "2026-04-05", "trade_date": "2026-04-04", "decision": "BUY", "started_at": "2026-04-05T09:13:00+09:00", diff --git a/tests/test_yfinance_news.py b/tests/test_yfinance_news.py new file mode 100644 index 00000000..9275b294 --- /dev/null +++ b/tests/test_yfinance_news.py @@ -0,0 +1,68 @@ +import unittest +from unittest.mock import patch + +from tradingagents.dataflows.yfinance_news import get_news_yfinance + + +def _article(date_value: str, title: str, link: str) -> dict: + return { + "content": { + "title": title, + "summary": f"Summary for {title}", + "provider": {"displayName": "Unit Test"}, + "canonicalUrl": {"url": link}, + "pubDate": f"{date_value}T12:00:00Z", + } + } + + +class _FakeTicker: + def __init__(self, full_news: list[dict]): + self.full_news = list(full_news) + + def get_news(self, count=20): + return self.full_news[:count] + + +class YFinanceNewsTests(unittest.TestCase): + def test_get_news_yfinance_expands_feed_depth_to_cover_requested_window(self): + recent_articles = [ + _article(f"2026-04-{day:02d}", f"Recent article {day}", f"https://example.com/recent-{day}") + for day in range(6, 2, -1) + for _ in range(15) + ] + older_articles = [ + _article("2026-04-02", "Alphabet April 2 article", "https://example.com/apr2"), + _article("2026-04-01", "Alphabet April 1 article", "https://example.com/apr1"), + ] + fake_ticker = _FakeTicker(recent_articles + older_articles) + + with ( + patch("tradingagents.dataflows.yfinance_news.yf.Ticker", return_value=fake_ticker), + patch("tradingagents.dataflows.yfinance_news.yf_retry", side_effect=lambda fn: fn()), + ): + result = get_news_yfinance("GOOGL", "2026-03-26", "2026-04-02") + + self.assertIn("Alphabet April 2 article", result) + self.assertIn("[2026-04-02]", result) + + def test_get_news_yfinance_reports_feed_coverage_when_window_is_unavailable(self): + fake_ticker = _FakeTicker( + [ + _article("2026-04-06", "Fresh article", "https://example.com/fresh"), + _article("2026-04-05", "Fresh article 2", "https://example.com/fresh-2"), + ] + ) + + with ( + patch("tradingagents.dataflows.yfinance_news.yf.Ticker", return_value=fake_ticker), + patch("tradingagents.dataflows.yfinance_news.yf_retry", side_effect=lambda fn: fn()), + ): + result = get_news_yfinance("GOOGL", "2026-03-26", "2026-04-02") + + self.assertIn("No news found for GOOGL between 2026-03-26 and 2026-04-02", result) + self.assertIn("2026-04-05 to 2026-04-06", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tradingagents/agents/analysts/news_analyst.py b/tradingagents/agents/analysts/news_analyst.py index e0fe93c5..275a498a 100644 --- a/tradingagents/agents/analysts/news_analyst.py +++ b/tradingagents/agents/analysts/news_analyst.py @@ -10,7 +10,7 @@ from tradingagents.dataflows.config import get_config def create_news_analyst(llm): def news_analyst_node(state): - current_date = state["trade_date"] + current_date = state.get("analysis_date") or state["trade_date"] instrument_context = build_instrument_context(state["company_of_interest"]) tools = [ diff --git a/tradingagents/agents/analysts/social_media_analyst.py b/tradingagents/agents/analysts/social_media_analyst.py index 34a53c46..2f97d174 100644 --- a/tradingagents/agents/analysts/social_media_analyst.py +++ b/tradingagents/agents/analysts/social_media_analyst.py @@ -5,7 +5,7 @@ from tradingagents.dataflows.config import get_config def create_social_media_analyst(llm): def social_media_analyst_node(state): - current_date = state["trade_date"] + current_date = state.get("analysis_date") or state["trade_date"] instrument_context = build_instrument_context(state["company_of_interest"]) tools = [ diff --git a/tradingagents/agents/utils/agent_states.py b/tradingagents/agents/utils/agent_states.py index 6423b936..41fcb037 100644 --- a/tradingagents/agents/utils/agent_states.py +++ b/tradingagents/agents/utils/agent_states.py @@ -46,6 +46,7 @@ class RiskDebateState(TypedDict): class AgentState(MessagesState): company_of_interest: Annotated[str, "Company that we are interested in trading"] trade_date: Annotated[str, "What date we are trading at"] + analysis_date: Annotated[str, "What date the full analysis is being generated on"] sender: Annotated[str, "Agent that sent this message"] diff --git a/tradingagents/dataflows/yfinance_news.py b/tradingagents/dataflows/yfinance_news.py index dd1046f5..a20c4034 100644 --- a/tradingagents/dataflows/yfinance_news.py +++ b/tradingagents/dataflows/yfinance_news.py @@ -1,12 +1,46 @@ """yfinance-based news data fetching functions.""" -import yfinance as yf -from datetime import datetime +from datetime import datetime, timezone + from dateutil.relativedelta import relativedelta +import yfinance as yf from .stockstats_utils import yf_retry +_TICKER_NEWS_FETCH_COUNTS = (20, 50, 100) +_MAX_FILTERED_TICKER_ARTICLES = 25 + + +def _parse_pub_date(raw_value) -> datetime | None: + """Normalize yfinance pub date values into a timezone-aware datetime.""" + if raw_value in (None, ""): + return None + + if isinstance(raw_value, datetime): + return raw_value + + if isinstance(raw_value, (int, float)): + try: + return datetime.fromtimestamp(raw_value, tz=timezone.utc) + except (OverflowError, OSError, ValueError): + return None + + if isinstance(raw_value, str): + normalized = raw_value.strip() + if not normalized: + return None + try: + return datetime.fromisoformat(normalized.replace("Z", "+00:00")) + except ValueError: + try: + return datetime.fromtimestamp(float(normalized), tz=timezone.utc) + except (OverflowError, OSError, ValueError): + return None + + return None + + def _extract_article_data(article: dict) -> dict: """Extract article data from yfinance news format (handles nested 'content' structure).""" # Handle nested content structure @@ -22,13 +56,7 @@ def _extract_article_data(article: dict) -> dict: link = url_obj.get("url", "") # Get publish date - pub_date_str = content.get("pubDate", "") - pub_date = None - if pub_date_str: - try: - pub_date = datetime.fromisoformat(pub_date_str.replace("Z", "+00:00")) - except (ValueError, AttributeError): - pass + pub_date = _parse_pub_date(content.get("pubDate", "")) return { "title": title, @@ -44,10 +72,79 @@ def _extract_article_data(article: dict) -> dict: "summary": article.get("summary", ""), "publisher": article.get("publisher", "Unknown"), "link": article.get("link", ""), - "pub_date": None, + "pub_date": _parse_pub_date(article.get("providerPublishTime")), } +def _article_identity(article: dict) -> str: + """Return a stable identity key for deduplicating news articles.""" + link = article.get("link", "").strip() + if link: + return link + + title = article.get("title", "").strip() + publisher = article.get("publisher", "").strip() + pub_date = article.get("pub_date") + stamp = pub_date.isoformat() if isinstance(pub_date, datetime) else "" + return f"{publisher}::{title}::{stamp}" + + +def _collect_ticker_news( + ticker: str, + start_dt: datetime, +) -> tuple[list[dict], datetime | None, datetime | None]: + """Fetch increasingly larger ticker feeds until the requested window is covered.""" + collected: list[dict] = [] + seen: set[str] = set() + oldest_pub_date = None + newest_pub_date = None + + for count in _TICKER_NEWS_FETCH_COUNTS: + news = yf_retry(lambda batch_size=count: yf.Ticker(ticker).get_news(count=batch_size)) + if not news: + continue + + for article in news: + data = _extract_article_data(article) + identity = _article_identity(data) + if identity in seen: + continue + seen.add(identity) + collected.append(data) + + pub_date = data.get("pub_date") + if pub_date: + if newest_pub_date is None or pub_date > newest_pub_date: + newest_pub_date = pub_date + if oldest_pub_date is None or pub_date < oldest_pub_date: + oldest_pub_date = pub_date + + if oldest_pub_date and oldest_pub_date.replace(tzinfo=None) <= start_dt: + break + if len(news) < count: + break + + collected.sort( + key=lambda article: article["pub_date"].timestamp() if article.get("pub_date") else float("-inf"), + reverse=True, + ) + return collected, oldest_pub_date, newest_pub_date + + +def _format_coverage_note(oldest_pub_date: datetime | None, newest_pub_date: datetime | None) -> str: + """Describe the yfinance coverage window when no article matches the requested range.""" + if oldest_pub_date and newest_pub_date: + return ( + "; the current yfinance ticker feed only covered " + f"{oldest_pub_date.strftime('%Y-%m-%d')} to {newest_pub_date.strftime('%Y-%m-%d')} at query time" + ) + if oldest_pub_date: + return f"; the current yfinance ticker feed only reached back to {oldest_pub_date.strftime('%Y-%m-%d')}" + if newest_pub_date: + return f"; the current yfinance ticker feed only returned articles up to {newest_pub_date.strftime('%Y-%m-%d')}" + return "" + + def get_news_yfinance( ticker: str, start_date: str, @@ -65,38 +162,40 @@ def get_news_yfinance( Formatted string containing news articles """ try: - stock = yf.Ticker(ticker) - news = yf_retry(lambda: stock.get_news(count=20)) - - if not news: - return f"No news found for {ticker}" - - # Parse date range for filtering start_dt = datetime.strptime(start_date, "%Y-%m-%d") end_dt = datetime.strptime(end_date, "%Y-%m-%d") + articles, oldest_pub_date, newest_pub_date = _collect_ticker_news(ticker, start_dt) + + if not articles: + return f"No news found for {ticker}" news_str = "" filtered_count = 0 - for article in news: - data = _extract_article_data(article) - + for data in articles: # Filter by date if publish time is available if data["pub_date"]: pub_date_naive = data["pub_date"].replace(tzinfo=None) if not (start_dt <= pub_date_naive <= end_dt + relativedelta(days=1)): continue - news_str += f"### {data['title']} (source: {data['publisher']})\n" + date_prefix = "" + if data["pub_date"]: + date_prefix = f"[{data['pub_date'].strftime('%Y-%m-%d')}] " + + news_str += f"### {date_prefix}{data['title']} (source: {data['publisher']})\n" if data["summary"]: news_str += f"{data['summary']}\n" if data["link"]: news_str += f"Link: {data['link']}\n" news_str += "\n" filtered_count += 1 + if filtered_count >= _MAX_FILTERED_TICKER_ARTICLES: + break if filtered_count == 0: - return f"No news found for {ticker} between {start_date} and {end_date}" + coverage_note = _format_coverage_note(oldest_pub_date, newest_pub_date) + return f"No news found for {ticker} between {start_date} and {end_date}{coverage_note}" return f"## {ticker} News, from {start_date} to {end_date}:\n\n{news_str}" diff --git a/tradingagents/graph/propagation.py b/tradingagents/graph/propagation.py index 0fd10c0c..a7281718 100644 --- a/tradingagents/graph/propagation.py +++ b/tradingagents/graph/propagation.py @@ -16,13 +16,17 @@ class Propagator: self.max_recur_limit = max_recur_limit def create_initial_state( - self, company_name: str, trade_date: str + self, + company_name: str, + trade_date: str, + analysis_date: str | None = None, ) -> Dict[str, Any]: """Create the initial state for the agent graph.""" return { "messages": [("human", company_name)], "company_of_interest": company_name, "trade_date": str(trade_date), + "analysis_date": str(analysis_date or trade_date), "investment_debate_state": InvestDebateState( { "bull_history": "", diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 0723ebf5..0484616b 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -202,14 +202,14 @@ class TradingAgentsGraph: ), } - def propagate(self, company_name, trade_date): + def propagate(self, company_name, trade_date, analysis_date=None): """Run the trading agents graph for a company on a specific date.""" self.ticker = company_name # Initialize state init_agent_state = self.propagator.create_initial_state( - company_name, trade_date + company_name, trade_date, analysis_date=analysis_date ) args = self.propagator.get_graph_args() @@ -245,6 +245,7 @@ class TradingAgentsGraph: self.log_states_dict[str(trade_date)] = { "company_of_interest": final_state["company_of_interest"], "trade_date": final_state["trade_date"], + "analysis_date": final_state.get("analysis_date", final_state["trade_date"]), "market_report": final_state["market_report"], "sentiment_report": final_state["sentiment_report"], "news_report": final_state["news_report"], diff --git a/tradingagents/reporting.py b/tradingagents/reporting.py index f0b829e7..4537cc92 100644 --- a/tradingagents/reporting.py +++ b/tradingagents/reporting.py @@ -19,6 +19,8 @@ def save_report_bundle( save_path = Path(save_path) save_path.mkdir(parents=True, exist_ok=True) labels = _labels_for(language) + analysis_date = _coerce_text(final_state.get("analysis_date")) + trade_date = _coerce_text(final_state.get("trade_date")) sections: list[str] = [] @@ -104,10 +106,13 @@ def save_report_bundle( f"### {labels['portfolio_manager']}\n{portfolio_decision}" ) - header = ( - f"# {labels['report_title']}: {ticker}\n\n" - f"{labels['generated_at']}: {generated_at.strftime('%Y-%m-%d %H:%M:%S')}\n\n" - ) + metadata_lines = [f"{labels['generated_at']}: {generated_at.strftime('%Y-%m-%d %H:%M:%S')}"] + if analysis_date: + metadata_lines.append(f"{labels['analysis_date']}: {analysis_date}") + if trade_date: + metadata_lines.append(f"{labels['trade_date']}: {trade_date}") + + header = f"# {labels['report_title']}: {ticker}\n\n" + "\n".join(metadata_lines) + "\n\n" complete_report = save_path / "complete_report.md" _write_text(complete_report, header + "\n\n".join(sections)) return complete_report @@ -132,6 +137,8 @@ def _labels_for(language: str) -> dict[str, str]: return { "report_title": "트레이딩 분석 리포트", "generated_at": "생성 시각", + "analysis_date": "분석 기준일", + "trade_date": "시장 데이터 기준일", "section_analysts": "I. 애널리스트 팀 리포트", "section_research": "II. 리서치 팀 판단", "section_trading": "III. 트레이딩 팀 계획", @@ -154,6 +161,8 @@ def _labels_for(language: str) -> dict[str, str]: return { "report_title": "Trading Analysis Report", "generated_at": "Generated", + "analysis_date": "Analysis date", + "trade_date": "Market data date", "section_analysts": "I. Analyst Team Reports", "section_research": "II. Research Team Decision", "section_trading": "III. Trading Team Plan", diff --git a/tradingagents/scheduled/runner.py b/tradingagents/scheduled/runner.py index 7ccb3f5f..7ed27e65 100644 --- a/tradingagents/scheduled/runner.py +++ b/tradingagents/scheduled/runner.py @@ -159,6 +159,7 @@ def _run_single_ticker( ticker_started = datetime.now(ZoneInfo(config.run.timezone)) timer_start = perf_counter() + analysis_date = ticker_started.date().isoformat() try: trade_date = resolve_trade_date(ticker, config) @@ -169,7 +170,11 @@ def _run_single_ticker( config=_graph_config(config, engine_results_dir), callbacks=[stats_handler], ) - final_state, decision = graph.propagate(ticker, trade_date) + final_state, decision = graph.propagate( + ticker, + trade_date, + analysis_date=analysis_date, + ) report_dir = ticker_dir / "report" report_file = save_report_bundle( @@ -198,6 +203,7 @@ def _run_single_ticker( "ticker": ticker, "status": "success", "trade_date": trade_date, + "analysis_date": analysis_date, "decision": str(decision), "started_at": ticker_started.isoformat(), "finished_at": datetime.now(ZoneInfo(config.run.timezone)).isoformat(), @@ -216,6 +222,7 @@ def _run_single_ticker( "ticker": ticker, "status": "success", "trade_date": trade_date, + "analysis_date": analysis_date, "decision": str(decision), "started_at": ticker_started.isoformat(), "finished_at": analysis_payload["finished_at"], @@ -232,6 +239,7 @@ def _run_single_ticker( error_payload = { "ticker": ticker, "status": "failed", + "analysis_date": analysis_date, "error": str(exc), "traceback": traceback.format_exc(), "started_at": ticker_started.isoformat(), @@ -244,6 +252,7 @@ def _run_single_ticker( return { "ticker": ticker, "status": "failed", + "analysis_date": analysis_date, "trade_date": None, "decision": None, "error": str(exc), @@ -285,6 +294,7 @@ def _serialize_final_state(final_state: dict[str, Any]) -> dict[str, Any]: return { "company_of_interest": final_state.get("company_of_interest"), "trade_date": final_state.get("trade_date"), + "analysis_date": final_state.get("analysis_date"), "market_report": final_state.get("market_report"), "sentiment_report": final_state.get("sentiment_report"), "news_report": final_state.get("news_report"), diff --git a/tradingagents/scheduled/site.py b/tradingagents/scheduled/site.py index 48261c2d..ed17ab28 100644 --- a/tradingagents/scheduled/site.py +++ b/tradingagents/scheduled/site.py @@ -163,6 +163,7 @@ def _render_run_page(manifest: dict[str, Any], settings: SiteSettings) -> str: {_escape(ticker_summary['ticker'])} {_escape(ticker_summary['status'])} +

Analysis date{_escape(ticker_summary.get('analysis_date') or '-')}

Trade date{_escape(ticker_summary.get('trade_date') or '-')}

Duration{ticker_summary.get('duration_seconds', 0):.1f}s

Decision{_escape(ticker_summary.get('decision') or ticker_summary.get('error') or '-')}

@@ -239,10 +240,12 @@ def _render_ticker_page(

Ticker report

{_escape(ticker_summary['ticker'])}

-

{_escape(ticker_summary.get('trade_date') or '-')} / {_escape(ticker_summary['status'])}

+

Analysis {_escape(ticker_summary.get('analysis_date') or '-')} / Market {_escape(ticker_summary.get('trade_date') or '-')} / {_escape(ticker_summary['status'])}

{_escape(ticker_summary['status'])}
+

Analysis date{_escape(ticker_summary.get('analysis_date') or '-')}

+

Trade date{_escape(ticker_summary.get('trade_date') or '-')}

Decision{_escape(ticker_summary.get('decision') or '-')}

Duration{ticker_summary.get('duration_seconds', 0):.1f}s

LLM calls{ticker_summary.get('metrics', {}).get('llm_calls', 0)}

From e7584b8d838905ef9b1935e53fb113680e3ea2cf Mon Sep 17 00:00:00 2001 From: nornen0202 <75664002+nornen0202@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:13:38 +0900 Subject: [PATCH 13/21] Fix scheduled news coverage and run date reporting (#10) --- tests/test_report_localization.py | 4 + tests/test_scheduled_analysis.py | 6 +- tests/test_yfinance_news.py | 68 +++++++++ tradingagents/agents/analysts/news_analyst.py | 2 +- .../agents/analysts/social_media_analyst.py | 2 +- tradingagents/agents/utils/agent_states.py | 1 + tradingagents/dataflows/yfinance_news.py | 143 +++++++++++++++--- tradingagents/graph/propagation.py | 6 +- tradingagents/graph/trading_graph.py | 5 +- tradingagents/reporting.py | 17 ++- tradingagents/scheduled/runner.py | 12 +- tradingagents/scheduled/site.py | 5 +- 12 files changed, 237 insertions(+), 34 deletions(-) create mode 100644 tests/test_yfinance_news.py diff --git a/tests/test_report_localization.py b/tests/test_report_localization.py index 6782e31b..7f0274e1 100644 --- a/tests/test_report_localization.py +++ b/tests/test_report_localization.py @@ -10,6 +10,8 @@ from tradingagents.reporting import save_report_bundle class ReportLocalizationTests(unittest.TestCase): def test_save_report_bundle_uses_korean_labels(self): final_state = { + "analysis_date": "2026-04-06", + "trade_date": "2026-04-02", "market_report": "시장 보고서 본문", "sentiment_report": "소셜 보고서 본문", "news_report": "뉴스 보고서 본문", @@ -39,6 +41,8 @@ class ReportLocalizationTests(unittest.TestCase): self.assertIn("트레이딩 분석 리포트", report_text) self.assertIn("생성 시각", report_text) + self.assertIn("분석 기준일: 2026-04-06", report_text) + self.assertIn("시장 데이터 기준일: 2026-04-02", report_text) self.assertIn("애널리스트 팀 리포트", report_text) self.assertIn("포트폴리오 매니저 최종 판단", report_text) self.assertIn("시장 애널리스트", report_text) diff --git a/tests/test_scheduled_analysis.py b/tests/test_scheduled_analysis.py index 6adadcc5..ff854fca 100644 --- a/tests/test_scheduled_analysis.py +++ b/tests/test_scheduled_analysis.py @@ -24,13 +24,14 @@ class _FakeTradingAgentsGraph: self.config = config or {} self.callbacks = callbacks or [] - def propagate(self, ticker, trade_date): + def propagate(self, ticker, trade_date, analysis_date=None): if ticker == "FAIL": raise RuntimeError("synthetic failure") final_state = { "company_of_interest": ticker, "trade_date": trade_date, + "analysis_date": analysis_date or trade_date, "market_report": f"## Market\n{ticker} market analysis", "sentiment_report": f"## Sentiment\n{ticker} sentiment analysis", "news_report": f"## News\n{ticker} news analysis", @@ -104,6 +105,7 @@ subtitle = "Automated" self.assertEqual(manifest["settings"]["provider"], "codex") self.assertEqual(manifest["settings"]["deep_model"], "gpt-5.4") self.assertEqual(manifest["settings"]["quick_model"], "gpt-5.4") + self.assertEqual(manifest["tickers"][0]["analysis_date"], manifest["started_at"][:10]) run_dir = archive_dir / "runs" / manifest["started_at"][:4] / manifest["run_id"] self.assertTrue((run_dir / "run.json").exists()) @@ -118,6 +120,7 @@ subtitle = "Automated" self.assertIn("partial failure", index_html) self.assertIn("NVDA", run_html) self.assertIn("Rendered report", ticker_html) + self.assertIn("Analysis date", ticker_html) self.assertTrue((site_dir / "downloads" / manifest["run_id"] / "NVDA" / "complete_report.md").exists()) def test_main_site_only_rebuilds_from_existing_archive(self): @@ -162,6 +165,7 @@ subtitle = "Automated" { "ticker": "NVDA", "status": "success", + "analysis_date": "2026-04-05", "trade_date": "2026-04-04", "decision": "BUY", "started_at": "2026-04-05T09:13:00+09:00", diff --git a/tests/test_yfinance_news.py b/tests/test_yfinance_news.py new file mode 100644 index 00000000..9275b294 --- /dev/null +++ b/tests/test_yfinance_news.py @@ -0,0 +1,68 @@ +import unittest +from unittest.mock import patch + +from tradingagents.dataflows.yfinance_news import get_news_yfinance + + +def _article(date_value: str, title: str, link: str) -> dict: + return { + "content": { + "title": title, + "summary": f"Summary for {title}", + "provider": {"displayName": "Unit Test"}, + "canonicalUrl": {"url": link}, + "pubDate": f"{date_value}T12:00:00Z", + } + } + + +class _FakeTicker: + def __init__(self, full_news: list[dict]): + self.full_news = list(full_news) + + def get_news(self, count=20): + return self.full_news[:count] + + +class YFinanceNewsTests(unittest.TestCase): + def test_get_news_yfinance_expands_feed_depth_to_cover_requested_window(self): + recent_articles = [ + _article(f"2026-04-{day:02d}", f"Recent article {day}", f"https://example.com/recent-{day}") + for day in range(6, 2, -1) + for _ in range(15) + ] + older_articles = [ + _article("2026-04-02", "Alphabet April 2 article", "https://example.com/apr2"), + _article("2026-04-01", "Alphabet April 1 article", "https://example.com/apr1"), + ] + fake_ticker = _FakeTicker(recent_articles + older_articles) + + with ( + patch("tradingagents.dataflows.yfinance_news.yf.Ticker", return_value=fake_ticker), + patch("tradingagents.dataflows.yfinance_news.yf_retry", side_effect=lambda fn: fn()), + ): + result = get_news_yfinance("GOOGL", "2026-03-26", "2026-04-02") + + self.assertIn("Alphabet April 2 article", result) + self.assertIn("[2026-04-02]", result) + + def test_get_news_yfinance_reports_feed_coverage_when_window_is_unavailable(self): + fake_ticker = _FakeTicker( + [ + _article("2026-04-06", "Fresh article", "https://example.com/fresh"), + _article("2026-04-05", "Fresh article 2", "https://example.com/fresh-2"), + ] + ) + + with ( + patch("tradingagents.dataflows.yfinance_news.yf.Ticker", return_value=fake_ticker), + patch("tradingagents.dataflows.yfinance_news.yf_retry", side_effect=lambda fn: fn()), + ): + result = get_news_yfinance("GOOGL", "2026-03-26", "2026-04-02") + + self.assertIn("No news found for GOOGL between 2026-03-26 and 2026-04-02", result) + self.assertIn("2026-04-05 to 2026-04-06", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tradingagents/agents/analysts/news_analyst.py b/tradingagents/agents/analysts/news_analyst.py index e0fe93c5..275a498a 100644 --- a/tradingagents/agents/analysts/news_analyst.py +++ b/tradingagents/agents/analysts/news_analyst.py @@ -10,7 +10,7 @@ from tradingagents.dataflows.config import get_config def create_news_analyst(llm): def news_analyst_node(state): - current_date = state["trade_date"] + current_date = state.get("analysis_date") or state["trade_date"] instrument_context = build_instrument_context(state["company_of_interest"]) tools = [ diff --git a/tradingagents/agents/analysts/social_media_analyst.py b/tradingagents/agents/analysts/social_media_analyst.py index 34a53c46..2f97d174 100644 --- a/tradingagents/agents/analysts/social_media_analyst.py +++ b/tradingagents/agents/analysts/social_media_analyst.py @@ -5,7 +5,7 @@ from tradingagents.dataflows.config import get_config def create_social_media_analyst(llm): def social_media_analyst_node(state): - current_date = state["trade_date"] + current_date = state.get("analysis_date") or state["trade_date"] instrument_context = build_instrument_context(state["company_of_interest"]) tools = [ diff --git a/tradingagents/agents/utils/agent_states.py b/tradingagents/agents/utils/agent_states.py index 6423b936..41fcb037 100644 --- a/tradingagents/agents/utils/agent_states.py +++ b/tradingagents/agents/utils/agent_states.py @@ -46,6 +46,7 @@ class RiskDebateState(TypedDict): class AgentState(MessagesState): company_of_interest: Annotated[str, "Company that we are interested in trading"] trade_date: Annotated[str, "What date we are trading at"] + analysis_date: Annotated[str, "What date the full analysis is being generated on"] sender: Annotated[str, "Agent that sent this message"] diff --git a/tradingagents/dataflows/yfinance_news.py b/tradingagents/dataflows/yfinance_news.py index dd1046f5..a20c4034 100644 --- a/tradingagents/dataflows/yfinance_news.py +++ b/tradingagents/dataflows/yfinance_news.py @@ -1,12 +1,46 @@ """yfinance-based news data fetching functions.""" -import yfinance as yf -from datetime import datetime +from datetime import datetime, timezone + from dateutil.relativedelta import relativedelta +import yfinance as yf from .stockstats_utils import yf_retry +_TICKER_NEWS_FETCH_COUNTS = (20, 50, 100) +_MAX_FILTERED_TICKER_ARTICLES = 25 + + +def _parse_pub_date(raw_value) -> datetime | None: + """Normalize yfinance pub date values into a timezone-aware datetime.""" + if raw_value in (None, ""): + return None + + if isinstance(raw_value, datetime): + return raw_value + + if isinstance(raw_value, (int, float)): + try: + return datetime.fromtimestamp(raw_value, tz=timezone.utc) + except (OverflowError, OSError, ValueError): + return None + + if isinstance(raw_value, str): + normalized = raw_value.strip() + if not normalized: + return None + try: + return datetime.fromisoformat(normalized.replace("Z", "+00:00")) + except ValueError: + try: + return datetime.fromtimestamp(float(normalized), tz=timezone.utc) + except (OverflowError, OSError, ValueError): + return None + + return None + + def _extract_article_data(article: dict) -> dict: """Extract article data from yfinance news format (handles nested 'content' structure).""" # Handle nested content structure @@ -22,13 +56,7 @@ def _extract_article_data(article: dict) -> dict: link = url_obj.get("url", "") # Get publish date - pub_date_str = content.get("pubDate", "") - pub_date = None - if pub_date_str: - try: - pub_date = datetime.fromisoformat(pub_date_str.replace("Z", "+00:00")) - except (ValueError, AttributeError): - pass + pub_date = _parse_pub_date(content.get("pubDate", "")) return { "title": title, @@ -44,10 +72,79 @@ def _extract_article_data(article: dict) -> dict: "summary": article.get("summary", ""), "publisher": article.get("publisher", "Unknown"), "link": article.get("link", ""), - "pub_date": None, + "pub_date": _parse_pub_date(article.get("providerPublishTime")), } +def _article_identity(article: dict) -> str: + """Return a stable identity key for deduplicating news articles.""" + link = article.get("link", "").strip() + if link: + return link + + title = article.get("title", "").strip() + publisher = article.get("publisher", "").strip() + pub_date = article.get("pub_date") + stamp = pub_date.isoformat() if isinstance(pub_date, datetime) else "" + return f"{publisher}::{title}::{stamp}" + + +def _collect_ticker_news( + ticker: str, + start_dt: datetime, +) -> tuple[list[dict], datetime | None, datetime | None]: + """Fetch increasingly larger ticker feeds until the requested window is covered.""" + collected: list[dict] = [] + seen: set[str] = set() + oldest_pub_date = None + newest_pub_date = None + + for count in _TICKER_NEWS_FETCH_COUNTS: + news = yf_retry(lambda batch_size=count: yf.Ticker(ticker).get_news(count=batch_size)) + if not news: + continue + + for article in news: + data = _extract_article_data(article) + identity = _article_identity(data) + if identity in seen: + continue + seen.add(identity) + collected.append(data) + + pub_date = data.get("pub_date") + if pub_date: + if newest_pub_date is None or pub_date > newest_pub_date: + newest_pub_date = pub_date + if oldest_pub_date is None or pub_date < oldest_pub_date: + oldest_pub_date = pub_date + + if oldest_pub_date and oldest_pub_date.replace(tzinfo=None) <= start_dt: + break + if len(news) < count: + break + + collected.sort( + key=lambda article: article["pub_date"].timestamp() if article.get("pub_date") else float("-inf"), + reverse=True, + ) + return collected, oldest_pub_date, newest_pub_date + + +def _format_coverage_note(oldest_pub_date: datetime | None, newest_pub_date: datetime | None) -> str: + """Describe the yfinance coverage window when no article matches the requested range.""" + if oldest_pub_date and newest_pub_date: + return ( + "; the current yfinance ticker feed only covered " + f"{oldest_pub_date.strftime('%Y-%m-%d')} to {newest_pub_date.strftime('%Y-%m-%d')} at query time" + ) + if oldest_pub_date: + return f"; the current yfinance ticker feed only reached back to {oldest_pub_date.strftime('%Y-%m-%d')}" + if newest_pub_date: + return f"; the current yfinance ticker feed only returned articles up to {newest_pub_date.strftime('%Y-%m-%d')}" + return "" + + def get_news_yfinance( ticker: str, start_date: str, @@ -65,38 +162,40 @@ def get_news_yfinance( Formatted string containing news articles """ try: - stock = yf.Ticker(ticker) - news = yf_retry(lambda: stock.get_news(count=20)) - - if not news: - return f"No news found for {ticker}" - - # Parse date range for filtering start_dt = datetime.strptime(start_date, "%Y-%m-%d") end_dt = datetime.strptime(end_date, "%Y-%m-%d") + articles, oldest_pub_date, newest_pub_date = _collect_ticker_news(ticker, start_dt) + + if not articles: + return f"No news found for {ticker}" news_str = "" filtered_count = 0 - for article in news: - data = _extract_article_data(article) - + for data in articles: # Filter by date if publish time is available if data["pub_date"]: pub_date_naive = data["pub_date"].replace(tzinfo=None) if not (start_dt <= pub_date_naive <= end_dt + relativedelta(days=1)): continue - news_str += f"### {data['title']} (source: {data['publisher']})\n" + date_prefix = "" + if data["pub_date"]: + date_prefix = f"[{data['pub_date'].strftime('%Y-%m-%d')}] " + + news_str += f"### {date_prefix}{data['title']} (source: {data['publisher']})\n" if data["summary"]: news_str += f"{data['summary']}\n" if data["link"]: news_str += f"Link: {data['link']}\n" news_str += "\n" filtered_count += 1 + if filtered_count >= _MAX_FILTERED_TICKER_ARTICLES: + break if filtered_count == 0: - return f"No news found for {ticker} between {start_date} and {end_date}" + coverage_note = _format_coverage_note(oldest_pub_date, newest_pub_date) + return f"No news found for {ticker} between {start_date} and {end_date}{coverage_note}" return f"## {ticker} News, from {start_date} to {end_date}:\n\n{news_str}" diff --git a/tradingagents/graph/propagation.py b/tradingagents/graph/propagation.py index 0fd10c0c..a7281718 100644 --- a/tradingagents/graph/propagation.py +++ b/tradingagents/graph/propagation.py @@ -16,13 +16,17 @@ class Propagator: self.max_recur_limit = max_recur_limit def create_initial_state( - self, company_name: str, trade_date: str + self, + company_name: str, + trade_date: str, + analysis_date: str | None = None, ) -> Dict[str, Any]: """Create the initial state for the agent graph.""" return { "messages": [("human", company_name)], "company_of_interest": company_name, "trade_date": str(trade_date), + "analysis_date": str(analysis_date or trade_date), "investment_debate_state": InvestDebateState( { "bull_history": "", diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 0723ebf5..0484616b 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -202,14 +202,14 @@ class TradingAgentsGraph: ), } - def propagate(self, company_name, trade_date): + def propagate(self, company_name, trade_date, analysis_date=None): """Run the trading agents graph for a company on a specific date.""" self.ticker = company_name # Initialize state init_agent_state = self.propagator.create_initial_state( - company_name, trade_date + company_name, trade_date, analysis_date=analysis_date ) args = self.propagator.get_graph_args() @@ -245,6 +245,7 @@ class TradingAgentsGraph: self.log_states_dict[str(trade_date)] = { "company_of_interest": final_state["company_of_interest"], "trade_date": final_state["trade_date"], + "analysis_date": final_state.get("analysis_date", final_state["trade_date"]), "market_report": final_state["market_report"], "sentiment_report": final_state["sentiment_report"], "news_report": final_state["news_report"], diff --git a/tradingagents/reporting.py b/tradingagents/reporting.py index f0b829e7..4537cc92 100644 --- a/tradingagents/reporting.py +++ b/tradingagents/reporting.py @@ -19,6 +19,8 @@ def save_report_bundle( save_path = Path(save_path) save_path.mkdir(parents=True, exist_ok=True) labels = _labels_for(language) + analysis_date = _coerce_text(final_state.get("analysis_date")) + trade_date = _coerce_text(final_state.get("trade_date")) sections: list[str] = [] @@ -104,10 +106,13 @@ def save_report_bundle( f"### {labels['portfolio_manager']}\n{portfolio_decision}" ) - header = ( - f"# {labels['report_title']}: {ticker}\n\n" - f"{labels['generated_at']}: {generated_at.strftime('%Y-%m-%d %H:%M:%S')}\n\n" - ) + metadata_lines = [f"{labels['generated_at']}: {generated_at.strftime('%Y-%m-%d %H:%M:%S')}"] + if analysis_date: + metadata_lines.append(f"{labels['analysis_date']}: {analysis_date}") + if trade_date: + metadata_lines.append(f"{labels['trade_date']}: {trade_date}") + + header = f"# {labels['report_title']}: {ticker}\n\n" + "\n".join(metadata_lines) + "\n\n" complete_report = save_path / "complete_report.md" _write_text(complete_report, header + "\n\n".join(sections)) return complete_report @@ -132,6 +137,8 @@ def _labels_for(language: str) -> dict[str, str]: return { "report_title": "트레이딩 분석 리포트", "generated_at": "생성 시각", + "analysis_date": "분석 기준일", + "trade_date": "시장 데이터 기준일", "section_analysts": "I. 애널리스트 팀 리포트", "section_research": "II. 리서치 팀 판단", "section_trading": "III. 트레이딩 팀 계획", @@ -154,6 +161,8 @@ def _labels_for(language: str) -> dict[str, str]: return { "report_title": "Trading Analysis Report", "generated_at": "Generated", + "analysis_date": "Analysis date", + "trade_date": "Market data date", "section_analysts": "I. Analyst Team Reports", "section_research": "II. Research Team Decision", "section_trading": "III. Trading Team Plan", diff --git a/tradingagents/scheduled/runner.py b/tradingagents/scheduled/runner.py index 7ccb3f5f..7ed27e65 100644 --- a/tradingagents/scheduled/runner.py +++ b/tradingagents/scheduled/runner.py @@ -159,6 +159,7 @@ def _run_single_ticker( ticker_started = datetime.now(ZoneInfo(config.run.timezone)) timer_start = perf_counter() + analysis_date = ticker_started.date().isoformat() try: trade_date = resolve_trade_date(ticker, config) @@ -169,7 +170,11 @@ def _run_single_ticker( config=_graph_config(config, engine_results_dir), callbacks=[stats_handler], ) - final_state, decision = graph.propagate(ticker, trade_date) + final_state, decision = graph.propagate( + ticker, + trade_date, + analysis_date=analysis_date, + ) report_dir = ticker_dir / "report" report_file = save_report_bundle( @@ -198,6 +203,7 @@ def _run_single_ticker( "ticker": ticker, "status": "success", "trade_date": trade_date, + "analysis_date": analysis_date, "decision": str(decision), "started_at": ticker_started.isoformat(), "finished_at": datetime.now(ZoneInfo(config.run.timezone)).isoformat(), @@ -216,6 +222,7 @@ def _run_single_ticker( "ticker": ticker, "status": "success", "trade_date": trade_date, + "analysis_date": analysis_date, "decision": str(decision), "started_at": ticker_started.isoformat(), "finished_at": analysis_payload["finished_at"], @@ -232,6 +239,7 @@ def _run_single_ticker( error_payload = { "ticker": ticker, "status": "failed", + "analysis_date": analysis_date, "error": str(exc), "traceback": traceback.format_exc(), "started_at": ticker_started.isoformat(), @@ -244,6 +252,7 @@ def _run_single_ticker( return { "ticker": ticker, "status": "failed", + "analysis_date": analysis_date, "trade_date": None, "decision": None, "error": str(exc), @@ -285,6 +294,7 @@ def _serialize_final_state(final_state: dict[str, Any]) -> dict[str, Any]: return { "company_of_interest": final_state.get("company_of_interest"), "trade_date": final_state.get("trade_date"), + "analysis_date": final_state.get("analysis_date"), "market_report": final_state.get("market_report"), "sentiment_report": final_state.get("sentiment_report"), "news_report": final_state.get("news_report"), diff --git a/tradingagents/scheduled/site.py b/tradingagents/scheduled/site.py index 48261c2d..ed17ab28 100644 --- a/tradingagents/scheduled/site.py +++ b/tradingagents/scheduled/site.py @@ -163,6 +163,7 @@ def _render_run_page(manifest: dict[str, Any], settings: SiteSettings) -> str: {_escape(ticker_summary['ticker'])} {_escape(ticker_summary['status'])}
+

Analysis date{_escape(ticker_summary.get('analysis_date') or '-')}

Trade date{_escape(ticker_summary.get('trade_date') or '-')}

Duration{ticker_summary.get('duration_seconds', 0):.1f}s

Decision{_escape(ticker_summary.get('decision') or ticker_summary.get('error') or '-')}

@@ -239,10 +240,12 @@ def _render_ticker_page(

Ticker report

{_escape(ticker_summary['ticker'])}

-

{_escape(ticker_summary.get('trade_date') or '-')} / {_escape(ticker_summary['status'])}

+

Analysis {_escape(ticker_summary.get('analysis_date') or '-')} / Market {_escape(ticker_summary.get('trade_date') or '-')} / {_escape(ticker_summary['status'])}

{_escape(ticker_summary['status'])}
+

Analysis date{_escape(ticker_summary.get('analysis_date') or '-')}

+

Trade date{_escape(ticker_summary.get('trade_date') or '-')}

Decision{_escape(ticker_summary.get('decision') or '-')}

Duration{ticker_summary.get('duration_seconds', 0):.1f}s

LLM calls{ticker_summary.get('metrics', {}).get('llm_calls', 0)}

From e40242cc394039403fbb05d9502fa4ccea56a9b6 Mon Sep 17 00:00:00 2001 From: nornen0202 Date: Tue, 7 Apr 2026 20:50:07 +0900 Subject: [PATCH 14/21] Harden Windows Actions shell execution --- .github/workflows/daily-codex-analysis.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/daily-codex-analysis.yml b/.github/workflows/daily-codex-analysis.yml index 325b5d74..433a8963 100644 --- a/.github/workflows/daily-codex-analysis.yml +++ b/.github/workflows/daily-codex-analysis.yml @@ -33,6 +33,9 @@ jobs: analyze: runs-on: [self-hosted, Windows] timeout-minutes: 240 + defaults: + run: + shell: powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File {0} env: PYTHONUTF8: "1" PIP_DISABLE_PIP_VERSION_CHECK: "1" @@ -51,13 +54,13 @@ jobs: python-version: "3.13" - name: Install TradingAgents - shell: powershell run: | python -m pip install --upgrade pip + if ($LASTEXITCODE) { exit $LASTEXITCODE } python -m pip install -e . + if ($LASTEXITCODE) { exit $LASTEXITCODE } - name: Verify Codex login and model availability - shell: powershell run: | $workspaceDir = Join-Path $env:GITHUB_WORKSPACE ".codex-preflight" $script = @( @@ -73,9 +76,9 @@ jobs: "print('First available models:', ', '.join(result.models[:8]))" ) -join "`n" $script | python - + if ($LASTEXITCODE) { exit $LASTEXITCODE } - name: Run scheduled analysis and build site - shell: powershell run: | $configPath = "config/scheduled_analysis.toml" if (-not (Test-Path $configPath)) { @@ -106,9 +109,9 @@ jobs: } python @args + if ($LASTEXITCODE) { exit $LASTEXITCODE } - name: Add Git Bash to PATH for Pages packaging - shell: powershell run: | $gitBinCandidates = @( "C:\Program Files\Git\bin", From f1f6a2d1ca58114963eab447fc0a54abbc279502 Mon Sep 17 00:00:00 2001 From: nornen0202 <75664002+nornen0202@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:50:40 +0900 Subject: [PATCH 15/21] Harden Windows Actions shell execution (#11) * Fix scheduled news coverage and run date reporting * Harden Windows Actions shell execution --- .github/workflows/daily-codex-analysis.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/daily-codex-analysis.yml b/.github/workflows/daily-codex-analysis.yml index 325b5d74..433a8963 100644 --- a/.github/workflows/daily-codex-analysis.yml +++ b/.github/workflows/daily-codex-analysis.yml @@ -33,6 +33,9 @@ jobs: analyze: runs-on: [self-hosted, Windows] timeout-minutes: 240 + defaults: + run: + shell: powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File {0} env: PYTHONUTF8: "1" PIP_DISABLE_PIP_VERSION_CHECK: "1" @@ -51,13 +54,13 @@ jobs: python-version: "3.13" - name: Install TradingAgents - shell: powershell run: | python -m pip install --upgrade pip + if ($LASTEXITCODE) { exit $LASTEXITCODE } python -m pip install -e . + if ($LASTEXITCODE) { exit $LASTEXITCODE } - name: Verify Codex login and model availability - shell: powershell run: | $workspaceDir = Join-Path $env:GITHUB_WORKSPACE ".codex-preflight" $script = @( @@ -73,9 +76,9 @@ jobs: "print('First available models:', ', '.join(result.models[:8]))" ) -join "`n" $script | python - + if ($LASTEXITCODE) { exit $LASTEXITCODE } - name: Run scheduled analysis and build site - shell: powershell run: | $configPath = "config/scheduled_analysis.toml" if (-not (Test-Path $configPath)) { @@ -106,9 +109,9 @@ jobs: } python @args + if ($LASTEXITCODE) { exit $LASTEXITCODE } - name: Add Git Bash to PATH for Pages packaging - shell: powershell run: | $gitBinCandidates = @( "C:\Program Files\Git\bin", From b054cccc531eb79c0420a0cd47152f9491c2f7f6 Mon Sep 17 00:00:00 2001 From: nornen0202 Date: Tue, 7 Apr 2026 20:59:17 +0900 Subject: [PATCH 16/21] Stabilize Codex runtime for Windows runner --- .github/workflows/daily-codex-analysis.yml | 63 ++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/.github/workflows/daily-codex-analysis.yml b/.github/workflows/daily-codex-analysis.yml index 433a8963..ef955a13 100644 --- a/.github/workflows/daily-codex-analysis.yml +++ b/.github/workflows/daily-codex-analysis.yml @@ -41,6 +41,8 @@ jobs: PIP_DISABLE_PIP_VERSION_CHECK: "1" TRADINGAGENTS_SITE_DIR: ${{ github.workspace }}\site TRADINGAGENTS_ARCHIVE_DIR: ${{ vars.TRADINGAGENTS_ARCHIVE_DIR }} + CODEX_BINARY: ${{ vars.CODEX_BINARY }} + CODEX_HOME: ${{ vars.CODEX_HOME }} steps: - name: Check out repository uses: actions/checkout@v4 @@ -60,7 +62,68 @@ jobs: python -m pip install -e . if ($LASTEXITCODE) { exit $LASTEXITCODE } + - name: Resolve Codex runtime + if: ${{ github.event.inputs.site_only != 'true' }} + run: | + function Test-CodexCandidate { + param([string]$Candidate) + + if ([string]::IsNullOrWhiteSpace($Candidate) -or -not (Test-Path $Candidate)) { + return $false + } + + try { + & $Candidate --version | Out-Null + return ($LASTEXITCODE -eq 0) + } catch { + Write-Warning "Codex candidate failed: $Candidate :: $($_.Exception.Message)" + return $false + } + } + + $candidates = [System.Collections.Generic.List[string]]::new() + + if (-not [string]::IsNullOrWhiteSpace($env:CODEX_BINARY)) { + $candidates.Add($env:CODEX_BINARY) + } + + $candidates.Add((Join-Path $env:USERPROFILE ".codex\.sandbox-bin\codex.exe")) + + Get-ChildItem -Path "C:\Users\*\.codex\.sandbox-bin\codex.exe" -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + ForEach-Object { $candidates.Add($_.FullName) } + + Get-ChildItem -Path "C:\Users\*\.vscode\extensions\openai.chatgpt-*\bin\windows-x86_64\codex.exe" -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + ForEach-Object { $candidates.Add($_.FullName) } + + $resolvedBinary = $null + foreach ($candidate in $candidates) { + if (Test-CodexCandidate $candidate) { + $resolvedBinary = $candidate + break + } + } + + if (-not $resolvedBinary) { + throw "Could not find a usable Codex binary. Set the CODEX_BINARY repository variable or install Codex for the runner service account." + } + + Add-Content -Path $env:GITHUB_ENV -Value "CODEX_BINARY=$resolvedBinary" + Write-Host "Resolved Codex binary: $resolvedBinary" + + $resolvedHome = $env:CODEX_HOME + if ([string]::IsNullOrWhiteSpace($resolvedHome) -and $resolvedBinary -like "*.codex\.sandbox-bin\codex.exe") { + $resolvedHome = Split-Path (Split-Path $resolvedBinary -Parent) -Parent + } + + if (-not [string]::IsNullOrWhiteSpace($resolvedHome)) { + Add-Content -Path $env:GITHUB_ENV -Value "CODEX_HOME=$resolvedHome" + Write-Host "Using CODEX_HOME: $resolvedHome" + } + - name: Verify Codex login and model availability + if: ${{ github.event.inputs.site_only != 'true' }} run: | $workspaceDir = Join-Path $env:GITHUB_WORKSPACE ".codex-preflight" $script = @( From 5d45c191b7c9056cd4e1767304455f79da4bac08 Mon Sep 17 00:00:00 2001 From: nornen0202 Date: Tue, 7 Apr 2026 21:23:59 +0900 Subject: [PATCH 17/21] Report Codex auth status in Actions --- .github/workflows/daily-codex-analysis.yml | 80 ++++++++++++++++++---- 1 file changed, 68 insertions(+), 12 deletions(-) diff --git a/.github/workflows/daily-codex-analysis.yml b/.github/workflows/daily-codex-analysis.yml index ef955a13..96787cca 100644 --- a/.github/workflows/daily-codex-analysis.yml +++ b/.github/workflows/daily-codex-analysis.yml @@ -126,18 +126,74 @@ jobs: if: ${{ github.event.inputs.site_only != 'true' }} run: | $workspaceDir = Join-Path $env:GITHUB_WORKSPACE ".codex-preflight" - $script = @( - "from tradingagents.llm_clients.codex_preflight import run_codex_preflight", - "result = run_codex_preflight(", - " codex_binary=None,", - " model='gpt-5.4',", - " request_timeout=30.0,", - " workspace_dir=r'$workspaceDir',", - " cleanup_threads=True,", - ")", - "print('Codex account:', result.account)", - "print('First available models:', ', '.join(result.models[:8]))" - ) -join "`n" + $script = @" +import os +from tradingagents.llm_clients.codex_app_server import CodexAppServerAuthError, CodexAppServerBinaryError +from tradingagents.llm_clients.codex_preflight import run_codex_preflight + +workspace_dir = r"$workspaceDir" +summary_path = os.getenv("GITHUB_STEP_SUMMARY") + +def write_summary(lines): + if not summary_path: + return + with open(summary_path, "a", encoding="utf-8") as handle: + handle.write("\n".join(lines) + "\n") + +try: + result = run_codex_preflight( + codex_binary=None, + model="gpt-5.4", + request_timeout=30.0, + workspace_dir=workspace_dir, + cleanup_threads=True, + ) +except CodexAppServerAuthError as exc: + message = ( + "Codex is installed but not logged in for the runner. " + "Run `codex login` or `codex login --device-auth` on the runner machine, " + "then retry the workflow." + ) + print(f"::error::{message}") + print(exc) + write_summary( + [ + "## Codex login required", + "", + message, + ] + ) + raise SystemExit(1) +except CodexAppServerBinaryError as exc: + message = ( + "A usable Codex binary is not available for the runner. " + "Check the `CODEX_BINARY` repository variable or install Codex for the runner service account." + ) + print(f"::error::{message}") + print(exc) + write_summary( + [ + "## Codex runtime issue", + "", + message, + "", + str(exc), + ] + ) + raise SystemExit(1) + +print("Codex account:", result.account) +print("First available models:", ", ".join(result.models[:8])) +write_summary( + [ + "## Codex preflight passed", + "", + f"- Account: {result.account}", + f"- Models: {', '.join(result.models[:8])}", + f"- Binary: {os.getenv('CODEX_BINARY', '(auto)')}", + ] +) +"@ $script | python - if ($LASTEXITCODE) { exit $LASTEXITCODE } From 9a863a0336adcf7b4717ba02616ccc52c5065392 Mon Sep 17 00:00:00 2001 From: nornen0202 <75664002+nornen0202@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:26:04 +0900 Subject: [PATCH 18/21] Improve Windows Codex runner diagnostics (#12) * Fix scheduled news coverage and run date reporting * Harden Windows Actions shell execution * Stabilize Codex runtime for Windows runner * Report Codex auth status in Actions --- .github/workflows/daily-codex-analysis.yml | 143 +++++++++++++++++++-- 1 file changed, 131 insertions(+), 12 deletions(-) diff --git a/.github/workflows/daily-codex-analysis.yml b/.github/workflows/daily-codex-analysis.yml index 433a8963..96787cca 100644 --- a/.github/workflows/daily-codex-analysis.yml +++ b/.github/workflows/daily-codex-analysis.yml @@ -41,6 +41,8 @@ jobs: PIP_DISABLE_PIP_VERSION_CHECK: "1" TRADINGAGENTS_SITE_DIR: ${{ github.workspace }}\site TRADINGAGENTS_ARCHIVE_DIR: ${{ vars.TRADINGAGENTS_ARCHIVE_DIR }} + CODEX_BINARY: ${{ vars.CODEX_BINARY }} + CODEX_HOME: ${{ vars.CODEX_HOME }} steps: - name: Check out repository uses: actions/checkout@v4 @@ -60,21 +62,138 @@ jobs: python -m pip install -e . if ($LASTEXITCODE) { exit $LASTEXITCODE } + - name: Resolve Codex runtime + if: ${{ github.event.inputs.site_only != 'true' }} + run: | + function Test-CodexCandidate { + param([string]$Candidate) + + if ([string]::IsNullOrWhiteSpace($Candidate) -or -not (Test-Path $Candidate)) { + return $false + } + + try { + & $Candidate --version | Out-Null + return ($LASTEXITCODE -eq 0) + } catch { + Write-Warning "Codex candidate failed: $Candidate :: $($_.Exception.Message)" + return $false + } + } + + $candidates = [System.Collections.Generic.List[string]]::new() + + if (-not [string]::IsNullOrWhiteSpace($env:CODEX_BINARY)) { + $candidates.Add($env:CODEX_BINARY) + } + + $candidates.Add((Join-Path $env:USERPROFILE ".codex\.sandbox-bin\codex.exe")) + + Get-ChildItem -Path "C:\Users\*\.codex\.sandbox-bin\codex.exe" -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + ForEach-Object { $candidates.Add($_.FullName) } + + Get-ChildItem -Path "C:\Users\*\.vscode\extensions\openai.chatgpt-*\bin\windows-x86_64\codex.exe" -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + ForEach-Object { $candidates.Add($_.FullName) } + + $resolvedBinary = $null + foreach ($candidate in $candidates) { + if (Test-CodexCandidate $candidate) { + $resolvedBinary = $candidate + break + } + } + + if (-not $resolvedBinary) { + throw "Could not find a usable Codex binary. Set the CODEX_BINARY repository variable or install Codex for the runner service account." + } + + Add-Content -Path $env:GITHUB_ENV -Value "CODEX_BINARY=$resolvedBinary" + Write-Host "Resolved Codex binary: $resolvedBinary" + + $resolvedHome = $env:CODEX_HOME + if ([string]::IsNullOrWhiteSpace($resolvedHome) -and $resolvedBinary -like "*.codex\.sandbox-bin\codex.exe") { + $resolvedHome = Split-Path (Split-Path $resolvedBinary -Parent) -Parent + } + + if (-not [string]::IsNullOrWhiteSpace($resolvedHome)) { + Add-Content -Path $env:GITHUB_ENV -Value "CODEX_HOME=$resolvedHome" + Write-Host "Using CODEX_HOME: $resolvedHome" + } + - name: Verify Codex login and model availability + if: ${{ github.event.inputs.site_only != 'true' }} run: | $workspaceDir = Join-Path $env:GITHUB_WORKSPACE ".codex-preflight" - $script = @( - "from tradingagents.llm_clients.codex_preflight import run_codex_preflight", - "result = run_codex_preflight(", - " codex_binary=None,", - " model='gpt-5.4',", - " request_timeout=30.0,", - " workspace_dir=r'$workspaceDir',", - " cleanup_threads=True,", - ")", - "print('Codex account:', result.account)", - "print('First available models:', ', '.join(result.models[:8]))" - ) -join "`n" + $script = @" +import os +from tradingagents.llm_clients.codex_app_server import CodexAppServerAuthError, CodexAppServerBinaryError +from tradingagents.llm_clients.codex_preflight import run_codex_preflight + +workspace_dir = r"$workspaceDir" +summary_path = os.getenv("GITHUB_STEP_SUMMARY") + +def write_summary(lines): + if not summary_path: + return + with open(summary_path, "a", encoding="utf-8") as handle: + handle.write("\n".join(lines) + "\n") + +try: + result = run_codex_preflight( + codex_binary=None, + model="gpt-5.4", + request_timeout=30.0, + workspace_dir=workspace_dir, + cleanup_threads=True, + ) +except CodexAppServerAuthError as exc: + message = ( + "Codex is installed but not logged in for the runner. " + "Run `codex login` or `codex login --device-auth` on the runner machine, " + "then retry the workflow." + ) + print(f"::error::{message}") + print(exc) + write_summary( + [ + "## Codex login required", + "", + message, + ] + ) + raise SystemExit(1) +except CodexAppServerBinaryError as exc: + message = ( + "A usable Codex binary is not available for the runner. " + "Check the `CODEX_BINARY` repository variable or install Codex for the runner service account." + ) + print(f"::error::{message}") + print(exc) + write_summary( + [ + "## Codex runtime issue", + "", + message, + "", + str(exc), + ] + ) + raise SystemExit(1) + +print("Codex account:", result.account) +print("First available models:", ", ".join(result.models[:8])) +write_summary( + [ + "## Codex preflight passed", + "", + f"- Account: {result.account}", + f"- Models: {', '.join(result.models[:8])}", + f"- Binary: {os.getenv('CODEX_BINARY', '(auto)')}", + ] +) +"@ $script | python - if ($LASTEXITCODE) { exit $LASTEXITCODE } From d83089cb2f25dbe013e45af198ed2e7861eab23b Mon Sep 17 00:00:00 2001 From: nornen0202 <75664002+nornen0202@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:27:29 +0900 Subject: [PATCH 19/21] Improve Codex runtime detection and auth reporting (#13) * Fix scheduled news coverage and run date reporting * Harden Windows Actions shell execution * Stabilize Codex runtime for Windows runner * Report Codex auth status in Actions From 86127789a345b9da573cd04ba8776d598c209b2e Mon Sep 17 00:00:00 2001 From: nornen0202 Date: Tue, 7 Apr 2026 21:29:39 +0900 Subject: [PATCH 20/21] Avoid push-time workflow input evaluation --- .github/workflows/daily-codex-analysis.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/daily-codex-analysis.yml b/.github/workflows/daily-codex-analysis.yml index 96787cca..d926da4f 100644 --- a/.github/workflows/daily-codex-analysis.yml +++ b/.github/workflows/daily-codex-analysis.yml @@ -63,8 +63,13 @@ jobs: if ($LASTEXITCODE) { exit $LASTEXITCODE } - name: Resolve Codex runtime - if: ${{ github.event.inputs.site_only != 'true' }} run: | + $siteOnly = "${{ github.event.inputs.site_only }}" + if ($siteOnly -eq "true") { + Write-Host "Skipping Codex runtime resolution because site_only=true." + exit 0 + } + function Test-CodexCandidate { param([string]$Candidate) @@ -123,8 +128,13 @@ jobs: } - name: Verify Codex login and model availability - if: ${{ github.event.inputs.site_only != 'true' }} run: | + $siteOnly = "${{ github.event.inputs.site_only }}" + if ($siteOnly -eq "true") { + Write-Host "Skipping Codex preflight because site_only=true." + exit 0 + } + $workspaceDir = Join-Path $env:GITHUB_WORKSPACE ".codex-preflight" $script = @" import os From 2fe027dd97b412fa53f46b941d1ec3adbea8faea Mon Sep 17 00:00:00 2001 From: nornen0202 Date: Tue, 7 Apr 2026 21:32:36 +0900 Subject: [PATCH 21/21] Fix workflow YAML for Codex preflight --- .github/workflows/daily-codex-analysis.yml | 126 ++++++++++----------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/.github/workflows/daily-codex-analysis.yml b/.github/workflows/daily-codex-analysis.yml index d926da4f..f50d2ab9 100644 --- a/.github/workflows/daily-codex-analysis.yml +++ b/.github/workflows/daily-codex-analysis.yml @@ -137,73 +137,73 @@ jobs: $workspaceDir = Join-Path $env:GITHUB_WORKSPACE ".codex-preflight" $script = @" -import os -from tradingagents.llm_clients.codex_app_server import CodexAppServerAuthError, CodexAppServerBinaryError -from tradingagents.llm_clients.codex_preflight import run_codex_preflight + import os + from tradingagents.llm_clients.codex_app_server import CodexAppServerAuthError, CodexAppServerBinaryError + from tradingagents.llm_clients.codex_preflight import run_codex_preflight -workspace_dir = r"$workspaceDir" -summary_path = os.getenv("GITHUB_STEP_SUMMARY") + workspace_dir = r"$workspaceDir" + summary_path = os.getenv("GITHUB_STEP_SUMMARY") -def write_summary(lines): - if not summary_path: - return - with open(summary_path, "a", encoding="utf-8") as handle: - handle.write("\n".join(lines) + "\n") + def write_summary(lines): + if not summary_path: + return + with open(summary_path, "a", encoding="utf-8") as handle: + handle.write("\n".join(lines) + "\n") -try: - result = run_codex_preflight( - codex_binary=None, - model="gpt-5.4", - request_timeout=30.0, - workspace_dir=workspace_dir, - cleanup_threads=True, - ) -except CodexAppServerAuthError as exc: - message = ( - "Codex is installed but not logged in for the runner. " - "Run `codex login` or `codex login --device-auth` on the runner machine, " - "then retry the workflow." - ) - print(f"::error::{message}") - print(exc) - write_summary( - [ - "## Codex login required", - "", - message, - ] - ) - raise SystemExit(1) -except CodexAppServerBinaryError as exc: - message = ( - "A usable Codex binary is not available for the runner. " - "Check the `CODEX_BINARY` repository variable or install Codex for the runner service account." - ) - print(f"::error::{message}") - print(exc) - write_summary( - [ - "## Codex runtime issue", - "", - message, - "", - str(exc), - ] - ) - raise SystemExit(1) + try: + result = run_codex_preflight( + codex_binary=None, + model="gpt-5.4", + request_timeout=30.0, + workspace_dir=workspace_dir, + cleanup_threads=True, + ) + except CodexAppServerAuthError as exc: + message = ( + "Codex is installed but not logged in for the runner. " + "Run `codex login` or `codex login --device-auth` on the runner machine, " + "then retry the workflow." + ) + print(f"::error::{message}") + print(exc) + write_summary( + [ + "## Codex login required", + "", + message, + ] + ) + raise SystemExit(1) + except CodexAppServerBinaryError as exc: + message = ( + "A usable Codex binary is not available for the runner. " + "Check the `CODEX_BINARY` repository variable or install Codex for the runner service account." + ) + print(f"::error::{message}") + print(exc) + write_summary( + [ + "## Codex runtime issue", + "", + message, + "", + str(exc), + ] + ) + raise SystemExit(1) -print("Codex account:", result.account) -print("First available models:", ", ".join(result.models[:8])) -write_summary( - [ - "## Codex preflight passed", - "", - f"- Account: {result.account}", - f"- Models: {', '.join(result.models[:8])}", - f"- Binary: {os.getenv('CODEX_BINARY', '(auto)')}", - ] -) -"@ + print("Codex account:", result.account) + print("First available models:", ", ".join(result.models[:8])) + write_summary( + [ + "## Codex preflight passed", + "", + f"- Account: {result.account}", + f"- Models: {', '.join(result.models[:8])}", + f"- Binary: {os.getenv('CODEX_BINARY', '(auto)')}", + ] + ) + "@ $script | python - if ($LASTEXITCODE) { exit $LASTEXITCODE }