## 배경
이전 커밋 fedbf1c 에서 globals() 검사 가드로 rerun 시 상태 초기화를 막으려
했으나 동작하지 않음. streamlit.log 의 "[일일리포트] 스레드 기동" 메시지가
restart 후에도 12 회 이상 누적된 것이 증거.
## 원인
Streamlit 은 rerun 시 메인 스크립트를 새 namespace 에서 exec() 한다.
이 namespace 는 매번 새로 만들어지므로 globals() 안에는 이전 run 의
변수가 존재하지 않음 -> 가드가 항상 True 분기를 타고 mutable state 가
매번 초기화됨. 그 결과:
- 알림 dedup 상태 (last_fired_candle) 가 None 으로 리셋
- 진입 추적 (long_entry / short_entry) 가 None 으로 리셋
- 스레드 기동 가드 (alert_started) 가 False 로 리셋 -> 새 스레드 spawn
- threading.Lock() 도 매번 새로 생성되어 동기화 깨짐
이로 인해 같은 캔들 (예: 30분봉 20:30 일반 숏) 알림이 텔레그램으로 30초
간격 반복 발사되는 증상 발생.
## 수정
mutable state 를 별도 alert_state.py 모듈로 분리. 메인 스크립트는
"import alert_state" 만 실행하는데, 이 import 는 sys.modules 캐싱
덕분에 첫 실행 후 노옵 -> alert_state 모듈은 process lifetime 동안
같은 객체이며 그 attribute 들은 보존된다. 메인 스크립트 namespace 가
매번 새로 만들어져도 alert_state 의 state 는 영향받지 않음.
상태 항목:
- last_alert (signal type 별 마지막 발사 시각, 쿨다운용)
- last_fired_candle (signal type 별 마지막 발사 캔들 open_time, dedup)
- long_entry / short_entry (진입 추적)
- alert_lock, alert_symbol, alert_interval (스레드 동기화 + UI -> 스레드)
- alert_started, daily_report_started (스레드 1회 기동 가드)
- last_report_date (자정 통과 감지용)
## 검증
import 동작 확인 (별도 process 에서):
- 같은 모듈 객체 (s1 is s2)
- 같은 dict 객체 (s1.last_fired_candle is s2.last_fired_candle)
- attribute set/get 양쪽에 반영
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## 증상
30분봉 일반 숏 신호가 한 캔들(20:30)에 발화한 뒤 30초 ~ 수 분 간격으로
계속 같은 시간(20:30) 으로 반복 알림이 텔레그램에 도착.
## 원인
Streamlit 은 사용자가 페이지를 새로고침하거나 컨트롤을 조작할 때마다
스크립트 전체를 위에서부터 재실행한다. 이 과정에서 모듈 최상단의 mutable
state 들이 매번 재할당된다:
_last_alert = {... 0 ...} # 쿨다운 타이머 0 으로 리셋
_last_fired_candle = {... None ...} # per-candle dedup 상태 None 리셋
_long_entry = None # 진입 추적 None 리셋
_alert_started = False # 스레드 가드 False 리셋
_alert_lock = threading.Lock() # 새 락 객체 생성 (기존 락 무시)
결과적으로 매 rerun 마다:
1. 새 알림 스레드가 추가로 spawn → 동일 polling 을 중복 수행
2. dedup 상태가 None 으로 리셋되어 이미 알린 캔들도 새로 알림 처리
3. 새 락 객체로 기존 스레드와 동기화 깨짐
streamlit.log 에 "[일일리포트] 스레드 기동" 메시지가 50회 가까이 찍힌 것이
1번 원인의 직접 증거.
## 수정
모듈 최상단에 globals() 검사 가드 추가:
if "_alert_state_initialized" not in globals():
_last_alert = {...}
_last_fired_candle = {...}
_long_entry = None
_short_entry = None
_alert_state_initialized = True
같은 패턴으로 _alert_thread_state_initialized 와 _last_report_date 도 보호.
첫 실행 시에만 초기화되고, 이후 rerun 에서는 globals() 에 이미 키가 존재
하므로 if 블록을 건너뛴다 -> 이전 값이 그대로 유지된다.
## 검증
페이지 5회 hit 후 streamlit.log 의 "기동" 로그 카운트:
이전: 50+ (rerun 마다 추가)
이후: 1 (단 한 번만)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## 동작
자정 KST 발송 시 텔레그램 알림 2개를 연속 발송:
1. 1배 시간 (다음 봉 검증) — 기존 로직, 신호 발화 캔들의 다음 캔들에서
반대 신호가 뜨면 실패.
2. 2배 시간 (2번째 봉 검증) — 신규, 신호 발화 캔들의 2개 뒤 캔들에서
반대 신호가 뜨면 실패.
예: 5분봉 14:00 숏 진입 신호
1배 검증: 14:05 캔들에 반대(롱)신호 -> F
2배 검증: 14:10 캔들에 반대(롱)신호 -> F
## 구현
- _count_daily_signals_per_type 에 offset 파라미터 추가 (기본 1).
- _build_daily_report_lines 헬퍼 추출 — 동일 dfs 와 cutoff 로 offset 만
바꿔서 두 메시지 본문 생성.
- send_daily_report 에서 시간봉별 df 한 번만 빌드 후 1x / 2x 두 번 포맷
-> 두 메시지 발송 (API 비용 중복 제거).
## 메시지 헤더 변화
이전: "📊 24시간 신호 통계 (BTCUSDT)"
이후: "📊 24시간 신호 통계 (BTCUSDT) - 1배 시간 (다음 봉 검증)"
"📊 24시간 신호 통계 (BTCUSDT) - 2배 시간 (2번째 봉 검증)"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## 배경
이전 자정에 일일 리포트가 발송되지 않은 1차 원인은 긴 sleep 의존(별도 커밋
057335a 에서 폴링 방식으로 전환). 그러나 폴링 적용 후 다시 살펴보니, 새
print 문에서 cp949 (Windows 콘솔 기본 인코딩) 환경에서 em-dash(—)와
right arrow(→) 인코딩 실패로 print 가 예외를 던져 try/except 가
"[일일리포트 스레드 오류]" 로 매번 잡히고 있었음. 자정 발송 로직 자체는
실행되어도 로그가 silent fail 가능성. 향후 동일 문제 차단을 위해 처리.
## 변경
- sys.stdout.reconfigure(encoding="utf-8") + sys.stderr 동일 처리.
- PYTHONIOENCODING=utf-8 환경변수도 설정 (subprocess 가 상속받도록).
- 로그 메시지의 em-dash -> "--", right arrow -> "->" 로 ASCII 화 (이중
안전장치).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## 문제
2026-05-02 00:00 KST 자정에 일일 신호 통계 알림이 발송되지 않았음.
원인 추정: time.sleep(약 2.77h) 같은 긴 sleep 이 Streamlit 의 메인 루프와
함께 돌면서 데몬 스레드가 깨어나지 않았거나, 깨어났더라도 silent 하게
중단됨. send_daily_report 함수 자체를 직접 호출하면 정상 동작 확인.
## 변경
- 짧은 sleep(60s) 폴링 루프로 변경.
- 매 폴링마다 KST 날짜를 확인 → 마지막 발송 날짜와 다르면(=자정 통과) 발송.
- 자정 통과 후 최대 60초 이내 발송 보장.
- 첫 폴링에서는 _last_report_date 를 오늘 날짜로 초기화 (재기동 직후 즉시
발송되어 사용자가 혼란해지는 것 방지).
- 발송 / 기동 / 오류 시 print 로그 남김.
기존 send_daily_report 함수 자체는 변경 없음.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## 1. 같은 캔들 같은 방향 신호를 1개 알림으로 그룹핑
이전: 한 캔들에서 강한 롱 / 일반 롱 / 볼륨 롱 신호가 동시에 떴을 때
3개의 별도 텔레그램이 같은 분 안에 연속 도착 → 스팸 체감.
이후: 발화한 신호들의 라벨을 " + " 로 결합해 1개 메시지로 발송.
🟢 강한 롱 + 🔼 일반 롱 + 🔼 볼륨 롱 진입 신호
BTCUSDT 5분봉
시간: 2026-05-01 21:00
진입가: 76,200.00
손절가: 73,914.00
per-candle dedup 과 ALERT_COOLDOWN 가드는 그대로 유지. 그룹 안 모든
신호의 _last_alert / _last_fired_candle 한꺼번에 갱신.
## 2. 손절가 비율 10% -> 3%
STOP_LOSS_PCT = 0.03 으로 조정. 5m/15m 단타 기준에 10% 는 너무 헐거워
손절 알림이 사실상 작동 안 함. 3% 면 보통 1~2시간 내 결과 결판.
## 3. 가격 표기 천단위 반점
진입가/손절가/현재가 등 모든 텔레그램 가격 출력에 ',' 천단위 구분자 적용.
(차트 hover 는 이미 적용돼있어 변경 없음.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## 변경 사항
### 진입가 open 가격 사용
- 신호 캔들의 close 가 아닌 open 을 진입가로 표기. "그 캔들이 알려준
시점의 시작가" 를 진입가로 보고 싶다는 사용자 요청.
- 텔레그램 진입 알림, 손절가 알림 reference, 차트 hover 모두 일괄 변경.
### 진입 알림 포맷 확장
이전:
🔼 롱 진입 신호
BTCUSDT 5m
진입가: ...
손절가: ...
이후:
🔼 롱 진입 신호
BTCUSDT 5분봉
시간: 2026-05-01 15:00
진입가: ...
손절가: ...
- 시간봉 코드(5m) 대신 한글 라벨(5분봉) 사용. TF_LABEL_MAP 으로 매핑.
- 신호 캔들 open_time 을 시간 행으로 추가.
### 손절가 알림 [손절가알림] 프리픽스
이전 손절가 알림은 별도 포맷이었음.
이후 진입 알림 메시지를 그대로 보존(_long_entry/_short_entry 에 entry_msg
저장)하고, 손절 도달 시 [손절가알림] 헤더와 현재가 한 줄만 추가:
[손절가알림]
🔼 롱 진입 신호
BTCUSDT 5분봉
시간: 2026-05-01 15:00
진입가: ...
손절가: ...
현재가: ...
### 일일 리포트 신호별 테이블화
이전: 시간봉당 1줄 합계만 표기.
이후: 시간봉별 블록 안에 6개 신호(강한/일반/볼륨 × 롱/숏) 라인 + 합계.
[5분봉]
강한 롱: 1T 0F
강한 숏: 1T 0F
일반 롱: 7T 0F
일반 숏: 8T 0F
볼륨 롱: 9T 1F
볼륨 숏: 9T 1F
합계: 35T 2F (승률 94.59%)
[15분봉] ...
_count_daily_signals 를 _count_daily_signals_per_type 으로 교체.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## 동작
- 매일 00:00 KST 에 BTCUSDT 기준으로 5m/15m/30m/1h/4h 봉의 지난 24시간
진입 신호를 backfill 분석해 텔레그램으로 발송.
- 실패 정의: 신호가 발화한 캔들의 다음 캔들에서 반대 방향 신호가 발화하면
해당 신호는 실패.
- 대상 신호 6종(롱/숏 × 강한/일반/볼륨급등). short_caution 은 진입 신호가
아니므로 통계에서 제외.
## 메시지 예시
📊 24시간 신호 통계 (BTCUSDT)
기준: 2026-05-02 00:00 KST
5분봉 45번 T, 5번 F (승률 90.00%)
15분봉 14번 T, 0번 F (승률 100.00%)
30분봉 7번 T, 0번 F (승률 100.00%)
1시간봉 2번 T, 0번 F (승률 100.00%)
4시간봉 0번 T, 0번 F (승률 0.00%)
## 구현
- 알림 스레드의 fetch+merge+compute 로직을 _build_signal_df 헬퍼로 분리해
일일 리포트와 공유.
- _daily_report_loop 스레드가 다음 자정 KST 까지 대기 → send_daily_report
호출 → 다시 다음 자정까지 sleep.
- main() 에서 _daily_report_started 가드로 1회만 기동.
- 시간봉별 lookback 캔들 수: 5m=500, 15m=250, 30m=200, 1h=200, 4h=200
(24h 데이터 + MA99 워밍업 여유분).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
마커 위치는 시각적 충돌 방지를 위해 기존 보정값(low*0.9998 / high*1.0002)
유지하되, hover 텍스트의 "가격" 표기는 customdata 로 close 를 전달해 표시.
이제 차트 hover 와 텔레그램 알림 모두 동일한 close 가격으로 통일됨.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
이전: 롱 = low * 0.9998, 숏 = high * 1.0002 (차트 마커 시각 보정값)
이후: 롱/숏 모두 신호 캔들의 close
지표(MA, RSI, MACD, BB)가 close 기준으로 계산되고 백테스팅 / TradingView
디폴트도 close 기준이라, 진입가 표기를 close 로 통일해 트레이더가 보는
관행과 일치시킴. 손절가 비율은 STOP_LOSS_PCT 그대로 ±10%.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## 변경 사항
- 진입가를 차트 hover 마커가 보여주는 값과 동일하게 통일.
- 롱: low * 0.9998
- 숏: high * 1.0002
- 손절가는 진입가 * (1 ± STOP_LOSS_PCT) 로 계산되므로 reference 가격이
10bps 차이나도 손절가 비율은 정확히 ±10% 로 유지됨.
- 텔레그램 진입 신호 메시지에 진입가/손절가 두 줄 추가.
## 메시지 예시
🔼 롱 진입 신호
BTCUSDT 5m
진입가: 76245.84
손절가: 68621.26
🛑 롱 손절가 도달 (-10%)
BTCUSDT 5m
진입가: 76245.84
손절가: 68621.26
현재가: 68500.00
## 영향 없는 시그널
short_caution_signal 은 진입 신호가 아니므로 가격 정보 없이 기존 형식 유지.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## 동작
- 진입 신호(strong/일반/볼륨급등 × long/short)가 발화한 캔들의 close 가격을
진입가로 기록.
- 롱 진입 → 손절가 = 진입가 * 0.90, 현재가가 손절가 이하로 내려가면 알림.
- 숏 진입 → 손절가 = 진입가 * 1.10, 현재가가 손절가 이상으로 올라가면 알림.
- 발화 시 해당 방향의 진입 상태를 클리어 → 새 진입 신호 전까지 같은 손절가
알림은 재발송되지 않음.
- 기본 비율은 STOP_LOSS_PCT 상수(0.10)로 분리. 추후 조정 용이.
## 메시지 예시
🛑 롱 손절가 도달 (-10%)
BTCUSDT 5m
진입가: 76200.00
손절가: 68580.00
현재가: 68500.00
## 주의
- short_caution_signal 은 진입 신호가 아니므로 손절가 추적 대상에서 제외.
- 롱/숏 진입 상태는 독립 추적 — 한쪽 손절 도달이 다른 쪽 상태에 영향 없음.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## 변경 사항
- git rm --cached .env : 토큰/채팅ID 가 평문으로 git push 되지 않도록
추적 해제. 로컬 파일은 그대로 유지.
- .env.example 추가 : 클론하는 사람이 어떤 환경변수가 필요한지 알 수
있게 placeholder 만 담은 템플릿 커밋.
- .gitignore 에 .env : 향후 실수로 추가되는 것 방지.
## 주의
- 이미 git history 에 들어간 옛날 토큰은 그대로 남아있음. 해당 토큰은
이미 revoke 되어 무효화되었으므로 별도 history 재작성은 진행하지 않음.
- 새 환경(서버 등)에 배포할 때는 .env.example 을 .env 로 복사한 뒤
실제 토큰/ID 를 채워 넣어야 함.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## 문제
30s 폴링 × 3캔들 윈도우 × 600s 쿨다운이 맞물려, 동일 캔들에 대한 알림이
한 번 발송된 뒤 쿨다운(10분)이 풀리는 시점에 같은 캔들이 여전히 tail(3)
윈도우 안에 남아있으면(5m 기준 최대 15분 머무름) 두 번째 알림이 다시 발송
되는 현상.
## 변경 사항
- 시그널별로 마지막 발화 캔들의 open_time 을 추적하는 _last_fired_candle
dict 추가.
- check_and_alert 에서 tail(3) 중 신호가 True 인 가장 최신 캔들의 open_time
을 키로 잡고, 직전 발화와 동일하면 스킵.
- 기존 ALERT_COOLDOWN(시간 기반) 가드는 그대로 유지 — 다른 캔들로 신호가
연속 발생할 때의 과다 알림은 여전히 차단.
결과적으로 한 캔들당 시그널 종류별로 1회만 알림이 발송됨.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- streamlit / urllib3 추가: 코드에서 import 하지만 누락돼있어 새 환경에서
서버 기동 자체가 실패하던 원인.
- ccxt / dash 제거: 어디에서도 import 되지 않음 (잔재).
- python-dotenv 버전 핀 추가, 파일 끝 개행 정리.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## 문제
대시보드 차트에는 진입 신호가 정상 표기되지만 텔레그램 알림이 전혀 발송되지
않거나 일부 신호 종류가 영구적으로 누락되는 현상.
## 변경 사항
1. 알림 스레드 캔들 50 -> 200
- MA99(99 SMA) 가 50 캔들에서는 항상 NaN 이라 bull_ma / bear_ma 가
False 가 되고, strong_long_signal / strong_short_signal 이 영원히
발화하지 않던 문제. UI 와 동일한 200 캔들로 맞춰 신호 일관성 확보.
- OI 도 50 -> 200 으로 정렬해 lookback 보강.
2. 알림 스레드에 fundingRate fetch + merge 추가
- 기존 스레드 df 에 fundingRate 컬럼이 없어 short_caution_signal
('fundingRate' in df.columns 분기) 가 항상 False 였음.
- UI 와 동일한 패턴(get_funding_rate -> floor('1h') merge -> ffill)
으로 fundingRate 합류, short_caution_signal 정상 발화.
3. check_and_alert 1캔들 -> 3캔들 체크
- df.iloc[-1] (현재 형성 중 캔들)만 보던 로직을 df.tail(3) 으로 확장.
- 30s 폴링 사이 닫혀버린 캔들의 신호 누락 방지. cooldown 은 그대로
유지되므로 중복 알림은 발생하지 않음.
## 부가
- streamlit.log / streamlit.err.log 를 .gitignore 에 추가
(런타임 산출물 — 6.9MB까지 커지는 상황 발생).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>