johnkim commited on
Commit
d8835bf
·
1 Parent(s): 9a35465

직전 매도 이력 표시 + 재베이스 메시지 상세화 + market_map 자동갱신

Browse files
Files changed (3) hide show
  1. app.py +39 -0
  2. buy_engine_dipfix.py +104 -14
  3. 배포_규칙_README.md +20 -8
app.py CHANGED
@@ -49,6 +49,36 @@ MAP_PATH = os.path.join(HERE, "market_map.csv")
49
  BOLL_PATH = os.path.join(HERE, "bollinger_count_debug.py")
50
  VCP_PATH = os.path.join(HERE, "vcp_debug.py")
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  ALERT_DAYS = 5 # 전량매도: 최근 이 거래일 안의 매도신호를 [매도]로 본다
53
 
54
  _TICKER_RE = re.compile(r"TICKER\s*=\s*['\"][0-9A-Za-z]+['\"]")
@@ -318,6 +348,15 @@ def run_monitor(text):
318
  verdict = (f"### 🟢 [보유] {_label(code)} ({res['market']})\n"
319
  f"- 매도 신호 없음. 보유 유지.\n"
320
  f"- 현재 단계: {_status_line(res)}\n")
 
 
 
 
 
 
 
 
 
321
  out.append(verdict)
322
 
323
  out.append(f"**[지금]** {res['last_date']} 종가 {res['price_now']:,.0f} · {m240s}\n")
 
49
  BOLL_PATH = os.path.join(HERE, "bollinger_count_debug.py")
50
  VCP_PATH = os.path.join(HERE, "vcp_debug.py")
51
 
52
+ # ── 실행 시 market_map.csv 백그라운드 자동 갱신 (오래됐을 때만) ────────────────
53
+ # build_market_map.py 를 찾아 --if-stale N --out <내 market_map.csv> 로 백그라운드 실행.
54
+ # fdr/한국IP 없는 배포 서버에선 조용히 실패 → 커밋된 market_map.csv 그대로 사용(앱은 정상).
55
+ # 맥 로컬(fdr 됨)에선 앱 켤 때마다 N일 넘었으면 알아서 최신화. 맥 스케줄러와 이중 안전망.
56
+ MARKET_MAP_STALE_DAYS = 7 # 이 일수 넘으면 갱신 시도. 0이면 자동갱신 끔.
57
+
58
+ def _refresh_market_map_bg():
59
+ import threading, subprocess, sys as _sys
60
+ def _run():
61
+ try:
62
+ cands = [
63
+ os.path.join(HERE, "build_market_map.py"),
64
+ os.path.join(HERE, "..", "quant", "sell_debug", "build_market_map.py"),
65
+ os.path.expanduser("~/Projects/quant/sell_debug/build_market_map.py"),
66
+ ]
67
+ script = next((p for p in cands if os.path.exists(p)), None)
68
+ if not script:
69
+ return # 배포 서버 등 생성기 없음 → 조용히 스킵
70
+ subprocess.run(
71
+ [_sys.executable, script, "--if-stale", str(MARKET_MAP_STALE_DAYS),
72
+ "--out", MAP_PATH],
73
+ timeout=180, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
74
+ except Exception:
75
+ pass # fdr 실패/해외IP/타임아웃 등 전부 무시 — 앱 동작에 영향 없음
76
+ if MARKET_MAP_STALE_DAYS:
77
+ threading.Thread(target=_run, daemon=True).start()
78
+
79
+ _refresh_market_map_bg()
80
+ # ──────────────────────────────────────────────────────────────────────────────
81
+
82
  ALERT_DAYS = 5 # 전량매도: 최근 이 거래일 안의 매도신호를 [매도]로 본다
83
 
84
  _TICKER_RE = re.compile(r"TICKER\s*=\s*['\"][0-9A-Za-z]+['\"]")
 
348
  verdict = (f"### 🟢 [보유] {_label(code)} ({res['market']})\n"
349
  f"- 매도 신호 없음. 보유 유지.\n"
350
  f"- 현재 단계: {_status_line(res)}\n")
351
+ # ★ 이격이 회복돼 리셋됐어도 '직전 매도가 언제였는지'는 항상 보여준다.
352
+ ls = res.get("last_sell")
353
+ if ls:
354
+ ago = st.get("last_i", 0) - ls["i"]
355
+ _nowdiv = st.get("div")
356
+ verdict += (f"- [직전 매도(참고)] {ls['date']} @ {ls['price']:,.0f} "
357
+ f"(그때 이격 {ls['div']*100:.0f}%) · {ago}거래일 전\n")
358
+ if _nowdiv is not None:
359
+ verdict += f" - ※ 지금은 이격 {_nowdiv*100:.0f}%로 회복돼서 신호 꺼짐\n"
360
  out.append(verdict)
361
 
362
  out.append(f"**[지금]** {res['last_date']} 종가 {res['price_now']:,.0f} · {m240s}\n")
buy_engine_dipfix.py CHANGED
@@ -34,6 +34,34 @@ LIFELINE_BASE_MIN = True # [생명선] True면 '베이스 구간 전체의 최
34
  # ※ 60선이 240선 위/아래인지 무관하게 WAIT(베이스) 내내 추적 —
35
  # 상장초기 평행연장 240 구간에서 60선이 위아래로 왔다갔다해도 급락을 놓치지 않음.
36
  LIFELINE_MARGIN = 0.01 # 생명선 = 시작점 BB하단 × (1-1%)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  LIFELINE_KEEP_MTN = True # [일봉/PEAK] ★생명선 이탈해도 동산 구조는 유지:
38
  # 주가가 생명선을 깨면 전량매도(손절)는 하되, 동산을 폐기하지 않고 계속 관찰.
39
  # 이후 60선이 240선 위로 복귀할 때 '끝지점 60선 > 시작점 60선'(더 높은 저점)이면
@@ -218,6 +246,10 @@ def run(df, tf='5min', sf=None, sell_dates=None, vcp_wins=None):
218
  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
219
  dip_start_dt=None # DIP 시작일 (REBASE_DIP_DAYS 기간 판정용)
220
  dip_floor_logged=False # '조기재베이스 보류(생명선바닥)' 이벤트 1회 출력용
 
 
 
 
221
  wave_confirmed=False; conf_logged=False; touch_logged=False # 위아래 복귀 확인 여부(가짜 복귀 차단)
222
  cross_up_dt=None # 이번 위아래에서 60선이 240선 위(gap>0)로 처음 올라간 날 (시간 기반 확인용)
223
  wave_hat=0.0 # 이번 위아래에서 60선이 240선 위로 만든 최대 gap (모자 봉우리 감지 — 스침 오판 방지)
@@ -261,6 +293,32 @@ def run(df, tf='5min', sf=None, sell_dates=None, vcp_wins=None):
261
  above = m60 > m240
262
  gap = m60/m240 - 1
263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  if phase=='WAIT':
265
  # ★ 베이스 구간 전체의 최저 BB하단 추적 (above/below 무관 — 평행연장 구간 대응)
266
  if bl < base_bbmin: base_bbmin = bl
@@ -319,6 +377,7 @@ def run(df, tf='5min', sf=None, sell_dates=None, vcp_wins=None):
319
  life_broken=False
320
  mtn_count=1 # ★ 새 시퀀스의 첫 동산 → 위아래 매수 시작
321
  wave_fresh=True # 동산 완성 직후 복귀 → 모자 체크 대상
 
322
  wave_confirmed=False; conf_logged=False; touch_logged=False; cross_up_dt=None; wave_hat=0.0 # 복귀확인 리셋
323
  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=[]
324
  mts.append({'start':d,'s60':mtn_s60,'e60':endlow_ma60,'life':life,'endpx':endlow_px,'sdt':mtn_sdt,'edt':endlow_dt})
@@ -329,12 +388,14 @@ def run(df, tf='5min', sf=None, sell_dates=None, vcp_wins=None):
329
  + (f' ★생명선 깼었지만 끝지점 60선이 시작점보다 높아 구조유지 → 동산성공 인정 / 생명선 재설정 {life:,.0f}' if _revived else '')))
330
  elif not higher:
331
  # 끝지점이 시작점보다 낮음 = 더 낮은 저점(구조 붕괴) → 그 저점으로 재베이스
 
 
332
  mtn_s60 = endlow_ma60; mtn_sdt = endlow_dt
333
  life = min(endlow_bb60, endlow_bbpx)*(1-LIFELINE_MARGIN)
334
  mtn_count=0 # 구조 붕괴 → 시퀀스 리셋
335
  life_broken=False
336
  ev.append((d,'동산실패→재베이스(더낮은저점)',
337
- f' 시작60선 {endlow_ma60:,.0f} / 새 생명선 {life:,.0f}'))
338
  peak_gap=gap; peak_m60=m60; endlow_gap=0.0; endlow_ma60=HI; endlow_px=HI # 새 산봉우리로
339
  else:
340
  # 봉우리/끝지점이 작음(노이즈) → 무시하고 같은 PEAK 유지
@@ -360,10 +421,11 @@ def run(df, tf='5min', sf=None, sell_dates=None, vcp_wins=None):
360
  phase='DIP'
361
  dip_ma60=m60; dip_bb60=bl; dip_px=L; dip_bbpx=bl; dip_dt=d; dip_gap=gap
362
  dip_start_dt=d # ★ DIP 시작일 (기간 기반 재베이스 REBASE_DIP_DAYS 판정용)
 
363
  dip_floor_logged=False
364
  dip_peak=wave_peak_ma60 # DIP 직전까지 = 이번 산의 피크 확정
365
  ev.append((d,'끝지점(2) 추적중→매수중단',
366
- f'60MA {m60:,.0f} < 240MA -{DOUBLE_MTN_PCT*100:.0f}% 더블동산인지 판정 대기'))
367
  elif above or (daily and WAVE_TOUCH_BUY and gap <= 0):
368
  # above=60>240 (정상 위아래) / gap<=0 = 스침구간(240선 위로 못 올라감, DIP 전) → 바닥 다지기로 보고 매수 허용
369
  if m60 > wave_peak_ma60: wave_peak_ma60 = m60 # 현재 위아래 구간의 60선 최고(피크)만 추적
@@ -444,7 +506,8 @@ def run(df, tf='5min', sf=None, sell_dates=None, vcp_wins=None):
444
  if deep_ok:
445
  # 확정! → 새 위아래 시작, 못 산 날들 소급 매수
446
  wave+=1
447
- if wave<=2:
 
448
  daily_open=True; hold=True
449
  amt = d1 if wave==1 else d2
450
  d0 = pend[0][0]
@@ -501,6 +564,7 @@ def run(df, tf='5min', sf=None, sell_dates=None, vcp_wins=None):
501
  elif not above:
502
  # 끝지점(2) 최저 갱신 (★ 사이클종료 없음 — 끝지점(1)과 동일하게 생명선만이 사망조건)
503
  if gap < dip_gap: dip_gap=gap
 
504
  if m60 < dip_ma60: dip_ma60=m60; dip_bb60=bl; dip_dt=d
505
  if L < dip_px: dip_px=L; dip_bbpx=bl
506
  # ★ 조기 '속음' 판정: 240 복귀를 기다리지 않고, 깊이가 임계를 찍는 순간 재베이스.
@@ -526,7 +590,7 @@ def run(df, tf='5min', sf=None, sell_dates=None, vcp_wins=None):
526
  if _deep_hit and (not _long_hit) and _floor0 and (not _endok0) and _blk and (not dip_floor_logged):
527
  dip_floor_logged=True
528
  ev.append((d,'조기재베이스 보류(생명선바닥)',
529
- f'끝지점(2) {dip_gap*100:.1f}% 지만 저가 {dip_px:,.0f} > 첫생명선 {life:,.0f} 시퀀스 생존, 더블동산 가능성 유지 → 재베이스 안 함'))
530
  if not _blk:
531
  _why0 = ('계단식하락' if (_pg0 is not None and _pg0 < DOUBLE_MTN_GAP_MIN)
532
  else ('끝지점 낮아짐' if not _endok0
@@ -536,13 +600,13 @@ def run(df, tf='5min', sf=None, sell_dates=None, vcp_wins=None):
536
  _dur0 = (d - dip_start_dt).days if dip_start_dt is not None else 0
537
  if _deep_hit:
538
  _tag0 = '속음→재베이스(조기판정)'
539
- _rsn0 = f'끝지점(2) {dip_gap*100:.1f}% -{REBASE_DEEP_PCT*100:.0f}%(깊이)'
540
  else:
541
  _tag0 = '속음→재베이스(장기DIP)'
542
- _rsn0 = (f'DIP {_dur0}일 {REBASE_DIP_DAYS} (시작 {str(dip_start_dt)[:10]}) '
543
- f'깊이는 {dip_gap*100:.1f}%로 얕지만 너무 오래 = 눌림')
544
  ev.append((d,_tag0,
545
- f'{_rsn0} / 피크갭 {(_pg0*100 if _pg0 is not None else 0):+.1f}%({_why0}) 240복귀 기다리지 않고 위아래 해제 동산부터 / 시작점 {str(dip_dt)[:10]}({dip_ma60:,.0f}) / 생명선 {life:,.0f}'))
546
  phase='PEAK'; peak_gap=gap; peak_m60=m60
547
  endlow_gap=0.0; endlow_ma60=HI; endlow_px=HI
548
  hold=False; wave=0; armed=False; b240={}; flag240up=False
@@ -575,6 +639,16 @@ def run(df, tf='5min', sf=None, sell_dates=None, vcp_wins=None):
575
  # ※ 생명선(life)은 리셋하지 않음 — 첫 동산 시작점 생명선을 시퀀스 내내 그대로 유지.
576
  # (더블/트리플 눌림에서 얕게 팔려나가지 않도록. 재베이스는 새 시퀀스라 별도)
577
  gap_pct = (p_now/p_prev - 1)*100
 
 
 
 
 
 
 
 
 
 
578
  mtn_s60 = dip_ma60; mtn_sdt = dip_dt
579
  mts.append({'start':d,'s60':mtn_s60,'e60':dip_ma60,'life':life,
580
  'endpx':dip_px,'sdt':mtn_sdt,'edt':dip_dt})
@@ -583,7 +657,8 @@ def run(df, tf='5min', sf=None, sell_dates=None, vcp_wins=None):
583
  _over = mtn_count > MAX_MTN_WAVE # 트리플(3)+ 이면 위아래 매수 안 함
584
  _nm = {2:'더블',3:'트리플',4:'쿼드러플'}.get(mtn_count, f'{mtn_count}중')
585
  ev.append((d,f'{_nm}동산 확정→위아래 리셋',
586
- f'피크갭 {gap_pct:+.1f}% < {DOUBLE_MTN_GAP*100:.0f}% (제자리) — {mtn_count}번째 동산 / 생명선 {life:,.0f}(첫동산 유지)'
 
587
  + (' | ★끝지점 낮아졌지만 저가>첫생명선 → 더블 인정(생명선바닥)' if (_floor_ok and not _end_ok) else '')
588
  + (f' | ★{MAX_MTN_WAVE}개 초과 → 위아래 매수 안 함(관찰만)' if _over else '')))
589
  wave_fresh=False # 시퀀스 진행 중(더블동산) → 모자 무시
@@ -597,7 +672,7 @@ def run(df, tf='5min', sf=None, sell_dates=None, vcp_wins=None):
597
  mtn_s60 = dip_ma60; mtn_sdt = dip_dt
598
  life = min(dip_bb60, dip_bbpx)*(1-LIFELINE_MARGIN)
599
  ev.append((d,'깊은눌림→새동산(재베이스)',
600
- f'피크갭 {_pg*100:+.1f}%(피전진 +{DOUBLE_MTN_GAP*100:.0f}%) / 끝지점(2) {dip_gap*100:.1f}% -{emin*100:.0f}% / 시작점 {str(dip_dt)[:10]}({dip_ma60:,.0f}) / 생명선 {life:,.0f}'))
601
  phase='PEAK'; peak_gap=gap; peak_m60=m60
602
  endlow_gap=0.0; endlow_ma60=HI; endlow_px=HI
603
  hold=False; wave=0; armed=False; b240={}; flag240up=False
@@ -615,7 +690,7 @@ def run(df, tf='5min', sf=None, sell_dates=None, vcp_wins=None):
615
  _whyR = ('계단식하락' if (_pg is not None and _pg < DOUBLE_MTN_GAP_MIN)
616
  else ('끝지점 낮아짐' if not _end_ok else '피크데이터 없음'))
617
  ev.append((d,'속음→재베이스(깊은붕괴)',
618
- f'끝지점(2) {dip_gap*100:.1f}% -{REBASE_DEEP_PCT*100:.0f}% / 피크갭 {(_pg*100 if _pg is not None else 0):+.1f}%({_whyR}) 위아래 아님 동산부터 / 시작점 {str(dip_dt)[:10]}({dip_ma60:,.0f}) / 생명선 {life:,.0f}'))
619
  phase='PEAK'; peak_gap=gap; peak_m60=m60
620
  endlow_gap=0.0; endlow_ma60=HI; endlow_px=HI
621
  hold=False; wave=0; armed=False; b240={}; flag240up=False
@@ -628,11 +703,26 @@ def run(df, tf='5min', sf=None, sell_dates=None, vcp_wins=None):
628
  # 하지만 첫 동산 생명선을 안 깼으니 시퀀스(동산+더블동산)는 살아있음 (M자도 더블동산).
629
  # 60>240 복귀 = 새 매수구간 → 1번째 위아래부터 재개. 생명선은 계속 첫 동산 것.
630
  # (생명선이 유일한 사망조건 — 깨지면 DIP 중에 이미 전량매도됐을 것)
631
- _why3 = (f'계단식하락 <{DOUBLE_MTN_GAP_MIN*100:.0f}%' if (_pg is not None and _pg < DOUBLE_MTN_GAP_MIN)
632
- else ('끝지점 낮아짐' if not _end_ok else '피크데이터 없음'))
633
  wave_fresh=False # 시퀀스 진행 중(재개) → 모자 무시
 
 
 
 
 
 
 
 
 
 
634
  ev.append((d,'작은봉우리(동산아님)→위아래 재개',
635
- f'피크갭 {(_pg*100 if _pg is not None else 0):+.1f}%({_why3}) / 끝지점(2) {dip_gap*100:.1f}% 생명선({life:,.0f}) 유지 시퀀스 계속, 1번째 위아래부터'))
 
 
 
 
 
636
  phase='WAVE'; wave=0; armed=False; b240={}; flag240up=False
637
  lwl=None; lwl_c=None; daily_open=False; pend=[]; up_peak=0.0; up_days=0
638
  wave_confirmed=False; conf_logged=False; touch_logged=False; cross_up_dt=None; wave_hat=0.0 # 복귀확인 리셋
 
34
  # ※ 60선이 240선 위/아래인지 무관하게 WAIT(베이스) 내내 추적 —
35
  # 상장초기 평행연장 240 구간에서 60선이 위아래로 왔다갔다해도 급락을 놓치지 않음.
36
  LIFELINE_MARGIN = 0.01 # 생명선 = 시작점 BB하단 × (1-1%)
37
+ VIRTUAL_LIFELINE = True # [일봉] ★가상 생명선 (사용자 아이디어 2026-08):
38
+ # 진짜 생명선(첫 동산 것)은 '구조용'으로 그대로 두고,
39
+ # 더블동산/위아래 재개로 '작은 언덕'이 생길 때마다 그 구간 전용 보호선을 따로 긋는다.
40
+ # 가상 생명선 = 직전 눌림(DIP) 구간의 최저 BB하단 ×(1-마진). 진짜 생명선보다 위일 때만 유효.
41
+ # 이 선을 깨면: ① 그 작은 언덕에서 산 물량만 매도(전량 아님) ② 재베이스로 동산부터 다시 그림.
42
+ # → 물타기는 끊되 전체 포지션·구조는 안 날림. False면 이 기능 끔.
43
+ DEEP_DIP_ONE_WAVE = False # [일봉] ★깊게 빠진 뒤 재개할 땐 '1번째 위아래'만 매수 (2026-08 사용자):
44
+ # 직전 눌림이 DEEP_DIP_ONE_WAVE_PCT 보다 깊었으면, 그 뒤 열리는 위아래는
45
+ # 1번째만 사고 2번째는 안 삼(구조가 이미 상한 구간이라 추가 물타기 축소).
46
+ # False면 기존처럼 1·2번째 다 매수.
47
+ DEEP_DIP_ONE_WAVE_PCT = 0.20 # 위 규칙 적용 기준 깊이(끝지점이 240선 대비 -20% 이하)
48
+ VLIFE_GRACE_DAYS = 20 # [일봉] ★가상생명선 이탈 유예일 (2026-08):
49
+ # 선을 깬 즉시 팔지 않고, 이 일수 안에 종가가 선 위로 회복하면 없던 일로 함.
50
+ # 유예 내내 회복 못 하면 그때 구간매도.
51
+ # 이유: 즉시매도는 '패닉 투매 바닥'에서 파는 경향(166090 2020-03-19=코로나 바닥 당일,
52
+ # 033100 2016-01-20, 236200 2022-06-23 모두 매도 직후가 최저점이었음).
53
+ # 진짜 붕괴는 계속 선 아래 머물고, 급락 바닥은 며칠 만에 되돌아오므로 시간으로 걸러냄.
54
+ # 0 = 유예 없음(즉시 매도).
55
+ DOUBLE_MTN_RELIFE = False # [일봉] (실험) 더블동산/재개 시 '진짜 생명선' 자체를 올려잡기 — 전수조사 결과 성적 악화로 OFF.
56
+ # 대신 위 VIRTUAL_LIFELINE 방식 사용. True로 켜면 진짜 생명선을 올림(옛 실험).
57
+ _DOUBLE_MTN_RELIFE_DOC = """[일봉] ★더블동산/재개 시 생명선을 '직전 눌림(DIP) 구간의 최저 BB하단'으로 올려잡기.
58
+ # 문제(096770): 첫 동산 생명선(58,739)을 시퀀스 내내 유지하다 보니, 주가가 15만원대인데
59
+ # 보호선이 5.8만이라 사실상 보호가 없음 → 계속 떨어지는데 위아래 매수만 반복.
60
+ # 해결(사용자): 60선이 240선을 다시 만나 더블동산/재개가 확정되는 그 시점에,
61
+ # 방금 지나온 DIP 구간에서 가장 낮았던 BB하단을 새 생명선으로 삼는다.
62
+ # (096770: 2022-10-11 BB하단 127,575 × 0.99 = 126,299)
63
+ # ※ 생명선은 '올리기만' 함(내리지 않음) — 옛 생명선보다 낮으면 그대로 둠.
64
+ # False면 예전처럼 첫 동산 생명선을 계속 유지."""
65
  LIFELINE_KEEP_MTN = True # [일봉/PEAK] ★생명선 이탈해도 동산 구조는 유지:
66
  # 주가가 생명선을 깨면 전량매도(손절)는 하되, 동산을 폐기하지 않고 계속 관찰.
67
  # 이후 60선이 240선 위로 복귀할 때 '끝지점 60선 > 시작점 60선'(더 높은 저점)이면
 
246
  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
247
  dip_start_dt=None # DIP 시작일 (REBASE_DIP_DAYS 기간 판정용)
248
  dip_floor_logged=False # '조기재베이스 보류(생명선바닥)' 이벤트 1회 출력용
249
+ vlife=None; vseg_i=0 # 가상 생명선 / 이 작은언덕 매수 시작 인덱스(buys 기준)
250
+ vbreak_dt=None # 가상생명선 아래로 처음 내려간 날 (유예 판정용)
251
+ one_wave=False # 이번 위아래는 1번째만 매수(깊은 눌림 뒤 재개)
252
+ dip_bbmin=HI # DIP 구간 전체의 최저 BB하단 (DOUBLE_MTN_RELIFE: 새 생명선 후보)
253
  wave_confirmed=False; conf_logged=False; touch_logged=False # 위아래 복귀 확인 여부(가짜 복귀 차단)
254
  cross_up_dt=None # 이번 위아래에서 60선이 240선 위(gap>0)로 처음 올라간 날 (시간 기반 확인용)
255
  wave_hat=0.0 # 이번 위아래에서 60선이 240선 위로 만든 최대 gap (모자 봉우리 감지 — 스침 오판 방지)
 
293
  above = m60 > m240
294
  gap = m60/m240 - 1
295
 
296
+ # ★ 가상 생명선 이탈: 이 작은언덕에서 산 물량만 매도 + 재베이스(동산부터 다시)
297
+ if VIRTUAL_LIFELINE and vlife and phase in ('WAVE','DIP'):
298
+ if C >= vlife:
299
+ vbreak_dt=None # 종가가 선 위로 회복 → 유예 리셋(없던 일)
300
+ elif L < vlife and vbreak_dt is None:
301
+ vbreak_dt=d # 처음 깬 날 기록
302
+ if (VIRTUAL_LIFELINE and vlife and phase in ('WAVE','DIP') and vbreak_dt is not None
303
+ and (d - vbreak_dt).days >= VLIFE_GRACE_DAYS):
304
+ _sq = buys[vseg_i:]
305
+ _amt = sum(x[2] for x in _sq)
306
+ _sh = sum(x[2]/x[1] for x in _sq)
307
+ _avg = (_amt/_sh) if _sh else 0
308
+ ev.append((d,'가상생명선이탈→구간매도',
309
+ f'저가 {L:,.0f} < 가상생명선 {vlife:,.0f} — 이 작은언덕 매수분만 매도(진짜 생명선 {life:,.0f}는 유지) '
310
+ f'| 구간매수 {len(_sq)}회 {_amt:,.0f}원 평단 {_avg:,.0f} | {VLIFE_GRACE_DAYS}일 유예 내내 회복 실패({str(vbreak_dt)[:10]}부터) → 동산부터 다시'))
311
+ vlife=None; vseg_i=len(buys); vbreak_dt=None
312
+ mtn_s60 = m60; mtn_sdt = d
313
+ life = min(bl, life if life else bl)
314
+ phase='PEAK'; peak_gap=gap; peak_m60=m60
315
+ endlow_gap=0.0; endlow_ma60=HI; endlow_px=HI
316
+ hold=False; wave=0; armed=False; b240={}; flag240up=False
317
+ daily_open=False; pend=[]; up_peak=0.0; up_days=0
318
+ mtn_count=0; life_broken=False
319
+ wave_confirmed=False; conf_logged=False; touch_logged=False; cross_up_dt=None; wave_hat=0.0
320
+ continue
321
+
322
  if phase=='WAIT':
323
  # ★ 베이스 구간 전체의 최저 BB하단 추적 (above/below 무관 — 평행연장 구간 대응)
324
  if bl < base_bbmin: base_bbmin = bl
 
377
  life_broken=False
378
  mtn_count=1 # ★ 새 시퀀스의 첫 동산 → 위아래 매수 시작
379
  wave_fresh=True # 동산 완성 직후 복귀 → 모자 체크 대상
380
+ one_wave=False # 새 동산 → 1·2번째 다 매수
381
  wave_confirmed=False; conf_logged=False; touch_logged=False; cross_up_dt=None; wave_hat=0.0 # 복귀확인 리셋
382
  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=[]
383
  mts.append({'start':d,'s60':mtn_s60,'e60':endlow_ma60,'life':life,'endpx':endlow_px,'sdt':mtn_sdt,'edt':endlow_dt})
 
388
  + (f' ★생명선 깼었지만 끝지점 60선이 시작점보다 높아 구조유지 → 동산성공 인정 / 생명선 재설정 {life:,.0f}' if _revived else '')))
389
  elif not higher:
390
  # 끝지점이 시작점보다 낮음 = 더 낮은 저점(구조 붕괴) → 그 저점으로 재베이스
391
+ _os60=mtn_s60; _osdt=mtn_sdt # 실패한 산의 시작점(비교용, 덮어쓰기 전 저장)
392
+ _drop=(endlow_ma60/_os60-1)*100 if _os60 else 0
393
  mtn_s60 = endlow_ma60; mtn_sdt = endlow_dt
394
  life = min(endlow_bb60, endlow_bbpx)*(1-LIFELINE_MARGIN)
395
  mtn_count=0 # 구조 붕괴 → 시퀀스 리셋
396
  life_broken=False
397
  ev.append((d,'동산실패→재베이스(더낮은저점)',
398
+ f' 산 실패 — 시작 바닥 {str(_osdt)[:10]}(60선 {_os60:,.0f})보다 끝 바닥 {str(endlow_dt)[:10]}(60선 {endlow_ma60:,.0f})이 {_drop:+.1f}% 더 낮음(=더 낮은 저점, 구조 붕괴). 이 낮은 끝 바닥을 새 시작점으로 산 다시 그림 / 새 생명선 {life:,.0f}'))
399
  peak_gap=gap; peak_m60=m60; endlow_gap=0.0; endlow_ma60=HI; endlow_px=HI # 새 산봉우리로
400
  else:
401
  # 봉우리/끝지점이 작음(노이즈) → 무시하고 같은 PEAK 유지
 
421
  phase='DIP'
422
  dip_ma60=m60; dip_bb60=bl; dip_px=L; dip_bbpx=bl; dip_dt=d; dip_gap=gap
423
  dip_start_dt=d # ★ DIP 시작일 (기간 기반 재베이스 REBASE_DIP_DAYS 판정용)
424
+ dip_bbmin=bl # ★ DIP 구간 최저 BB하단 추적 시작
425
  dip_floor_logged=False
426
  dip_peak=wave_peak_ma60 # DIP 직전까지 = 이번 산의 피크 확정
427
  ev.append((d,'끝지점(2) 추적중→매수중단',
428
+ f'매수 잠시 멈춤 — 60선 {m60:,.0f} 240선 밑으로 -{DOUBLE_MTN_PCT*100:.0f}% 넘게 꺾임. 여기가 이번 눌림 시작({str(d)[:10]}). 더블동산인지 무너진 건지 지켜보는 중'))
429
  elif above or (daily and WAVE_TOUCH_BUY and gap <= 0):
430
  # above=60>240 (정상 위아래) / gap<=0 = 스침구간(240선 위로 못 올라감, DIP 전) → 바닥 다지기로 보고 매수 허용
431
  if m60 > wave_peak_ma60: wave_peak_ma60 = m60 # 현재 위아래 구간의 60선 최고(피크)만 추적
 
506
  if deep_ok:
507
  # 확정! → 새 위아래 시작, 못 산 날들 소급 매수
508
  wave+=1
509
+ _cap = 1 if (DEEP_DIP_ONE_WAVE and one_wave) else 2
510
+ if wave<=_cap:
511
  daily_open=True; hold=True
512
  amt = d1 if wave==1 else d2
513
  d0 = pend[0][0]
 
564
  elif not above:
565
  # 끝지점(2) 최저 갱신 (★ 사이클종료 없음 — 끝지점(1)과 동일하게 생명선만이 사망조건)
566
  if gap < dip_gap: dip_gap=gap
567
+ if bl < dip_bbmin: dip_bbmin=bl # ★ DIP 구간 최저 BB하단
568
  if m60 < dip_ma60: dip_ma60=m60; dip_bb60=bl; dip_dt=d
569
  if L < dip_px: dip_px=L; dip_bbpx=bl
570
  # ★ 조기 '속음' 판정: 240 복귀를 기다리지 않고, 깊이가 임계를 찍는 순간 재베이스.
 
590
  if _deep_hit and (not _long_hit) and _floor0 and (not _endok0) and _blk and (not dip_floor_logged):
591
  dip_floor_logged=True
592
  ev.append((d,'조기재베이스 보류(생명선바닥)',
593
+ f'다시 안 그림 — 많이({dip_gap*100:.1f}%) 빠졌지만 저가 {dip_px:,.0f} 생명선 {life:,.0f}보다 위. 아직 구조 안 무너짐 → 더블동산 가능성 살려둠'))
594
  if not _blk:
595
  _why0 = ('계단식하락' if (_pg0 is not None and _pg0 < DOUBLE_MTN_GAP_MIN)
596
  else ('끝지점 낮아짐' if not _endok0
 
600
  _dur0 = (d - dip_start_dt).days if dip_start_dt is not None else 0
601
  if _deep_hit:
602
  _tag0 = '속음→재베이스(조기판정)'
603
+ _rsn0 = f'60선이 240선 밑으로 {dip_gap*100:.1f}% 깊이 빠짐(기준 -{REBASE_DEEP_PCT*100:.0f}% ). 눌림 시작 {str(dip_start_dt)[:10]}부터 {_dur0}일째'
604
  else:
605
  _tag0 = '속음→재베이스(장기DIP)'
606
+ _rsn0 = (f'눌림이 {_dur0}일(약 {_dur0//365}년)이나 지속({str(dip_start_dt)[:10]}~{str(d)[:10]}, 기준 {REBASE_DIP_DAYS}일). '
607
+ f'깊이는 {dip_gap*100:.1f}%로 얕아도 이렇게 오래 끌면 눌림 아니라 무너짐')
608
  ev.append((d,_tag0,
609
+ f'속았음 → 처음부터 다시 — {_rsn0} / 이번 봉우리는 직전 산보다 {(_pg0*100 if _pg0 is not None else 0):+.1f}%({_why0}) / 240복귀 기다리고 위아래 접음 / 시작점 {str(dip_dt)[:10]}(60선 {dip_ma60:,.0f}) / 생명선 {life:,.0f}'))
610
  phase='PEAK'; peak_gap=gap; peak_m60=m60
611
  endlow_gap=0.0; endlow_ma60=HI; endlow_px=HI
612
  hold=False; wave=0; armed=False; b240={}; flag240up=False
 
639
  # ※ 생명선(life)은 리셋하지 않음 — 첫 동산 시작점 생명선을 시퀀스 내내 그대로 유지.
640
  # (더블/트리플 눌림에서 얕게 팔려나가지 않도록. 재베이스는 새 시퀀스라 별도)
641
  gap_pct = (p_now/p_prev - 1)*100
642
+ # ★ 생명선 올려잡기: 방금 지나온 DIP 구간 최저 BB하단 기준 (내리지는 않음)
643
+ _oldlife = life
644
+ if VIRTUAL_LIFELINE and dip_bbmin < HI:
645
+ _v = dip_bbmin*(1-LIFELINE_MARGIN)
646
+ vlife = _v if (life is None or _v > life) else None
647
+ vseg_i = len(buys); vbreak_dt=None
648
+ one_wave = bool(DEEP_DIP_ONE_WAVE and dip_gap <= -DEEP_DIP_ONE_WAVE_PCT)
649
+ if DOUBLE_MTN_RELIFE and dip_bbmin < HI:
650
+ _cand = dip_bbmin*(1-LIFELINE_MARGIN)
651
+ if life is None or _cand > life: life = _cand
652
  mtn_s60 = dip_ma60; mtn_sdt = dip_dt
653
  mts.append({'start':d,'s60':mtn_s60,'e60':dip_ma60,'life':life,
654
  'endpx':dip_px,'sdt':mtn_sdt,'edt':dip_dt})
 
657
  _over = mtn_count > MAX_MTN_WAVE # 트리플(3)+ 이면 위아래 매수 안 함
658
  _nm = {2:'더블',3:'트리플',4:'쿼드러플'}.get(mtn_count, f'{mtn_count}중')
659
  ev.append((d,f'{_nm}동산 확정→위아래 리셋',
660
+ f'피크갭 {gap_pct:+.1f}% < {DOUBLE_MTN_GAP*100:.0f}% (제자리) — {mtn_count}번째 동산 / 생명선 {life:,.0f}'
661
+ + (f'(직전 눌림 최저BB로 올림, 기존 {_oldlife:,.0f})' if (_oldlife and life>_oldlife) else '(첫동산 유지)')
662
  + (' | ★끝지점 낮아졌지만 저가>첫생명선 → 더블 인정(생명선바닥)' if (_floor_ok and not _end_ok) else '')
663
  + (f' | ★{MAX_MTN_WAVE}개 초과 → 위아래 매수 안 함(관찰만)' if _over else '')))
664
  wave_fresh=False # 시퀀스 진행 중(더블동산) → 모자 무시
 
672
  mtn_s60 = dip_ma60; mtn_sdt = dip_dt
673
  life = min(dip_bb60, dip_bbpx)*(1-LIFELINE_MARGIN)
674
  ev.append((d,'깊은눌림→새동산(재베이스)',
675
+ 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}'))
676
  phase='PEAK'; peak_gap=gap; peak_m60=m60
677
  endlow_gap=0.0; endlow_ma60=HI; endlow_px=HI
678
  hold=False; wave=0; armed=False; b240={}; flag240up=False
 
690
  _whyR = ('계단식하락' if (_pg is not None and _pg < DOUBLE_MTN_GAP_MIN)
691
  else ('끝지점 낮아짐' if not _end_ok else '피크데이터 없음'))
692
  ev.append((d,'속음→재베이스(깊은붕괴)',
693
+ 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}'))
694
  phase='PEAK'; peak_gap=gap; peak_m60=m60
695
  endlow_gap=0.0; endlow_ma60=HI; endlow_px=HI
696
  hold=False; wave=0; armed=False; b240={}; flag240up=False
 
703
  # 하지만 첫 동산 생명선을 안 깼으니 시퀀스(동산+더블동산)는 살아있음 (M자도 더블동산).
704
  # 60>240 복귀 = 새 매수구간 → 1번째 위아래부터 재개. 생명선은 계속 첫 동산 것.
705
  # (생명선이 유일한 사망조건 — 깨지면 DIP 중에 이미 전량매도됐을 것)
706
+ _why3 = ('계단식 하락' if (_pg is not None and _pg < DOUBLE_MTN_GAP_MIN)
707
+ else ('끝지점 직전보다 낮아짐' if not _end_ok else '피크데이터 없음'))
708
  wave_fresh=False # 시퀀스 진행 중(재개) → 모자 무시
709
+ # ★ 재개 때도 생명선 올려잡기 (직전 눌림 최저 BB하단). 내리지는 않음.
710
+ _oldlife3 = life
711
+ if VIRTUAL_LIFELINE and dip_bbmin < HI:
712
+ _v3 = dip_bbmin*(1-LIFELINE_MARGIN)
713
+ vlife = _v3 if (life is None or _v3 > life) else None
714
+ vseg_i = len(buys); vbreak_dt=None
715
+ one_wave = bool(DEEP_DIP_ONE_WAVE and dip_gap <= -DEEP_DIP_ONE_WAVE_PCT)
716
+ if DOUBLE_MTN_RELIFE and dip_bbmin < HI:
717
+ _c3 = dip_bbmin*(1-LIFELINE_MARGIN)
718
+ if life is None or _c3 > life: life = _c3
719
  ev.append((d,'작은봉우리(동산아님)→위아래 재개',
720
+ 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}% '
721
+ f'(꼭대기가 직전과 비슷해야[-{abs(DOUBLE_MTN_GAP_MIN)*100:.0f}%~+{DOUBLE_MTN_GAP*100:.0f}%] 더블동산인데 벗어남 → {_why3}) / '
722
+ f'60선이 240선 밑으로 {dip_gap*100:.1f}% 빠짐(재베이스 기준 -{REBASE_DEEP_PCT*100:.0f}% 미달이라 동산 다시 안 그림) '
723
+ f'→ 새 산 아님(동산 개수 안 올림). 생명선 {life:,.0f}'
724
+ + (f'(직전 눌림 최저BB로 올림, 기존 {_oldlife3:,.0f})' if (_oldlife3 and life>_oldlife3) else '')
725
+ + f' → 위아래 매수만 1번째부터 다시'))
726
  phase='WAVE'; wave=0; armed=False; b240={}; flag240up=False
727
  lwl=None; lwl_c=None; daily_open=False; pend=[]; up_peak=0.0; up_days=0
728
  wave_confirmed=False; conf_logged=False; touch_logged=False; cross_up_dt=None; wave_hat=0.0 # 복귀확인 리셋
배포_규칙_README.md CHANGED
@@ -34,24 +34,36 @@ push 하면 웹에 반영된다.
34
 
35
  ---
36
 
37
- ## 스크립트가 동기화하는 파일 (consolidated → QQuery)
38
- - `buy_engine_dipfix.py` ← quant/consolidated/
39
- - `final_sell_monitor.py` ← quant/consolidated/
40
- - `confirm_sell_backtest.py` ← quant/consolidated/
41
  - `confirm_sell_backtest_rs.py` ← quant/consolidated/rs_sell/
42
  - `final_sell_monitor_rs.py` ← quant/consolidated/rs_sell/
 
43
 
44
  ## 스크립트가 건드리지 않는 파일 (QQuery가 정본)
45
- - `app.py` — QQuery 전용 웹 앱
46
- - `bollinger_count_debug.py` — QQuery 버전이 웹용으로 개선됨(오늘 날짜 자동). quant 것으로 덮지 말 것.
47
  - `market_map.csv` — 양쪽 스냅샷이 조금씩 다름. 204320 등 핵심은 이미 맞음. 건드릴 필요 없음.
48
  - `README.md`, `requirements.txt`, `vcp_debug.py` — 웹 전용.
49
 
50
- > 이 셋 중 하 앞으로 quant에서 관리하고 싶으면: 먼저 quant 원본을 QQuery 최신본으로 맞춰놓고,
51
- > `sync_to_qquery.sh`의 MAP 목록에 옮겨 넣으면 된다.
 
52
 
53
  ---
54
 
 
 
 
 
 
 
 
 
 
 
 
55
  ## 최근 반영된 것 (2026-08-06)
56
  - 매수 엔진 최신 전체 (WAVE_HAT_ONLY_FRESH, REBASE_DIP_DAYS, LIFELINE_KEEP_MTN 등)
57
  - `confirm_sell_backtest.py`: **204320 HL만도를 KOSDAQ→KOSPI로 수정** (이격을 KS11로 계산)
 
34
 
35
  ---
36
 
37
+ ## 스크립트가 동기화하는 파일 (consolidated → QQuery) — 편집은 여기서만!
38
+ - `buy_engine_dipfix.py` ← quant/consolidated/
39
+ - `final_sell_monitor.py` ← quant/consolidated/
40
+ - `confirm_sell_backtest.py` ← quant/consolidated/
41
  - `confirm_sell_backtest_rs.py` ← quant/consolidated/rs_sell/
42
  - `final_sell_monitor_rs.py` ← quant/consolidated/rs_sell/
43
+ - `bollinger_count_debug.py` ← quant/consolidated/ (2026-08-06 QQuery 최신본을 consolidated로 끌어와 정본화)
44
 
45
  ## 스크립트가 건드리지 않는 파일 (QQuery가 정본)
46
+ - `app.py` — QQuery 전용 웹 앱. 여기서 직접 관리.
 
47
  - `market_map.csv` — 양쪽 스냅샷이 조금씩 다름. 204320 등 핵심은 이미 맞음. 건드릴 필요 없음.
48
  - `README.md`, `requirements.txt`, `vcp_debug.py` — 웹 전용.
49
 
50
+ > app.pymarket_map을 앞으로 consolidated에서 관리하고 싶으면: 먼저 QQuery 최신본
51
+ > consolidated로 복사한 뒤, `sync_to_qquery.sh`의 MAP 목록에 줄 추가하면 된다.
52
+ > (bollinger_count_debug.py 를 이 방식으로 정본화한 예시가 스크립트에 있음)
53
 
54
  ---
55
 
56
+ ## market_map.csv 자동 갱신 (2026-08-06 추가)
57
+ 종목→시장(KOSPI/KOSDAQ) 표. **두 겹으로 최신 유지:**
58
+ 1. **맥 스케줄러** `com.glorifiedjx.marketmap` (launchd) — 주기적으로 `build_market_map.py` 실행 →
59
+ `quant/sell_debug/market_map.csv` 갱신. 확인: `launchctl list | grep marketmap`
60
+ 2. **app.py 실행 시 자동 갱신** — `app.py`가 켜질 때 백그라운드로 `build_market_map.py --if-stale 7
61
+ --out <자기 market_map.csv>` 를 돌림. **7일 넘었으면** 그 자리(QQuery/market_map.csv)에서 바로 최신화.
62
+ - fdr/한국IP 없는 배포 서버에선 조용히 스킵(커밋된 csv 사용). 맥 로컬(fdr O)에선 실제 갱신.
63
+ - 끄려면 app.py의 `MARKET_MAP_STALE_DAYS = 0`. 주기 바꾸려면 그 숫자(일) 조정.
64
+ - `build_market_map.py`는 `quant/sell_debug/`에 있고, app.py가 알아서 찾음(같은폴더 / ../quant/sell_debug / ~/Projects/quant/sell_debug 순).
65
+ - 로컬에서 app 켠 뒤 갱신됐으면 그 QQuery/market_map.csv 를 git commit&push 하면 웹 반영.
66
+
67
  ## 최근 반영된 것 (2026-08-06)
68
  - 매수 엔진 최신 전체 (WAVE_HAT_ONLY_FRESH, REBASE_DIP_DAYS, LIFELINE_KEEP_MTN 등)
69
  - `confirm_sell_backtest.py`: **204320 HL만도를 KOSDAQ→KOSPI로 수정** (이격을 KS11로 계산)