first vibe coding
This commit is contained in:
+10
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 []
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 연결 종료")
|
||||
@@ -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
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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%)")
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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개월 이상 검증 후 실거래 전환할 것.
|
||||
Reference in New Issue
Block a user