Compare commits
6 Commits
f17c531c27
...
d3299a84bb
| Author | SHA1 | Date |
|---|---|---|
|
|
d3299a84bb | |
|
|
151969e377 | |
|
|
83ad742dec | |
|
|
e17db7bd35 | |
|
|
6050c25bb2 | |
|
|
f598476a10 |
|
|
@ -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
|
||||
|
|
|
|||
11
cli/main.py
11
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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 "))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue