Compare commits

...

6 Commits

Author SHA1 Message Date
nornen0202 d3299a84bb
Merge 151969e377 into 10c136f49c 2026-04-07 02:13:55 +09:00
nornen0202 151969e377 Fix scheduled news coverage and run date reporting 2026-04-07 02:12:56 +09:00
nornen0202 83ad742dec Normalize model validation inputs 2026-04-07 01:32:42 +09:00
nornen0202 e17db7bd35 Improve Korean report localization 2026-04-07 01:30:12 +09:00
nornen0202 6050c25bb2 Update scheduled ticker list 2026-04-07 00:33:58 +09:00
nornen0202 f598476a10 Update Codex operations guide and FAQ 2026-04-07 00:32:29 +09:00
18 changed files with 822 additions and 367 deletions

View File

@ -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 <NEW_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 <NEW_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 <REGISTRATION_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

View File

@ -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:

View File

@ -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"

View File

@ -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 "))

View File

@ -0,0 +1,102 @@
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 = {
"analysis_date": "2026-04-06",
"trade_date": "2026-04-02",
"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("분석 기준일: 2026-04-06", report_text)
self.assertIn("시장 데이터 기준일: 2026-04-02", 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()

View File

@ -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",

View File

@ -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()

View File

@ -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 = [

View File

@ -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 = [

View File

@ -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"]

View File

@ -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:

View File

@ -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}"

View File

@ -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": "",

View File

@ -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
@ -200,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()
@ -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,13 +238,14 @@ 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."""
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"],
@ -296,3 +302,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

View File

@ -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]

View File

@ -11,22 +11,26 @@ 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)
analysis_date = _coerce_text(final_state.get("analysis_date"))
trade_date = _coerce_text(final_state.get("trade_date"))
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 +41,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 +49,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 +62,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 +71,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 +92,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,14 +102,17 @@ 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"
)
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
@ -121,3 +130,54 @@ 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": "생성 시각",
"analysis_date": "분석 기준일",
"trade_date": "시장 데이터 기준일",
"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",
"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",
"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",
}

View File

@ -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,10 +170,20 @@ 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(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))
@ -192,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(),
@ -210,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"],
@ -226,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(),
@ -238,6 +252,7 @@ def _run_single_ticker(
return {
"ticker": ticker,
"status": "failed",
"analysis_date": analysis_date,
"trade_date": None,
"decision": None,
"error": str(exc),
@ -279,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"),

View File

@ -163,6 +163,7 @@ def _render_run_page(manifest: dict[str, Any], settings: SiteSettings) -> str:
<a href="{_escape(ticker_summary['ticker'])}.html">{_escape(ticker_summary['ticker'])}</a>
<span class="status {ticker_summary['status']}">{_escape(ticker_summary['status'])}</span>
</div>
<p><strong>Analysis date</strong><span>{_escape(ticker_summary.get('analysis_date') or '-')}</span></p>
<p><strong>Trade date</strong><span>{_escape(ticker_summary.get('trade_date') or '-')}</span></p>
<p><strong>Duration</strong><span>{ticker_summary.get('duration_seconds', 0):.1f}s</span></p>
<p><strong>Decision</strong><span>{_escape(ticker_summary.get('decision') or ticker_summary.get('error') or '-')}</span></p>
@ -239,10 +240,12 @@ def _render_ticker_page(
<div>
<p class="eyebrow">Ticker report</p>
<h1>{_escape(ticker_summary['ticker'])}</h1>
<p class="subtitle">{_escape(ticker_summary.get('trade_date') or '-')} / {_escape(ticker_summary['status'])}</p>
<p class="subtitle">Analysis {_escape(ticker_summary.get('analysis_date') or '-')} / Market {_escape(ticker_summary.get('trade_date') or '-')} / {_escape(ticker_summary['status'])}</p>
</div>
<div class="hero-card">
<div class="status {ticker_summary['status']}">{_escape(ticker_summary['status'])}</div>
<p><strong>Analysis date</strong><span>{_escape(ticker_summary.get('analysis_date') or '-')}</span></p>
<p><strong>Trade date</strong><span>{_escape(ticker_summary.get('trade_date') or '-')}</span></p>
<p><strong>Decision</strong><span>{_escape(ticker_summary.get('decision') or '-')}</span></p>
<p><strong>Duration</strong><span>{ticker_summary.get('duration_seconds', 0):.1f}s</span></p>
<p><strong>LLM calls</strong><span>{ticker_summary.get('metrics', {}).get('llm_calls', 0)}</span></p>