# -*- coding: utf-8 -*- """ 전량매도 최후보루 - 보유종목 모니터 ===================================== confirm_sell_backtest.py 의 핵심 로직을 그대로 import 해서 쓴다(파리티 100%). 백테스터는 과거 전체 매도내역을 뽑지만, 이 모니터는 "내 보유종목이 '오늘/최근' 매도신호가 떴는가"만 본다. 쓰는 법: 1) 같은 폴더에 confirm_sell_backtest.py 가 있어야 함. 2) (선택) 같은 폴더에 market_map.csv (code,market,name) 있으면 시장+종목명 자동표시. 없으면 fdr.StockListing 으로 시장만 판별(이름은 코드로 대체). 3) 아래 WATCH 에 보유종목 코드만 넣는다. 4) python final_sell_monitor.py 5) [매도] 로 뜨면 그 종목은 최근 ALERT_DAYS 거래일 안에 전량매도 신호가 난 것. 임계값(A-4·가변이격 등)은 전부 confirm_sell_backtest.py 를 따라간다. """ import sys, ssl, os from datetime import datetime, timezone, timedelta import FinanceDataReader as fdr import confirm_sell_backtest as BT # 핵심 로직/설정 그대로 (main()은 __main__ 가드라 실행 안 됨) import buy_engine_dipfix as be # 매수 엔진 (일봉 동산/위아래 국면 → '모아가는 구간' 판별). dipfix=활성 최신본으로 통일 KST = timezone(timedelta(hours=9)) def asof_label(last_date): """상태 기준 표시: 마지막 데이터가 '오늘'이고 장중이면 '장중 현재가(HH:MM)', 아니면 '종가'. 한국 장: 평일 09:00~15:30 (KST).""" now = datetime.now(KST) today = now.strftime('%Y-%m-%d') intraday = (last_date == today and now.weekday() < 5 and (9 * 60) <= (now.hour * 60 + now.minute) <= (15 * 60 + 30)) if intraday: return f"{last_date} 장중 현재가 ({now:%H:%M} KST 기준, 종가 아님)" return f"{last_date} 종가 기준" ssl._create_default_https_context = ssl._create_unverified_context if sys.platform == 'win32': try: sys.stdout.reconfigure(encoding='utf-8') except Exception: pass HERE = os.path.dirname(os.path.abspath(__file__)) # build_market_map.py / market_map.csv / 모니터 다 같은 폴더(sell_debug)에 둠. MAP_PATH = os.path.join(HERE, 'market_map.csv') # ===================================================== # [여기만 바꾸면 됨] 보유종목 코드 # ===================================================== # ===================================================== # 모아가는 구간 (매수 국면) — 이 구간의 전량매도 알람은 무시 # 구간 = 동산성공(위아래 시작) ~ 2번째 아래 종료(240올) # 중간에 생명선이탈/사이클종료/더블동산 나오면 거기서 끝 # ===================================================== IGNORE_SELL_IN_ACCUM = True # False 로 하면 기존처럼 항상 알람 _accum_cache = {} def accum_ranges(tk): """buy_engine 일봉 국면에서 '모아가는 구간' [(시작일, 끝일|None)]""" if tk in _accum_cache: return _accum_cache[tk] out = [] try: df = fdr.DataReader(tk, '2000-01-01') if df is not None and len(df) > 300 and 'Close' in df.columns: ev, buys, mts = be.run(df, '1day') start = None for d, tag, dt in ev: ds = str(d)[:10] if tag == '동산성공→위아래시작': start = ds elif start and (('2번째 아래 종료' in tag) or ('240올' in tag) or ('생명선이탈' in tag) or ('사이클종료' in tag) or ('더블동산' in tag)): out.append((start, ds)); start = None if start: out.append((start, None)) # 진행 중 except Exception: out = [] _accum_cache[tk] = out return out def in_accum(ds, ranges): return any(ds >= a and (b is None or ds < b) for a, b in ranges) WATCH = [ # '277810', # 레인보우로보틱스 # '373220', # LG에너지솔루션 # 코드 대신 종목명으로도 됨! 예: '레인보우로보틱스', 'LG에너지솔루션' # '060370', '483650', # 달바글로벌 '096770', # SK이노베이션 '090430', # 아모레퍼시픽 '326030', # SK바이오팜 '035420', # NAVER '352820', # 하이브 '003490', # 대한항공 '025540', # 한국단자 '373220', # LG에너지솔루션 '443060', # HD현대마린솔루션 '032350', # 롯데관광개발 '005380', # 현대차 '003670', # 포스코퓨처엠 '011790', # SKC '005930', # 삼성전자 '000660', # SK하이닉스 '078520', # 에이블씨엔씨 '206640', # 바디텍메드 '302440', # SK바이오사이언스 '328130', # 루닛 '005250', # 녹십자홀딩스 '234690', # 녹십자웰빙 '083930', # 아바코 '195940', # HK이노엔 '122870', # 와이지엔터테인먼트 '112610', # 씨에스윈드 '257720', # 실리콘투 '086710', # 선진뷰티사이언스 '217730', # 강스템바이오텍 '003570', # SNT다이내믹스 '194370', # 제이에스코퍼레이션 '131030', # 옵투스제약 '204320', # HL만도 '096530', # 씨젠 '035900', # JYP Ent. '178920', # PI첨단소재 '216080', # 제테마 '099320', # 쎄트렉아이 '088130', # 동아엘텍 '420770', # 기가비스 '222080', # SFA반도체 '147830', # 제룡산업 '281820', # 케이씨텍 '017510', # 세명전기 '112290', # 와이씨켐 '089010', # 켐트로닉스 '102120', # 어보브반도체 '251370', # 와이에이치티 '094360', # 칩스앤미디어 '166090', # 하나머티리얼즈 '277810', # 레인보우로보틱스 '389020', # 자람테크놀로지 '161580', # 필옵틱스 '232140', # 와이씨 '033100', # 제룡전기 '031980', # 피에스케이홀딩스 '403870', # HPSP '183300', # 코미코 '445090', # 에이직랜드 '347850', # 디앤디파마텍 '030530', # 원익홀딩스 '399720', # 가온칩스 '042700', # 한미반도체 '440110', # 파두 '039030', # 이오테크닉스 ] ALERT_DAYS = 5 # 최근 이 거래일 안에 매도신호가 났으면 [매도]로 알림 # ===================================================== # market_map.csv 로드 (code -> (market, name)). 없으면 StockListing 폴백. # ===================================================== def load_market_map(): m = {} if os.path.exists(MAP_PATH): try: with open(MAP_PATH, encoding='utf-8') as f: for line in f: parts = [p.strip() for p in line.strip().split(',')] if len(parts) < 2 or parts[0].lower() == 'code': continue name = parts[2] if len(parts) >= 3 else '' m[parts[0].zfill(6)] = (parts[1].upper(), name) except Exception as e: print(f"[경고] market_map.csv 로드 실패: {e}") return m MARKET_MAP = load_market_map() import difflib # 종목명 -> 코드 역방향 맵. 키는 정규화(공백제거+소문자)해서 대소문자 무시. def _norm(s): return str(s).replace(' ', '').lower() NAME_TO_CODE = {} # 정규화된 종목명 -> 코드 _NORM_NAMES = [] # 퍼지매칭용 정규화 이름 리스트 for _code, (_mkt, _nm) in MARKET_MAP.items(): if _nm: NAME_TO_CODE[_norm(_nm)] = _code _NORM_NAMES.append(_norm(_nm)) def resolve_to_code(q): """6자리 코드면 그대로. 종목명이면 코드로 (대소문자 무시, 부분일치, 오타허용). 반환 (code, 안내메시지). 못 찾거나 후보 여럿이면 (None, 안내).""" q = str(q).strip() z = q.zfill(6) if z.isdigit() and len(z) == 6: return z, '' # 숫자 코드 zu = q.upper().zfill(6) # ETF/ETN 영숫자 코드 (예: 0091P0) if len(zu) == 6 and zu.isalnum() and zu in MARKET_MAP: return zu, '' key = _norm(q) if not key: return None, '입력이 비었어' if key in NAME_TO_CODE: # 정확 일치(대소문자 무시) return NAME_TO_CODE[key], '' # 부분 일치 (포함) hits = [(nm, c) for nm, c in NAME_TO_CODE.items() if key in nm] if len(hits) == 1: return hits[0][1], f"('{q}' → {MARKET_MAP[hits[0][1]][1]})" if len(hits) > 1: # 여럿이면: 입력과 가장 비슷한 순으로 정렬해 후보 제시 hits.sort(key=lambda h: difflib.SequenceMatcher(None, key, h[0]).ratio(), reverse=True) cand = ', '.join(f"{MARKET_MAP[c][1]}({c})" for _, c in hits[:8]) return None, f"'{q}' 후보 여럿 → {cand}" # 오타 허용: 가장 비슷한 이름 (difflib) close = difflib.get_close_matches(key, _NORM_NAMES, n=5, cutoff=0.6) if len(close) == 1: c = NAME_TO_CODE[close[0]] return c, f"('{q}' → {MARKET_MAP[c][1]} 로 추정)" if len(close) > 1: cand = ', '.join(f"{MARKET_MAP[NAME_TO_CODE[nm]][1]}({NAME_TO_CODE[nm]})" for nm in close) return None, f"'{q}' 혹시 이거? → {cand}" return None, f"'{q}' 종목명/코드 못 찾음" def detect_via_stocklisting(tickers): """market_map.csv 없을 때 폴백 (한국 IP 데스크탑에서만 됨).""" try: kospi = set(fdr.StockListing('KOSPI')['Code'].astype(str)) except Exception: kospi = set() try: kosdaq = set(fdr.StockListing('KOSDAQ')['Code'].astype(str)) except Exception: kosdaq = set() out = {} for tk in tickers: out[tk] = 'KOSPI' if tk in kospi else 'KOSDAQ' return out def market_of(code, fallback): if code.zfill(6) in MARKET_MAP: return MARKET_MAP[code.zfill(6)][0] return fallback.get(code, 'KOSDAQ') def label_of(code): """'레인보우로보틱스(277810)' 형태. 이름 없으면 코드만.""" nm = MARKET_MAP.get(code.zfill(6), ('', ''))[1] return f"{nm}({code.zfill(6)})" if nm else code.zfill(6) def _skip_reason(stock, low, ma240, s): """이 매도를 왜 무시했는지 사람 말로. 무시 안 하면 None.""" cat, days = BT.classify_240(stock, low, ma240, s['i'], s['price']) if getattr(BT, 'SKIP_IF_NO_240', False) and cat == 'UNKNOWN': return '240일선이 아직 없는 종목(상장 얼마 안 됨)이라 신호를 믿을 수 없어서' if getattr(BT, 'SKIP_IF_BELOW_240', False) and cat == 'ALREADY_DOWN': tol = getattr(BT, 'BELOW_240_TOL', 0.05) dpct = f'{days*100:.1f}%' if days is not None else '' return f'신호 당일 종가가 240일선 -{tol*100:.0f}% 넘게 한참 아래(-{dpct}) → 이미 늦은 신호라서 (240 근처였으면 울렸음)' med = getattr(BT, 'MAX_EARLY_DAYS', None) if med is not None and cat == 'EARLY' and days is not None and days > med: return f'신호 뒤 {days}거래일이나 지나서야 240일선이 깨짐 → 너무 성급한 신호라서' if getattr(BT, 'SKIP_IF_STILL_UP', False) and cat == 'STILL_UP': return '신호 뒤 200거래일 동안 240일선을 안 깨고 추세가 살아있었음 → 헛매도라서' return None # PENDING(최근신호)이면 여기로 -> 안 지움 def _div_explain(st): """이격이 무슨 뜻인지 계산 내역을 풀어서.""" if not st or 'stock_from_peak' not in st: return '' return (f"지금 이격 {st['div']*100:.1f}% @현재가 " f"= 종목이 고점대비 {st['stock_from_peak']*100:.1f}% " f"(고점 {st['peak_price']:,.0f} @ {st['peak_date']}) " f"− 지수는 같은기간 {st['index_from_peak']*100:+.1f}%") def _real_sells(stock, low, ma240, sells): """백테스터 main()과 동일한 4종 필터로 '진짜 매도'만 남김. (240터치/240없음/너무일찍/추세유지 = SKIP)""" out = [] for s in sells: i = s['i']; price = s['price'] cat, days = BT.classify_240(stock, low, ma240, i, price) if getattr(BT, 'SKIP_IF_NO_240', False) and cat == 'UNKNOWN': continue if getattr(BT, 'SKIP_IF_BELOW_240', False) and cat == 'ALREADY_DOWN': continue med = getattr(BT, 'MAX_EARLY_DAYS', None) if med is not None and cat == 'EARLY' and days is not None and days > med: continue if getattr(BT, 'SKIP_IF_STILL_UP', False) and cat == 'STILL_UP': continue # cat == 'PENDING' (최근 신호라 판정 불가) -> 절대 안 지움. 알람 살림! out.append(s) return out def check_one(tk, market): r = BT.load_one(tk, market) if r is None: return {'tk': tk, 'market': market, 'ok': False, 'msg': '데이터 없음'} stock = r['stock']; index = r['index'] low = r['low']; ma240 = r['ma240'] raw_sells, jumps, state = BT.find_confirmed_sells( stock, index, r['dates'], r['start_pos'], return_state=True) # 백테스터와 동일한 필터로 '진짜 매도'만 sells = _real_sells(stock, low, ma240, raw_sells) # ★ 모아가는 구간(매수 국면)의 매도신호는 무시 accum_now = False; n_muted = 0 if IGNORE_SELL_IN_ACCUM: rngs = accum_ranges(tk) if rngs: before = len(sells) sells = [s for s in sells if not in_accum(s['date'], rngs)] n_muted = before - len(sells) accum_now = in_accum(r['dates'][len(stock) - 1], rngs) last_i = len(stock) - 1 recent = [s for s in sells if s['i'] >= last_i - ALERT_DAYS] # 지금 sold 상태를 만든 '바로 그 매도'를 찾는다. # 필터에 걸러진 매도가 만든 상태면 -> 그 상태도 표시 안 함(무시하기로 한 알람이므로) trigger = None; skipped = None if state.get('sold') and state.get('sold_i') is not None: trigger = next((s for s in sells if s['i'] == state['sold_i']), None) if trigger is None: # 그 매도는 필터로 무시된 것 -> 왜 무시했는지 사유를 찾아둔다 raw = next((s for s in raw_sells if s['i'] == state['sold_i']), None) if raw is not None: skipped = {'sell': raw, 'reason': _skip_reason(stock, low, ma240, raw) or '조건 미달'} state = dict(state, sold=False) import numpy as _np _m = ma240[last_i] return { 'tk': tk, 'market': market, 'ok': True, 'price_now': float(stock[last_i]), 'ma240_now': (None if _np.isnan(_m) else float(_m)), 'last_date': r['dates'][last_i], # 데이터 마지막 거래일 = 상태 기준일 'recent': recent, 'last_sell': sells[-1] if sells else None, 'trigger': trigger, 'skipped': skipped, 'state': state, 'accum_now': accum_now, 'n_muted': n_muted, } def status_line(res): """현재 단계를 짧게: 관망 / flagON 대기 / 1차 / 2차flagON 대기 + 이격.""" if res.get('accum_now'): mut = f" · 무시된 매도신호 {res['n_muted']}건" if res.get('n_muted') else "" return f"★모아가는 구간 (동산 위아래 매수 국면) — 매도알람 무시 중{mut}" st = res.get('state') if not st: return '관망 (신호 대기)' div_now = st['div'] * 100 if st.get('sold'): return (f'과거 매도신호 유지중 (지금 팔라는 신호 아님) · 지금 이격 {div_now:.1f}%') nm = {'없음': '관망 — 아직 아무 신호 없음', 'flagON': '1차 flag ON — 이격이 -10% 도달, 관찰 시작', '1차': '1차 경고 — 더 깊어짐 (다음: 2차 flag)', '2차flagON': '2차 flag ON — 한 번 더 깊어지면 전량매도!', '2차매도': '관망 — 아직 아무 신호 없음'}.get(st['stage_name'], st['stage_name']) return nm def main(): if getattr(BT, 'ENGINE_VERSION', 0) < 3: print("!" * 66) print("[경고] confirm_sell_backtest.py 가 구버전이야!") print(" -> 단계 진행내역/고점 정보가 안 나와. 최신 백테스터로 교체해줘.") print("!" * 66) print("=" * 66) print(f"전량매도 최후보루 - 보유종목 모니터 (최근 {ALERT_DAYS}거래일 기준)") if not MARKET_MAP: print("(market_map.csv 없음 -> StockListing 으로 시장만 판별, 이름은 코드로 표시)") print("=" * 66) fallback = {} if MARKET_MAP else detect_via_stocklisting(WATCH) alerts = [] asof = None for _q in WATCH: tk, msg = resolve_to_code(_q) if tk is None: print(f" [건너뜀] {msg}") continue market = market_of(tk, fallback) res = check_one(tk, market) lbl = label_of(tk) + (f" {msg}" if msg else "") if not res['ok']: print(f" {lbl} ({market}) - {res['msg']}") continue asof = res['last_date'] st = res.get('state') or {} steps = st.get('steps') or [] trig = res.get('trigger') skp = res.get('skipped') # --- 헤더: [코드 종목명] 시장 | 날짜 종가 | 240선 (괴리%) --- m240 = res.get('ma240_now') m240s = (f"240선 {m240:,.0f} ({(res['price_now']/m240-1)*100:+.1f}%)" if m240 else "240선 없음") print(f"\n[{tk} {MARKET_MAP.get(tk, ('', ''))[1] or ''}] {res['market']} | " f"{res['last_date']} 종가 {res['price_now']:,.0f} | {m240s}") # --- 고점 / 지금 이격 / 단계 수 --- if 'peak_price' in st: print(f" 고점 {st['peak_price']:,.0f}({st['peak_date']}) | " f"지금 이격 {st['div']*100:.1f}% " f"(종목 고점대비 {st['stock_from_peak']*100:.1f}% − 지수 {st['index_from_peak']*100:+.1f}%) | " f"단계 {len(steps)}개") # --- 단계 진행 내역 --- for stp in steps: mark = ' <<<' if '전량매도' in stp['name'] else '' print(f" {stp['name']:<14} {stp['date']} @ {stp['price']:,.0f} " f"(이격 {stp['div']*100:.1f}%){mark}") # --- 주 신호 --- if res['recent']: sl = res['recent'][-1] alerts.append(lbl) ago = st.get('last_i', 0) - sl['i'] print(f" >>> [★지금 매도] 전량매도: {sl['date']} @ {sl['price']:,.0f} " f"(신호시점 이격 {sl['div']*100:.0f}%) | {ago}거래일 전") elif trig and st.get('sold'): ago = st.get('last_i', 0) - trig['i'] print(f" >>> [과거 신호] 전량매도: {trig['date']} @ {trig['price']:,.0f} " f"(그때 이격 {trig['div']*100:.0f}%) | {ago}거래일 전") print(f" >>> ※ 지금 팔라는 신호 아님 (이격 {BT.DIV_THRESHOLD*100:.0f}% 위로 회복하면 리셋)") elif skp: sl = skp['sell'] ago = st.get('last_i', 0) - sl['i'] print(f" >>> [무시된 신호] 전량매도: {sl['date']} @ {sl['price']:,.0f} " f"(그때 이격 {sl['div']*100:.0f}%) | {ago}거래일 전") print(f" >>> 무시한 이유 — {skp['reason']}") else: print(f" >>> 매도 신호 없음. 보유 유지. (현재 단계: {status_line(res)})") # ★ 이격이 회복되어 리셋됐어도 '직전 매도가 언제였는지'는 항상 보여준다. # (사용자: 그 선까지 올라오면 정리 판단하려면 마지막 매도 시점을 꼭 알아야 함) ls = res.get('last_sell') if ls: ago = st.get('last_i', 0) - ls['i'] _nowdiv = st.get('div') print(f" >>> [직전 매도(참고)] {ls['date']} @ {ls['price']:,.0f} " f"(그때 이격 {ls['div']*100:.0f}%) | {ago}거래일 전") if _nowdiv is not None: print(f" >>> ※ 지금은 이격 {_nowdiv*100:.0f}%로 회복돼서 신호 꺼짐") print("\n" + "=" * 66) if asof: print(f"※ 상태 기준: {asof_label(asof)}") if alerts: print(f"오늘 확인 필요: {len(alerts)}종목 -> " + ", ".join(alerts)) else: print("최근 매도신호 없음. 전부 보유 유지.") print("=" * 66) if __name__ == '__main__': main()