늘림목까지 완성성

This commit is contained in:
2026-02-22 21:42:41 +09:00
parent 6a9fef7be8
commit 168e7d016e
10 changed files with 2120 additions and 266 deletions

View File

@@ -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"
# DB 값 그대로 사용 (10자리면 앞8/뒤2만 분리)
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"
# 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,13 +852,14 @@ 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):
"""관심 종목 리스트 로드"""
if not self.watchlist_path.exists():
@@ -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))
# 예수금 업데이트 (0원이 아닐 때만, 또는 초기화 시)
if new_cash > 0 or self.current_cash == 0:
self.current_cash = new_cash
# 보유 종목 평가액 계산 (빠른 버전: 로컬 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
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
# 보유 종목 평가액: 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"]
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
@@ -913,6 +1025,20 @@ class LongTradingBot:
def _update_cash_only(self):
"""예수금만 빠르게 업데이트 (하위 호환성용, _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분 정각까지 남은 초 계산"""
@@ -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__":