first vibe coding

This commit is contained in:
jongjae0305
2026-05-14 15:14:50 +09:00
commit bfff65e55b
40 changed files with 2795 additions and 0 deletions
+10
View File
@@ -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
+49
View File
@@ -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
```
+6
View File
@@ -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"]
View File
View File
+36
View File
@@ -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
View File
+22
View File
@@ -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()
+38
View File
@@ -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 []
View File
+88
View File
@@ -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)
+78
View File
@@ -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
View File
+550
View File
@@ -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 연결 종료")
+99
View File
@@ -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,
))
+458
View File
@@ -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())
View File
+86
View File
@@ -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)
+79
View File
@@ -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%)")
+8
View File
@@ -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
View File
+107
View File
@@ -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(),
}
View File
+19
View File
@@ -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
+217
View File
@@ -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
+7
View File
@@ -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"]
+58
View File
@@ -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
+6
View File
@@ -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"]
+52
View File
@@ -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
View File
+74
View File
@@ -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"]
+5
View File
@@ -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"]
View File
+51
View File
@@ -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())
View File
View File
View File
View File
+84
View File
@@ -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())
+508
View File
@@ -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개월 이상 검증 후 실거래 전환할 것.