[2026-05-27] 포맷 후 복구 설치 스크립트 추가

This commit is contained in:
2026-05-27 16:53:52 +09:00
parent 04577c63f1
commit 29db1bfcab
135 changed files with 2909 additions and 251 deletions
+28 -71
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
[shell_environment_policy]
inherit = "core"
[shell_environment_policy.set]
PYTHONUTF8 = "1"
PYTHONIOENCODING = "utf-8"
+15
View File
@@ -0,0 +1,15 @@
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python .claude/discord_notify.py",
"timeout": 30
}
]
}
]
}
}
+8
View File
@@ -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
+291
View File
@@ -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`)
+37
View File
@@ -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.
---
## 프로젝트 개요
+48
View File
@@ -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.
---
+12
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+2 -1
View File
@@ -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)
+72
View File
@@ -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}")
+39
View File
@@ -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
View File
@@ -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
View File
@@ -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}원)")
+1
View File
@@ -0,0 +1 @@
+55
View File
@@ -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
+55
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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 \
+10
View File
@@ -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×"
}
+64
View File
@@ -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연속 이후에도 새 진입 신호가 발생한 점 확인
+10
View File
@@ -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.
+80
View File
@@ -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"`.
+1
View File
@@ -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.
---
+1
View File
@@ -0,0 +1 @@
-r app/requirements.txt
+29
View File
@@ -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}')
+9
View File
@@ -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 알림 전송 완료')
+25
View File
@@ -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('실행 중인 봇 없음 — 새로 시작합니다')
+29
View File
@@ -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('실행 중인 봇 없음 — 새로 시작합니다')
+9
View File
@@ -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 알림 전송 완료')
+9
View File
@@ -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 알림 전송 완료')
+113
View File
@@ -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)
+17
View File
@@ -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 전송 완료')
+54
View File
@@ -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 알림 전송 완료")
+52
View File
@@ -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 알림 전송 완료')
+14
View File
@@ -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}')
+58
View File
@@ -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 알림 전송 완료")
+15
View File
@@ -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())
+170
View File
@@ -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()
+84
View File
@@ -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()
+102
View File
@@ -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()
+13
View File
@@ -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
+128
View File
@@ -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)
+25
View File
@@ -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
+192
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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
+56
View File
@@ -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
View File
@@ -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
View File
@@ -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
+55
View File
@@ -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."
}
+179
View File
@@ -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()))
+1
View File
@@ -0,0 +1 @@
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.
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.
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.
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.
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.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More