늘림목까지 완성성
This commit is contained in:
@@ -18,3 +18,9 @@
|
||||
- [즉시 저장 (Atomic Save)]: 데이터 파일(json 등)은 프로그램 종료 시점이 아니라, 이벤트(알림 발송 등)가 발생할 때마다 즉시 저장하세요. 재시작 시 기존 데이터를 삭제하지 않고 수정 데이터만 끼워 넣는 방식으로 안정적으로 운영하세요.
|
||||
- [API 요청 규칙]: 모든 API 요청은 `utils/request_handler.py`의 `SafeRequest` 클래스를 상속받아 구현하세요. HTTP 429(Too Many Requests) 에러 발생 시 재시도(Retry) 로직을 반드시 포함하세요.
|
||||
- [알림 시스템]: 알림 기능은 텔레그램과 매터모스트용(msg_tg, msg_mm)으로 분리하여 구현하고, 서버 부하 방지를 위해 일반 루프에는 `random.sleep(1~3)`을 기본 적용하세요. (실시간 매매 로직 제외)
|
||||
## [로직 누락 방지 규칙]
|
||||
- 모든 코드를 작성한 후, 스스로 다음 항목이 포함되었는지 검토하고 대답하세요.
|
||||
- 1. 손절(Stop-loss) 및 예외 처리 로직이 포함되었는가?
|
||||
- 2. API 호출 제한(429 Error) 및 슬리피지 고려가 되었는가?
|
||||
- 3. 사용자가 요청한 기존 로직과 100% 동일한 기능을 수행하는가?
|
||||
- 만약 하나라도 빠졌다면 코드를 출력하기 전에 스스로 수정하세요.
|
||||
Binary file not shown.
263
auto_ai_reporter.py
Executable file
263
auto_ai_reporter.py
Executable file
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
자동 AI 분석 보고서
|
||||
- 매일 13:00에 최근 10건 거래 분석
|
||||
- AI가 문제점 진단 및 .env 수정 권장
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import sqlite3
|
||||
import datetime
|
||||
import warnings
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# .env 로드
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
env_file = os.path.join(current_dir, '.env')
|
||||
load_dotenv(env_file)
|
||||
|
||||
# Gemini deprecated 경고 억제
|
||||
warnings.filterwarnings("ignore", message=".*google.generativeai.*")
|
||||
|
||||
# Gemini API
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
||||
if GEMINI_API_KEY:
|
||||
genai.configure(api_key=GEMINI_API_KEY)
|
||||
model = genai.GenerativeModel('gemini-2.5-flash')
|
||||
else:
|
||||
model = None
|
||||
except Exception as e:
|
||||
print(f"❌ Gemini 초기화 실패: {e}")
|
||||
model = None
|
||||
|
||||
# Mattermost (Ver2와 동일: mm_config.json + MM_BOT_TOKEN_)
|
||||
MM_SERVER_URL = os.getenv("MM_SERVER_URL", "https://mattermost.hoonfam.org")
|
||||
MM_TOKEN = os.environ.get("MM_BOT_TOKEN_", os.getenv("MATTERMOST_TOKEN", "")).strip()
|
||||
MM_CONFIG_FILE = os.path.join(current_dir, "mm_config.json")
|
||||
MM_CHANNEL = os.getenv("MATTERMOST_CHANNEL", "stock")
|
||||
|
||||
def _load_mm_channels():
|
||||
try:
|
||||
if os.path.exists(MM_CONFIG_FILE):
|
||||
with open(MM_CONFIG_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f).get("channels", {})
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def send_mm(message):
|
||||
"""Mattermost 전송"""
|
||||
channels = _load_mm_channels()
|
||||
channel_id = channels.get(MM_CHANNEL)
|
||||
if not channel_id or not MM_TOKEN:
|
||||
print("❌ MM 설정 없음 (mm_config.json 또는 MM_BOT_TOKEN_)")
|
||||
return False
|
||||
api_url = f"{MM_SERVER_URL.rstrip('/')}/api/v4/posts"
|
||||
headers = {"Authorization": f"Bearer {MM_TOKEN}", "Content-Type": "application/json"}
|
||||
payload = {"channel_id": channel_id, "message": message}
|
||||
try:
|
||||
r = requests.post(api_url, headers=headers, json=payload, timeout=5)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ MM 전송 에러: {e}")
|
||||
return False
|
||||
|
||||
def get_recent_trades(limit=10):
|
||||
"""최근 거래 N건 조회"""
|
||||
db_path = os.path.join(os.path.dirname(__file__), 'quant_bot.db')
|
||||
conn = sqlite3.connect(db_path)
|
||||
|
||||
cursor = conn.execute(f"""
|
||||
SELECT
|
||||
code, name,
|
||||
buy_price, sell_price, qty,
|
||||
profit_rate, realized_pnl,
|
||||
strategy, sell_reason,
|
||||
buy_date, sell_date, hold_minutes
|
||||
FROM trade_history
|
||||
ORDER BY id DESC
|
||||
LIMIT {limit}
|
||||
""")
|
||||
|
||||
trades = []
|
||||
for row in cursor.fetchall():
|
||||
trades.append({
|
||||
'code': row[0],
|
||||
'name': row[1],
|
||||
'buy_price': row[2],
|
||||
'sell_price': row[3],
|
||||
'qty': row[4],
|
||||
'profit_rate': row[5],
|
||||
'realized_pnl': row[6],
|
||||
'strategy': row[7],
|
||||
'sell_reason': row[8],
|
||||
'buy_date': row[9],
|
||||
'sell_date': row[10],
|
||||
'hold_minutes': row[11]
|
||||
})
|
||||
|
||||
conn.close()
|
||||
return trades
|
||||
|
||||
def get_trade_summary(trades):
|
||||
"""거래 통계 요약"""
|
||||
if not trades:
|
||||
return "거래 없음"
|
||||
|
||||
total = len(trades)
|
||||
wins = sum(1 for t in trades if t['profit_rate'] > 0)
|
||||
losses = total - wins
|
||||
win_rate = wins / total * 100 if total > 0 else 0
|
||||
|
||||
avg_profit = sum(t['profit_rate'] for t in trades) / total
|
||||
total_pnl = sum(t['realized_pnl'] for t in trades)
|
||||
|
||||
avg_hold = sum(t['hold_minutes'] for t in trades) / total
|
||||
|
||||
return f"""
|
||||
📊 최근 {total}건 거래 통계
|
||||
- 승률: {win_rate:.1f}% ({wins}승 {losses}패)
|
||||
- 평균 수익률: {avg_profit:.2f}%
|
||||
- 총 손익: {total_pnl:,.0f}원
|
||||
- 평균 보유: {avg_hold:.0f}분
|
||||
"""
|
||||
|
||||
def analyze_with_ai(trades):
|
||||
"""AI로 거래 분석 및 권장사항"""
|
||||
if not model:
|
||||
return "❌ AI 분석 불가 (Gemini API 키 없음)"
|
||||
|
||||
trades_text = ""
|
||||
for i, t in enumerate(trades, 1):
|
||||
trades_text += f"""
|
||||
[거래 {i}] {t['name']} ({t['strategy']})
|
||||
- 매수: {t['buy_price']:,.0f}원 × {t['qty']}주
|
||||
- 매도: {t['sell_price']:,.0f}원
|
||||
- 손익: {t['profit_rate']:+.2f}% ({t['realized_pnl']:,.0f}원)
|
||||
- 보유: {t['hold_minutes']}분
|
||||
- 사유: {t['sell_reason']}
|
||||
"""
|
||||
|
||||
prompt = f"""당신은 퀀트 트레이딩 전문가입니다.
|
||||
|
||||
다음은 최근 {len(trades)}건의 거래 내역입니다:
|
||||
|
||||
{trades_text}
|
||||
|
||||
{get_trade_summary(trades)}
|
||||
|
||||
**당신의 임무:**
|
||||
1. 문제점 3가지 진단 (구체적으로)
|
||||
2. .env 수정 권장사항 (변수명=값 형식)
|
||||
3. 예상 효과
|
||||
|
||||
**출력 형식:**
|
||||
## 🔍 문제점
|
||||
1. [구체적 문제 1]
|
||||
2. [구체적 문제 2]
|
||||
3. [구체적 문제 3]
|
||||
|
||||
## 💡 권장 수정사항
|
||||
```
|
||||
RSI_OVERHEAT_THRESHOLD=XX
|
||||
HIGH_PRICE_CHASE_THRESHOLD=X.XX
|
||||
STOP_LOSS_PCT=-X.XX
|
||||
...
|
||||
```
|
||||
|
||||
## 📈 예상 효과
|
||||
- [효과 1]
|
||||
- [효과 2]
|
||||
|
||||
**간결하고 명확하게 답변하세요.**
|
||||
"""
|
||||
|
||||
try:
|
||||
response = model.generate_content(prompt)
|
||||
return response.text
|
||||
except Exception as e:
|
||||
return f"❌ AI 분석 실패: {e}"
|
||||
|
||||
def send_daily_report():
|
||||
"""13시 정기 보고서"""
|
||||
print(f"\n{'='*80}")
|
||||
print(f"🤖 자동 AI 분석 시작: {datetime.datetime.now()}")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
trades = get_recent_trades(10)
|
||||
|
||||
if not trades:
|
||||
print("❌ 거래 내역 없음")
|
||||
return
|
||||
|
||||
print("🧠 AI 분석 중...")
|
||||
analysis = analyze_with_ai(trades)
|
||||
|
||||
summary = get_trade_summary(trades)
|
||||
|
||||
message = f"""🤖 **[13시 AI 자동 분석]**
|
||||
|
||||
{summary}
|
||||
|
||||
{analysis}
|
||||
|
||||
---
|
||||
💬 명령어 사용법:
|
||||
- `!env 보기` - 현재 설정 확인
|
||||
- `!env RSI_OVERHEAT_THRESHOLD=75` - 설정 변경
|
||||
- `!ai 왜 승률이 낮아?` - AI 질문
|
||||
"""
|
||||
|
||||
if send_mm(message):
|
||||
print("✅ 보고서 전송 완료")
|
||||
else:
|
||||
print("❌ 전송 실패")
|
||||
|
||||
def main():
|
||||
"""메인 루프"""
|
||||
print("🤖 자동 AI 분석 보고서 시작")
|
||||
print(f"- 보고 시간: 매일 13:00")
|
||||
print(f"- 분석 대상: 최근 10건 거래")
|
||||
print(f"- AI 모델: Gemini 2.5 Flash")
|
||||
print()
|
||||
|
||||
if not model:
|
||||
print("❌ Gemini API 키가 없습니다.")
|
||||
return
|
||||
|
||||
reported_today = False
|
||||
main.last_date = datetime.date.today()
|
||||
|
||||
while True:
|
||||
now = datetime.datetime.now()
|
||||
current_date = now.date()
|
||||
|
||||
if main.last_date != current_date:
|
||||
reported_today = False
|
||||
print(f"\n📅 날짜 변경: {current_date}")
|
||||
|
||||
main.last_date = current_date
|
||||
|
||||
if now.hour == 13 and now.minute == 0 and not reported_today:
|
||||
send_daily_report()
|
||||
reported_today = True
|
||||
time.sleep(60)
|
||||
|
||||
time.sleep(60)
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n👋 종료")
|
||||
except Exception as e:
|
||||
print(f"\n\n❌ 에러: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
76
database.py
76
database.py
@@ -147,7 +147,21 @@ class TradeDB:
|
||||
)
|
||||
""")
|
||||
|
||||
# 5. 매수 후보군 테이블 (target_universe 대체)
|
||||
# 5. 매수 체결 이력 (일일 한도용 - '산 시점' 날짜 기준 누적)
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS buy_execution_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
strategy TEXT NOT NULL,
|
||||
buy_date TEXT NOT NULL, -- YYYY-MM-DD (산 날짜 = 한도 기준일)
|
||||
executed_at TEXT NOT NULL, -- 체결 시각
|
||||
amount REAL NOT NULL, -- 매수 금액 (주가×수량)
|
||||
qty INTEGER NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# 6. 매수 후보군 테이블 (target_universe 대체)
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS target_candidates (
|
||||
code TEXT PRIMARY KEY, -- 종목코드
|
||||
@@ -159,7 +173,7 @@ class TradeDB:
|
||||
)
|
||||
""")
|
||||
|
||||
# 6. env 설정 전용 테이블 (관리자용, INSERT만 / 최신 1건 = 현재 설정, 키당 컬럼)
|
||||
# 7. env 설정 전용 테이블 (관리자용, INSERT만 / 최신 1건 = 현재 설정, 키당 컬럼)
|
||||
cols = ", ".join([f'"{k}" TEXT' for k in ENV_CONFIG_KEYS])
|
||||
self.conn.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS env_config (
|
||||
@@ -306,15 +320,25 @@ class TradeDB:
|
||||
logger.error(f"❌ upsert_trade 실패 ({code}): {e}")
|
||||
return False
|
||||
|
||||
def get_active_trades(self):
|
||||
def get_active_trades(self, strategy_prefix: Optional[str] = None):
|
||||
"""
|
||||
활성 트레이딩 목록 조회 (봇 재시작 시 사용)
|
||||
|
||||
Args:
|
||||
strategy_prefix: None이면 전부, 'LONG'이면 strategy LIKE 'LONG%'만, 'SHORT'면 'SHORT%'만
|
||||
(늘림목/단타 섞임 방지)
|
||||
|
||||
Returns:
|
||||
{종목코드: {trade_info}} 형태의 딕셔너리
|
||||
"""
|
||||
try:
|
||||
cursor = self.conn.execute("SELECT * FROM active_trades")
|
||||
if strategy_prefix:
|
||||
cursor = self.conn.execute(
|
||||
"SELECT * FROM active_trades WHERE strategy LIKE ?",
|
||||
(strategy_prefix.strip().upper() + "%",)
|
||||
)
|
||||
else:
|
||||
cursor = self.conn.execute("SELECT * FROM active_trades")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
# 기존 JSON 포맷과 호환되도록 딕셔너리 변환
|
||||
@@ -464,6 +488,50 @@ class TradeDB:
|
||||
logger.error(f"❌ 삭제 실패 ({code}): {e}")
|
||||
return False
|
||||
|
||||
def insert_buy_execution(
|
||||
self,
|
||||
code: str,
|
||||
name: str,
|
||||
strategy: str,
|
||||
amount: float,
|
||||
qty: int,
|
||||
):
|
||||
"""
|
||||
매수 체결 이력 저장 (일일 한도용). '하루' = 산 날짜(buy_date) 기준.
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
buy_date = now.strftime("%Y-%m-%d")
|
||||
executed_at = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
try:
|
||||
with self.conn:
|
||||
self.conn.execute("""
|
||||
INSERT INTO buy_execution_log (code, name, strategy, buy_date, executed_at, amount, qty)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (code, name, strategy, buy_date, executed_at, amount, qty))
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"❌ insert_buy_execution 실패 ({code}): {e}")
|
||||
return False
|
||||
|
||||
def get_daily_buy_amount(self, date_str: str, strategy_prefix: str = "LONG") -> Tuple[float, int]:
|
||||
"""
|
||||
해당 날짜(산 시점 기준)에 strategy_prefix에 해당하는 매수 누적 금액·건수.
|
||||
date_str: YYYY-MM-DD
|
||||
Returns:
|
||||
(누적 금액, 건수)
|
||||
"""
|
||||
try:
|
||||
cursor = self.conn.execute("""
|
||||
SELECT COALESCE(SUM(amount), 0), COUNT(*)
|
||||
FROM buy_execution_log
|
||||
WHERE buy_date = ? AND strategy LIKE ?
|
||||
""", (date_str, strategy_prefix.strip().upper() + "%"))
|
||||
row = cursor.fetchone()
|
||||
return (float(row[0]), int(row[1]))
|
||||
except Exception as e:
|
||||
logger.error(f"❌ get_daily_buy_amount 실패: {e}")
|
||||
return (0.0, 0)
|
||||
|
||||
# ============================================================
|
||||
# [보강] 주문·체결 이력 (kt00007 / ka10076)
|
||||
# ============================================================
|
||||
|
||||
267
kis_long_alert.py
Normal file
267
kis_long_alert.py
Normal file
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
KIS Long Alert - 늘림목 알림 전용 (매매 없음, 하루 2회 Mattermost 브리핑)
|
||||
- kis_long_ver1.py에서 알림(리포트) 로직만 분리
|
||||
- 13:00 오전 브리핑, 15:35 장마감 최종 브리핑 (환경변수로 시각 변경 가능)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from datetime import datetime as dt
|
||||
|
||||
from database import TradeDB
|
||||
|
||||
# kis_long_ver1과 동일한 설정·클래스 사용 (알림만 필요)
|
||||
from kis_long_ver1 import (
|
||||
db,
|
||||
get_env_from_db,
|
||||
get_env_float,
|
||||
get_env_bool,
|
||||
SCRIPT_DIR,
|
||||
MM_SERVER_URL,
|
||||
MM_BOT_TOKEN,
|
||||
MM_CONFIG_FILE,
|
||||
MM_CHANNEL_LONG,
|
||||
MattermostBot,
|
||||
KISClientWithOrder,
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
format="[%(asctime)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
level=logging.INFO,
|
||||
)
|
||||
logger = logging.getLogger("KISLongAlert")
|
||||
|
||||
# 알림 발송 시각 (하루 2회). DB/환경: ALERT_TIME_1="13:00", ALERT_TIME_2="15:35"
|
||||
def _parse_time(s: str):
|
||||
"""'13:00' -> (13, 0)"""
|
||||
try:
|
||||
h, m = s.strip().split(":")
|
||||
return int(h), int(m)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _get_alert_times():
|
||||
t1 = get_env_from_db("ALERT_TIME_1", "13:00").strip()
|
||||
t2 = get_env_from_db("ALERT_TIME_2", "15:35").strip()
|
||||
return _parse_time(t1) or (13, 0), _parse_time(t2) or (15, 35)
|
||||
|
||||
|
||||
# 당일 시작 자산 저장 (첫 알림 시점에 설정, 같은 날 재사용)
|
||||
STATE_FILE = SCRIPT_DIR / "kis_long_alert_state.json"
|
||||
|
||||
def _load_state():
|
||||
try:
|
||||
if STATE_FILE.exists():
|
||||
with open(STATE_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.debug(f"상태 파일 로드 실패: {e}")
|
||||
return {}
|
||||
|
||||
def _save_state(state):
|
||||
try:
|
||||
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.warning(f"상태 파일 저장 실패: {e}")
|
||||
|
||||
|
||||
class LongAlertBot:
|
||||
"""늘림목 알림 전용 - KIS 자산 조회 + Mattermost 2회 전송 (ver1과 동일한 보유 기준: DB LONG + watchlist 병합)"""
|
||||
|
||||
def __init__(self):
|
||||
self.db = db
|
||||
self.client = KISClientWithOrder()
|
||||
self.mm = MattermostBot()
|
||||
self.mm_channel = MM_CHANNEL_LONG
|
||||
|
||||
self.current_cash = 0
|
||||
self.current_total_asset = 0
|
||||
self.start_day_asset = 0
|
||||
self.total_deposit = get_env_float("TOTAL_DEPOSIT", 0)
|
||||
self.today_date = dt.now().strftime("%Y-%m-%d")
|
||||
self.holdings = {}
|
||||
|
||||
# ver1과 동일: DB에서 늘림목(LONG) 보유만 로드
|
||||
for code, trade in self.db.get_active_trades(strategy_prefix="LONG").items():
|
||||
qty = trade.get("current_qty", 0) or 0
|
||||
if qty > 0:
|
||||
self.holdings[code] = {"total_qty": qty, "name": trade.get("name", code)}
|
||||
|
||||
# watchlist 로드 (새로 발굴·반영한 종목도 알림에 포함되도록)
|
||||
self.watchlist_path = SCRIPT_DIR / "long_term_watchlist.json"
|
||||
self.watchlist = self._load_watchlist()
|
||||
|
||||
self._update_assets()
|
||||
self._maybe_set_start_day_asset()
|
||||
|
||||
def _load_watchlist(self):
|
||||
if not self.watchlist_path.exists():
|
||||
return []
|
||||
try:
|
||||
with open(self.watchlist_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f).get("items", [])
|
||||
except Exception as e:
|
||||
logger.debug(f"watchlist 로드 실패: {e}")
|
||||
return []
|
||||
|
||||
def _merge_watchlist_holdings_from_balance(self, balance):
|
||||
"""ver1과 동일: watchlist LONG/dca인 계좌 보유를 holdings에 합침 (inquire-balance → output1=종목 리스트)"""
|
||||
positions_list = balance.get("output1") or []
|
||||
if isinstance(positions_list, dict):
|
||||
positions_list = [positions_list]
|
||||
if not isinstance(positions_list, list):
|
||||
positions_list = []
|
||||
watchlist_dca_codes = {
|
||||
(item.get("code") or "").strip()
|
||||
for item in self.watchlist
|
||||
if (item.get("strategy") == "LONG" or item.get("use") == "dca")
|
||||
}
|
||||
watchlist_dca_codes.discard("")
|
||||
for row in positions_list:
|
||||
code = (row.get("pdno") or "").strip()
|
||||
if not code or code in self.holdings or code not in watchlist_dca_codes:
|
||||
continue
|
||||
try:
|
||||
qty = int(float(row.get("hldg_qty") or row.get("ord_psbl_qty") or 0))
|
||||
if qty <= 0:
|
||||
continue
|
||||
name = (row.get("prdt_name") or row.get("prdt_name_eng") or code).strip()
|
||||
self.holdings[code] = {"total_qty": qty, "name": name or code}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _update_assets(self):
|
||||
"""자산 정보 업데이트 (KIS API)"""
|
||||
try:
|
||||
balance = self.client.get_account_evaluation()
|
||||
if not balance:
|
||||
return
|
||||
# ver1과 동일: watchlist LONG 반영 (단타와 동일 inquire-balance → output2=예수금, output1=종목 리스트)
|
||||
self._merge_watchlist_holdings_from_balance(balance)
|
||||
out2 = balance.get("output2")
|
||||
if isinstance(out2, list) and out2:
|
||||
out2 = out2[0]
|
||||
out2 = out2 if isinstance(out2, dict) else {}
|
||||
ord_psbl = out2.get("ord_psbl_cash") or out2.get("dnca_tot_amt")
|
||||
self.current_cash = float(str(ord_psbl or 0).replace(",", "").strip()) if ord_psbl is not None else 0
|
||||
output1_list = balance.get("output1") or []
|
||||
if isinstance(output1_list, dict):
|
||||
output1_list = [output1_list]
|
||||
holdings_value = 0
|
||||
for code in self.holdings:
|
||||
evlu = None
|
||||
for item in output1_list:
|
||||
if (item.get("pdno") or "").strip() == code:
|
||||
evlu = float(item.get("evlu_amt", 0) or 0)
|
||||
break
|
||||
if evlu is not None:
|
||||
holdings_value += evlu
|
||||
else:
|
||||
price_data = self.client.inquire_price(code)
|
||||
if price_data:
|
||||
holdings_value += abs(float(price_data.get("stck_prpr", 0) or 0)) * self.holdings[code]["total_qty"]
|
||||
self.current_total_asset = self.current_cash + holdings_value
|
||||
except Exception as e:
|
||||
logger.error(f"자산 정보 업데이트 실패: {e}")
|
||||
|
||||
def _maybe_set_start_day_asset(self):
|
||||
"""오늘 날짜 기준으로 start_day_asset 설정 (당일 첫 실행 시)"""
|
||||
state = _load_state()
|
||||
saved_date = state.get("date", "")
|
||||
if saved_date == self.today_date and state.get("start_day_asset"):
|
||||
self.start_day_asset = float(state["start_day_asset"])
|
||||
return
|
||||
self.start_day_asset = self.current_total_asset
|
||||
_save_state({"date": self.today_date, "start_day_asset": self.current_total_asset})
|
||||
|
||||
def send_mm(self, msg):
|
||||
try:
|
||||
self.mm.send(self.mm_channel, msg)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ MM 전송 에러: {e}")
|
||||
|
||||
def send_report_1(self):
|
||||
"""1회차: 오전 브리핑 (13:00)"""
|
||||
self._update_assets()
|
||||
self._maybe_set_start_day_asset()
|
||||
day_pnl = self.current_total_asset - self.start_day_asset
|
||||
day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0
|
||||
msg = f"""📊 **[늘림목 오전 브리핑 - {dt.now().strftime('%H:%M')}]**
|
||||
- 당일 시작: {self.start_day_asset:,.0f}원
|
||||
- 현재 자산: {self.current_total_asset:,.0f}원
|
||||
- 당일 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)
|
||||
- 보유 종목: {len(self.holdings)}개"""
|
||||
self.send_mm(msg)
|
||||
logger.info("📊 오전 브리핑 전송 완료")
|
||||
|
||||
def send_report_2(self):
|
||||
"""2회차: 장마감 최종 브리핑 (15:35)"""
|
||||
self._update_assets()
|
||||
self._maybe_set_start_day_asset()
|
||||
day_pnl = self.current_total_asset - self.start_day_asset
|
||||
day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0
|
||||
cumulative_pnl = self.current_total_asset - self.total_deposit
|
||||
cumulative_pnl_pct = (cumulative_pnl / self.total_deposit * 100) if self.total_deposit > 0 else 0
|
||||
today_ymd = dt.now().strftime("%Y%m%d")
|
||||
today_trades = self.db.get_trades_by_date(today_ymd)
|
||||
msg = f"""🏁 **[늘림목 장마감 브리핑 - {dt.now().strftime('%H:%M')}]**
|
||||
━━━━━━━━━━━━━━━━━━━━
|
||||
📅 **당일 손익**
|
||||
- 시작: {self.start_day_asset:,.0f}원
|
||||
- 종료: {self.current_total_asset:,.0f}원
|
||||
- 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)
|
||||
|
||||
💰 **누적 손익 (총 입금액 대비)**
|
||||
- 총 입금: {self.total_deposit:,.0f}원
|
||||
- 현재 자산: {self.current_total_asset:,.0f}원
|
||||
- 누적 손익: {cumulative_pnl:+,.0f}원 ({cumulative_pnl_pct:+.2f}%)
|
||||
|
||||
📊 **거래 현황**
|
||||
- 오늘 매매: {len(today_trades)}건
|
||||
- 보유 종목: {len(self.holdings)}개
|
||||
- 예수금: {self.current_cash:,.0f}원
|
||||
━━━━━━━━━━━━━━━━━━━━"""
|
||||
self.send_mm(msg)
|
||||
logger.info("🏁 장마감 브리핑 전송 완료")
|
||||
|
||||
|
||||
def main():
|
||||
(h1, m1), (h2, m2) = _get_alert_times()
|
||||
logger.info(f"늘림목 알림 봇 시작 (하루 2회: {h1:02d}:{m1:02d}, {h2:02d}:{m2:02d})")
|
||||
sent_1 = False
|
||||
sent_2 = False
|
||||
last_date = ""
|
||||
|
||||
while True:
|
||||
try:
|
||||
now = dt.now()
|
||||
today = now.strftime("%Y-%m-%d")
|
||||
if today != last_date:
|
||||
last_date = today
|
||||
sent_1 = False
|
||||
sent_2 = False
|
||||
|
||||
if now.hour == h1 and now.minute == m1 and not sent_1:
|
||||
bot = LongAlertBot()
|
||||
bot.send_report_1()
|
||||
sent_1 = True
|
||||
elif now.hour == h2 and now.minute == m2 and not sent_2:
|
||||
bot = LongAlertBot()
|
||||
bot.send_report_2()
|
||||
sent_2 = True
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("종료")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"알림 루프 에러: {e}")
|
||||
time.sleep(60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
430
kis_long_ver1.py
430
kis_long_ver1.py
@@ -8,7 +8,7 @@ KIS Long Trading Bot Ver1 - 늘림목 전략용 한투 API 트레이딩 시스
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import ㅁㅁ
|
||||
import random
|
||||
import logging
|
||||
import datetime
|
||||
@@ -154,55 +154,51 @@ def _save_kis_token_cache(access_token, access_token_token_expired, mock):
|
||||
|
||||
|
||||
class KISClientWithOrder:
|
||||
"""주문 기능이 추가된 KIS 클라이언트"""
|
||||
"""주문 기능이 추가된 KIS 클라이언트 (env 키는 단타 봇과 동일: KIS_APP_KEY_MOCK/REAL, KIS_ACCOUNT_NO_MOCK/REAL)"""
|
||||
def __init__(self, mock=None):
|
||||
# DB에서 환경변수 읽기
|
||||
self.app_key = get_env_from_db("KIS_APP_KEY", "").strip()
|
||||
self.app_secret = get_env_from_db("KIS_APP_SECRET", "").strip()
|
||||
# 모의 여부 결정
|
||||
# 모의 여부 결정 (단타 봇과 동일)
|
||||
if mock is not None:
|
||||
use_mock = mock
|
||||
else:
|
||||
use_mock = get_env_bool("KIS_MOCK", True)
|
||||
|
||||
# 계좌번호: 모의/실전 분리 (사용자가 8자리로 직접 입력)
|
||||
# 앱키/시크릿: 모의·실전 분리 (단타와 동일 키 사용 → DB 한 세트로 둘 다 실행 가능)
|
||||
if use_mock:
|
||||
raw_no_mock = get_env_from_db("KIS_ACCOUNT_NO_MOCK", "").strip()
|
||||
raw_code_mock = get_env_from_db("KIS_ACCOUNT_CODE_MOCK", "").strip()
|
||||
if raw_no_mock:
|
||||
raw_no = raw_no_mock
|
||||
if raw_code_mock:
|
||||
raw_code = raw_code_mock
|
||||
else:
|
||||
raw_code = "01"
|
||||
else:
|
||||
raw_no = get_env_from_db("KIS_ACCOUNT_NO", "").strip()
|
||||
raw_code = get_env_from_db("KIS_ACCOUNT_CODE", "01").strip()
|
||||
if not raw_code:
|
||||
raw_code = "01"
|
||||
self.app_key = (get_env_from_db("KIS_APP_KEY_MOCK", "") or get_env_from_db("KIS_APP_KEY", "")).strip()
|
||||
self.app_secret = (get_env_from_db("KIS_APP_SECRET_MOCK", "") or get_env_from_db("KIS_APP_SECRET", "")).strip()
|
||||
if not self.app_key or not self.app_secret:
|
||||
logger.error("❌ 모의투자용 APP KEY/SECRET이 DB에 없습니다. KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK 설정 필요.")
|
||||
raise ValueError("모의투자 KIS_APP_KEY_MOCK / KIS_APP_SECRET_MOCK 미설정")
|
||||
else:
|
||||
raw_no = get_env_from_db("KIS_ACCOUNT_NO", "").strip()
|
||||
raw_code = get_env_from_db("KIS_ACCOUNT_CODE", "01").strip()
|
||||
self.app_key = (get_env_from_db("KIS_APP_KEY_REAL", "") or get_env_from_db("KIS_APP_KEY", "")).strip()
|
||||
self.app_secret = (get_env_from_db("KIS_APP_SECRET_REAL", "") or get_env_from_db("KIS_APP_SECRET", "")).strip()
|
||||
|
||||
# 계좌번호: 모의/실전 분리 (단타와 동일)
|
||||
if use_mock:
|
||||
raw_no = (get_env_from_db("KIS_ACCOUNT_NO_MOCK", "") or get_env_from_db("KIS_ACCOUNT_NO", "")).strip()
|
||||
raw_code = (get_env_from_db("KIS_ACCOUNT_CODE_MOCK", "") or get_env_from_db("KIS_ACCOUNT_CODE", "01")).strip()
|
||||
if not raw_code:
|
||||
raw_code = "01"
|
||||
else:
|
||||
raw_no = (get_env_from_db("KIS_ACCOUNT_NO_REAL", "") or get_env_from_db("KIS_ACCOUNT_NO", "")).strip()
|
||||
raw_code = (get_env_from_db("KIS_ACCOUNT_CODE_REAL", "") or get_env_from_db("KIS_ACCOUNT_CODE", "01")).strip()
|
||||
if not raw_code:
|
||||
raw_code = "01"
|
||||
|
||||
# DB 값 그대로 사용 (10자리면 앞8/뒤2만 분리)
|
||||
# 10자리면 앞 8 / 뒤 2 분리
|
||||
if len(raw_no) >= 10:
|
||||
self.acc_no = raw_no[:8]
|
||||
self.acc_code = raw_no[8:10]
|
||||
else:
|
||||
self.acc_no = raw_no
|
||||
if len(raw_code) >= 2:
|
||||
self.acc_code = raw_code[:2]
|
||||
else:
|
||||
self.acc_code = "01"
|
||||
self.acc_code = raw_code[:2] if len(raw_code) >= 2 else "01"
|
||||
if len(self.acc_no) != 8:
|
||||
logger.warning("⚠️ 계좌번호 CANO 8자리 아님: '%s'(%s자리). DB 확인.", self.acc_no, len(self.acc_no))
|
||||
|
||||
if len(self.acc_no) != 8 or len(self.acc_code) != 2:
|
||||
logger.error(
|
||||
"❌ 계좌번호 형식 오류: CANO=%s(%s자리), ACNT_PRDT_CD=%s(%s자리) → OPSQ2000 발생. "
|
||||
"모의면 KIS_ACCOUNT_NO_MOCK/KIS_ACCOUNT_CODE_MOCK, 실전이면 KIS_ACCOUNT_NO/KIS_ACCOUNT_CODE 확인.",
|
||||
"모의면 KIS_ACCOUNT_NO_MOCK/KIS_ACCOUNT_CODE_MOCK, 실전이면 KIS_ACCOUNT_NO_REAL 또는 KIS_ACCOUNT_NO 확인.",
|
||||
self.acc_no, len(self.acc_no), self.acc_code, len(self.acc_code)
|
||||
)
|
||||
else:
|
||||
@@ -219,7 +215,8 @@ class KISClientWithOrder:
|
||||
def _auth(self):
|
||||
"""접근 토큰 발급"""
|
||||
if not self.app_key or not self.app_secret:
|
||||
raise ValueError("KIS_APP_KEY, KIS_APP_SECRET 설정 필요")
|
||||
hint = "KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK" if self.mock else "KIS_APP_KEY_REAL, KIS_APP_SECRET_REAL (또는 KIS_APP_KEY, KIS_APP_SECRET)"
|
||||
raise ValueError(f"한투 앱키 미설정: {hint}")
|
||||
|
||||
cached = _load_kis_token_cache(self.mock)
|
||||
if cached:
|
||||
@@ -341,37 +338,51 @@ class KISClientWithOrder:
|
||||
return None
|
||||
|
||||
def get_account_evaluation(self):
|
||||
"""계좌 평가 잔고 조회 [v1_국내주식-011]. 모의=VTTC8494R, 실전=TTTC8494R."""
|
||||
"""계좌 잔고 조회 (단타와 동일: inquire-balance, VTTC8434R/TTTC8434R)."""
|
||||
if self.mock is True:
|
||||
tr_id = "VTTC8494R"
|
||||
tr_id = "VTTC8434R"
|
||||
else:
|
||||
tr_id = "TTTC8494R"
|
||||
tr_id = "TTTC8434R"
|
||||
params = {
|
||||
"CANO": self.acc_no,
|
||||
"ACNT_PRDT_CD": self.acc_code,
|
||||
"AFHR_FLPR_YN": "N",
|
||||
"OFL_YN": "",
|
||||
"INQR_DVSN": "01",
|
||||
"UNPR_DVSN": "01",
|
||||
"FUND_STTL_ICLD_YN": "N",
|
||||
"FNCG_AMT_AUTO_RDPT_YN": "N",
|
||||
"PRCS_DVSN": "00",
|
||||
"CTX_AREA_FK100": "",
|
||||
"CTX_AREA_NK100": "",
|
||||
}
|
||||
try:
|
||||
r = self._get(
|
||||
"/uapi/domestic-stock/v1/trading/inquire-balance-rlz-pl",
|
||||
"/uapi/domestic-stock/v1/trading/inquire-balance",
|
||||
tr_id,
|
||||
{
|
||||
"CANO": self.acc_no,
|
||||
"ACNT_PRDT_CD": self.acc_code,
|
||||
"AFHR_FLPR_YN": "N",
|
||||
"OFL_YN": "",
|
||||
"INQR_DVSN": "01",
|
||||
"UNPR_DVSN": "01",
|
||||
"FUND_STTL_ICLD_YN": "N",
|
||||
"FNCG_AMT_AUTO_RDPT_YN": "N",
|
||||
"PRCS_DVSN": "01",
|
||||
"CTX_AREA_FK100": "",
|
||||
"CTX_AREA_NK100": "",
|
||||
},
|
||||
params,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
logger.warning(
|
||||
f"💵 [예수금] 잔고 API HTTP 오류: status={r.status_code}, body={getattr(r, 'text', '')[:200]} | TR={tr_id}, CANO={self.acc_no}, 모의={self.mock}"
|
||||
)
|
||||
return None
|
||||
j = r.json()
|
||||
if j.get("rt_cd") != "0":
|
||||
msg_cd = j.get("msg_cd", "")
|
||||
msg1 = (j.get("msg1") or "")[:200]
|
||||
logger.warning(
|
||||
f"💵 [예수금] 잔고 API 응답 오류: rt_cd={j.get('rt_cd')}, msg_cd={msg_cd}, msg1={msg1} | "
|
||||
f"TR={tr_id}, CANO={self.acc_no}, ACNT_PRDT_CD={self.acc_code}, 모의={self.mock}"
|
||||
)
|
||||
if "OPSQ2000" in str(msg_cd) or "INVALID" in msg1.upper():
|
||||
logger.warning(
|
||||
"💵 [예수금] 계좌번호 검증 실패일 수 있음. 모의면 KIS_ACCOUNT_NO_MOCK/KIS_ACCOUNT_CODE_MOCK 확인."
|
||||
)
|
||||
return None
|
||||
return j
|
||||
except Exception as e:
|
||||
logger.error(f"계좌 평가 조회 실패: {e}")
|
||||
logger.error(f"💵 [예수금] 잔고 조회 실패: {e} | CANO={self.acc_no}, 모의={self.mock}")
|
||||
return None
|
||||
|
||||
def inquire_daily_itemchartprice(self, stock_code, start_ymd, end_ymd, period="D", adj="1"):
|
||||
@@ -777,9 +788,26 @@ class MattermostBot:
|
||||
# 늘림목 트레이딩 봇
|
||||
# ============================================================
|
||||
class LongTradingBot:
|
||||
"""늘림목 전략 트레이딩 봇 - 장기 보유 + 분할 매수"""
|
||||
"""늘림목 전략 트레이딩 봇 - 장기 보유 + 분할 매수
|
||||
(단타 봇과 동일 env 키 사용: KIS_MOCK, KIS_APP_KEY_MOCK/REAL, KIS_ACCOUNT_NO_MOCK/REAL, TOTAL_DEPOSIT, MAX_STOCKS 등)
|
||||
"""
|
||||
def __init__(self):
|
||||
self.db = db
|
||||
# 자산·리포트·루프용 변수 (단타 봇과 동일 이름)
|
||||
self.today_date = dt.now().strftime("%Y-%m-%d")
|
||||
self.morning_report_sent = False
|
||||
self.closing_report_sent = False
|
||||
self.final_report_sent = False
|
||||
self.ai_report_sent = False
|
||||
self.start_day_asset = 0
|
||||
self.current_cash = 0
|
||||
self.current_total_asset = 0
|
||||
self.d2_excc_amt = 0 # D+2 예수금 (전일 정산 수령 예정, 매매 가능 판단 참고)
|
||||
self.total_deposit = get_env_float("TOTAL_DEPOSIT", 0)
|
||||
self.max_stocks = get_env_int("MAX_STOCKS", 5)
|
||||
# 일일 매수 한도 (0 = 총자산의 30% 자동, 양수면 해당 금액 한도). 누적은 DB buy_execution_log(산 날짜 기준)
|
||||
self.daily_buy_limit = get_env_float("DAILY_BUY_LIMIT", 0)
|
||||
|
||||
self.client = KISClientWithOrder()
|
||||
|
||||
# Mattermost 초기화
|
||||
@@ -801,11 +829,14 @@ class LongTradingBot:
|
||||
|
||||
# 손절/익절
|
||||
self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.30)
|
||||
self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.50)
|
||||
self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.10)
|
||||
# 매매 루프 체크 간격 (늘림목은 주가 변동성 적음 → 5~10분 권장)
|
||||
self.loop_interval_min_low = get_env_float("LOOP_INTERVAL_MIN_LOW", 5)
|
||||
self.loop_interval_min_high = get_env_float("LOOP_INTERVAL_MIN_HIGH", 10)
|
||||
|
||||
# DB에서 활성 트레이드 로드
|
||||
# DB에서 활성 트레이드 로드 (늘림목만: LONG_INITIAL, LONG_DCA - 단타와 섞이지 않도록)
|
||||
self.holdings = {}
|
||||
active_trades = self.db.get_active_trades()
|
||||
active_trades = self.db.get_active_trades(strategy_prefix="LONG")
|
||||
for code, trade in active_trades.items():
|
||||
# 분할 매수 정보 복원 (간단화)
|
||||
self.holdings[code] = {
|
||||
@@ -821,12 +852,13 @@ class LongTradingBot:
|
||||
self.watchlist_path = SCRIPT_DIR / "long_term_watchlist.json"
|
||||
self.watchlist = self._load_watchlist()
|
||||
|
||||
# 초기 자산 조회
|
||||
self._update_assets()
|
||||
# 초기 자산 조회는 하지 않음 → 루프 진입 시 "예수금 될 때까지 3초 간격 재시도"로만 수행 (중복/EGW00201 방지)
|
||||
|
||||
# 비동기 태스크 관리
|
||||
self._report_task = None
|
||||
self._asset_task = None
|
||||
# 잔고 API 초당 제한(EGW00201) 방지: 마지막 조회 시각
|
||||
self._last_balance_fetch_time = 0.0
|
||||
|
||||
def _load_watchlist(self):
|
||||
"""관심 종목 리스트 로드"""
|
||||
@@ -842,26 +874,92 @@ class LongTradingBot:
|
||||
logger.error(f"관심 종목 로드 실패: {e}")
|
||||
return []
|
||||
|
||||
def _merge_watchlist_holdings_from_balance(self, balance):
|
||||
"""
|
||||
watchlist에 strategy "LONG" 또는 use "dca"로 넣어둔 종목 중,
|
||||
계좌에는 보유 중인데 DB(봇)에는 없는 종목을 holdings에 합침 (단타와 동일 inquire-balance → output1=종목 리스트).
|
||||
"""
|
||||
positions_list = balance.get("output1") or []
|
||||
if isinstance(positions_list, dict):
|
||||
positions_list = [positions_list]
|
||||
if not isinstance(positions_list, list):
|
||||
positions_list = []
|
||||
watchlist_dca_codes = {
|
||||
(item.get("code") or "").strip()
|
||||
for item in self.watchlist
|
||||
if (item.get("strategy") == "LONG" or item.get("use") == "dca")
|
||||
}
|
||||
watchlist_dca_codes.discard("")
|
||||
if not watchlist_dca_codes:
|
||||
return
|
||||
for row in positions_list:
|
||||
code = (row.get("pdno") or "").strip()
|
||||
if not code or code in self.holdings or code not in watchlist_dca_codes:
|
||||
continue
|
||||
try:
|
||||
qty = int(float(row.get("hldg_qty") or row.get("ord_psbl_qty") or 0))
|
||||
if qty <= 0:
|
||||
continue
|
||||
evlu_amt = float(row.get("evlu_amt") or 0)
|
||||
evlu_pfls = float(row.get("evlu_pfls_amt") or 0)
|
||||
pchs_avg = row.get("pchs_avg_pric")
|
||||
if pchs_avg is not None and str(pchs_avg).strip() != "":
|
||||
avg_price = abs(float(str(pchs_avg).replace(",", "").strip()))
|
||||
else:
|
||||
# 매입금액 = 평가금액 - 평가손익 → 평단 = 매입금액/수량
|
||||
cost = evlu_amt - evlu_pfls
|
||||
avg_price = cost / qty if qty else 0
|
||||
if avg_price <= 0:
|
||||
continue
|
||||
name = (row.get("prdt_name") or row.get("prdt_name_eng") or code).strip()
|
||||
self.holdings[code] = {
|
||||
"buy_prices": [avg_price],
|
||||
"qtys": [qty],
|
||||
"total_qty": qty,
|
||||
"avg_price": avg_price,
|
||||
"first_buy_date": dt.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"name": name or code,
|
||||
}
|
||||
logger.info(
|
||||
f"📂 [늘림목] watchlist LONG 반영: {name} ({code}) {qty}주 평단 {avg_price:,.0f}원 (계좌 보유)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"watchlist 병합 스킵 {code}: {e}")
|
||||
|
||||
def _update_assets(self):
|
||||
"""자산 정보 업데이트"""
|
||||
"""자산 정보 업데이트 (단타와 동일: inquire-balance 응답에서 output2로 예수금)"""
|
||||
try:
|
||||
balance = self.client.get_account_evaluation()
|
||||
if balance:
|
||||
output1 = balance.get("output1", {})
|
||||
self.current_cash = float(output1.get("dnca_tot_amt", 0))
|
||||
# 보유 종목 평가액 계산
|
||||
holdings_value = 0
|
||||
for code, holding in self.holdings.items():
|
||||
if not balance:
|
||||
return
|
||||
# watchlist에 LONG/dca로 넣어둔 계좌 보유 종목을 holdings에 합침
|
||||
self._merge_watchlist_holdings_from_balance(balance)
|
||||
# 단타와 동일: output2=예수금 요약
|
||||
out2 = balance.get("output2")
|
||||
if isinstance(out2, list) and out2:
|
||||
out2 = out2[0]
|
||||
out2 = out2 if isinstance(out2, dict) else {}
|
||||
ord_psbl = out2.get("ord_psbl_cash") or out2.get("dnca_tot_amt")
|
||||
if ord_psbl is not None and str(ord_psbl).strip() != "":
|
||||
self.current_cash = float(str(ord_psbl).replace(",", "").strip())
|
||||
# 보유 종목 평가액 (output1=종목별 잔고 리스트에서 evlu_amt 사용, 없으면 현재가 조회)
|
||||
output1_list = balance.get("output1") or []
|
||||
if isinstance(output1_list, dict):
|
||||
output1_list = [output1_list]
|
||||
holdings_value = 0
|
||||
for code, holding in self.holdings.items():
|
||||
for item in output1_list:
|
||||
if (item.get("pdno") or "").strip() == code:
|
||||
holdings_value += float(item.get("evlu_amt", 0) or 0)
|
||||
break
|
||||
else:
|
||||
price_data = self.client.inquire_price(code)
|
||||
if price_data:
|
||||
current_price = abs(float(price_data.get("stck_prpr", 0)))
|
||||
holdings_value += current_price * holding["total_qty"]
|
||||
|
||||
self.current_total_asset = self.current_cash + holdings_value
|
||||
|
||||
# 오늘 첫 실행 시 시작 자산 저장
|
||||
if self.start_day_asset == 0:
|
||||
self.start_day_asset = self.current_total_asset
|
||||
holdings_value += abs(float(price_data.get("stck_prpr", 0) or 0)) * holding["total_qty"]
|
||||
self.current_total_asset = self.current_cash + holdings_value
|
||||
if self.start_day_asset == 0:
|
||||
self.start_day_asset = self.current_total_asset
|
||||
self._last_balance_fetch_time = time.time()
|
||||
except Exception as e:
|
||||
logger.error(f"자산 정보 업데이트 실패: {e}")
|
||||
|
||||
@@ -873,39 +971,53 @@ class LongTradingBot:
|
||||
"""
|
||||
try:
|
||||
balance = self.client.get_account_evaluation()
|
||||
if balance:
|
||||
output1 = balance.get("output1", {})
|
||||
new_cash = float(output1.get("dnca_tot_amt", 0))
|
||||
if not balance:
|
||||
logger.warning("💵 [예수금] 잔고 API 응답 없음 → 예수금 갱신 스킵 (TR=VTTC8434R/TTTC8434R 확인)")
|
||||
return False
|
||||
self._merge_watchlist_holdings_from_balance(balance)
|
||||
# 단타와 동일: output2=예수금 요약, output1=종목별 잔고 리스트
|
||||
def _cash_block(obj):
|
||||
if not obj:
|
||||
return {}
|
||||
if isinstance(obj, list) and obj:
|
||||
return obj[0]
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
def _parse_amt(v):
|
||||
if v is None or str(v).strip() == "":
|
||||
return None
|
||||
return float(str(v).replace(",", "").strip())
|
||||
out2 = _cash_block(balance.get("output2"))
|
||||
ord_psbl_val = _parse_amt(out2.get("ord_psbl_cash"))
|
||||
dnca_tot_val = _parse_amt(out2.get("dnca_tot_amt")) or 0
|
||||
new_cash = ord_psbl_val if ord_psbl_val is not None else dnca_tot_val
|
||||
if new_cash > 0 or self.current_cash == 0:
|
||||
self.current_cash = new_cash
|
||||
prvs_rcdl = _parse_amt(out2.get("prvs_rcdl_excc_amt"))
|
||||
self.d2_excc_amt = prvs_rcdl if prvs_rcdl is not None else 0
|
||||
|
||||
# 예수금 업데이트 (0원이 아닐 때만, 또는 초기화 시)
|
||||
if new_cash > 0 or self.current_cash == 0:
|
||||
self.current_cash = new_cash
|
||||
# 보유 종목 평가액: inquire-balance는 output1이 종목별 잔고(리스트)
|
||||
holdings_value = 0
|
||||
output1_list = balance.get("output1") or []
|
||||
if isinstance(output1_list, dict):
|
||||
output1_list = [output1_list]
|
||||
for code, holding in self.holdings.items():
|
||||
for item in output1_list:
|
||||
if (item.get("pdno") or "").strip() == code:
|
||||
holdings_value += float(item.get("evlu_amt", 0) or 0)
|
||||
break
|
||||
else:
|
||||
price_data = self.client.inquire_price(code)
|
||||
if price_data:
|
||||
holdings_value += abs(float(price_data.get("stck_prpr", 0) or 0)) * holding["total_qty"]
|
||||
|
||||
# 보유 종목 평가액 계산 (빠른 버전: 로컬 holdings 사용)
|
||||
holdings_value = 0
|
||||
for code, holding in self.holdings.items():
|
||||
# output2에서 보유 종목 정보 확인 (더 빠름)
|
||||
output2 = balance.get("output2", [])
|
||||
for item in output2:
|
||||
if item.get("pdno", "").strip() == code:
|
||||
# API에서 받은 평가액 사용
|
||||
evlu_amt = float(item.get("evlu_amt", 0)) # 평가금액
|
||||
holdings_value += evlu_amt
|
||||
break
|
||||
else:
|
||||
# output2에 없으면 로컬 계산 (fallback)
|
||||
price_data = self.client.inquire_price(code)
|
||||
if price_data:
|
||||
current_price = abs(float(price_data.get("stck_prpr", 0)))
|
||||
holdings_value += current_price * holding["total_qty"]
|
||||
|
||||
# 총자산 계산 (손익 반영)
|
||||
self.current_total_asset = self.current_cash + holdings_value
|
||||
if profit_val != 0:
|
||||
self.current_total_asset += profit_val
|
||||
|
||||
logger.debug(f"💵 [경량갱신] 예수금: {self.current_cash:,.0f}원 | 총자산: {self.current_total_asset:,.0f}원")
|
||||
return True
|
||||
self.current_total_asset = self.current_cash + holdings_value
|
||||
if profit_val != 0:
|
||||
self.current_total_asset += profit_val
|
||||
if self.start_day_asset == 0:
|
||||
self.start_day_asset = self.current_total_asset
|
||||
self._last_balance_fetch_time = time.time()
|
||||
logger.debug(f"💵 [경량갱신] 예수금: {self.current_cash:,.0f}원 | 총자산: {self.current_total_asset:,.0f}원")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 경량 갱신 실패: {e}")
|
||||
return False
|
||||
@@ -914,6 +1026,20 @@ class LongTradingBot:
|
||||
"""예수금만 빠르게 업데이트 (하위 호환성용, _update_account_light 사용 권장)"""
|
||||
return self._update_account_light(profit_val=0)
|
||||
|
||||
def _ensure_account_balance(self, profit_val=0, min_interval_sec=0):
|
||||
"""예수금 조회 성공할 때까지 재시도. min_interval_sec>0이면 그 초 미만일 때 캐시 사용(EGW00201 방지). 매수 직전에는 0으로 호출해 항상 최신 조회(단타와 공유 예수금)."""
|
||||
if min_interval_sec > 0:
|
||||
now = time.time()
|
||||
if now - getattr(self, "_last_balance_fetch_time", 0) < min_interval_sec:
|
||||
if getattr(self, "_last_balance_fetch_time", 0) > 0:
|
||||
return True
|
||||
retry_interval_sec = 3
|
||||
while True:
|
||||
if self._update_account_light(profit_val=profit_val):
|
||||
return True
|
||||
logger.warning(f"💵 [예수금] 조회 실패 → {retry_interval_sec}초 후 재시도 (성공할 때까지)")
|
||||
time.sleep(retry_interval_sec)
|
||||
|
||||
def _seconds_until_next_5min(self):
|
||||
"""다음 5분 정각까지 남은 초 계산"""
|
||||
now = dt.now()
|
||||
@@ -1309,7 +1435,7 @@ DCA_INTERVALS=X,-X.XX,-X.XX
|
||||
# 외국인/기관 점수 추가
|
||||
score += investor_score
|
||||
|
||||
return {
|
||||
analysis_result = {
|
||||
"code": code,
|
||||
"name": name,
|
||||
"current_price": current_price,
|
||||
@@ -1332,8 +1458,8 @@ DCA_INTERVALS=X,-X.XX,-X.XX
|
||||
),
|
||||
}
|
||||
|
||||
# 필터링 로그 출력
|
||||
if not analysis["is_buyable"]:
|
||||
# 필터링 로그 출력 (return 전에 실행되도록 수정)
|
||||
if not analysis_result["is_buyable"]:
|
||||
reasons = []
|
||||
if per and per > self.max_per:
|
||||
reasons.append(f"PER {per:.1f} > {self.max_per}")
|
||||
@@ -1345,12 +1471,10 @@ DCA_INTERVALS=X,-X.XX,-X.XX
|
||||
reasons.append(f"점수 {score:.1f} < 60")
|
||||
if investor_score < -10:
|
||||
reasons.append(f"수급 점수 {investor_score} < -10")
|
||||
|
||||
if reasons:
|
||||
logger.info(f"🔍 [Pass-밸류] {name} {code}: {', '.join(reasons)}")
|
||||
|
||||
return {
|
||||
}
|
||||
return analysis_result
|
||||
except Exception as e:
|
||||
logger.error(f"종목 분석 실패({code}): {e}")
|
||||
return None
|
||||
@@ -1371,15 +1495,14 @@ DCA_INTERVALS=X,-X.XX,-X.XX
|
||||
# 평단가 대비 하락률 계산
|
||||
drop_pct = (current_price - avg_price) / avg_price
|
||||
|
||||
# 분할 매수 구간 확인
|
||||
# 분할 매수 구간 확인 (구간별 1회만 매수 - i번째 구간은 len(buy_prices) > i 이면 이미 매수 완료)
|
||||
buy_prices = holding.get("buy_prices", [])
|
||||
for i, interval in enumerate(self.dca_intervals):
|
||||
if drop_pct <= interval:
|
||||
# 이미 이 구간에서 매수했는지 확인
|
||||
buy_prices = holding.get("buy_prices", [])
|
||||
if buy_prices and min(buy_prices) <= current_price * 1.02: # 2% 오차 범위 내
|
||||
continue # 이미 매수한 구간
|
||||
# i번째 DCA 구간 이미 매수했는지 확인 (매수 횟수로 구간 추적)
|
||||
if len(buy_prices) > i:
|
||||
continue # 이미 이 구간 매수 완료
|
||||
|
||||
# 분할 매수 실행
|
||||
amount = self.dca_amounts[i] if i < len(self.dca_amounts) else self.dca_amounts[-1]
|
||||
qty = int(amount / current_price)
|
||||
if qty > 0:
|
||||
@@ -1438,6 +1561,51 @@ DCA_INTERVALS=X,-X.XX,-X.XX
|
||||
|
||||
return sell_signals
|
||||
|
||||
def _check_daily_limits(self, amount):
|
||||
"""일일 한도 체크 - 매수 전 반드시 호출. '하루' = 산 날짜(buy_date) 기준, DB에서 조회."""
|
||||
today = dt.now().strftime("%Y-%m-%d")
|
||||
if today != self.today_date:
|
||||
self.today_date = today
|
||||
self.morning_report_sent = False
|
||||
self.closing_report_sent = False
|
||||
self.final_report_sent = False
|
||||
|
||||
# 오늘(산 시점 날짜) 매수 누적은 DB에서 조회 (재시작해도 유지)
|
||||
daily_buy_amount, daily_buy_count = self.db.get_daily_buy_amount(today, "LONG")
|
||||
|
||||
if len(self.holdings) >= self.max_stocks:
|
||||
logger.info(f"🚫 최대 종목 수 도달: {len(self.holdings)}/{self.max_stocks}개")
|
||||
return False, "max_stocks"
|
||||
|
||||
daily_limit = (
|
||||
self.daily_buy_limit
|
||||
if self.daily_buy_limit > 0
|
||||
else self.current_total_asset * 0.30
|
||||
)
|
||||
if daily_buy_amount + amount > daily_limit:
|
||||
remain = daily_limit - daily_buy_amount
|
||||
logger.info(
|
||||
f"🚫 일일 매수 한도 도달: "
|
||||
f"누적 {daily_buy_amount:,.0f}원 / 한도 {daily_limit:,.0f}원 (잔여 {remain:,.0f}원)"
|
||||
)
|
||||
self.send_mm(
|
||||
f"⛔ **일일 매수 한도 도달**\n"
|
||||
f"- 오늘 매수: {daily_buy_amount:,.0f}원\n"
|
||||
f"- 한도: {daily_limit:,.0f}원\n"
|
||||
f"- 오늘 추가 매수 중단 (매도/손절 감시는 계속)"
|
||||
)
|
||||
return False, "daily_limit"
|
||||
return True, "ok"
|
||||
|
||||
def _after_buy(self, code, amount, name, strategy, qty):
|
||||
"""매수 성공 후 DB에 체결 이력 저장 (산 시점 날짜 기준 → 재시작해도 한도 유지)"""
|
||||
self.db.insert_buy_execution(code=code, name=name, strategy=strategy, amount=amount, qty=qty)
|
||||
today = dt.now().strftime("%Y-%m-%d")
|
||||
daily_buy_amount, daily_buy_count = self.db.get_daily_buy_amount(today, "LONG")
|
||||
logger.info(
|
||||
f"📊 일일 매수 현황: {daily_buy_count}건 / {daily_buy_amount:,.0f}원 (오늘, DB 기준)"
|
||||
)
|
||||
|
||||
def execute_buy(self, signal, is_dca=False):
|
||||
"""매수 실행"""
|
||||
code = signal["code"]
|
||||
@@ -1445,13 +1613,21 @@ DCA_INTERVALS=X,-X.XX,-X.XX
|
||||
price = signal["price"]
|
||||
qty = signal["qty"]
|
||||
|
||||
# 🔥 매수 직전 예수금 실시간 확인 (30분마다 업데이트된 값이 부정확할 수 있음)
|
||||
if not self._update_account_light(profit_val=0):
|
||||
logger.warning(f"⚠️ [{name}] 예수금 조회 실패 -> 매수 스킵")
|
||||
return False
|
||||
# 🔥 매수 직전 예수금 실시간 조회 (단타와 같이 돌릴 때 공유 예수금 반영, 캐시 없이 항상 조회)
|
||||
self._ensure_account_balance(profit_val=0, min_interval_sec=0)
|
||||
|
||||
# 예수금 부족 체크 (수수료 포함 여유분 5% 고려)
|
||||
required_amount = price * qty * 1.05 # 수수료 포함
|
||||
order_amount = int(price * qty)
|
||||
# 1회 주문 최소 10만 원 (수수료 비율 감안). 0원이면 가격/수량 확인 필요
|
||||
if order_amount < 100_000:
|
||||
logger.warning(
|
||||
f"⚠️ [{name}] 1회 주문 최소 10만 원 미만: {order_amount:,}원 (price={price:,.0f}, qty={qty}) -> 매수 스킵"
|
||||
)
|
||||
return False
|
||||
ok, reason = self._check_daily_limits(order_amount)
|
||||
if not ok:
|
||||
return False
|
||||
if self.current_cash < required_amount:
|
||||
logger.warning(
|
||||
f"⚠️ [{name}] 예수금 부족: 필요 {required_amount:,.0f}원 / "
|
||||
@@ -1498,6 +1674,13 @@ DCA_INTERVALS=X,-X.XX,-X.XX
|
||||
"buy_date": holding["first_buy_date"],
|
||||
})
|
||||
|
||||
self._after_buy(
|
||||
code,
|
||||
int(price * qty),
|
||||
name=name,
|
||||
strategy="LONG_DCA" if is_dca else "LONG_INITIAL",
|
||||
qty=qty,
|
||||
)
|
||||
action = "늘림목 매수" if is_dca else "초기 매수"
|
||||
logger.info(f"💰 [{action}] {name} ({code}): {price:,.0f}원 × {qty}주 (평단: {holding['avg_price']:,.0f}원)")
|
||||
return True
|
||||
@@ -1567,7 +1750,11 @@ DCA_INTERVALS=X,-X.XX,-X.XX
|
||||
def _sync_trading_loop(self):
|
||||
"""동기 매매 루프 (메인 로직) - 백그라운드 작업과 분리"""
|
||||
logger.info("📈 매매 루프 시작 (동기 모드)")
|
||||
|
||||
# 최초 예수금 조회: 성공할 때까지 3초 간격 재시도 (매매는 그 다음 5~10분 간격)
|
||||
self._ensure_account_balance(profit_val=0)
|
||||
logger.info(
|
||||
f"💵 예수금 조회 완료 → 매매 루프 진입 | 예수금(주문가능): {self.current_cash:,.0f}원 | D+2: {self.d2_excc_amt:,.0f}원"
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
now = dt.now()
|
||||
@@ -1645,8 +1832,13 @@ DCA_INTERVALS=X,-X.XX,-X.XX
|
||||
time.sleep(random.uniform(2, 4))
|
||||
break # 한 번에 하나씩만
|
||||
|
||||
# 대기
|
||||
time.sleep(random.uniform(10, 15))
|
||||
# 대기 (늘림목은 변동성 적음 → 5~10분 간격, env: LOOP_INTERVAL_MIN_LOW / LOOP_INTERVAL_MIN_HIGH)
|
||||
wait_sec = random.uniform(
|
||||
max(60, self.loop_interval_min_low * 60),
|
||||
max(60, self.loop_interval_min_high * 60),
|
||||
)
|
||||
logger.info(f"⏳ 다음 체크까지 {wait_sec/60:.1f}분 대기 (로그 멈춤 아님)")
|
||||
time.sleep(wait_sec)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("⏹ 봇 종료")
|
||||
@@ -1660,7 +1852,7 @@ DCA_INTERVALS=X,-X.XX,-X.XX
|
||||
logger.error(f"❌ 루프 에러: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
time.sleep(10)
|
||||
time.sleep(60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -42,7 +42,7 @@ LOG_GREEN = "\033[92m" # 통과
|
||||
LOG_CYAN = "\033[96m" # 강조
|
||||
LOG_RESET = "\033[0m"
|
||||
|
||||
# DB 초기화
|
||||
# DB 초기화 (스크립트所在 디렉터리 기준 경로)
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
db = TradeDB(db_path=str(SCRIPT_DIR / "quant_bot.db"))
|
||||
|
||||
@@ -1162,12 +1162,11 @@ class KISClient:
|
||||
exclude_spec_etn_leverage: bool,
|
||||
) -> list:
|
||||
"""
|
||||
거래량순위 API 연속 조회 (tr_cont)로 limit건까지 수집.
|
||||
API가 한 번에 20~30건만 주므로, tr_cont='M'이면 다음 페이지 요청 반복.
|
||||
거래량순위 API 1회 조회 (한투 volume-rank API는 다음 페이지 tr_cont 미지원 → 1회만 호출).
|
||||
"""
|
||||
path = "/uapi/domestic-stock/v1/quotations/volume-rank"
|
||||
tr_id = "FHPST01710000"
|
||||
base = {
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": market,
|
||||
"FID_COND_SCR_DIV_CODE": "20171",
|
||||
"FID_INPUT_ISCD": "0000",
|
||||
@@ -1180,68 +1179,22 @@ class KISClient:
|
||||
"FID_VOL_CNT": "0",
|
||||
"FID_INPUT_DATE_1": "",
|
||||
}
|
||||
accumulated = []
|
||||
tr_cont = ""
|
||||
max_pages = 20 # 20페이지 이상이면 중단 (과부하 방지)
|
||||
page = 0
|
||||
try:
|
||||
while len(accumulated) < limit and page < max_pages:
|
||||
params = {**base}
|
||||
time.sleep(0.5)
|
||||
# 연속 조회 시 tr_cont는 요청 헤더로 전달 (한투 문서: Request Header tr_cont=N)
|
||||
r = self._get(path, tr_id, params, tr_cont=tr_cont if tr_cont else None)
|
||||
if r.status_code != 200:
|
||||
break
|
||||
j = r.json()
|
||||
if j.get("rt_cd") != "0":
|
||||
break
|
||||
output = j.get("output", [])
|
||||
if exclude_spec_etn_leverage:
|
||||
output = self._filter_rank_by_valid_stock(output)
|
||||
# 2차 이상 수신 시: 이번 output이 이미 누적된 종목과 완전 동일하면 서버가 같은 페이지를 반복 준 것 → 중복 누적·추가 요청 중단
|
||||
def _codes_from_list(lst):
|
||||
s = set()
|
||||
for item in lst:
|
||||
c = (item.get("stk_cd") or item.get("mksc_shrn_iscd") or item.get("code") or "").strip()
|
||||
if c:
|
||||
s.add(c)
|
||||
return s
|
||||
if page >= 1 and output:
|
||||
already = _codes_from_list(accumulated)
|
||||
this_codes = _codes_from_list(output)
|
||||
if this_codes and this_codes <= already:
|
||||
logger.info(f" 📡 [순위API] 2차 수신 {len(output)}건은 1차와 동일(중복) → 연속조회 중단 (API가 다음 페이지 미지원 또는 동일 데이터 반환)")
|
||||
break
|
||||
accumulated.extend(output)
|
||||
# 연속 조회: tr_cont는 HTTP 응답 헤더에 있음 (한투 문서). 소문자/대문자 모두 확인
|
||||
tr_cont_resp = ""
|
||||
for k, v in (r.headers or {}).items():
|
||||
if k.strip().lower() == "tr_cont" and v:
|
||||
tr_cont_resp = v.strip() if isinstance(v, str) else str(v)
|
||||
break
|
||||
if not tr_cont_resp:
|
||||
tr_cont_resp = (r.headers.get("tr_cont") or r.headers.get("TR_CONT") or "").strip()
|
||||
if isinstance(tr_cont_resp, str):
|
||||
tr_cont_resp = tr_cont_resp.strip()
|
||||
# 페이지네이션 동작 확인용 INFO 로그 (기본 로그 레벨에서 보이도록)
|
||||
header_keys = list((r.headers or {}).keys())
|
||||
logger.info(f" 📡 [순위API] 1차 수신 {len(output)}건, 누적 {len(accumulated)}건 | 응답 tr_cont='{tr_cont_resp}' | 헤더키: {header_keys}")
|
||||
# tr_cont=M이면 다음 페이지 있음. 또는 첫 페이지에서 누적이 limit 미만이면 한 번 더 시도 (서버가 tr_cont 없이도 다음 페이지 지원하는 경우 대비)
|
||||
if tr_cont_resp == "M":
|
||||
tr_cont = "N"
|
||||
page += 1
|
||||
logger.info(f" 📡 [연속조회] tr_cont=M → tr_cont=N으로 다음 페이지 요청 (페이지 {page})")
|
||||
elif page == 0 and len(output) > 0 and len(accumulated) < limit:
|
||||
tr_cont = "N"
|
||||
page += 1
|
||||
logger.info(f" 📡 [연속조회] 누적 {len(accumulated)}건 < {limit}건 → tr_cont=N으로 다음 페이지 1회 시도")
|
||||
else:
|
||||
break
|
||||
time.sleep(random.uniform(0.8, 1.5))
|
||||
return accumulated[:limit]
|
||||
time.sleep(0.5)
|
||||
r = self._get(path, tr_id, params, tr_cont=None)
|
||||
if r.status_code != 200:
|
||||
return []
|
||||
j = r.json()
|
||||
if j.get("rt_cd") != "0":
|
||||
return []
|
||||
output = j.get("output", [])
|
||||
if exclude_spec_etn_leverage:
|
||||
output = self._filter_rank_by_valid_stock(output)
|
||||
logger.info(f" 📡 [순위API] 수신 {len(output)}건 (다음페이지 미지원 → 1회만 호출)")
|
||||
return output[:limit]
|
||||
except Exception as e:
|
||||
logger.debug(f"거래량순위 연속 조회 실패: {e}")
|
||||
return accumulated[:limit]
|
||||
logger.debug(f"거래량순위 조회 실패: {e}")
|
||||
return []
|
||||
|
||||
def get_volume_rank(
|
||||
self,
|
||||
@@ -1250,7 +1203,7 @@ class KISClient:
|
||||
exclude_spec_etn_leverage: bool = True,
|
||||
):
|
||||
"""
|
||||
거래량순위 조회 [v1_국내주식-047] (연속 조회로 limit건까지 수집)
|
||||
거래량순위 조회 [v1_국내주식-047] (1회 호출, API가 반환한 건수만큼 수집)
|
||||
"""
|
||||
try:
|
||||
output = self._fetch_volume_rank_paged(
|
||||
@@ -1530,9 +1483,9 @@ class ShortTradingBot:
|
||||
self.d2_excc_amt = 0 # D+2 예수금 (output2 prvs_rcdl_excc_amt)
|
||||
self.total_deposit = get_env_float("TOTAL_DEPOSIT", 0)
|
||||
|
||||
# DB에서 활성 트레이드 로드
|
||||
# DB에서 활성 트레이드 로드 (단타만: SHORT_% - 늘림목과 섞이지 않도록)
|
||||
self.holdings = {}
|
||||
active_trades = self.db.get_active_trades()
|
||||
active_trades = self.db.get_active_trades(strategy_prefix="SHORT")
|
||||
for code, trade in active_trades.items():
|
||||
self.holdings[code] = {
|
||||
"buy_price": trade.get("avg_buy_price", 0),
|
||||
@@ -1681,6 +1634,37 @@ class ShortTradingBot:
|
||||
except Exception as e:
|
||||
logger.warning(f" ⚠️ [거래대금순위] 수집 실패: {e}")
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 4-2. 테마/인기 종목 (단타 묘미 - 테마별로 빵 뜨는 종목 보너스)
|
||||
# 상승률·거래증가율 상위 = 테마성 수요 대리 지표 (추후 테마 API 연동 시 교체 가능)
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
try:
|
||||
theme_hot = self.client.get_price_change_rank(market="J", sort_type="1", limit=20)
|
||||
if theme_hot:
|
||||
for idx, item in enumerate(theme_hot):
|
||||
code = item.get("stk_cd", "").strip() or item.get("code", "").strip()
|
||||
if not code or len(code) != 6:
|
||||
continue
|
||||
bonus = (20 - idx) / 20.0 * 0.15 # 순위별 보너스 최대 +0.15
|
||||
if code in all_candidates:
|
||||
all_candidates[code]["bonus_score"] += bonus
|
||||
else:
|
||||
price_data = self.client.inquire_price(code)
|
||||
if price_data:
|
||||
current_price = abs(float(price_data.get("stck_prpr", 0) or 0))
|
||||
if current_price > 0 and (not slot_money or current_price <= slot_money):
|
||||
all_candidates[code] = {
|
||||
"code": code,
|
||||
"name": item.get("stk_nm", code),
|
||||
"price": current_price,
|
||||
"base_score": 0.0,
|
||||
"bonus_score": bonus,
|
||||
"from_ant": False,
|
||||
}
|
||||
logger.info(f" ✅ [테마/인기] 상승률 상위 20개 보너스 반영 (테마성 수요 대리)")
|
||||
except Exception as e:
|
||||
logger.warning(f" ⚠️ [테마/인기] 수집 실패: {e}")
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 5. 외국인/기관 순매수 - 보너스 +0.3 (투자자 동향 기반)
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
@@ -2491,6 +2475,13 @@ class ShortTradingBot:
|
||||
logger.warning(" ⚠️ [개미털기] 스캔 대상 0개 → 스캔 생략 (API에서 종목 리스트를 받지 못함)")
|
||||
return []
|
||||
|
||||
# 후보 등록 방식: RELAXED면 낙폭+회복만 통과한 상위 N명만 DB 등록 (피뢰침/RSI/MA20은 매수 시점에 적용)
|
||||
# 기본 True → 후보 풀 확대(6개 수준), False면 기존처럼 전 필터 통과한 종목만 등록
|
||||
relaxed = get_env_bool("RELAXED_CANDIDATE_SCAN", False)
|
||||
top_n = get_env_int("CANDIDATE_LIST_TOP_N", 6)
|
||||
if relaxed:
|
||||
logger.info(f" 📌 [RELAXED 모드] 낙폭+회복 통과만으로 후보 수집 → 상위 {top_n}명만 DB 등록 (피뢰침/RSI/MA20은 매수 시점 적용)")
|
||||
|
||||
# 스캔 대상 리스트를 거래량/낙폭 체크 전에 한 번 출력 (종목명 · 코드)
|
||||
# 참고: 위 [스캔유니버스] 6소스(거래량·거래대금·회전율·등락률상승·등락률하락·거래증가율) 합산 → 동일 개미털기 필터 적용
|
||||
logger.info(f" 📋 [개미털기 스캔 대상] {len(scan_list)}개 (6소스 합산, 종목명 · 코드)")
|
||||
@@ -2503,7 +2494,7 @@ class ShortTradingBot:
|
||||
time.sleep(random.uniform(0.3, 0.6))
|
||||
execution_strength_map = self.client.get_execution_strength_map(market="J", limit=200)
|
||||
if execution_strength_map:
|
||||
logger.info(f" 📡 [체결강도] 상위 {len(execution_strength_map)}종목 로드 (통과 시 100+ → +10점, 120+ → +20점)")
|
||||
logger.info(f" 📡 [체결강도] 상위 {len(execution_strength_map)}종목 로드 (통과 시 100+ → +1점, 120+ → +2점)")
|
||||
except Exception as e:
|
||||
logger.debug(f"체결강도 맵 로드 스킵: {e}")
|
||||
|
||||
@@ -2598,52 +2589,54 @@ class ShortTradingBot:
|
||||
logger.warning(f" ⚠️ SK증권(001510) 회복률부족으로 탈락: 회복률={recovery_pos*100:.1f}%, 기준={self.min_recovery_ratio*100:.0f}%")
|
||||
continue
|
||||
|
||||
# [필터 3] 피뢰침 방지 - 고점 추격 매수 방지
|
||||
high_chase_threshold = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", 0.96)
|
||||
if current_price >= high_price * high_chase_threshold:
|
||||
filter_counts["피뢰침(고점근접)"] += 1
|
||||
drop_from_high = (high_price - current_price) / high_price * 100
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침] {name} {code}: 고점 대비 {drop_from_high:.1f}% 조정 부족 (최소 4% 필요){LOG_RESET}")
|
||||
continue
|
||||
# [필터 3~6] RELAXED 모드가 아닐 때만 적용 (후보 풀 확대 시 등록은 넓게, 매수 시점에 엄격 적용)
|
||||
if not relaxed:
|
||||
# [필터 3] 피뢰침 방지 - 고점 추격 매수 방지
|
||||
high_chase_threshold = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", 0.96)
|
||||
if current_price >= high_price * high_chase_threshold:
|
||||
filter_counts["피뢰침(고점근접)"] += 1
|
||||
drop_from_high = (high_price - current_price) / high_price * 100
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침] {name} {code}: 고점 대비 {drop_from_high:.1f}% 조정 부족 (최소 4% 필요){LOG_RESET}")
|
||||
continue
|
||||
|
||||
# [필터 4] 피뢰침 방지 - 급등주 제외
|
||||
if low_price > 0:
|
||||
daily_change_pct = (high_price - low_price) / low_price * 100
|
||||
else:
|
||||
daily_change_pct = 0
|
||||
max_daily_change = get_env_float("MAX_DAILY_CHANGE_PCT", 20.0)
|
||||
if daily_change_pct > max_daily_change:
|
||||
filter_counts["피뢰침(급등주)"] += 1
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침] {name} {code}: 당일 변동폭 {daily_change_pct:.1f}% 과도 (최대 {max_daily_change}%){LOG_RESET}")
|
||||
continue
|
||||
# [필터 4] 피뢰침 방지 - 급등주 제외
|
||||
if low_price > 0:
|
||||
daily_change_pct = (high_price - low_price) / low_price * 100
|
||||
else:
|
||||
daily_change_pct = 0
|
||||
max_daily_change = get_env_float("MAX_DAILY_CHANGE_PCT", 20.0)
|
||||
if daily_change_pct > max_daily_change:
|
||||
filter_counts["피뢰침(급등주)"] += 1
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침] {name} {code}: 당일 변동폭 {daily_change_pct:.1f}% 과도 (최대 {max_daily_change}%){LOG_RESET}")
|
||||
continue
|
||||
|
||||
# [필터 5] RSI 과열 체크 (분봉 데이터 필요)
|
||||
try:
|
||||
df = self.client.get_minute_chart(code, period="3", limit=20)
|
||||
if not df.empty and len(df) >= 14 and "RSI" in df.columns:
|
||||
rsi = float(df["RSI"].iloc[-1])
|
||||
rsi_threshold = get_env_float("RSI_OVERHEAT_THRESHOLD", 78.0)
|
||||
if rsi >= rsi_threshold:
|
||||
filter_counts["RSI과열"] += 1
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-RSI] {name} {code}: RSI 과열 ({rsi:.1f} >= {rsi_threshold}){LOG_RESET}")
|
||||
continue
|
||||
|
||||
# [필터 6] MA20 체크
|
||||
if "MA20" in df.columns and len(df) >= 20:
|
||||
ma20 = float(df["MA20"].iloc[-1])
|
||||
if current_price < ma20:
|
||||
filter_counts["MA20"] += 1
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20] {name} {code}: 현재가({current_price:.0f}) < MA20({ma20:.2f}){LOG_RESET}")
|
||||
# [필터 5] RSI 과열 체크 (분봉 데이터 필요)
|
||||
try:
|
||||
df = self.client.get_minute_chart(code, period="3", limit=20)
|
||||
if not df.empty and len(df) >= 14 and "RSI" in df.columns:
|
||||
rsi = float(df["RSI"].iloc[-1])
|
||||
rsi_threshold = get_env_float("RSI_OVERHEAT_THRESHOLD", 78.0)
|
||||
if rsi >= rsi_threshold:
|
||||
filter_counts["RSI과열"] += 1
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-RSI] {name} {code}: RSI 과열 ({rsi:.1f} >= {rsi_threshold}){LOG_RESET}")
|
||||
continue
|
||||
|
||||
ma20_cap_pct = get_env_float("MA20_MAX_ABOVE_PCT", 3.0)
|
||||
if ma20 > 0 and current_price > ma20 * (1 + ma20_cap_pct / 100):
|
||||
filter_counts["MA20"] += 1
|
||||
gap_pct = (current_price - ma20) / ma20 * 100
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20과열] {name} {code}: 20선 대비 {gap_pct:.1f}% 위 (최대 {ma20_cap_pct}%){LOG_RESET}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(f"RSI/MA20 체크 실패({code}): {e}")
|
||||
# [필터 6] MA20 체크
|
||||
if "MA20" in df.columns and len(df) >= 20:
|
||||
ma20 = float(df["MA20"].iloc[-1])
|
||||
if current_price < ma20:
|
||||
filter_counts["MA20"] += 1
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20] {name} {code}: 현재가({current_price:.0f}) < MA20({ma20:.2f}){LOG_RESET}")
|
||||
continue
|
||||
|
||||
ma20_cap_pct = get_env_float("MA20_MAX_ABOVE_PCT", 3.0)
|
||||
if ma20 > 0 and current_price > ma20 * (1 + ma20_cap_pct / 100):
|
||||
filter_counts["MA20"] += 1
|
||||
gap_pct = (current_price - ma20) / ma20 * 100
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20과열] {name} {code}: 20선 대비 {gap_pct:.1f}% 위 (최대 {ma20_cap_pct}%){LOG_RESET}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(f"RSI/MA20 체크 실패({code}): {e}")
|
||||
|
||||
# 외국인/기관 동향 확인
|
||||
investor_trend = self.client.get_investor_trend(code, days=3)
|
||||
@@ -2684,18 +2677,21 @@ class ShortTradingBot:
|
||||
except Exception as e:
|
||||
logger.debug(f"ML 예측 실패({code}): {e}")
|
||||
|
||||
# 점수 계산: 낙폭 + 회복률 + 거래량 + 수급 + ML 승률
|
||||
score = (drop_rate * 100) + (recovery_pos * 50) + investor_score
|
||||
if volume > 1000000: # 거래량 100만주 이상 가산점
|
||||
score += 10
|
||||
# 강도(점수) 계산: 스케일 0~15 전후 (10=높은 편, 5 전후=평범, 한투 체결강도/수급은 소폭 가산)
|
||||
# 기존 (drop*100 + rec*50)은 30~50대라 평범한 구간이 없었음 → 10 단위로 조정
|
||||
score = (drop_rate * 10) + (recovery_pos * 10) # 낙폭·회복 기여 (각 0~10 수준)
|
||||
if investor_score >= 10: # 수급 보너스 (0 / 1 / 2점)
|
||||
score += 2 if investor_score >= 20 else 1
|
||||
if volume > 1000000: # 거래량 100만주 이상 +1점
|
||||
score += 1
|
||||
if ml_prob is not None:
|
||||
score += (ml_prob - 0.5) * 100 # ML 승률 가산점
|
||||
# 체결강도 상위 API(FHPST01710000) 보너스: 100 이상 +10점, 120 이상 +20점
|
||||
score += (ml_prob - 0.5) * 10 # ML 승률 -5~+5점
|
||||
# 체결강도 보너스: 100 이상 +1점, 120 이상 +2점 (과도한 가산 방지)
|
||||
execution_strength = execution_strength_map.get(code, 0)
|
||||
if execution_strength >= 120:
|
||||
score += 20
|
||||
score += 2
|
||||
elif execution_strength >= 100:
|
||||
score += 10
|
||||
score += 1
|
||||
candidate = {
|
||||
"code": code,
|
||||
"name": name,
|
||||
@@ -2713,20 +2709,21 @@ class ShortTradingBot:
|
||||
candidates.append(candidate)
|
||||
passed_filters += 1
|
||||
|
||||
# 통과 즉시 DB 저장 (매매 루프가 스캔 완료를 기다리지 않고 실시간으로 후보 읽기 위함)
|
||||
# 통과 즉시 DB 저장 (RELAXED가 아닐 때만; RELAXED면 루프 끝나고 상위 N명만 일괄 등록)
|
||||
# 같은 종목 중복 시 ON CONFLICT(code) DO UPDATE 로 최신 점수/가격으로 갱신됨
|
||||
try:
|
||||
self.db.add_target_candidate({
|
||||
"code": code,
|
||||
"name": name,
|
||||
"score": score,
|
||||
"price": current_price,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"후보 즉시 저장 실패({code}): {e}")
|
||||
if not relaxed:
|
||||
try:
|
||||
self.db.add_target_candidate({
|
||||
"code": code,
|
||||
"name": name,
|
||||
"score": score,
|
||||
"price": current_price,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"후보 즉시 저장 실패({code}): {e}")
|
||||
|
||||
ml_info = f" | ML {ml_prob:.1%}" if ml_prob is not None else ""
|
||||
strength_info = f" | 체결강도 {execution_strength:.0f}(+{'20' if execution_strength >= 120 else '10'}점)" if execution_strength >= 100 else ""
|
||||
strength_info = f" | 체결강도 {execution_strength:.0f}(+{'2' if execution_strength >= 120 else '1'}점)" if execution_strength >= 100 else ""
|
||||
logger.info(
|
||||
f"{LOG_GREEN}✅ [통과] {name} {code}: 낙폭 {drop_rate*100:.1f}% | 회복 {recovery_pos*100:.0f}% | 강도 {score:.1f}{strength_info}{ml_info}{LOG_RESET}"
|
||||
)
|
||||
@@ -2741,6 +2738,21 @@ class ShortTradingBot:
|
||||
|
||||
candidates.sort(key=lambda x: x["score"], reverse=True)
|
||||
|
||||
# RELAXED 모드: 낙폭+회복만 통과한 풀에서 상위 N명만 DB 등록 (후보 풀 확대)
|
||||
if relaxed and candidates:
|
||||
n_register = min(top_n, len(candidates))
|
||||
for c in candidates[:n_register]:
|
||||
try:
|
||||
self.db.add_target_candidate({
|
||||
"code": c["code"],
|
||||
"name": c.get("name", c["code"]),
|
||||
"score": c["score"],
|
||||
"price": c.get("price", 0),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"후보 등록 실패({c.get('code')}): {e}")
|
||||
logger.info(f" 📌 [RELAXED] 상위 {n_register}명 DB 등록 (후보 풀 {len(candidates)}개 중 점수순)")
|
||||
|
||||
# 필터별 탈락/통과 요약 (색상: 탈락=노랑, 통과=초록)
|
||||
summary = ", ".join(f"{k}={v}" for k, v in filter_counts.items() if v > 0)
|
||||
logger.info(f" 📊 [필터 요약] 스캔 {total_scanned}개 중 {LOG_YELLOW}탈락: {summary or '없음'}{LOG_RESET} | {LOG_GREEN}통과: {len(candidates)}개{LOG_RESET}")
|
||||
|
||||
1045
kiwoom_trader_ver2.py
Normal file
1045
kiwoom_trader_ver2.py
Normal file
File diff suppressed because it is too large
Load Diff
BIN
quant_bot.db
BIN
quant_bot.db
Binary file not shown.
@@ -55,8 +55,8 @@ if __name__ == "__main__":
|
||||
my_updates = {
|
||||
# 한투 API 설정
|
||||
"KIS_MOCK": "true",
|
||||
"KIS_APP_KEY_REAL": "PSUT2l4CO94DjrwDa2EAnEl639YXnWHdbbkN", # 여기에 앱키 입력
|
||||
"KIS_APP_SECRET_REAL": "2SkPBKrztpBomcR+pYNEBuVa5/iSqYLQxsDn/YqJuQ0dULp/GqTAePhe4czJHuf/1XBUd18KDV6ZTrmxfI8eTiCfIEaO6jMKSq0u+CoUkHTrO9TfliYtxsNbl43jL+rokLB54V2VmHrlqM4WCF+54bMWhzzSE7z3OOl67V9yWKCWIoTrcYg=",
|
||||
#"KIS_APP_KEY_REAL": "PSUT2l4CO94DjrwDa2EAnEl639YXnWHdbbkN", # 여기에 앱키 입력
|
||||
#"KIS_APP_SECRET_REAL": "2SkPBKrztpBomcR+pYNEBuVa5/iSqYLQxsDn/YqJuQ0dULp/GqTAePhe4czJHuf/1XBUd18KDV6ZTrmxfI8eTiCfIEaO6jMKSq0u+CoUkHTrO9TfliYtxsNbl43jL+rokLB54V2VmHrlqM4WCF+54bMWhzzSE7z3OOl67V9yWKCWIoTrcYg=",
|
||||
# "KIS_APP_KEY_MOCK": "PSdfKtsMihgC9tLiUr2XISscuR3fHxl6kvmV", # 여기에 앱키 입력`
|
||||
# "KIS_APP_SECRET_MOCK": "Ip+XZrZcoz11thgDD40XS8i6R1AalYkKFZwg2w8+ZMulVKN8rJVXiqGONxc4EYxw1S3TgOcx7fSldDc6EGq63bprfbgHwKWxstu29ZmLAtRNU0oFqV7e9vCOfgiWxrfnCqwcihoS7ovmza9+Ylqd8/EtjFGNmhQHWocyTAm8kdp5IG6tFtc=", # 여기에 앱시크릿 입력
|
||||
|
||||
@@ -66,9 +66,10 @@ if __name__ == "__main__":
|
||||
# "KIS_ACCOUNT_NO_MOCK": "50169256",
|
||||
# "KIS_ACCOUNT_CODE_REAL": "01",
|
||||
# "KIS_ACCOUNT_CODE_MOCK": "01",
|
||||
|
||||
"MIN_RECOVERY_RATIO": 0.35,
|
||||
"MIN_DROP_RATE": 0.02,
|
||||
#"MM_BOT_TOKEN_": "5o4bfsqo97dedyq7599wz6joie",
|
||||
"TAKE_PROFIT_PCT_LONG": "0.010",
|
||||
#"MIN_RECOVERY_RATIO": 0.35,
|
||||
#"MIN_DROP_RATE": 0.02,
|
||||
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user