""" 비본주(우선주·ETF·ETN·스팩) 판별 — 후보 매수 차단 공용. 키움 조건검색은 종목명 없이 code 만 주는 경우가 많아(name==code), 이름 끝 '우' 만으로는 우선주를 못 막는다. 코드 끝자리 규칙으로 보완한다. KRX 단축코드 끝자리(종목구분): - 보통주: 대개 '0' - 우선주: '5','7','9' (구형) + 'K','L','M'… (2013년 이후 알파벳) """ from __future__ import annotations from typing import FrozenSet from .env import get_env_bool, get_env_from_db # ETF/ETN 브랜드·유형 (이름 부분일치). '파워' 단독은 뉴파워프라즈마 오탐 → 넣지 않음. _DEFAULT_NON_STOCK_NAME_KEYWORDS = ( "ETN", "ETF", "레버리지", "인버스", "2X", "3X", "선물", "KODEX", "TIGER", "KBSTAR", "ARIRANG", "HANARO", "SOL ", "KOSEF", "ACE ", "KINDEX", "RISE ", "PLUS ", "TIMEFOLIO", "히어로", "SMART", "TREX", "WON", "KOACT", ) def _pref_digit_suffixes() -> FrozenSet[str]: """우선주 숫자 끝자리 — NON_STOCK_PREF_CODE_SUFFIXES (기본 5,7,9).""" raw = get_env_from_db("NON_STOCK_PREF_CODE_SUFFIXES", "5,7,9") or "5,7,9" return frozenset(s.strip().upper() for s in str(raw).split(",") if s.strip()) def is_preferred_code(code: str) -> bool: """종목코드만으로 우선주 여부 (name 없어도 판단).""" c = (code or "").strip() if len(c) < 1: return False last = c[-1].upper() if last in _pref_digit_suffixes(): return True # K,L,M… 알파벳 끝 = 신형 우선주 코드 if get_env_bool("NON_STOCK_EXCLUDE_ALPHA_SUFFIX", True) and last.isalpha(): return True return False def is_etn_code(code: str) -> bool: c = (code or "").strip() return len(c) == 6 and c[0] == "7" def _name_keywords() -> tuple: """NON_STOCK_NAME_KEYWORDS — 콤마 구분. 비우면 기본 튜플.""" raw = (get_env_from_db("NON_STOCK_NAME_KEYWORDS", "") or "").strip() if not raw: return _DEFAULT_NON_STOCK_NAME_KEYWORDS parts = tuple(s.strip() for s in raw.split(",") if s.strip()) return parts or _DEFAULT_NON_STOCK_NAME_KEYWORDS def _name_hits_keyword(name: str) -> bool: nm = name.upper() for k in _name_keywords(): if not k: continue if k.isascii(): if k.upper() in nm: return True elif k in name: return True return False def is_non_stock(name: str, code: str) -> bool: """ETN/ETF/스팩/우선주 등 매수 금지 종목이면 True. name 이 비었거나 name==code 여도 코드 규칙으로 우선주·ETN 을 잡는다. """ code = (code or "").strip() name = (name or "").strip() if not code: return True if is_preferred_code(code): return True if is_etn_code(code): return True if name: if _name_hits_keyword(name): return True nm = name.upper() if "스팩" in name or "SPAC" in nm: return True # 우선주 표기 (우 / 우B / 우C …) if name.endswith("우") or "우B" in name or "우C" in name or name.endswith("(전환)"): return True return False def is_unmanageable_holding(name: str, code: str) -> bool: """보유 동기화에서 빼서 봇이 매도하지 않을 종목 (시세 불가 ETF/ETN·스팩). 우선주는 시세·매도 가능 → False. (실수로 산 우선주는 매도로 정리) """ code = (code or "").strip() name = (name or "").strip() if is_etn_code(code): return True if not name or name == code: return False nm = name.upper() for k in ("ETN", "ETF", "레버리지", "인버스", "KODEX", "TIGER", "KBSTAR"): if k.isascii(): if k in nm: return True elif k in name: return True if "스팩" in name or "SPAC" in nm: return True return False