From bfff65e55bcf914dad0d62deeff49e2e82f9f4af Mon Sep 17 00:00:00 2001 From: jongjae0305 Date: Thu, 14 May 2026 15:14:50 +0900 Subject: [PATCH] first vibe coding --- .gitignore | 10 + README.md | 49 +++ app/Dockerfile | 6 + app/__init__.py | 0 app/ai/__init__.py | 0 app/config.py | 36 ++ app/data/__init__.py | 0 app/data/collector.py | 22 ++ app/data/universe.py | 38 ++ app/db/__init__.py | 0 app/db/models.py | 88 +++++ app/db/repository.py | 78 ++++ app/execution/__init__.py | 0 app/execution/kis_client.py | 550 ++++++++++++++++++++++++++++ app/execution/order_executor.py | 99 +++++ app/main.py | 458 +++++++++++++++++++++++ app/monitor/__init__.py | 0 app/monitor/dashboard.py | 86 +++++ app/monitor/notifier.py | 79 ++++ app/requirements.txt | 8 + app/risk/__init__.py | 0 app/risk/manager.py | 107 ++++++ app/strategy/__init__.py | 0 app/strategy/base.py | 19 + app/strategy/volatility_breakout.py | 217 +++++++++++ claude_evening/Dockerfile | 7 + claude_evening/run.sh | 58 +++ claude_morning/Dockerfile | 6 + claude_morning/run.sh | 52 +++ data/.gitkeep | 0 docker-compose.yml | 74 ++++ kill_switch/Dockerfile | 5 + kill_switch/__init__.py | 0 kill_switch/kill.py | 51 +++ logs/.gitkeep | 0 reports/daily/.gitkeep | 0 reports/live_ready/.gitkeep | 0 reports/weekly/.gitkeep | 0 test_connection.py | 84 +++++ 종합기획서_단타자동매매_v3.md | 508 +++++++++++++++++++++++++ 40 files changed, 2795 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/Dockerfile create mode 100644 app/__init__.py create mode 100644 app/ai/__init__.py create mode 100644 app/config.py create mode 100644 app/data/__init__.py create mode 100644 app/data/collector.py create mode 100644 app/data/universe.py create mode 100644 app/db/__init__.py create mode 100644 app/db/models.py create mode 100644 app/db/repository.py create mode 100644 app/execution/__init__.py create mode 100644 app/execution/kis_client.py create mode 100644 app/execution/order_executor.py create mode 100644 app/main.py create mode 100644 app/monitor/__init__.py create mode 100644 app/monitor/dashboard.py create mode 100644 app/monitor/notifier.py create mode 100644 app/requirements.txt create mode 100644 app/risk/__init__.py create mode 100644 app/risk/manager.py create mode 100644 app/strategy/__init__.py create mode 100644 app/strategy/base.py create mode 100644 app/strategy/volatility_breakout.py create mode 100644 claude_evening/Dockerfile create mode 100644 claude_evening/run.sh create mode 100644 claude_morning/Dockerfile create mode 100644 claude_morning/run.sh create mode 100644 data/.gitkeep create mode 100644 docker-compose.yml create mode 100644 kill_switch/Dockerfile create mode 100644 kill_switch/__init__.py create mode 100644 kill_switch/kill.py create mode 100644 logs/.gitkeep create mode 100644 reports/daily/.gitkeep create mode 100644 reports/live_ready/.gitkeep create mode 100644 reports/weekly/.gitkeep create mode 100644 test_connection.py create mode 100644 종합기획서_단타자동매매_v3.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff1f188 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +data/stockbot.db +data/daily_context.json +data/universe_cache.json +data/redis/ +logs/*.log +__pycache__/ +*.pyc +*.pyo +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec8bf2e --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# 단타 자동매매 시스템 v3.0 + +기획서 v3.0 기준 / KIS Open API / Synology NAS Docker +AI: Claude Code headless (장 전 분석 + 장 후 피드백) + +## 운영 모드 + +| KIS_MOCK | DRY_RUN | 동작 | +|----------|---------|------| +| true | true | 신호 확인만 (주문 없음) ← 처음 시작 | +| true | false | 모의투자 실제 주문 ← 3개월 검증 | +| false | false | 실거래 ← 조건 충족 후 | + +## 빠른 시작 + +```bash +# 1. .env 설정 +cp .env.example .env +# .env 열어서 KIS 키, Discord Webhook URL 입력 + +# 2. KIS 연결 테스트 +pip install aiohttp python-dotenv +python test_connection.py + +# 3. 신호 확인 (DRY_RUN=true) +python app/main.py + +# 4. Docker 실행 (NAS) +docker-compose up -d +``` + +## 컨테이너 구성 + +| 컨테이너 | 역할 | 실행 시간 | +|---------|------|---------| +| stockbot-main | 매매 프로그램 | 상시 (09:00~15:00 활성) | +| stockbot-redis | 시세 캐시 | 상시 | +| stockbot-dashboard | Streamlit 모니터링 | 상시 (포트 8501) | +| claude-morning | 장 전 AI 분석 | 08:30 (실행 후 종료) | +| claude-evening | 장 후 AI 피드백 | 15:30 (실행 후 종료) | +| stockbot-killswitch | 긴급 청산 | 수동 트리거 | + +## 긴급 청산 + +```bash +docker-compose --profile emergency up kill-switch +# 또는 +python kill_switch/kill.py +``` diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..dc3c137 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +CMD ["python", "main.py"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ai/__init__.py b/app/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..15deeba --- /dev/null +++ b/app/config.py @@ -0,0 +1,36 @@ +""" +config.py - 전략 파라미터 전용 (기획서 v3.0 기준) +Claude Code가 이 파일을 읽고 필요시 수정함 +""" + +# ── 변동성 돌파 ── +STRATEGY_K = 0.5 +ENTRY_START = "09:00" +ENTRY_END = "14:30" +FORCE_EXIT = "14:50" # 절대 변경 불가 +TP1_PCT = 0.02 # 1차 익절 +2% → 50% 매도 +TP2_PCT = 0.03 # 2차 익절 +3% → 전량 +SL_PCT = 0.015 # 손절 -1.5% +MAX_HOLD_MIN = 120 + +# ── 리스크 ── +POS_SIZE_PCT = 0.20 +MAX_POSITIONS = 2 +DAILY_SL_PCT = 0.03 +CONSEC_LOSS = 3 +AI_RISK_SL_MAP = {"낮음": 0.015, "보통": 0.015, "높음": 0.010} + +# ── 유니버스 ── +MIN_TRADE_AMOUNT = 10_000_000_000 +MAX_UNIVERSE = 30 +KOSPI_MIN_CHG = -1.0 + +# ── AI ── +AI_CONTEXT_PATH = "data/daily_context.json" +AI_MIN_SCORE = 40 +AI_BOOST_MULTI = 1.5 + +# ── 비용 ── +FEE_RATE = 0.00015 +TAX_RATE = 0.0018 +SLIPPAGE = 0.001 diff --git a/app/data/__init__.py b/app/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/data/collector.py b/app/data/collector.py new file mode 100644 index 0000000..f9d5263 --- /dev/null +++ b/app/data/collector.py @@ -0,0 +1,22 @@ +""" +data/collector.py - KIS WebSocket 실시간 시세 수신 +""" +import asyncio, logging +from app.execution.kis_client import KISWebSocket + +logger = logging.getLogger(__name__) + + +class DataCollector: + def __init__(self, kis_client, on_price, on_vi): + self.ws = KISWebSocket(kis_client) + self.on_price = on_price + self.on_vi = on_vi + + async def start(self, tickers: list): + self.ws.on_price("*", self.on_price) + self.ws.on_vi(self.on_vi) + await self.ws.subscribe(tickers) + + async def stop(self): + await self.ws.close() diff --git a/app/data/universe.py b/app/data/universe.py new file mode 100644 index 0000000..eb1a982 --- /dev/null +++ b/app/data/universe.py @@ -0,0 +1,38 @@ +""" +data/universe.py - 종목 풀 갱신 (08:30) +""" +import json, os, logging +from app.config import MAX_UNIVERSE + +logger = logging.getLogger(__name__) +CACHE_PATH = "data/universe_cache.json" + + +async def update_universe(kis_client, ai_context: dict) -> list: + """거래량 순위 기반 유니버스 갱신 + AI 필터""" + try: + rank = await kis_client.get_volume_rank(top_n=MAX_UNIVERSE * 2) + tickers = [r["ticker"] for r in rank] + + blacklist = ai_context.get("blacklist_tickers", []) + tickers = [t for t in tickers if t not in blacklist] + + boosted = ai_context.get("boosted_tickers", []) + tickers = ( + [t for t in boosted if t in tickers] + + [t for t in tickers if t not in boosted] + )[:MAX_UNIVERSE] + + os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True) + with open(CACHE_PATH, "w") as f: + json.dump({"tickers": tickers, "boosted": boosted}, f) + + logger.info(f"유니버스 갱신: {len(tickers)}종목 (부스트: {len(boosted)})") + return tickers + + except Exception as e: + logger.error(f"유니버스 갱신 실패: {e}") + if os.path.exists(CACHE_PATH): + with open(CACHE_PATH) as f: + return json.load(f).get("tickers", []) + return [] diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/models.py b/app/db/models.py new file mode 100644 index 0000000..9ed658f --- /dev/null +++ b/app/db/models.py @@ -0,0 +1,88 @@ +""" +db/models.py - SQLite 스키마 생성 +기획서 v2.1 기준 4개 테이블 +""" +import sqlite3 +import os + +DB_PATH = os.getenv("DB_PATH", "data/stockbot.db") + +def init_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # 체결 내역 + c.execute(""" + CREATE TABLE IF NOT EXISTS trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + ticker TEXT NOT NULL, + name TEXT, + entry_time TEXT NOT NULL, + exit_time TEXT, + entry_price REAL NOT NULL, + exit_price REAL, + quantity INTEGER NOT NULL, + side TEXT NOT NULL, + exit_reason TEXT, + pnl REAL, + fee REAL, + slippage REAL, + strategy TEXT DEFAULT 'VB', + ai_boosted INTEGER DEFAULT 0 + )""") + + # 일일 요약 + c.execute(""" + CREATE TABLE IF NOT EXISTS daily_summary ( + date TEXT PRIMARY KEY, + total_trades INTEGER DEFAULT 0, + win_trades INTEGER DEFAULT 0, + lose_trades INTEGER DEFAULT 0, + gross_pnl REAL DEFAULT 0, + total_fee REAL DEFAULT 0, + net_pnl REAL DEFAULT 0, + max_drawdown REAL DEFAULT 0, + trading_stopped INTEGER DEFAULT 0 + )""") + + # 포지션 (장중 현황) + c.execute(""" + CREATE TABLE IF NOT EXISTS positions ( + ticker TEXT PRIMARY KEY, + name TEXT, + entry_time TEXT, + entry_price REAL, + quantity INTEGER, + tp1_done INTEGER DEFAULT 0, + target_price REAL, + stop_price REAL, + ai_boosted INTEGER DEFAULT 0 + )""") + + # AI 판단 이력 + c.execute(""" + CREATE TABLE IF NOT EXISTS ai_context_log ( + date TEXT PRIMARY KEY, + generated_at TEXT, + trade_allowed INTEGER, + market_sentiment TEXT, + sentiment_score INTEGER, + risk_level TEXT, + hot_sectors TEXT, + avoid_sectors TEXT, + boosted_tickers TEXT, + blacklist_tickers TEXT, + position_size_mult REAL, + reason TEXT, + claude_tokens_used INTEGER, + api_call_success INTEGER DEFAULT 1 + )""") + + conn.commit() + conn.close() + print(f"DB 초기화 완료: {DB_PATH}") + +def get_conn(): + return sqlite3.connect(DB_PATH) diff --git a/app/db/repository.py b/app/db/repository.py new file mode 100644 index 0000000..0dbd419 --- /dev/null +++ b/app/db/repository.py @@ -0,0 +1,78 @@ +""" +db/repository.py - DB 접근 레이어 +""" +import json +from datetime import datetime +from app.db.models import get_conn + + +def save_trade(ticker, name, entry_time, entry_price, + quantity, side, ai_boosted=False): + with get_conn() as conn: + conn.execute(""" + INSERT INTO trades + (date, ticker, name, entry_time, entry_price, quantity, side, ai_boosted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (datetime.now().strftime("%Y-%m-%d"), ticker, name, + entry_time, entry_price, quantity, side, + 1 if ai_boosted else 0)) + + +def update_trade_exit(ticker, exit_time, exit_price, exit_reason, pnl, fee): + with get_conn() as conn: + conn.execute(""" + UPDATE trades SET exit_time=?, exit_price=?, + exit_reason=?, pnl=?, fee=? + WHERE ticker=? AND exit_time IS NULL + ORDER BY id DESC LIMIT 1 + """, (exit_time, exit_price, exit_reason, pnl, fee, ticker)) + + +def save_daily_summary(date, total, wins, losses, + gross_pnl, fee, net_pnl, mdd, stopped): + with get_conn() as conn: + conn.execute(""" + INSERT OR REPLACE INTO daily_summary + VALUES (?,?,?,?,?,?,?,?,?) + """, (date, total, wins, losses, gross_pnl, fee, net_pnl, mdd, stopped)) + + +def save_ai_context(ctx: dict, tokens_used: int = 0, success: bool = True): + with get_conn() as conn: + conn.execute(""" + INSERT OR REPLACE INTO ai_context_log + (date, generated_at, trade_allowed, market_sentiment, + sentiment_score, risk_level, hot_sectors, avoid_sectors, + boosted_tickers, blacklist_tickers, position_size_mult, + reason, claude_tokens_used, api_call_success) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, ( + ctx.get("date"), ctx.get("generated_at"), + 1 if ctx.get("trade_allowed") else 0, + ctx.get("market_sentiment"), ctx.get("sentiment_score"), + ctx.get("risk_level"), + 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), + ctx.get("position_size_multiplier", 1.0), + ctx.get("reason", ""), + tokens_used, 1 if success else 0, + )) + + +def get_today_trades(date: str = None): + date = date or datetime.now().strftime("%Y-%m-%d") + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM trades WHERE date=?", (date,) + ).fetchall() + return rows + + +def get_open_positions(): + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM positions" + ).fetchall() + return rows diff --git a/app/execution/__init__.py b/app/execution/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/execution/kis_client.py b/app/execution/kis_client.py new file mode 100644 index 0000000..2a414bc --- /dev/null +++ b/app/execution/kis_client.py @@ -0,0 +1,550 @@ +""" +kis_client.py +KIS Open API REST + WebSocket 래퍼 +- 토큰 자동 발급/갱신 +- 모의투자/실거래 모드 자동 전환 +- rate limit 제어 (초당 20건) +""" + +import os +import json +import time +import asyncio +import aiohttp +import logging +from datetime import datetime, timedelta +from typing import Optional, Dict, Any, Callable + +logger = logging.getLogger(__name__) + +# ── 모드별 베이스 URL ── +URL_REAL = "https://openapi.koreainvestment.com:9443" +URL_MOCK = "https://openapivts.koreainvestment.com:29443" + + +class KISClient: + """ + KIS Open API 클라이언트 + 모의투자/실거래 모드를 .env의 KIS_MOCK 값으로 자동 전환 + """ + + def __init__(self): + self.is_mock = os.getenv("KIS_MOCK", "true").lower() == "true" + self.base_url = URL_MOCK if self.is_mock else URL_REAL + + # 모드별 키 자동 선택 + if self.is_mock: + self.app_key = os.getenv("KIS_MOCK_APP_KEY", "") + self.app_secret = os.getenv("KIS_MOCK_APP_SECRET", "") + self.account_no = os.getenv("KIS_MOCK_ACCOUNT_NO", "") + else: + self.app_key = os.getenv("KIS_APP_KEY", "") + self.app_secret = os.getenv("KIS_APP_SECRET", "") + self.account_no = os.getenv("KIS_ACCOUNT_NO", "") + + # 계좌번호 파싱 (앞 8자리 + 뒤 2자리) + self._parse_account() + + # 토큰 관련 + self._access_token : Optional[str] = None + self._token_expires_at: Optional[datetime] = None + + # rate limit: 초당 20건 + self._semaphore = asyncio.Semaphore(20) + self._req_times : list = [] + + mode = "모의투자" if self.is_mock else "실거래" + logger.info(f"KISClient 초기화 완료 [{mode}] 계좌: {self.account_no}") + + def _parse_account(self): + """계좌번호 파싱: '50123456-01' → ('50123456', '01')""" + raw = self.account_no.replace("-", "") + if len(raw) >= 10: + self.acct_prefix = raw[:8] + self.acct_suffix = raw[8:10] + else: + self.acct_prefix = raw + self.acct_suffix = "01" + + # ───────────────────────────────────────── + # 토큰 관리 + # ───────────────────────────────────────── + + async def get_access_token(self) -> str: + """액세스 토큰 발급/갱신 (만료 30분 전 자동 갱신)""" + now = datetime.now() + if (self._access_token + and self._token_expires_at + and now < self._token_expires_at - timedelta(minutes=30)): + return self._access_token + + url = f"{self.base_url}/oauth2/tokenP" + body = { + "grant_type" : "client_credentials", + "appkey" : self.app_key, + "appsecret" : self.app_secret, + } + + async with aiohttp.ClientSession() as session: + async with session.post(url, json=body) as resp: + data = await resp.json() + + if "access_token" not in data: + raise RuntimeError(f"토큰 발급 실패: {data}") + + self._access_token = data["access_token"] + # 유효기간 24시간 + self._token_expires_at = now + timedelta(hours=24) + logger.info("KIS 액세스 토큰 발급/갱신 완료") + return self._access_token + + # ───────────────────────────────────────── + # REST API 기본 호출 + # ───────────────────────────────────────── + + async def _request( + self, + method : str, + path : str, + tr_id : str, + params : Optional[Dict] = None, + body : Optional[Dict] = None, + ) -> Dict[str, Any]: + """ + KIS REST API 공통 호출 + - rate limit 제어 (초당 20건) + - 토큰 자동 첨부 + """ + token = await self.get_access_token() + url = f"{self.base_url}{path}" + headers = { + "content-type" : "application/json; charset=utf-8", + "authorization" : f"Bearer {token}", + "appkey" : self.app_key, + "appsecret" : self.app_secret, + "tr_id" : tr_id, + "custtype" : "P", # 개인 + } + + async with self._semaphore: + # 초당 20건 rate limit + now = time.monotonic() + self._req_times = [t for t in self._req_times if now - t < 1.0] + if len(self._req_times) >= 20: + wait = 1.0 - (now - self._req_times[0]) + if wait > 0: + await asyncio.sleep(wait) + self._req_times.append(time.monotonic()) + + async with aiohttp.ClientSession() as session: + if method == "GET": + async with session.get(url, headers=headers, params=params) as r: + data = await r.json() + else: + async with session.post(url, headers=headers, json=body) as r: + data = await r.json() + + # 응답 코드 체크 + rt_cd = data.get("rt_cd", "") + if rt_cd != "0": + msg = data.get("msg1", "알 수 없는 오류") + logger.error(f"KIS API 오류 [{tr_id}]: {rt_cd} - {msg}") + raise RuntimeError(f"KIS API 오류: {msg}") + + return data + + # ───────────────────────────────────────── + # 시세 조회 + # ───────────────────────────────────────── + + async def get_price(self, ticker: str) -> Dict: + """주식 현재가 조회 (FHKST01010100)""" + data = await self._request( + method = "GET", + path = "/uapi/domestic-stock/v1/quotations/inquire-price", + tr_id = "FHKST01010100", + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD" : ticker, + } + ) + o = data["output"] + return { + "ticker" : ticker, + "current" : int(o["stck_prpr"]), # 현재가 + "open" : int(o["stck_oprc"]), # 시가 + "high" : int(o["stck_hgpr"]), # 고가 + "low" : int(o["stck_lwpr"]), # 저가 + "prev_close" : int(o["stck_sdpr"]), # 전일 종가 + "volume" : int(o["acml_vol"]), # 누적 거래량 + "change_pct" : float(o["prdy_ctrt"]), # 등락률 + "market_cap" : int(o.get("hts_avls", 0)) * 100_000_000, # 시가총액 (억→원) + } + + async def get_ohlcv_daily(self, ticker: str, start: str, end: str) -> list: + """ + 주식 기간별 시세 (일봉) - 백테스트/AI 분석용 + start, end: 'YYYYMMDD' + """ + data = await self._request( + method = "GET", + path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice", + tr_id = "FHKST03010100", + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD" : ticker, + "FID_INPUT_DATE_1" : start, + "FID_INPUT_DATE_2" : end, + "FID_PERIOD_DIV_CODE" : "D", # 일봉 + "FID_ORG_ADJ_PRC" : "0", + } + ) + result = [] + for row in data.get("output2", []): + result.append({ + "date" : row["stck_bsop_date"], + "open" : int(row["stck_oprc"]), + "high" : int(row["stck_hgpr"]), + "low" : int(row["stck_lwpr"]), + "close" : int(row["stck_clpr"]), + "volume": int(row["acml_vol"]), + }) + return result + + # ───────────────────────────────────────── + # 주문 + # ───────────────────────────────────────── + + async def order_buy( + self, + ticker : str, + qty : int, + price : int = 0, # 0 = 시장가 + order_type: str = "01", # 01=시장가, 00=지정가 + ) -> Dict: + """주식 매수 주문""" + dry_run = os.getenv("DRY_RUN", "true").lower() == "true" + + if dry_run: + logger.info(f"[DRY_RUN] 매수 {ticker} {qty}주 @ {price or '시장가'}") + return {"dry_run": True, "ticker": ticker, "qty": qty} + + # 모의/실거래 TR 구분 + tr_id = "VTTC0802U" if self.is_mock else "TTTC0802U" + + data = await self._request( + method = "POST", + path = "/uapi/domestic-stock/v1/trading/order-cash", + tr_id = tr_id, + body = { + "CANO" : self.acct_prefix, + "ACNT_PRDT_CD": self.acct_suffix, + "PDNO" : ticker, + "ORD_DVSN" : order_type, + "ORD_QTY" : str(qty), + "ORD_UNPR" : str(price), + } + ) + logger.info(f"매수 주문 완료: {ticker} {qty}주") + return data + + async def order_sell( + self, + ticker : str, + qty : int, + price : int = 0, + order_type: str = "01", + ) -> Dict: + """주식 매도 주문""" + dry_run = os.getenv("DRY_RUN", "true").lower() == "true" + + if dry_run: + logger.info(f"[DRY_RUN] 매도 {ticker} {qty}주 @ {price or '시장가'}") + return {"dry_run": True, "ticker": ticker, "qty": qty} + + tr_id = "VTTC0801U" if self.is_mock else "TTTC0801U" + + data = await self._request( + method = "POST", + path = "/uapi/domestic-stock/v1/trading/order-cash", + tr_id = tr_id, + body = { + "CANO" : self.acct_prefix, + "ACNT_PRDT_CD": self.acct_suffix, + "PDNO" : ticker, + "ORD_DVSN" : order_type, + "ORD_QTY" : str(qty), + "ORD_UNPR" : str(price), + } + ) + logger.info(f"매도 주문 완료: {ticker} {qty}주") + return data + + # ───────────────────────────────────────── + # 잔고 조회 + # ───────────────────────────────────────── + + async def get_balance(self) -> Dict: + """주식 잔고 조회 (보유 종목 + 예수금)""" + tr_id = "VTTC8001R" if self.is_mock else "TTTC8001R" + + data = await self._request( + method = "GET", + path = "/uapi/domestic-stock/v1/trading/inquire-balance", + tr_id = tr_id, + params = { + "CANO" : self.acct_prefix, + "ACNT_PRDT_CD" : self.acct_suffix, + "AFHR_FLPR_YN" : "N", + "OFL_YN" : "", + "INQR_DVSN" : "02", + "UNPR_DVSN" : "01", + "FUND_STTL_ICLD_YN" : "N", + "FNCG_AMT_AUTO_RDPT_YN": "N", + "PRCS_DVSN" : "01", + "CTX_AREA_FK100" : "", + "CTX_AREA_NK100" : "", + } + ) + + holdings = [] + for item in data.get("output1", []): + qty = int(item.get("hldg_qty", "0")) + if qty > 0: + holdings.append({ + "ticker" : item["pdno"], + "name" : item["prdt_name"], + "qty" : qty, + "avg_price" : int(item["pchs_avg_pric"].replace(".", "")), + "current" : int(item["prpr"]), + "pnl_pct" : float(item["evlu_pfls_rt"]), + }) + + cash = int(data["output2"][0].get("dnca_tot_amt", "0")) if data.get("output2") else 0 + + return { + "holdings" : holdings, + "cash" : cash, + "total_cnt": len(holdings), + } + + # ───────────────────────────────────────── + # AI 판단용 수급 데이터 + # ───────────────────────────────────────── + + async def get_volume_rank(self, top_n: int = 30) -> list: + """거래량 순위 상위 종목 (AI 판단용)""" + data = await self._request( + method = "GET", + path = "/uapi/domestic-stock/v1/quotations/volume-rank", + tr_id = "FHPST01710000", + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_COND_SCR_DIV_CODE" : "20171", + "FID_INPUT_ISCD" : "0000", + "FID_DIV_CLS_CODE" : "0", + "FID_BLNG_CLS_CODE" : "0", + "FID_TRGT_CLS_CODE" : "111111111", + "FID_TRGT_EXLS_CLS_CODE": "000000", + "FID_INPUT_PRICE_1" : "", + "FID_INPUT_PRICE_2" : "", + "FID_VOL_CNT" : "", + "FID_INPUT_DATE_1" : "", + } + ) + result = [] + for i, row in enumerate(data.get("output", [])[:top_n]): + result.append({ + "rank" : i + 1, + "ticker" : row["mksc_shrn_iscd"], + "name" : row["hts_kor_isnm"], + "volume" : int(row["acml_vol"]), + "change_pct": float(row["prdy_ctrt"]), + }) + return result + + async def get_foreign_institution_rank(self, top_n: int = 30) -> Dict: + """외국인/기관 순매수 상위 (AI 판단용)""" + data = await self._request( + method = "GET", + path = "/uapi/domestic-stock/v1/quotations/inquire-investor", + tr_id = "FHKST04430000", + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD" : "0000", + "FID_INPUT_DATE_1" : "", + "FID_INPUT_DATE_2" : "", + "FID_PERIOD_DIV_CODE" : "D", + } + ) + foreign = [] + institution = [] + for row in data.get("output", [])[:top_n]: + entry = { + "ticker": row.get("mksc_shrn_iscd", ""), + "name" : row.get("hts_kor_isnm", ""), + "amount": int(row.get("frgn_ntby_qty", "0")), + } + foreign.append(entry) + entry2 = { + "ticker": row.get("mksc_shrn_iscd", ""), + "name" : row.get("hts_kor_isnm", ""), + "amount": int(row.get("orgn_ntby_qty", "0")), + } + institution.append(entry2) + + return { + "foreign" : sorted(foreign, key=lambda x: x["amount"], reverse=True)[:top_n], + "institution": sorted(institution, key=lambda x: x["amount"], reverse=True)[:top_n], + } + + async def get_sector_trend(self) -> list: + """업종별 등락률 (AI 판단용)""" + data = await self._request( + method = "GET", + path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice", + tr_id = "FHKST03010100", + params = { + "FID_COND_MRKT_DIV_CODE": "U", # 업종 + "FID_INPUT_ISCD" : "0001", + "FID_INPUT_DATE_1" : "", + "FID_INPUT_DATE_2" : "", + "FID_PERIOD_DIV_CODE" : "D", + "FID_ORG_ADJ_PRC" : "0", + } + ) + result = [] + for row in data.get("output1", []): + result.append({ + "sector" : row.get("hts_kor_isnm", ""), + "change_pct": float(row.get("prdy_ctrt", "0")), + }) + return result + + +# ───────────────────────────────────────── +# WebSocket 클라이언트 (실시간 시세) +# ───────────────────────────────────────── + +class KISWebSocket: + """ + KIS 실시간 시세 WebSocket + - 체결가 (H0STCNT0) + - 호가 (H0STASP0) + - VI (H0STVI0) + """ + + WS_URL_REAL = "ws://ops.koreainvestment.com:21000" + WS_URL_MOCK = "ws://ops.koreainvestment.com:31000" + + def __init__(self, kis_client: KISClient): + self.kis = kis_client + self.ws_url = self.WS_URL_MOCK if kis_client.is_mock else self.WS_URL_REAL + self._ws = None + self._handlers : Dict[str, Callable] = {} # ticker → callback + self._vi_handler: Optional[Callable] = None + self._running = False + + def on_price(self, ticker: str, handler: Callable): + """실시간 체결가 핸들러 등록""" + self._handlers[ticker] = handler + + def on_vi(self, handler: Callable): + """VI 발동 핸들러 등록""" + self._vi_handler = handler + + async def subscribe(self, tickers: list): + """종목 구독 시작""" + token = await self.kis.get_access_token() + + # 접속키 발급 + async with aiohttp.ClientSession() as session: + resp = await session.post( + f"{self.kis.base_url}/oauth2/Approval", + json={ + "grant_type": "client_credentials", + "appkey" : self.kis.app_key, + "secretkey" : self.kis.app_secret, + } + ) + key_data = await resp.json() + + approval_key = key_data.get("approval_key", "") + + async with aiohttp.ClientSession() as session: + async with session.ws_connect(self.ws_url) as ws: + self._ws = ws + self._running = True + logger.info(f"WebSocket 연결 완료: {len(tickers)}종목 구독 시작") + + # 종목별 구독 등록 + for ticker in tickers: + for tr_id in ["H0STCNT0", "H0STVI0"]: + await ws.send_json({ + "header": { + "approval_key": approval_key, + "custtype" : "P", + "tr_type" : "1", # 등록 + "content-type": "utf-8", + }, + "body": { + "input": { + "tr_id" : tr_id, + "tr_key" : ticker, + } + } + }) + + logger.info("구독 등록 완료") + + # 메시지 수신 루프 + async for msg in ws: + if not self._running: + break + if msg.type == aiohttp.WSMsgType.TEXT: + await self._handle_message(msg.data) + elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + logger.error("WebSocket 연결 끊김") + self._running = False + break + + async def _handle_message(self, raw: str): + """WebSocket 메시지 파싱""" + try: + # KIS WebSocket 메시지 포맷: 헤더|바디 + if raw.startswith("{"): + # JSON 형식 (시스템 메시지) + return + + parts = raw.split("|") + if len(parts) < 4: + return + + tr_id = parts[1] + data = parts[3].split("^") + + if tr_id == "H0STCNT0": + # 실시간 체결가 + ticker = data[0] + price = int(data[2]) + volume = int(data[9]) + handler = self._handlers.get(ticker) + if handler: + await handler(ticker, price, volume) + + elif tr_id == "H0STVI0": + # VI 발동/해제 + ticker = data[0] + vi_status = data[1] # 1=발동, 2=해제 + ref_price = int(data[5]) if len(data) > 5 else 0 + if self._vi_handler: + await self._vi_handler(ticker, vi_status, ref_price) + + except Exception as e: + logger.error(f"WebSocket 메시지 파싱 오류: {e}") + + async def close(self): + self._running = False + if self._ws: + await self._ws.close() + logger.info("WebSocket 연결 종료") diff --git a/app/execution/order_executor.py b/app/execution/order_executor.py new file mode 100644 index 0000000..afa87e2 --- /dev/null +++ b/app/execution/order_executor.py @@ -0,0 +1,99 @@ +""" +execution/order_executor.py +주문 실행 모듈 +DRY_RUN=true 시 실제 주문 전송 없음 +""" +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 + +logger = logging.getLogger(__name__) + + +class OrderExecutor: + def __init__(self, kis: KISClient): + 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: + """시장가 매수""" + try: + result = await self.kis.order_buy(ticker, qty) + price = result.get("entry_price", 0) + + # DB 저장 + fee = self._calc_fee(price, qty, True) + 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} + + except Exception as e: + logger.error(f"매수 실패 {ticker}: {e}") + return {"success": False, "error": str(e)} + + async def sell(self, ticker: str, name: str, + qty: int, reason: str = "") -> dict: + """시장가 매도""" + try: + result = await self.kis.order_sell(ticker, qty) + price = result.get("exit_price", 0) + + fee = self._calc_fee(price, qty, False) + self._update_trade_exit( + 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}]") + return {"success": True, "price": price, "qty": qty} + + except Exception as e: + logger.error(f"매도 실패 {ticker}: {e}") + return {"success": False, "error": str(e)} + + def _save_trade(self, ticker, name, entry_price, + qty, side, fee, ai_boosted=False): + with get_conn() as conn: + 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, + datetime.now().strftime("%H:%M:%S"), + entry_price, qty, side, fee, + 1 if ai_boosted else 0, + )) + + def _update_trade_exit(self, ticker, exit_price, + qty, reason, fee): + with get_conn() as conn: + conn.execute(""" + UPDATE trades + SET exit_time=?, exit_price=?, exit_reason=?, fee=fee+? + WHERE ticker=? AND exit_time IS NULL + ORDER BY id DESC LIMIT 1 + """, ( + datetime.now().strftime("%H:%M:%S"), + exit_price, reason, fee, ticker, + )) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..4d9d5e4 --- /dev/null +++ b/app/main.py @@ -0,0 +1,458 @@ +""" +main.py +단타 자동매매 시스템 메인 진입점 +기획서 v2.1 기준 + +실행: + python -m app.main (Docker 컨테이너) + python app/main.py (로컬 테스트) + +환경변수: + KIS_MOCK=true → 모의투자 모드 + DRY_RUN=true → 신호만 확인, 주문 전송 안 함 +""" + +import os +import sys +import asyncio +import logging +from datetime import datetime, time +from pathlib import Path + +# .env 로드 +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().strip('"').strip("'") + if k and v and k not in os.environ: + os.environ[k] = v + +load_env() + +# 로깅 설정 +logging.basicConfig( + level=getattr(logging, os.getenv("LOG_LEVEL", "INFO")), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler("logs/stockbot.log", encoding="utf-8"), + ] +) +logger = logging.getLogger(__name__) + +from app.execution.kis_client import KISClient +from app.execution.order_executor import OrderExecutor +from app.strategy.volatility_breakout import VolatilityBreakout +from app.risk.manager import RiskManager +from app.monitor.notifier import ( + notify_buy, notify_tp1, notify_tp2, notify_sl, + notify_force_exit, notify_risk, notify_daily_summary, + notify_error, notify_ai_result, notify_ai_blocked, + notify_ai_fallback, send +) +from app.db.models import init_db, get_conn +from app.config import ( + MAX_UNIVERSE, FORCE_EXIT, MAX_POSITIONS, + MAX_HOLD_MIN, KOSPI_MIN_CHG +) + + +class StockBot: + def __init__(self): + self.kis = KISClient() + self.executor = OrderExecutor(self.kis) + self.strategy = VolatilityBreakout() + self.positions = {} # ticker → {name, entry, qty, tp1_done, entry_time} + self.universe = [] # 감시 종목 리스트 + self.risk = None # RiskManager (잔고 확인 후 초기화) + self.running = False + + mode = "모의투자" if self.kis.is_mock else "실거래" + dry = " [DRY_RUN]" if os.getenv("DRY_RUN","true")=="true" else "" + logger.info(f"StockBot 시작 [{mode}]{dry}") + + # ───────────────────────────────────────── + # 초기화 + # ───────────────────────────────────────── + + async def initialize(self): + """시스템 초기화""" + init_db() + await self.kis.get_access_token() + + # 잔고 조회 → RiskManager 초기화 + balance = await self.kis.get_balance() + cash = balance["cash"] + self.risk = RiskManager(init_cash=cash) + logger.info(f"초기 예수금: {cash:,}원") + await send(f"[시작] 단타봇 가동 | 예수금: {cash:,}원 | " + f"{'모의투자' if self.kis.is_mock else '실거래'}") + + # ───────────────────────────────────────── + # 유니버스 갱신 (08:30) + # ───────────────────────────────────────── + + async def update_universe(self): + """종목 풀 갱신 + 전일 데이터 수집""" + logger.info("유니버스 갱신 시작") + try: + # 거래량 순위 상위 30종목 + rank = await self.kis.get_volume_rank(top_n=MAX_UNIVERSE) + tickers = [r["ticker"] for r in rank] + + # AI 블랙리스트 제거 + ctx = self.strategy.context + blacklist = ctx.get("blacklist_tickers", []) + tickers = [t for t in tickers if t not in blacklist] + + # boosted 종목 상단 배치 + boosted = ctx.get("boosted_tickers", []) + tickers = ( + [t for t in boosted if t in tickers] + + [t for t in tickers if t not in boosted] + )[:MAX_UNIVERSE] + + self.universe = tickers + logger.info(f"유니버스: {len(tickers)}종목") + + # 전일 OHLCV 수집 (목표가 계산용) + today = datetime.now().strftime("%Y%m%d") + for ticker in self.universe: + try: + ohlcv = await self.kis.get_ohlcv_daily( + ticker, + start=today, + end=today, + ) + if len(ohlcv) >= 2: + prev = ohlcv[-2] + self.strategy.set_prev_data( + ticker, + high = prev["high"], + low = prev["low"], + amount= prev.get("amount", + prev.get("volume",0) * prev.get("close",0)) + ) + except Exception as e: + logger.warning(f"전일 데이터 실패 {ticker}: {e}") + await asyncio.sleep(0.1) # rate limit + + except Exception as e: + logger.error(f"유니버스 갱신 실패: {e}") + + # ───────────────────────────────────────── + # 시가 수집 + 목표가 계산 (08:50) + # ───────────────────────────────────────── + + async def calc_targets(self): + """당일 시가 기반 목표가 계산""" + logger.info("목표가 계산 시작") + for ticker in self.universe: + try: + price_info = await self.kis.get_price(ticker) + self.strategy.set_today_open(ticker, price_info["open"]) + target = self.strategy.get_target(ticker) + if target > 0: + logger.debug(f"{ticker} 목표가: {target:,.0f}원") + await asyncio.sleep(0.05) + except Exception as e: + logger.warning(f"시가 수집 실패 {ticker}: {e}") + + # ───────────────────────────────────────── + # 메인 매매 루프 (09:00~14:50) + # ───────────────────────────────────────── + + async def trading_loop(self): + """1초 단위 메인 루프""" + logger.info("매매 루프 시작") + self.running = True + + while self.running: + now = datetime.now() + now_str = now.strftime("%H:%M") + + # 14:50 강제 청산 + if now_str >= FORCE_EXIT: + await self.force_exit_all() + self.running = False + break + + # 14:30 이후 신규 진입 중단 + if now_str > "14:30": + await asyncio.sleep(1) + continue + + # 09:00 이전 대기 + if now_str < "09:00": + await asyncio.sleep(1) + continue + + # 점심 (11:30~13:00) 신규 진입 중단 + if "11:30" <= now_str < "13:00": + await asyncio.sleep(1) + continue + + # 리스크 체크 + if not self.risk.can_trade(): + await asyncio.sleep(5) + continue + + # 보유 포지션 청산 체크 + await self.check_exits() + + # 신규 진입 체크 + if self.risk.can_add_position(len(self.positions)): + await self.check_entries() + + await asyncio.sleep(1) + + # ───────────────────────────────────────── + # 진입 체크 + # ───────────────────────────────────────── + + async def check_entries(self): + """유니버스 전체 진입 신호 확인""" + for ticker in self.universe: + if ticker in self.positions: + continue + if len(self.positions) >= MAX_POSITIONS: + break + + try: + price_info = await self.kis.get_price(ticker) + current = price_info["current"] + name = price_info.get("name", ticker) + + # 전략 신호 체크 + signal = self.strategy.check_entry( + ticker=ticker, + name=name, + current_price=current, + ) + + if not signal["signal"]: + continue + + # 포지션 사이즈 계산 + balance = await self.kis.get_balance() + cash = balance["cash"] + invest = self.risk.get_pos_size( + cash, signal.get("multiplier", 1.0) + ) + qty = max(1, int(invest // current)) + + # 매수 실행 + result = await self.executor.buy( + ticker=ticker, name=name, + qty=qty, reason=signal["reason"], + ai_boosted=signal.get("boosted", False), + ) + + if result["success"]: + entry_price = result["price"] or current + sl_price = entry_price * (1 - self.risk.get_sl_pct()) + tp1_price = entry_price * (1 + 0.02) + + self.positions[ticker] = { + "name" : name, + "entry" : entry_price, + "qty" : qty, + "tp1_done" : False, + "entry_time": datetime.now(), + "sl_price" : sl_price, + "boosted" : signal.get("boosted", False), + } + + await notify_buy( + ticker=ticker, name=name, + price=entry_price, + target=int(entry_price * 1.03), + stop=int(sl_price), + boosted=signal.get("boosted", False), + ) + + await asyncio.sleep(0.1) + + except Exception as e: + logger.error(f"진입 체크 오류 {ticker}: {e}") + + # ───────────────────────────────────────── + # 청산 체크 + # ───────────────────────────────────────── + + async def check_exits(self): + """보유 포지션 청산 신호 확인""" + for ticker, pos in list(self.positions.items()): + try: + price_info = await self.kis.get_price(ticker) + current = price_info["current"] + name = pos["name"] + + # 시간 청산: MAX_HOLD_MIN 초과 + hold_min = (datetime.now() - pos["entry_time"]).seconds / 60 + if hold_min >= MAX_HOLD_MIN: + await self._do_exit(ticker, pos, current, qty=pos["qty"], reason="TIME") + continue + + # 전략 청산 신호 + signal = self.strategy.check_exit( + ticker=ticker, + entry_price=pos["entry"], + current_price=current, + qty=pos["qty"], + tp1_done=pos["tp1_done"], + sl_pct=self.risk.get_sl_pct(), + ) + + if signal["signal"]: + await self._do_exit( + ticker, pos, current, + qty=signal["qty"], + reason=signal["reason"], + ) + + await asyncio.sleep(0.05) + + except Exception as e: + logger.error(f"청산 체크 오류 {ticker}: {e}") + + async def _do_exit(self, ticker: str, pos: dict, + current: float, qty: int, reason: str): + """실제 청산 실행""" + name = pos["name"] + result = await self.executor.sell(ticker, name, qty, reason) + + if not result["success"]: + return + + exit_price = result["price"] or current + pnl = (exit_price - pos["entry"]) * qty + pnl_pct = (exit_price - pos["entry"]) / pos["entry"] * 100 + + self.risk.record_trade(pnl) + + if reason == "TP1": + pos["tp1_done"] = True + pos["qty"] -= qty + if pos["qty"] <= 0: + del self.positions[ticker] + await notify_tp1(ticker, name, pnl_pct) + + elif reason in ("TP2", "SL", "TIME", "FORCE"): + del self.positions[ticker] + if reason == "TP2": + await notify_tp2(ticker, name, pnl_pct) + elif reason == "SL": + await notify_sl(ticker, name, pnl_pct) + + # L2/L3 체크 후 디스코드 경고 + if not self.risk.can_trade(): + await notify_risk( + self.risk.stop_reason.split(":")[0], + self.risk.stop_reason + ) + + # ───────────────────────────────────────── + # 강제 청산 (14:50) + # ───────────────────────────────────────── + + async def force_exit_all(self): + """14:50 전량 강제 청산""" + logger.info("14:50 강제 청산 시작") + for ticker, pos in list(self.positions.items()): + try: + price_info = await self.kis.get_price(ticker) + current = price_info["current"] + await self._do_exit( + ticker, pos, current, + qty=pos["qty"], reason="FORCE" + ) + except Exception as e: + logger.error(f"강제 청산 실패 {ticker}: {e}") + await notify_force_exit() + logger.info("강제 청산 완료") + + # ───────────────────────────────────────── + # 일일 결산 (15:10) + # ───────────────────────────────────────── + + async def daily_summary(self): + """당일 결산 로그 및 디스코드 알림""" + today = datetime.now().strftime("%Y-%m-%d") + with get_conn() as conn: + rows = conn.execute(""" + SELECT pnl FROM trades + WHERE date=? AND exit_time IS NOT NULL + """, (today,)).fetchall() + + pnls = [r[0] for r in rows if r[0] is not None] + total = len(pnls) + wins = sum(1 for p in pnls if p > 0) + losses = total - wins + net = sum(pnls) + + await notify_daily_summary(total, wins, losses, net) + self.risk.reset_daily() + logger.info(f"결산: {total}회 / 승{wins} 패{losses} / {net:+,.0f}원") + + +# ───────────────────────────────────────── +# 스케줄러 +# ───────────────────────────────────────── + +async def run(): + bot = StockBot() + await bot.initialize() + + while True: + now = datetime.now().strftime("%H:%M") + + # 07:30 AI 판단 (컨텍스트 로드) + if now == "08:05": + bot.strategy.load_ai_context() + ctx = bot.strategy.context + await notify_ai_result( + ctx["market_sentiment"], + ctx["sentiment_score"], + ctx.get("hot_sectors", []), + ctx.get("avoid_sectors", []), + ctx.get("reason", ""), + ) + bot.risk.set_risk_level(ctx.get("risk_level", "보통")) + + # 08:30 유니버스 갱신 + elif now == "08:30": + await bot.update_universe() + + # 08:50 목표가 계산 + elif now == "08:50": + await bot.calc_targets() + + # 09:00 매매 루프 시작 + elif now == "09:00": + await bot.trading_loop() + + # 15:10 결산 + elif now == "15:10": + await bot.daily_summary() + + await asyncio.sleep(30) + + +if __name__ == "__main__": + os.makedirs("logs", exist_ok=True) + os.makedirs("data", exist_ok=True) + logger.info("=" * 50) + logger.info("단타 자동매매 시스템 시작") + logger.info(f"모드: {'모의투자' if os.getenv('KIS_MOCK','true')=='true' else '실거래'}") + logger.info(f"DRY_RUN: {os.getenv('DRY_RUN','true')}") + logger.info("=" * 50) + asyncio.run(run()) diff --git a/app/monitor/__init__.py b/app/monitor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/monitor/dashboard.py b/app/monitor/dashboard.py new file mode 100644 index 0000000..7ab6917 --- /dev/null +++ b/app/monitor/dashboard.py @@ -0,0 +1,86 @@ +""" +monitor/dashboard.py - Streamlit 대시보드 +실행: streamlit run monitor/dashboard.py --server.port 8501 +""" +import streamlit as st +import pandas as pd +import sqlite3 +import json +import os +from datetime import datetime, timedelta + +DB_PATH = os.getenv("DB_PATH", "data/stockbot.db") +AI_CTX = os.getenv("AI_CONTEXT_PATH", "data/daily_context.json") + +st.set_page_config(page_title="단타봇 대시보드", layout="wide") +st.title("📈 단타 자동매매 대시보드") +st.caption(f"마지막 갱신: {datetime.now().strftime('%H:%M:%S')}") + +# ── AI 판단 현황 ── +st.subheader("🤖 오늘의 AI 판단") +try: + with open(AI_CTX, encoding="utf-8") as f: + ctx = json.load(f) + col1, col2, col3, col4 = st.columns(4) + col1.metric("시장 분위기", ctx.get("market_sentiment", "-")) + col2.metric("감성 점수", f"{ctx.get('sentiment_score', 0)}점") + col3.metric("리스크 레벨", ctx.get("risk_level", "-")) + col4.metric("거래 허용", "✅" if ctx.get("trade_allowed") else "❌") + st.info(f"💬 {ctx.get('reason', '')}") + col_l, col_r = st.columns(2) + col_l.write(f"**주목 섹터:** {', '.join(ctx.get('hot_sectors', []))}") + col_r.write(f"**회피 섹터:** {', '.join(ctx.get('avoid_sectors', []))}") +except: + st.warning("AI 판단 데이터 없음 (08:00 이후 생성)") + +st.divider() + +# ── 오늘 매매 현황 ── +st.subheader("📊 오늘 매매 현황") +try: + conn = sqlite3.connect(DB_PATH) + today = datetime.now().strftime("%Y-%m-%d") + df = pd.read_sql( + "SELECT * FROM trades WHERE date=?", conn, params=(today,) + ) + conn.close() + + if df.empty: + st.info("오늘 매매 없음") + else: + total = len(df) + wins = (df["pnl"] > 0).sum() + net = df["pnl"].sum() + win_rt = wins / total * 100 if total else 0 + + c1, c2, c3, c4 = st.columns(4) + c1.metric("총 매매", f"{total}회") + c2.metric("승률", f"{win_rt:.0f}%") + c3.metric("순손익", f"{net:+,.0f}원") + c4.metric("승/패", f"{wins}/{total-wins}") + st.dataframe(df[["entry_time","ticker","name","entry_price", + "exit_price","pnl","exit_reason"]].fillna("-"), + use_container_width=True) +except Exception as e: + st.error(f"DB 연결 실패: {e}") + +st.divider() + +# ── 주간 손익 ── +st.subheader("📅 최근 7일 손익") +try: + conn = sqlite3.connect(DB_PATH) + week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + df_w = pd.read_sql( + "SELECT date, SUM(pnl) as net_pnl, COUNT(*) as trades " + "FROM trades WHERE date >= ? GROUP BY date ORDER BY date", + conn, params=(week_ago,) + ) + conn.close() + if not df_w.empty: + st.bar_chart(df_w.set_index("date")["net_pnl"]) + st.dataframe(df_w, use_container_width=True) +except: + st.info("주간 데이터 없음") + +st.button("🔄 새로고침", on_click=st.rerun) diff --git a/app/monitor/notifier.py b/app/monitor/notifier.py new file mode 100644 index 0000000..e89e655 --- /dev/null +++ b/app/monitor/notifier.py @@ -0,0 +1,79 @@ +""" +monitor/notifier.py +디스코드 Webhook 알림 (단방향) +aiohttp만 사용 - 별도 라이브러리 없음 +""" +import os +import asyncio +import aiohttp +import logging + +logger = logging.getLogger(__name__) + +WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "") + +async def send(message: str) -> None: + """디스코드 Webhook 메시지 전송""" + if not WEBHOOK_URL: + logger.warning(f"[Discord 미설정] {message}") + return + try: + async with aiohttp.ClientSession() as session: + await session.post( + WEBHOOK_URL, + json={"content": message}, + timeout=aiohttp.ClientTimeout(total=5) + ) + except Exception as e: + logger.error(f"Discord 알림 실패: {e}") + +# ── 이벤트별 메시지 ── + +async def notify_ai_result(sentiment: str, score: int, + hot: list, avoid: list, reason: str): + hot_str = ",".join(hot) if hot else "없음" + avoid_str = ",".join(avoid) if avoid else "없음" + await send( + f"[AI분석] 시장: {sentiment}({score}점) | " + f"주목: {hot_str} | 회피: {avoid_str}\n💬 {reason}" + ) + +async def notify_ai_blocked(ticker: str, name: str, reason: str): + await send(f"[AI차단] {name}({ticker}) - {reason}") + +async def notify_buy(ticker: str, name: str, price: int, + target: int, stop: int, boosted: bool = False): + star = "★ " if boosted else "" + await send( + f"[매수{star}] {name}({ticker}) {price:,}원 | " + f"목표 {target:,} | 손절 {stop:,}" + ) + +async def notify_tp1(ticker: str, name: str, pct: float): + await send(f"[익절1] {name}({ticker}) +{pct:.1f}% / 잔여 50%") + +async def notify_tp2(ticker: str, name: str, pct: float): + await send(f"[익절2] {name}({ticker}) +{pct:.1f}% / 전량 청산") + +async def notify_sl(ticker: str, name: str, pct: float): + await send(f"[손절] {name}({ticker}) {pct:.1f}% / 즉시 청산") + +async def notify_force_exit(): + await send("[14:50 강제청산] 전 포지션 청산 완료") + +async def notify_risk(level: str, message: str): + await send(f"[경고-{level}] {message}") + +async def notify_daily_summary(trades: int, wins: int, + losses: int, net_pnl: float): + win_rate = wins / trades * 100 if trades else 0 + await send( + f"[결산] 매매 {trades}회 / 승 {wins} 패 {losses} " + f"({win_rate:.0f}%) / 순손익 {net_pnl:+,.0f}원" + ) + +async def notify_error(message: str): + await send(f"[긴급] {message}") + +async def notify_ai_fallback(): + await send("[경고] AI 판단 실패 → 기본값 적용 (비중 80%)") diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..90ee012 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,8 @@ +aiohttp==3.9.5 +python-dotenv==1.0.1 +APScheduler==3.10.4 +beautifulsoup4==4.12.3 +redis==5.0.7 +streamlit==1.36.0 +pandas==2.2.2 +numpy==1.26.4 diff --git a/app/risk/__init__.py b/app/risk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/risk/manager.py b/app/risk/manager.py new file mode 100644 index 0000000..c3a5516 --- /dev/null +++ b/app/risk/manager.py @@ -0,0 +1,107 @@ +""" +risk/manager.py +리스크 매니저 L1~L5 +기획서 v2.1 기준 +""" +import logging +from app.config import ( + SL_PCT, DAILY_SL_PCT, CONSEC_LOSS, + AI_RISK_SL_MAP, POS_SIZE_PCT, MAX_POSITIONS +) + +logger = logging.getLogger(__name__) + +class RiskManager: + def __init__(self, init_cash: float): + self.init_cash = init_cash + self.daily_pnl = 0.0 + self.weekly_pnl = 0.0 + self.monthly_pnl = 0.0 + self.consec_loss = 0 + self.trading_stopped = False + self.stop_reason = "" + self.risk_level = "보통" + + def set_risk_level(self, level: str): + """AI 판단 결과로 risk_level 설정""" + self.risk_level = level + + def get_sl_pct(self) -> float: + """현재 risk_level에 따른 손절 비율 반환""" + return AI_RISK_SL_MAP.get(self.risk_level, SL_PCT) + + def get_pos_size(self, cash: float, multiplier: float = 1.0) -> float: + """포지션 사이즈 계산 (AI multiplier 반영)""" + return cash * POS_SIZE_PCT * multiplier + + # ── 손실 기록 ── + + def record_trade(self, pnl: float): + """매매 결과 기록 및 손실 한도 체크""" + self.daily_pnl += pnl + self.weekly_pnl += pnl + self.monthly_pnl += pnl + + if pnl < 0: + self.consec_loss += 1 + else: + self.consec_loss = 0 + + self._check_limits() + + def _check_limits(self): + """L1~L5 손실 한도 체크""" + # L2: 일일 누적 손실 -3% + if self.daily_pnl / self.init_cash < -DAILY_SL_PCT: + 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% + if self.weekly_pnl / self.init_cash < -0.07: + self._stop("L4", f"주간 손실 {self.weekly_pnl/self.init_cash*100:.1f}%") + + # L5: 월간 누적 -15% + if self.monthly_pnl / self.init_cash < -0.15: + self._stop("L5", f"월간 손실 {self.monthly_pnl/self.init_cash*100:.1f}%") + + def _stop(self, level: str, reason: str): + self.trading_stopped = True + self.stop_reason = f"{level}: {reason}" + logger.warning(f"매매 중단 - {self.stop_reason}") + + # ── 상태 조회 ── + + def can_trade(self) -> bool: + return not self.trading_stopped + + def can_add_position(self, current_positions: int) -> bool: + return (not self.trading_stopped + and current_positions < MAX_POSITIONS) + + def reset_daily(self): + """매일 장 시작 전 일일 손익 초기화""" + self.daily_pnl = 0.0 + self.consec_loss = 0 + self.trading_stopped = False + self.stop_reason = "" + + def reset_weekly(self): + self.weekly_pnl = 0.0 + + def reset_monthly(self): + self.monthly_pnl = 0.0 + + def status(self) -> dict: + return { + "trading_stopped": self.trading_stopped, + "stop_reason" : self.stop_reason, + "daily_pnl" : self.daily_pnl, + "weekly_pnl" : self.weekly_pnl, + "monthly_pnl" : self.monthly_pnl, + "consec_loss" : self.consec_loss, + "risk_level" : self.risk_level, + "sl_pct" : self.get_sl_pct(), + } diff --git a/app/strategy/__init__.py b/app/strategy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/strategy/base.py b/app/strategy/base.py new file mode 100644 index 0000000..a63ae7c --- /dev/null +++ b/app/strategy/base.py @@ -0,0 +1,19 @@ +""" +strategy/base.py - 전략 추상 클래스 +""" +from abc import ABC, abstractmethod + + +class BaseStrategy(ABC): + @abstractmethod + def check_entry(self, ticker: str, name: str, + current_price: float, **kwargs) -> dict: + """진입 신호 체크. signal, reason, boosted, multiplier 반환""" + pass + + @abstractmethod + def check_exit(self, ticker: str, entry_price: float, + current_price: float, qty: int, + tp1_done: bool, sl_pct: float) -> dict: + """청산 신호 체크. signal, reason, qty 반환""" + pass diff --git a/app/strategy/volatility_breakout.py b/app/strategy/volatility_breakout.py new file mode 100644 index 0000000..a30c1a1 --- /dev/null +++ b/app/strategy/volatility_breakout.py @@ -0,0 +1,217 @@ +""" +strategy/volatility_breakout.py +변동성 돌파 전략 + AI 필터 +기획서 v2.1 기준 +""" +import json +import logging +import os +from datetime import datetime +from app.config import ( + STRATEGY_K, TP1_PCT, TP2_PCT, + ENTRY_START, ENTRY_END, + AI_CONTEXT_PATH, AI_MIN_SCORE, + AI_BOOST_MULTI, MIN_TRADE_AMOUNT, + KOSPI_MIN_CHG +) + +logger = logging.getLogger(__name__) + +# AI fallback 기본값 +DEFAULT_CONTEXT = { + "trade_allowed" : True, + "market_sentiment" : "중립", + "sentiment_score" : 50, + "risk_level" : "보통", + "hot_sectors" : [], + "avoid_sectors" : [], + "boosted_tickers" : [], + "blacklist_tickers" : [], + "position_size_multiplier": 0.8, + "reason" : "AI 판단 실패 - 기본값 적용", +} + + +class VolatilityBreakout: + """ + 변동성 돌파 전략 + 목표가 = 당일 시가 + (전일 고가 - 전일 저가) × K + 현재가 >= 목표가 → 진입 신호 + """ + + def __init__(self): + self.context = DEFAULT_CONTEXT.copy() + self.prev_data = {} # ticker → {high, low, amount} 전일 데이터 + self.today_open = {} # ticker → 당일 시가 + self.targets = {} # ticker → 목표가 + + # ── AI 컨텍스트 로드 ── + + def load_ai_context(self) -> dict: + """daily_context.json 로드, 실패 시 fallback""" + try: + if not os.path.exists(AI_CONTEXT_PATH): + logger.warning("daily_context.json 없음 → fallback") + return DEFAULT_CONTEXT.copy() + + with open(AI_CONTEXT_PATH, encoding="utf-8") as f: + ctx = json.load(f) + + # 날짜 체크 (오늘 날짜 파일인지 확인) + today = datetime.now().strftime("%Y-%m-%d") + if ctx.get("date") != today: + logger.warning(f"AI 컨텍스트 날짜 불일치: {ctx.get('date')} → fallback") + return DEFAULT_CONTEXT.copy() + + # 최소 감성 점수 체크 + if ctx.get("sentiment_score", 50) < AI_MIN_SCORE: + ctx["trade_allowed"] = False + logger.info(f"감성점수 {ctx['sentiment_score']} < {AI_MIN_SCORE} → 거래 중단") + + self.context = ctx + logger.info( + f"AI 컨텍스트 로드: {ctx['market_sentiment']}({ctx['sentiment_score']}점) " + f"/ {ctx['reason']}" + ) + return ctx + + except Exception as e: + logger.error(f"AI 컨텍스트 로드 실패: {e} → fallback") + return DEFAULT_CONTEXT.copy() + + # ── 목표가 계산 ── + + def set_prev_data(self, ticker: str, high: float, + low: float, amount: float): + """전일 고가/저가/거래대금 저장""" + self.prev_data[ticker] = { + "high" : high, + "low" : low, + "amount": amount, + } + + def set_today_open(self, ticker: str, open_price: float): + """당일 시가로 목표가 계산""" + prev = self.prev_data.get(ticker) + if not prev or prev["amount"] < MIN_TRADE_AMOUNT: + return + + prev_range = prev["high"] - prev["low"] + if prev_range <= 0: + return + + target = open_price + prev_range * STRATEGY_K + self.today_open[ticker] = open_price + self.targets[ticker] = target + + def get_target(self, ticker: str) -> float: + return self.targets.get(ticker, 0.0) + + # ── 진입 신호 판단 ── + + def check_entry(self, ticker: str, name: str, + current_price: float, sector: str = "") -> dict: + """ + 진입 신호 체크 + 반환: {"signal": bool, "reason": str, "boosted": bool, "multiplier": float} + """ + result = {"signal": False, "reason": "", "boosted": False, "multiplier": 1.0} + + # 시간 체크 + now = datetime.now().strftime("%H:%M") + if not (ENTRY_START <= now <= ENTRY_END): + result["reason"] = f"진입 시간 외 ({now})" + return result + + # 목표가 확인 + target = self.targets.get(ticker, 0) + if target <= 0: + result["reason"] = "목표가 없음" + return result + + # 기술적 조건: 현재가 >= 목표가 + if current_price < target: + result["reason"] = f"목표가 미달 ({current_price:,} < {target:,.0f})" + return result + + # ── AI 필터 ── + ctx = self.context + + # trade_allowed 체크 + if not ctx.get("trade_allowed", True): + result["reason"] = f"AI 거래 중단: {ctx.get('reason', '')}" + return result + + # 블랙리스트 체크 + if ticker in ctx.get("blacklist_tickers", []): + result["reason"] = "AI 블랙리스트" + return result + + # 섹터 회피 체크 + avoid = ctx.get("avoid_sectors", []) + if sector and any(s in sector for s in avoid): + result["reason"] = f"AI 섹터 회피 ({sector})" + return result + + # 부스트 체크 + boosted = ticker in ctx.get("boosted_tickers", []) + multiplier = ctx.get("position_size_multiplier", 1.0) + if boosted: + multiplier = min(multiplier * AI_BOOST_MULTI, 1.5) + + result.update({ + "signal" : True, + "reason" : f"목표가 돌파 ({current_price:,} >= {target:,.0f})", + "boosted" : boosted, + "multiplier": multiplier, + "target" : target, + }) + return result + + # ── 청산 신호 판단 ── + + def check_exit(self, ticker: str, entry_price: float, + current_price: float, qty: int, + tp1_done: bool, sl_pct: float) -> dict: + """ + 청산 신호 체크 + 우선순위: 손절 > 1차 익절 > 2차 익절 + 반환: {"signal": bool, "reason": str, "qty": int} + """ + result = {"signal": False, "reason": "", "qty": 0} + + sl_price = entry_price * (1 - sl_pct) + tp1_price = entry_price * (1 + TP1_PCT) + tp2_price = entry_price * (1 + TP2_PCT) + + # 손절 (최우선) + if current_price <= sl_price: + result.update({ + "signal": True, + "reason": "SL", + "qty" : qty, + "price" : sl_price, + }) + return result + + # 2차 익절 + if current_price >= tp2_price: + result.update({ + "signal": True, + "reason": "TP2", + "qty" : qty - (qty // 2 if not tp1_done else 0), + "price" : tp2_price, + }) + return result + + # 1차 익절 (아직 안 했으면) + if not tp1_done and current_price >= tp1_price: + result.update({ + "signal": True, + "reason": "TP1", + "qty" : qty // 2, + "price" : tp1_price, + }) + return result + + return result diff --git a/claude_evening/Dockerfile b/claude_evening/Dockerfile new file mode 100644 index 0000000..50fe1c4 --- /dev/null +++ b/claude_evening/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-slim +RUN npm install -g @anthropic-ai/claude-code +RUN apt-get update && apt-get install -y sqlite3 && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY run.sh . +RUN chmod +x run.sh +CMD ["bash", "run.sh"] diff --git a/claude_evening/run.sh b/claude_evening/run.sh new file mode 100644 index 0000000..e742d0c --- /dev/null +++ b/claude_evening/run.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# 장 후 피드백 - 매일 15:30 자동 실행 +# NAS Container Manager 스케줄: 평일 15:30 + +TODAY=$(date '+%Y-%m-%d') + +claude --bare -p " +오늘($TODAY) 매매 결과를 분석하고 개선해. + +## 데이터 수집 +1. sqlite3 data/stockbot.db 로 오늘 매매 내역 조회: + SELECT * FROM trades WHERE date='$TODAY'; + SELECT * FROM daily_summary WHERE date='$TODAY'; +2. logs/trades.log 에서 오늘 로그 확인 +3. reports/daily/ 에서 최근 30일 리포트 읽어서 패턴 파악 + +## 분석 항목 +- 오늘 총 매매 횟수, 승률, 순손익 +- 청산 이유 분포 (TP1/TP2/SL/FORCE/TIME) +- 이상 패턴 감지: + * 연속 손절 3회 이상 여부 + * 14:50 강제청산 비율 30% 초과 여부 + * 슬리피지 과다 여부 +- AI 판단 정확도 (boosted 종목 성과) + +## 코드 수정 (문제 명확할 때만) +- app/config.py 의 파라미터만 수정 가능 +- 반드시 수정 이유를 주석으로 추가 +- FORCE_EXIT=14:50 절대 변경 불가 +- 수정 없으면 건드리지 말 것 + +## 실전 전환 조건 체크 +sqlite3로 최근 30거래일 데이터 집계 후 아래 5가지 모두 충족 시: +1. 누적 운영 30거래일 이상 +2. 최근 30일 승률 > 48% +3. 최근 30일 MDD < -10% +4. 최근 30일 샤프지수 > 1.0 +5. L3 발동 월 2회 이하 + +→ 충족 시 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로 변경하세요. +" \ +--allowedTools "Read,Write,Bash" \ +--dangerously-skip-permissions \ +--max-turns 20 diff --git a/claude_morning/Dockerfile b/claude_morning/Dockerfile new file mode 100644 index 0000000..5f24897 --- /dev/null +++ b/claude_morning/Dockerfile @@ -0,0 +1,6 @@ +FROM node:20-slim +RUN npm install -g @anthropic-ai/claude-code +WORKDIR /app +COPY run.sh . +RUN chmod +x run.sh +CMD ["bash", "run.sh"] diff --git a/claude_morning/run.sh b/claude_morning/run.sh new file mode 100644 index 0000000..5230bcd --- /dev/null +++ b/claude_morning/run.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# 장 전 분석 - 매일 08:30 자동 실행 +# NAS Container Manager 스케줄: 평일 08:30 + +TODAY=$(date '+%Y-%m-%d') + +claude --bare -p " +오늘($TODAY) 장 시작 전 분석을 수행해. + +## 데이터 수집 +1. 네이버 금융(https://finance.naver.com)에서 오늘 주요 뉴스 헤드라인 20건 수집 +2. data/market/ 폴더에 수집한 데이터가 있으면 읽기 +3. Bash로 KIS API 호출이 가능하면 KOSPI/KOSDAQ 전일 지수 확인 + +## 분석 및 판단 +- 시장 분위기: 강세/중립/약세 +- 감성 점수: 0~100 +- 리스크 레벨: 낮음/보통/높음 +- 주목할 섹터 (최대 3개) +- 회피할 섹터 (최대 3개) +- AI 추천 종목 (boosted, 최대 5개) +- AI 제외 종목 (blacklist) +- 포지션 사이즈 배율: 0.5~1.5 + +## 결과 저장 +다음 형식으로 data/daily_context.json 저장: +{ + \"date\": \"$TODAY\", + \"generated_at\": \"HH:MM:SS\", + \"trade_allowed\": true, + \"market_sentiment\": \"중립\", + \"sentiment_score\": 60, + \"risk_level\": \"보통\", + \"hot_sectors\": [], + \"avoid_sectors\": [], + \"boosted_tickers\": [], + \"blacklist_tickers\": [], + \"position_size_multiplier\": 1.0, + \"reason\": \"한 줄 판단 이유\" +} + +## Discord 알림 +환경변수 DISCORD_WEBHOOK_URL로 다음 메시지 전송: +[AI분석] $TODAY | 시장:감성점수점 / 주목:섹터 / 회피:섹터 / reason + +## 절대 금지 +- FORCE_EXIT 값(14:50) 절대 변경 불가 +- app/config.py 수정 불가 (장 전에는 읽기만) +" \ +--allowedTools "Read,Write,Bash" \ +--dangerously-skip-permissions \ +--max-turns 10 diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4480d53 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,74 @@ +version: "3.9" + +services: + redis: + image: redis:7-alpine + container_name: stockbot-redis + restart: unless-stopped + volumes: + - ./data/redis:/data + + stockbot: + build: ./app + container_name: stockbot-main + restart: unless-stopped + depends_on: + - redis + env_file: .env + volumes: + - ./data:/app/data + - ./logs:/app/logs + environment: + - TZ=Asia/Seoul + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "5" + + dashboard: + build: ./app + container_name: stockbot-dashboard + restart: unless-stopped + command: streamlit run monitor/dashboard.py --server.port 8501 + ports: + - "8501:8501" + env_file: .env + volumes: + - ./data:/app/data + environment: + - TZ=Asia/Seoul + + claude-morning: + build: ./claude_morning + container_name: claude-morning + restart: "no" + env_file: .env + volumes: + - ./data:/app/data + - ./logs:/app/logs + - ./reports:/app/reports + environment: + - TZ=Asia/Seoul + profiles: ["morning"] + + claude-evening: + build: ./claude_evening + container_name: claude-evening + restart: "no" + env_file: .env + volumes: + - ./data:/app/data + - ./logs:/app/logs + - ./reports:/app/reports + - ./app:/app/app + environment: + - TZ=Asia/Seoul + profiles: ["evening"] + + kill-switch: + build: ./kill_switch + container_name: stockbot-killswitch + restart: "no" + env_file: .env + profiles: ["emergency"] diff --git a/kill_switch/Dockerfile b/kill_switch/Dockerfile new file mode 100644 index 0000000..68607bc --- /dev/null +++ b/kill_switch/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.11-slim +WORKDIR /app +RUN pip install --no-cache-dir aiohttp python-dotenv +COPY kill.py . +CMD ["python", "kill.py"] diff --git a/kill_switch/__init__.py b/kill_switch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kill_switch/kill.py b/kill_switch/kill.py new file mode 100644 index 0000000..0bc98bf --- /dev/null +++ b/kill_switch/kill.py @@ -0,0 +1,51 @@ +""" +kill_switch/kill.py +긴급 전량 청산 스크립트 +단독 실행: python kill_switch/kill.py +""" +import os, sys, asyncio +from pathlib import Path + +def load_env(): + for p in [Path(".env"), Path("../.env")]: + if p.exists(): + with open(p) 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().strip('"').strip("'") + if k and v and k not in os.environ: + os.environ[k] = v + break + +load_env() +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from app.execution.kis_client import KISClient + +async def kill_all(): + print("=" * 40) + print(" 긴급 전량 청산 실행") + print("=" * 40) + + kis = KISClient() + balance = await kis.get_balance() + holdings = balance.get("holdings", []) + + if not holdings: + print(" 보유 종목 없음") + return + + print(f" 보유 종목: {len(holdings)}개") + for h in holdings: + print(f" 청산 중: {h['name']}({h['ticker']}) {h['qty']}주") + await kis.order_sell(h["ticker"], h["qty"]) + print(f" ✅ 완료") + await asyncio.sleep(0.5) + + print(" 전량 청산 완료") + +if __name__ == "__main__": + asyncio.run(kill_all()) diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/reports/daily/.gitkeep b/reports/daily/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/reports/live_ready/.gitkeep b/reports/live_ready/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/reports/weekly/.gitkeep b/reports/weekly/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test_connection.py b/test_connection.py new file mode 100644 index 0000000..64c1b9d --- /dev/null +++ b/test_connection.py @@ -0,0 +1,84 @@ +""" +test_kis_connection.py +KIS API 연결 테스트 스크립트 +실행: python test_kis_connection.py + +테스트 항목: +1. 토큰 발급 +2. 삼성전자 현재가 조회 +3. 잔고 조회 +4. 거래량 순위 조회 +""" + +import asyncio +import os +import json +import sys +from dotenv import load_dotenv + +# .env 로드 +load_dotenv() + +# 프로젝트 경로 추가 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app.execution.kis_client import KISClient + +async def test_connection(): + print("=" * 55) + print(" KIS API 연결 테스트") + mode = "모의투자" if os.getenv("KIS_MOCK","true") == "true" else "실거래" + print(f" 현재 모드: {mode}") + print("=" * 55) + + client = KISClient() + + # ── 1. 토큰 발급 ── + print("\n[1] 액세스 토큰 발급...") + try: + token = await client.get_access_token() + print(f" ✅ 성공: {token[:20]}...") + except Exception as e: + print(f" ❌ 실패: {e}") + print(" → .env의 KIS 키를 확인해주세요") + return + + # ── 2. 삼성전자 현재가 ── + print("\n[2] 삼성전자(005930) 현재가 조회...") + try: + price = await client.get_price("005930") + print(f" ✅ 현재가: {price['current']:,}원") + print(f" 시가: {price['open']:,} | 고가: {price['high']:,} | 저가: {price['low']:,}") + print(f" 등락률: {price['change_pct']:+.2f}%") + except Exception as e: + print(f" ❌ 실패: {e}") + + # ── 3. 잔고 조회 ── + print("\n[3] 계좌 잔고 조회...") + try: + balance = await client.get_balance() + print(f" ✅ 예수금: {balance['cash']:,}원") + print(f" 보유 종목: {balance['total_cnt']}개") + for h in balance['holdings']: + print(f" └ {h['name']}({h['ticker']}) " + f"{h['qty']}주 / 평균가 {h['avg_price']:,}원 / " + f"{h['pnl_pct']:+.2f}%") + except Exception as e: + print(f" ❌ 실패: {e}") + + # ── 4. 거래량 순위 ── + print("\n[4] 거래량 순위 상위 5종목...") + try: + rank = await client.get_volume_rank(top_n=5) + for r in rank: + print(f" {r['rank']}위 {r['name']}({r['ticker']}) " + f"거래량 {r['volume']:,} / {r['change_pct']:+.2f}%") + except Exception as e: + print(f" ❌ 실패: {e}") + + print("\n" + "=" * 55) + print(" 테스트 완료") + print("=" * 55) + +if __name__ == "__main__": + asyncio.run(test_connection()) diff --git a/종합기획서_단타자동매매_v3.md b/종합기획서_단타자동매매_v3.md new file mode 100644 index 0000000..63d0227 --- /dev/null +++ b/종합기획서_단타자동매매_v3.md @@ -0,0 +1,508 @@ +# 단타 자동매매 시스템 종합 기획서 v3.0 + +> 버전: v3.0 (최종 확정) +> 최종 수정: 2026-05-14 +> 인프라: Synology NAS (Container Manager) +> 언어: Python 3.11 +> API: KIS Open API (한국투자증권) +> 알림: Discord Webhook +> AI: Claude Code headless (Docker 컨테이너, 자동 스케줄) + +--- + +## 0. 핵심 설계 원칙 (절대 불변) + +1. **감정 0** — 진입/청산은 코드가 결정, AI는 보조 +2. **손절 우선** — AI 긍정 판단과 무관하게 손절 룰 항상 우선 +3. **14:50 강제 청산** — 하드코딩, 어떤 상황에서도 예외 없음 +4. **검증 순서 필수** — 모의투자 3개월 → 실전 전환 조건 충족 → 실거래 +5. **AI 역할 분리** — Claude Code: 장 전 분석 + 장 후 개선 (동일 방식, 시간만 다름) + +--- + +## 1. 확정 사항 요약 + +| 항목 | 확정값 | 비고 | +|------|--------|------| +| 증권사 | KIS 한국투자증권 | REST + WebSocket, 모의투자 지원 | +| 인프라 | Synology NAS Docker | Container Manager, 24시간 운영 | +| 언어 | Python 3.11 | KIS 예제 모두 Python | +| DB | SQLite | 단일 파일, 백업 단순 | +| 캐시 | Redis (Docker) | 실시간 시세 캐시 | +| 알림 | Discord Webhook | 단방향, aiohttp POST 5줄 | +| 감시 종목 | 최대 30개 | KIS WebSocket 안정성 기준 | +| 전략 | 변동성 돌파 (K=0.5) | 단순·검증됨 | +| AI 엔진 | Claude Code headless | Docker 컨테이너로 NAS에서 자동 실행 | +| 월 운영비 | Claude Code 구독 요금만 | API 키 별도 불필요 | +| 코드 관리 | Gitea (NAS) | 자동 커밋/리포트 저장 | + +--- + +## 2. 하루 전체 흐름 + +``` +08:30 ┌─────────────────────────────────────────────────┐ + │ [컨테이너 1] Claude Code - 장 전 분석 │ + │ │ + │ claude -p " │ + │ 오늘 날짜 기준 뉴스, 수급, 지수 데이터 수집 │ + │ 오늘 단타 전략 판단 (섹터/종목/리스크) │ + │ → daily_context.json 저장 │ + │ " │ + │ │ + │ Discord 전송: │ + │ "[AI분석] 시장:중립 / 반도체 주목 / 금융 회피" │ + └─────────────────────────────────────────────────┘ + ↓ daily_context.json 생성 +08:50 유니버스 30종목 확정 + 목표가 계산 + ↓ +09:00 ┌─────────────────────────────────────────────────┐ + │ [컨테이너 2] 매매 프로그램 시작 │ + │ │ + │ KIS WebSocket → 실시간 시세 수신 │ + │ 변동성 돌파 신호 발생 │ + │ → AI 필터 (daily_context.json 참조) │ + │ → 조건 충족 시 매수 실행 │ + │ │ + │ 청산 우선순위: │ + │ 1순위: 14:50 강제 청산 (절대 불변) │ + │ 2순위: 손절 -1.5% │ + │ 3순위: 1차 익절 +2% (50% 매도) │ + │ 4순위: 2차 익절 +3% (전량) │ + │ 5순위: 120분 경과 시 청산 │ + │ │ + │ Discord 실시간 전송: │ + │ [매수] [손절] [익절1] [익절2] [경고] │ + └─────────────────────────────────────────────────┘ + ↓ +14:50 강제 전량 청산 + ↓ +15:10 ┌─────────────────────────────────────────────────┐ + │ [결산] 오늘 결과 저장 │ + │ │ + │ SQLite → 매매내역 / 손익 / 승률 저장 │ + │ │ + │ Discord 전송: │ + │ "[결산] 매매5회 / 승3패2 / 순손익 +1.2%" │ + └─────────────────────────────────────────────────┘ + ↓ +15:30 ┌─────────────────────────────────────────────────┐ + │ [컨테이너 3] Claude Code - 장 후 피드백 │ + │ │ + │ claude -p " │ + │ 오늘 매매 결과 분석 │ + │ 이상 패턴 감지 (연속손절/비정상수익 등) │ + │ 문제 있으면 app/config.py 수정 │ + │ reports/daily/날짜.md 저장 │ + │ 실전 전환 조건 체크 │ + │ " │ + │ │ + │ Discord 전송: │ + │ "[분석] 오늘 평가 + 수정사항 요약" │ + │ (실전 조건 충족 시) "[🚀실전전환권고]" │ + └─────────────────────────────────────────────────┘ + ↓ +Gitea 수정된 코드 + 리포트 자동 커밋 +``` + +--- + +## 3. 시스템 구성도 + +``` +Synology NAS (Container Manager) +│ +├── [컨테이너] stockbot-main ← 매매 프로그램 (09:00~15:00 상시) +├── [컨테이너] stockbot-redis ← 실시간 시세 캐시 +├── [컨테이너] stockbot-dashboard ← Streamlit 모니터링 (포트 8501) +├── [컨테이너] claude-morning ← 08:30 장 전 분석 (실행 후 종료) +├── [컨테이너] claude-evening ← 15:30 장 후 피드백 (실행 후 종료) +└── [컨테이너] stockbot-killswitch ← 긴급 청산 (수동 트리거) + +외부 연결 +├── KIS WebSocket ← 실시간 체결/호가/VI +├── KIS REST API ← 주문/잔고/수급/순위 +├── 네이버 금융 ← 뉴스 크롤링 +├── Discord Webhook ← 단방향 알림 +└── Gitea (NAS) ← 코드 관리 / 리포트 저장 +``` + +--- + +## 4. 폴더 구조 + +``` +/volume1/docker/stockbot/ +│ +├── .env ← API 키 (Git 절대 제외) +├── .env.example ← 키 입력 가이드 +├── docker-compose.yml +├── README.md +├── test_connection.py ← KIS 연결 테스트 +│ +├── app/ ← 매매 프로그램 +│ ├── main.py ← asyncio 메인 루프 +│ ├── config.py ← 전략 파라미터 +│ ├── Dockerfile +│ ├── requirements.txt +│ │ +│ ├── ai/ ← Claude Code가 읽고 쓰는 영역 +│ │ └── daily_context.json ← 장 전 분석 결과 (매일 갱신) +│ │ +│ ├── data/ +│ │ ├── universe.py ← 종목 풀 (30개) +│ │ └── collector.py ← WebSocket 시세 수신 +│ │ +│ ├── strategy/ +│ │ ├── base.py +│ │ └── volatility_breakout.py ← 변동성 돌파 + AI 필터 +│ │ +│ ├── risk/ +│ │ └── manager.py ← L1~L5 손실 한도 +│ │ +│ ├── execution/ +│ │ ├── kis_client.py ← KIS REST/WebSocket 래퍼 +│ │ └── order_executor.py ← 주문 전송 +│ │ +│ ├── monitor/ +│ │ ├── notifier.py ← Discord Webhook +│ │ └── dashboard.py ← Streamlit +│ │ +│ └── db/ +│ ├── models.py ← SQLite 스키마 +│ └── repository.py ← DB 접근 +│ +├── claude_morning/ ← 장 전 분석 컨테이너 +│ ├── Dockerfile +│ └── run.sh ← claude -p "..." 실행 스크립트 +│ +├── claude_evening/ ← 장 후 피드백 컨테이너 +│ ├── Dockerfile +│ └── run.sh +│ +├── kill_switch/ +│ └── kill.py ← 긴급 전량 청산 +│ +├── reports/ +│ ├── daily/ ← 매일 자동 생성 (날짜.md) +│ ├── weekly/ ← 매주 자동 생성 +│ └── live_ready/ ← 실전 전환 조건 충족 시 생성 +│ +├── data/ +│ ├── stockbot.db ← SQLite +│ └── universe_cache.json +│ +└── logs/ + ├── trades.log ← 영구 보관 (세금 신고용) + └── claude.log ← AI 판단 이력 +``` + +--- + +## 5. 전략 파라미터 (config.py) + +```python +# 변동성 돌파 +STRATEGY_K = 0.5 # 변동성 계수 +ENTRY_START = "09:00" +ENTRY_END = "14:30" +FORCE_EXIT = "14:50" # 절대 변경 불가 +TP1_PCT = 0.02 # 1차 익절 +2% → 50% 매도 +TP2_PCT = 0.03 # 2차 익절 +3% → 전량 +SL_PCT = 0.015 # 손절 -1.5% +MAX_HOLD_MIN = 120 # 최대 보유 120분 + +# 리스크 +POS_SIZE_PCT = 0.20 # 1종목 최대 20% +MAX_POSITIONS = 2 # 동시 최대 2종목 +DAILY_SL_PCT = 0.03 # 일일 손실 한도 -3% +CONSEC_LOSS = 3 # 연속 손절 횟수 한도 + +# AI 필터 +AI_CONTEXT_PATH = "data/daily_context.json" +AI_MIN_SCORE = 40 # 감성점수 40 미만 → 거래 중단 +``` + +--- + +## 6. 리스크 관리 (L1~L5) + +| 레벨 | 조건 | 동작 | Discord | +|------|------|------|---------| +| L1 | 1회 -1.5% | 즉시 손절 | [손절] | +| L2 | 일일 -3% | 당일 신규 진입 중단 | [경고] | +| L3 | 3연속 손절 | 당일 매매 중단 | [경고] | +| L4 | 주간 -7% | 주말까지 중단 | [경고] | +| L5 | 월간 -15% | 전략 폐기 + Claude Code 재검토 | [긴급] | + +--- + +## 7. Claude Code 컨테이너 구성 + +### 장 전 분석 (08:30) + +```dockerfile +# claude_morning/Dockerfile +FROM node:20-slim +RUN npm install -g @anthropic-ai/claude-code +WORKDIR /app +COPY run.sh . +CMD ["bash", "run.sh"] +``` + +```bash +# claude_morning/run.sh +claude --bare -p " +오늘($(date '+%Y-%m-%d')) 장 시작 전 분석을 수행해. + +1. data/news/ 폴더에서 오늘 수집된 뉴스 파일 읽기 +2. data/market/ 폴더에서 수급/지수 데이터 읽기 + +분석 항목: +- 시장 분위기 (강세/중립/약세) +- 감성 점수 (0~100) +- 리스크 레벨 (낮음/보통/높음) +- 주목할 섹터 +- 회피할 섹터 +- 추천 종목 (boosted) +- 제외 종목 (blacklist) +- 포지션 사이즈 배율 (0.5~1.5) + +결과를 data/daily_context.json 으로 저장. +Discord Webhook으로 분석 요약 전송. +FORCE_EXIT=14:50 은 절대 수정 불가. +" \ +--allowedTools "Read,Write,Bash" \ +--dangerously-skip-permissions \ +--max-turns 10 +``` + +### 장 후 피드백 (15:30) + +```bash +# claude_evening/run.sh +TODAY=$(date '+%Y-%m-%d') + +claude --bare -p " +오늘($TODAY) 매매 결과를 분석하고 개선해. + +1. data/stockbot.db 에서 오늘 매매 내역 조회 (sqlite3) +2. logs/trades.log 에서 오늘 로그 확인 +3. reports/daily/ 의 최근 30일 리포트 참조 + +분석 항목: +- 오늘 승률/손익/이상패턴 +- 연속 손절 여부 +- 파라미터 조정 필요 여부 + +코드 수정: +- 문제 명확할 때만 app/config.py 수정 +- FORCE_EXIT=14:50 절대 변경 불가 + +실전 전환 조건 체크 (모두 충족 시 live_ready 파일 생성): +- 30거래일 이상 운영 +- 최근 30일 승률 > 48% +- 최근 30일 MDD < -10% +- 최근 30일 샤프지수 > 1.0 +- L3 발동 월 2회 이하 + +결과를 reports/daily/${TODAY}.md 저장. +Discord로 요약 전송. +실전 조건 충족 시 '[🚀실전전환권고]' 메시지 전송. +" \ +--allowedTools "Read,Write,Bash" \ +--dangerously-skip-permissions \ +--max-turns 20 +``` + +--- + +## 8. Discord 알림 목록 + +| 시각 | 이벤트 | 메시지 형식 | +|------|--------|-----------| +| 08:30 | AI 분석 완료 | `[AI분석] 시장:중립(62점) / 주목:반도체 / 회피:금융` | +| 09:00~ | 매수 | `[매수] 삼성전자 74,000원 / 목표 75,480 / 손절 72,890` | +| 09:00~ | 매수 (AI추천) | `[매수★] 하이닉스 185,000원 / AI 추천 종목` | +| 09:00~ | 1차 익절 | `[익절1] 삼성전자 +2.1% / 잔여 50%` | +| 09:00~ | 2차 익절 | `[익절2] 삼성전자 +3.0% / 전량 청산` | +| 09:00~ | 손절 | `[손절] 삼성전자 -1.5% / 즉시 청산` | +| 14:50 | 강제 청산 | `[14:50 강제청산] 전 포지션 청산 완료` | +| 15:00~ | L2~L4 발동 | `[경고-L2] 일일 손실 -3% 도달. 오늘 매매 중단.` | +| 15:10 | 일일 결산 | `[결산] 매매5회 / 승3패2 / 순손익 +1.2%` | +| 15:30 | AI 피드백 | `[분석] 오늘 평가 요약 + 수정사항` | +| 조건충족 | 실전 전환 | `[🚀실전전환권고] 30일 검증 완료. 실거래 전환 검토.` | +| 수시 | 긴급 | `[긴급] WebSocket 끊김. kill-switch 실행.` | + +--- + +## 9. .env 구조 + +``` +# KIS 실거래 +KIS_APP_KEY=... +KIS_APP_SECRET=... +KIS_ACCOUNT_NO=... + +# KIS 모의투자 +KIS_MOCK_APP_KEY=... +KIS_MOCK_APP_SECRET=... +KIS_MOCK_ACCOUNT_NO=... + +# 운영 모드 +KIS_MOCK=true # true=모의투자 / false=실거래 +DRY_RUN=true # true=신호만 확인 / false=실제 주문 + +# Discord +DISCORD_WEBHOOK_URL=... + +# Redis +REDIS_HOST=stockbot-redis +REDIS_PORT=6379 + +# 기타 +LOG_LEVEL=INFO +DB_PATH=data/stockbot.db +``` + +--- + +## 10. SQLite 스키마 + +```sql +-- 체결 내역 +CREATE TABLE trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + ticker TEXT NOT NULL, + name TEXT, + entry_time TEXT NOT NULL, + exit_time TEXT, + entry_price REAL NOT NULL, + exit_price REAL, + quantity INTEGER NOT NULL, + side TEXT NOT NULL, -- BUY / SELL + exit_reason TEXT, -- TP1/TP2/SL/FORCE/TIME + pnl REAL, + fee REAL, + ai_boosted INTEGER DEFAULT 0 +); + +-- 일일 요약 +CREATE TABLE daily_summary ( + date TEXT PRIMARY KEY, + total_trades INTEGER DEFAULT 0, + win_trades INTEGER DEFAULT 0, + lose_trades INTEGER DEFAULT 0, + net_pnl REAL DEFAULT 0, + max_drawdown REAL DEFAULT 0, + trading_stopped INTEGER DEFAULT 0 +); + +-- AI 판단 이력 +CREATE TABLE ai_context_log ( + date TEXT PRIMARY KEY, + generated_at TEXT, + trade_allowed INTEGER, + market_sentiment TEXT, + sentiment_score INTEGER, + risk_level TEXT, + hot_sectors TEXT, + avoid_sectors TEXT, + boosted_tickers TEXT, + blacklist_tickers TEXT, + position_size_mult REAL, + reason TEXT +); +``` + +--- + +## 11. 실전 전환 조건 + +모의투자 시작 후 매일 Claude Code가 자동 체크. +아래 5가지 **전부** 충족 시 `reports/live_ready/날짜_READY.md` 생성 + Discord 🚀 알림. + +| 조건 | 기준 | +|------|------| +| 누적 운영 | 30거래일 이상 | +| 승률 | 최근 30일 > 48% | +| MDD | 최근 30일 < -10% | +| 샤프지수 | 최근 30일 > 1.0 | +| L3 발동 | 월 2회 이하 | + +전환 시: `.env`에서 `KIS_MOCK=false`, `DRY_RUN=false` 로 변경. + +--- + +## 12. 운영 모드 조합 + +| KIS_MOCK | DRY_RUN | 동작 | +|----------|---------|------| +| true | true | 신호만 확인 (주문 없음) ← 처음 테스트 | +| true | false | 모의투자 실제 주문 ← 3개월 검증 | +| false | false | 실거래 ← 조건 충족 후 | + +--- + +## 13. 개발 로드맵 + +### Phase 1 — 연결 테스트 (1주) +- [ ] .env 설정 (KIS 모의투자 키 4개) +- [ ] test_connection.py 실행 → KIS 연결 확인 +- [ ] Discord Webhook 테스트 +- [ ] DRY_RUN=true로 신호 발생 확인 + +### Phase 2 — 모의투자 시작 +- [ ] KIS_MOCK=true, DRY_RUN=false +- [ ] 매일 자동 실행 확인 (08:30 / 09:00 / 15:30) +- [ ] Discord 알림 정상 수신 확인 +- [ ] 매일 reports/daily/ 리포트 자동 생성 확인 + +### Phase 3 — 3개월 검증 +- [ ] 30거래일 이상 운영 +- [ ] Claude Code 자동 피드백 / 코드 개선 축적 +- [ ] 실전 전환 조건 5가지 체크 + +### Phase 4 — 실거래 (조건 충족 후) +- [ ] KIS_MOCK=false +- [ ] 총자산 100만원으로 시작 +- [ ] 1개월 단위 성과 검토 → 점진적 증액 + +--- + +## 14. 보안 체크리스트 + +- [ ] .env → .gitignore 등록 (최우선) +- [ ] KIS API 키 → Gitea 절대 커밋 금지 +- [ ] DISCORD_WEBHOOK_URL 외부 노출 금지 +- [ ] NAS 방화벽: 포트 8501 내부망만 허용 +- [ ] 매매 로그 SQLite + logs/ 이중 보관 + +--- + +## 15. 절대 금지 + +```python +HARD_EXIT_TIME = "14:50" # 절대 변경 불가 +AI_SCOPE = "ENTRY_ONLY" # AI는 신규 진입 차단만, 청산 불관여 + +BLACKLIST = [ + "신규상장 6개월 미만", "관리종목", "투자경고", + "거래정지", "우선주", "스팩", "ETF/ETN", +] + +TRADING_BLACKOUT = [ + ("08:00", "09:00"), # 동시호가 + ("11:30", "13:00"), # 점심 + ("14:50", "15:30"), # 마감 +] +``` + +--- + +## 16. 면책 + +> 본 기획서는 시스템 설계 문서이며 투자 수익을 보장하지 않는다. +> 단타는 개인투자자의 90% 이상이 손실을 보는 영역이다. +> 반드시 모의투자 3개월 이상 검증 후 실거래 전환할 것.