[2026-05-27] 포맷 후 복구 설치 스크립트 추가
This commit is contained in:
+28
-71
@@ -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.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[shell_environment_policy]
|
||||
inherit = "core"
|
||||
|
||||
[shell_environment_policy.set]
|
||||
PYTHONUTF8 = "1"
|
||||
PYTHONIOENCODING = "utf-8"
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python .claude/discord_notify.py",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+8
-1
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
+109
-40
@@ -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,
|
||||
))
|
||||
|
||||
+352
-3
@@ -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}원)")
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+49
-41
@@ -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 \
|
||||
|
||||
@@ -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×"
|
||||
}
|
||||
@@ -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연속 이후에도 새 진입 신호가 발생한 점 확인
|
||||
@@ -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.
|
||||
|
||||
@@ -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"`.
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-r app/requirements.txt
|
||||
@@ -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}')
|
||||
@@ -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 알림 전송 완료')
|
||||
@@ -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('실행 중인 봇 없음 — 새로 시작합니다')
|
||||
@@ -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('실행 중인 봇 없음 — 새로 시작합니다')
|
||||
@@ -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 알림 전송 완료')
|
||||
@@ -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 알림 전송 완료')
|
||||
@@ -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'''<?xml version="1.0" encoding="UTF-16"?>
|
||||
<Task version="1.3" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||
<RegistrationInfo>
|
||||
<URI>\\StockBot\\StockBot_Watchdog</URI>
|
||||
</RegistrationInfo>
|
||||
<Principals>
|
||||
<Principal id="Author">
|
||||
<LogonType>InteractiveToken</LogonType>
|
||||
<RunLevel>LeastPrivilege</RunLevel>
|
||||
</Principal>
|
||||
</Principals>
|
||||
<Settings>
|
||||
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||
<ExecutionTimeLimit>PT3M</ExecutionTimeLimit>
|
||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||||
<StartWhenAvailable>true</StartWhenAvailable>
|
||||
<IdleSettings>
|
||||
<Duration>PT10M</Duration>
|
||||
<WaitTimeout>PT1H</WaitTimeout>
|
||||
<StopOnIdleEnd>true</StopOnIdleEnd>
|
||||
<RestartOnIdle>false</RestartOnIdle>
|
||||
</IdleSettings>
|
||||
</Settings>
|
||||
<Triggers>
|
||||
<CalendarTrigger>
|
||||
<StartBoundary>2026-05-27T09:00:00+09:00</StartBoundary>
|
||||
<Repetition>
|
||||
<Interval>PT5M</Interval>
|
||||
<Duration>PT6H10M</Duration>
|
||||
<StopAtDurationEnd>true</StopAtDurationEnd>
|
||||
</Repetition>
|
||||
<ScheduleByWeek>
|
||||
<WeeksInterval>1</WeeksInterval>
|
||||
<DaysOfWeek>
|
||||
<Monday />
|
||||
<Tuesday />
|
||||
<Wednesday />
|
||||
<Thursday />
|
||||
<Friday />
|
||||
</DaysOfWeek>
|
||||
</ScheduleByWeek>
|
||||
</CalendarTrigger>
|
||||
</Triggers>
|
||||
<Actions Context="Author">
|
||||
<Exec>
|
||||
<Command>powershell.exe</Command>
|
||||
<Arguments>-NonInteractive -ExecutionPolicy Bypass -File "{SCRIPT}"</Arguments>
|
||||
<WorkingDirectory>{PROJECT}</WorkingDirectory>
|
||||
</Exec>
|
||||
</Actions>
|
||||
</Task>'''
|
||||
|
||||
# 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)
|
||||
@@ -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 전송 완료')
|
||||
@@ -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 알림 전송 완료")
|
||||
@@ -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 알림 전송 완료')
|
||||
@@ -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}')
|
||||
@@ -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 알림 전송 완료")
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
+7
-5
@@ -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
|
||||
)
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
+28
-10
@@ -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"
|
||||
|
||||
+53
-70
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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()))
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user