diff --git a/.claude/commands/evening.md b/.claude/commands/evening.md index dceae57..673bd22 100644 --- a/.claude/commands/evening.md +++ b/.claude/commands/evening.md @@ -1,78 +1,35 @@ -# claude_evening — 장 후 분석 및 피드백 +# claude_evening daily review -오늘 매매 결과를 분석하고 `reports/daily/날짜.md`를 생성한다. +Analyze today's trading result and write `reports/daily/YYYY-MM-DD.md`. -## 실행 순서 +## Steps -### 1. 데이터 수집 -```bash -python app/ai/evening.py --print -``` +1. Collect data: + ```bash + python app/ai/evening.py --print + ``` -### 2. 분석 항목 -수집된 데이터를 바탕으로 다음을 판단한다: +2. Review: + - total trades, win rate, net PnL, fees + - exit reason distribution: TP1 / TP2 / SL / TIME / FORCE + - overtrading: daily entry count, repeated stop losses, TIME/FORCE concentration + - AI filter quality: boosted tickers and blacklists + - execution quality: missing prices, zero-price rows, inconsistent open positions -**오늘 매매 평가** -- 승률·손익 수준이 적절했는가 -- 손절이 제대로 작동했는가 -- AI 부스트 종목 성과 vs 일반 종목 비교 -- 이상 패턴 (연속 손절, 특정 시간대 집중 손실 등) +3. Strategy changes: + - Do not edit `app/config.py` directly. + - If a change looks justified, create `reports/proposals/YYYY-MM-DD_strategy_proposal.md`. + - Include exact proposed values, evidence, sample size, expected benefit, and risk. + - If fewer than 30 closed trades support the change, clearly mark the evidence as insufficient. + - `FORCE_EXIT = "14:50"` is not changeable. -**파라미터 조정** (문제가 명확할 때만) -- 연속 손절 3회 이상 → `SL_PCT` 축소 검토 -- 목표가 도달 후 즉시 반락 패턴 → `TP1_PCT` 상향 검토 -- 승률 < 40% 지속 → `STRATEGY_K` 조정 검토 -- 조정이 필요하면 `app/config.py`를 직접 수정 +4. Live readiness: + - at least 30 closed trades + - recent win rate > 48% + - MDD better than -10% + - Sharpe > 1.0 + - stop/kill risk events <= 2 + - If all pass, create `reports/live_ready/YYYY-MM-DD_READY.md`. -### 3. 실전 전환 조건 체크 -`live_ready` 섹션의 5가지 조건을 확인한다: -- 누적 운영 30거래일 이상 -- 최근 30일 승률 > 48% -- 최근 30일 MDD < -10% -- 최근 30일 샤프지수 > 1.0 -- 이번 달 L3 발동 2회 이하 - -**5가지 모두 충족 시:** -```bash -mkdir -p reports/live_ready -``` -`reports/live_ready/날짜_READY.md`를 생성하고 Discord에 🚀 알림을 보낸다. - -### 4. 일일 리포트 저장 -`reports/daily/날짜.md` 형식으로 저장한다: -```markdown -# [날짜] 일일 리포트 - -## 매매 결과 -- 총 매매: N회 / 승 N 패 N (승률 N%) -- 순손익: +N원 - -## 매매 상세 -| 종목 | 진입 | 청산 | 사유 | 손익 | -|------|------|------|------|------| - -## 분석 및 피드백 -(Claude 분석 내용) - -## 파라미터 변경 -(변경했으면 내용, 없으면 "없음") - -## 실전 전환 조건 -| 조건 | 기준 | 현재 | 통과 | -|------|------|------|------| -``` - -### 5. Discord 알림 전송 -```bash -python -c " -import asyncio, json, sys -sys.path.insert(0, '.') -from app.main import load_env; load_env() -from app.monitor.notifier import send -# 분석 결과 요약을 msg에 담아 전송 -asyncio.run(send(msg)) -" -``` - -### 6. 완료 -리포트 경로와 한 줄 요약을 출력하고 종료한다. +5. Discord: + Send a concise result summary. If a proposal file was created, include that manual approval is required. diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..4ab8f8c --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,6 @@ +[shell_environment_policy] +inherit = "core" + +[shell_environment_policy.set] +PYTHONUTF8 = "1" +PYTHONIOENCODING = "utf-8" diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..1262c6f --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python .claude/discord_notify.py", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index afed35e..9b541f1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,17 @@ data/stockbot.db data/daily_context.json data/universe_cache.json +data/training_dataset*.csv +data/external_training_dataset*.csv +data/kis_token_*.json +data/news/ +data/market/ data/redis/ +models/ logs/*.log +logs/*.pid __pycache__/ +.venv/ *.pyc *.pyo .DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ed4f999 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,291 @@ +# StockBot v3.0 — Codex 운영 가이드 + +> 최종 수정: 2026-05-19 +> 인프라: 로컬 Windows → Synology NAS Docker 이전 예정 +> 현재 모드: 모의투자 (KIS_MOCK=true, DRY_RUN=true) + +## Current safety policy + +- AI is for market review and proposals only; code decides entries and exits. +- Evening Codex must not edit `app/config.py` or execution/strategy/risk code directly. +- Parameter changes must be written to `reports/proposals/YYYY-MM-DD_strategy_proposal.md` with evidence and manual approval required. +- `FORCE_EXIT = "14:50"` remains immutable. + +## Current implementation status - 2026-05-27 + +- Mode: paper trading / dry-run focused. Real-cash trading is not approved yet. +- Data layer: `entry_snapshots` and `post_entry_snapshots` are active for training data. +- Post-entry sampling: 60s, 180s, 300s, and 600s after successful entry. +- Training data export: `scripts/export_training_dataset.py`. +- External data collection: + - Daily market features: `scripts/collect_daily_features.py`. + - KIS minute bars: `scripts/collect_minute_data.py`. + - External dataset builder: `scripts/build_external_training_dataset.py`. +- ML engine: + - Training: `scripts/train_ai_model.py`. + - Model output: `models/scalping_model.joblib`. + - Metrics output: `models/scalping_model.metrics.json`. + - Runtime loader: `app/ml/predictor.py`. +- AI runtime mode: observation only. If a model exists, entry-time scores are logged and saved to `entry_snapshots`; they do not block or resize trades. +- Training schedule: `StockBot_Training` runs at 16:00 on trading days via `scripts/run_training_pipeline.ps1`. +- Dependency install: + - `requirements.txt` includes `app/requirements.txt`. + - `scripts/install_dependencies.ps1` installs from `vendor/wheels` when available. + - `scripts/download_dependencies.ps1` builds the local wheelhouse. + +## Current operational risks - 2026-05-27 + +- KIS minute-bar endpoint must be verified with live response logs. +- Early ML models may be meaningless until enough labeled rows exist. +- External minute data is pretraining data, not actual bot-trade data. +- Real-cash mode still needs stronger fill, partial-fill, unfilled-order, cancel/replace, and recovery logic. +- Existing logs and older docs contain encoding damage; new operational notes should stay ASCII unless the file encoding is intentionally cleaned. + +--- + +## 프로젝트 개요 + +| 항목 | 내용 | +|------|------| +| 목표 | KIS API 기반 한국 주식 단타 자동매매 | +| 전략 | 변동성 돌파 (K=0.5) | +| 언어 | Python 3.11 | +| DB | SQLite (data/stockbot.db) | +| 알림 | Discord Webhook (거래 알림 + 코드 변경 알림 채널 분리) | +| AI | Codex headless (`Codex -p "/커맨드" --dangerously-skip-permissions`) | +| 코드 관리 | Gitea (NAS) — 파일 수정 후 확인 없이 자동 commit + push | + +--- + +## Codex 권한 및 자동화 설정 + +- 모든 Bash/파일 작업 **영구 승인** (`.Codex/settings.json` `bypassPermissions`) +- 글로벌 설정 `~/.Codex/settings.json`에도 `dangerouslySkipPermissions: true` 적용 +- **파일 수정 완료 후 즉시 `git add + commit + push origin master` 자동 실행** (확인 불필요) +- 인코딩: `PYTHONUTF8=1`, `PYTHONIOENCODING=utf-8` 환경변수 설정 + +--- + +## 핵심 설계 원칙 (절대 불변) + +1. **14:50 강제 청산** — 하드코딩, 예외 없음 +2. **손절 우선** — AI 판단과 무관하게 손절 룰 항상 우선순위 1위 +3. **AI 역할 분리** — Codex는 장 전 분석 + 장 후 피드백만, 실시간 매매 개입 불가 +4. **검증 순서** — 모의투자 3개월 → 조건 충족 → 실거래 + +--- + +## 하루 자동화 흐름 + +``` +07:30 StockBot_Morning → run_morning.ps1 → Codex /morning + RSS+네이버 뉴스+KIS 수급 분석 → daily_context.json + 완료 후 자동으로 /start-bot 호출 → 봇 백그라운드 시작 +08:30 봇이 daily_context.json 로드 → Discord 전송 → 유니버스 30종목 확정 +08:50 목표가 계산 +09:00 아침 세션 시작 (변동성 돌파 신호 + AI 필터) + B안: 연속 손절 시 포지션 크기 자동 축소 (0회→1.0× / 1회→0.7× / 2회→0.5× / 3+→0.3×) +11:00 midday_context.json 미로드 시 신규 진입 일시 중단 +11:20 StockBot_Midday → run_midday.ps1 → Codex /midday + 오전 결과+시장 스냅샷 수집 → midday_context.json 저장 + 파일 생성 즉시 봇이 감지 → 점심 세션 자동 시작 +14:00 신규 진입 마감 +14:50 강제 전량 청산 (절대 불변) +15:10 일일 결산 → Discord 전송 +15:30 StockBot_Evening → run_evening.ps1 → Codex /evening + 결과 분석 + 리포트 저장 +``` + +### 스케줄러 스크립트 주의사항 (scripts/run_*.ps1) +- 경로: `$PROJECT = Split-Path -Parent $PSScriptRoot` (한글 경로 인코딩 문제 방지) +- Codex 실행: `$Codex = "C:\Users\whdwo\AppData\Roaming\npm\Codex.cmd"` (전체 경로 필수) +- 인코딩: `New-Object System.Text.UTF8Encoding $false` + UTF-8 BOM으로 저장 +- 로그: `logs/bot_start.log`, `logs/morning.log`, `logs/midday.log`, `logs/evening.log` + +--- + +## Codex 역할 상세 + +### 장 전 분석 — `/morning` 슬래시 커맨드 +``` +1. python app/ai/morning.py --print (뉴스 크롤링 + KIS 수급 수집) +2. Codex가 데이터 분석 → 시장 분위기/섹터/boosted_tickers 판단 +3. data/daily_context.json 저장 +4. Discord로 분석 요약 전송 +``` + +### 장중 분석 — `/midday` 슬래시 커맨드 +``` +1. python app/ai/midday.py --print (오전 거래 결과 + 현재 시장 스냅샷 수집) +2. 오전 daily_context 예측 vs 실제 결과 비교 분석 +3. 점심 세션 파라미터 결정 (진입 허용 여부, 포지션 배율, 섹터 업데이트) +4. data/midday_context.json 저장 → 봇이 즉시 감지해 점심 세션 시작 +5. Discord로 장중 분석 전송 +``` + +### 장 후 피드백 — `/evening` 슬래시 커맨드 +``` +1. python app/ai/evening.py --print (오늘 매매 내역 조회) +2. 승률/손익/이상패턴 분석 +3. app/config.py 파라미터 조정 (문제 명확할 때만) +4. reports/daily/날짜.md 저장 +5. 실전 전환 조건 5가지 체크 +6. Discord로 요약 전송 +``` + +### 봇 시작 — `/start-bot` 슬래시 커맨드 +``` +1. 이미 실행 중인지 확인 (중복 방지) +2. app/main.py를 DETACHED_PROCESS로 백그라운드 시작 +3. Discord "[모의투자] 자동매매 봇 시작" 알림 +``` + +--- + +## Discord 알림 구조 + +| 채널 | 내용 | 발신 | +|------|------|------| +| 거래 알림 | 매수/매도/손절/결산 | `app/monitor/notifier.py` | +| 코드 변경 | 커밋 내용 + Push 완료 여부 | `.Codex/discord_notify.py` (Stop 훅) | + +- 코드 변경 알림: **커밋이 있을 때만** 발송 (스케줄러 태스크 등 노이즈 없음) +- Discord 요청 시 반드시 `User-Agent: DiscordBot (stockbot, 1.0)` 헤더 포함 (Cloudflare 차단 방지) + +--- + +## 파일 구조 + +``` +stockbot_v3/ +├── AGENTS.md +├── .env ← API 키 (Git 절대 제외) +├── .Codex/ +│ ├── settings.json ← 권한·훅·환경변수 설정 +│ ├── discord_notify.py ← Stop 훅: 코드 변경 Discord 전송 +│ └── session_start_sha.txt ← 세션 시작 시 HEAD SHA 저장 +├── app/ +│ ├── main.py ← 메인 매매 루프 (승인 필요) +│ ├── config.py ← 전략 파라미터 (수정 가능) +│ ├── ai/ +│ │ ├── morning.py ← 장 전 데이터 수집 +│ │ ├── midday.py ← 장중 데이터 수집 +│ │ └── evening.py ← 장 후 데이터 수집 +│ ├── strategy/ +│ │ └── volatility_breakout.py ← 전략 로직 (수정 가능) +│ ├── execution/ +│ │ ├── kis_client.py ← KIS API 래퍼 (승인 필요) +│ │ └── order_executor.py ← 주문 실행 (승인 필요) +│ ├── risk/ +│ │ └── manager.py ← 리스크 관리 (수정 가능) +│ ├── monitor/ +│ │ └── notifier.py ← Discord Webhook +│ └── db/ +│ ├── models.py ← SQLite 스키마 (승인 필요) +│ └── repository.py ← DB 접근 (승인 필요) +├── scripts/ +│ ├── run_bot.ps1 ← 스케줄러용 봇 시작 +│ ├── run_morning.ps1 ← 스케줄러용 morning +│ ├── run_midday.ps1 ← 스케줄러용 midday (11:20) +│ ├── run_evening.ps1 ← 스케줄러용 evening +│ └── setup_scheduler.ps1 ← 스케줄러 전체 재등록 +├── reports/ +│ ├── daily/ ← 매일 자동 생성 +│ └── live_ready/ ← 실전 전환 조건 충족 시 생성 +├── data/ +│ ├── stockbot.db +│ ├── daily_context.json ← 매일 /morning이 갱신, 봇이 08:30에 로드 +│ ├── midday_context.json ← 매일 /midday가 갱신, 봇이 파일 감지 즉시 로드 +│ ├── news/ +│ └── market/ +└── logs/ + ├── stockbot.log ← 봇 메인 로그 (UTF-8) + ├── trades.log + ├── bot_start.log ← /start-bot 실행 로그 + ├── morning.log ← /morning 실행 로그 + └── evening.log ← /evening 실행 로그 +``` + +--- + +## 수정 범위 + +### 자유롭게 수정 가능 +- `app/config.py` — 전략 파라미터 (TP1_PCT, TP2_PCT, SL_PCT, STRATEGY_K 등) +- `app/strategy/volatility_breakout.py` — 전략 로직 +- `app/risk/manager.py` — 리스크 기준값 (L1~L5) +- `app/ai/daily_context.json` — 매일 장 전 생성 +- `reports/` — 리포트 생성/저장 + +### 승인 필요 (사용자 확인 후 수정) +- `app/main.py` — 구조 변경, 스케줄 변경 +- `app/execution/` — 주문 로직 변경 +- `app/db/` — 스키마 변경 + +### 절대 금지 +- `FORCE_EXIT = "14:50"` 변경 +- 손절을 익절보다 후순위로 변경 +- `.env` 파일 직접 수정 (KIS_MOCK, DRY_RUN 포함) +- `.env` Git 커밋 + +--- + +## Git 규칙 + +- branch: `master` / remote: `origin` (Gitea NAS) +- 파일 수정 후 **자동으로** `git add -A && git commit && git push origin master` +- 커밋 메시지: `[날짜] 변경내용 요약` +- `.env`는 절대 커밋 금지 + +--- + +## 리스크 관리 (L1~L5) + +| 레벨 | 조건 | 동작 | Discord | +|------|------|------|---------| +| L1 | 1회 -1.5% | 즉시 손절 | [손절] | +| L2 | 일일 -3% | 당일 신규 진입 중단 | [경고] | +| L3-B | 연속 손절 | 포지션 크기 단계 축소 (전면 중단 없음) | [경고] | +| L4 | 주간 -7% | 주말까지 중단 | [경고] | +| L5 | 월간 -15% | 전략 폐기 + 재검토 | [긴급] | + +**L3-B 포지션 배율** (익절 시 한 단계 회복): + +| 연속 손절 | 포지션 크기 | +|-----------|------------| +| 0회 | 1.0× (정상) | +| 1회 | 0.7× | +| 2회 | 0.5× | +| 3회+ | 0.3× (최소) | + +--- + +## 실전 전환 조건 (claude_evening 자동 체크) + +| 조건 | 기준 | +|------|------| +| 누적 운영 | 30거래일 이상 | +| 승률 | 최근 30일 > 48% | +| MDD | 최근 30일 < -10% | +| 샤프지수 | 최근 30일 > 1.0 | +| L3-B 최소배율(0.3×) 도달 | 월 2회 이하 | + +전부 충족 시 → `reports/live_ready/날짜_READY.md` 생성 + Discord 🚀 알림 +전환 방법: `.env`에서 `KIS_MOCK=false`, `DRY_RUN=false` 로 변경 + +--- + +## 운영 모드 + +| KIS_MOCK | DRY_RUN | 동작 | +|----------|---------|------| +| true | true | 신호 확인만 ← **현재** | +| true | false | 모의투자 실주문 | +| false | false | 실거래 | + +--- + +## 다음 단계 + +- [ ] WebSocket 전환 — REST 폴링 → KIS WebSocket 실시간 시세 +- [ ] NAS Docker 이전 (개발 완료 후 `git push` → NAS `git pull` → `docker-compose up -d`) diff --git a/CLAUDE.md b/CLAUDE.md index 5a95f50..d8e46d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,43 @@ > 인프라: 로컬 Windows → Synology NAS Docker 이전 예정 > 현재 모드: 모의투자 (KIS_MOCK=true, DRY_RUN=true) +## Current safety policy + +- AI is for market review and proposals only; code decides entries and exits. +- Evening Claude must not edit `app/config.py` or execution/strategy/risk code directly. +- Parameter changes must be written to `reports/proposals/YYYY-MM-DD_strategy_proposal.md` with evidence and manual approval required. +- `FORCE_EXIT = "14:50"` remains immutable. + +## Current implementation status - 2026-05-27 + +- Mode: paper trading / dry-run focused. Real-cash trading is not approved yet. +- Data layer: `entry_snapshots` and `post_entry_snapshots` are active for training data. +- Post-entry sampling: 60s, 180s, 300s, and 600s after successful entry. +- Training data export: `scripts/export_training_dataset.py`. +- External data collection: + - Daily market features: `scripts/collect_daily_features.py`. + - KIS minute bars: `scripts/collect_minute_data.py`. + - External dataset builder: `scripts/build_external_training_dataset.py`. +- ML engine: + - Training: `scripts/train_ai_model.py`. + - Model output: `models/scalping_model.joblib`. + - Metrics output: `models/scalping_model.metrics.json`. + - Runtime loader: `app/ml/predictor.py`. +- AI runtime mode: observation only. If a model exists, entry-time scores are logged and saved to `entry_snapshots`; they do not block or resize trades. +- Training schedule: `StockBot_Training` runs at 16:00 on trading days via `scripts/run_training_pipeline.ps1`. +- Dependency install: + - `requirements.txt` includes `app/requirements.txt`. + - `scripts/install_dependencies.ps1` installs from `vendor/wheels` when available. + - `scripts/download_dependencies.ps1` builds the local wheelhouse. + +## Current operational risks - 2026-05-27 + +- KIS minute-bar endpoint must be verified with live response logs. +- Early ML models may be meaningless until enough labeled rows exist. +- External minute data is pretraining data, not actual bot-trade data. +- Real-cash mode still needs stronger fill, partial-fill, unfilled-order, cancel/replace, and recovery logic. +- Existing logs and older docs contain encoding damage; new operational notes should stay ASCII unless the file encoding is intentionally cleaned. + --- ## 프로젝트 개요 diff --git a/README.md b/README.md index ec8bf2e..f2ec0ab 100644 --- a/README.md +++ b/README.md @@ -47,3 +47,51 @@ docker-compose --profile emergency up kill-switch # 또는 python kill_switch/kill.py ``` +# StockBot Current Status - 2026-05-27 + +This project is currently a paper-trading scalping bot with an AI training +pipeline in observation mode. + +Active: +- Windows Task Scheduler operation for morning, midday, evening, watchdog, and training jobs. +- Entry snapshots for model training. +- Post-entry snapshots at 1m, 3m, 5m, and 10m. +- Bot-data export to `data/training_dataset.csv`. +- External daily/minute data collection for pretraining. +- RandomForest-based training engine. +- Optional AI entry scoring when `models/scalping_model.joblib` exists. + +Not active yet: +- AI does not block buys. +- AI does not change position size. +- AI does not override exits. +- Real-cash trading is not ready until fill, unfilled-order, and partial-fill handling is hardened. + +Daily schedule: + +| Time | Task | Purpose | +|---|---|---| +| 08:15 | `StockBot_Morning` | Run `/morning`, build context, start bot | +| 09:00-15:10 | `StockBot_Watchdog` | Check/restart bot every 5 minutes | +| 11:20 | `StockBot_Midday` | Midday review and context update | +| 15:30 | `StockBot_Evening` | Daily review and proposal report | +| 16:00 | `StockBot_Training` | Collect data, export datasets, train model | + +Useful commands: + +```powershell +python -m pip install -r requirements.txt +powershell -ExecutionPolicy Bypass -File scripts\install_dependencies.ps1 +powershell -ExecutionPolicy Bypass -File scripts\setup_scheduler.ps1 +powershell -ExecutionPolicy Bypass -File scripts\run_training_pipeline.ps1 +python scripts\export_training_dataset.py +python scripts\train_ai_model.py +``` + +Dependency portability: +- Root `requirements.txt` includes `app/requirements.txt`. +- `scripts/download_dependencies.ps1` downloads Windows/Python 3.11 wheels to `vendor/wheels`. +- `scripts/install_dependencies.ps1` installs from `vendor/wheels` first, then falls back to online pip. +- Raspberry Pi needs its own wheelhouse or online install because Windows wheels are not ARM/Linux compatible. + +--- diff --git a/Restore_StockBot.bat b/Restore_StockBot.bat new file mode 100644 index 0000000..1df8e26 --- /dev/null +++ b/Restore_StockBot.bat @@ -0,0 +1,12 @@ +@echo off +setlocal +cd /d "%~dp0" +net session >nul 2>&1 +if not "%errorlevel%"=="0" ( + echo Requesting Administrator permission for Task Scheduler registration... + powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%~f0' -ArgumentList '%*' -Verb RunAs" + exit /b +) +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\restore_after_format.ps1" %* +echo. +pause diff --git a/app/ai/evening.py b/app/ai/evening.py index 80f669a..45bdd57 100644 --- a/app/ai/evening.py +++ b/app/ai/evening.py @@ -15,6 +15,7 @@ import math import os import sqlite3 import sys +from collections import Counter from datetime import datetime, timedelta from pathlib import Path @@ -184,6 +185,8 @@ def main(print_mode: bool = False): wins = [t for t in closed if (t["pnl"] or 0) > 0] losses = [t for t in closed if (t["pnl"] or 0) <= 0] net_pnl = sum(t["pnl"] or 0 for t in closed) + exit_reason_counts = Counter(t.get("exit_reason") or "UNKNOWN" for t in closed) + forced_count = exit_reason_counts.get("FORCE", 0) summary = { "date": TODAY, @@ -193,6 +196,8 @@ def main(print_mode: bool = False): "losses": len(losses), "win_rate": round(len(wins) / len(closed) * 100, 1) if closed else 0, "net_pnl": round(net_pnl), + "exit_reason_counts": dict(exit_reason_counts), + "force_exit_ratio": round(forced_count / len(closed) * 100, 1) if closed else 0, "trades": trades, }, "last_30_days": { diff --git a/app/ai/status.py b/app/ai/status.py index 43c532b..f9bae66 100644 --- a/app/ai/status.py +++ b/app/ai/status.py @@ -62,7 +62,7 @@ async def get_status(): if open_rows: kis = KISClient() - await kis.ensure_token() + await kis.get_access_token() for ticker, name, entry_price, qty in open_rows: try: info = await kis.get_price(ticker) diff --git a/app/config.py b/app/config.py index 8705346..8566867 100644 --- a/app/config.py +++ b/app/config.py @@ -5,7 +5,7 @@ Claude Code가 이 파일을 읽고 필요시 수정함 # ── 변동성 돌파 ── STRATEGY_K = 0.5 -ENTRY_START = "09:05" +ENTRY_START = "09:15" ENTRY_END = "14:30" FORCE_EXIT = "14:50" # 절대 변경 불가 TP1_PCT = 0.020 # 1차 익절 +2.0% → 70% 매도 @@ -18,6 +18,13 @@ TICKER_REENTRY_COOLDOWN_MIN = 60 # 동일 종목 재진입 금지 시간(분) # ── 리스크 ── POS_SIZE_PCT = 0.20 MAX_POSITIONS = 2 +ENTRY_LIMIT_ENFORCE = False +MAX_DAILY_ENTRIES = 30 +MAX_HOURLY_STOP_LOSS = 4 +ENTRY_PAUSE_WINDOWS = ( + ("11:00", "11:20"), + ("14:00", "15:30"), +) DAILY_SL_PCT = 0.03 CONSEC_LOSS = 3 AI_RISK_SL_MAP = {"낮음": 0.015, "보통": 0.015, "높음": 0.010} diff --git a/app/data/collector.py b/app/data/collector.py index f9d5263..4b8f597 100644 --- a/app/data/collector.py +++ b/app/data/collector.py @@ -14,7 +14,8 @@ class DataCollector: self.on_vi = on_vi async def start(self, tickers: list): - self.ws.on_price("*", self.on_price) + for ticker in tickers: + self.ws.on_price(ticker, self.on_price) self.ws.on_vi(self.on_vi) await self.ws.subscribe(tickers) diff --git a/app/db/models.py b/app/db/models.py index 9ed658f..4c23f47 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -7,6 +7,12 @@ import os DB_PATH = os.getenv("DB_PATH", "data/stockbot.db") +def _ensure_columns(cursor, table: str, columns: dict[str, str]): + existing = {row[1] for row in cursor.execute(f"PRAGMA table_info({table})").fetchall()} + for name, ddl in columns.items(): + if name not in existing: + cursor.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}") + def init_db(): os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) conn = sqlite3.connect(DB_PATH) @@ -80,6 +86,72 @@ def init_db(): api_call_success INTEGER DEFAULT 1 )""") + c.execute(""" + CREATE TABLE IF NOT EXISTS entry_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trade_id INTEGER, + date TEXT NOT NULL, + ticker TEXT NOT NULL, + name TEXT, + entry_time TEXT NOT NULL, + current_price REAL, + entry_price REAL, + target_price REAL, + stop_price REAL, + today_open REAL, + prev_high REAL, + prev_low REAL, + prev_amount REAL, + volume REAL, + change_pct REAL, + market_sentiment TEXT, + sentiment_score INTEGER, + risk_level TEXT, + trade_allowed INTEGER, + hot_sectors TEXT, + avoid_sectors TEXT, + boosted_tickers TEXT, + blacklist_tickers TEXT, + ai_boosted INTEGER DEFAULT 0, + ai_win_score REAL, + ai_stop_loss_score REAL, + ai_model_version TEXT, + position_size_multiplier REAL, + combined_multiplier REAL, + entry_reason TEXT, + strategy TEXT DEFAULT 'VB', + created_at TEXT NOT NULL + )""") + c.execute("CREATE INDEX IF NOT EXISTS idx_entry_snapshots_trade_id ON entry_snapshots(trade_id)") + c.execute("CREATE INDEX IF NOT EXISTS idx_entry_snapshots_date_ticker ON entry_snapshots(date, ticker)") + _ensure_columns(c, "entry_snapshots", { + "ai_win_score": "REAL", + "ai_stop_loss_score": "REAL", + "ai_model_version": "TEXT", + }) + + c.execute(""" + CREATE TABLE IF NOT EXISTS post_entry_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trade_id INTEGER, + date TEXT NOT NULL, + ticker TEXT NOT NULL, + sample_time TEXT NOT NULL, + elapsed_sec INTEGER NOT NULL, + entry_price REAL, + current_price REAL, + return_pct REAL, + mfe_pct REAL, + mae_pct REAL, + volume REAL, + change_pct REAL, + position_open INTEGER DEFAULT 1, + created_at TEXT NOT NULL, + UNIQUE(trade_id, elapsed_sec) + )""") + c.execute("CREATE INDEX IF NOT EXISTS idx_post_entry_snapshots_trade_id ON post_entry_snapshots(trade_id)") + c.execute("CREATE INDEX IF NOT EXISTS idx_post_entry_snapshots_date_ticker ON post_entry_snapshots(date, ticker)") + conn.commit() conn.close() print(f"DB 초기화 완료: {DB_PATH}") diff --git a/app/execution/kis_client.py b/app/execution/kis_client.py index 9aacefc..0d6b253 100644 --- a/app/execution/kis_client.py +++ b/app/execution/kis_client.py @@ -264,6 +264,45 @@ class KISClient: # 주문 # ───────────────────────────────────────── + async def get_ohlcv_minute(self, ticker: str, hour: str = "153000") -> list: + """Domestic stock intraday minute bars from KIS.""" + data = await self._request( + method="GET", + path="/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice", + tr_id="FHKST03010200", + params={ + "FID_ETC_CLS_CODE": "", + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": ticker, + "FID_INPUT_HOUR_1": hour, + "FID_PW_DATA_INCU_YN": "Y", + }, + ) + + def _num(row: dict, *keys: str, default=0): + for key in keys: + value = row.get(key) + if value not in (None, ""): + try: + return int(float(str(value).replace(",", ""))) + except (TypeError, ValueError): + return default + return default + + result = [] + for row in data.get("output2", []) or data.get("output", []): + result.append({ + "date": row.get("stck_bsop_date") or row.get("bsop_date") or datetime.now().strftime("%Y%m%d"), + "time": row.get("stck_cntg_hour") or row.get("stck_bsop_hour") or row.get("cntg_hour") or "", + "ticker": ticker, + "open": _num(row, "stck_oprc", "oprc"), + "high": _num(row, "stck_hgpr", "hgpr"), + "low": _num(row, "stck_lwpr", "lwpr"), + "close": _num(row, "stck_prpr", "prpr", "stck_clpr", "clpr"), + "volume": _num(row, "cntg_vol", "acml_vol", "vol"), + }) + return sorted(result, key=lambda r: (r["date"], r["time"])) + async def order_buy( self, ticker : str, diff --git a/app/execution/order_executor.py b/app/execution/order_executor.py index d62b5ea..aefc68c 100644 --- a/app/execution/order_executor.py +++ b/app/execution/order_executor.py @@ -1,108 +1,177 @@ """ execution/order_executor.py -주문 실행 모듈 -DRY_RUN=true 시 실제 주문 전송 없음 +Order execution and trade persistence. + +DRY_RUN=true means KISClient simulates order fills with the current quote. """ import os -import asyncio import logging from datetime import datetime -from app.execution.kis_client import KISClient -from app.db.models import get_conn + from app.config import FEE_RATE, TAX_RATE +from app.db.models import get_conn +from app.execution.kis_client import KISClient logger = logging.getLogger(__name__) class OrderExecutor: def __init__(self, kis: KISClient): - self.kis = kis + self.kis = kis self.dry_run = os.getenv("DRY_RUN", "true").lower() == "true" def _calc_fee(self, price: float, qty: int, is_buy: bool) -> float: amt = price * qty return amt * (FEE_RATE + (0 if is_buy else TAX_RATE)) - async def buy(self, ticker: str, name: str, - qty: int, reason: str = "", - ai_boosted: bool = False) -> dict: - """시장가 매수""" + async def buy( + self, + ticker: str, + name: str, + qty: int, + reason: str = "", + ai_boosted: bool = False, + ) -> dict: + """Submit a market buy and save the opened trade.""" try: result = await self.kis.order_buy(ticker, qty) - price = result.get("entry_price", 0) + price = result.get("entry_price", 0) + if not price: + price = (await self.kis.get_price(ticker))["current"] - # DB 저장 fee = self._calc_fee(price, qty, True) - self._save_trade( - ticker=ticker, name=name, - entry_price=price, qty=qty, - side="BUY", fee=fee, + trade_id = self._save_trade( + ticker=ticker, + name=name, + entry_price=price, + qty=qty, + side="BUY", + fee=fee, ai_boosted=ai_boosted, ) mode = "[DRY]" if self.dry_run else "" - logger.info(f"{mode} 매수 {name}({ticker}) {qty}주 @ {price:,}원") - return {"success": True, "price": price, "qty": qty} + logger.info("%s BUY %s(%s) %s @ %s", mode, name, ticker, qty, price) + return {"success": True, "price": price, "qty": qty, "trade_id": trade_id} except Exception as e: - logger.error(f"매수 실패 {ticker}: {e}") + logger.error("BUY failed %s: %s", ticker, e) return {"success": False, "error": str(e)} - async def sell(self, ticker: str, name: str, - qty: int, reason: str = "") -> dict: - """시장가 매도""" + async def sell( + self, + ticker: str, + name: str, + qty: int, + reason: str = "", + ) -> dict: + """Submit a market sell and save full or partial exit results.""" try: result = await self.kis.order_sell(ticker, qty) - price = result.get("exit_price", 0) + price = result.get("exit_price", 0) + if not price: + price = (await self.kis.get_price(ticker))["current"] fee = self._calc_fee(price, qty, False) self._update_trade_exit( - ticker=ticker, exit_price=price, - qty=qty, reason=reason, fee=fee, + ticker=ticker, + exit_price=price, + qty=qty, + reason=reason, + fee=fee, ) mode = "[DRY]" if self.dry_run else "" - logger.info(f"{mode} 매도 {name}({ticker}) {qty}주 @ {price:,}원 [{reason}]") + logger.info("%s SELL %s(%s) %s @ %s [%s]", mode, name, ticker, qty, price, reason) return {"success": True, "price": price, "qty": qty} except Exception as e: - logger.error(f"매도 실패 {ticker}: {e}") + logger.error("SELL failed %s: %s", ticker, e) return {"success": False, "error": str(e)} - def _save_trade(self, ticker, name, entry_price, - qty, side, fee, ai_boosted=False): + def _save_trade(self, ticker, name, entry_price, qty, side, fee, ai_boosted=False): with get_conn() as conn: - conn.execute(""" + cur = conn.execute(""" INSERT INTO trades (date, ticker, name, entry_time, entry_price, quantity, side, fee, ai_boosted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( datetime.now().strftime("%Y-%m-%d"), - ticker, name, + ticker, + name, datetime.now().strftime("%H:%M:%S"), - entry_price, qty, side, fee, + entry_price, + qty, + side, + fee, 1 if ai_boosted else 0, )) + return cur.lastrowid - def _update_trade_exit(self, ticker, exit_price, - qty, reason, fee): + def _update_trade_exit(self, ticker, exit_price, qty, reason, fee): with get_conn() as conn: row = conn.execute(""" - SELECT id, entry_price, quantity FROM trades + SELECT id, date, name, entry_time, entry_price, quantity, + side, fee, ai_boosted + FROM trades WHERE ticker=? AND exit_time IS NULL ORDER BY id DESC LIMIT 1 """, (ticker,)).fetchone() if not row: + logger.warning("No open trade row found for exit: %s", ticker) return - trade_id, entry_price, trade_qty = row + + (trade_id, trade_date, name, entry_time, entry_price, trade_qty, + side, entry_fee, ai_boosted) = row actual_qty = qty if qty else trade_qty - pnl = (exit_price - entry_price) * actual_qty - fee + actual_qty = min(actual_qty, trade_qty) + entry_fee = entry_fee or 0 + allocated_entry_fee = entry_fee * (actual_qty / trade_qty) if trade_qty else 0 + total_fee = allocated_entry_fee + fee + pnl = (exit_price - entry_price) * actual_qty - total_fee + exit_time = datetime.now().strftime("%H:%M:%S") + + if actual_qty < trade_qty: + remaining_qty = trade_qty - actual_qty + remaining_fee = entry_fee - allocated_entry_fee + conn.execute(""" + UPDATE trades + SET quantity=?, fee=? + WHERE id=? + """, (remaining_qty, remaining_fee, trade_id)) + conn.execute(""" + INSERT INTO trades + (date, ticker, name, entry_time, exit_time, entry_price, + exit_price, quantity, side, exit_reason, pnl, fee, + ai_boosted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + trade_date, + ticker, + name, + entry_time, + exit_time, + entry_price, + exit_price, + actual_qty, + side, + reason, + pnl, + total_fee, + ai_boosted, + )) + return + conn.execute(""" UPDATE trades - SET exit_time=?, exit_price=?, exit_reason=?, fee=fee+?, pnl=? + SET exit_time=?, exit_price=?, exit_reason=?, fee=?, pnl=? WHERE id=? """, ( - datetime.now().strftime("%H:%M:%S"), - exit_price, reason, fee, pnl, trade_id, + exit_time, + exit_price, + reason, + total_fee, + pnl, + trade_id, )) diff --git a/app/main.py b/app/main.py index 3c9f713..b0be194 100644 --- a/app/main.py +++ b/app/main.py @@ -18,7 +18,7 @@ import os import sys import asyncio import logging -from datetime import datetime, time +from datetime import datetime, time, timedelta from pathlib import Path # 한글 로그 깨짐 방지 — stdout을 UTF-8로 강제 @@ -76,9 +76,12 @@ from app.monitor.notifier import ( notify_ai_fallback, send ) from app.db.models import init_db, get_conn +from app.ml.predictor import ScalpingModel from app.config import ( MAX_UNIVERSE, FORCE_EXIT, MAX_POSITIONS, - MAX_HOLD_MIN, KOSPI_MIN_CHG + MAX_HOLD_MIN, KOSPI_MIN_CHG, MAX_DAILY_ENTRIES, + MAX_HOURLY_STOP_LOSS, ENTRY_PAUSE_WINDOWS, + ENTRY_LIMIT_ENFORCE ) @@ -93,6 +96,7 @@ class StockBot: self.sl_tickers = set() # 당일 SL 당한 종목 — 재진입 차단 self.risk = None # RiskManager (잔고 확인 후 초기화) self.running = False + self.scalping_model = ScalpingModel() # 장중 컨텍스트 (midday_context.json 갱신 감지용) self._midday_ctx_mtime : float = 0.0 @@ -147,6 +151,296 @@ class StockBot: except Exception as e: logger.warning(f"midday_context 로드 실패: {e}") + def _today_entry_count(self) -> int: + today = datetime.now().strftime("%Y-%m-%d") + with get_conn() as conn: + row = conn.execute( + "SELECT COUNT(DISTINCT ticker || '|' || entry_time) FROM trades WHERE date=? AND side='BUY'", + (today,), + ).fetchone() + return int(row[0] or 0) + + def _recent_stop_loss_count(self, minutes: int = 60) -> int: + cutoff = (datetime.now() - timedelta(minutes=minutes)).strftime("%H:%M:%S") + today = datetime.now().strftime("%Y-%m-%d") + with get_conn() as conn: + row = conn.execute(""" + SELECT COUNT(*) FROM trades + WHERE date=? AND exit_reason='SL' AND exit_time >= ? + """, (today, cutoff)).fetchone() + return int(row[0] or 0) + + def _entry_gate_reason(self, now_str: str) -> str: + for start, end in ENTRY_PAUSE_WINDOWS: + if start <= now_str < end: + return f"entry pause window {start}-{end}" + + entries = self._today_entry_count() + if ENTRY_LIMIT_ENFORCE and entries >= MAX_DAILY_ENTRIES: + return f"daily entry limit reached {entries}/{MAX_DAILY_ENTRIES}" + + stop_losses = self._recent_stop_loss_count(60) + if ENTRY_LIMIT_ENFORCE and stop_losses >= MAX_HOURLY_STOP_LOSS: + return f"{stop_losses} stop losses in last 60 minutes" + + return "" + + def _entry_warning_reason(self) -> str: + warnings = [] + entries = self._today_entry_count() + if entries >= MAX_DAILY_ENTRIES: + warnings.append(f"daily entries high {entries}/{MAX_DAILY_ENTRIES}") + + stop_losses = self._recent_stop_loss_count(60) + if stop_losses >= MAX_HOURLY_STOP_LOSS: + warnings.append(f"{stop_losses} stop losses in last 60 minutes") + + return "; ".join(warnings) + + def _log_entry_acceptance( + self, + ticker: str, + name: str, + current: float, + target: float, + qty: int, + multiplier: float, + reason: str, + ): + logger.info( + "ENTRY accepted %s(%s) current=%s target=%.0f qty=%s mult=%.2f reason=%s", + name, + ticker, + current, + target, + qty, + multiplier, + reason, + ) + + def _save_entry_snapshot( + self, + trade_id: int | None, + ticker: str, + name: str, + price_info: dict, + target: float, + entry_price: float, + stop_price: float, + qty: int, + signal: dict, + combined_mult: float, + model_scores: dict | None = None, + ): + ctx = self.strategy.context + prev = self.strategy.prev_data.get(ticker, {}) + now = datetime.now() + model_scores = model_scores or {} + with get_conn() as conn: + conn.execute(""" + INSERT INTO entry_snapshots + (trade_id, date, ticker, name, entry_time, current_price, + entry_price, target_price, stop_price, today_open, prev_high, + prev_low, prev_amount, volume, change_pct, market_sentiment, + sentiment_score, risk_level, trade_allowed, hot_sectors, + avoid_sectors, boosted_tickers, blacklist_tickers, ai_boosted, + ai_win_score, ai_stop_loss_score, ai_model_version, + position_size_multiplier, combined_multiplier, entry_reason, + strategy, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, ( + trade_id, + now.strftime("%Y-%m-%d"), + ticker, + name, + now.strftime("%H:%M:%S"), + price_info.get("current"), + entry_price, + target, + stop_price, + self.strategy.today_open.get(ticker), + prev.get("high"), + prev.get("low"), + prev.get("amount"), + price_info.get("volume"), + price_info.get("change_pct"), + ctx.get("market_sentiment"), + ctx.get("sentiment_score"), + ctx.get("risk_level"), + 1 if ctx.get("trade_allowed", True) else 0, + json.dumps(ctx.get("hot_sectors", []), ensure_ascii=False), + json.dumps(ctx.get("avoid_sectors", []), ensure_ascii=False), + json.dumps(ctx.get("boosted_tickers", []), ensure_ascii=False), + json.dumps(ctx.get("blacklist_tickers", []), ensure_ascii=False), + 1 if signal.get("boosted") else 0, + model_scores.get("label_win"), + model_scores.get("label_stop_loss"), + model_scores.get("model_version"), + signal.get("multiplier", 1.0), + combined_mult, + signal.get("reason", ""), + "VB", + now.isoformat(timespec="seconds"), + )) + + def _build_entry_feature_row( + self, + ticker: str, + price_info: dict, + target: float, + entry_price: float, + stop_price: float, + signal: dict, + combined_mult: float, + ) -> dict: + ctx = self.strategy.context + prev = self.strategy.prev_data.get(ticker, {}) + return { + "current_price": price_info.get("current"), + "entry_price": entry_price, + "target_price": target, + "stop_price": stop_price, + "today_open": self.strategy.today_open.get(ticker), + "prev_high": prev.get("high"), + "prev_low": prev.get("low"), + "prev_amount": prev.get("amount"), + "volume": price_info.get("volume"), + "change_pct": price_info.get("change_pct"), + "sentiment_score": ctx.get("sentiment_score"), + "trade_allowed": 1 if ctx.get("trade_allowed", True) else 0, + "ai_boosted": 1 if signal.get("boosted") else 0, + "position_size_multiplier": signal.get("multiplier", 1.0), + "combined_multiplier": combined_mult, + } + + def _score_entry_candidate( + self, + ticker: str, + name: str, + price_info: dict, + target: float, + entry_price: float, + stop_price: float, + signal: dict, + combined_mult: float, + ) -> dict: + if not self.scalping_model.available: + return {} + try: + row = self._build_entry_feature_row( + ticker=ticker, + price_info=price_info, + target=target, + entry_price=entry_price, + stop_price=stop_price, + signal=signal, + combined_mult=combined_mult, + ) + scores = self.scalping_model.score(row) + if scores: + scores["model_version"] = self.scalping_model.version + logger.info( + "AI_SCORE %s(%s) win=%.3f stop_loss=%.3f", + name, + ticker, + scores.get("label_win", -1), + scores.get("label_stop_loss", -1), + ) + return scores + except Exception as e: + logger.warning("AI score failed %s(%s): %s", name, ticker, e) + return {} + + def _save_post_entry_snapshot( + self, + trade_id: int | None, + ticker: str, + elapsed_sec: int, + entry_price: float, + price_info: dict, + mfe_pct: float, + mae_pct: float, + position_open: bool, + ): + if trade_id is None: + return + current = price_info.get("current") + if not entry_price or current is None: + return + now = datetime.now() + return_pct = (current - entry_price) / entry_price * 100 + with get_conn() as conn: + conn.execute(""" + INSERT OR REPLACE INTO post_entry_snapshots + (trade_id, date, ticker, sample_time, elapsed_sec, entry_price, + current_price, return_pct, mfe_pct, mae_pct, volume, + change_pct, position_open, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, ( + trade_id, + now.strftime("%Y-%m-%d"), + ticker, + now.strftime("%H:%M:%S"), + elapsed_sec, + entry_price, + current, + return_pct, + mfe_pct, + mae_pct, + price_info.get("volume"), + price_info.get("change_pct"), + 1 if position_open else 0, + now.isoformat(timespec="seconds"), + )) + + async def _track_post_entry( + self, + trade_id: int | None, + ticker: str, + name: str, + entry_price: float, + ): + if trade_id is None: + return + + checkpoints = (60, 180, 300, 600) + start_ts = datetime.now().timestamp() + high = entry_price + low = entry_price + + for elapsed_sec in checkpoints: + delay = max(0, start_ts + elapsed_sec - datetime.now().timestamp()) + await asyncio.sleep(delay) + try: + price_info = await self.kis.get_price(ticker) + current = price_info["current"] + high = max(high, current) + low = min(low, current) + mfe_pct = (high - entry_price) / entry_price * 100 + mae_pct = (low - entry_price) / entry_price * 100 + self._save_post_entry_snapshot( + trade_id=trade_id, + ticker=ticker, + elapsed_sec=elapsed_sec, + entry_price=entry_price, + price_info=price_info, + mfe_pct=mfe_pct, + mae_pct=mae_pct, + position_open=ticker in self.positions, + ) + logger.info( + "POST_ENTRY %s(%s) t=%ss current=%s mfe=%.2f%% mae=%.2f%% open=%s", + name, + ticker, + elapsed_sec, + current, + mfe_pct, + mae_pct, + ticker in self.positions, + ) + except Exception as e: + logger.warning("post-entry snapshot failed %s t=%ss: %s", ticker, elapsed_sec, e) + # ───────────────────────────────────────── # 초기화 # ───────────────────────────────────────── @@ -404,6 +698,13 @@ class StockBot: # lunch_trade_allowed=false이면 점심 세션 진입 차단 if self._midday_loaded and not self.strategy.context.get("lunch_trade_allowed", True): return + gate_reason = self._entry_gate_reason(now_str) + if gate_reason: + logger.info("ENTRY blocked: %s", gate_reason) + return + warning_reason = self._entry_warning_reason() + if warning_reason: + logger.warning("ENTRY warning: %s", warning_reason) _now_ts = datetime.now().timestamp() _do_diag = (_now_ts - self._last_diag) >= 300 # 5분마다 진단 로그 @@ -456,6 +757,15 @@ class StockBot: ) invest = self.risk.get_pos_size(cash, combined_mult) qty = max(1, int(invest // current)) + self._log_entry_acceptance( + ticker=ticker, + name=name, + current=current, + target=target, + qty=qty, + multiplier=combined_mult, + reason=signal["reason"], + ) result = await self.executor.buy( ticker=ticker, name=name, @@ -466,6 +776,37 @@ class StockBot: if result["success"]: entry_price = result["price"] or current sl_price = entry_price * (1 - self.risk.get_sl_pct()) + model_scores = self._score_entry_candidate( + ticker=ticker, + name=name, + price_info=price_info, + target=target, + entry_price=entry_price, + stop_price=sl_price, + signal=signal, + combined_mult=combined_mult, + ) + self._save_entry_snapshot( + trade_id=result.get("trade_id"), + ticker=ticker, + name=name, + price_info=price_info, + target=target, + entry_price=entry_price, + stop_price=sl_price, + qty=qty, + signal=signal, + combined_mult=combined_mult, + model_scores=model_scores, + ) + asyncio.create_task( + self._track_post_entry( + trade_id=result.get("trade_id"), + ticker=ticker, + name=name, + entry_price=entry_price, + ) + ) pos = { "name" : name, @@ -621,7 +962,7 @@ class StockBot: today = datetime.now().strftime("%Y-%m-%d") with get_conn() as conn: rows = conn.execute(""" - SELECT pnl, fee FROM trades + SELECT pnl, fee, exit_reason FROM trades WHERE date=? AND exit_time IS NOT NULL """, (today,)).fetchall() @@ -635,6 +976,10 @@ class StockBot: net = sum(pnls) mdd = min(self.risk.daily_pnl / self.risk.init_cash * 100, 0.0) stopped = 0 if self.risk.can_trade() else 1 + exit_counts = {} + for _, _, reason in rows: + key = reason or "UNKNOWN" + exit_counts[key] = exit_counts.get(key, 0) + 1 # daily_summary 테이블 저장 with get_conn() as conn: @@ -646,6 +991,10 @@ class StockBot: """, (today, total, wins, losses, gross_pnl, total_fee, net, mdd, stopped)) await notify_daily_summary(total, wins, losses, net) + if exit_counts: + dist = " / ".join(f"{k}:{v}" for k, v in sorted(exit_counts.items())) + logger.info("Exit distribution: %s", dist) + await send(f"[청산분포] {dist}") self.risk.reset_daily() logger.info(f"결산: {total}회 / 승{wins} 패{losses} / {net:+,.0f}원 (fee {total_fee:,.0f}원)") diff --git a/app/ml/__init__.py b/app/ml/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/ml/__init__.py @@ -0,0 +1 @@ + diff --git a/app/ml/features.py b/app/ml/features.py new file mode 100644 index 0000000..d14f6c7 --- /dev/null +++ b/app/ml/features.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import Iterable + +import pandas as pd + + +LABEL_COLUMNS = {"label_win", "label_stop_loss"} + +EXCLUDED_COLUMNS = { + "id", + "trade_id", + "date", + "ticker", + "name", + "entry_time", + "exit_time", + "sample_time", + "created_at", + "exit_reason", + "strategy", + "reason", + "source_file", + "ai_win_score", + "ai_stop_loss_score", + "ai_model_version", +} + + +def select_feature_columns(df: pd.DataFrame, targets: Iterable[str] = LABEL_COLUMNS) -> list[str]: + excluded = EXCLUDED_COLUMNS | set(targets) + numeric_columns = [ + column + for column in df.columns + if column not in excluded and pd.api.types.is_numeric_dtype(df[column]) + ] + return sorted(numeric_columns) + + +def build_feature_matrix( + df: pd.DataFrame, + feature_columns: list[str], + medians: dict[str, float] | None = None, +) -> tuple[pd.DataFrame, dict[str, float]]: + features = df.reindex(columns=feature_columns) + features = features.apply(pd.to_numeric, errors="coerce") + + if medians is None: + medians = { + column: float(value) if pd.notna(value) else 0.0 + for column, value in features.median(numeric_only=True).items() + } + + features = features.fillna(medians).fillna(0.0) + return features, medians diff --git a/app/ml/predictor.py b/app/ml/predictor.py new file mode 100644 index 0000000..9218b26 --- /dev/null +++ b/app/ml/predictor.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import joblib +import pandas as pd + +from app.ml.features import build_feature_matrix + + +DEFAULT_MODEL_PATH = Path("models/scalping_model.joblib") + + +class ScalpingModel: + def __init__(self, model_path: str | Path = DEFAULT_MODEL_PATH): + self.model_path = Path(model_path) + self.bundle: dict[str, Any] | None = None + + @property + def available(self) -> bool: + return self.model_path.exists() + + def load(self) -> bool: + if not self.available: + return False + self.bundle = joblib.load(self.model_path) + return True + + @property + def version(self) -> str: + if self.bundle is None and not self.load(): + return "" + assert self.bundle is not None + return str(self.bundle.get("created_at", "")) + + def score(self, row: dict[str, Any]) -> dict[str, float]: + if self.bundle is None and not self.load(): + return {} + + assert self.bundle is not None + frame = pd.DataFrame([row]) + features, _ = build_feature_matrix( + frame, + self.bundle["feature_columns"], + self.bundle.get("medians"), + ) + + scores: dict[str, float] = {} + for target, model in self.bundle.get("models", {}).items(): + if hasattr(model, "predict_proba"): + scores[target] = float(model.predict_proba(features)[0][1]) + else: + scores[target] = float(model.predict(features)[0]) + return scores diff --git a/app/requirements.txt b/app/requirements.txt index 90ee012..e56db64 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -6,3 +6,8 @@ redis==5.0.7 streamlit==1.36.0 pandas==2.2.2 numpy==1.26.4 +holidays==0.48 +pykrx==1.0.48 +finance-datareader==0.9.94 +scikit-learn==1.5.1 +joblib==1.4.2 diff --git a/claude_evening/run.sh b/claude_evening/run.sh index e742d0c..23c30a5 100644 --- a/claude_evening/run.sh +++ b/claude_evening/run.sh @@ -1,57 +1,65 @@ #!/bin/bash -# 장 후 피드백 - 매일 15:30 자동 실행 -# NAS Container Manager 스케줄: 평일 15:30 +# Evening review - runs after market close. TODAY=$(date '+%Y-%m-%d') claude --bare -p " -오늘($TODAY) 매매 결과를 분석하고 개선해. +Analyze today's StockBot trading result for $TODAY. -## 데이터 수집 -1. sqlite3 data/stockbot.db 로 오늘 매매 내역 조회: +Hard rule: +- Do not edit app/config.py or any execution, strategy, risk, or order code. +- If a parameter change looks necessary, write it as a proposal only. +- FORCE_EXIT must remain 14:50. + +Data to inspect: +1. SQLite: SELECT * FROM trades WHERE date='$TODAY'; SELECT * FROM daily_summary WHERE date='$TODAY'; -2. logs/trades.log 에서 오늘 로그 확인 -3. reports/daily/ 에서 최근 30일 리포트 읽어서 패턴 파악 +2. logs/stockbot.log for today's runtime errors and entry diagnostics. +3. Recent reports under reports/daily/. -## 분석 항목 -- 오늘 총 매매 횟수, 승률, 순손익 -- 청산 이유 분포 (TP1/TP2/SL/FORCE/TIME) -- 이상 패턴 감지: - * 연속 손절 3회 이상 여부 - * 14:50 강제청산 비율 30% 초과 여부 - * 슬리피지 과다 여부 -- AI 판단 정확도 (boosted 종목 성과) +Review focus: +- Total trades, win rate, net PnL, fees. +- Exit reason distribution: TP1 / TP2 / SL / TIME / FORCE. +- Overtrading signals: + * daily entries near or above MAX_DAILY_ENTRIES + * repeated stop losses + * many TIME or FORCE exits +- AI filter quality: + * boosted tickers performance + * blacklisted or avoided sectors +- Execution quality: + * missing prices + * abnormal zero-price rows + * duplicated or inconsistent position/trade rows -## 코드 수정 (문제 명확할 때만) -- app/config.py 의 파라미터만 수정 가능 -- 반드시 수정 이유를 주석으로 추가 -- FORCE_EXIT=14:50 절대 변경 불가 -- 수정 없으면 건드리지 말 것 +Write outputs: +1. reports/daily/${TODAY}.md + - result summary + - exit reason distribution + - overtrading assessment + - execution or logging issues + - next-day watch items -## 실전 전환 조건 체크 -sqlite3로 최근 30거래일 데이터 집계 후 아래 5가지 모두 충족 시: -1. 누적 운영 30거래일 이상 -2. 최근 30일 승률 > 48% -3. 최근 30일 MDD < -10% -4. 최근 30일 샤프지수 > 1.0 -5. L3 발동 월 2회 이하 +2. reports/proposals/${TODAY}_strategy_proposal.md only if changes are justified. + Include: + - exact proposed config value + - evidence from at least 30 closed trades, or clearly state sample size is insufficient + - expected benefit + - risk of the change -→ 충족 시 reports/live_ready/${TODAY}_READY.md 생성 +Live readiness check: +- at least 30 closed trades +- recent win rate > 48% +- recent MDD better than -10% +- Sharpe > 1.0 +- stop/kill risk events <= 2 +If all pass, write reports/live_ready/${TODAY}_READY.md. -## 리포트 저장 -reports/daily/${TODAY}.md 저장 (마크다운, 한국어): -- 오늘 결과 요약 -- 이상 패턴 여부 -- 코드 수정 내역 (있을 경우) -- 누적 성과 (운영 N일차) -- 내일을 위한 한 줄 코멘트 - -## Discord 알림 -환경변수 DISCORD_WEBHOOK_URL로 전송: -1. [일일결산] $TODAY | 매매N회 | 승률X% | 손익+X원 -2. 코드 수정 발생 시: [🔧코드수정] 변경 내용 요약 -3. 실전 전환 조건 충족 시: [🚀실전전환권고] 30일 검증 완료! .env에서 KIS_MOCK=false로 변경하세요. +Discord: +Send a concise summary to DISCORD_WEBHOOK_URL: +[일일결산] $TODAY | trades=N | win=X% | pnl=+X | exits=TP1:n/TP2:n/SL:n/TIME:n/FORCE:n +If a proposal file was created, include: [전략제안] proposal saved, manual approval required. " \ --allowedTools "Read,Write,Bash" \ --dangerously-skip-permissions \ diff --git a/data/midday_context.json b/data/midday_context.json new file mode 100644 index 0000000..1159e2a --- /dev/null +++ b/data/midday_context.json @@ -0,0 +1,10 @@ +{ + "date": "2026-05-27", + "generated_at": "11:20:14", + "lunch_trade_allowed": true, + "position_size_multiplier": 0.7, + "hot_sectors": ["금융업", "반도체", "유통업"], + "avoid_sectors": ["전기전자", "화학", "의약품"], + "strategy_note": "시초가 갭업 오버슈팅 주의 — 점심 진입 시 추세 안정 확인 후 진입", + "reason": "KOSPI +4.43% 강세이나 중소형 돌파 실패 연속, 연손절1회 배율0.7×" +} diff --git a/reports/daily/2026-05-15.md b/reports/daily/2026-05-15.md new file mode 100644 index 0000000..3c0b85c --- /dev/null +++ b/reports/daily/2026-05-15.md @@ -0,0 +1,64 @@ +# [2026-05-15] 일일 리포트 + +## 매매 결과 +- 총 신호: 10건 (SL 청산 3건, 미청산 7건) +- 완결 매매: 0회 (DRY_RUN=true, 실주문 없음) +- 순손익: 0원 (모의투자 신호 테스트 단계) +- 모드: 모의투자 (KIS_MOCK=true, DRY_RUN=true) + +## 신호 상세 + +| 종목 | 진입시간 | 청산시간 | 사유 | 가격 | +|------|----------|----------|------|------| +| 412570 | 09:09:54 | - | 미청산 | 0 (미수집) | +| 462330 | 09:09:58 | - | 미청산 | 0 | +| 462330 | 09:12:55 | - | 미청산 | 0 | +| 138360 | 09:13:13 | - | 미청산 | 0 | +| 090710 | 10:03:19 | - | 미청산 | 0 | +| 018880 | 10:03:25 | - | 미청산 | 0 | +| **018880** | 10:17:16 | 10:29:59 | **SL** | 0 | +| **036540** | 10:17:26 | 10:28:34 | **SL** | 0 | +| **090710** | 10:28:44 | 10:32:27 | **SL** | 0 | +| 252670 | 10:30:19 | - | 미청산 | 0 | + +## 분석 및 피드백 + +### 데이터 품질 문제 (우선 해결 필요) +- **entry_price / exit_price 모두 0.0**: DB에 가격 데이터가 저장되지 않는 버그 + - DRY_RUN=true 상태에서 주문 체결 시 가격을 기록하지 않는 것으로 추정 + - pnl 계산 불가 → 전략 평가 전혀 불가 +- **daily_summary 테이블 미기록**: 봇 결산 로직이 DB에 저장하지 않음 + +### 봇 중복 실행 문제 +- 14:54:14 ~ 14:55:24 사이에 봇이 3번 재시작됨 +- 14:50 강제청산 이후 재시작으로 추정되나 원인 불명확 +- `/start-bot` 중복 방지 로직이 종료된 프로세스를 감지하지 못한 것으로 보임 + +### SL 패턴 관찰 +- 10:17~10:32 시간대에 SL 3건이 집중 (약 15분 내) +- 3연속 손절 패턴 → L3 조건에 해당하나, DRY_RUN이라 실제 매매 중단은 없음 +- 10:30에도 새 진입 신호 발생 (252670) → L3 중단 로직 점검 필요 + +### 긍정적 사항 +- 변동성 돌파 신호 자체는 감지됨 (09:09~10:30에 10건) +- 손절 청산 로직이 작동함 (exit_reason='SL' 기록) +- 강제청산(14:50) 이후 신호 없음 → 시간 제한 작동 확인 + +## 파라미터 변경 +없음 — 가격 데이터가 모두 0이라 SL/TP 효과 평가 불가. 데이터 품질 문제 해결 후 재평가 필요. + +## 실전 전환 조건 +| 조건 | 기준 | 현재 | 통과 | +|------|------|------|------| +| 누적 운영 | 30거래일 이상 | 0일 | ❌ | +| 승률 | > 48% | 데이터 없음 | ❌ | +| MDD | < -10% | 데이터 없음 | ❌ | +| 샤프지수 | > 1.0 | 데이터 없음 | ❌ | +| L3 발동 | 월 2회 이하 | 0회 | ✅ | + +**실전 전환: 미충족 (4/5 조건 데이터 부족)** + +## 다음 우선순위 +1. **DB 가격 데이터 저장 버그 수정** — entry_price/exit_price가 0으로 저장되는 원인 확인 +2. **daily_summary 자동 기록** — 결산 시 DB에 저장하는 로직 점검 +3. **L3 중단 후 재진입 차단** — SL 3연속 이후에도 새 진입 신호가 발생한 점 확인 diff --git a/reports/daily/2026-05-27.md b/reports/daily/2026-05-27.md index 2d7a6c4..52854ba 100644 --- a/reports/daily/2026-05-27.md +++ b/reports/daily/2026-05-27.md @@ -115,3 +115,13 @@ - 14:50 강제청산 정상 작동 확인 - 신호진단 로그 정상 작동 확인 - 장 종료 후 미청산 포지션 없음 +- Applied `ENTRY_START = "09:15"` in `app/config.py`. +- Reason: 09:05-09:06 produced four immediate SL trades and about 74.5% of today's loss. +- Deferred: time-based position-size reduction and time-based SL changes. These need more data. + +### Strategy Change Log + +- User approved the change after reviewing Claude Evening feedback. +- Applied `ENTRY_START = "09:15"`. +- Verified by importing `ENTRY_START` from `app.config`. +- Expected next validation: next trading day after 08:15 scheduled restart. diff --git a/reports/implementation_log.md b/reports/implementation_log.md new file mode 100644 index 0000000..d84e4bd --- /dev/null +++ b/reports/implementation_log.md @@ -0,0 +1,80 @@ +# Implementation Log + +## 2026-05-27 + +- Reviewed the stock scalping bot structure and moved it toward an AI-training-ready paper-trading platform. +- Added database tables for AI training: + - `entry_snapshots` + - `post_entry_snapshots` +- Added entry-time data capture after successful buys. +- Added post-entry sampling at 60s, 180s, 300s, and 600s. +- Fixed partial-exit accounting so partial TP1 exits no longer close the whole trade row. +- Relaxed strict entry-limit enforcement during paper-trading data collection while preserving warning logs. +- Added daily/export training scripts: + - `scripts/export_training_dataset.py` + - `scripts/collect_daily_features.py` + - `scripts/collect_minute_data.py` + - `scripts/build_external_training_dataset.py` +- Added ML training and runtime support: + - `app/ml/features.py` + - `app/ml/predictor.py` + - `scripts/train_ai_model.py` +- Added observation-only AI scoring: + - Runtime scores are recorded only when a trained model exists. + - AI scores do not block entries, change sizing, or override exits. +- Added daily training pipeline: + - `scripts/run_training_pipeline.ps1` +- Registered and verified Windows Scheduler tasks: + - `StockBot_Morning` + - `StockBot_Watchdog` + - `StockBot_Midday` + - `StockBot_Evening` + - `StockBot_Training` +- Rewrote scheduler setup to avoid hardcoded broken Korean paths: + - `scripts/setup_scheduler.ps1` +- Rewrote watchdog wrapper with trading-day and time-window checks: + - `scripts/run_watchdog.ps1` +- Added dependency portability: + - Root `requirements.txt` + - `scripts/install_dependencies.ps1` + - `scripts/download_dependencies.ps1` + - `vendor/wheels` wheelhouse for Windows/Python 3.11 +- Updated operational docs: + - `README.md` + - `CLAUDE.md` + - `reports/daily/2026-05-27.md` + +Validation performed: +- Python compile check passed. +- DB migration checked. +- AI score snapshot insert checked with a temporary DB. +- Training script checked with empty dataset. +- Scheduler registration checked. +- PowerShell script parse check passed. + +Open risks: +- KIS minute endpoint still needs live response verification. +- Early model quality is expected to be weak until enough labeled rows exist. +- External minute data is useful for pretraining, not final bot-trade truth. +- Real-cash trading still needs stronger fill, partial-fill, unfilled-order, cancel/replace, and recovery handling. +- Raspberry Pi dependency packaging needs a Linux/ARM-specific setup. +- Approved and applied `ENTRY_START = "09:15"` after the 2026-05-27 evening review. +- Reason: 09:05-09:06 generated four immediate SL trades and most of the daily loss. +- Time-based sizing and time-based SL changes remain deferred. + +### 2026-05-27 Strategy Approval Log + +- User approved Claude Evening Proposal 1. +- Changed `app/config.py`: + - Before: `ENTRY_START = "09:05"` + - After: `ENTRY_START = "09:15"` +- Rationale: + - Four immediate SL trades occurred from 09:05:03 to 09:05:49. + - Those four trades lost about `-183,969 KRW`. + - This represented about 74.5% of the daily loss. +- Deferred: + - Time-based position-size reduction. + - Time-based stop-loss adjustment. +- Verification: + - Python compile check passed. + - Runtime import confirmed `ENTRY_START == "09:15"`. diff --git a/reports/proposals/.gitkeep b/reports/proposals/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/reports/proposals/.gitkeep @@ -0,0 +1 @@ + diff --git a/reports/proposals/2026-05-27_strategy_proposal.md b/reports/proposals/2026-05-27_strategy_proposal.md index fe55637..33baf4c 100644 --- a/reports/proposals/2026-05-27_strategy_proposal.md +++ b/reports/proposals/2026-05-27_strategy_proposal.md @@ -101,3 +101,10 @@ SL_PCT = 0.020 - [ ] 과거 5/19~5/22 기간 09:05~09:15 진입 건 별도 확인 (DB 직접 조회) - [ ] ENTRY_START 변경 시 morning 분석의 종목 선정 로직과 충돌 없는지 확인 - [ ] 변경 적용 후 최소 5거래일 관찰 후 효과 재검토 +# Manual Approval Update - 2026-05-27 + +- Proposal 1 approved and applied. +- `ENTRY_START` changed from `"09:05"` to `"09:15"` in `app/config.py`. +- Proposal 2 and Proposal 3 remain deferred until more data is collected. + +--- diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c884df8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +-r app/requirements.txt diff --git a/scripts/_check_status.py b/scripts/_check_status.py new file mode 100644 index 0000000..b574492 --- /dev/null +++ b/scripts/_check_status.py @@ -0,0 +1,29 @@ +import sys, os +sys.path.insert(0, '.') +from app.db.models import get_conn +from datetime import datetime + +with get_conn() as c: + pos = c.execute('SELECT ticker, name, entry_time, entry_price, quantity, stop_price FROM positions').fetchall() + +print('=== 현재 포지션 ===') +if pos: + for r in pos: + print(f' {r[1]}({r[0]}) {r[4]}주 @ {r[3]:,.0f}원 SL={r[5]:,.0f}원 진입={r[2]}') +else: + print(' 없음') + +today = datetime.now().strftime('%Y-%m-%d') +with get_conn() as c: + trades = c.execute( + "SELECT name, exit_reason, pnl FROM trades WHERE date=? AND exit_time IS NOT NULL ORDER BY id", + (today,) + ).fetchall() + +print(f'\n=== 오늘 거래 ({len(trades)}건) ===') +wins = sum(1 for t in trades if t[2] and t[2] > 0) +losses = len(trades) - wins +for t in trades: + sign = '+' if t[2] and t[2] > 0 else '' + print(f' {t[0]:12} [{t[1]:4}] {sign}{t[2]:,.0f}원') +print(f' 승:{wins} 패:{losses}') diff --git a/scripts/_discord_notify_start.py b/scripts/_discord_notify_start.py new file mode 100644 index 0000000..c495776 --- /dev/null +++ b/scripts/_discord_notify_start.py @@ -0,0 +1,9 @@ +import asyncio, sys, os +sys.path.insert(0, r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3') +from app.main import load_env; load_env() +from app.monitor.notifier import send +mode = os.getenv('KIS_MOCK', 'true') +dry = os.getenv('DRY_RUN', 'true') +label = '[모의투자]' if mode == 'true' else '[실거래]' +asyncio.run(send(f'{label} 자동매매 봇 시작 (DRY_RUN={dry})')) +print('Discord 알림 전송 완료') diff --git a/scripts/_kill_bot.py b/scripts/_kill_bot.py new file mode 100644 index 0000000..13ab26e --- /dev/null +++ b/scripts/_kill_bot.py @@ -0,0 +1,25 @@ +import subprocess, os + +pid_file = r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3\logs\bot.pid' + +if os.path.exists(pid_file): + try: + pid = int(open(pid_file).read().strip()) + subprocess.run(['taskkill', '/PID', str(pid), '/F'], capture_output=True) + print(f'PID 파일 종료: {pid}') + except Exception as e: + print(f'PID 파일 종료 실패: {e}') + os.remove(pid_file) + +r = subprocess.run( + ['powershell', '-Command', + 'Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like "*app/main.py*" -or $_.CommandLine -like "*app\\main.py*" } | Select-Object -ExpandProperty ProcessId'], + capture_output=True, text=True +) +pids = [p.strip() for p in r.stdout.strip().splitlines() if p.strip().isdigit()] +for pid in pids: + subprocess.run(['taskkill', '/PID', pid, '/F'], capture_output=True) + print(f'잔존 프로세스 종료: {pid}') + +if not pids: + print('실행 중인 봇 없음 — 새로 시작합니다') diff --git a/scripts/_kill_existing.py b/scripts/_kill_existing.py new file mode 100644 index 0000000..891ccf7 --- /dev/null +++ b/scripts/_kill_existing.py @@ -0,0 +1,29 @@ +import subprocess, os + +pid_file = r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3\logs\bot.pid' + +# 1-a) PID 파일로 종료 +if os.path.exists(pid_file): + try: + pid = int(open(pid_file).read().strip()) + subprocess.run(['taskkill', '/PID', str(pid), '/F'], capture_output=True) + print(f'PID 파일 종료: {pid}') + except Exception as e: + print(f'PID 파일 종료 실패: {e}') + os.remove(pid_file) +else: + print('PID 파일 없음') + +# 1-b) Get-CimInstance로 잔존 프로세스 스캔 +r = subprocess.run( + ['powershell', '-Command', + 'Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like "*app/main.py*" -or $_.CommandLine -like "*app\\main.py*" } | Select-Object -ExpandProperty ProcessId'], + capture_output=True, text=True +) +pids = [p.strip() for p in r.stdout.strip().splitlines() if p.strip().isdigit()] +for pid in pids: + subprocess.run(['taskkill', '/PID', pid, '/F'], capture_output=True) + print(f'잔존 프로세스 종료: {pid}') + +if not pids: + print('실행 중인 봇 없음 — 새로 시작합니다') diff --git a/scripts/_notify_bot.py b/scripts/_notify_bot.py new file mode 100644 index 0000000..2607790 --- /dev/null +++ b/scripts/_notify_bot.py @@ -0,0 +1,9 @@ +import asyncio, sys, os +sys.path.insert(0, '.') +from app.main import load_env; load_env() +from app.monitor.notifier import send +mode = os.getenv('KIS_MOCK', 'true') +dry = os.getenv('DRY_RUN', 'true') +label = '[모의투자]' if mode == 'true' else '[실거래]' +asyncio.run(send(f'{label} 자동매매 봇 시작 (DRY_RUN={dry})')) +print('Discord 알림 전송 완료') diff --git a/scripts/_notify_start.py b/scripts/_notify_start.py new file mode 100644 index 0000000..2607790 --- /dev/null +++ b/scripts/_notify_start.py @@ -0,0 +1,9 @@ +import asyncio, sys, os +sys.path.insert(0, '.') +from app.main import load_env; load_env() +from app.monitor.notifier import send +mode = os.getenv('KIS_MOCK', 'true') +dry = os.getenv('DRY_RUN', 'true') +label = '[모의투자]' if mode == 'true' else '[실거래]' +asyncio.run(send(f'{label} 자동매매 봇 시작 (DRY_RUN={dry})')) +print('Discord 알림 전송 완료') diff --git a/scripts/_register_watchdog.py b/scripts/_register_watchdog.py new file mode 100644 index 0000000..25a4010 --- /dev/null +++ b/scripts/_register_watchdog.py @@ -0,0 +1,113 @@ +""" +워치독 태스크를 XML로 직접 등록 +관리자 권한 없이 현재 사용자 세션으로 등록 +""" +import subprocess, sys, os, tempfile + +PROJECT = r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3' +SCRIPT = fr'{PROJECT}\scripts\run_watchdog.ps1' + +xml = f''' + + + \\StockBot\\StockBot_Watchdog + + + + InteractiveToken + LeastPrivilege + + + + false + false + PT3M + IgnoreNew + true + + PT10M + PT1H + true + false + + + + + 2026-05-27T09:00:00+09:00 + + PT5M + PT6H10M + true + + + 1 + + + + + + + + + + + + + powershell.exe + -NonInteractive -ExecutionPolicy Bypass -File "{SCRIPT}" + {PROJECT} + + +''' + +# UTF-16 LE BOM으로 임시 파일 저장 (Task Scheduler XML 요구사항) +with tempfile.NamedTemporaryFile(suffix='.xml', delete=False, mode='wb') as f: + f.write(b'\xff\xfe') # UTF-16 LE BOM + f.write(xml.encode('utf-16-le')) + tmp = f.name + +try: + # 1단계: XML로 등록 + ps_cmd = f'Register-ScheduledTask -TaskName "StockBot_Watchdog" -TaskPath "\\\\StockBot\\\\" -Xml (Get-Content -Path \'{tmp}\' -Raw -Encoding Unicode) -Force | Out-Null; Write-Host "1단계 완료"' + r = subprocess.run( + ['powershell', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', ps_cmd], + capture_output=True, text=True, encoding='utf-8', errors='replace' + ) + print(r.stdout.strip()) + + # 2단계: 한글 경로 깨짐 교정 (setup_scheduler.ps1과 동일 방식) + # 등록된 태스크의 Arguments에서 garbled 문자열 추출 → 정상 한글로 치환 후 재등록 + fix_cmd = r''' +$task = Get-ScheduledTask -TaskName "StockBot_Watchdog" -TaskPath "\StockBot\" +$stored = $task.Actions[0].Arguments +if ($stored -match [regex]::Escape("바탕 화면")) { + Write-Host "경로 정상" +} else { + $xml = Export-ScheduledTask -TaskName "StockBot_Watchdog" -TaskPath "\StockBot\" + if ($stored -match 'OneDrive\\(.+?)\\stockbot') { + $garbled = $Matches[1] + $fixedXml = $xml.Replace($garbled, "바탕 화면") + Register-ScheduledTask -TaskName "StockBot_Watchdog" -TaskPath "\StockBot\" -Xml $fixedXml -Force | Out-Null + Write-Host "한글 경로 교정 완료" + } else { + Write-Host "교정 패턴 미일치 — 수동 확인 필요" + } +} +''' + r2 = subprocess.run( + ['powershell', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', fix_cmd], + capture_output=True, text=True, encoding='utf-8', errors='replace' + ) + print(r2.stdout.strip()) + if r2.returncode != 0: + print('STDERR:', r2.stderr.strip()) + + # 최종 확인 + check = subprocess.run( + ['powershell', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', + 'Get-ScheduledTask -TaskPath "\\StockBot\\" | Format-Table TaskName, State -AutoSize'], + capture_output=True, text=True, encoding='utf-8', errors='replace' + ) + print(check.stdout) +finally: + os.unlink(tmp) diff --git a/scripts/_send_midday_discord.py b/scripts/_send_midday_discord.py new file mode 100644 index 0000000..c1ba250 --- /dev/null +++ b/scripts/_send_midday_discord.py @@ -0,0 +1,17 @@ +import asyncio, json, sys +sys.path.insert(0, '.') +from app.main import load_env; load_env() +from app.monitor.notifier import send + +ctx = json.load(open('data/midday_context.json', encoding='utf-8')) +hot = ', '.join(ctx.get('hot_sectors', [])) or '없음' +avoid = ', '.join(ctx.get('avoid_sectors', [])) or '없음' +flag = '✅ 점심진입허용' if ctx.get('lunch_trade_allowed', True) else '🚫 점심진입중단' +msg = ( + f'[장중분석] {ctx["date"]} {ctx.get("generated_at","")}\n' + f'{flag} | 포지션배율: x{ctx.get("position_size_multiplier", 1.0)}\n' + f'주목: {hot} | 회피: {avoid}\n' + f'📝 {ctx.get("reason","")}' +) +asyncio.run(send(msg)) +print('Discord 전송 완료') diff --git a/scripts/_start2_tmp.py b/scripts/_start2_tmp.py new file mode 100644 index 0000000..76843bb --- /dev/null +++ b/scripts/_start2_tmp.py @@ -0,0 +1,54 @@ +import subprocess, os, sys, asyncio, time + +BASE = r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3' +pid_file = os.path.join(BASE, 'logs', 'bot.pid') +os.chdir(BASE) +sys.path.insert(0, BASE) + +# 토큰 발급 가능한지 테스트 (최대 3분 재시도) +from app.main import load_env +load_env() + +max_wait = 180 +interval = 15 +elapsed = 0 +while elapsed < max_wait: + try: + import httpx + from app.execution.kis_client import KISClient + client = KISClient() + asyncio.run(client.get_access_token()) + print(f"토큰 발급 성공 ({elapsed}초 경과)") + break + except RuntimeError as e: + if "EGW00133" in str(e) or "잠시 후" in str(e): + print(f"토큰 대기 중... ({elapsed}초, {interval}초 후 재시도)") + time.sleep(interval) + elapsed += interval + else: + print(f"토큰 오류 (재시도 불가): {e}") + sys.exit(1) +else: + print("토큰 발급 타임아웃") + sys.exit(1) + +# 봇 시작 +log_path = os.path.join(BASE, "logs", "bot_stderr.log") +proc = subprocess.Popen( + [sys.executable, "app/main.py"], + creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP, + stdout=open(log_path, "a", encoding="utf-8"), + stderr=subprocess.STDOUT, + close_fds=True, +) +with open(pid_file, "w") as f: + f.write(str(proc.pid)) +print(f"봇 시작 완료 PID={proc.pid}") + +from app.monitor.notifier import send +import os as _os +mode = _os.getenv("KIS_MOCK", "true") +dry = _os.getenv("DRY_RUN", "true") +label = "[모의투자]" if mode == "true" else "[실거래]" +asyncio.run(send(f"{label} 자동매매 봇 시작 (DRY_RUN={dry})")) +print("Discord 알림 전송 완료") diff --git a/scripts/_start_bot.py b/scripts/_start_bot.py new file mode 100644 index 0000000..aae71d4 --- /dev/null +++ b/scripts/_start_bot.py @@ -0,0 +1,52 @@ +import subprocess, os, sys, asyncio + +pid_file = r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3\logs\bot.pid' +project_dir = r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3' + +# 1-a) PID 파일로 종료 +if os.path.exists(pid_file): + try: + pid = int(open(pid_file).read().strip()) + subprocess.run(['taskkill', '/PID', str(pid), '/F'], capture_output=True) + print(f'PID 파일 종료: {pid}') + except Exception as e: + print(f'PID 파일 종료 실패: {e}') + os.remove(pid_file) + +# 1-b) Get-CimInstance로 잔존 프로세스 스캔 +r = subprocess.run( + ['powershell', '-Command', + 'Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like "*app/main.py*" -or $_.CommandLine -like "*app\\main.py*" } | Select-Object -ExpandProperty ProcessId'], + capture_output=True, text=True +) +pids = [p.strip() for p in r.stdout.strip().splitlines() if p.strip().isdigit()] +for pid in pids: + subprocess.run(['taskkill', '/PID', pid, '/F'], capture_output=True) + print(f'잔존 프로세스 종료: {pid}') + +if not pids: + print('실행 중인 봇 없음 — 새로 시작합니다') + +# 2. 봇 시작 +os.chdir(project_dir) +proc = subprocess.Popen( + [sys.executable, 'app/main.py'], + creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP, + stdout=open('logs/bot_stderr.log', 'a', encoding='utf-8'), + stderr=subprocess.STDOUT, + close_fds=True, +) +with open('logs/bot.pid', 'w') as f: + f.write(str(proc.pid)) +print(f'봇 시작 완료 PID={proc.pid}') + +# 3. Discord 알림 +sys.path.insert(0, '.') +from app.main import load_env +load_env() +from app.monitor.notifier import send +mode = os.getenv('KIS_MOCK', 'true') +dry = os.getenv('DRY_RUN', 'true') +label = '[모의투자]' if mode == 'true' else '[실거래]' +asyncio.run(send(f'{label} 자동매매 봇 시작 (DRY_RUN={dry})')) +print('Discord 알림 전송 완료') diff --git a/scripts/_start_bot_proc.py b/scripts/_start_bot_proc.py new file mode 100644 index 0000000..e918a1c --- /dev/null +++ b/scripts/_start_bot_proc.py @@ -0,0 +1,14 @@ +import subprocess, sys, os + +os.chdir(r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3') +proc = subprocess.Popen( + [sys.executable, 'app/main.py'], + creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP, + stdout=open('logs/bot_stderr.log', 'a', encoding='utf-8'), + stderr=subprocess.STDOUT, + close_fds=True, +) +# PID 파일 저장 (BOM 없이 저장) +with open('logs/bot.pid', 'w', encoding='utf-8', newline='') as f: + f.write(str(proc.pid)) +print(f'봇 시작 완료 PID={proc.pid}') diff --git a/scripts/_start_tmp.py b/scripts/_start_tmp.py new file mode 100644 index 0000000..0c4d912 --- /dev/null +++ b/scripts/_start_tmp.py @@ -0,0 +1,58 @@ +import subprocess, os, sys, asyncio + +BASE = r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3' +pid_file = os.path.join(BASE, 'logs', 'bot.pid') + +# 1-a) PID 파일로 종료 +if os.path.exists(pid_file): + try: + pid = int(open(pid_file).read().strip()) + result = subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True, text=True) + print(f"PID 파일 종료: {pid} -> returncode={result.returncode}") + except Exception as e: + print(f"PID 파일 종료 실패: {e}") + os.remove(pid_file) +else: + print("PID 파일 없음") + +# 1-b) 잔존 프로세스 스캔 +r = subprocess.run( + ["powershell", "-Command", + "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*app/main.py*' -or $_.CommandLine -like '*app\\\\main.py*' } | Select-Object -ExpandProperty ProcessId"], + capture_output=True, text=True +) +pids = [p.strip() for p in r.stdout.strip().splitlines() if p.strip().isdigit()] +for pid in pids: + result = subprocess.run(["taskkill", "/PID", pid, "/F"], capture_output=True, text=True) + print(f"잔존 프로세스 종료: {pid} -> returncode={result.returncode}") + +if not pids: + print("실행 중인 봇 없음 — 새로 시작합니다") + +# 2. 봇 시작 + PID 저장 +os.chdir(BASE) +log_path = os.path.join(BASE, "logs", "bot_stderr.log") +env = os.environ.copy() +env["PYTHONUNBUFFERED"] = "1" +proc = subprocess.Popen( + [sys.executable, "-u", "app/main.py"], + creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP, + stdout=open(log_path, "a", encoding="utf-8"), + stderr=subprocess.STDOUT, + close_fds=True, + env=env, +) +with open(pid_file, "w") as f: + f.write(str(proc.pid)) +print(f"봇 시작 완료 PID={proc.pid}") + +# 3. Discord 알림 +sys.path.insert(0, BASE) +from app.main import load_env +load_env() +from app.monitor.notifier import send +mode = os.getenv("KIS_MOCK", "true") +dry = os.getenv("DRY_RUN", "true") +label = "[모의투자]" if mode == "true" else "[실거래]" +asyncio.run(send(f"{label} 자동매매 봇 시작 (DRY_RUN={dry})")) +print("Discord 알림 전송 완료") diff --git a/scripts/_test_init.py b/scripts/_test_init.py new file mode 100644 index 0000000..62c8805 --- /dev/null +++ b/scripts/_test_init.py @@ -0,0 +1,15 @@ +import sys, asyncio +sys.path.insert(0, '.') +from app.main import load_env, StockBot +load_env() + +async def test(): + bot = StockBot() + print("StockBot 생성 완료") + await bot.initialize() + print("initialize() 완료") + print("5초 슬립...") + await asyncio.sleep(5) + print("5초 완료 - 정상 실행 중") + +asyncio.run(test()) diff --git a/scripts/build_external_training_dataset.py b/scripts/build_external_training_dataset.py new file mode 100644 index 0000000..9659a92 --- /dev/null +++ b/scripts/build_external_training_dataset.py @@ -0,0 +1,170 @@ +""" +Build training rows from external minute bars. + +This generates synthetic candidate rows from minute bars, not actual bot trades. +Rows are useful for pretraining movement/holding-period models. +""" +import argparse +import csv +from collections import defaultdict +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +MINUTE_ROOT = ROOT / "data" / "external" / "minute" +DAILY_ROOT = ROOT / "data" / "external" / "daily" +DEFAULT_OUT = ROOT / "data" / "external_training_dataset.csv" + + +CHECKPOINTS = (1, 3, 5, 10) + + +def _read_csv(path: Path) -> list[dict]: + with path.open("r", encoding="utf-8-sig", newline="") as f: + return list(csv.DictReader(f)) + + +def _num(value, default=0.0): + try: + return float(str(value).replace(",", "")) + except (TypeError, ValueError): + return default + + +def _load_daily_amounts() -> dict[tuple[str, str], dict]: + result = {} + for file in DAILY_ROOT.glob("*/stocks.csv"): + rows = _read_csv(file) + for row in rows: + date = str(row.get("date") or file.parent.name) + ticker = str(row.get("ticker") or row.get("티커") or "") + if ticker: + result[(date, ticker)] = row + return result + + +def _future_metrics(rows: list[dict], idx: int, entry_price: float): + metrics = {} + highs = [] + lows = [] + for minutes in CHECKPOINTS: + j = idx + minutes + if j >= len(rows): + metrics[f"price_{minutes}m"] = "" + metrics[f"ret_{minutes}m"] = "" + metrics[f"mfe_{minutes}m"] = "" + metrics[f"mae_{minutes}m"] = "" + continue + window = rows[idx + 1:j + 1] + highs.extend(_num(r["high"]) for r in window) + lows.extend(_num(r["low"]) for r in window) + close = _num(rows[j]["close"]) + high = max(highs) if highs else entry_price + low = min(lows) if lows else entry_price + metrics[f"price_{minutes}m"] = close + metrics[f"ret_{minutes}m"] = (close - entry_price) / entry_price * 100 if entry_price else 0 + metrics[f"mfe_{minutes}m"] = (high - entry_price) / entry_price * 100 if entry_price else 0 + metrics[f"mae_{minutes}m"] = (low - entry_price) / entry_price * 100 if entry_price else 0 + return metrics + + +def _rows_for_file(path: Path, daily: dict, k: float, breakout_only: bool): + rows = _read_csv(path) + rows = [r for r in rows if r.get("time") and _num(r.get("close")) > 0] + rows.sort(key=lambda r: (r.get("date", ""), r.get("time", ""))) + if len(rows) < 20: + return [] + + by_date = defaultdict(list) + for row in rows: + by_date[row["date"]].append(row) + + out = [] + prev_by_date = {} + for date in sorted(by_date): + day_rows = by_date[date] + ticker = day_rows[0]["ticker"] + daily_row = daily.get((date, ticker), {}) + prev_high = _num(daily_row.get("high")) + prev_low = _num(daily_row.get("low")) + prev_amount = _num(daily_row.get("amount")) + + if not prev_high or not prev_low: + prev = prev_by_date.get(ticker) + if prev: + prev_high = prev["high"] + prev_low = prev["low"] + prev_amount = prev["amount"] + + today_open = _num(day_rows[0]["open"]) or _num(day_rows[0]["close"]) + target = today_open + (prev_high - prev_low) * k if prev_high and prev_low else 0 + crossed = False + + for idx, row in enumerate(day_rows[:-max(CHECKPOINTS)]): + tm = row["time"][:4] + if tm < "0905" or tm >= "1400": + continue + close = _num(row["close"]) + if breakout_only: + if not target or close < target or crossed: + continue + crossed = True + + metrics = _future_metrics(day_rows, idx, close) + label_win = 1 if _num(metrics.get("ret_10m")) > 0 else 0 + label_stop_loss = 1 if _num(metrics.get("mae_10m")) <= -2.0 else 0 + out.append({ + "source": "external_minute", + "date": date, + "ticker": ticker, + "entry_time": row["time"], + "current_price": close, + "entry_price": close, + "target_price": target, + "today_open": today_open, + "prev_high": prev_high, + "prev_low": prev_low, + "prev_amount": prev_amount, + "volume": row.get("volume", ""), + **metrics, + "label_win": label_win, + "label_stop_loss": label_stop_loss, + }) + + prev_by_date[ticker] = { + "high": max(_num(r["high"]) for r in day_rows), + "low": min(_num(r["low"]) for r in day_rows), + "amount": sum(_num(r["close"]) * _num(r["volume"]) for r in day_rows), + } + return out + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--minute-root", default=str(MINUTE_ROOT)) + parser.add_argument("--out", default=str(DEFAULT_OUT)) + parser.add_argument("--k", type=float, default=0.5) + parser.add_argument("--all-minutes", action="store_true", help="Use every eligible minute, not only first breakout.") + args = parser.parse_args() + + daily = _load_daily_amounts() + all_rows = [] + for path in Path(args.minute_root).glob("*/*.csv"): + all_rows.extend(_rows_for_file(path, daily, args.k, breakout_only=not args.all_minutes)) + + out_path = Path(args.out) + if not out_path.is_absolute(): + out_path = ROOT / out_path + out_path.parent.mkdir(parents=True, exist_ok=True) + + fieldnames = sorted({key for row in all_rows for key in row.keys()}) + with out_path.open("w", encoding="utf-8-sig", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(all_rows) + + print(f"external dataset rows={len(all_rows)} -> {out_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/collect_daily_features.py b/scripts/collect_daily_features.py new file mode 100644 index 0000000..3cd9c16 --- /dev/null +++ b/scripts/collect_daily_features.py @@ -0,0 +1,84 @@ +""" +Collect daily Korean-market features for model training. + +Outputs: + data/external/daily/YYYYMMDD/stocks.csv + data/external/daily/YYYYMMDD/indexes.csv +""" +import argparse +import sys +from datetime import datetime +from pathlib import Path + +import pandas as pd + + +ROOT = Path(__file__).resolve().parent.parent +OUT_ROOT = ROOT / "data" / "external" / "daily" + + +def _yyyymmdd(date_text: str | None) -> str: + if date_text: + return date_text.replace("-", "") + return datetime.now().strftime("%Y%m%d") + + +def _standardize_stock_ohlcv(df: pd.DataFrame, date_yyyymmdd: str) -> pd.DataFrame: + df = df.reset_index() + columns = list(df.columns) + rename = {columns[0]: "ticker"} + standard = ["open", "high", "low", "close", "volume", "amount", "change_pct"] + for source, target in zip(columns[1:], standard): + rename[source] = target + df = df.rename(columns=rename) + df.insert(0, "date", date_yyyymmdd) + return df[[c for c in ["date", "ticker", *standard] if c in df.columns]] + + +def _standardize_index_row(row: dict, date_yyyymmdd: str, code: str, name: str) -> dict: + values = list(row.values()) + keys = ["open", "high", "low", "close", "volume", "amount", "change_pct"] + out = {"date": date_yyyymmdd, "code": code, "name": name} + for key, value in zip(keys, values): + out[key] = value + return out + + +def collect_with_pykrx(date_yyyymmdd: str, out_dir: Path): + try: + from pykrx import stock + except ImportError as exc: + raise RuntimeError("pykrx is not installed. Install requirements first.") from exc + + stocks_raw = stock.get_market_ohlcv_by_ticker(date_yyyymmdd, market="ALL") + stocks = _standardize_stock_ohlcv(stocks_raw, date_yyyymmdd) + stocks.to_csv(out_dir / "stocks.csv", index=False, encoding="utf-8-sig") + + index_rows = [] + for code, name in (("1001", "KOSPI"), ("2001", "KOSDAQ")): + try: + df = stock.get_index_ohlcv_by_date(date_yyyymmdd, date_yyyymmdd, code) + if not df.empty: + index_rows.append(_standardize_index_row(df.iloc[-1].to_dict(), date_yyyymmdd, code, name)) + except Exception as exc: + print(f"index fetch failed {name}: {exc}", file=sys.stderr) + + pd.DataFrame(index_rows).to_csv(out_dir / "indexes.csv", index=False, encoding="utf-8-sig") + return len(stocks), len(index_rows) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--date", help="YYYY-MM-DD or YYYYMMDD. Defaults to today.") + args = parser.parse_args() + + date_yyyymmdd = _yyyymmdd(args.date) + out_dir = OUT_ROOT / date_yyyymmdd + out_dir.mkdir(parents=True, exist_ok=True) + + stock_count, index_count = collect_with_pykrx(date_yyyymmdd, out_dir) + print(f"saved daily features: stocks={stock_count}, indexes={index_count}, dir={out_dir}") + + +if __name__ == "__main__": + main() diff --git a/scripts/collect_minute_data.py b/scripts/collect_minute_data.py new file mode 100644 index 0000000..47f4605 --- /dev/null +++ b/scripts/collect_minute_data.py @@ -0,0 +1,102 @@ +""" +Collect KIS intraday minute bars for selected tickers. + +Usage: + python scripts/collect_minute_data.py --tickers 005930,000660 + python scripts/collect_minute_data.py --top 30 +""" +import argparse +import asyncio +import csv +import json +import os +import sys +from datetime import datetime +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from app.main import load_env +from app.execution.kis_client import KISClient + + +OUT_ROOT = ROOT / "data" / "external" / "minute" + + +def _date_dir(date_text: str | None) -> str: + if date_text: + return date_text.replace("-", "") + return datetime.now().strftime("%Y%m%d") + + +def _load_cached_tickers(limit: int) -> list[str]: + cache = ROOT / "data" / "universe_cache.json" + if not cache.exists(): + return [] + data = json.loads(cache.read_text(encoding="utf-8")) + return list(data.get("tickers", []))[:limit] + + +async def _resolve_tickers(kis: KISClient, args) -> list[str]: + if args.tickers: + return [t.strip() for t in args.tickers.split(",") if t.strip()] + + cached = _load_cached_tickers(args.top) + if cached: + return cached + + rank = await kis.get_volume_rank(top_n=args.top) + return [r["ticker"] for r in rank] + + +def _write_csv(path: Path, rows: list[dict]): + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="", encoding="utf-8-sig") as f: + writer = csv.DictWriter(f, fieldnames=["date", "time", "ticker", "open", "high", "low", "close", "volume"]) + writer.writeheader() + writer.writerows(rows) + + +async def main_async(args): + load_env() + if args.real_quotes: + os.environ["KIS_MOCK"] = "false" + + kis = KISClient() + await kis.get_access_token() + tickers = await _resolve_tickers(kis, args) + + out_dir = OUT_ROOT / _date_dir(args.date) + saved = 0 + for ticker in tickers: + try: + rows = await kis.get_ohlcv_minute(ticker, hour=args.hour) + if rows: + _write_csv(out_dir / f"{ticker}.csv", rows) + saved += 1 + print(f"saved {ticker}: {len(rows)} rows") + else: + print(f"no rows {ticker}") + except Exception as exc: + print(f"failed {ticker}: {exc}", file=sys.stderr) + await asyncio.sleep(args.sleep) + + print(f"minute collection done: saved={saved}/{len(tickers)}, dir={out_dir}") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--date", help="YYYY-MM-DD or YYYYMMDD. Defaults to today.") + parser.add_argument("--tickers", help="Comma-separated tickers.") + parser.add_argument("--top", type=int, default=30) + parser.add_argument("--hour", default="153000", help="KIS upper-bound time HHMMSS.") + parser.add_argument("--sleep", type=float, default=1.1) + parser.add_argument("--real-quotes", action="store_true", help="Use real quote API even if .env is mock.") + args = parser.parse_args() + asyncio.run(main_async(args)) + + +if __name__ == "__main__": + main() diff --git a/scripts/download_dependencies.ps1 b/scripts/download_dependencies.ps1 new file mode 100644 index 0000000..db33382 --- /dev/null +++ b/scripts/download_dependencies.ps1 @@ -0,0 +1,13 @@ +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +Set-Location $Root + +$Python = "python" +$Requirements = Join-Path $Root "requirements.txt" +$Wheelhouse = Join-Path $Root "vendor\wheels" + +New-Item -ItemType Directory -Force -Path $Wheelhouse | Out-Null + +Write-Host "[download] saving packages to $Wheelhouse" +& $Python -m pip download -r $Requirements -d $Wheelhouse diff --git a/scripts/export_training_dataset.py b/scripts/export_training_dataset.py new file mode 100644 index 0000000..3561c57 --- /dev/null +++ b/scripts/export_training_dataset.py @@ -0,0 +1,128 @@ +""" +Export entry snapshots joined with trade outcomes for later model training. + +Usage: + python scripts/export_training_dataset.py + python scripts/export_training_dataset.py data/training_dataset.csv +""" +import csv +import os +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from app.db.models import get_conn, init_db + + +DEFAULT_OUT = ROOT / "data" / "training_dataset.csv" + + +def export_csv(out_path: Path): + init_db() + out_path.parent.mkdir(parents=True, exist_ok=True) + + query = """ + WITH post AS ( + SELECT + trade_id, + MAX(CASE WHEN elapsed_sec = 60 THEN current_price END) AS price_1m, + MAX(CASE WHEN elapsed_sec = 60 THEN return_pct END) AS ret_1m, + MAX(CASE WHEN elapsed_sec = 60 THEN mfe_pct END) AS mfe_1m, + MAX(CASE WHEN elapsed_sec = 60 THEN mae_pct END) AS mae_1m, + MAX(CASE WHEN elapsed_sec = 180 THEN current_price END) AS price_3m, + MAX(CASE WHEN elapsed_sec = 180 THEN return_pct END) AS ret_3m, + MAX(CASE WHEN elapsed_sec = 180 THEN mfe_pct END) AS mfe_3m, + MAX(CASE WHEN elapsed_sec = 180 THEN mae_pct END) AS mae_3m, + MAX(CASE WHEN elapsed_sec = 300 THEN current_price END) AS price_5m, + MAX(CASE WHEN elapsed_sec = 300 THEN return_pct END) AS ret_5m, + MAX(CASE WHEN elapsed_sec = 300 THEN mfe_pct END) AS mfe_5m, + MAX(CASE WHEN elapsed_sec = 300 THEN mae_pct END) AS mae_5m, + MAX(CASE WHEN elapsed_sec = 600 THEN current_price END) AS price_10m, + MAX(CASE WHEN elapsed_sec = 600 THEN return_pct END) AS ret_10m, + MAX(CASE WHEN elapsed_sec = 600 THEN mfe_pct END) AS mfe_10m, + MAX(CASE WHEN elapsed_sec = 600 THEN mae_pct END) AS mae_10m + FROM post_entry_snapshots + GROUP BY trade_id + ) + SELECT + s.trade_id, + s.date, + s.ticker, + s.name, + s.entry_time, + s.current_price, + s.entry_price, + s.target_price, + s.stop_price, + s.today_open, + s.prev_high, + s.prev_low, + s.prev_amount, + s.volume, + s.change_pct, + s.market_sentiment, + s.sentiment_score, + s.risk_level, + s.trade_allowed, + s.hot_sectors, + s.avoid_sectors, + s.boosted_tickers, + s.blacklist_tickers, + s.ai_boosted, + s.ai_win_score, + s.ai_stop_loss_score, + s.ai_model_version, + s.position_size_multiplier, + s.combined_multiplier, + s.entry_reason, + post.price_1m, + post.ret_1m, + post.mfe_1m, + post.mae_1m, + post.price_3m, + post.ret_3m, + post.mfe_3m, + post.mae_3m, + post.price_5m, + post.ret_5m, + post.mfe_5m, + post.mae_5m, + post.price_10m, + post.ret_10m, + post.mfe_10m, + post.mae_10m, + t.exit_time, + t.exit_price, + t.quantity, + t.exit_reason, + t.pnl, + CASE WHEN t.pnl > 0 THEN 1 ELSE 0 END AS label_win, + CASE WHEN t.exit_reason = 'SL' THEN 1 ELSE 0 END AS label_stop_loss + FROM entry_snapshots s + LEFT JOIN trades t ON t.id = s.trade_id + LEFT JOIN post ON post.trade_id = s.trade_id + ORDER BY s.date, s.entry_time, s.ticker + """ + + with get_conn() as conn: + conn.row_factory = None + cur = conn.execute(query) + headers = [d[0] for d in cur.description] + rows = cur.fetchall() + + with out_path.open("w", newline="", encoding="utf-8-sig") as f: + writer = csv.writer(f) + writer.writerow(headers) + writer.writerows(rows) + + print(f"exported {len(rows)} rows -> {out_path}") + + +if __name__ == "__main__": + target = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_OUT + if not target.is_absolute(): + target = ROOT / target + export_csv(target) diff --git a/scripts/install_dependencies.ps1 b/scripts/install_dependencies.ps1 new file mode 100644 index 0000000..de1ff16 --- /dev/null +++ b/scripts/install_dependencies.ps1 @@ -0,0 +1,25 @@ +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +Set-Location $Root + +. "$Root\scripts\stockbot_env.ps1" +$Python = Resolve-StockBotPython -Project $Root +$Requirements = Join-Path $Root "requirements.txt" +$Wheelhouse = Join-Path $Root "vendor\wheels" + +if (-not (Test-Path $Requirements)) { + throw "requirements.txt not found: $Requirements" +} + +if (Test-Path $Wheelhouse) { + $WheelFiles = Get-ChildItem -Path $Wheelhouse -Filter "*.whl" -ErrorAction SilentlyContinue + if ($WheelFiles.Count -gt 0) { + Write-Host "[install] using local wheelhouse: $Wheelhouse" + & $Python -m pip install --no-index --find-links $Wheelhouse -r $Requirements + exit $LASTEXITCODE + } +} + +Write-Host "[install] using online package index" +& $Python -m pip install -r $Requirements diff --git a/scripts/restore_after_format.ps1 b/scripts/restore_after_format.ps1 new file mode 100644 index 0000000..d108113 --- /dev/null +++ b/scripts/restore_after_format.ps1 @@ -0,0 +1,192 @@ +param( + [switch]$SkipWinget, + [switch]$SkipCliInstall, + [switch]$SkipScheduler, + [switch]$OnlinePip +) + +$ErrorActionPreference = "Stop" + +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 +$env:PYTHONUTF8 = "1" +$env:PYTHONIOENCODING = "utf-8" + +$Project = Split-Path -Parent $PSScriptRoot +$VenvPython = Join-Path $Project ".venv\Scripts\python.exe" +$Requirements = Join-Path $Project "requirements.txt" +$Wheelhouse = Join-Path $Project "vendor\wheels" + +function Write-Step { + param([string]$Message) + Write-Host "" + Write-Host "== $Message ==" -ForegroundColor Cyan +} + +function Test-Admin { + $Identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $Principal = New-Object Security.Principal.WindowsPrincipal($Identity) + return $Principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Ensure-Directory { + param([string]$Path) + New-Item -ItemType Directory -Force -Path $Path | Out-Null +} + +function Install-WingetPackage { + param( + [string]$Id, + [string]$CommandName + ) + + if ($SkipWinget) { + Write-Host "[skip] winget install disabled for $Id" + return + } + + if ($CommandName -and (Get-Command $CommandName -ErrorAction SilentlyContinue)) { + Write-Host "[ok] $CommandName already available" + return + } + + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + Write-Host "[warn] winget not found. Install $Id manually if this step is needed." -ForegroundColor Yellow + return + } + + Write-Host "[install] $Id" + winget install --id $Id -e --accept-package-agreements --accept-source-agreements + Refresh-ProcessPath +} + +function Refresh-ProcessPath { + $MachinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") + $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") + $env:Path = "$MachinePath;$UserPath" +} + +function Resolve-BasePython { + Refresh-ProcessPath + + if (Get-Command py -ErrorAction SilentlyContinue) { + $Path = (& py -3.11 -c "import sys; print(sys.executable)" 2>$null) + if ($LASTEXITCODE -eq 0 -and $Path) { + return $Path.Trim() + } + } + + if (Get-Command python -ErrorAction SilentlyContinue) { + $Version = (& python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null) + if ($LASTEXITCODE -eq 0 -and $Version.Trim() -eq "3.11") { + $Path = (& python -c "import sys; print(sys.executable)") + return $Path.Trim() + } + } + + $KnownPaths = @( + (Join-Path $env:LOCALAPPDATA "Programs\Python\Python311\python.exe"), + "C:\Program Files\Python311\python.exe", + "C:\Program Files (x86)\Python311\python.exe" + ) + foreach ($KnownPath in $KnownPaths) { + if (Test-Path $KnownPath) { + return $KnownPath + } + } + + throw "Python 3.11 was not found. Re-run without -SkipWinget, or install Python 3.11 manually." +} + +function Install-NodeCli { + if ($SkipCliInstall) { + Write-Host "[skip] CLI install disabled" + return + } + + Refresh-ProcessPath + + if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { + Write-Host "[warn] npm not found. Claude/Codex CLI install skipped." -ForegroundColor Yellow + return + } + + $HasClaude = (Get-Command claude.cmd -ErrorAction SilentlyContinue) -or (Test-Path (Join-Path $env:APPDATA "npm\claude.cmd")) + $HasCodex = (Get-Command codex.cmd -ErrorAction SilentlyContinue) -or (Test-Path (Join-Path $env:APPDATA "npm\codex.cmd")) + + if (-not $HasClaude) { + Write-Host "[install] Claude Code CLI" + npm install -g @anthropic-ai/claude-code + } else { + Write-Host "[ok] Claude CLI already available" + } + + if (-not $HasCodex) { + Write-Host "[install] Codex CLI" + npm install -g @openai/codex + } else { + Write-Host "[ok] Codex CLI already available" + } +} + +if (-not (Test-Admin)) { + Write-Host "[warn] Not running as Administrator. Scheduler registration may fail." -ForegroundColor Yellow +} + +Set-Location $Project + +Write-Step "Project folders" +Ensure-Directory (Join-Path $Project "logs") +Ensure-Directory (Join-Path $Project "data") +Ensure-Directory (Join-Path $Project "models") +Ensure-Directory (Join-Path $Project "reports\daily") +Ensure-Directory (Join-Path $Project "reports\proposals") + +if (-not (Test-Path (Join-Path $Project ".env"))) { + Write-Host "[warn] .env not found. Restore it from your backup before live API use." -ForegroundColor Yellow +} + +Write-Step "System tools" +Refresh-ProcessPath +Install-WingetPackage -Id "Git.Git" -CommandName "git" +Install-WingetPackage -Id "OpenJS.NodeJS.LTS" -CommandName "node" +Install-WingetPackage -Id "Python.Python.3.11" -CommandName $null + +Write-Step "Python virtual environment" +$BasePython = Resolve-BasePython +Write-Host "[ok] Python base: $BasePython" + +if (-not (Test-Path $VenvPython)) { + & $BasePython -m venv (Join-Path $Project ".venv") +} + +& $VenvPython -m ensurepip --upgrade + +Write-Step "Python libraries" +if (-not (Test-Path $Requirements)) { + throw "requirements.txt not found: $Requirements" +} + +if ((Test-Path $Wheelhouse) -and -not $OnlinePip) { + Write-Host "[install] using local wheelhouse: $Wheelhouse" + & $VenvPython -m pip install --no-index --find-links $Wheelhouse -r $Requirements +} else { + Write-Host "[install] using online package index" + & $VenvPython -m pip install -r $Requirements +} + +Write-Step "Claude/Codex CLI" +Install-NodeCli + +Write-Step "Sanity checks" +& $VenvPython -c "import aiohttp, pandas, sklearn, joblib, holidays; print('python deps ok')" + +if (-not $SkipScheduler) { + Write-Step "Windows Task Scheduler" + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $Project "scripts\setup_scheduler.ps1") +} else { + Write-Host "[skip] scheduler registration disabled" +} + +Write-Step "Done" +Write-Host "Restore complete. Reboot once if newly installed winget packages are not visible in new terminals." -ForegroundColor Green diff --git a/scripts/run_bot.bat b/scripts/run_bot.bat index e26224b..13ce0c0 100644 --- a/scripts/run_bot.bat +++ b/scripts/run_bot.bat @@ -1,6 +1,8 @@ @echo off -REM 매매 봇 실행 스크립트 -REM 작업 스케줄러에서 07:55에 실행 (평일) - -cd /d "C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3" -python app\main.py +setlocal +cd /d "%~dp0.." +if exist ".venv\Scripts\python.exe" ( + ".venv\Scripts\python.exe" app\main.py +) else ( + python app\main.py +) diff --git a/scripts/run_bot.ps1 b/scripts/run_bot.ps1 index c3a5ccf..7870bc1 100644 --- a/scripts/run_bot.ps1 +++ b/scripts/run_bot.ps1 @@ -7,8 +7,9 @@ $env:PYTHONIOENCODING = "utf-8" $PROJECT = Split-Path -Parent $PSScriptRoot $LOG = "$PROJECT\logs\bot_start.log" -$CLAUDE = "C:\Users\whdwo\AppData\Roaming\npm\claude.cmd" -$PYTHON = "C:\Users\whdwo\.pyenv\pyenv-win\versions\3.11.9\python.exe" +. "$PROJECT\scripts\stockbot_env.ps1" +$CLAUDE = Resolve-StockBotClaude +$PYTHON = Resolve-StockBotPython -Project $PROJECT $utf8 = New-Object System.Text.UTF8Encoding $false Set-Location $PROJECT diff --git a/scripts/run_evening.ps1 b/scripts/run_evening.ps1 index 7aabdaf..2872fb8 100644 --- a/scripts/run_evening.ps1 +++ b/scripts/run_evening.ps1 @@ -9,8 +9,9 @@ $env:PYTHONIOENCODING = "utf-8" $PROJECT = Split-Path -Parent $PSScriptRoot $LOG = "$PROJECT\logs\evening.log" -$CLAUDE = "C:\Users\whdwo\AppData\Roaming\npm\claude.cmd" -$PYTHON = "C:\Users\whdwo\.pyenv\pyenv-win\versions\3.11.9\python.exe" +. "$PROJECT\scripts\stockbot_env.ps1" +$CLAUDE = Resolve-StockBotClaude +$PYTHON = Resolve-StockBotPython -Project $PROJECT $utf8 = New-Object System.Text.UTF8Encoding $false Set-Location $PROJECT diff --git a/scripts/run_midday.ps1 b/scripts/run_midday.ps1 index a93ce14..5a8011c 100644 --- a/scripts/run_midday.ps1 +++ b/scripts/run_midday.ps1 @@ -9,8 +9,9 @@ $env:PYTHONIOENCODING = "utf-8" $PROJECT = Split-Path -Parent $PSScriptRoot $LOG = "$PROJECT\logs\midday.log" -$CLAUDE = "C:\Users\whdwo\AppData\Roaming\npm\claude.cmd" -$PYTHON = "C:\Users\whdwo\.pyenv\pyenv-win\versions\3.11.9\python.exe" +. "$PROJECT\scripts\stockbot_env.ps1" +$CLAUDE = Resolve-StockBotClaude +$PYTHON = Resolve-StockBotPython -Project $PROJECT $utf8 = New-Object System.Text.UTF8Encoding $false Set-Location $PROJECT diff --git a/scripts/run_morning.ps1 b/scripts/run_morning.ps1 index e7878cd..5ce3e35 100644 --- a/scripts/run_morning.ps1 +++ b/scripts/run_morning.ps1 @@ -9,8 +9,9 @@ $env:PYTHONIOENCODING = "utf-8" $PROJECT = Split-Path -Parent $PSScriptRoot $LOG = "$PROJECT\logs\morning.log" -$CLAUDE = "C:\Users\whdwo\AppData\Roaming\npm\claude.cmd" -$PYTHON = "C:\Users\whdwo\.pyenv\pyenv-win\versions\3.11.9\python.exe" +. "$PROJECT\scripts\stockbot_env.ps1" +$CLAUDE = Resolve-StockBotClaude +$PYTHON = Resolve-StockBotPython -Project $PROJECT $utf8 = New-Object System.Text.UTF8Encoding $false Set-Location $PROJECT diff --git a/scripts/run_training_pipeline.ps1 b/scripts/run_training_pipeline.ps1 new file mode 100644 index 0000000..669c617 --- /dev/null +++ b/scripts/run_training_pipeline.ps1 @@ -0,0 +1,56 @@ +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +Set-Location $Root +. "$Root\scripts\stockbot_env.ps1" +$Python = Resolve-StockBotPython -Project $Root + +$LogDir = Join-Path $Root "logs" +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null +$LogPath = Join-Path $LogDir "training.log" + +function Write-Log { + param([string]$Message) + $Line = "[{0}] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Message + Add-Content -Path $LogPath -Value $Line -Encoding UTF8 + Write-Host $Line +} + +function Invoke-PythonStep { + param( + [string]$Name, + [string[]]$Args, + [bool]$Required = $true + ) + + Write-Log $Name + & $Python @Args *>> $LogPath + $Code = $LASTEXITCODE + if ($Code -ne 0) { + $Message = "$Name failed with exit code $Code" + if ($Required) { + throw $Message + } + Write-Log "warning: $Message" + } +} + +Write-Log "training pipeline started" + +$HolidayCheck = & $Python scripts\_is_trading_day.py 2>&1 +if ($LASTEXITCODE -ne 0) { + Write-Log "market closed - skipped ($HolidayCheck)" + exit 0 +} + +Invoke-PythonStep -Name "collecting daily market features" -Args @("scripts\collect_daily_features.py") -Required $false + +Invoke-PythonStep -Name "collecting KIS minute data" -Args @("scripts\collect_minute_data.py", "--top", "30", "--real-quotes") -Required $false + +Invoke-PythonStep -Name "exporting bot training dataset" -Args @("scripts\export_training_dataset.py", "data\training_dataset.csv") -Required $true + +Invoke-PythonStep -Name "building external training dataset" -Args @("scripts\build_external_training_dataset.py", "--out", "data\external_training_dataset.csv") -Required $true + +Invoke-PythonStep -Name "training model" -Args @("scripts\train_ai_model.py") -Required $true + +Write-Log "training pipeline finished" diff --git a/scripts/run_watchdog.ps1 b/scripts/run_watchdog.ps1 index cd05be2..a8a208b 100644 --- a/scripts/run_watchdog.ps1 +++ b/scripts/run_watchdog.ps1 @@ -1,21 +1,39 @@ -# 워치독 스크립트 — 봇 생존 감시 + 자동 재시작 -# 작업 스케줄러에서 5분마다 실행 (평일 09:00~15:10) +$ErrorActionPreference = "Stop" $OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$env:PYTHONUTF8 = "1" $env:PYTHONIOENCODING = "utf-8" $PROJECT = Split-Path -Parent $PSScriptRoot -$LOG = "$PROJECT\logs\watchdog.log" -$utf8 = New-Object System.Text.UTF8Encoding $false +$LOG = "$PROJECT\logs\watchdog.log" +. "$PROJECT\scripts\stockbot_env.ps1" +$PYTHON = Resolve-StockBotPython -Project $PROJECT +$utf8 = New-Object System.Text.UTF8Encoding $false Set-Location $PROJECT -$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" -[System.IO.File]::AppendAllText($LOG, "[$timestamp] 워치독 실행`n", $utf8) +function Write-WatchdogLog { + param([string]$Message) + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + [System.IO.File]::AppendAllText($LOG, "[$timestamp] $Message`n", $utf8) +} -python scripts/_watchdog.py 2>&1 | +$holidayResult = & $PYTHON scripts\_is_trading_day.py 2>&1 +if ($LASTEXITCODE -ne 0) { + Write-WatchdogLog "market closed - skipped ($holidayResult)" + exit 0 +} + +$now = Get-Date +$start = Get-Date -Hour 9 -Minute 0 -Second 0 +$end = Get-Date -Hour 15 -Minute 10 -Second 59 +if ($now -lt $start -or $now -gt $end) { + Write-WatchdogLog "outside watchdog window - skipped" + exit 0 +} + +Write-WatchdogLog "watchdog started" +& $PYTHON scripts\_watchdog.py 2>&1 | ForEach-Object { [System.IO.File]::AppendAllText($LOG, "$_`n", $utf8) } - -$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" -[System.IO.File]::AppendAllText($LOG, "[$timestamp] 워치독 완료`n", $utf8) +Write-WatchdogLog "watchdog finished" diff --git a/scripts/setup_scheduler.ps1 b/scripts/setup_scheduler.ps1 index d2c833f..07bcb1e 100644 --- a/scripts/setup_scheduler.ps1 +++ b/scripts/setup_scheduler.ps1 @@ -1,92 +1,75 @@ -# StockBot 작업 스케줄러 등록 (전체 재등록용) -# 실행: powershell -ExecutionPolicy Bypass -File scripts\setup_scheduler.ps1 -# 주의: 이 파일은 UTF-8 BOM으로 저장해야 한글 경로가 올바르게 등록됨 +$ErrorActionPreference = "Stop" -# 콘솔 인코딩 UTF-8 강제 (한글 경로 깨짐 방지) [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [System.Text.Encoding]::UTF8 chcp 65001 | Out-Null -$PROJECT = "C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3" -$weekdays = @( - [DayOfWeek]::Monday, [DayOfWeek]::Tuesday, [DayOfWeek]::Wednesday, - [DayOfWeek]::Thursday, [DayOfWeek]::Friday +$Project = Split-Path -Parent $PSScriptRoot +$TaskPath = "\StockBot\" +$Weekdays = @( + [DayOfWeek]::Monday, + [DayOfWeek]::Tuesday, + [DayOfWeek]::Wednesday, + [DayOfWeek]::Thursday, + [DayOfWeek]::Friday ) -function Register-StockTask($name, $time, $script, $limitMin) { - $trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek $weekdays -At $time - $action = New-ScheduledTaskAction ` +function Register-StockTask { + param( + [string]$Name, + [string]$Time, + [string]$Script, + [int]$LimitMinutes + ) + + $Trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek $Weekdays -At $Time + $Action = New-ScheduledTaskAction ` -Execute "powershell.exe" ` - -Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$PROJECT\scripts\$script`"" ` - -WorkingDirectory $PROJECT - $settings = New-ScheduledTaskSettingsSet ` - -ExecutionTimeLimit (New-TimeSpan -Minutes $limitMin) ` + -Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$Project\scripts\$Script`"" ` + -WorkingDirectory $Project + $Settings = New-ScheduledTaskSettingsSet ` + -ExecutionTimeLimit (New-TimeSpan -Minutes $LimitMinutes) ` -StartWhenAvailable ` -DontStopIfGoingOnBatteries ` -RunOnlyIfNetworkAvailable:$false - $settings.DisallowStartIfOnBatteries = $false # 배터리 시작 제한 해제 - Register-ScheduledTask -TaskName $name -TaskPath "\StockBot\" ` - -Trigger $trigger -Action $action -Settings $settings -RunLevel Limited -Force | Out-Null + $Settings.DisallowStartIfOnBatteries = $false - # PowerShell 5.1 버그: Register-ScheduledTask가 한글 경로를 ANSI로 저장 - # → XML export → 경로 수정 → 재import로 교정 - $xml = Export-ScheduledTask -TaskName $name -TaskPath "\StockBot\" - $stored = (Get-ScheduledTask -TaskName $name -TaskPath "\StockBot\").Actions[0].Arguments - if (-not ($stored -match [regex]::Escape("바탕 화면"))) { - $garbled = ($stored -replace '.*OneDrive\\(.+?)\\stockbot.*', '$1') - if ($garbled -ne $stored) { - $fixedXml = $xml.Replace($garbled, "바탕 화면") - Register-ScheduledTask -TaskName $name -TaskPath "\StockBot\" -Xml $fixedXml -Force | Out-Null - } - } - Write-Host "[OK] $name 등록 완료 (평일 $time)" -ForegroundColor Green + Register-ScheduledTask ` + -TaskName $Name ` + -TaskPath $TaskPath ` + -Trigger $Trigger ` + -Action $Action ` + -Settings $Settings ` + -RunLevel Limited ` + -Force | Out-Null + + Write-Host "[OK] $Name registered at $Time" -ForegroundColor Green } -# ── 태스크 등록 ──────────────────────────────────────────────────────────────── -# 08:15 claude /morning → 뉴스+KIS 수집 → daily_context.json → /start-bot 호출 -Register-StockTask "StockBot_Morning" "08:15" "run_morning.ps1" 20 - -# 11:20 claude /midday → 장중 스냅샷 → midday_context.json → 점심 세션 시작 -Register-StockTask "StockBot_Midday" "11:20" "run_midday.ps1" 20 - -# 15:30 claude /evening → 결과 분석 → 리포트 → Discord -Register-StockTask "StockBot_Evening" "15:30" "run_evening.ps1" 30 - -# 워치독: 5분마다 봇 생존 확인 → 죽어있으면 Discord 알림 + 자동 재시작 function Register-WatchdogTask { - $times = @("09:00","09:05","09:10","09:15","09:20","09:25","09:30","09:35","09:40","09:45","09:50","09:55", - "10:00","10:05","10:10","10:15","10:20","10:25","10:30","10:35","10:40","10:45","10:50","10:55", - "11:00","11:05","11:10","11:15","11:20","11:25","11:30","11:35","11:40","11:45","11:50","11:55", - "12:00","12:05","12:10","12:15","12:20","12:25","12:30","12:35","12:40","12:45","12:50","12:55", - "13:00","13:05","13:10","13:15","13:20","13:25","13:30","13:35","13:40","13:45","13:50","13:55", - "14:00","14:05","14:10","14:15","14:20","14:25","14:30","14:35","14:40","14:45","14:50","14:55", - "15:00","15:05","15:10") - $triggers = $times | ForEach-Object { - New-ScheduledTaskTrigger -Weekly -DaysOfWeek $weekdays -At $_ + $TaskName = "\StockBot\StockBot_Watchdog" + $ScriptPath = Join-Path $Project "scripts\run_watchdog.ps1" + $Command = 'schtasks /Create /TN "\StockBot\StockBot_Watchdog" /TR "\"powershell.exe\" -NonInteractive -ExecutionPolicy Bypass -File \"' + $ScriptPath + '\"" /SC MINUTE /MO 5 /ST 09:00 /ET 15:10 /F' + cmd.exe /c $Command | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "StockBot_Watchdog registration failed" } - $action = New-ScheduledTaskAction ` - -Execute "powershell.exe" ` - -Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$PROJECT\scripts\run_watchdog.ps1`"" ` - -WorkingDirectory $PROJECT - $settings = New-ScheduledTaskSettingsSet ` - -ExecutionTimeLimit (New-TimeSpan -Minutes 3) ` - -StartWhenAvailable ` - -MultipleInstances IgnoreNew ` - -DontStopIfGoingOnBatteries - $settings.DisallowStartIfOnBatteries = $false - Register-ScheduledTask -TaskName "StockBot_Watchdog" -TaskPath "\StockBot\" ` - -Trigger $triggers -Action $action -Settings $settings -RunLevel Limited -Force | Out-Null - Write-Host "[OK] StockBot_Watchdog 등록 완료 (평일 09:00~15:10, 5분 간격)" -ForegroundColor Green + + Write-Host "[OK] StockBot_Watchdog registered at 09:00-15:10 every 5 minutes" -ForegroundColor Green } + +Register-StockTask "StockBot_Morning" "08:15" "run_morning.ps1" 20 +Register-StockTask "StockBot_Midday" "11:20" "run_midday.ps1" 20 +Register-StockTask "StockBot_Evening" "15:30" "run_evening.ps1" 30 +Register-StockTask "StockBot_Training" "16:00" "run_training_pipeline.ps1" 60 Register-WatchdogTask -# StockBot_Bot 비활성화 유지 (이미 존재할 경우) -$botTask = Get-ScheduledTask -TaskName "StockBot_Bot" -TaskPath "\StockBot\" -ErrorAction SilentlyContinue -if ($botTask) { - Disable-ScheduledTask -TaskName "StockBot_Bot" -TaskPath "\StockBot\" | Out-Null - Write-Host "[OK] StockBot_Bot 비활성화 유지" -ForegroundColor Yellow +$BotTask = Get-ScheduledTask -TaskName "StockBot_Bot" -TaskPath $TaskPath -ErrorAction SilentlyContinue +if ($BotTask) { + Disable-ScheduledTask -TaskName "StockBot_Bot" -TaskPath $TaskPath | Out-Null + Write-Host "[OK] StockBot_Bot disabled" -ForegroundColor Yellow } Write-Host "" -Write-Host "등록된 작업:" -ForegroundColor Cyan -Get-ScheduledTask -TaskPath "\StockBot\" | Format-Table TaskName, State +Write-Host "Registered StockBot tasks:" -ForegroundColor Cyan +Get-ScheduledTask -TaskPath $TaskPath | Format-Table TaskName, State diff --git a/scripts/stockbot_env.ps1 b/scripts/stockbot_env.ps1 new file mode 100644 index 0000000..4ec98dc --- /dev/null +++ b/scripts/stockbot_env.ps1 @@ -0,0 +1,55 @@ +$ErrorActionPreference = "Stop" + +function Get-StockBotProjectRoot { + return (Split-Path -Parent $PSScriptRoot) +} + +function Resolve-StockBotPython { + param([string]$Project) + + $VenvPython = Join-Path $Project ".venv\Scripts\python.exe" + if (Test-Path $VenvPython) { + return $VenvPython + } + + $PyenvPython = Join-Path $env:USERPROFILE ".pyenv\pyenv-win\versions\3.11.9\python.exe" + if (Test-Path $PyenvPython) { + return $PyenvPython + } + + $Command = Get-Command python -ErrorAction SilentlyContinue + if ($Command) { + return $Command.Source + } + + throw "Python not found. Run Restore_StockBot.bat first." +} + +function Resolve-StockBotClaude { + $Candidates = @() + if ($env:STOCKBOT_CLAUDE) { + $Candidates += $env:STOCKBOT_CLAUDE + } + if ($env:APPDATA) { + $Candidates += (Join-Path $env:APPDATA "npm\claude.cmd") + $Candidates += (Join-Path $env:APPDATA "npm\codex.cmd") + } + + foreach ($Candidate in $Candidates) { + if ($Candidate -and (Test-Path $Candidate)) { + return $Candidate + } + } + + $Claude = Get-Command claude.cmd -ErrorAction SilentlyContinue + if ($Claude) { + return $Claude.Source + } + + $Codex = Get-Command codex.cmd -ErrorAction SilentlyContinue + if ($Codex) { + return $Codex.Source + } + + throw "Claude/Codex CLI not found. Run Restore_StockBot.bat or install the CLI, then retry." +} diff --git a/scripts/train_ai_model.py b/scripts/train_ai_model.py new file mode 100644 index 0000000..9e49a57 --- /dev/null +++ b/scripts/train_ai_model.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime +from pathlib import Path + +import joblib +import pandas as pd +from sklearn.ensemble import RandomForestClassifier +from sklearn.metrics import accuracy_score, precision_score, roc_auc_score +from sklearn.model_selection import train_test_split + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from app.ml.features import LABEL_COLUMNS, build_feature_matrix, select_feature_columns + + +DEFAULT_INPUTS = [ + Path("data/training_dataset.csv"), + Path("data/external_training_dataset.csv"), +] + + +def _read_csv(path: Path) -> pd.DataFrame: + if not path.exists() or path.stat().st_size == 0: + return pd.DataFrame() + return pd.read_csv(path) + + +def _load_inputs(paths: list[Path]) -> pd.DataFrame: + frames = [] + for path in paths: + frame = _read_csv(path) + if not frame.empty: + frame["source_file"] = str(path) + frames.append(frame) + if not frames: + return pd.DataFrame() + return pd.concat(frames, ignore_index=True, sort=False) + + +def _train_one_target( + x: pd.DataFrame, + y: pd.Series, + random_state: int, +) -> tuple[RandomForestClassifier, dict[str, float | int]]: + stratify = y if y.nunique() == 2 and y.value_counts().min() >= 2 else None + x_train, x_test, y_train, y_test = train_test_split( + x, + y, + test_size=0.25, + random_state=random_state, + stratify=stratify, + ) + + model = RandomForestClassifier( + n_estimators=300, + max_depth=7, + min_samples_leaf=10, + class_weight="balanced_subsample", + random_state=random_state, + n_jobs=-1, + ) + model.fit(x_train, y_train) + + prediction = model.predict(x_test) + metrics: dict[str, float | int] = { + "rows": int(len(y)), + "train_rows": int(len(y_train)), + "test_rows": int(len(y_test)), + "positive_rows": int(y.sum()), + "accuracy": float(accuracy_score(y_test, prediction)), + "precision": float(precision_score(y_test, prediction, zero_division=0)), + } + + if y_test.nunique() == 2 and hasattr(model, "predict_proba"): + metrics["roc_auc"] = float(roc_auc_score(y_test, model.predict_proba(x_test)[:, 1])) + + return model, metrics + + +def train(args: argparse.Namespace) -> int: + df = _load_inputs(args.inputs) + if df.empty: + print("No training rows found. Export datasets first.") + return 0 + + targets = [target for target in args.targets if target in df.columns] + if not targets: + print("No supported label columns found.") + return 0 + + if len(df) < args.min_rows: + print(f"Only {len(df)} rows found. Need at least {args.min_rows} rows before training.") + return 0 + + feature_columns = select_feature_columns(df, targets) + if not feature_columns: + print("No numeric feature columns found.") + return 0 + + x, medians = build_feature_matrix(df, feature_columns) + + models = {} + metrics = { + "created_at": datetime.now().isoformat(timespec="seconds"), + "input_files": [str(path) for path in args.inputs], + "feature_count": len(feature_columns), + "features": feature_columns, + "targets": {}, + } + + for target in targets: + labeled = df[target].dropna() + target_index = labeled.index + y = pd.to_numeric(labeled, errors="coerce").dropna().astype(int) + target_x = x.loc[y.index] + + if len(y) < args.min_rows: + print(f"Skipping {target}: only {len(y)} labeled rows.") + continue + if y.nunique() < 2: + print(f"Skipping {target}: target has only one class.") + continue + + model, target_metrics = _train_one_target(target_x, y, args.random_state) + models[target] = model + metrics["targets"][target] = target_metrics + print(f"Trained {target}: {target_metrics}") + + if not models: + print("No model was trained.") + return 0 + + args.out.parent.mkdir(parents=True, exist_ok=True) + bundle = { + "created_at": metrics["created_at"], + "feature_columns": feature_columns, + "medians": medians, + "models": models, + "metrics": metrics, + } + joblib.dump(bundle, args.out) + + metrics_path = args.metrics_out or args.out.with_suffix(".metrics.json") + metrics_path.write_text(json.dumps(metrics, indent=2), encoding="utf-8") + + print(f"Saved model: {args.out}") + print(f"Saved metrics: {metrics_path}") + return 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Train scalping AI models from exported datasets.") + parser.add_argument( + "--inputs", + nargs="+", + type=Path, + default=DEFAULT_INPUTS, + help="CSV datasets to combine.", + ) + parser.add_argument("--out", type=Path, default=Path("models/scalping_model.joblib")) + parser.add_argument("--metrics-out", type=Path, default=None) + parser.add_argument("--min-rows", type=int, default=200) + parser.add_argument("--random-state", type=int, default=42) + parser.add_argument( + "--targets", + nargs="+", + default=sorted(LABEL_COLUMNS), + choices=sorted(LABEL_COLUMNS), + ) + return parser.parse_args() + + +if __name__ == "__main__": + raise SystemExit(train(parse_args())) diff --git a/vendor/wheels/.gitkeep b/vendor/wheels/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/vendor/wheels/.gitkeep @@ -0,0 +1 @@ + diff --git a/vendor/wheels/APScheduler-3.10.4-py3-none-any.whl b/vendor/wheels/APScheduler-3.10.4-py3-none-any.whl new file mode 100644 index 0000000..78df99b Binary files /dev/null and b/vendor/wheels/APScheduler-3.10.4-py3-none-any.whl differ diff --git a/vendor/wheels/aiohttp-3.9.5-cp311-cp311-win_amd64.whl b/vendor/wheels/aiohttp-3.9.5-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..59ed1ab Binary files /dev/null and b/vendor/wheels/aiohttp-3.9.5-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/aiosignal-1.4.0-py3-none-any.whl b/vendor/wheels/aiosignal-1.4.0-py3-none-any.whl new file mode 100644 index 0000000..95f31ef Binary files /dev/null and b/vendor/wheels/aiosignal-1.4.0-py3-none-any.whl differ diff --git a/vendor/wheels/altair-5.5.0-py3-none-any.whl b/vendor/wheels/altair-5.5.0-py3-none-any.whl new file mode 100644 index 0000000..ff65e3c Binary files /dev/null and b/vendor/wheels/altair-5.5.0-py3-none-any.whl differ diff --git a/vendor/wheels/attrs-26.1.0-py3-none-any.whl b/vendor/wheels/attrs-26.1.0-py3-none-any.whl new file mode 100644 index 0000000..3f0a9de Binary files /dev/null and b/vendor/wheels/attrs-26.1.0-py3-none-any.whl differ diff --git a/vendor/wheels/beautifulsoup4-4.12.3-py3-none-any.whl b/vendor/wheels/beautifulsoup4-4.12.3-py3-none-any.whl new file mode 100644 index 0000000..f0f9607 Binary files /dev/null and b/vendor/wheels/beautifulsoup4-4.12.3-py3-none-any.whl differ diff --git a/vendor/wheels/blinker-1.9.0-py3-none-any.whl b/vendor/wheels/blinker-1.9.0-py3-none-any.whl new file mode 100644 index 0000000..62dd87b Binary files /dev/null and b/vendor/wheels/blinker-1.9.0-py3-none-any.whl differ diff --git a/vendor/wheels/cachetools-5.5.2-py3-none-any.whl b/vendor/wheels/cachetools-5.5.2-py3-none-any.whl new file mode 100644 index 0000000..576c7af Binary files /dev/null and b/vendor/wheels/cachetools-5.5.2-py3-none-any.whl differ diff --git a/vendor/wheels/certifi-2026.5.20-py3-none-any.whl b/vendor/wheels/certifi-2026.5.20-py3-none-any.whl new file mode 100644 index 0000000..a2e0e89 Binary files /dev/null and b/vendor/wheels/certifi-2026.5.20-py3-none-any.whl differ diff --git a/vendor/wheels/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl b/vendor/wheels/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..1456e64 Binary files /dev/null and b/vendor/wheels/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/click-8.4.1-py3-none-any.whl b/vendor/wheels/click-8.4.1-py3-none-any.whl new file mode 100644 index 0000000..8745a53 Binary files /dev/null and b/vendor/wheels/click-8.4.1-py3-none-any.whl differ diff --git a/vendor/wheels/colorama-0.4.6-py2.py3-none-any.whl b/vendor/wheels/colorama-0.4.6-py2.py3-none-any.whl new file mode 100644 index 0000000..f666ce9 Binary files /dev/null and b/vendor/wheels/colorama-0.4.6-py2.py3-none-any.whl differ diff --git a/vendor/wheels/contourpy-1.3.3-cp311-cp311-win_amd64.whl b/vendor/wheels/contourpy-1.3.3-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..44b316a Binary files /dev/null and b/vendor/wheels/contourpy-1.3.3-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/cycler-0.12.1-py3-none-any.whl b/vendor/wheels/cycler-0.12.1-py3-none-any.whl new file mode 100644 index 0000000..6478c3f Binary files /dev/null and b/vendor/wheels/cycler-0.12.1-py3-none-any.whl differ diff --git a/vendor/wheels/datetime-6.0-py3-none-any.whl b/vendor/wheels/datetime-6.0-py3-none-any.whl new file mode 100644 index 0000000..6e6b473 Binary files /dev/null and b/vendor/wheels/datetime-6.0-py3-none-any.whl differ diff --git a/vendor/wheels/deprecated-1.3.1-py2.py3-none-any.whl b/vendor/wheels/deprecated-1.3.1-py2.py3-none-any.whl new file mode 100644 index 0000000..cbf01c6 Binary files /dev/null and b/vendor/wheels/deprecated-1.3.1-py2.py3-none-any.whl differ diff --git a/vendor/wheels/finance_datareader-0.9.94-py3-none-any.whl b/vendor/wheels/finance_datareader-0.9.94-py3-none-any.whl new file mode 100644 index 0000000..a30bbd3 Binary files /dev/null and b/vendor/wheels/finance_datareader-0.9.94-py3-none-any.whl differ diff --git a/vendor/wheels/fonttools-4.63.0-cp311-cp311-win_amd64.whl b/vendor/wheels/fonttools-4.63.0-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..c40fc9e Binary files /dev/null and b/vendor/wheels/fonttools-4.63.0-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/frozenlist-1.8.0-cp311-cp311-win_amd64.whl b/vendor/wheels/frozenlist-1.8.0-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..4a24292 Binary files /dev/null and b/vendor/wheels/frozenlist-1.8.0-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/gitdb-4.0.12-py3-none-any.whl b/vendor/wheels/gitdb-4.0.12-py3-none-any.whl new file mode 100644 index 0000000..1da1660 Binary files /dev/null and b/vendor/wheels/gitdb-4.0.12-py3-none-any.whl differ diff --git a/vendor/wheels/gitpython-3.1.50-py3-none-any.whl b/vendor/wheels/gitpython-3.1.50-py3-none-any.whl new file mode 100644 index 0000000..d56fcd9 Binary files /dev/null and b/vendor/wheels/gitpython-3.1.50-py3-none-any.whl differ diff --git a/vendor/wheels/holidays-0.48-py3-none-any.whl b/vendor/wheels/holidays-0.48-py3-none-any.whl new file mode 100644 index 0000000..caa045a Binary files /dev/null and b/vendor/wheels/holidays-0.48-py3-none-any.whl differ diff --git a/vendor/wheels/idna-3.16-py3-none-any.whl b/vendor/wheels/idna-3.16-py3-none-any.whl new file mode 100644 index 0000000..870cb6b Binary files /dev/null and b/vendor/wheels/idna-3.16-py3-none-any.whl differ diff --git a/vendor/wheels/jinja2-3.1.6-py3-none-any.whl b/vendor/wheels/jinja2-3.1.6-py3-none-any.whl new file mode 100644 index 0000000..5046d77 Binary files /dev/null and b/vendor/wheels/jinja2-3.1.6-py3-none-any.whl differ diff --git a/vendor/wheels/joblib-1.4.2-py3-none-any.whl b/vendor/wheels/joblib-1.4.2-py3-none-any.whl new file mode 100644 index 0000000..617a589 Binary files /dev/null and b/vendor/wheels/joblib-1.4.2-py3-none-any.whl differ diff --git a/vendor/wheels/jsonschema-4.26.0-py3-none-any.whl b/vendor/wheels/jsonschema-4.26.0-py3-none-any.whl new file mode 100644 index 0000000..2b6668d Binary files /dev/null and b/vendor/wheels/jsonschema-4.26.0-py3-none-any.whl differ diff --git a/vendor/wheels/jsonschema_specifications-2025.9.1-py3-none-any.whl b/vendor/wheels/jsonschema_specifications-2025.9.1-py3-none-any.whl new file mode 100644 index 0000000..e04d5d6 Binary files /dev/null and b/vendor/wheels/jsonschema_specifications-2025.9.1-py3-none-any.whl differ diff --git a/vendor/wheels/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl b/vendor/wheels/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..2a05ccf Binary files /dev/null and b/vendor/wheels/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/lxml-6.1.1-cp311-cp311-win_amd64.whl b/vendor/wheels/lxml-6.1.1-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..b9ee682 Binary files /dev/null and b/vendor/wheels/lxml-6.1.1-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/markdown_it_py-4.2.0-py3-none-any.whl b/vendor/wheels/markdown_it_py-4.2.0-py3-none-any.whl new file mode 100644 index 0000000..2aa6d80 Binary files /dev/null and b/vendor/wheels/markdown_it_py-4.2.0-py3-none-any.whl differ diff --git a/vendor/wheels/markupsafe-3.0.3-cp311-cp311-win_amd64.whl b/vendor/wheels/markupsafe-3.0.3-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..a92128f Binary files /dev/null and b/vendor/wheels/markupsafe-3.0.3-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/matplotlib-3.10.9-cp311-cp311-win_amd64.whl b/vendor/wheels/matplotlib-3.10.9-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..6400069 Binary files /dev/null and b/vendor/wheels/matplotlib-3.10.9-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/mdurl-0.1.2-py3-none-any.whl b/vendor/wheels/mdurl-0.1.2-py3-none-any.whl new file mode 100644 index 0000000..6b8b6ab Binary files /dev/null and b/vendor/wheels/mdurl-0.1.2-py3-none-any.whl differ diff --git a/vendor/wheels/multidict-6.7.1-cp311-cp311-win_amd64.whl b/vendor/wheels/multidict-6.7.1-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..18a95c5 Binary files /dev/null and b/vendor/wheels/multidict-6.7.1-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/multipledispatch-1.0.0-py3-none-any.whl b/vendor/wheels/multipledispatch-1.0.0-py3-none-any.whl new file mode 100644 index 0000000..ade2b50 Binary files /dev/null and b/vendor/wheels/multipledispatch-1.0.0-py3-none-any.whl differ diff --git a/vendor/wheels/narwhals-2.21.2-py3-none-any.whl b/vendor/wheels/narwhals-2.21.2-py3-none-any.whl new file mode 100644 index 0000000..a80f5b0 Binary files /dev/null and b/vendor/wheels/narwhals-2.21.2-py3-none-any.whl differ diff --git a/vendor/wheels/numpy-1.26.4-cp311-cp311-win_amd64.whl b/vendor/wheels/numpy-1.26.4-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..29dd991 Binary files /dev/null and b/vendor/wheels/numpy-1.26.4-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/packaging-24.2-py3-none-any.whl b/vendor/wheels/packaging-24.2-py3-none-any.whl new file mode 100644 index 0000000..b38a4a5 Binary files /dev/null and b/vendor/wheels/packaging-24.2-py3-none-any.whl differ diff --git a/vendor/wheels/pandas-2.2.2-cp311-cp311-win_amd64.whl b/vendor/wheels/pandas-2.2.2-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..2fe2d1a Binary files /dev/null and b/vendor/wheels/pandas-2.2.2-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/pillow-10.4.0-cp311-cp311-win_amd64.whl b/vendor/wheels/pillow-10.4.0-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..7f9edae Binary files /dev/null and b/vendor/wheels/pillow-10.4.0-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/propcache-0.5.2-cp311-cp311-win_amd64.whl b/vendor/wheels/propcache-0.5.2-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..ebafd6b Binary files /dev/null and b/vendor/wheels/propcache-0.5.2-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/protobuf-5.29.6-cp310-abi3-win_amd64.whl b/vendor/wheels/protobuf-5.29.6-cp310-abi3-win_amd64.whl new file mode 100644 index 0000000..0872955 Binary files /dev/null and b/vendor/wheels/protobuf-5.29.6-cp310-abi3-win_amd64.whl differ diff --git a/vendor/wheels/pyarrow-24.0.0-cp311-cp311-win_amd64.whl b/vendor/wheels/pyarrow-24.0.0-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..dc74f1d Binary files /dev/null and b/vendor/wheels/pyarrow-24.0.0-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/pydeck-0.9.2-py2.py3-none-any.whl b/vendor/wheels/pydeck-0.9.2-py2.py3-none-any.whl new file mode 100644 index 0000000..223b2d3 Binary files /dev/null and b/vendor/wheels/pydeck-0.9.2-py2.py3-none-any.whl differ diff --git a/vendor/wheels/pygments-2.20.0-py3-none-any.whl b/vendor/wheels/pygments-2.20.0-py3-none-any.whl new file mode 100644 index 0000000..2009f93 Binary files /dev/null and b/vendor/wheels/pygments-2.20.0-py3-none-any.whl differ diff --git a/vendor/wheels/pykrx-1.0.48-py3-none-any.whl b/vendor/wheels/pykrx-1.0.48-py3-none-any.whl new file mode 100644 index 0000000..9fd4691 Binary files /dev/null and b/vendor/wheels/pykrx-1.0.48-py3-none-any.whl differ diff --git a/vendor/wheels/pyparsing-3.3.2-py3-none-any.whl b/vendor/wheels/pyparsing-3.3.2-py3-none-any.whl new file mode 100644 index 0000000..ae2d24c Binary files /dev/null and b/vendor/wheels/pyparsing-3.3.2-py3-none-any.whl differ diff --git a/vendor/wheels/python_dateutil-2.9.0.post0-py2.py3-none-any.whl b/vendor/wheels/python_dateutil-2.9.0.post0-py2.py3-none-any.whl new file mode 100644 index 0000000..b9a14e1 Binary files /dev/null and b/vendor/wheels/python_dateutil-2.9.0.post0-py2.py3-none-any.whl differ diff --git a/vendor/wheels/python_dotenv-1.0.1-py3-none-any.whl b/vendor/wheels/python_dotenv-1.0.1-py3-none-any.whl new file mode 100644 index 0000000..3386c10 Binary files /dev/null and b/vendor/wheels/python_dotenv-1.0.1-py3-none-any.whl differ diff --git a/vendor/wheels/pytz-2026.2-py2.py3-none-any.whl b/vendor/wheels/pytz-2026.2-py2.py3-none-any.whl new file mode 100644 index 0000000..4160b2e Binary files /dev/null and b/vendor/wheels/pytz-2026.2-py2.py3-none-any.whl differ diff --git a/vendor/wheels/redis-5.0.7-py3-none-any.whl b/vendor/wheels/redis-5.0.7-py3-none-any.whl new file mode 100644 index 0000000..75d41d6 Binary files /dev/null and b/vendor/wheels/redis-5.0.7-py3-none-any.whl differ diff --git a/vendor/wheels/referencing-0.37.0-py3-none-any.whl b/vendor/wheels/referencing-0.37.0-py3-none-any.whl new file mode 100644 index 0000000..b2b482d Binary files /dev/null and b/vendor/wheels/referencing-0.37.0-py3-none-any.whl differ diff --git a/vendor/wheels/requests-2.34.2-py3-none-any.whl b/vendor/wheels/requests-2.34.2-py3-none-any.whl new file mode 100644 index 0000000..fb4b8a7 Binary files /dev/null and b/vendor/wheels/requests-2.34.2-py3-none-any.whl differ diff --git a/vendor/wheels/requests_file-3.0.1-py2.py3-none-any.whl b/vendor/wheels/requests_file-3.0.1-py2.py3-none-any.whl new file mode 100644 index 0000000..a966138 Binary files /dev/null and b/vendor/wheels/requests_file-3.0.1-py2.py3-none-any.whl differ diff --git a/vendor/wheels/rich-13.9.4-py3-none-any.whl b/vendor/wheels/rich-13.9.4-py3-none-any.whl new file mode 100644 index 0000000..e1864d8 Binary files /dev/null and b/vendor/wheels/rich-13.9.4-py3-none-any.whl differ diff --git a/vendor/wheels/rpds_py-0.30.0-cp311-cp311-win_amd64.whl b/vendor/wheels/rpds_py-0.30.0-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..05eaa7d Binary files /dev/null and b/vendor/wheels/rpds_py-0.30.0-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/scikit_learn-1.5.1-cp311-cp311-win_amd64.whl b/vendor/wheels/scikit_learn-1.5.1-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..8bf6d67 Binary files /dev/null and b/vendor/wheels/scikit_learn-1.5.1-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/scipy-1.17.1-cp311-cp311-win_amd64.whl b/vendor/wheels/scipy-1.17.1-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..0ccbe4d Binary files /dev/null and b/vendor/wheels/scipy-1.17.1-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/six-1.17.0-py2.py3-none-any.whl b/vendor/wheels/six-1.17.0-py2.py3-none-any.whl new file mode 100644 index 0000000..c506fd0 Binary files /dev/null and b/vendor/wheels/six-1.17.0-py2.py3-none-any.whl differ diff --git a/vendor/wheels/smmap-5.0.3-py3-none-any.whl b/vendor/wheels/smmap-5.0.3-py3-none-any.whl new file mode 100644 index 0000000..7331288 Binary files /dev/null and b/vendor/wheels/smmap-5.0.3-py3-none-any.whl differ diff --git a/vendor/wheels/soupsieve-2.8.4-py3-none-any.whl b/vendor/wheels/soupsieve-2.8.4-py3-none-any.whl new file mode 100644 index 0000000..6284127 Binary files /dev/null and b/vendor/wheels/soupsieve-2.8.4-py3-none-any.whl differ diff --git a/vendor/wheels/streamlit-1.36.0-py2.py3-none-any.whl b/vendor/wheels/streamlit-1.36.0-py2.py3-none-any.whl new file mode 100644 index 0000000..eb0fed2 Binary files /dev/null and b/vendor/wheels/streamlit-1.36.0-py2.py3-none-any.whl differ diff --git a/vendor/wheels/tenacity-8.5.0-py3-none-any.whl b/vendor/wheels/tenacity-8.5.0-py3-none-any.whl new file mode 100644 index 0000000..1f830cd Binary files /dev/null and b/vendor/wheels/tenacity-8.5.0-py3-none-any.whl differ diff --git a/vendor/wheels/threadpoolctl-3.6.0-py3-none-any.whl b/vendor/wheels/threadpoolctl-3.6.0-py3-none-any.whl new file mode 100644 index 0000000..c2b5299 Binary files /dev/null and b/vendor/wheels/threadpoolctl-3.6.0-py3-none-any.whl differ diff --git a/vendor/wheels/toml-0.10.2-py2.py3-none-any.whl b/vendor/wheels/toml-0.10.2-py2.py3-none-any.whl new file mode 100644 index 0000000..2cb8dcb Binary files /dev/null and b/vendor/wheels/toml-0.10.2-py2.py3-none-any.whl differ diff --git a/vendor/wheels/tornado-6.5.5-cp39-abi3-win_amd64.whl b/vendor/wheels/tornado-6.5.5-cp39-abi3-win_amd64.whl new file mode 100644 index 0000000..a9ce55a Binary files /dev/null and b/vendor/wheels/tornado-6.5.5-cp39-abi3-win_amd64.whl differ diff --git a/vendor/wheels/tqdm-4.67.3-py3-none-any.whl b/vendor/wheels/tqdm-4.67.3-py3-none-any.whl new file mode 100644 index 0000000..936ccbb Binary files /dev/null and b/vendor/wheels/tqdm-4.67.3-py3-none-any.whl differ diff --git a/vendor/wheels/typing_extensions-4.15.0-py3-none-any.whl b/vendor/wheels/typing_extensions-4.15.0-py3-none-any.whl new file mode 100644 index 0000000..5fec9ca Binary files /dev/null and b/vendor/wheels/typing_extensions-4.15.0-py3-none-any.whl differ diff --git a/vendor/wheels/tzdata-2026.2-py2.py3-none-any.whl b/vendor/wheels/tzdata-2026.2-py2.py3-none-any.whl new file mode 100644 index 0000000..7e03653 Binary files /dev/null and b/vendor/wheels/tzdata-2026.2-py2.py3-none-any.whl differ diff --git a/vendor/wheels/tzlocal-5.3.1-py3-none-any.whl b/vendor/wheels/tzlocal-5.3.1-py3-none-any.whl new file mode 100644 index 0000000..fba595b Binary files /dev/null and b/vendor/wheels/tzlocal-5.3.1-py3-none-any.whl differ diff --git a/vendor/wheels/urllib3-2.7.0-py3-none-any.whl b/vendor/wheels/urllib3-2.7.0-py3-none-any.whl new file mode 100644 index 0000000..1400ec6 Binary files /dev/null and b/vendor/wheels/urllib3-2.7.0-py3-none-any.whl differ diff --git a/vendor/wheels/watchdog-4.0.2-py3-none-win_amd64.whl b/vendor/wheels/watchdog-4.0.2-py3-none-win_amd64.whl new file mode 100644 index 0000000..b70572d Binary files /dev/null and b/vendor/wheels/watchdog-4.0.2-py3-none-win_amd64.whl differ diff --git a/vendor/wheels/wrapt-2.2.1-cp311-cp311-win_amd64.whl b/vendor/wheels/wrapt-2.2.1-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..22476d8 Binary files /dev/null and b/vendor/wheels/wrapt-2.2.1-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/xlrd-2.0.2-py2.py3-none-any.whl b/vendor/wheels/xlrd-2.0.2-py2.py3-none-any.whl new file mode 100644 index 0000000..ba996f3 Binary files /dev/null and b/vendor/wheels/xlrd-2.0.2-py2.py3-none-any.whl differ diff --git a/vendor/wheels/yarl-1.24.2-cp311-cp311-win_amd64.whl b/vendor/wheels/yarl-1.24.2-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..e63487a Binary files /dev/null and b/vendor/wheels/yarl-1.24.2-cp311-cp311-win_amd64.whl differ diff --git a/vendor/wheels/zope_interface-8.5-cp311-cp311-win_amd64.whl b/vendor/wheels/zope_interface-8.5-cp311-cp311-win_amd64.whl new file mode 100644 index 0000000..46051e8 Binary files /dev/null and b/vendor/wheels/zope_interface-8.5-cp311-cp311-win_amd64.whl differ