늘림목까지 완성성

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

@@ -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
# [필터 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}")
# [필터 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
# [필터 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}")