# -*- coding: utf-8 -*- """ 매수 로직 엔진 (분봉) - 동산/끝지점/위아래/생명선 [v4: 60선최저점 앵커] CSV(datetime,Open,High,Low,Close,Volume) 경로만 인자로. [동산 성공 조건] (1) 60선이 240선 아래 → 그 구간 '60선 최저점' = 시작점 (생명선/비교 기준) ※ 60선 최저점에 앵커링 (가격 급락봉 아님 → BB하단 후행 버그 방지) (2) 60선이 240선 위로 올라감 (산봉우리) ※ 산봉우리 충분히 커야: 60선이 240선 위로 PEAK_MIN_PCT 이상 (노이즈 필터) (3) 60선이 다시 240선 아래로 내려가 끝지점 ※ 끝지점도 충분히 깊어야: 60선이 240선 아래로 ENDLOW_MIN_PCT 이상 (노이즈 필터) (4) 끝지점의 60선이 시작점 60선보다 '높아야' 동산 성공 (낮으면 실패) (5) 60선이 끝지점에서 다시 240선 위로 올라감 (6) 이때부터 위아래 매수 (60>240에서 60선 눌릴 때) - 1번째 위아래 ×2, 2번째 ×1 - 3·4·5번째... = '240올' (추매 안 함, 다시 240선 아래로 꺾일 때까지 대기) ※ 산봉우리/끝지점 작으면(노이즈) 무시하고 계속 관찰 생명선 = 시작점(60선최저점) 볼린저 하단 × (1 - LIFELINE_MARGIN). 동산 내내 고정. 240근처 추가매수 = 저가가 240선 실제로 닿을 때 (저가 ≤ 240선) 실행: python buy_engine.py 007340_5min.csv 5min """ import sys import pandas as pd, numpy as np # ===== 조정 파라미터 ===== MA_60, MA_240 = 60, 240 BB_WIN, BB_STD = 20, 2 LIFELINE_BASE_MIN = True # [생명선] True면 '베이스 구간 전체의 최저 BB하단'을 생명선 앵커로 사용(B안). # 기존(False)은 60선최저일·가격최저일 '그날의' BB하단만 봐서, 베이스 중간의 # 날카로운 급락(BB가 쭉 벌어진 날)을 놓쳐 생명선이 너무 높게 잡힘. # ※ 60선이 240선 위/아래인지 무관하게 WAIT(베이스) 내내 추적 — # 상장초기 평행연장 240 구간에서 60선이 위아래로 왔다갔다해도 급락을 놓치지 않음. LIFELINE_MARGIN = 0.01 # 생명선 = 시작점 BB하단 × (1-1%) VIRTUAL_LIFELINE = True # [일봉] ★가상 생명선 (사용자 아이디어 2026-08): # 진짜 생명선(첫 동산 것)은 '구조용'으로 그대로 두고, # 더블동산/위아래 재개로 '작은 언덕'이 생길 때마다 그 구간 전용 보호선을 따로 긋는다. # 가상 생명선 = 직전 눌림(DIP) 구간의 최저 BB하단 ×(1-마진). 진짜 생명선보다 위일 때만 유효. # 이 선을 깨면: ① 그 작은 언덕에서 산 물량만 매도(전량 아님) ② 재베이스로 동산부터 다시 그림. # → 물타기는 끊되 전체 포지션·구조는 안 날림. False면 이 기능 끔. DEEP_DIP_ONE_WAVE = False # [일봉] ★깊게 빠진 뒤 재개할 땐 '1번째 위아래'만 매수 (2026-08 사용자): # 직전 눌림이 DEEP_DIP_ONE_WAVE_PCT 보다 깊었으면, 그 뒤 열리는 위아래는 # 1번째만 사고 2번째는 안 삼(구조가 이미 상한 구간이라 추가 물타기 축소). # False면 기존처럼 1·2번째 다 매수. DEEP_DIP_ONE_WAVE_PCT = 0.20 # 위 규칙 적용 기준 깊이(끝지점이 240선 대비 -20% 이하) VLIFE_GRACE_DAYS = 20 # [일봉] ★가상생명선 이탈 유예일 (2026-08): # 선을 깬 즉시 팔지 않고, 이 일수 안에 종가가 선 위로 회복하면 없던 일로 함. # 유예 내내 회복 못 하면 그때 구간매도. # 이유: 즉시매도는 '패닉 투매 바닥'에서 파는 경향(166090 2020-03-19=코로나 바닥 당일, # 033100 2016-01-20, 236200 2022-06-23 모두 매도 직후가 최저점이었음). # 진짜 붕괴는 계속 선 아래 머물고, 급락 바닥은 며칠 만에 되돌아오므로 시간으로 걸러냄. # 0 = 유예 없음(즉시 매도). DOUBLE_MTN_RELIFE = False # [일봉] (실험) 더블동산/재개 시 '진짜 생명선' 자체를 올려잡기 — 전수조사 결과 성적 악화로 OFF. # 대신 위 VIRTUAL_LIFELINE 방식 사용. True로 켜면 진짜 생명선을 올림(옛 실험). _DOUBLE_MTN_RELIFE_DOC = """[일봉] ★더블동산/재개 시 생명선을 '직전 눌림(DIP) 구간의 최저 BB하단'으로 올려잡기. # 문제(096770): 첫 동산 생명선(58,739)을 시퀀스 내내 유지하다 보니, 주가가 15만원대인데 # 보호선이 5.8만이라 사실상 보호가 없음 → 계속 떨어지는데 위아래 매수만 반복. # 해결(사용자): 60선이 240선을 다시 만나 더블동산/재개가 확정되는 그 시점에, # 방금 지나온 DIP 구간에서 가장 낮았던 BB하단을 새 생명선으로 삼는다. # (096770: 2022-10-11 BB하단 127,575 × 0.99 = 126,299) # ※ 생명선은 '올리기만' 함(내리지 않음) — 옛 생명선보다 낮으면 그대로 둠. # False면 예전처럼 첫 동산 생명선을 계속 유지.""" LIFELINE_KEEP_MTN = True # [일봉/PEAK] ★생명선 이탈해도 동산 구조는 유지: # 주가가 생명선을 깨면 전량매도(손절)는 하되, 동산을 폐기하지 않고 계속 관찰. # 이후 60선이 240선 위로 복귀할 때 '끝지점 60선 > 시작점 60선'(더 높은 저점)이면 # 동산 성공으로 인정 → 위아래 매수 시작. 더 낮은 저점이면 기존대로 재베이스. # 근거(236200): 주가는 급락해 생명선을 깼지만 60선은 안 꺾여 # 시작점 19,898(2017-03-20) < 끝지점 20,178(2018-11-19) = 구조상 성공한 동산인데, # 기존 로직은 2018-10-19에 통째 폐기 → 위아래가 2020-01-06까지 1년 이상 밀렸음. # 동산성공 인정 시 생명선은 새 저점(끝지점 BB하단)으로 재설정. # False면 예전처럼 생명선 이탈=동산 실패(WAIT로). PEAK_MIN_PCT = {'5min':0.01,'15min':0.01,'1hour':0.02,'1day':0.05} # 산봉우리 최소(60선이 240선 위로). 일봉은 크게 ENDLOW_MIN_PCT = {'5min':0.010,'15min':0.010,'1hour':0.02,'1day':0.07} # 끝지점 최소(60선이 240선 아래로, 60선 기준). # 2026-08 봉별 영점 재조정(깊이분포로 '걸러내는 비율'을 일봉(~37%) 수준에 맞춤): # 5min 1.0% / 15min 0.5%→1.0%(5분봉과 통일, 영향 420770 1건) / 1hour 1.0%→2.0%(204320 2025-04 산 합쳐짐) # 1day 7%. (봉 클수록 눌림 깊이 커지므로 기준도 커지는 게 논리적) NEAR_240 = 0.003 # 240근처 매수 = 저가 ≤ 240선 +0.3% (바짝 붙으면 매수). 6/12 15:15(+0.17%) 잡으려고 RECOVER_PCT = {'5min': 0.05, '15min': 0.08, '1hour': 0.10, '1day': 0.08} # 직전 위아래 '종가' 대비 재무장 % WAVE_RISE_PCT = {'5min': 0.01, '15min': 0.01, '1hour': 0.01, '1day': 0.01} # 1번째 위아래 전: 종가가 60선 위로 이만큼 WAVE_EXIT_PCT = 0.05 # [일봉] '위' 구간에서 종가가 60선 위로 최고 +5% 이상 올라야 그 위아래(아래구간) 종료 # 살짝 올라갔다 마는 건(예: +3.5%) 아직 같은 아래 구간으로 봄 DOUBLE_MTN_GAP = 0.44 # [일봉] 더블동산 상한: 이번 산 피크가 직전 산 피크 대비 +44% 미만이면 '제자리=더블동산'. 0=OFF # (2026-08: 42%→44%. 006260 2025-05-15 피크갭 +43.3%를 더블동산으로 인정. 실제 '완주 후 붕괴'는 # 005930'23=+45.4%, 078520'17=+46.1%로 45%↑라 그대로 재베이스 유지. 44%가 그 사이 깔끔한 컷) DOUBLE_MTN_GAP_MIN = -0.10 # [일봉] 더블동산 하한: 피크가 직전 대비 -10%보다 더 내려가면 '제자리'가 아니라 # 계단식 하락(추세붕괴) → 더블동산 아님. 깊은눌림→새동산으로 넘김 DOUBLE_MTN_END_TOL = 0.0 # [일봉] 더블동산 끝지점 조건: 끝지점(2) 60선 >= 끝지점(1) 60선 × (1-TOL). # 끝지점이 계속 낮아지면 더블동산 아님(진짜 끝지점을 향해 가는 중) DOUBLE_MTN_PCT = 0.02 # [일봉] 위아래 중 60선이 240선 -2% 아래로 내려가야 '더블동산'(끝지점2) 인정. 살짝 스침은 무시 WAVE_UP_DAYS = 10 # [일봉] '위' 인정: 60선 위로 +5% 넘고 N일 연속 위에 있어야 (하루 급등 무시) WAVE_DEEP_PCT = 0.01 # [일봉] 2번째 위아래(재진입)는 종가가 60선 -1% 이상 깊게 내려가야 인정 (7/8 같은 살짝 스침 방지) # 첫 위아래는 얕아도 인정. 확정되면 그 아래구간 첫날부터 소급 매수 MAX_MTN_WAVE = 2 # [일봉] 위아래 매수는 동산 N개까지 (첫 동산 + 더블동산 = 2). 트리플(3번째)+ 부터는 매수 안 함(관찰만). # 재베이스(깊은눌림→새동산)로 구조가 새로 시작하면 카운트 리셋. REBASE_DEEP_PCT = 0.25 # [일봉] ★속음→재베이스: 위아래인 줄 알고 샀는데 240올(사이클 완주)로 못 가고 # 끝지점(2)이 240선 대비 이보다 더 깊이(예: -25%) 붕괴하면, '작은봉우리→위아래 재개' 대신 # 재베이스(동산 그리기부터 다시)로 보냄. 피크가 계단식하락(제자리 아님)인 경우에만 적용. # 0=끔(기존: 깊어도 위아래 재개). 하이닉스'23(-16.3%)은 재개로 남기려면 이보다 얕게. REBASE_EARLY = True # [일봉] ★조기 판정: 위 재베이스를 'DIP 탈출(60선 240 복귀)'까지 기다리지 말고, # DIP 추적 중에 깊이가 -REBASE_DEEP_PCT를 찍는 순간 바로 재베이스. # (아모레 2018-08 DIP진입 → 2019-12 탈출까지 16개월을 '판정대기'로 끌던 문제 해결) # 안전장치: 더블동산 가능성이 남아있으면(피크갭이 밴드 안 + 끝지점 유지) 조기발동 안 함. # 피크갭은 DIP 진입시 확정, 끝지점갭은 단조감소(한번 실패=영구실패)라 조기판정이 안전. # False면 예전처럼 DIP 탈출 때만 판정. REBASE_DIP_DAYS = 600 # [일봉] ★기간 기반 재베이스: DIP(끝지점2 추적=매수중단)가 이 일수를 넘게 지속되면 # 깊이(60선 gap)와 무관하게 재베이스. '2년짜리 눌림'은 눌림이 아니라 구조 붕괴 후 재건. # 필요한 이유(236200): 2020-10-06~2022-10-07 DIP가 731일. 주가는 -58.4% 빠졌는데 # 60선 vs 240선 gap은 최저 -11.5%뿐이라 REBASE_DEEP_PCT(-25%)에 안 걸림. # → 천천히 오래 흘러내리면 240선도 같이 내려가 두 선 간격이 안 벌어지는 맹점. # 검증: 위아래재개로 끝난 DIP는 최대 490일인데 236200만 731일 → 사이가 텅 빔. # 232140 더블동산(577일)·하이닉스'23 재개(359일)는 600일 미만이라 안전. # 안전장치는 REBASE_EARLY와 동일(더블동산 가능성 있으면 발동 안 함). 0=끔. DOUBLE_MTN_LIFELINE_FLOOR = True # [일봉] ★더블동산 재정의(사용자, 030530): # 끝지점(2)이 끝지점(1)보다 낮아져도, DIP 구간 '저가'가 첫 동산 생명선 위에 있으면 # 시퀀스가 안 죽은 것 → 더블동산으로 인정(생명선·시퀀스 유지, 재베이스 안 함). # 피크갭 밴드(-10%~+42%) 밖(계단식하락/피크전진)은 여전히 재베이스 — 기존 차단 유지. # ※ 600일 장기DIP 재베이스는 이 규칙보다 우선(236200: 시간이 구조를 이김). # False면 기존(끝지점(2)≥끝지점(1) 조건만). WAVE_CONFIRM_GAP = 0.07 # [일봉] ★위아래 복귀 확인: 동산성공/더블동산/재개로 위아래가 열려도, # 60선이 240선 위로 이만큼(+7%) 올라가야 실제 매수 시작. # 60선이 240선을 찔끔 뚫고 24~70일 만에 되꺾이는 '가짜 복귀'(모자형태)에서 # 물리는 것 방지. 검증: 가짜 9건 최대 5.7% vs 진짜 21건 최소 9.5% → 사이가 비어 100% 분리. # 대가: 진짜도 진입이 6~141일 늦어짐(대부분 12~16일). 0 = 이 필터 끔(예전 동작). WAVE_CONFIRM_DAYS = 90 # [일봉] ★시간 기반 복귀 확인 (WAVE_CONFIRM_GAP과 OR): # gap이 +7%에 못 미쳐도, 60선이 240선 위(gap>0)에서 이 일수만큼 '연속 생존'하면 # 진짜 복귀로 인정 → 위아래 매수 시작. 중간에 gap≤0(240 아래로)되면 시간카운터 리셋(가짜). # 근거: 가짜모자는 240 위 생존 최대 85일(000660'08=34, 090430'17=85)에 되꺾이는데, # 진짜(삼성'19)는 gap이 끝내 +7% 미달(최대+1.3%)이어도 240 위 399일 생존 → 시간이 판별. # 0 = 시간 확인 끔(gap만으로 확인). MINBAR_STATE = False # [분봉] ★분봉(5/15/60분)도 일봉식 상태감지 사용 (2026-08): # ⚠️ True로 켜면 분봉이 일봉 DIP '단계'에 들어가 매수구조가 확 바뀜(매수 4~8배 폭증). # John은 "터치매수 그대로"를 원하므로 이 방식(단계 재라우팅)은 부적합 → 기본 OFF. # 상태표시는 '단계 재라우팅' 말고 '병렬 오버레이'로 해야 함(매수 불변). 아래는 옛 설명: # DIP(매수멈춤)·복귀확인·스침·모자를 분봉에도 적용. 시간기준(일)은 '봉 개수'로 환산. # 단 매일매수·소급·가상생명선은 분봉에 안 씀(터치기반 매수 방식은 그대로). # False면 예전처럼 분봉은 순수 터치기반(상태감지 없음). WAVE_TOUCH_BUY = True # [일봉] ★스침 구간 매수 허용 (WAVE_CONFIRM_GAP과 짝): # 60선이 240선을 '스치거나 그 아래'(gap ≤ 0, DIP -2% 진입 전)에 있으면 # 모자(가짜 복귀)가 아예 안 생긴 것 = 바닥 다지는 중 → 위아래 매수 허용. # 결과: 매수 금지 구간은 gap이 (0, +7%) 열린 밴드뿐. 그 위(진짜 복귀)·아래(스침) 다 매수. # False면 예전처럼 60>240 + 확인(+7%) 돼야만 매수. WAVE_HAT_ONLY_FRESH = True # [일봉] ★모자 체크를 '동산 완성 직후 복귀'에만 적용 (사용자 지시 2026-08): # 모자를 의심할 상황은 '많이 오른 뒤 꺾이는 순간' = 동산을 크게 그리고 60선이 240선 위로 # 복귀하는 그 순간. 이미 위아래 시퀀스가 돌아가는 중(더블동산 리셋 / 위아래 재개·계속)에는 # 모자가 생기든 말든 무시하고 스침 매수 허용. # → 위아래를 연 사건이 '동산성공→위아래시작'일 때만 모자 체크. False면 항상 체크. WAVE_HAT_PCT = 0.0103 # [일봉] ★스침 vs 모자 구분 (WAVE_TOUCH_BUY 보정): # '스침(바닥 다지기)'은 아직 한 번도 안 올라간 진짜 바닥이어야 함. 그런데 이 위아래에서 # 이미 60선이 240선 위로 이만큼(+1%) 올라가 '모자 봉우리'를 만든 뒤 다시 gap≤0로 # 내려오면, 그건 바닥이 아니라 '모자의 하강 다리' → 스침 매수 금지(확인 대기 유지). # 근거(166090): 2019-04~05 gap이 +1.5%까지 올랐다가(모자) 5/22 gap-0.0%로 내려오자 # 기존 코드가 '스침=바닥'으로 오판해 매수. 봉우리를 만든 이력을 봐야 진짜 바닥과 구분됨. # 0 = 이 보정 끔(예전처럼 gap≤0이면 무조건 스침 매수). # ※ 임계 1.03%인 이유: 090430 2008 모자 실측 1.0208%를 '모자 아님'으로 두라는 사용자 지시 # ("1.02% 이하는 모자 아님, 초과가 모자"). 바로 위 케이스는 030530 1.0445%, 112610 1.1473%. WAVE_BUY_FLOOR = 0.08 # [일봉] 위아래 매수 하한: 종가가 240선보다 -8% 넘게 깊으면 '깊은 구간'. # 깊은 구간에선 하락 중엔 매수 보류('위아래 속았다'), 반등한 날(종가>전일종가)에만 매수. # → 2008형 계속하락=칼 회피 / 2020형 V바닥=반등순간 재개. 0이면 필터 끔. EARLY_EXTEND = True # [일봉] 상장초기 240선 뒤로연장 ON/OFF. 기본 ON(평행연장 채택). OFF면 기존(i<240 스킵)과 동일 EXTEND_MODE = 'flat' # 'flat'=첫 진짜240값 수평 연장(검증상 초기동산 포착됨) / 'slope'=기울기연장(초기 born-above라 미포착) EXTEND_SLOPE_WIN = 60 # slope 모드 전용: 기울기 = 첫 진짜240 이후 N일 평균 일변화 CYCLE_END = 0.10 # 60선이 240선 -10% 아래 → 사이클 종료 (진짜 추세붕괴. -5%는 너무 빡빡해서 깊은 눌림도 끊김) SELL_ORPHAN_ON_CYCLEEND = True # [채택 2026-08] ★사이클종료로 hold=False 된 뒤에도, 판 적 없는 물량(held)이 # 남아있고 저가가 생명선 밑으로 가면 그 물량 전량매도. # 문제: 사이클종료가 hold를 꺼버려 그 물량이 보호 없이 방치되는 빈틈이 있었음 # (006260 5분봉: 사이클1 60만원이 생명선 439,368 깨져도 안 팔림 → 방치). # ※ 적용 범위는 SELL_ORPHAN_TFS 로 제한(현재 5분/15분봉만). 일봉은 최후보루가 담당하므로 제외. SELL_ORPHAN_TFS = ('5min','15min') # ★위 규칙을 적용할 타임프레임. 일봉/1시간봉 제외(원하면 여기에 추가). SELL_COOLDOWN_DAYS = 90 # 전량매도(최후보루 등) 후 이 기간 매수 금지 (30→90일로 확대) WAVE_OVERRIDE_COOLDOWN = True # [일봉] ★일봉 1·2번째 위아래 매수는 90일 쿨다운보다 최우선 → 쿨다운 무시하고 매수. # (분봉 5·15·60분·기타 매수는 그대로 90일 금지 유지). False면 일봉도 쿨다운 적용. VCP_MULT = 3 # VCP 살아있는 구간에서 위아래 매수 시 배수 AMT = {'5min': {'a': 200_000, 'b': 300_000}, '15min': {'a': 300_000, 'b': 500_000}, '1hour': {'a': 800_000, 'b': 1_200_000}, # 일봉: 터치 1회 매수가 아니라 '60선 아래 있는 동안 매일 반복 매수' '1day': {'daily1': 1_000_000, 'daily2': 500_000}} def _daily_peaks(df): """일봉 피크 사전 계산 (peak_gap_debug와 동일 로직). 60선이 240선 위에 있는 각 구간의 60선 최고점. 반환 [(피크일자str, 피크60선값)]""" d = df[(df['Close'] > 0)].copy() c = d['Close'] m60 = c.rolling(MA_60, min_periods=20).mean() m240 = c.rolling(MA_240, min_periods=60).mean() ok = m60.notna() & m240.notna() above = (m60 > m240) & ok grp = (above != above.shift()).cumsum() peaks = [] for g, idx in d.index.to_series().groupby(grp): seg = idx.index if not above.loc[seg[0]]: continue if len(seg) < 5: continue seg_m60 = m60.loc[seg] pk_i = seg_m60.idxmax() pk_gap = m60.loc[pk_i] / m240.loc[pk_i] - 1 if pk_gap < PEAK_MIN_PCT['1day']: continue peaks.append((str(pk_i)[:10], float(m60.loc[pk_i]))) return peaks LAST_STATUS = '' # be.run 후 '지금 상태' 한 줄 요약 (full_debug/app이 읽음) LAST_STATUS_KIND = 'wait' # 상태 종류(색칠용): buy/danger/dip/done/wait LAST_STATUS_SHORT = '' # 짧은 상태명(라벨용): '산봉우리 그리는 중' 등 LAST_STATUS_DETAIL = '' # 상태 상세(뒷부분): '시작 바닥 ... / 생명선 ...' def run(df, tf='5min', sf=None, sell_dates=None, vcp_wins=None, block_ranges=None): # sell_dates: 전량매도 발생일 리스트. 이후 SELL_COOLDOWN_DAYS 매수금지 # block_ranges: [(start,end)] 이 구간엔 매수 금지 (분봉용 — 일봉이 DIP(매수중단)이면 분봉도 멈춤) # vcp_wins: VCP 유효구간 [{'start','end'}]. 이 구간의 위아래 매수는 VCP_MULT(3배) daily = (tf == '1day') # 일봉 = 매일 반복매수 모드 if daily: d1 = AMT['1day']['daily1']; d2 = AMT['1day']['daily2'] a = b = 0 else: a = AMT[tf]['a']; b = AMT[tf]['b'] recov = RECOVER_PCT[tf]; wrise = WAVE_RISE_PCT[tf] day_peaks = _daily_peaks(df) if tf=='1day' else [] def _peak_before(ds): """ds(YYYY-MM-DD) 이전의 마지막 피크값. 없으면 None""" out=None for pdt,pv in day_peaks: if pdt < ds: out=pv else: break return out pmin = PEAK_MIN_PCT[tf]; emin = ENDLOW_MIN_PCT[tf] # 데이터 클리닝: 0/음수 가격 봉 제거 (액면분할 등 아티팩트) df = df[(df['Close']>0) & (df['Low']>0) & (df['High']>0)].copy() # 전량매도 후 쿨다운(매수금지) 판정 _sd = sorted(pd.to_datetime(sell_dates)) if sell_dates else [] _br = [(pd.Timestamp(a), (pd.Timestamp(b) if b is not None else None)) for a,b in (block_ranges or [])] def _blocked(ts): # 막혔으면 사유 문자열 반환(어느 전량매도/DIP 때문인지), 안 막혔으면 None for sd in _sd: if sd <= ts <= sd + pd.Timedelta(days=SELL_COOLDOWN_DAYS): return f"{str(sd)[:10]} 전량매도 후 {SELL_COOLDOWN_DAYS}일 매수금지" for a,b in _br: # 일봉 DIP(매수중단) 구간 → 분봉도 금지 if ts >= a and (b is None or ts < b): return f"일봉 {str(a)[:10]}부터 매수멈춤(눌림 판정) 구간이라 금지" return None c = df['Close']; lo = df['Low'] ma60 = c.rolling(MA_60, min_periods=20).mean() bbl = c.rolling(BB_WIN).mean() - BB_STD * c.rolling(BB_WIN).std() # ── 240선: 진짜값 + 상장초기 선형 뒤로연장(기울기 보존) ── # 240봉이 안 쌓인 극초기는 첫 진짜240값에서 시작기울기로 과거로 외삽 → 선을 왼쪽으로 늘려 그림 ma240_true = c.rolling(MA_240).mean() # min_periods=240 (진짜) ma240 = ma240_true.copy() if EARLY_EXTEND and ma240_true.notna().any(): i0 = int(ma240_true.notna().values.argmax()) # 첫 진짜240 바 v0 = float(ma240_true.iloc[i0]) slope = 0.0 if EXTEND_MODE=='flat' else float(ma240_true.diff().iloc[i0:i0+EXTEND_SLOPE_WIN].mean()) vals = ma240.values.copy() for _j in range(i0): vals[_j] = v0 - slope*(i0 - _j) ma240 = pd.Series(vals, index=ma240.index) idx = df.index HI = 9e12 phase='WAIT' # WAIT → PEAK(산봉우리,끝지점) → WAVE(위아래) start_ma60=HI; start_bb60=None # 시작점 후보A: 60선 최저 + 그 BB start_pxlow=HI; start_bbpx=None # 시작점 후보B: 가격 최저 + 그 BB base_bbmin=HI # 후보C(B안): 베이스 구간 전체의 최저 BB하단 life=None peak_gap=-9.9 endlow_gap=0.0; endlow_ma60=HI; endlow_px=HI; endlow_bb60=None; endlow_bbpx=None; endlow_dt=None # 끝지점 start_dt=None; mtn_s60=HI; mtn_sdt=None prev_end60=None; peak_m60=0.0 dip_ma60=HI; dip_bb60=None; dip_px=HI; dip_bbpx=None; dip_dt=None; dip_gap=0.0; wave_peak_ma60=0.0; prev_mtn_peak=None; dip_peak=0.0 dip_start_dt=None; dip_start_i=None # DIP 시작일/시작봉 (기간 판정: 일봉=일수, 분봉=봉수) dip_floor_logged=False # '조기재베이스 보류(생명선바닥)' 이벤트 1회 출력용 vlife=None; vseg_i=0 # 가상 생명선 / 이 작은언덕 매수 시작 인덱스(buys 기준) vbreak_dt=None # 가상생명선 아래로 처음 내려간 날 (유예 판정용) one_wave=False # 이번 위아래는 1번째만 매수(깊은 눌림 뒤 재개) dip_bbmin=HI # DIP 구간 전체의 최저 BB하단 (DOUBLE_MTN_RELIFE: 새 생명선 후보) wave_confirmed=False; conf_logged=False; touch_logged=False # 위아래 복귀 확인 여부(가짜 복귀 차단) cross_up_dt=None # 이번 위아래에서 60선이 240선 위(gap>0)로 처음 올라간 날 (시간 기반 확인용) wave_hat=0.0 # 이번 위아래에서 60선이 240선 위로 만든 최대 gap (모자 봉우리 감지 — 스침 오판 방지) wave_fresh=True # 이 위아래를 연 사건이 '동산성공'인가(=많이 오른 뒤 복귀) — 모자 체크 대상 여부 life_broken=False # PEAK 중 생명선 이탈했는지 (LIFELINE_KEEP_MTN: 손절했지만 동산은 계속 관찰) wave=0; armed=False; b240={}; flag240up=False; mtn_count=0 # mtn_count: 위아래 매수한 동산 순번(첫=1, 더블=2, 트리플=3…) hold=False; lwl=None; lwl_c=None; daily_open=False; pend=[]; up_peak=0.0; up_days=0 held=False # 실보유(판 적 없는 물량 있음) — hold(매수국면중)와 별개. 실제 전량매도 때만 False prev_above=None ev=[]; buys=[]; mts=[]; blocked_n=[0] def _in_vcp(dt_): if not vcp_wins: return False ds_ = str(dt_)[:10] for w in vcp_wins: if ds_ >= w['start'] and (w['end'] is None or ds_ < w['end']): return True return False def _buy(dt_, px_, amt_, lbl_, force=False): # force=True → 전량매도 90일 쿨다운 무시(일봉 1·2번째 위아래 최우선). 분봉/기타는 force=False라 쿨다운 유지. _why = None if force else _blocked(dt_) if _why: blocked_n[0]+=1 ev.append((dt_,' └매수보류',_why)) return False if _in_vcp(dt_): # ★ VCP 살아있는 구간 → 3배 amt_ = amt_ * VCP_MULT lbl_ = lbl_ + f' ×{VCP_MULT}(VCP)' nonlocal held buys.append((dt_,px_,amt_,lbl_)); held=True; return True for i in range(len(df)): if i < (MA_60 if EARLY_EXTEND else MA_240): # ★ 상장 초기(60선 생기기 전) 급락 BB하단도 생명선 후보로 추적. # 60선이 없어 이 봉들은 관찰에서 빠지지만, BB하단(20봉)은 이미 있으므로 최저값은 기억해 둠. # (166090: 2017-05-31 BB하단 7,473이 첫 동산 생명선 후보에서 빠지던 문제 해결) if EARLY_EXTEND and LIFELINE_BASE_MIN: _bl0 = float(bbl.iloc[i]) if not np.isnan(_bl0) and _bl0 < base_bbmin: base_bbmin = _bl0 continue d=idx[i]; C=float(c.iloc[i]); L=float(lo.iloc[i]) m60=float(ma60.iloc[i]); m240=float(ma240.iloc[i]); bl=float(bbl.iloc[i]) if np.isnan(m60) or np.isnan(m240) or np.isnan(bl): continue above = m60 > m240 gap = m60/m240 - 1 # ★ [측정용] 고아물량 매도: 사이클종료로 hold=False 됐지만 판 적 없는 물량(held)이 남아 # 생명선 밑으로 가면 그때 전량매도. WAIT 단계 전용(다른 단계는 자체 생명선 체크가 있음). if SELL_ORPHAN_ON_CYCLEEND and tf in SELL_ORPHAN_TFS and held and (not hold) and phase=='WAIT' and life and L < life: ev.append((d,'생명선이탈→전량매도(사이클종료후 잔여물량)', f'저가 {L:,.0f} < 생명선 {life:,.0f} — 사이클 끝난 뒤 남아있던 물량 손절')) held=False # ★ 가상 생명선 이탈: 이 작은언덕에서 산 물량만 매도 + 재베이스(동산부터 다시) if VIRTUAL_LIFELINE and vlife and phase in ('WAVE','DIP'): if C >= vlife: vbreak_dt=None # 종가가 선 위로 회복 → 유예 리셋(없던 일) elif L < vlife and vbreak_dt is None: vbreak_dt=d # 처음 깬 날 기록 if (VIRTUAL_LIFELINE and vlife and phase in ('WAVE','DIP') and vbreak_dt is not None and (d - vbreak_dt).days >= VLIFE_GRACE_DAYS): _sq = buys[vseg_i:] _amt = sum(x[2] for x in _sq) _sh = sum(x[2]/x[1] for x in _sq) _avg = (_amt/_sh) if _sh else 0 ev.append((d,'가상생명선이탈→구간매도', f'저가 {L:,.0f} < 가상생명선 {vlife:,.0f} — 이 작은언덕 매수분만 매도(진짜 생명선 {life:,.0f}는 유지) ' f'| 구간매수 {len(_sq)}회 {_amt:,.0f}원 평단 {_avg:,.0f} | {VLIFE_GRACE_DAYS}일 유예 내내 회복 실패({str(vbreak_dt)[:10]}부터) → 동산부터 다시')) vlife=None; vseg_i=len(buys); vbreak_dt=None mtn_s60 = m60; mtn_sdt = d life = min(bl, life if life else bl) phase='PEAK'; peak_gap=gap; peak_m60=m60; vlife=None; vbreak_dt=None; vseg_i=len(buys) # ★새 산 그리기 시작 → 옛 가상생명선 폐기 endlow_gap=0.0; endlow_ma60=HI; endlow_px=HI hold=False; wave=0; armed=False; b240={}; flag240up=False daily_open=False; pend=[]; up_peak=0.0; up_days=0 mtn_count=0; life_broken=False wave_confirmed=False; conf_logged=False; touch_logged=False; cross_up_dt=None; wave_hat=0.0 continue if phase=='WAIT': # ★ 베이스 구간 전체의 최저 BB하단 추적 (above/below 무관 — 평행연장 구간 대응) if bl < base_bbmin: base_bbmin = bl # 60선이 240 아래 → '60선 최저점' 추적 (가격 급락봉 아님) if not above: if m60 < start_ma60: # 60선 최저점 start_ma60=m60; start_bb60=bl; start_dt=d if L < start_pxlow: # 가격 최저점 start_pxlow=L; start_bbpx=bl # 60선이 240 위로 크로스 → 산봉우리 시작 if prev_above is False and above and start_bb60 is not None: bb_low = min(start_bb60, start_bbpx) # ★ 둘 중 더 낮은 BB하단 (스파이크 후행값 자동 회피) if LIFELINE_BASE_MIN and base_bbmin < HI: bb_low = min(bb_low, base_bbmin) # ★B안: 베이스 전체 최저 BB하단까지 고려 life=bb_low*(1-LIFELINE_MARGIN) base_bbmin=HI # 새 동산 시작 → 베이스 추적 리셋 mtn_s60=start_ma60; mtn_sdt=start_dt # 이 동산의 시작점 고정 life_broken=False phase='PEAK'; peak_gap=gap; peak_m60=m60; vlife=None; vbreak_dt=None; vseg_i=len(buys) # ★새 산 그리기 시작 → 옛 가상생명선 폐기 endlow_gap=0.0; endlow_ma60=HI; endlow_px=HI ev.append((d,'산봉우리시작',f'동산 시작점 {str(mtn_sdt)[:10]} (60선 {mtn_s60:,.0f}) / 생명선 {life:,.0f}')) elif phase=='PEAK': if peak_gap < gap: peak_gap=gap if above and m60 > peak_m60: peak_m60=m60 _life_hit = bool(life) and L < life if _life_hit and not LIFELINE_KEEP_MTN: ev.append((d,'생명선이탈→실패',f'저가 {L:,.0f} < 생명선 {life:,.0f}')) phase='WAIT'; start_ma60=HI; start_bb60=None; start_pxlow=HI; start_bbpx=None else: # ★ LIFELINE_KEEP_MTN: 손절(전량매도)은 하되 동산 구조는 폐기하지 않고 계속 관찰. # 나중에 60선이 240 위로 복귀할 때 '끝지점 60선 > 시작점 60선'이면 동산성공 인정. if _life_hit and not life_broken: # ★ hold 체크 필수: 재베이스 직후 등 보유물량이 없으면 '전량매도'가 아니라 단순 이탈 기록. # (안 그러면 full_debug가 유령 손절을 P&L에 넣음) _t = '생명선이탈→전량매도(동산유지)' if hold else '생명선이탈(미보유·동산유지)' if hold: held=False # 실제 전량매도 → 실보유 청산 life_broken=True; hold=False ev.append((d,_t, f'저가 {L:,.0f} < 생명선 {life:,.0f} — ' + ('손절하되' if _t.startswith('생명선이탈→전량') else '보유물량 없음(매도 아님).') + ' 동산 폐기 안 함(끝지점 60선이 시작점보다 높으면 동산성공 인정)')) if not above: # 끝지점 dip if gap < endlow_gap: endlow_gap=gap if m60 < endlow_ma60: endlow_ma60=m60; endlow_bb60=bl; endlow_dt=d if L < endlow_px: endlow_px=L; endlow_bbpx=bl elif above and endlow_ma60 < HI: # 60선 240 위로 복귀 → 성패 판정 big_peak = peak_gap >= pmin deep_end = endlow_gap <= -emin higher = endlow_ma60 > mtn_s60 # 60선 더 높은 저점 if big_peak and deep_end and higher: # ★ 생명선을 깼었어도(life_broken) 끝지점 60선이 시작점보다 높으면 = 구조 유지 → 동산성공. # 단 생명선은 이번 저점(끝지점 BB하단)으로 재설정 (옛 생명선은 이미 깨졌으므로 무의미). _revived = life_broken if life_broken: life = min(endlow_bb60, endlow_bbpx)*(1-LIFELINE_MARGIN) life_broken=False mtn_count=1 # ★ 새 시퀀스의 첫 동산 → 위아래 매수 시작 vlife=None; vbreak_dt=None; vseg_i=len(buys) # ★새 동산성공 = 새 시퀀스 → 옛 가상생명선 폐기 wave_fresh=True # 동산 완성 직후 복귀 → 모자 체크 대상 one_wave=False # 새 동산 → 1·2번째 다 매수 wave_confirmed=False; conf_logged=False; touch_logged=False; cross_up_dt=None; wave_hat=0.0 # 복귀확인 리셋 phase='WAVE'; wave=0; armed=False; b240={}; flag240up=False; hold=False; lwl=None; lwl_c=None; daily_open=False; pend=[]; up_peak=0.0; up_days=0; pend=[] mts.append({'start':d,'s60':mtn_s60,'e60':endlow_ma60,'life':life,'endpx':endlow_px,'sdt':mtn_sdt,'edt':endlow_dt}) prev_end60=endlow_ma60 # ★ 끝지점(1) 60선 — 더블동산 끝지점 비교 기준 prev_mtn_peak=peak_m60; wave_peak_ma60=0.0 # ★ 이 동산의 피크(60선 최고) = 다음 판정의 비교 기준 ev.append((d,'동산성공→위아래시작', f'시작점 {str(mtn_sdt)[:10]}({mtn_s60:,.0f}) → 끝지점 {str(endlow_dt)[:10]}({endlow_ma60:,.0f}) ↑ | 봉우리+{peak_gap*100:.1f}% [1번째 동산]' + (f' ★생명선 깼었지만 끝지점 60선이 시작점보다 높아 구조유지 → 동산성공 인정 / 생명선 재설정 {life:,.0f}' if _revived else ''))) elif not higher: # 끝지점이 시작점보다 낮음 = 더 낮은 저점(구조 붕괴) → 그 저점으로 재베이스 _os60=mtn_s60; _osdt=mtn_sdt # 실패한 산의 시작점(비교용, 덮어쓰기 전 저장) _drop=(endlow_ma60/_os60-1)*100 if _os60 else 0 mtn_s60 = endlow_ma60; mtn_sdt = endlow_dt life = min(endlow_bb60, endlow_bbpx)*(1-LIFELINE_MARGIN) mtn_count=0 # 구조 붕괴 → 시퀀스 리셋 life_broken=False ev.append((d,'동산실패→재베이스(더낮은저점)', f'이 산 실패 — 시작 바닥 {str(_osdt)[:10]}(60선 {_os60:,.0f})보다 끝 바닥 {str(endlow_dt)[:10]}(60선 {endlow_ma60:,.0f})이 {_drop:+.1f}% 더 낮음(=더 낮은 저점, 구조 붕괴). 이 낮은 끝 바닥을 새 시작점으로 산 다시 그림 / 새 생명선 {life:,.0f}')) peak_gap=gap; peak_m60=m60; endlow_gap=0.0; endlow_ma60=HI; endlow_px=HI # 새 산봉우리로 else: # 봉우리/끝지점이 작음(노이즈) → 무시하고 같은 PEAK 유지 why=[] if not big_peak: why.append(f'봉우리+{peak_gap*100:.2f}%<{pmin*100:.1f}%') if not deep_end: why.append(f'끝지점{endlow_gap*100:.2f}%>-{emin*100:.1f}%') ev.append((d,'동산아님(노이즈/작음)→무시',' / '.join(why))) endlow_gap=0.0; endlow_ma60=HI; endlow_px=HI elif phase=='WAVE': if life and L < life: tag='생명선이탈→전량매도' if hold else '생명선이탈→매도' ev.append((d,tag,f'저가 {L:,.0f} < 생명선 {life:,.0f}')) if hold: held=False # 실제 전량매도 → 실보유 청산 phase='WAIT'; hold=False; start_ma60=HI; start_bb60=None; start_pxlow=HI; start_bbpx=None elif m60 < m240*(1-CYCLE_END): ev.append((d,'사이클종료',f'60MA {m60:,.0f} < 240 -{CYCLE_END*100:.0f}%')) phase='WAIT'; hold=False; start_ma60=HI; start_bb60=None; start_pxlow=HI; start_bbpx=None elif (daily or MINBAR_STATE) and m60 < m240*(1-DOUBLE_MTN_PCT): # ★ 더블동산 후보: 위아래 중 60선이 240선 -2% 아래로 → 끝지점(2) 추적 시작 (일봉+분봉) # 나중에 60선이 240 위로 복귀할 때 판정: # 끝지점(2) < 끝지점(1) → 더블동산 확정 (매수 중단, 위아래 리셋) # 끝지점(2) ≥ 끝지점(1) → 그냥 눌림 (위아래 계속) phase='DIP' dip_ma60=m60; dip_bb60=bl; dip_px=L; dip_bbpx=bl; dip_dt=d; dip_gap=gap dip_start_dt=d; dip_start_i=i # ★ DIP 시작일/봉 (재베이스 기간판정: 일봉=일수/분봉=봉수) dip_bbmin=bl # ★ DIP 구간 최저 BB하단 추적 시작 dip_floor_logged=False dip_peak=wave_peak_ma60 # DIP 직전까지 = 이번 산의 피크 확정 ev.append((d,'끝지점(2) 추적중→매수중단', f'매수 잠시 멈춤 — 60선 {m60:,.0f}이 240선 밑으로 -{DOUBLE_MTN_PCT*100:.0f}% 넘게 꺾임. 여기가 이번 눌림 시작({str(d)[:10]}). 더블동산인지 무너진 건지 지켜보는 중')) elif above or (daily and WAVE_TOUCH_BUY and gap <= 0): # above=60>240 (정상 위아래) / gap<=0 = 스침구간(240선 위로 못 올라감, DIP 전) → 바닥 다지기로 보고 매수 허용 if m60 > wave_peak_ma60: wave_peak_ma60 = m60 # 현재 위아래 구간의 60선 최고(피크)만 추적 if gap > wave_hat: wave_hat = gap # ★ 이 위아래에서 만든 최대 gap (모자 봉우리 감지) if daily: # ===== 일봉: '위아래' = 60선 아래로 내려간 구간(에피소드) ===== bnc = (i>0 and C > float(c.iloc[i-1])) # 반등일(종가>전일종가) — 깊은 구간 매수 게이트 # ★ 복귀 확인: 60선이 240선 위로 WAVE_CONFIRM_GAP 도달해야 매수 시작 (가짜 복귀=모자형태 차단) if WAVE_CONFIRM_GAP and not wave_confirmed: if gap >= WAVE_CONFIRM_GAP: wave_confirmed = True ev.append((d,'위아래 확인(진짜 복귀)', f'60선이 240선 +{gap*100:.1f}% ≥ +{WAVE_CONFIRM_GAP*100:.0f}% 도달 → 위아래 매수 시작')) elif gap > 0: # gap이 +7% 미달이라도 60선이 240선 위에서 '연속 생존'한 일수로 확인 (시간 기반, OR) if cross_up_dt is None: cross_up_dt = d # 240 위로 올라온 첫날 앵커 surv = (d - cross_up_dt).days if WAVE_CONFIRM_DAYS and surv >= WAVE_CONFIRM_DAYS: wave_confirmed = True ev.append((d,'위아래 확인(시간생존)', f'60선이 240선 위(gap {gap*100:+.1f}%)에서 {surv}일 연속 생존 ≥ {WAVE_CONFIRM_DAYS}일 → 진짜 복귀(모자 아님) → 위아래 매수 시작')) elif not conf_logged: conf_logged = True ev.append((d,'복귀확인 대기(매수보류)', f'60선 +{gap*100:.1f}% < +{WAVE_CONFIRM_GAP*100:.0f}% — 가짜 복귀(모자) 가능성, gap도달 or {WAVE_CONFIRM_DAYS}일생존까지 매수 안 함')) else: cross_up_dt = None # 240 아래로(gap≤0) 되꺾임 → 시간 카운터 리셋(가짜 가능성) # ★ 스침구간(gap ≤ 0): 모자가 '아직 안 생긴' 것 = 바닥 다지기 → 확인 없이도 매수 허용. # 단 이 위아래에서 이미 모자 봉우리(gap ≥ WAVE_HAT_PCT)를 만든 뒤 내려온 거면 # 그건 바닥이 아니라 모자 하강다리 → 스침 매수 금지(확인 대기 유지). _hat_formed = (WAVE_HAT_PCT and wave_hat >= WAVE_HAT_PCT and ((not WAVE_HAT_ONLY_FRESH) or wave_fresh)) _touch = (WAVE_TOUCH_BUY and gap <= 0 and not _hat_formed) if WAVE_HAT_PCT and gap <= 0 and _hat_formed and not conf_logged: conf_logged = True ev.append((d,'복귀확인 대기(모자하강)', f'이 위아래에서 60선이 240선 +{wave_hat*100:.1f}%까지 올랐다 내려옴(모자 봉우리) — 스침 아님, 매수 보류')) _can_buy = (not WAVE_CONFIRM_GAP) or wave_confirmed or _touch if _touch and not touch_logged: touch_logged = True ev.append((d,'스침구간(바닥) 매수허용', f'60선 {gap*100:+.1f}% ≤ 0 (240선 스침/아래) — 모자 아님 → 위아래 매수 진행')) if daily_open: if C < m60: # 아래 구간 진행 중 → 매일 매수 (위로 튀었다 와도 같은 구간) amt = d1 if wave==1 else d2; hold=True if WAVE_BUY_FLOOR and C < m240*(1-WAVE_BUY_FLOOR) and not bnc: # 240선 -8% 아래 + 하락(반등 아님) → 보류. 반등하면 그날 매수 재개 ev.append((d,' └매수보류(240 -{:.0f}%·반등대기)'.format(WAVE_BUY_FLOOR*100), f'종가 {C:,.0f} < 240 -{WAVE_BUY_FLOOR*100:.0f}% ({(C/m240-1)*100:+.1f}%) 하락중 — 위아래 속았다, 반등시 재개')) elif _buy(d,C,amt,f'{wave}번 일봉매수', force=(daily and WAVE_OVERRIDE_COOLDOWN)): _deeptag = ' (240 -{:.0f}%↓ 반등확인)'.format(WAVE_BUY_FLOOR*100) if (WAVE_BUY_FLOOR and C < m240*(1-WAVE_BUY_FLOOR)) else '' ev.append((d,' └매수',f'{amt:,}원 (일봉 반복){_deeptag}')) up_peak=0.0; up_days=0 # 아래로 내려오면 위 최고치/연속일수 리셋 else: # 60선 위 → 최고 상승률 + 연속일수 추적 (매수는 쉼) rise = C/m60 - 1 if rise > up_peak: up_peak = rise up_days += 1 if up_peak >= WAVE_EXIT_PCT and up_days >= WAVE_UP_DAYS: # 충분히 높이 + 충분히 오래 위에 있음 → 이 위아래(아래 구간) 종료 daily_open=False; pend=[] ev.append((d,f'{wave}번째 아래 종료', f'위 구간 최고 +{up_peak*100:.1f}% ≥ +{WAVE_EXIT_PCT*100:.0f}%, {up_days}일 연속 위 → 진짜 위 구간')) if wave>=2: flag240up=True ev.append((d,'240올(추매종료)','2번째 위아래 끝 — 다시 240선 아래로 꺾일 때까지 대기')) up_peak=0.0; up_days=0 else: # 아래 구간 대기 중 if flag240up: pass elif C < m60 and _can_buy: # 60선 아래로 내려옴 → 후보 구간에 쌓아둠 (아직 확정 전). bnc=그날 반등여부 pend.append((d,C,m240,bnc)) first_wave = (wave==0) deep_ok = first_wave or (C <= m60*(1-WAVE_DEEP_PCT)) if deep_ok: # 확정! → 새 위아래 시작, 못 산 날들 소급 매수 wave+=1 _cap = 1 if (DEEP_DIP_ONE_WAVE and one_wave) else 2 if wave<=_cap: daily_open=True; hold=True amt = d1 if wave==1 else d2 d0 = pend[0][0] tagdeep = '' if first_wave else f' (깊이 {(C/m60-1)*100:+.1f}% ≤ -{WAVE_DEEP_PCT*100:.0f}%)' ev.append((d,f'{wave}번째 아래 시작', f'종가 {C:,.0f} < 60선 {m60:,.0f}{tagdeep}')) if len(pend)>1: ev.append((d,' └소급매수',f'{str(d0)[:10]}부터 못산 {len(pend)-1}일치 함께 매수')) for pd_, pc_, pm_, pb_ in pend: if WAVE_BUY_FLOOR and pc_ < pm_*(1-WAVE_BUY_FLOOR) and not pb_: ev.append((pd_,' └매수보류(240 -{:.0f}%·반등대기)'.format(WAVE_BUY_FLOOR*100), f'종가 {pc_:,.0f} < 240 -{WAVE_BUY_FLOOR*100:.0f}% ({(pc_/pm_-1)*100:+.1f}%) 하락중')) elif _buy(pd_,pc_,amt,f'{wave}번 일봉매수', force=(daily and WAVE_OVERRIDE_COOLDOWN)): ev.append((pd_,' └매수',f'{amt:,}원 (매일 {amt//10000}만)')) pend=[] else: flag240up=True; pend=[] ev.append((d,'240올(추매종료)','2번째 위아래 후 — 다시 240선 아래로 꺾일 때까지 대기')) else: # 60선 위로 복귀 → 확정 못한 후보 구간은 버림 (살짝 스침이었음) if pend: pend=[] else: # ===== 분봉/시간봉: 기존 터치 기반 (검증됨, 변경 없음) ===== if armed and L <= m60: wave+=1; armed=False; lwl=L; lwl_c=C if wave <= 2: ev.append((d,f'{wave}번째 위아래',f'저가 {L:,.0f} ≤ 60MA {m60:,.0f}')) mlt=2 if wave==1 else 1; amt=a*mlt; hold=True if _buy(d,C,amt,f'{wave}번 60아래 x{mlt}'): ev.append((d,' └매수',f'{amt:,}원')) elif not flag240up: flag240up=True ev.append((d,'240올(추매종료)','2번째 위아래 후 — 다시 240선 아래로 꺾일 때까지 대기')) # 240근처 = 대기 지정가 주문. 현재 위아래 슬롯만 체결. # 이전 위아래 미체결분은 소멸(하락 물타기 방지). # ★ 240올(3번째 위) 되면 대기주문도 취소 — 사이클 꺾이는 중엔 추매 금지 if (not flag240up) and wave in (1,2) and not b240.get(wave): if L <= m240*(1+NEAR_240): mlt=2 if wave==1 else 1; amt=b*mlt; hold=True; b240[wave]=True if _buy(d,C,amt,f'{wave}번 240근처 x{mlt}'): ev.append((d,' └매수',f'{amt:,}원 ({wave}번 240근처)')) if (not daily) and (not armed) and lwl_c and C > lwl_c*(1+recov): armed=True # 1번째 위아래 무장: 가격 종가가 60선 위로 +WAVE_RISE_PCT 올라와야 ('위' 확인) if (not armed) and wave==0 and C > m60*(1+wrise): armed=True elif phase=='DIP': # 끝지점(2) 추적 중 (매수 중단 상태) if life and L < life: tag='생명선이탈→전량매도' if hold else '생명선이탈→매도' ev.append((d,tag,f'저가 {L:,.0f} < 생명선 {life:,.0f}')) if hold: held=False # 실제 전량매도 → 실보유 청산 phase='WAIT'; hold=False; start_ma60=HI; start_bb60=None; start_pxlow=HI; start_bbpx=None elif not above: # 끝지점(2) 최저 갱신 (★ 사이클종료 없음 — 끝지점(1)과 동일하게 생명선만이 사망조건) if gap < dip_gap: dip_gap=gap if bl < dip_bbmin: dip_bbmin=bl # ★ DIP 구간 최저 BB하단 if m60 < dip_ma60: dip_ma60=m60; dip_bb60=bl; dip_dt=d if L < dip_px: dip_px=L; dip_bbpx=bl # ★ 조기 '속음' 판정: 240 복귀를 기다리지 않고, 깊이가 임계를 찍는 순간 재베이스. # 단 더블동산 가능성이 살아있으면(피크갭 밴드 안 + 끝지점 유지) 발동 안 함 → 더블동산 보존. # ★ 기간 기반(REBASE_DIP_DAYS)도 같은 자리에서 OR로 판정: # 깊이가 얕아도 DIP가 너무 오래 지속되면(2년짜리 눌림=눌림 아님) 재베이스. _deep_hit = bool(REBASE_EARLY and REBASE_DEEP_PCT and dip_gap <= -REBASE_DEEP_PCT) _elapsed = ((d - dip_start_dt).days if daily else (i - dip_start_i)) if dip_start_dt is not None else 0 _long_hit = bool(REBASE_DIP_DAYS and dip_start_dt is not None and _elapsed >= REBASE_DIP_DAYS) if _deep_hit or _long_hit: _p0 = dip_peak if dip_peak > 0 else None _pg0 = (_p0/prev_mtn_peak - 1) if (prev_mtn_peak and _p0) else None _eg0 = (dip_ma60/prev_end60 - 1) if prev_end60 else None _endok0 = (_eg0 is None) or (_eg0 >= -DOUBLE_MTN_END_TOL) _floor0 = bool(DOUBLE_MTN_LIFELINE_FLOOR and life and dip_px > life) # 저가가 첫 생명선 위 _band0 = (DOUBLE_MTN_GAP > 0 and _pg0 is not None and DOUBLE_MTN_GAP_MIN <= _pg0 < DOUBLE_MTN_GAP) _dbl_strict = _band0 and _endok0 # 원래 기준(끝지점 유지) _dbl_floor = _band0 and (_endok0 or _floor0) # +생명선바닥 인정 # ★깊이(-25%) 트리거는 생명선바닥까지 인정해서 막고(030530 보호: 저가>첫생명선이면 시퀀스 생존), # 장기(600일) 트리거는 원래 기준만으로 막음(236200 유지: 시간이 생명선바닥보다 우선). _blk = _dbl_floor if (_deep_hit and not _long_hit) else _dbl_strict if _deep_hit and (not _long_hit) and _floor0 and (not _endok0) and _blk and (not dip_floor_logged): dip_floor_logged=True ev.append((d,'조기재베이스 보류(생명선바닥)', f'다시 안 그림 — 많이({dip_gap*100:.1f}%) 빠졌지만 최저가 {dip_px:,.0f}가 첫 생명선 {life:,.0f}보다 위. 아직 구조 안 무너짐 → 더블동산 가능성 살려둠')) if not _blk: _why0 = ('계단식하락' if (_pg0 is not None and _pg0 < DOUBLE_MTN_GAP_MIN) else ('끝지점 낮아짐' if not _endok0 else ('피크전진' if _pg0 is not None else '피크데이터 없음'))) mtn_s60 = dip_ma60; mtn_sdt = dip_dt life = min(dip_bb60, dip_bbpx)*(1-LIFELINE_MARGIN) _dur0 = _elapsed # 일봉=일수 / 분봉=봉수 if _deep_hit: _tag0 = '속음→재베이스(조기판정)' _rsn0 = f'60선이 240선 밑으로 {dip_gap*100:.1f}% 깊이 빠짐(기준 -{REBASE_DEEP_PCT*100:.0f}% 이하). 눌림 시작 {str(dip_start_dt)[:10]}부터 {_dur0}일째' else: _tag0 = '속음→재베이스(장기DIP)' _rsn0 = (f'눌림이 {_dur0}일(약 {_dur0//365}년)이나 지속({str(dip_start_dt)[:10]}~{str(d)[:10]}, 기준 {REBASE_DIP_DAYS}일). ' f'깊이는 {dip_gap*100:.1f}%로 얕아도 이렇게 오래 끌면 눌림 아니라 무너짐') ev.append((d,_tag0, f'속았음 → 처음부터 다시 — {_rsn0} / 이번 봉우리는 직전 산보다 {(_pg0*100 if _pg0 is not None else 0):+.1f}%({_why0}) / 240선 복귀 안 기다리고 위아래 접음 / 새 시작점 {str(dip_dt)[:10]}(60선 {dip_ma60:,.0f}) / 새 생명선 {life:,.0f}')) phase='PEAK'; peak_gap=gap; peak_m60=m60; vlife=None; vbreak_dt=None; vseg_i=len(buys) # ★새 산 그리기 시작 → 옛 가상생명선 폐기 endlow_gap=0.0; endlow_ma60=HI; endlow_px=HI hold=False; wave=0; armed=False; b240={}; flag240up=False daily_open=False; pend=[]; up_peak=0.0; up_days=0 mtn_count=0; life_broken=False wave_confirmed=False; conf_logged=False; touch_logged=False; cross_up_dt=None; wave_hat=0.0 prev_mtn_peak=_p0; wave_peak_ma60=0.0 else: # 60선이 240 위로 복귀 → 더블동산인지 판정 (피크 갭 기준) # 직전 산 피크(prev_mtn_peak) vs 이번 산 피크(dip_peak, DIP 직전까지의 최고) # dip_dt(끝지점2) 이전의 마지막 피크 = 이번 산 피크 # 그 피크 이전의 피크 = 직전 산 피크 (사전계산 리스트에서 조회 — 상태추적 안 함) # ★ 동산 단위 피크 비교 (세그먼트 조각 아님): # 이번 산 피크 = dip_peak (DIP 진입 직전까지 WAVE에서 추적한 60선 최고) # 직전 산 피크 = prev_mtn_peak (직전 동산성공/더블동산의 피크) p_now = dip_peak if dip_peak > 0 else None p_prev = prev_mtn_peak _pg = (p_now/p_prev - 1) if (p_prev and p_now) else None _deep = dip_gap <= -emin # 끝지점(2)이 진짜 끝지점급 깊이(-7%)인가 _eg = (dip_ma60/prev_end60 - 1) if prev_end60 else None # 끝지점(2) vs 끝지점(1) _end_ok = (_eg is None) or (_eg >= -DOUBLE_MTN_END_TOL) # 끝지점 유지/상승? _floor_ok = bool(DOUBLE_MTN_LIFELINE_FLOOR and life and dip_px > life) # ★저가가 첫 생명선 위 = 시퀀스 생존 ev.append((d,' └DIP판정데이터', f'피크갭 {(_pg*100 if _pg is not None else 0):+.1f}% / 끝깊이 {dip_gap*100:.2f}% / 끝지점갭 {(_eg*100 if _eg is not None else 0):+.1f}%')) # ※ _floor_ok(저가>첫생명선)는 '더블동산 확정'에는 안 씀 — 더블로 치면 카운트가 올라 # 트리플캡에 걸려 이후 매수가 죽는 부작용(하이닉스2023 재개 케이스 뒤집힘, 전수 -129M 확인). # 대신 조기재베이스를 막는 데만 써서, 끝지점 낮아짐+생명선 위 케이스는 '위아래 재개'(카운트 유지)로 흘림. if DOUBLE_MTN_GAP > 0 and _pg is not None and DOUBLE_MTN_GAP_MIN <= _pg < DOUBLE_MTN_GAP and _deep and _end_ok: # ★ 더블동산 확정: 피크가 별로 안 올랐음(제자리) → 위아래 리셋, 새 동산 # ※ 생명선(life)은 리셋하지 않음 — 첫 동산 시작점 생명선을 시퀀스 내내 그대로 유지. # (더블/트리플 눌림에서 얕게 팔려나가지 않도록. 재베이스는 새 시퀀스라 별도) gap_pct = (p_now/p_prev - 1)*100 # ★ 생명선 올려잡기: 방금 지나온 DIP 구간 최저 BB하단 기준 (내리지는 않음) _oldlife = life if daily and VIRTUAL_LIFELINE and dip_bbmin < HI: _v = dip_bbmin*(1-LIFELINE_MARGIN) vlife = _v if (life is None or _v > life) else None vseg_i = len(buys); vbreak_dt=None one_wave = bool(DEEP_DIP_ONE_WAVE and dip_gap <= -DEEP_DIP_ONE_WAVE_PCT) if DOUBLE_MTN_RELIFE and dip_bbmin < HI: _cand = dip_bbmin*(1-LIFELINE_MARGIN) if life is None or _cand > life: life = _cand mtn_s60 = dip_ma60; mtn_sdt = dip_dt mts.append({'start':d,'s60':mtn_s60,'e60':dip_ma60,'life':life, 'endpx':dip_px,'sdt':mtn_sdt,'edt':dip_dt}) mtn_count += 1 # ★ 동산 순번 증가 (2=더블, 3=트리플…) wave_confirmed=False; conf_logged=False; touch_logged=False; cross_up_dt=None; wave_hat=0.0 # 복귀확인 리셋 _over = mtn_count > MAX_MTN_WAVE # 트리플(3)+ 이면 위아래 매수 안 함 _nm = {2:'더블',3:'트리플',4:'쿼드러플'}.get(mtn_count, f'{mtn_count}중') ev.append((d,f'{_nm}동산 확정→위아래 리셋', f'피크갭 {gap_pct:+.1f}% < {DOUBLE_MTN_GAP*100:.0f}% (제자리) — {mtn_count}번째 동산 / 생명선 {life:,.0f}' + (f'(직전 눌림 최저BB로 올림, 기존 {_oldlife:,.0f})' if (_oldlife and life>_oldlife) else '(첫동산 유지)') + (' | ★끝지점 낮아졌지만 저가>첫생명선 → 더블 인정(생명선바닥)' if (_floor_ok and not _end_ok) else '') + (f' | ★{MAX_MTN_WAVE}개 초과 → 위아래 매수 안 함(관찰만)' if _over else ''))) wave_fresh=False # 시퀀스 진행 중(더블동산) → 모자 무시 phase='WAVE'; wave=0; armed=False; b240={}; flag240up=_over # ★ 트리플+ = 매수 억제 lwl=None; lwl_c=None; daily_open=False; pend=[]; up_peak=0.0; up_days=0 prev_end60=dip_ma60 # ★ 끝지점(2)가 새 비교 기준 prev_mtn_peak=p_now; wave_peak_ma60=0.0 elif _deep and _pg is not None and _pg >= DOUBLE_MTN_GAP: # ★ 피크가 크게 전진(+42%↑)한 뒤의 깊은 눌림 = 한 사이클 완주 후 붕괴 → 재베이스 (새 시퀀스) # dip 최저점을 새 동산 시작점으로 삼고 PEAK부터 다시 (삼성 2013, 하이닉스 2016 유형) mtn_s60 = dip_ma60; mtn_sdt = dip_dt life = min(dip_bb60, dip_bbpx)*(1-LIFELINE_MARGIN) ev.append((d,'깊은눌림→새동산(재베이스)', f'한 바퀴 다 돌고 무너짐 → 처음부터 다시 — 봉우리가 직전 산보다 +{_pg*100:.1f}% 크게 올랐다가(기준 +{DOUBLE_MTN_GAP*100:.0f}% 넘음) 60선이 240선 밑으로 {dip_gap*100:.1f}% 깊이 빠짐(기준 -{emin*100:.0f}% 이하) / 새 시작점 {str(dip_dt)[:10]}(60선 {dip_ma60:,.0f}) / 새 생명선 {life:,.0f}')) phase='PEAK'; peak_gap=gap; peak_m60=m60; vlife=None; vbreak_dt=None; vseg_i=len(buys) # ★새 산 그리기 시작 → 옛 가상생명선 폐기 endlow_gap=0.0; endlow_ma60=HI; endlow_px=HI hold=False; wave=0; armed=False; b240={}; flag240up=False daily_open=False; pend=[]; up_peak=0.0; up_days=0 mtn_count=0; life_broken=False # 사이클 완주 후 새 시퀀스 (다음 동산성공이 1번째) prev_mtn_peak=p_now; wave_peak_ma60=0.0 elif _deep and REBASE_DEEP_PCT and dip_gap <= -REBASE_DEEP_PCT \ and not (_floor_ok and _pg is not None and DOUBLE_MTN_GAP_MIN <= _pg < DOUBLE_MTN_GAP): # ★ 속음→재베이스: 위아래인 줄 알고 샀는데 240올 못 가고 깊이(-REBASE_DEEP_PCT↓) 붕괴. # 피크가 계단식하락(제자리/하락)이라 더블동산도 아님 → '위아래 재개' 대신 동산부터 다시. # 단(생명선바닥, 030530): 저가>첫생명선 + 피크갭 밴드 안이면 시퀀스 생존 → 재베이스 대신 아래 '재개'로. # dip 최저점을 새 동산 시작점으로, 생명선도 새로. (John: "위아래 아니었으니 다시 동산 보자") mtn_s60 = dip_ma60; mtn_sdt = dip_dt life = min(dip_bb60, dip_bbpx)*(1-LIFELINE_MARGIN) _whyR = ('계단식하락' if (_pg is not None and _pg < DOUBLE_MTN_GAP_MIN) else ('끝지점 낮아짐' if not _end_ok else '피크데이터 없음')) ev.append((d,'속음→재베이스(깊은붕괴)', f'속았음 → 처음부터 다시 — 60선이 240선 밑으로 {dip_gap*100:.1f}% 깊이 빠짐(기준 -{REBASE_DEEP_PCT*100:.0f}% 이하) + 봉우리가 직전 산보다 {(_pg*100 if _pg is not None else 0):+.1f}%({_whyR}). 위아래 아님 / 새 시작점 {str(dip_dt)[:10]}(60선 {dip_ma60:,.0f}) / 새 생명선 {life:,.0f}')) phase='PEAK'; peak_gap=gap; peak_m60=m60; vlife=None; vbreak_dt=None; vseg_i=len(buys) # ★새 산 그리기 시작 → 옛 가상생명선 폐기 endlow_gap=0.0; endlow_ma60=HI; endlow_px=HI hold=False; wave=0; armed=False; b240={}; flag240up=False daily_open=False; pend=[]; up_peak=0.0; up_days=0 mtn_count=0; life_broken=False # 구조 새 시작 → 다음 동산성공이 1번째 wave_confirmed=False; conf_logged=False; touch_logged=False; cross_up_dt=None; wave_hat=0.0 prev_mtn_peak=p_now; wave_peak_ma60=0.0 elif _deep: # ★ 깊은 눌림인데 피크는 제자리/하락(작은 봉우리) → 동산으로 안 침(트리플 아님). # 하지만 첫 동산 생명선을 안 깼으니 시퀀스(동산+더블동산)는 살아있음 (M자도 더블동산). # 60>240 복귀 = 새 매수구간 → 1번째 위아래부터 재개. 생명선은 계속 첫 동산 것. # (생명선이 유일한 사망조건 — 깨지면 DIP 중에 이미 전량매도됐을 것) _why3 = ('계단식 하락' if (_pg is not None and _pg < DOUBLE_MTN_GAP_MIN) else ('끝지점이 직전보다 낮아짐' if not _end_ok else '피크데이터 없음')) wave_fresh=False # 시퀀스 진행 중(재개) → 모자 무시 # ★ 재개 때도 생명선 올려잡기 (직전 눌림 최저 BB하단). 내리지는 않음. _oldlife3 = life if daily and VIRTUAL_LIFELINE and dip_bbmin < HI: _v3 = dip_bbmin*(1-LIFELINE_MARGIN) vlife = _v3 if (life is None or _v3 > life) else None vseg_i = len(buys); vbreak_dt=None one_wave = bool(DEEP_DIP_ONE_WAVE and dip_gap <= -DEEP_DIP_ONE_WAVE_PCT) if DOUBLE_MTN_RELIFE and dip_bbmin < HI: _c3 = dip_bbmin*(1-LIFELINE_MARGIN) if life is None or _c3 > life: life = _c3 ev.append((d,'작은봉우리(동산아님)→위아래 재개', f'이번 60선 꼭대기 {(p_now if p_now else 0):,.0f} vs 직전 산 꼭대기 {(p_prev if p_prev else 0):,.0f} = {(_pg*100 if _pg is not None else 0):+.1f}% ' f'(꼭대기가 직전과 비슷해야[-{abs(DOUBLE_MTN_GAP_MIN)*100:.0f}%~+{DOUBLE_MTN_GAP*100:.0f}%] 더블동산인데 벗어남 → {_why3}) / ' f'60선이 240선 밑으로 {dip_gap*100:.1f}% 빠짐(재베이스 기준 -{REBASE_DEEP_PCT*100:.0f}% 미달이라 동산 다시 안 그림) ' f'→ 새 산 아님(동산 개수 안 올림). 생명선 {life:,.0f}' + (f'(직전 눌림 최저BB로 올림, 기존 {_oldlife3:,.0f})' if (_oldlife3 and life>_oldlife3) else '') + f' → 위아래 매수만 1번째부터 다시')) phase='WAVE'; wave=0; armed=False; b240={}; flag240up=False lwl=None; lwl_c=None; daily_open=False; pend=[]; up_peak=0.0; up_days=0 wave_confirmed=False; conf_logged=False; touch_logged=False; cross_up_dt=None; wave_hat=0.0 # 복귀확인 리셋 prev_end60=dip_ma60 # 낮아진 끝지점이 새 비교 기준 (동산 카운트는 그대로) prev_mtn_peak=p_now; wave_peak_ma60=0.0 else: # 여기 도달 = _deep이 False (끝깊이 -7% 미달). 즉 진짜 끝지점이 아니라 '얕은 눌림'. # 피크갭이 밴드 안이어도 끝지점이 얕으면 동산 자격 미달 → 그냥 눌림(위아래 계속) gp = _pg*100 if _pg is not None else 0.0 wave_fresh=False # 시퀀스 진행 중(얕은눌림, 위아래 계속) → 모자 무시 ev.append((d,'더블동산 아님→위아래 계속', f'끝깊이 {dip_gap*100:.2f}% > -{emin*100:.0f}% (얕은 눌림, 진짜 끝지점 아님) / 피크갭 {gp:+.1f}% — 위아래 계속')) phase='WAVE' prev_mtn_peak=p_now; wave_peak_ma60=0.0 up_peak=0.0; up_days=0 prev_above=above if sf: ev=[e for e in ev if str(e[0])>=sf]; buys=[x for x in buys if str(x[0])>=sf] mts=[m for m in mts if str(m['start'])>=sf] # ★ 마지막 봉 기준 '지금 상태' 한 줄 요약 (full_debug/app이 be.LAST_STATUS로 읽음). 반환값은 안 바꿈. # LAST_STATUS_KIND: 색칠용 종류 — 'buy'(매수국면,빨강) / 'danger'(무너지는중,주황) / 'dip'(눌림판정) # / 'done'(매수끝) / 'wait'(관망) global LAST_STATUS, LAST_STATUS_KIND, LAST_STATUS_SHORT, LAST_STATUS_DETAIL try: _g = f"{gap*100:+.1f}%" if 'gap' in dir() else '' _gap_txt = f"60선이 240선보다 {_g}" if _g else '' # 동산 이름(몇 번째 산인지)을 쉽게 _mtn_nm = {1:'첫 산', 2:'더블산(2번째)', 3:'트리플산(3번째)'}.get(mtn_count, f'{mtn_count}번째 산') # ★ '어떤 동산 → 위아래 몇 번' 스토리 앞머리 (현재 살아있는 산 = mts[-1]) _mtn = mts[-1] if mts else None if _mtn: _sd = str(_mtn['sdt'])[:10]; _ed = str(_mtn['edt'])[:10] if _sd == _ed: # 더블/트리플동산: 시작바닥=끝지점(2) 같은 날(별도 시작바닥 없음) → 확정 자리로 표현 _sanhead = f"{_ed} 자리에서 확정된 {_mtn_nm}" else: _sanhead = f"{_sd} 시작한 {_mtn_nm}(끝지점 {_ed})" else: _sanhead = _mtn_nm _wtxt = f"위아래 {wave}번째까지 매수함" if wave else "아직 위아래 매수 전" if phase=='WAIT': LAST_STATUS_SHORT = '관망' LAST_STATUS_DETAIL = '아직 산 시작 안 함 (60선이 240선 아래에서 바닥 다지는 중)' LAST_STATUS_KIND = 'wait' elif phase=='PEAK': _pk_detail = f"시작 바닥 {str(mtn_sdt)[:10]}(60선 {mtn_s60:,.0f}), 봉우리 최고 +{peak_gap*100:.1f}% / 생명선 {life:,.0f}" if life_broken: LAST_STATUS_SHORT = '무너지는 중' LAST_STATUS_DETAIL = f"생명선 {life:,.0f} 아래로 내려감(손절됨). 60선이 시작보다 높게 끝나면 그때 다시 성공 인정" LAST_STATUS_KIND = 'danger' elif above: LAST_STATUS_SHORT = f'{str(mtn_sdt)[:10]} 시작하는 동산 그리는 중' LAST_STATUS_DETAIL = _pk_detail LAST_STATUS_KIND = 'wait' else: LAST_STATUS_SHORT = f'{str(mtn_sdt)[:10]} 시작하는 동산 후 끝지점 가는 중' LAST_STATUS_DETAIL = _pk_detail LAST_STATUS_KIND = 'wait' elif phase=='DIP': _durd = (idx[-1]-dip_start_dt).days if dip_start_dt is not None else 0 LAST_STATUS_SHORT = '눌림 판정(매수멈춤)' LAST_STATUS_DETAIL = (f"{_sanhead}에서 {_wtxt}. 그 뒤 60선이 240선 아래로 꺾여 {str(dip_start_dt)[:10]}부터 " f"{_durd}일째 눌림 판정 중(지금 60선이 240선보다 {dip_gap*100:.1f}%) — 매수 멈추고 " f"더블동산인지 무너진 건지 지켜보는 중") LAST_STATUS_KIND = 'dip' elif phase=='WAVE': if flag240up: LAST_STATUS_SHORT = '매수 끝(쉬는 중)' LAST_STATUS_DETAIL = (f"{_sanhead}에서 위아래 2번 다 마침 → 매수 끝. " f"다시 240선 아래로 꺾일 때까지 쉬는 중 ({_gap_txt})") LAST_STATUS_KIND = 'done' elif daily_open: _ud = f"지금은 60선 위로 튐({up_days}봉째) — 잠깐 매수 쉬는 중" if up_days else "지금 60선 밑 — 매수 중" LAST_STATUS_SHORT = '매수 국면' LAST_STATUS_DETAIL = f"{_sanhead}에서 {wave}번째 위아래 매수 중. {_ud} ({_gap_txt})" LAST_STATUS_KIND = 'buy' elif wave==0: if wave_confirmed: LAST_STATUS_SHORT = '매수 국면(대기)' LAST_STATUS_DETAIL = f"{_mtn_nm} 위아래 열림, 1번째 눌림 대기(복귀 확인됨, 60선 밑으로 오면 매수) ({_gap_txt})" LAST_STATUS_KIND = 'buy' else: LAST_STATUS_SHORT = '복귀 확인 대기' LAST_STATUS_DETAIL = f"{_mtn_nm} 위아래 열렸지만 가짜(모자)일 수 있어 아직 매수 전 ({_gap_txt})" LAST_STATUS_KIND = 'wait' else: LAST_STATUS_SHORT = '위 구간 대기' LAST_STATUS_DETAIL = f"{_mtn_nm} · {wave}번째 눌림 후 위 구간 대기 중 ({_gap_txt})" LAST_STATUS_KIND = 'wait' else: LAST_STATUS_SHORT = phase; LAST_STATUS_DETAIL = ''; LAST_STATUS_KIND = 'wait' LAST_STATUS = f"{LAST_STATUS_SHORT} — {LAST_STATUS_DETAIL}" if LAST_STATUS_DETAIL else LAST_STATUS_SHORT except Exception as _e: LAST_STATUS = f"(상태 요약 실패: {_e})"; LAST_STATUS_KIND = 'wait'; LAST_STATUS_SHORT=''; LAST_STATUS_DETAIL='' return ev,buys,mts if __name__=='__main__': path=sys.argv[1] if len(sys.argv)>1 else '007340_5min.csv' # 타임프레임: 인자로 주면 그거, 없으면 파일명에서 자동감지 if len(sys.argv)>2: tf=sys.argv[2] else: pl = path.lower() if '1day' in pl or '_day' in pl or 'daily' in pl: tf='1day' elif '1hour' in pl or '60min' in pl or '1h' in pl: tf='1hour' elif '15min' in pl: tf='15min' else: tf='5min' print(f"(타임프레임 자동감지: {tf})") sf=sys.argv[3] if len(sys.argv)>3 else None df=pd.read_csv(path,parse_dates=['datetime'],index_col='datetime') ev,buys,mts=run(df,tf,sf) print(f"=== {path} ({tf}) | {df.index[0]} ~ {df.index[-1]} ===\n") print(f"[동산 성공 {len(mts)}개]") for m in mts: print(f" {m['start']} | 시작점60선 {m['s60']:,.0f} | 끝지점60선 {m['e60']:,.0f} | 생명선 {m['life']:,.0f}") print(f"\n[이벤트]") for d,t,dt in ev: print(f" {str(d):<20}{t:<24}{dt}") print(f"\n[매수 {len(buys)}개]") tot=0 for d,p,a2,lb in buys: print(f" {str(d):<20}{p:>8,.0f}원 {a2:>9,}원 {lb}"); tot+=a2 print(f" 총 {tot:,}원")