[2026-05-19] 세션 분리 + L3→B안 전환 + /midday 장중 분석 추가
- L3 하드 중단 제거 → B안(연속 손절별 포지션 축소) 적용 0회×1.0 / 1회×0.7 / 2회×0.5 / 3+회×0.3, 익절 시 한 단계 회복 - 아침·점심 세션 분리: 11:00 이후 midday_context.json 감지 시 점심 세션 자동 시작 (12:00 고정 시작 제거 → 이벤트 기반) - app/ai/midday.py: 장중 데이터 수집 스크립트 신규 작성 - .claude/commands/midday.md: /midday 슬래시 커맨드 신규 작성 - scripts/run_midday.ps1: 11:20 스케줄러 스크립트 신규 작성 - setup_scheduler.ps1: StockBot_Midday 태스크 추가 - CLAUDE.md: 전체 문서 업데이트 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
# claude_midday — 장중 분석
|
||||||
|
|
||||||
|
11:20 스케줄러가 자동 실행. 오전 결과 분석 후 점심 세션 파라미터 결정 → midday_context.json 저장.
|
||||||
|
|
||||||
|
## 실행 순서
|
||||||
|
|
||||||
|
### 1. 데이터 수집
|
||||||
|
```bash
|
||||||
|
python app/ai/midday.py --print
|
||||||
|
```
|
||||||
|
수집 항목:
|
||||||
|
- **오전 거래 결과**: 체결 내역, 승/패, 연속 손절 수, 순손익
|
||||||
|
- **현재 포지션**: 보유 중인 종목 및 현재 손익
|
||||||
|
- **시장 스냅샷**: 거래량 순위(현재), 업종별 등락률
|
||||||
|
- **아침 컨텍스트**: daily_context.json (오전 예측 비교용)
|
||||||
|
|
||||||
|
### 2. 분석
|
||||||
|
|
||||||
|
오전 daily_context 예측과 실제 결과를 비교해 점심 세션 파라미터를 결정한다.
|
||||||
|
|
||||||
|
**비교 포인트:**
|
||||||
|
- 오전 예측 hot_sectors ↔ 실제 업종 등락률 — 예측이 맞았는가?
|
||||||
|
- 거래량 순위 변화 — 새로 뜨는 종목 / 사라진 종목
|
||||||
|
- 오전 거래 결과 — 전략이 작동했는가, 연속 손절 상태인가?
|
||||||
|
|
||||||
|
**판단 항목:**
|
||||||
|
- **lunch_trade_allowed**: false이면 점심 세션 진입 없음
|
||||||
|
- sentiment_score < 40이거나, 연속 손절 3회 이상이면 false 권장
|
||||||
|
- **position_size_multiplier**: B안 연속 손절 배율 × 시장 판단
|
||||||
|
- 연속 손절 0회: 1.0 / 1회: 0.7 / 2회: 0.5 / 3회+: 0.3
|
||||||
|
- 시장이 오전 예측보다 좋으면 상향, 나쁘면 하향
|
||||||
|
- **hot_sectors**: 오전 대비 업데이트 (실제 강한 업종)
|
||||||
|
- **avoid_sectors**: 오전 대비 업데이트 (실제 약한 업종)
|
||||||
|
- **strategy_note**: 전략 조정이 필요하면 메모 (없으면 빈 문자열)
|
||||||
|
- **reason**: 50자 이내 장중 요약
|
||||||
|
|
||||||
|
### 3. midday_context.json 저장
|
||||||
|
분석 결과를 `data/midday_context.json`에 저장한다. 형식:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"date": "YYYY-MM-DD",
|
||||||
|
"generated_at": "HH:MM:SS",
|
||||||
|
"lunch_trade_allowed": true,
|
||||||
|
"position_size_multiplier": 0.8,
|
||||||
|
"hot_sectors": ["반도체"],
|
||||||
|
"avoid_sectors": ["건설업"],
|
||||||
|
"strategy_note": "",
|
||||||
|
"reason": "50자 이내 요약"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
파일이 저장되는 즉시 봇이 감지해 점심 세션을 시작한다.
|
||||||
|
|
||||||
|
### 4. Discord 알림 전송
|
||||||
|
```bash
|
||||||
|
python -c "
|
||||||
|
import asyncio, json, os, 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{flag} | 포지션배율: x{ctx.get(\"position_size_multiplier\",1.0)}\n주목: {hot} | 회피: {avoid}\n📝 {ctx.get(\"reason\",\"\")}'
|
||||||
|
asyncio.run(send(msg))
|
||||||
|
print('Discord 전송 완료')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 완료
|
||||||
|
분석 요약을 한 줄로 출력하고 종료한다.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# StockBot v3.0 — Claude Code 운영 가이드
|
# StockBot v3.0 — Claude Code 운영 가이드
|
||||||
|
|
||||||
> 최종 수정: 2026-05-15
|
> 최종 수정: 2026-05-19
|
||||||
> 인프라: 로컬 Windows → Synology NAS Docker 이전 예정
|
> 인프라: 로컬 Windows → Synology NAS Docker 이전 예정
|
||||||
> 현재 모드: 모의투자 (KIS_MOCK=true, DRY_RUN=true)
|
> 현재 모드: 모의투자 (KIS_MOCK=true, DRY_RUN=true)
|
||||||
|
|
||||||
@@ -41,21 +41,29 @@
|
|||||||
## 하루 자동화 흐름
|
## 하루 자동화 흐름
|
||||||
|
|
||||||
```
|
```
|
||||||
07:30 StockBot_Morning → run_morning.ps1 → claude /morning → RSS+네이버 뉴스+수급 분석 → daily_context.json
|
07:30 StockBot_Morning → run_morning.ps1 → claude /morning
|
||||||
|
RSS+네이버 뉴스+KIS 수급 분석 → daily_context.json
|
||||||
완료 후 자동으로 /start-bot 호출 → 봇 백그라운드 시작
|
완료 후 자동으로 /start-bot 호출 → 봇 백그라운드 시작
|
||||||
08:30 봇이 daily_context.json 로드 → Discord에 분석 결과 전송 → 유니버스 30종목 확정
|
08:30 봇이 daily_context.json 로드 → Discord 전송 → 유니버스 30종목 확정
|
||||||
08:50 목표가 계산
|
08:50 목표가 계산
|
||||||
09:00 매매 루프 시작 (변동성 돌파 신호 + AI 필터)
|
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 → claude /midday
|
||||||
|
오전 결과+시장 스냅샷 수집 → midday_context.json 저장
|
||||||
|
파일 생성 즉시 봇이 감지 → 점심 세션 자동 시작
|
||||||
|
14:00 신규 진입 마감
|
||||||
14:50 강제 전량 청산 (절대 불변)
|
14:50 강제 전량 청산 (절대 불변)
|
||||||
15:10 일일 결산 → Discord 전송
|
15:10 일일 결산 → Discord 전송
|
||||||
15:30 StockBot_Evening → run_evening.ps1 → claude /evening → 결과 분석 + 리포트 저장
|
15:30 StockBot_Evening → run_evening.ps1 → claude /evening
|
||||||
|
결과 분석 + 리포트 저장
|
||||||
```
|
```
|
||||||
|
|
||||||
### 스케줄러 스크립트 주의사항 (scripts/run_*.ps1)
|
### 스케줄러 스크립트 주의사항 (scripts/run_*.ps1)
|
||||||
- 경로: `$PROJECT = Split-Path -Parent $PSScriptRoot` (한글 경로 인코딩 문제 방지)
|
- 경로: `$PROJECT = Split-Path -Parent $PSScriptRoot` (한글 경로 인코딩 문제 방지)
|
||||||
- Claude 실행: `$CLAUDE = "C:\Users\whdwo\AppData\Roaming\npm\claude.cmd"` (전체 경로 필수)
|
- Claude 실행: `$CLAUDE = "C:\Users\whdwo\AppData\Roaming\npm\claude.cmd"` (전체 경로 필수)
|
||||||
- 인코딩: `New-Object System.Text.UTF8Encoding $false` + UTF-8 BOM으로 저장
|
- 인코딩: `New-Object System.Text.UTF8Encoding $false` + UTF-8 BOM으로 저장
|
||||||
- 로그: `logs/bot_start.log`, `logs/morning.log`, `logs/evening.log`
|
- 로그: `logs/bot_start.log`, `logs/morning.log`, `logs/midday.log`, `logs/evening.log`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -65,10 +73,19 @@
|
|||||||
```
|
```
|
||||||
1. python app/ai/morning.py --print (뉴스 크롤링 + KIS 수급 수집)
|
1. python app/ai/morning.py --print (뉴스 크롤링 + KIS 수급 수집)
|
||||||
2. Claude가 데이터 분석 → 시장 분위기/섹터/boosted_tickers 판단
|
2. Claude가 데이터 분석 → 시장 분위기/섹터/boosted_tickers 판단
|
||||||
3. app/ai/daily_context.json 저장
|
3. data/daily_context.json 저장
|
||||||
4. Discord로 분석 요약 전송
|
4. Discord로 분석 요약 전송
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 장중 분석 — `/midday` 슬래시 커맨드
|
||||||
|
```
|
||||||
|
1. python app/ai/midday.py --print (오전 거래 결과 + 현재 시장 스냅샷 수집)
|
||||||
|
2. 오전 daily_context 예측 vs 실제 결과 비교 분석
|
||||||
|
3. 점심 세션 파라미터 결정 (진입 허용 여부, 포지션 배율, 섹터 업데이트)
|
||||||
|
4. data/midday_context.json 저장 → 봇이 즉시 감지해 점심 세션 시작
|
||||||
|
5. Discord로 장중 분석 전송
|
||||||
|
```
|
||||||
|
|
||||||
### 장 후 피드백 — `/evening` 슬래시 커맨드
|
### 장 후 피드백 — `/evening` 슬래시 커맨드
|
||||||
```
|
```
|
||||||
1. python app/ai/evening.py --print (오늘 매매 내역 조회)
|
1. python app/ai/evening.py --print (오늘 매매 내역 조회)
|
||||||
@@ -115,6 +132,7 @@ stockbot_v3/
|
|||||||
│ ├── config.py ← 전략 파라미터 (수정 가능)
|
│ ├── config.py ← 전략 파라미터 (수정 가능)
|
||||||
│ ├── ai/
|
│ ├── ai/
|
||||||
│ │ ├── morning.py ← 장 전 데이터 수집
|
│ │ ├── morning.py ← 장 전 데이터 수집
|
||||||
|
│ │ ├── midday.py ← 장중 데이터 수집
|
||||||
│ │ └── evening.py ← 장 후 데이터 수집
|
│ │ └── evening.py ← 장 후 데이터 수집
|
||||||
│ ├── strategy/
|
│ ├── strategy/
|
||||||
│ │ └── volatility_breakout.py ← 전략 로직 (수정 가능)
|
│ │ └── volatility_breakout.py ← 전략 로직 (수정 가능)
|
||||||
@@ -131,6 +149,7 @@ stockbot_v3/
|
|||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── run_bot.ps1 ← 스케줄러용 봇 시작
|
│ ├── run_bot.ps1 ← 스케줄러용 봇 시작
|
||||||
│ ├── run_morning.ps1 ← 스케줄러용 morning
|
│ ├── run_morning.ps1 ← 스케줄러용 morning
|
||||||
|
│ ├── run_midday.ps1 ← 스케줄러용 midday (11:20)
|
||||||
│ ├── run_evening.ps1 ← 스케줄러용 evening
|
│ ├── run_evening.ps1 ← 스케줄러용 evening
|
||||||
│ └── setup_scheduler.ps1 ← 스케줄러 전체 재등록
|
│ └── setup_scheduler.ps1 ← 스케줄러 전체 재등록
|
||||||
├── reports/
|
├── reports/
|
||||||
@@ -139,6 +158,7 @@ stockbot_v3/
|
|||||||
├── data/
|
├── data/
|
||||||
│ ├── stockbot.db
|
│ ├── stockbot.db
|
||||||
│ ├── daily_context.json ← 매일 /morning이 갱신, 봇이 08:30에 로드
|
│ ├── daily_context.json ← 매일 /morning이 갱신, 봇이 08:30에 로드
|
||||||
|
│ ├── midday_context.json ← 매일 /midday가 갱신, 봇이 파일 감지 즉시 로드
|
||||||
│ ├── news/
|
│ ├── news/
|
||||||
│ └── market/
|
│ └── market/
|
||||||
└── logs/
|
└── logs/
|
||||||
@@ -188,10 +208,19 @@ stockbot_v3/
|
|||||||
|------|------|------|---------|
|
|------|------|------|---------|
|
||||||
| L1 | 1회 -1.5% | 즉시 손절 | [손절] |
|
| L1 | 1회 -1.5% | 즉시 손절 | [손절] |
|
||||||
| L2 | 일일 -3% | 당일 신규 진입 중단 | [경고] |
|
| L2 | 일일 -3% | 당일 신규 진입 중단 | [경고] |
|
||||||
| L3 | 3연속 손절 | 당일 매매 중단 | [경고] |
|
| L3-B | 연속 손절 | 포지션 크기 단계 축소 (전면 중단 없음) | [경고] |
|
||||||
| L4 | 주간 -7% | 주말까지 중단 | [경고] |
|
| L4 | 주간 -7% | 주말까지 중단 | [경고] |
|
||||||
| L5 | 월간 -15% | 전략 폐기 + 재검토 | [긴급] |
|
| L5 | 월간 -15% | 전략 폐기 + 재검토 | [긴급] |
|
||||||
|
|
||||||
|
**L3-B 포지션 배율** (익절 시 한 단계 회복):
|
||||||
|
|
||||||
|
| 연속 손절 | 포지션 크기 |
|
||||||
|
|-----------|------------|
|
||||||
|
| 0회 | 1.0× (정상) |
|
||||||
|
| 1회 | 0.7× |
|
||||||
|
| 2회 | 0.5× |
|
||||||
|
| 3회+ | 0.3× (최소) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 실전 전환 조건 (claude_evening 자동 체크)
|
## 실전 전환 조건 (claude_evening 자동 체크)
|
||||||
@@ -202,7 +231,7 @@ stockbot_v3/
|
|||||||
| 승률 | 최근 30일 > 48% |
|
| 승률 | 최근 30일 > 48% |
|
||||||
| MDD | 최근 30일 < -10% |
|
| MDD | 최근 30일 < -10% |
|
||||||
| 샤프지수 | 최근 30일 > 1.0 |
|
| 샤프지수 | 최근 30일 > 1.0 |
|
||||||
| L3 발동 | 월 2회 이하 |
|
| L3-B 최소배율(0.3×) 도달 | 월 2회 이하 |
|
||||||
|
|
||||||
전부 충족 시 → `reports/live_ready/날짜_READY.md` 생성 + Discord 🚀 알림
|
전부 충족 시 → `reports/live_ready/날짜_READY.md` 생성 + Discord 🚀 알림
|
||||||
전환 방법: `.env`에서 `KIS_MOCK=false`, `DRY_RUN=false` 로 변경
|
전환 방법: `.env`에서 `KIS_MOCK=false`, `DRY_RUN=false` 로 변경
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""
|
||||||
|
app/ai/midday.py
|
||||||
|
장중 데이터 수집 스크립트 (claude_midday 헬퍼)
|
||||||
|
|
||||||
|
Claude Code headless가 이 스크립트를 실행해 장중 스냅샷을 수집한 뒤,
|
||||||
|
오전 daily_context와 비교 분석해 midday_context.json을 작성한다.
|
||||||
|
|
||||||
|
실행:
|
||||||
|
python app/ai/midday.py --print
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env():
|
||||||
|
env_path = Path(".env")
|
||||||
|
if not env_path.exists():
|
||||||
|
return
|
||||||
|
with open(env_path, encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
k, _, v = line.partition("=")
|
||||||
|
k = k.strip()
|
||||||
|
v = v.strip()
|
||||||
|
if " #" in v:
|
||||||
|
v = v[: v.index(" #")]
|
||||||
|
v = v.strip().strip('"').strip("'")
|
||||||
|
if k and v and k not in os.environ:
|
||||||
|
os.environ[k] = v
|
||||||
|
|
||||||
|
|
||||||
|
_load_env()
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parent.parent.parent
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
os.makedirs("logs", exist_ok=True)
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(sys.stderr),
|
||||||
|
logging.FileHandler("logs/stockbot.log", encoding="utf-8"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
from app.execution.kis_client import KISClient
|
||||||
|
from app.db.models import get_conn
|
||||||
|
|
||||||
|
TODAY = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
DAILY_CONTEXT_PATH = Path("data/daily_context.json")
|
||||||
|
MIDDAY_CONTEXT_PATH = Path("data/midday_context.json")
|
||||||
|
|
||||||
|
|
||||||
|
# ── DB 조회 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_today_trades() -> list:
|
||||||
|
"""오늘 체결된 거래 내역 (오전 결과)"""
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT ticker, name, entry_time, exit_time,
|
||||||
|
entry_price, exit_price, quantity,
|
||||||
|
exit_reason, pnl
|
||||||
|
FROM trades
|
||||||
|
WHERE date=? AND exit_time IS NOT NULL
|
||||||
|
ORDER BY exit_time""",
|
||||||
|
(TODAY,),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"ticker" : r[0],
|
||||||
|
"name" : r[1],
|
||||||
|
"entry_time" : r[2],
|
||||||
|
"exit_time" : r[3],
|
||||||
|
"entry_price": r[4],
|
||||||
|
"exit_price" : r[5],
|
||||||
|
"qty" : r[6],
|
||||||
|
"reason" : r[7],
|
||||||
|
"pnl" : r[8],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"거래 내역 조회 실패: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_positions() -> list:
|
||||||
|
"""현재 보유 포지션"""
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT ticker, name, entry_time, entry_price, quantity,
|
||||||
|
target_price, stop_price
|
||||||
|
FROM positions""",
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"ticker" : r[0],
|
||||||
|
"name" : r[1],
|
||||||
|
"entry_time" : r[2],
|
||||||
|
"entry_price" : r[3],
|
||||||
|
"qty" : r[4],
|
||||||
|
"target_price": r[5],
|
||||||
|
"stop_price" : r[6],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"포지션 조회 실패: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_morning_context() -> dict:
|
||||||
|
"""아침 daily_context.json 로드"""
|
||||||
|
if not DAILY_CONTEXT_PATH.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(DAILY_CONTEXT_PATH.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── KIS 시장 스냅샷 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def fetch_market_snapshot(kis: KISClient) -> dict:
|
||||||
|
"""거래량 순위 + 업종 동향 현재 시점 스냅샷"""
|
||||||
|
data: dict = {"volume_rank": [], "sectors": []}
|
||||||
|
|
||||||
|
try:
|
||||||
|
data["volume_rank"] = await kis.get_volume_rank(top_n=20)
|
||||||
|
logger.info(f"장중 거래량 순위 {len(data['volume_rank'])}종목")
|
||||||
|
await asyncio.sleep(1.1)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"거래량 순위 실패: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data["sectors"] = await kis.get_sector_trend()
|
||||||
|
logger.info(f"업종 동향 {len(data['sectors'])}개")
|
||||||
|
await asyncio.sleep(1.1)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"업종 동향 실패: {e}")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ── 통계 계산 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _calc_session_stats(trades: list) -> dict:
|
||||||
|
closed = [t for t in trades if t["pnl"] is not None]
|
||||||
|
wins = [t for t in closed if t["pnl"] > 0]
|
||||||
|
losses = [t for t in closed if t["pnl"] <= 0]
|
||||||
|
net = sum(t["pnl"] for t in closed)
|
||||||
|
|
||||||
|
# 현재 연속 손절 수 계산 (역순으로 순회)
|
||||||
|
consec = 0
|
||||||
|
for t in reversed(closed):
|
||||||
|
if t["pnl"] < 0:
|
||||||
|
consec += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total" : len(closed),
|
||||||
|
"wins" : len(wins),
|
||||||
|
"losses" : len(losses),
|
||||||
|
"net_pnl" : round(net, 0),
|
||||||
|
"win_rate_pct": round(len(wins) / len(closed) * 100, 1) if closed else 0,
|
||||||
|
"consec_loss" : consec,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 메인 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def main(print_mode: bool = False):
|
||||||
|
logger.info(f"장중 데이터 수집 시작 [{TODAY}]")
|
||||||
|
|
||||||
|
# 실거래 API로 KISClient 생성
|
||||||
|
_orig = os.environ.get("KIS_MOCK", "true")
|
||||||
|
os.environ["KIS_MOCK"] = "false"
|
||||||
|
kis = KISClient()
|
||||||
|
os.environ["KIS_MOCK"] = _orig
|
||||||
|
|
||||||
|
market: dict = {"volume_rank": [], "sectors": []}
|
||||||
|
try:
|
||||||
|
await kis.get_access_token()
|
||||||
|
market = await fetch_market_snapshot(kis)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"KIS 장중 수집 실패: {e}")
|
||||||
|
|
||||||
|
trades = get_today_trades()
|
||||||
|
positions = get_current_positions()
|
||||||
|
morning = get_morning_context()
|
||||||
|
stats = _calc_session_stats(trades)
|
||||||
|
|
||||||
|
logger.info(f"오전 결과: {stats['total']}건 / 승{stats['wins']} 패{stats['losses']} "
|
||||||
|
f"/ 연속손절 {stats['consec_loss']}회")
|
||||||
|
|
||||||
|
if print_mode:
|
||||||
|
print(json.dumps(
|
||||||
|
{
|
||||||
|
"date" : TODAY,
|
||||||
|
"generated_at" : datetime.now().strftime("%H:%M:%S"),
|
||||||
|
"morning_context" : morning,
|
||||||
|
"session_stats" : stats,
|
||||||
|
"trades" : trades,
|
||||||
|
"current_positions": positions,
|
||||||
|
"volume_rank" : market["volume_rank"],
|
||||||
|
"sectors" : market["sectors"],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print_mode = "--print" in sys.argv
|
||||||
|
asyncio.run(main(print_mode=print_mode))
|
||||||
+80
-9
@@ -12,6 +12,7 @@ main.py
|
|||||||
DRY_RUN=true → 신호만 확인, 주문 전송 안 함
|
DRY_RUN=true → 신호만 확인, 주문 전송 안 함
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -86,10 +87,58 @@ class StockBot:
|
|||||||
self.risk = None # RiskManager (잔고 확인 후 초기화)
|
self.risk = None # RiskManager (잔고 확인 후 초기화)
|
||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
|
# 장중 컨텍스트 (midday_context.json 갱신 감지용)
|
||||||
|
self._midday_ctx_mtime : float = 0.0
|
||||||
|
self._midday_pos_mult : float = 1.0 # midday position_size_multiplier
|
||||||
|
self._midday_loaded : bool = False
|
||||||
|
|
||||||
mode = "모의투자" if self.kis.is_mock else "실거래"
|
mode = "모의투자" if self.kis.is_mock else "실거래"
|
||||||
dry = " [DRY_RUN]" if os.getenv("DRY_RUN","true")=="true" else ""
|
dry = " [DRY_RUN]" if os.getenv("DRY_RUN","true")=="true" else ""
|
||||||
logger.info(f"StockBot 시작 [{mode}]{dry}")
|
logger.info(f"StockBot 시작 [{mode}]{dry}")
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
# 장중 컨텍스트 감시
|
||||||
|
# ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def _check_midday_context(self):
|
||||||
|
"""midday_context.json 갱신 감지 → 즉시 점심 세션 파라미터 반영"""
|
||||||
|
path = Path("data/midday_context.json")
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
mtime = path.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
if mtime <= self._midday_ctx_mtime:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ctx = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if ctx.get("date") != datetime.now().strftime("%Y-%m-%d"):
|
||||||
|
return
|
||||||
|
if not ctx.get("lunch_trade_allowed", True):
|
||||||
|
logger.warning("midday_context: 점심 세션 진입 중단 설정")
|
||||||
|
self._midday_pos_mult = float(ctx.get("position_size_multiplier", 1.0))
|
||||||
|
# 섹터·블랙리스트 업데이트
|
||||||
|
if "hot_sectors" in ctx:
|
||||||
|
self.strategy.context["hot_sectors"] = ctx["hot_sectors"]
|
||||||
|
if "avoid_sectors" in ctx:
|
||||||
|
self.strategy.context["avoid_sectors"] = ctx["avoid_sectors"]
|
||||||
|
for t in ctx.get("blacklist_tickers", []):
|
||||||
|
bl = self.strategy.context.setdefault("blacklist_tickers", [])
|
||||||
|
if t not in bl:
|
||||||
|
bl.append(t)
|
||||||
|
# lunch_trade_allowed=false이면 진입 자체를 막는 플래그 저장
|
||||||
|
self.strategy.context["lunch_trade_allowed"] = ctx.get("lunch_trade_allowed", True)
|
||||||
|
self._midday_ctx_mtime = mtime
|
||||||
|
self._midday_loaded = True
|
||||||
|
logger.info(
|
||||||
|
f"midday_context 로드 완료 — 점심 세션 시작 "
|
||||||
|
f"(포지션 배율: ×{self._midday_pos_mult}, "
|
||||||
|
f"진입허용: {ctx.get('lunch_trade_allowed', True)})"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"midday_context 로드 실패: {e}")
|
||||||
|
|
||||||
# ─────────────────────────────────────────
|
# ─────────────────────────────────────────
|
||||||
# 초기화
|
# 초기화
|
||||||
# ─────────────────────────────────────────
|
# ─────────────────────────────────────────
|
||||||
@@ -276,7 +325,7 @@ class StockBot:
|
|||||||
self.running = False
|
self.running = False
|
||||||
break
|
break
|
||||||
|
|
||||||
# 14:00 이후 신규 진입 중단 (강제청산 50분 전)
|
# 14:00 이후 신규 진입 중단
|
||||||
if now_str > "14:00":
|
if now_str > "14:00":
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
@@ -286,12 +335,10 @@ class StockBot:
|
|||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 점심 (11:30~13:00) 신규 진입 중단
|
# midday_context.json 갱신 감지 (점심 세션 이벤트 기반 시작)
|
||||||
if "11:30" <= now_str < "13:00":
|
self._check_midday_context()
|
||||||
await asyncio.sleep(1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 리스크 체크
|
# 리스크 체크 (L2/L4/L5 하드 중단)
|
||||||
if not self.risk.can_trade():
|
if not self.risk.can_trade():
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
continue
|
continue
|
||||||
@@ -311,8 +358,16 @@ class StockBot:
|
|||||||
|
|
||||||
async def check_entries(self):
|
async def check_entries(self):
|
||||||
"""유니버스 전체 진입 신호 확인"""
|
"""유니버스 전체 진입 신호 확인"""
|
||||||
# check_exits 처리 중 14:00을 넘었을 경우 진입 차단
|
now_str = datetime.now().strftime("%H:%M")
|
||||||
if datetime.now().strftime("%H:%M") > "14:00":
|
# 14:00 이후 진입 차단
|
||||||
|
if now_str > "14:00":
|
||||||
|
return
|
||||||
|
# midday_context 로드 전(11:20~) 11:00 이후 신규 진입 일시 중단
|
||||||
|
# — midday_context.json이 생성되면 _check_midday_context()가 자동 해제
|
||||||
|
if now_str >= "11:00" and not self._midday_loaded:
|
||||||
|
return
|
||||||
|
# lunch_trade_allowed=false이면 점심 세션 진입 차단
|
||||||
|
if self._midday_loaded and not self.strategy.context.get("lunch_trade_allowed", True):
|
||||||
return
|
return
|
||||||
for ticker in self.universe:
|
for ticker in self.universe:
|
||||||
if ticker in self.positions:
|
if ticker in self.positions:
|
||||||
@@ -341,7 +396,13 @@ class StockBot:
|
|||||||
|
|
||||||
balance = await self.kis.get_balance()
|
balance = await self.kis.get_balance()
|
||||||
cash = balance["cash"]
|
cash = balance["cash"]
|
||||||
invest = self.risk.get_pos_size(cash, signal.get("multiplier", 1.0))
|
# AI 신호 배율 × B안(연속 손절) 배율 × midday 배율
|
||||||
|
combined_mult = (
|
||||||
|
signal.get("multiplier", 1.0)
|
||||||
|
* self.risk.get_consec_multiplier()
|
||||||
|
* self._midday_pos_mult
|
||||||
|
)
|
||||||
|
invest = self.risk.get_pos_size(cash, combined_mult)
|
||||||
qty = max(1, int(invest // current))
|
qty = max(1, int(invest // current))
|
||||||
|
|
||||||
result = await self.executor.buy(
|
result = await self.executor.buy(
|
||||||
@@ -435,6 +496,16 @@ class StockBot:
|
|||||||
|
|
||||||
self.risk.record_trade(pnl)
|
self.risk.record_trade(pnl)
|
||||||
|
|
||||||
|
# B안: 연속 손절 2회·3회 도달 시 Discord 알림
|
||||||
|
if reason == "SL":
|
||||||
|
consec = self.risk.consec_loss
|
||||||
|
if consec in (2, 3):
|
||||||
|
mult = self.risk.get_consec_multiplier()
|
||||||
|
await notify_risk(
|
||||||
|
"L3-B",
|
||||||
|
f"{consec}연속 손절 — 포지션 크기 {int(mult * 100)}%로 축소"
|
||||||
|
)
|
||||||
|
|
||||||
if reason == "TP1":
|
if reason == "TP1":
|
||||||
pos["tp1_done"] = True
|
pos["tp1_done"] = True
|
||||||
pos["qty"] -= qty
|
pos["qty"] -= qty
|
||||||
|
|||||||
+11
-6
@@ -36,6 +36,14 @@ class RiskManager:
|
|||||||
|
|
||||||
# ── 손실 기록 ──
|
# ── 손실 기록 ──
|
||||||
|
|
||||||
|
# B안: 연속 손절 수 → 포지션 크기 배율
|
||||||
|
_CONSEC_MULT = {0: 1.0, 1: 0.7, 2: 0.5}
|
||||||
|
_CONSEC_MIN = 0.3 # 3회 이상 최소값
|
||||||
|
|
||||||
|
def get_consec_multiplier(self) -> float:
|
||||||
|
"""연속 손절 수에 따른 포지션 크기 배율 (B안)"""
|
||||||
|
return self._CONSEC_MULT.get(self.consec_loss, self._CONSEC_MIN)
|
||||||
|
|
||||||
def record_trade(self, pnl: float):
|
def record_trade(self, pnl: float):
|
||||||
"""매매 결과 기록 및 손실 한도 체크"""
|
"""매매 결과 기록 및 손실 한도 체크"""
|
||||||
self.daily_pnl += pnl
|
self.daily_pnl += pnl
|
||||||
@@ -45,20 +53,17 @@ class RiskManager:
|
|||||||
if pnl < 0:
|
if pnl < 0:
|
||||||
self.consec_loss += 1
|
self.consec_loss += 1
|
||||||
else:
|
else:
|
||||||
self.consec_loss = 0
|
# 익절 시 한 단계만 회복 (0으로 리셋 아님)
|
||||||
|
self.consec_loss = max(0, self.consec_loss - 1)
|
||||||
|
|
||||||
self._check_limits()
|
self._check_limits()
|
||||||
|
|
||||||
def _check_limits(self):
|
def _check_limits(self):
|
||||||
"""L1~L5 손실 한도 체크"""
|
"""L2/L4/L5 손실 한도 체크 (L3는 B안 포지션 축소로 대체)"""
|
||||||
# L2: 일일 누적 손실 -3%
|
# L2: 일일 누적 손실 -3%
|
||||||
if self.daily_pnl / self.init_cash < -DAILY_SL_PCT:
|
if self.daily_pnl / self.init_cash < -DAILY_SL_PCT:
|
||||||
self._stop("L2", f"일일 손실 {self.daily_pnl/self.init_cash*100:.1f}% 도달")
|
self._stop("L2", f"일일 손실 {self.daily_pnl/self.init_cash*100:.1f}% 도달")
|
||||||
|
|
||||||
# L3: 연속 손절 3회
|
|
||||||
if self.consec_loss >= CONSEC_LOSS:
|
|
||||||
self._stop("L3", f"{CONSEC_LOSS}연속 손절 발생")
|
|
||||||
|
|
||||||
# L4: 주간 누적 -7%
|
# L4: 주간 누적 -7%
|
||||||
if self.weekly_pnl / self.init_cash < -0.07:
|
if self.weekly_pnl / self.init_cash < -0.07:
|
||||||
self._stop("L4", f"주간 손실 {self.weekly_pnl/self.init_cash*100:.1f}%")
|
self._stop("L4", f"주간 손실 {self.weekly_pnl/self.init_cash*100:.1f}%")
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# claude_midday 실행 스크립트
|
||||||
|
# 작업 스케줄러에서 11:20에 실행 (평일)
|
||||||
|
|
||||||
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
$env:PYTHONIOENCODING = "utf-8"
|
||||||
|
|
||||||
|
$PROJECT = Split-Path -Parent $PSScriptRoot
|
||||||
|
$LOG = "$PROJECT\logs\midday.log"
|
||||||
|
$CLAUDE = "C:\Users\whdwo\AppData\Roaming\npm\claude.cmd"
|
||||||
|
$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] claude_midday 시작`n", $utf8)
|
||||||
|
|
||||||
|
& $CLAUDE -p "/midday" --dangerously-skip-permissions 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] claude_midday 완료`n", $utf8)
|
||||||
@@ -21,13 +21,16 @@ function Register-StockTask($name, $time, $script, $limitMin) {
|
|||||||
Write-Host "[OK] $name 등록 완료 (평일 $time)" -ForegroundColor Green
|
Write-Host "[OK] $name 등록 완료 (평일 $time)" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 3개 태스크 등록 ──────────────────────────────────────────────────────────
|
# ── 4개 태스크 등록 ──────────────────────────────────────────────────────────
|
||||||
# 07:55 claude /start-bot → Python 봇 백그라운드 시작
|
# 07:55 claude /start-bot → Python 봇 백그라운드 시작
|
||||||
Register-StockTask "StockBot_Bot" "07:55" "run_bot.ps1" 10
|
Register-StockTask "StockBot_Bot" "07:55" "run_bot.ps1" 10
|
||||||
|
|
||||||
# 08:15 claude /morning → 뉴스+KIS 수집 → daily_context.json
|
# 08:15 claude /morning → 뉴스+KIS 수집 → daily_context.json
|
||||||
Register-StockTask "StockBot_Morning" "08:15" "run_morning.ps1" 20
|
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
|
# 15:30 claude /evening → 결과 분석 → 리포트 → Discord
|
||||||
Register-StockTask "StockBot_Evening" "15:30" "run_evening.ps1" 30
|
Register-StockTask "StockBot_Evening" "15:30" "run_evening.ps1" 30
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user