Compare commits

..

10 Commits

Author SHA1 Message Date
hyowons 81e8847a8e auto: 일일 백업 2026-06-11 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:00:01 +09:00
hyowons af058cf36d fix: 분봉 툴팁 등락률을 당일시가대비 → 전일대비로 (현재 등락율과 일치)
당일시가대비(vs 오늘 시가)는 갭하락 때 현재 등락율과 어긋남(예: 삼성전자
시가대비 -4.1% vs 전일대비 -7%대). 관리자 요청대로 그 봉 종가 vs 전일종가
기준으로 변경 → 마지막 봉이 현재 등락율(-7%대)과 일치.
- meta.dayOpen 제거, meta.prevClose(=ref_price) 주입
- _setOhlcText: '당일시가대비' → '전일대비' 라인
- day_open 캡처/파라미터 제거(불필요)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:23:23 +09:00
hyowons 8bfaecbd1e fix: 분봉 당일시가대비 baseline 오류 + 우상단 전일대비 텍스트 제거
- 당일시가(dayOpen)를 range 슬라이스 전 '오늘 첫 봉' 시가로 고정 → 1h/30min 등
  구간을 좁혀도 baseline 동일. 마지막 봉 당일시가대비 = 현재가 등락율 일치.
  (기존: candles[0]가 슬라이스된 구간 첫 봉이라 baseline이 틀렸음)
- 우상단 '전일대비 X%' 텍스트 제거(관리자 요청). 전일종가 기준선은 유지.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:15:03 +09:00
hyowons 51d0384ed4 feat: 분봉 크로스헤어 툴팁에 '당일시가 대비 등락률' 추가
분봉 차트 클릭(세로선) 시 OHLC 팝업에 그 봉 종가의 당일 시가(첫 봉) 대비
등락률을 표시 — 그 시점까지의 오늘 누적 등락을 한눈에. 일봉은 미표시(분봉만).
- render_svg_chart(intraday=) → meta.dayOpen(첫 봉 시가, 분봉만) 주입
- 분봉 엔드포인트 intraday=True
- _setOhlcText: 당일시가대비 라인 + 팝업 박스/닫기버튼 동적 리사이즈

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:08:14 +09:00
hyowons 9c830f58f6 feat: 분봉 전일대비% 항상 표시 + 보유 상세 종목명 + 유형 폭 정리
- 분봉 차트(1/5/15m): prev_close를 일봉 캐시에서 못 찾으면 ka10095(현재가−전일대비)로
  보강 → 모든 종목에 '전일대비 +X%'(오늘 하루 등락) 항상 표시.
- 보유종목 자세히 보기 상단에 종목명+코드(.detail-name) 추가.
- 차트 컨트롤 2번째 줄: 연도/월은 내용 크기, 유형은 기간과 동일 폭(글씨 크기 유지).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:01:33 +09:00
hyowons e1fd6a7e84 refactor: 차트 컨트롤 2줄 분리 + 지정기간 단위 일별 고정
- 1번째 줄: 기간/단위/차트타입(모드) 유지. 지정기간 선택 시 단위 일별 고정(비활성).
- 2번째 줄(지정기간일 때만 노출): 유형(연도별/월별) / 연도 / 월.
- 유형=연도별이면 월 드롭다운 '-' 표시·비활성, 월별이면 월 활성.
- 연도별도 단위 일별 → 그 해 전체 일별 차트(기존 월별집계에서 변경).
- CSS: .chart-controls 컬럼 레이아웃 + .cc-line 행, .cc-line-ym[hidden] 숨김.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:13:17 +09:00
hyowons 8c891a820a refactor: 차트 기간 '연도별/월별' → '지정기간' + 유형 하위 드롭다운
기간 드롭다운의 연도별·월별 2옵션을 '지정기간' 1옵션으로 통합.
지정기간 선택 시 보조 드롭다운 노출: 유형(연도별/월별) → 연도 → 월(월별일 때만).
- _render_chart_controls: custom 옵션 + data-nw-ymtype 유형 select 추가, 가시성 재구성
- JS: range='custom' 진입·data-nw-ymtype 핸들러(ymSet/curYear/curType 헬퍼)
chart_ym 인코딩·서버 로직은 그대로(YYYY/YYYY-MM).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:57:40 +09:00
hyowons 5ee35a0e27 fix: 연도별·월별 과거 기간 차트에 오늘 라이브 점 안 붙이기
과거 기간(예: 4월)을 골라도 has_live 판정으로 오늘 순자산이 마지막 점·델타·
하단값으로 붙어 차트와 무관한 값이 나오던 문제. period가 이번달·올해가 아니면
(period_is_current=False) current_net/rt_owner를 None으로 → 라이브 점·라이브
갱신 모두 비활성. 과거 차트는 그 기간 끝점에서 마감, 델타·하단값이 차트와 일치.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:49:42 +09:00
hyowons 7bb07ea3ad feat: 자산 차트보기에 연도별·월별 절대 선택 추가
기간 드롭다운에 '연도별'(연도 선택→월별 집계)·'월별'(연도+월 선택→일별) 추가.
- _available_chart_periods(): 스냅샷에서 데이터 있는 연/월만 추출(드롭다운 채움)
- _load_net_worth_series(period=): 'YYYY'/'YYYY-MM' 절대 필터
- _render_summary_panel: chart_ym → 단위 강제(연도별=월별집계/월별=일별)
- _render_chart_controls: 연·월 보조 드롭다운 + months map(JS용) + period 시 단위 disable
- chart_ym 파라미터 체인·캐시키·GET 핸들러(형식검증) 전반 연결
- JS: chartYM 저장/전송 + 연도별/월별/연·월 셀렉터 핸들러
- CSS: .cc-ym[hidden] 숨김(flex가 [hidden] UA스타일 덮지 않도록)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:42:25 +09:00
hyowons f8cd71713b Revert "fix: 보유탭 등락 기준을 ka10095 전일종가로 통일 — 감시탭 미리보기와 일치"
This reverts commit 0ba61d4d7c.
2026-06-10 09:05:36 +09:00
16 changed files with 1291 additions and 409 deletions
+5 -5
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -50,9 +50,9 @@
| `holiday-sync` | 매주 일요일 03:00 | `holiday_sync.py` | investing.com → `state/market_holidays.json`. 실패 시 기존 파일 보존 | | `holiday-sync` | 매주 일요일 03:00 | `holiday_sync.py` | investing.com → `state/market_holidays.json`. 실패 시 기존 파일 보존 |
| `behive-web` | 상시 (`KeepAlive`) | `behive_web.py serve` | 3-탭 워치리스트 웹뷰. Tailscale `100.75.148.12:18790``https://stock.hyowons.net/`(Synology reverse proxy). 코드 변경 후 bootout/bootstrap 필요 | | `behive-web` | 상시 (`KeepAlive`) | `behive_web.py serve` | 3-탭 워치리스트 웹뷰. Tailscale `100.75.148.12:18790``https://stock.hyowons.net/`(Synology reverse proxy). 코드 변경 후 bootout/bootstrap 필요 |
| `trade-journal` | 평일 21:00 (스크립트가 휴장일 self-skip) | `trade_journal.py collect` | ka10170 당일매매일지 4계좌 → `state/trade_journal.jsonl` 누적. NXT 야간 마감 후 발화. 키움이 기간 거래내역 API 미제공이라 일자별 적재만이 유일. `briefing-fallback-2100`(21:00)과 같은 시각이지만 둘 다 read-only 충돌 없음. 재실행 시 (date,account) 단위 idempotent. **시드**: 2026-05-13 1회 `trade_journal.py seed` 로 적재 시작일 이전 보유분을 현재 평단가×(qty - tdy_buyq + tdy_sellq)로 단일 시드 행 압축(28건, seed=true 플래그, `*` 마커). 시드는 가중평균이라 과거 매수 단가와 정확히 일치 X. CLI: `collect`/`seed`/`show <code>`/`query --from --to --account --code` | | `trade-journal` | 평일 21:00 (스크립트가 휴장일 self-skip) | `trade_journal.py collect` | ka10170 당일매매일지 4계좌 → `state/trade_journal.jsonl` 누적. NXT 야간 마감 후 발화. 키움이 기간 거래내역 API 미제공이라 일자별 적재만이 유일. `briefing-fallback-2100`(21:00)과 같은 시각이지만 둘 다 read-only 충돌 없음. 재실행 시 (date,account) 단위 idempotent. **시드**: 2026-05-13 1회 `trade_journal.py seed` 로 적재 시작일 이전 보유분을 현재 평단가×(qty - tdy_buyq + tdy_sellq)로 단일 시드 행 압축(28건, seed=true 플래그, `*` 마커). 시드는 가중평균이라 과거 매수 단가와 정확히 일치 X. CLI: `collect`/`seed`/`show <code>`/`query --from --to --account --code` |
| `sim-scan` | 평일 09:0015:30 **매 5분** (`StartCalendarInterval` 79엔트리, 엔진이 장외/휴장 self-skip) ⚠️2026-06-09 15분→5분 단축 + `StartInterval` 900s→`StartCalendarInterval` 전환(StartInterval은 GUI세션 idle/디스플레이 sleep 시 timer coalescing으로 발화 보류돼 장중 안 돎. calendar는 git-autopush가 새벽 02:00 정상발화로 검증됨) | `python3 -m sim scan` (내부 `scan_all` — 메인+병렬변이 동시) | **자동매매 시뮬(페이퍼)** 1회 스캔. 가상자본 1천만, 규칙 엔진(LLM 미경유). universe=비하이브 워치+관심+보유(**ETF/ETN은 2026-06-09부터 자동매수 제외** — 애널 방향성 부재로 전략 부정합, `universe.is_etf()` 프리픽스 판별, 보유분은 청산까지 추적). 방향=애널 약한게이트+가점, 타이밍=수급·기술(2026-06-09 시장강약 필터: 하드차단→소프트게이트+장중 실시간 상승비율 + **지수추세 이중확인**). **2026-06-09 4종 추가**: 리스크 기반 사이징(`signals.target_value` 수량=자산×`RISK_PER_TRADE_PCT`÷손절폭, 균등비중 캡)·RSI 필터(돌파 과열/눌림목 과매도 보류)·**시장 국면 6단계**(`market_regime_map``engine.REGIME`: 🔥강세·📈반등·🌤완만상승=매수허용 / ➖혼조·📉약세·❄️급락=보류. 상승비율+지수추세+당일등락 조합. market_ok·시장탭 공용. 강세/약세 2단계가 84% 반등을 약세로 오판해 세분화)·**약세장 매수모드**(`BEAR_ENTRY_MODE` 0현금화/1돌파만/2눌림허용, market_ok False일 때 signals 분기. backtest는 시장중립이라 무영향→병렬변이로 라이브 비교. 2026-06-09 메인(mode1)+약세현금화·약세눌림허용 변이로 비교군 구성)·시간손절(`MAX_HOLD_DAYS` 미진전 청산)·**추세 질 보강**(2026-06-09 trend_ok 게이트에 ①20일선 우상향 기울기 ②5>60 중기정배열 추가〔종목데이터, backtest 반영〕 + ③상대강도〔종목−지수 N일수익률, 라이브 전용 체크〕). 사이징·시간손절은 엔진·백테스트 공용 헬퍼. TUNABLE 26개. 매수=돌파 즉시/눌림목 지정가, 매도=손절·2:1 후 트레일링·추세수급 이탈. 결과 `state/sim/`(portfolio.json·trades.jsonl·last_scan.json). 실주문 `orders/` 불가침. 일봉은 캐시 우선 읽기(ka10081 rate limit 회피). cwd=`agents/stock/workspace` | | `sim-scan` | 평일 09:0015:30 **매 5분** (`StartCalendarInterval` 79엔트리, 엔진이 장외/휴장 self-skip) ⚠️2026-06-09 15분→5분 단축 + `StartInterval` 900s→`StartCalendarInterval` 전환(StartInterval은 GUI세션 idle/디스플레이 sleep 시 timer coalescing으로 발화 보류돼 장중 안 돎. calendar는 git-autopush가 새벽 02:00 정상발화로 검증됨) | `python3 -m sim scan` (내부 `scan_all` — 메인+병렬변이 동시) | **자동매매 시뮬(페이퍼)** 1회 스캔. 가상자본 1억(2026-06-10 1천만→1억), 규칙 엔진(LLM 미경유). universe=비하이브 워치+관심+보유(**ETF/ETN은 2026-06-09부터 자동매수 제외** — 애널 방향성 부재로 전략 부정합, `universe.is_etf()` 프리픽스 판별, 보유분은 청산까지 추적). 방향=애널 약한게이트+가점, 타이밍=수급·기술(2026-06-09 시장강약 필터: 하드차단→소프트게이트+장중 실시간 상승비율 + **지수추세 이중확인**). **2026-06-09 4종 추가**: 리스크 기반 사이징(`signals.target_value` 수량=자산×`RISK_PER_TRADE_PCT`÷손절폭, 균등비중 캡)·RSI 필터(돌파 과열/눌림목 과매도 보류)·**시장 국면 6단계**(`market_regime_map``engine.REGIME`: 🔥강세·📈반등·🌤완만상승=매수허용 / ➖혼조·📉약세·❄️급락=보류. 상승비율+지수추세+당일등락 조합. market_ok·시장탭 공용. 강세/약세 2단계가 84% 반등을 약세로 오판해 세분화)·**약세장 매수모드**(`BEAR_ENTRY_MODE` 0현금화/1돌파만/2눌림허용, market_ok False일 때 signals 분기. backtest는 시장중립이라 무영향→병렬변이로 라이브 비교. 2026-06-09 메인(mode1)+약세현금화·약세눌림허용 변이로 비교군 구성)·시간손절(`MAX_HOLD_DAYS` 미진전 청산)·**추세 질 보강**(2026-06-09 trend_ok 게이트에 ①20일선 우상향 기울기 ②5>60 중기정배열 추가〔종목데이터, backtest 반영〕 + ③상대강도〔종목−지수 N일수익률, 라이브 전용 체크〕). 사이징·시간손절은 엔진·백테스트 공용 헬퍼. TUNABLE 27개. **2026-06-10 코드리뷰 보강**: ①수급DB 일일증분(trade-journal 21시 묶음 3번째 명령 `-m sim.backfill_flow --pages 1 --force` — stale시 백테스트 수급게이트 중립화됨) ②일별 성과이력 `state/sim/equity_history.json`(EOD 덮어쓰기, ph=파라미터해시) ③당일 재진입 쿨다운(`Portfolio.last_exit`, backtest 동일) ④장중 거래량 하루치 환산(`vol_time_frac` — 이전엔 오전 돌파 불가). ⑤백테스트 체결 현실화(당일종가 판단→다음날 시가 체결 pending큐 + `BT_SLIPPAGE` 0.2% — 낙관분 ~5%p 제거) ⑥3분할 꾸준함 순위(`worst_ret` 1순위 — 한 구간 운빨 배제) ⑧**기준전략(M) 폐지**(2026-06-10 — scan_all 메인 패스 execute=False 판단전용·계좌 동결, 비교/매매는 변이 36개만, 메인 제외 규칙 삭제, ⭐즐겨찾기 `favorites.json`+`/fav_toggle`, 관심종목 기본 시각=첫⭐→1위) ⑦비교군 동기화는 **수동 전용**(2026-06-10 자동 동기화 끔 — sweep은 결과 파일만 갱신, 비교군 변경은 관리자님 명시 요청 시만 `python3 -m sim variants sync`. `sync_label_tops`=유형(~36종)별 1위 동기화, 동일params 계좌 유지. 백테스트 탭은 유형별 대표 읽기전용 요약, 수동 추가/제거·튜닝적용 버튼 제거). 잔여 한계: 검증 데이터가 여전히 ~1년 강세 위주(진짜 하락장 검증은 라이브 누적)·생존편향 universe(의도된 설계). 매수=돌파 즉시/눌림목 지정가, 매도=손절·2:1 후 트레일링·추세수급 이탈. 결과 `state/sim/`(portfolio.json·trades.jsonl·last_scan.json). 실주문 `orders/` 불가침. 일봉은 캐시 우선 읽기(ka10081 rate limit 회피). cwd=`agents/stock/workspace` |
| `sim-web` | 상시 (`KeepAlive`) | `python3 -m sim.sim_web serve` | 시뮬 대시보드 **별도 서버 포트 18792** (behive-web 18790과 분리). `sim.hyowons.net` → mac:18792 (Synology reverse proxy, **관리자님 수동 등록 대기**). Tailnet 한정 무인증. ⚠️ 18791은 node 점유라 18792. **5탭**: 내 계좌·관심종목·시장(지수/ADR/투자자)·튜닝(파라미터 저장 → `params.json`)·백테스트(스윕 순위표+적용). 읽기전용 원칙이나 `do_POST /save_params`만 쓰기. 시장 탭은 장중엔 네이버 m.stock 실시간(지수+상승하락·ADR·투자자 순매수, `_get_live_market` 60s 캐시), 장외엔 EOD jsonl 폴백 (2026-06-09: 이전엔 상승하락·투자자가 EOD 고정이라 장중 안 바뀌던 것 수정) | | `sim-web` | 상시 (`KeepAlive`) | `python3 -m sim.sim_web serve` | 시뮬 대시보드 **별도 서버 포트 18792** (behive-web 18790과 분리). `sim.hyowons.net` → mac:18792 (Synology reverse proxy, **관리자님 수동 등록 대기**). Tailnet 한정 무인증. ⚠️ 18791은 node 점유라 18792. **5탭**: 내 계좌·관심종목·시장(지수/ADR/투자자)·튜닝(파라미터 저장 → `params.json`)·백테스트(스윕 순위표+적용). 읽기전용 원칙이나 `do_POST /save_params`만 쓰기. 시장 탭은 장중엔 네이버 m.stock 실시간(지수+상승하락·ADR·투자자 순매수, `_get_live_market` 60s 캐시), 장외엔 EOD jsonl 폴백 (2026-06-09: 이전엔 상승하락·투자자가 EOD 고정이라 장중 안 바뀌던 것 수정) |
| (트리거 없음) | on-demand | `python3 -m sim {sweep\|backtest}` | 튜닝 비교용 백테스트 스윕. signals.py 재사용·과거 일봉 되감기. 9파라미터(RR·STOP·SMA·PULLBACK·VOLUME + RSI과열/과매도·시간손절·리스크비율) 1152조합 학습/검증 분리 → `state/sim/backtest_results.json`. ⚠️ 조합당 ~2.3s → 1152조합 44분(GRID 곱셈 폭발 주의). ⚠️ 종가체결·생존편향 → **상대순위**로만. 2026-06-09 스윕: 핵심 레버 `VOLUME_BREAKOUT=1.3`, 신규4종은 강세창선 미미(하락장 보험). 균형값 params.json 적용(RISK 0.01 유지). 수급은 `backfill_flow.py`(ka10059 다년치 → `flow_history.sqlite`, 2026-06-08 54종목 3만행 적재)로 반영됨(`flow_used:true`). 런타임 튜닝은 `params.json`이 config 기본값 오버라이드(엔진 매 스캔 새프로세스라 즉시 반영). **병렬 페이퍼**: `variants.json`+`state/sim/variants/<id>/` — 스윕 상위 튜닝을 각자 가상계좌로 실시간 동시 운영, 같은 라이브 시세에 다른 파라미터(애널 게이트까지 실반영), `variants_compare.json`→웹 비교 탭. `python3 -m sim variants seed N`. sim-scan이 매 스캔 메인+변이 함께 갱신 | | (트리거 없음) | on-demand | `python3 -m sim {sweep\|backtest}` | 튜닝 비교용 백테스트 스윕. signals.py 재사용·과거 일봉 되감기. 9파라미터(RR·STOP·SMA·PULLBACK·VOLUME + RSI과열/과매도·시간손절·리스크비율) 1152조합 학습/검증 분리 → `state/sim/backtest_results.json`. ⚡멀티프로세싱(2026-06-10 spawn, 워커=코어−2)+일봉/수급 프로세스 메모이즈 → 1152조합 44분→~5-7분. 데이터는 sqlite캐시 전용(키움API 0), 병목은 지표 재계산이라 캐싱 아닌 병렬화가 답. GRID 곱셈 폭발 주의. ⚠️ 종가체결·생존편향 → **상대순위**로만. 2026-06-09 스윕: 핵심 레버 `VOLUME_BREAKOUT=1.3`, 신규4종은 강세창선 미미(하락장 보험). 균형값 params.json 적용(RISK 0.01 유지). 수급은 `backfill_flow.py`(ka10059 다년치 → `flow_history.sqlite`, 2026-06-08 54종목 3만행 적재)로 반영됨(`flow_used:true`). 런타임 튜닝은 `params.json`이 config 기본값 오버라이드(엔진 매 스캔 새프로세스라 즉시 반영). **병렬 페이퍼**: `variants.json`+`state/sim/variants/<id>/` — 스윕 상위 튜닝을 각자 가상계좌로 실시간 동시 운영, 같은 라이브 시세에 다른 파라미터(애널 게이트까지 실반영), `variants_compare.json`→웹 비교 탭. `python3 -m sim variants seed N`. sim-scan이 매 스캔 메인+변이 함께 갱신 |
휴장일 self-skip·당일 평가손익 ground truth·3-탭 개편 배경은 일지(2026-05-04·05-06)와 `behive_web.py` 주석 참조. 사고 이력은 `memory/2026-05-07-behive-dup-mail.md` 등. 휴장일 self-skip·당일 평가손익 ground truth·3-탭 개편 배경은 일지(2026-05-04·05-06)와 `behive_web.py` 주석 참조. 사고 이력은 `memory/2026-05-07-behive-dup-mail.md` 등.
+204 -62
View File
@@ -2592,18 +2592,6 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
star = ''.join(star_parts) star = ''.join(star_parts)
day_change = r.get('day_change', 0) day_change = r.get('day_change', 0)
day_change_pct = r.get('day_change_pct', 0.0) day_change_pct = r.get('day_change_pct', 0.0)
# 표시 등락 기준을 마크된 시장의 ka10095 전일종가(현재가−전일대비)로 통일 — 감시탭과 일치.
# (스냅샷 rebase 값과 50원 단위로 갈려 미리보기·당일종가 등락이 어긋나던 것 해소. 원본 r은 보존.)
_ok = r.get('ohlc_krx') or {}
_on = r.get('ohlc_nxt') or {}
_kp = _ok.get('price', 0) or 0
_np = _on.get('price', 0) or 0
_md = _on if (_np and _np == price and _np != _kp) else _ok
_md_prev = (_md.get('price', 0) or 0) - (_md.get('change', 0) or 0)
if _md_prev > 0 and price:
day_change = price - _md_prev
day_change_pct = (price - _md_prev) / _md_prev * 100.0
r = {**r, 'day_change': day_change, 'day_change_pct': day_change_pct}
candle_summary = '' candle_summary = ''
ohlc = r.get('ohlc') or {} ohlc = r.get('ohlc') or {}
@@ -2679,18 +2667,14 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
if _krx_prev > 0: if _krx_prev > 0:
ohlc_krx_for_render['prev_close'] = _krx_prev ohlc_krx_for_render['prev_close'] = _krx_prev
ohlc_nxt_for_render = r.get('ohlc_nxt') ohlc_nxt_for_render = r.get('ohlc_nxt')
if ohlc_nxt_for_render: _snap_prev = r.get('pred_close', 0) or 0
if ohlc_nxt_for_render and _snap_prev > 0:
ohlc_nxt_for_render = dict(ohlc_nxt_for_render) ohlc_nxt_for_render = dict(ohlc_nxt_for_render)
ohlc_nxt_for_render['open'] = _snap_prev
ohlc_nxt_for_render['prev_close'] = _snap_prev
_nxt_px = ohlc_nxt_for_render.get('price', 0) or 0 _nxt_px = ohlc_nxt_for_render.get('price', 0) or 0
# NXT 전일종가도 ka10095(현재가−전일대비)로 — 스냅샷(pred_close)과 갈려 등락이 어긋나던 것 통일. ohlc_nxt_for_render['change'] = _nxt_px - _snap_prev
_nxt_prev = _nxt_px - (ohlc_nxt_for_render.get('change', 0) or 0) ohlc_nxt_for_render['change_pct'] = ((_nxt_px - _snap_prev) / _snap_prev * 100) if _snap_prev else 0.0
if _nxt_prev <= 0:
_nxt_prev = r.get('pred_close', 0) or 0
if _nxt_prev > 0:
ohlc_nxt_for_render['open'] = _nxt_prev
ohlc_nxt_for_render['prev_close'] = _nxt_prev
ohlc_nxt_for_render['change'] = _nxt_px - _nxt_prev
ohlc_nxt_for_render['change_pct'] = ((_nxt_px - _nxt_prev) / _nxt_prev * 100) if _nxt_prev else 0.0
# 당일종가: KRX는 정규장 마감(15:30) 후, NXT는 마감(20:00) 후에만 그 시장 종가 표시. # 당일종가: KRX는 정규장 마감(15:30) 후, NXT는 마감(20:00) 후에만 그 시장 종가 표시.
_now_ohlc = datetime.now(KST) _now_ohlc = datetime.now(KST)
_hm_ohlc = _now_ohlc.hour * 60 + _now_ohlc.minute _hm_ohlc = _now_ohlc.hour * 60 + _now_ohlc.minute
@@ -2761,6 +2745,7 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
{candle_summary} {candle_summary}
</summary> </summary>
<div class="detail"> <div class="detail">
<div class="detail-name">{stock}{f'<span class="detail-code">{code}</span>' if code else ''}</div>
{f'<div class="detail-tag">{tag_chip}</div>' if tag_chip else ''} {f'<div class="detail-tag">{tag_chip}</div>' if tag_chip else ''}
<dl>{dl_inner}</dl> <dl>{dl_inner}</dl>
<dl class="pl-summary">{pl_dl_inner}</dl> <dl class="pl-summary">{pl_dl_inner}</dl>
@@ -3168,7 +3153,31 @@ def _aggregate_cash_flows_by_unit(daily_cf: dict[str, dict], aggregated_dates: l
return out return out
def _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: str = 'day', with_prev_baseline: bool = False): def _available_chart_periods() -> dict:
"""스냅샷에 데이터가 있는 연도·연월 목록 (연도별/월별 드롭다운 채움용). 최신이 앞.
반환: {'years': ['2026','2025',...], 'months': {'2026': ['06','05',...], ...}}"""
empty = {'years': [], 'months': {}}
if not SNAPSHOT_FILE.exists():
return empty
try:
snap = json.loads(SNAPSHOT_FILE.read_text())
except Exception:
return empty
years: set = set()
months: dict = {}
for date_key in snap.keys():
if len(date_key) < 7 or date_key[4] != '-':
continue
y, m = date_key[:4], date_key[5:7]
years.add(y)
months.setdefault(y, set()).add(m)
return {
'years': sorted(years, reverse=True),
'months': {y: sorted(ms, reverse=True) for y, ms in months.items()},
}
def _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: str = 'day', with_prev_baseline: bool = False, period: str = ''):
"""portfolio_daily_snapshot에서 owner별 순자산 시계열을 추출. """portfolio_daily_snapshot에서 owner별 순자산 시계열을 추출.
날짜의 net_worth = sum(holdings[*].eval_value) + deposit. 누락 필드는 0으로 처리. 날짜의 net_worth = sum(holdings[*].eval_value) + deposit. 누락 필드는 0으로 처리.
unit으로 /// 단위 집계 ( 구간 마지막 영업일 ). 기간(days) 필터는 단위 집계 적용. unit으로 /// 단위 집계 ( 구간 마지막 영업일 ). 기간(days) 필터는 단위 집계 적용.
@@ -3204,7 +3213,10 @@ def _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: s
# filtered 에 baseline 점이 prepend 되었으면 그게 series[0] = PnL baseline 역할 → prev_baseline 별도 안 넘김 # filtered 에 baseline 점이 prepend 되었으면 그게 series[0] = PnL baseline 역할 → prev_baseline 별도 안 넘김
baseline_prepended = False baseline_prepended = False
if days == -2: # 이번달 — KST 기준 이번 달 1일 이후만 if period: # 절대 선택 — 'YYYY'(연도별·월별집계) 또는 'YYYY-MM'(월별·일별). caller가 unit을 맞춰 호출.
range_start = (period + '-01-01') if len(period) == 4 else (period + '-01')
filtered = [(d, v) for d, v in aggregated if d.startswith(period)]
elif days == -2: # 이번달 — KST 기준 이번 달 1일 이후만
prefix = datetime.now(KST).strftime('%Y-%m') prefix = datetime.now(KST).strftime('%Y-%m')
range_start = f'{prefix}-01' range_start = f'{prefix}-01'
filtered = [(d, v) for d, v in aggregated if d.startswith(prefix)] filtered = [(d, v) for d, v in aggregated if d.startswith(prefix)]
@@ -4201,6 +4213,8 @@ details.row + .section-label { margin-top: 14px; padding-top: 24px; border-top-c
.tag-chip { display: inline-block; padding: 0 4px; border-radius: 3px; font-size: 8.5px; font-weight: 500; line-height: 1.4; color: #d6b34a; background: rgba(214,179,74,0.10); border: 1px solid rgba(214,179,74,0.30); white-space: nowrap; flex: none; letter-spacing: 0.01em; font-family: inherit; pointer-events: none; user-select: none; } .tag-chip { display: inline-block; padding: 0 4px; border-radius: 3px; font-size: 8.5px; font-weight: 500; line-height: 1.4; color: #d6b34a; background: rgba(214,179,74,0.10); border: 1px solid rgba(214,179,74,0.30); white-space: nowrap; flex: none; letter-spacing: 0.01em; font-family: inherit; pointer-events: none; user-select: none; }
.tag-chip.tag-leader { color: #ff6b78; background: rgba(255,77,94,0.12); border-color: rgba(255,77,94,0.42); font-weight: 600; } .tag-chip.tag-leader { color: #ff6b78; background: rgba(255,77,94,0.12); border-color: rgba(255,77,94,0.42); font-weight: 600; }
/* 펼친 detail의 태그는 크게 (미리보기·관심행 칩은 작게 유지). */ /* 펼친 detail의 태그는 크게 (미리보기·관심행 칩은 작게 유지). */
.detail-name { font-size: 15px; font-weight: 700; color: #f0f0f0; margin: 0 0 8px; display: flex; align-items: baseline; gap: 8px; }
.detail-name .detail-code { font-size: 11px; font-weight: 500; color: #8b8f9a; font-variant-numeric: tabular-nums; }
.detail-tag { margin: 0 0 8px; } .detail-tag { margin: 0 0 8px; }
.detail-tag .tag-chip { font-size: 11px; padding: 2px 8px; border-radius: 4px; } .detail-tag .tag-chip { font-size: 11px; padding: 2px 8px; border-radius: 4px; }
.btn-add-tag { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 500; line-height: 1.5; color: #5a5f6c; background: transparent; border: 1px dashed rgba(120,124,135,0.30); white-space: nowrap; flex: none; cursor: pointer; font-family: inherit; } .btn-add-tag { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 500; line-height: 1.5; color: #5a5f6c; background: transparent; border: 1px dashed rgba(120,124,135,0.30); white-space: nowrap; flex: none; cursor: pointer; font-family: inherit; }
@@ -4968,7 +4982,9 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
.holdings-toggle-wrap { margin-top: 4px; } .holdings-toggle-wrap { margin-top: 4px; }
/* 차트 컨트롤 자산정보 상단의 공통 기간·단위 select 묶음. */ /* 차트 컨트롤 자산정보 상단의 공통 기간·단위 select 묶음. */
.chart-controls { display: flex; gap: 10px; margin: 10px 14px 4px; flex-wrap: wrap; } .chart-controls { display: flex; flex-direction: column; gap: 6px; margin: 10px 14px 4px; }
.chart-controls .cc-line { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.chart-controls .cc-line-ym[hidden] { display: none; } /* 지정기간 아닐 2번째 숨김 ([hidden] UA 스타일이 flex에 가려지지 않도록 명시) */
.chart-controls .cc-field { display: flex; align-items: center; gap: 6px; } .chart-controls .cc-field { display: flex; align-items: center; gap: 6px; }
.chart-controls .cc-label { color: #8b8f9a; font-size: 11px; } .chart-controls .cc-label { color: #8b8f9a; font-size: 11px; }
.chart-controls .nw-select { .chart-controls .nw-select {
@@ -4980,6 +4996,10 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
min-width: 96px; min-width: 96px;
} }
.chart-controls .nw-select:focus { outline: 1px solid #4a4f5a; outline-offset: 0; } .chart-controls .nw-select:focus { outline: 1px solid #4a4f5a; outline-offset: 0; }
/* 2번째 (유형/연도/) 연도/월은 내용 크기로( 줄바뀜 방지), 유형은 기간과 동일 . 글씨는 1번째 줄과 동일. */
.chart-controls .cc-line-ym { gap: 8px; }
.chart-controls .cc-line-ym .nw-select { min-width: 0; }
.chart-controls .cc-line-ym .nw-select[data-nw-ymtype] { min-width: 96px; } /* 유형 = 기간 select 동일 */
@media (max-width: 600px) { @media (max-width: 600px) {
nav.tabs .tab-label { padding: 10px 12px; font-size: 12px; } nav.tabs .tab-label { padding: 10px 12px; font-size: 12px; }
@@ -5127,17 +5147,53 @@ def _owner_full_title(owner: str) -> str:
return OWNER_LABELS.get(owner, f'{owner} 자산') return OWNER_LABELS.get(owner, f'{owner} 자산')
def _render_chart_controls(selected_days: int, selected_unit: str, selected_mode: str = NET_WORTH_CHART_MODE) -> str: def _render_chart_controls(selected_days: int, selected_unit: str, selected_mode: str = NET_WORTH_CHART_MODE, chart_ym: str = '', avail: dict | None = None) -> str:
"""차트용 공통 컨트롤 — 기간 / 단위 / 모드(순자산·손익누적·기간손익) select 3개. """차트용 공통 컨트롤 — 기간 / 단위 / 모드(순자산·손익누적·기간손익) select.
자산정보 상단에 1세트만 노출, 본인·가희 차트 동시 컨트롤. 기간에 '지정기간' 추가 유형(연도별/월별) + 연도 + (월별일 ) 보조 드롭다운.
의미없는 단위(이번주+월별 ) disabled.""" period(chart_ym) 활성 단위는 강제·비활성. 의미없는 단위(이번주+월별 ) disabled."""
avail = avail or {'years': [], 'months': {}}
period = (chart_ym or '').strip()
is_year = len(period) == 4
is_month = len(period) == 7
period_active = is_year or is_month
years = avail.get('years') or []
months_map = avail.get('months') or {}
allowed_units = NET_WORTH_ALLOWED_UNITS_BY_DAYS.get( allowed_units = NET_WORTH_ALLOWED_UNITS_BY_DAYS.get(
selected_days, tuple(u for _, u in NET_WORTH_UNIT_PRESETS) selected_days, tuple(u for _, u in NET_WORTH_UNIT_PRESETS)
) )
range_opts = ''.join( range_opts = ''.join(
f'<option value="{d}"{" selected" if d == selected_days else ""}>{html.escape(lbl)}</option>' f'<option value="{d}"{" selected" if (not period_active and d == selected_days) else ""}>{html.escape(lbl)}</option>'
for lbl, d in NET_WORTH_CHART_PRESETS for lbl, d in NET_WORTH_CHART_PRESETS
) )
range_opts += f'<option value="custom"{" selected" if period_active else ""}>지정기간</option>'
# 유형: 연도별(year) / 월별(month) — period_active 일 때만 노출.
type_opts = (
f'<option value="year"{" selected" if not is_month else ""}>연도별</option>'
f'<option value="month"{" selected" if is_month else ""}>월별</option>'
)
cur_year = period[:4] if period_active else (years[0] if years else '')
cur_month = period[5:7] if is_month else ''
year_opts = ''.join(
f'<option value="{y}"{" selected" if y == cur_year else ""}>{html.escape(y)}년</option>'
for y in years
)
# 월 — 월별일 때만 실제 월 목록(활성), 연도별이면 '-' 한 칸 비활성.
if is_month:
month_opts = ''.join(
f'<option value="{m}"{" selected" if m == cur_month else ""}>{int(m)}월</option>'
for m in months_map.get(cur_year, [])
)
_month_dis = ''
else:
month_opts = '<option value="">-</option>'
_month_dis = ' disabled'
_customhide = '' if period_active else ' hidden'
months_json = html.escape(json.dumps(months_map, ensure_ascii=False), quote=True)
# 지정기간은 단위 일별 고정 → 단위 select 비활성, '일별' 선택 표시.
unit_opts = ''.join( unit_opts = ''.join(
f'<option value="{u}"' f'<option value="{u}"'
f'{" selected" if u == selected_unit else ""}' f'{" selected" if u == selected_unit else ""}'
@@ -5149,16 +5205,28 @@ def _render_chart_controls(selected_days: int, selected_unit: str, selected_mode
f'<option value="{m}"{" selected" if m == selected_mode else ""}>{html.escape(lbl)}</option>' f'<option value="{m}"{" selected" if m == selected_mode else ""}>{html.escape(lbl)}</option>'
for lbl, m in NET_WORTH_MODE_PRESETS for lbl, m in NET_WORTH_MODE_PRESETS
) )
return ( _unit_dis = ' disabled' if period_active else ''
'<div class="chart-controls">' # 1번째 줄: 기간 / 단위 / 차트타입(모드). 2번째 줄(지정기간일 때만): 유형 / 연도 / 월.
line1 = (
f'<div class="cc-line">'
f'<label class="cc-field"><span class="cc-label">기간</span>' f'<label class="cc-field"><span class="cc-label">기간</span>'
f'<select class="nw-select" data-nw-range>{range_opts}</select></label>' f'<select class="nw-select" data-nw-range>{range_opts}</select></label>'
# 단위·모드 한 cc-field 묶음 — 좁은 화면 줄바꿈 방지 + 시각적 인접 배치.
f'<label class="cc-field cc-unit"><span class="cc-label">단위</span>' f'<label class="cc-field cc-unit"><span class="cc-label">단위</span>'
f'<select class="nw-select" data-nw-unit>{unit_opts}</select>' f'<select class="nw-select" data-nw-unit{_unit_dis}>{unit_opts}</select>'
f'<select class="nw-select nw-mode-inline" data-nw-mode aria-label="차트 모드">{mode_opts}</select></label>' f'<select class="nw-select nw-mode-inline" data-nw-mode aria-label="차트 모드">{mode_opts}</select></label>'
'</div>' f'</div>'
) )
line2 = (
f'<div class="cc-line cc-line-ym"{_customhide}>'
f'<label class="cc-field"><span class="cc-label">유형</span>'
f'<select class="nw-select" data-nw-ymtype>{type_opts}</select></label>'
f'<label class="cc-field"><span class="cc-label">연도</span>'
f'<select class="nw-select" data-nw-year>{year_opts}</select></label>'
f'<label class="cc-field"><span class="cc-label">월</span>'
f'<select class="nw-select" data-nw-month{_month_dis}>{month_opts}</select></label>'
f'</div>'
)
return f'<div class="chart-controls" data-ym-months="{months_json}">{line1}{line2}</div>'
def _format_signed_billion(v: int | None) -> tuple[str, str]: def _format_signed_billion(v: int | None) -> tuple[str, str]:
@@ -5701,7 +5769,7 @@ def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str:
) )
def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances: dict | None = None, journal_by_label: dict | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS) -> str: def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances: dict | None = None, journal_by_label: dict | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> str:
"""자산정보 탭 — sub-tab(자산보기/차트보기/시장정보)로 분리. """자산정보 탭 — sub-tab(자산보기/차트보기/시장정보)로 분리.
- 자산보기(기본): owner별 compact KPI 카드 스택 (합산 / 각계좌별도 토글) - 자산보기(기본): owner별 compact KPI 카드 스택 (합산 / 각계좌별도 토글)
- 차트보기: 기간·단위·모드 select + owner별 차트 (순자산 / 손익누적 토글) - 차트보기: 기간·단위·모드 select + owner별 차트 (순자산 / 손익누적 토글)
@@ -5713,9 +5781,19 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
balances = balances or {} balances = balances or {}
journal_by_label = journal_by_label or {} journal_by_label = journal_by_label or {}
# 연도별/월별 절대 선택 — chart_ym('YYYY'=연도별·월별집계 / 'YYYY-MM'=월별·일별) 있으면 단위 강제 + period 필터.
period = (chart_ym or '').strip()
eff_unit = 'day' if period else chart_unit # 지정기간(연도별·월별)은 단위 일별 고정.
# 절대 선택이 과거 기간(이번달·올해가 아님)이면 오늘 라이브 점·라이브 갱신을 끈다 —
# 과거 차트(예: 4월)에 today 순자산이 마지막 점·델타·하단값으로 섞이는 것 방지. 프리셋은 항상 today 포함.
_now_dt = datetime.now(KST)
period_is_current = True
if period:
period_is_current = (period == _now_dt.strftime('%Y')) if len(period) == 4 else (period == _now_dt.strftime('%Y-%m'))
consolidated_parts: list[str] = [] consolidated_parts: list[str] = []
by_account_parts: list[str] = [] by_account_parts: list[str] = []
chart_parts: list[str] = [_render_chart_controls(chart_days, chart_unit, chart_mode)] chart_parts: list[str] = [_render_chart_controls(chart_days, eff_unit, chart_mode, chart_ym=period, avail=_available_chart_periods())]
for owner in ordered_owners: for owner in ordered_owners:
d = owner_data.get(owner) d = owner_data.get(owner)
if not d: if not d:
@@ -5734,11 +5812,11 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
has_prev_snap=has_prev_snap, has_prev_snap=has_prev_snap,
label_disp=label_disp, label_disp=label_disp,
)) ))
chart_series, prev_baseline = _load_net_worth_series(owner, days=chart_days, unit=chart_unit, with_prev_baseline=True) chart_series, prev_baseline = _load_net_worth_series(owner, days=chart_days, unit=eff_unit, with_prev_baseline=True, period=period)
if chart_series: if chart_series:
# 스냅샷에 누적된 일자별 입출금 → unit 집계에 맞춰 sum. # 스냅샷에 누적된 일자별 입출금 → unit 집계에 맞춰 sum.
daily_cf = _load_cash_flow_by_date(owner) daily_cf = _load_cash_flow_by_date(owner)
cf_by_date = _aggregate_cash_flows_by_unit(daily_cf, [d_ for d_, _ in chart_series], chart_unit) if daily_cf else {} cf_by_date = _aggregate_cash_flows_by_unit(daily_cf, [d_ for d_, _ in chart_series], eff_unit) if daily_cf else {}
# 오늘 live 포인트용 — snapshot에 아직 안 들어간 오늘 입출금. owner_data에 이미 합산되어 있음. # 오늘 live 포인트용 — snapshot에 아직 안 들어간 오늘 입출금. owner_data에 이미 합산되어 있음.
today_cf = None today_cf = None
if d.get('cash_in', 0) or d.get('cash_out', 0): if d.get('cash_in', 0) or d.get('cash_out', 0):
@@ -5752,7 +5830,7 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
prev_baseline_point = prev_baseline if prev_baseline else None prev_baseline_point = prev_baseline if prev_baseline else None
# live current_pnl = current_net - baseline_nw - (시작점 이후 누적 cf + 오늘 cf). # live current_pnl = current_net - baseline_nw - (시작점 이후 누적 cf + 오늘 cf).
# series 마지막 점이 오늘이면 today_cf 는 baseline 이후 누적에 안 들어있으니 추가로 빼줘야 일관. # series 마지막 점이 오늘이면 today_cf 는 baseline 이후 누적에 안 들어있으니 추가로 빼줘야 일관.
current_net = d.get('total_net') current_net = d.get('total_net') if period_is_current else None
if current_net is None: if current_net is None:
current_pnl = None current_pnl = None
else: else:
@@ -5764,25 +5842,25 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
pnl_series, pnl_series,
current_net=current_pnl, current_net=current_pnl,
selected_days=chart_days, selected_days=chart_days,
selected_unit=chart_unit, selected_unit=eff_unit,
cash_flow_by_date=None, # pnl·period 모두 cf 영향 이미 제거 — 마커 숨김 cash_flow_by_date=None, # pnl·period 모두 cf 영향 이미 제거 — 마커 숨김
today_cash_flow=None, today_cash_flow=None,
mode=chart_mode, mode=chart_mode,
prev_baseline_point=prev_baseline, prev_baseline_point=prev_baseline,
rt_owner=owner, rt_base_net=d.get('total_net'), rt_owner=(owner if period_is_current else None), rt_base_net=d.get('total_net'),
)) ))
else: else:
# net 모드도 baseline 점(예: 5/1) prepend → 첫 영업일 변동이 0 기점에서 분기되는 모양. baseline 구간은 점선. # net 모드도 baseline 점(예: 5/1) prepend → 첫 영업일 변동이 0 기점에서 분기되는 모양. baseline 구간은 점선.
chart_parts.append(_render_net_worth_chart( chart_parts.append(_render_net_worth_chart(
chart_series, chart_series,
current_net=d.get('total_net'), current_net=(d.get('total_net') if period_is_current else None),
selected_days=chart_days, selected_days=chart_days,
selected_unit=chart_unit, selected_unit=eff_unit,
cash_flow_by_date=cf_by_date, cash_flow_by_date=cf_by_date,
today_cash_flow=today_cf, today_cash_flow=today_cf,
mode='net', mode='net',
prev_baseline_point=prev_baseline, prev_baseline_point=prev_baseline,
rt_owner=owner, rt_base_net=d.get('total_net'), rt_owner=(owner if period_is_current else None), rt_base_net=d.get('total_net'),
)) ))
market_card = _render_market_indicators_card() market_card = _render_market_indicators_card()
@@ -6206,7 +6284,7 @@ def _render_interests_panel(cards: list[dict]) -> tuple[str, int]:
TAB_KEY_TO_OWNER = {'self': '본인', 'gahee': '가희'} TAB_KEY_TO_OWNER = {'self': '본인', 'gahee': '가희'}
def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS) -> dict: def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> dict:
"""fetch_all_data + 탭별 패널 HTML 생성. /api/panels와 캐시의 단위. """fetch_all_data + 탭별 패널 HTML 생성. /api/panels와 캐시의 단위.
owner 지정 : 해당 owner 계좌만 호출하고 응답 tabs는 owner 하나만 (summary·wl 제외). owner 지정 : 해당 owner 계좌만 호출하고 응답 tabs는 owner 하나만 (summary·wl 제외).
클라이언트가 partial swap으로 사용 보지 않는 owner 호출 폭주 방지. 클라이언트가 partial swap으로 사용 보지 않는 owner 호출 폭주 방지.
@@ -6236,7 +6314,7 @@ def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_
# 전체 — 모든 탭 채움. 모두 동일 fetched_at. # 전체 — 모든 탭 채움. 모두 동일 fetched_at.
wl_body, wl_count = _render_watchlist_panel(wl_cards) wl_body, wl_count = _render_watchlist_panel(wl_cards)
in_body, in_count = _render_interests_panel(in_cards) in_body, in_count = _render_interests_panel(in_cards)
summary_body = _render_summary_panel(ordered_owners, owner_data, balances, journal_by_label, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days) summary_body = _render_summary_panel(ordered_owners, owner_data, balances, journal_by_label, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
tabs.append({'id': 'tab-summary', 'label': '자산정보', 'html': summary_body, 'fetched_at': fetched_hms}) tabs.append({'id': 'tab-summary', 'label': '자산정보', 'html': summary_body, 'fetched_at': fetched_hms})
for o in ordered_owners: for o in ordered_owners:
d = owner_data[o] d = owner_data[o]
@@ -6503,10 +6581,21 @@ def render_html() -> str:
'if(v!==7&&v!==30&&v!==90&&v!==180&&v!==365&&v!==-1)return null;' 'if(v!==7&&v!==30&&v!==90&&v!==180&&v!==365&&v!==-1)return null;'
'return v;' 'return v;'
'}' '}'
# chart_ym — 연도별('YYYY')/월별('YYYY-MM') 절대 선택. 빈값이면 프리셋(chartDays) 사용.
'function chartYM(){'
'var v=localStorage.getItem("behive.chartYM")||"";'
'var p=v.split("-");'
'if(p.length===1&&/^\\d{4}$/.test(p[0]))return v;'
'if(p.length===2&&/^\\d{4}$/.test(p[0])&&/^\\d{2}$/.test(p[1]))return v;'
'return "";'
'}'
'function setYM(v){try{if(v)localStorage.setItem("behive.chartYM",v);else localStorage.removeItem("behive.chartYM");}catch(_){}}'
'window.__behive_chart_days=chartDays;' 'window.__behive_chart_days=chartDays;'
'window.__behive_chart_unit=chartUnit;' 'window.__behive_chart_unit=chartUnit;'
'window.__behive_chart_mode=chartMode;' 'window.__behive_chart_mode=chartMode;'
'window.__behive_adr_days=adrDays;' 'window.__behive_adr_days=adrDays;'
'window.__behive_chart_ym=chartYM;'
'window.__behive_set_ym=setYM;'
'function load(owner,fresh){' 'function load(owner,fresh){'
# 5분 이상 stale 면 전체로 승격하되, 화면은 클리어하지 않고 기존 내용 유지 → fetch 완료 시 교체(swap). # 5분 이상 stale 면 전체로 승격하되, 화면은 클리어하지 않고 기존 내용 유지 → fetch 완료 시 교체(swap).
'if(lastLoadAt&&(Date.now()-lastLoadAt)>STALE_MS){owner=null;}' 'if(lastLoadAt&&(Date.now()-lastLoadAt)>STALE_MS){owner=null;}'
@@ -6517,6 +6606,7 @@ def render_html() -> str:
'var cu=chartUnit();if(cu!==null)qs.push("chart_unit="+cu);' 'var cu=chartUnit();if(cu!==null)qs.push("chart_unit="+cu);'
'var cm=chartMode();if(cm!==null)qs.push("chart_mode="+cm);' 'var cm=chartMode();if(cm!==null)qs.push("chart_mode="+cm);'
'var ad=adrDays();if(ad!==null)qs.push("adr_days="+ad);' 'var ad=adrDays();if(ad!==null)qs.push("adr_days="+ad);'
'var ym=chartYM();if(ym)qs.push("chart_ym="+encodeURIComponent(ym));'
'var url="/api/panels"+(qs.length?("?"+qs.join("&")):"");' 'var url="/api/panels"+(qs.length?("?"+qs.join("&")):"");'
'var pr=fetch(url,{cache:"no-store",credentials:"same-origin"})' 'var pr=fetch(url,{cache:"no-store",credentials:"same-origin"})'
'.then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.json();})' '.then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.json();})'
@@ -6865,16 +6955,33 @@ def render_html() -> str:
'document.addEventListener("change",function(e){' 'document.addEventListener("change",function(e){'
'var sel=e.target;' 'var sel=e.target;'
'if(!sel||sel.tagName!=="SELECT"||!sel.classList||!sel.classList.contains("nw-select"))return;' 'if(!sel||sel.tagName!=="SELECT"||!sel.classList||!sel.classList.contains("nw-select"))return;'
'function ymFirstMonth(y){var cc=document.querySelector(".chart-controls");try{var mp=JSON.parse(cc.getAttribute("data-ym-months")||"{}");var a=mp[y]||[];return a.length?a[0]:"";}catch(_){return "";}}'
'function ymSet(tp,y){if(!y)return;if(tp==="month"){var mm=ymFirstMonth(y);window.__behive_set_ym(mm?(y+"-"+mm):y);}else{window.__behive_set_ym(y);}}'
'function curYear(){var e=document.querySelector(\'select.nw-select[data-nw-year]\');return e&&e.value?e.value:"";}'
'function curType(){var e=document.querySelector(\'select.nw-select[data-nw-ymtype]\');return e&&e.value?e.value:"year";}'
'if(sel.hasAttribute("data-nw-range")){' 'if(sel.hasAttribute("data-nw-range")){'
'try{localStorage.setItem("behive.chartDaysV2",sel.value);}catch(_){}' 'var rv=sel.value;'
'if(rv==="custom"){'
# 지정기간 진입 — 유형(기본 연도별)·연도(기본 최신)로 chartYM 세팅.
'var y=curYear();if(!y)return;' # 데이터 없음
'ymSet(curType(),y);'
'}else{'
'window.__behive_set_ym("");' # 프리셋 → 절대선택 해제
'try{localStorage.setItem("behive.chartDaysV2",rv);}catch(_){}'
# 기간 변경 시 현재 단위가 비허용이면 첫 허용으로 자동 보정 (서버 sanity와 일치). # 기간 변경 시 현재 단위가 비허용이면 첫 허용으로 자동 보정 (서버 sanity와 일치).
'var allowedMap={"-3":["day","week"],"-2":["day","week","month"],"-4":["day","week","month","year"],"-1":["day","week","month","year"]};' 'var allowedMap={"-3":["day","week"],"-2":["day","week","month"],"-4":["day","week","month","year"],"-1":["day","week","month","year"]};'
'var allowed=allowedMap[sel.value]||["day","week","month","year"];' 'var allowed=allowedMap[rv]||["day","week","month","year"];'
'var unitSel=document.querySelector(\'select.nw-select[data-nw-unit]\');' 'var unitSel=document.querySelector(\'select.nw-select[data-nw-unit]\');'
'var curU=unitSel?unitSel.value:"day";' 'var curU=unitSel?unitSel.value:"day";'
'if(allowed.indexOf(curU)<0){' 'if(allowed.indexOf(curU)<0){try{localStorage.setItem("behive.chartUnit",allowed[0]);}catch(_){}}'
'try{localStorage.setItem("behive.chartUnit",allowed[0]);}catch(_){}'
'}' '}'
'}else if(sel.hasAttribute("data-nw-ymtype")){'
'ymSet(sel.value,curYear());'
'}else if(sel.hasAttribute("data-nw-year")){'
'ymSet(curType(),sel.value);'
'}else if(sel.hasAttribute("data-nw-month")){'
'var ry=document.querySelector(\'select.nw-select[data-nw-year]\');'
'window.__behive_set_ym((ry?ry.value:"")+"-"+sel.value);'
'}else if(sel.hasAttribute("data-nw-unit")){' '}else if(sel.hasAttribute("data-nw-unit")){'
'try{localStorage.setItem("behive.chartUnit",sel.value);}catch(_){}' 'try{localStorage.setItem("behive.chartUnit",sel.value);}catch(_){}'
'}else if(sel.hasAttribute("data-nw-mode")){' '}else if(sel.hasAttribute("data-nw-mode")){'
@@ -7210,8 +7317,14 @@ def render_html() -> str:
'xTipTxt.appendChild(_mkTspan({"font-size":24,"font-weight":500},_pctStr(c.l,c.o),lcol));' 'xTipTxt.appendChild(_mkTspan({"font-size":24,"font-weight":500},_pctStr(c.l,c.o),lcol));'
'xTipTxt.appendChild(_mkTspan({x:16,dy:34,"font-size":28,"font-weight":700},""+c.c.toLocaleString()+" ",ccol));' 'xTipTxt.appendChild(_mkTspan({x:16,dy:34,"font-size":28,"font-weight":700},""+c.c.toLocaleString()+" ",ccol));'
'xTipTxt.appendChild(_mkTspan({"font-size":24,"font-weight":700},_pctStr(c.c,c.o),ccol));' 'xTipTxt.appendChild(_mkTspan({"font-size":24,"font-weight":700},_pctStr(c.c,c.o),ccol));'
# 분봉: 전일종가 대비 등락률 — 그 봉 종가 vs 전일종가 (현재 등락율과 일치).
'var hasPC=meta.prevClose&&meta.prevClose>0;'
'if(hasPC){xTipTxt.appendChild(_mkTspan({x:16,dy:32,"font-size":23,"font-weight":600},"전일대비 "+_pctStr(c.c,meta.prevClose),_col(c.c,meta.prevClose)));}'
# 팝업 위치 — vline 의 반대 쪽으로 자동 배치 (좌/우). 화면 벗어남 방지 clamp. # 팝업 위치 — vline 의 반대 쪽으로 자동 배치 (좌/우). 화면 벗어남 방지 clamp.
'var tipW=320,tipH=176,gap=10;' 'var tipW=340,tipH=hasPC?212:176,gap=10;'
# 당일시가 라인 유무에 따라 배경 박스·닫기버튼 위치 동적 조정.
'xTipBg.setAttribute("width",tipW);xTipBg.setAttribute("height",tipH);'
'closeG.setAttribute("transform","translate("+(tipW-20)+",20)");'
'var tipX=xPos+gap;' 'var tipX=xPos+gap;'
'if(tipX+tipW>meta.chartW-meta.padR)tipX=xPos-gap-tipW;' 'if(tipX+tipW>meta.chartW-meta.padR)tipX=xPos-gap-tipW;'
'if(tipX<meta.padL)tipX=meta.padL+2;' 'if(tipX<meta.padL)tipX=meta.padL+2;'
@@ -9345,11 +9458,11 @@ def _save_owner_slices(owner_data: dict, balances: dict, journal_by_label: dict
} }
def _patch_all_cache_for_owner(owner: str, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS) -> None: def _patch_all_cache_for_owner(owner: str, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> None:
"""partial fetch 직후 'all' 캐시의 summary 패널과 해당 owner 패널만 in-place로 갈아끼운다. """partial fetch 직후 'all' 캐시의 summary 패널과 해당 owner 패널만 in-place로 갈아끼운다.
다른 owner와 watchlist/interests 슬라이스는 cached 'all' 있던 그대로 TTL 만료 full fetch로 동기화. 다른 owner와 watchlist/interests 슬라이스는 cached 'all' 있던 그대로 TTL 만료 full fetch로 동기화.
핵심 목표: 자산정보 탭의 본인 KPI가 관리자 탭과 같은 시점이 되도록.""" 핵심 목표: 자산정보 탭의 본인 KPI가 관리자 탭과 같은 시점이 되도록."""
all_key = f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}' all_key = f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}'
all_lock = _panels_lock_for(all_key) all_lock = _panels_lock_for(all_key)
with all_lock: with all_lock:
slot = _panels_cache.get(all_key) slot = _panels_cache.get(all_key)
@@ -9370,7 +9483,7 @@ def _patch_all_cache_for_owner(owner: str, chart_days: int = NET_WORTH_CHART_DAY
for e in _owner_data_cache.values(): for e in _owner_data_cache.values():
merged_balances.update(e.get('balances') or {}) merged_balances.update(e.get('balances') or {})
merged_journal.update(e.get('journal_by_label') or {}) merged_journal.update(e.get('journal_by_label') or {})
new_summary = _render_summary_panel(ordered, merged, merged_balances, merged_journal, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days) new_summary = _render_summary_panel(ordered, merged, merged_balances, merged_journal, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
entry = _owner_data_cache.get(owner) entry = _owner_data_cache.get(owner)
if not entry: if not entry:
return return
@@ -9394,7 +9507,7 @@ def _patch_all_cache_for_owner(owner: str, chart_days: int = NET_WORTH_CHART_DAY
_panels_cache[all_key] = {'json': new_body, 'expires_at': slot['expires_at']} _panels_cache[all_key] = {'json': new_body, 'expires_at': slot['expires_at']}
def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS) -> bytes: def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> bytes:
"""fresh=True 시 캐시 우회 — 명시적 새로고침(?fresh=1)에서 호출. """fresh=True 시 캐시 우회 — 명시적 새로고침(?fresh=1)에서 호출.
응답을 새로 만든 뒤에는 캐시에 덮어쓰므로 자동 갱신은 캐시를 본다. 응답을 새로 만든 뒤에는 캐시에 덮어쓰므로 자동 갱신은 캐시를 본다.
@@ -9404,7 +9517,7 @@ def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, chart
갈아끼운다(_patch_all_cache_for_owner). 자산정보 = 본인+가희 KPI 카드 스택이라 owner_data만 갈아끼운다(_patch_all_cache_for_owner). 자산정보 = 본인+가희 KPI 카드 스택이라 owner_data만
있으면 재렌더 가능 다시 fetch할 필요 없음. 패치 실패 안전망으로 'all' 캐시 비워 있으면 재렌더 가능 다시 fetch할 필요 없음. 패치 실패 안전망으로 'all' 캐시 비워
다음 자산정보 진입에서 full fetch.""" 다음 자산정보 진입에서 full fetch."""
key = f'{owner or "all"}:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}' key = f'{owner or "all"}:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}'
if not fresh: if not fresh:
slot = _panels_cache.get(key) slot = _panels_cache.get(key)
if slot and slot['expires_at'] > time.time(): if slot and slot['expires_at'] > time.time():
@@ -9415,15 +9528,15 @@ def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, chart
slot = _panels_cache.get(key) slot = _panels_cache.get(key)
if slot and slot['expires_at'] > time.time(): if slot and slot['expires_at'] > time.time():
return slot['json'] return slot['json']
payload = _build_panels_payload(owner=owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days) payload = _build_panels_payload(owner=owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
body = json.dumps(payload, ensure_ascii=False).encode('utf-8') body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
_panels_cache[key] = {'json': body, 'expires_at': time.time() + PANELS_CACHE_TTL} _panels_cache[key] = {'json': body, 'expires_at': time.time() + PANELS_CACHE_TTL}
if owner is not None: if owner is not None:
try: try:
_patch_all_cache_for_owner(owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days) _patch_all_cache_for_owner(owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()
_panels_cache.pop(f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}', None) _panels_cache.pop(f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}', None)
return body return body
@@ -9928,7 +10041,27 @@ class Handler(BaseHTTPRequestHandler):
'sma20': sa.sma(closes, 20), 'sma20': sa.sma(closes, 20),
'sma60': sa.sma(closes, 60), 'sma60': sa.sma(closes, 60),
} }
svg = sa.render_svg_chart(snap, range_key='1Y') # 전일종가 — 일봉 캐시에서 오늘 이전 마지막 종가 → 기준선+전일대비 등락률 표시
prev_close = None
try:
import daily_candles_cache as dcc
_today = datetime.now(KST).strftime('%Y%m%d')
for b in dcc._select_latest(code, 3):
if b.get('date') and str(b['date']) < _today and b.get('close'):
prev_close = b['close']
break
except Exception:
prev_close = None
if not prev_close:
# 일봉 캐시 미스 → ka10095(현재가−전일대비)로 전일종가 보강. 모든 종목에 전일대비 % 표시.
try:
_q = kc.get_watchlist_quotes([code]).get(code) or {}
_pc = (_q.get('price') or 0) - (_q.get('change') or 0)
if _pc > 0:
prev_close = _pc
except Exception:
pass
svg = sa.render_svg_chart(snap, range_key='1Y', ref_price=prev_close, intraday=True)
body = svg.encode('utf-8') body = svg.encode('utf-8')
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding')) body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
self.send_response(200) self.send_response(200)
@@ -10098,8 +10231,17 @@ class Handler(BaseHTTPRequestHandler):
adr_days = ADR_TREND_DAYS adr_days = ADR_TREND_DAYS
if adr_days not in {d for _, d in ADR_TREND_PRESETS}: if adr_days not in {d for _, d in ADR_TREND_PRESETS}:
adr_days = ADR_TREND_DAYS adr_days = ADR_TREND_DAYS
# chart_ym — 'YYYY'(연도별) 또는 'YYYY-MM'(월별). 형식 위반은 무시(빈값=프리셋).
chart_ym = (qs.get('chart_ym') or [''])[0]
_ymp = (chart_ym or '').split('-')
_ym_ok = (
(len(_ymp) == 1 and len(_ymp[0]) == 4 and _ymp[0].isdigit())
or (len(_ymp) == 2 and len(_ymp[0]) == 4 and _ymp[0].isdigit() and len(_ymp[1]) == 2 and _ymp[1].isdigit())
)
if not _ym_ok:
chart_ym = ''
try: try:
body = _get_cached_panels_json(owner=owner, fresh=fresh, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days) body = _get_cached_panels_json(owner=owner, fresh=fresh, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()
self.send_error(500, 'panels failed') self.send_error(500, 'panels failed')
@@ -546,10 +546,11 @@ VOL_BOT = VOL_TOP + VOL_PANEL_H
CHART_RANGE_DAYS = {'1Y': 250, '6M': 120, '1M': 21} CHART_RANGE_DAYS = {'1Y': 250, '6M': 120, '1M': 21}
def render_svg_chart(snap: dict, range_key: str = '1Y') -> str: def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None = None, intraday: bool = False) -> str:
"""캔들 + SMA 3선 + 거래량 막대 인라인 SVG. """캔들 + SMA 3선 + 거래량 막대 인라인 SVG.
range_key: '1Y' (250일) | '6M' (120일) | '1M' (21일). 같은 snapshot 데이터에서 슬라이스만. range_key: '1Y' (250일) | '6M' (120일) | '1M' (21일). 같은 snapshot 데이터에서 슬라이스만.
ref_price: 기준가(전일종가 등) — 지정 시 노란 점선 기준선 + 마지막 종가의 대비 등락률 표시 (분봉용).
snap: collect_snapshot 결과. candles_asc·sma5/20/60 사용. snap: collect_snapshot 결과. candles_asc·sma5/20/60 사용.
외부 JS 의존성 0. CSS는 inline. 외부 JS 의존성 0. CSS는 inline.
""" """
@@ -570,6 +571,9 @@ def render_svg_chart(snap: dict, range_key: str = '1Y') -> str:
sma_vals = [v for v in (sma5 + sma20 + sma60) if v is not None] sma_vals = [v for v in (sma5 + sma20 + sma60) if v is not None]
y_max = max(max(highs), max(sma_vals) if sma_vals else 0) y_max = max(max(highs), max(sma_vals) if sma_vals else 0)
y_min = min(min(lows), min(sma_vals) if sma_vals else float('inf')) y_min = min(min(lows), min(sma_vals) if sma_vals else float('inf'))
if ref_price:
y_max = max(y_max, ref_price) # 기준선이 항상 보이게 스케일에 포함
y_min = min(y_min, ref_price)
if y_min == y_max: if y_min == y_max:
y_max = y_min + 1 y_max = y_min + 1
# 위·아래 패딩 5% # 위·아래 패딩 5%
@@ -607,6 +611,8 @@ def render_svg_chart(snap: dict, range_key: str = '1Y') -> str:
], ],
'ymin': y_min, 'ymin': y_min,
'ymax': y_max, 'ymax': y_max,
# 분봉일 때만 전일종가(ref_price) — 크로스헤어 툴팁의 '전일대비 등락률'용 (현재 등락율과 일치).
'prevClose': (int(ref_price) if (intraday and ref_price) else 0),
'priceTop': PRICE_TOP, 'priceTop': PRICE_TOP,
'priceBot': PRICE_BOT, 'priceBot': PRICE_BOT,
'volTop': VOL_TOP, 'volTop': VOL_TOP,
@@ -696,6 +702,18 @@ def render_svg_chart(snap: dict, range_key: str = '1Y') -> str:
parts.append(sma_path(sma20, '#34d399', 'SMA20')) # 초록 parts.append(sma_path(sma20, '#34d399', 'SMA20')) # 초록
parts.append(sma_path(sma60, '#a78bfa', 'SMA60')) # 보라 parts.append(sma_path(sma60, '#a78bfa', 'SMA60')) # 보라
# ---- 전일종가 기준선 + 대비 등락률 (ref_price 지정 시 — 분봉) ----
if ref_price:
ry = price_y(ref_price)
parts.append(
f'<line x1="{CHART_PAD_L}" y1="{ry:.1f}" x2="{CHART_W-CHART_PAD_R}" y2="{ry:.1f}" '
f'stroke="#eab308" stroke-width="1.5" stroke-dasharray="7 5" opacity="0.85"/>'
)
parts.append(
f'<text x="{CHART_W - CHART_PAD_R + 6}" y="{ry-4:.1f}" text-anchor="start" fill="#eab308" font-size="24">전일</text>'
)
# 우상단 '전일대비 X%' 텍스트는 제거(관리자 요청) — 전일종가 기준선만 유지.
# SMA 범례 # SMA 범례
legend_y = PRICE_TOP + 14 legend_y = PRICE_TOP + 14
legend_items = [('SMA5', '#fbbf24'), ('SMA20', '#34d399'), ('SMA60', '#a78bfa')] legend_items = [('SMA5', '#fbbf24'), ('SMA20', '#34d399'), ('SMA60', '#a78bfa')]
+7 -1
View File
@@ -3,7 +3,8 @@
python3 -m sim scan [--force] # 1회 스캔 (launchd/cron 진입점) python3 -m sim scan [--force] # 1회 스캔 (launchd/cron 진입점)
python3 -m sim status # 가상계좌 요약 python3 -m sim status # 가상계좌 요약
python3 -m sim report [N] # 최근 거래 N건 (기본 20) python3 -m sim report [N] # 최근 거래 N건 (기본 20)
python3 -m sim reset [--yes] # 가상계좌 초기화 (1천만 리셋) python3 -m sim reset [--yes] # 가상계좌 초기화
python3 -m sim variants sync # 비교군을 백테스트 유형별 1위로 동기화 (수동 전용)
""" """
from __future__ import annotations from __future__ import annotations
@@ -107,6 +108,11 @@ def _cmd_variants(argv):
print(f'변이 {len(vs)}개 등록:') print(f'변이 {len(vs)}개 등록:')
for v in vs: for v in vs:
print(' ', v['id'], v['name']) print(' ', v['id'], v['name'])
elif sub == 'sync':
import json as _json
from . import config as _cfg
res = _json.loads((_cfg.STATE_DIR / 'backtest_results.json').read_text())
print('동기화:', variants.sync_label_tops(res))
elif sub == 'reset': elif sub == 'reset':
variants.reset_all() variants.reset_all()
print('변이 계좌 초기화 (정의 유지)') print('변이 계좌 초기화 (정의 유지)')
+103 -51
View File
@@ -64,6 +64,24 @@ def load_flow(code: str) -> dict | None:
return None return None
# 프로세스 메모이즈 — 같은 sweep(한 프로세스) 안에서 종목당 sqlite를 1회만 읽는다.
# backtest.run 은 sweep CLI 프로세스에서만 호출되므로(엔진/웹은 apply_params만 씀) stale 위험 없음.
_HIST_MEMO: dict = {}
_FLOW_MEMO: dict = {}
def _hist_cached(code: str) -> list[dict]:
if code not in _HIST_MEMO:
_HIST_MEMO[code] = load_history(code)
return _HIST_MEMO[code]
def _flow_cached(code: str):
if code not in _FLOW_MEMO:
_FLOW_MEMO[code] = load_flow(code)
return _FLOW_MEMO[code]
def _flow_net_upto(flow: dict | None, dates_seen: list[str]) -> dict: def _flow_net_upto(flow: dict | None, dates_seen: list[str]) -> dict:
"""최근 FLOW_DAYS 일 외국인·기관 누적. flow 없으면 중립(둘 다 +1 → 수급 통과).""" """최근 FLOW_DAYS 일 외국인·기관 누적. flow 없으면 중립(둘 다 +1 → 수급 통과)."""
if flow is None: if flow is None:
@@ -82,12 +100,12 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to:
hist = {} hist = {}
for code in codes: for code in codes:
h = [c for c in load_history(code) if date_from <= c['date'] <= date_to] h = [c for c in _hist_cached(code) if date_from <= c['date'] <= date_to]
if len(h) > config.SMA_LONG + config.ATR_PERIOD + 5: if len(h) > config.SMA_LONG + config.ATR_PERIOD + 5:
hist[code] = h hist[code] = h
if not hist: if not hist:
return {'error': 'no_data', 'trades': 0} return {'error': 'no_data', 'trades': 0}
flows = {code: load_flow(code) for code in hist} flows = {code: _flow_cached(code) for code in hist}
# code별 date→index, 전체 거래일 축 # code별 date→index, 전체 거래일 축
idx_map = {code: {c['date']: i for i, c in enumerate(h)} for code, h in hist.items()} idx_map = {code: {c['date']: i for i, c in enumerate(h)} for code, h in hist.items()}
@@ -99,6 +117,7 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to:
wins = closed = 0 wins = closed = 0
gross_win = gross_loss = 0.0 gross_win = gross_loss = 0.0
peak_eq = capital peak_eq = capital
exited: dict[str, str] = {} # code → 전량청산일 (당일 재진입 금지)
max_dd = 0.0 max_dd = 0.0
comm, tax = config.COMMISSION_RATE, config.SELL_TAX_RATE comm, tax = config.COMMISSION_RATE, config.SELL_TAX_RATE
@@ -108,9 +127,82 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to:
def _qty_for(fill, value): def _qty_for(fill, value):
return int(value // (fill * (1 + comm))) return int(value // (fill * (1 + comm)))
slip = getattr(config, 'BT_SLIPPAGE', 0.002)
pending: list[dict] = [] # 전일 종가 신호 → 오늘 시가 체결 주문 (look-ahead 제거)
for day in all_dates: for day in all_dates:
day_prices = {} day_prices = {}
# ---- 보유 판단 (당일 종가 기준): 손절/익절(전량·분할) → 추격매수 ----
# ---- 0. 전일 큐잉 주문을 오늘 시가에 체결 (+슬리피지: 매수 비싸게/매도 싸게) ----
for od in pending:
code = od['code']
i = idx_map.get(code, {}).get(day)
if i is None:
continue # 오늘 거래 없으면 주문 소멸 — 신호 지속 시 다음 종가에 재큐잉됨
open_px = hist[code][i].get('open') or hist[code][i]['close']
kind = od['kind']
if kind in ('sell', 'scale_out'):
pos = positions.get(code)
if not pos:
continue
fill = open_px * (1 - slip)
total = pos['qty']
qty = total if od.get('frac', 1.0) >= 1.0 else max(1, int(total * od['frac']))
qty = min(qty, total)
proceeds = qty * fill * (1 - comm - tax)
cost = qty * pos['entry_price'] * (1 + comm)
pnl = proceeds - cost
cash += proceeds
realized += pnl
if qty >= total:
positions.pop(code)
exited[code] = day # 당일 재진입 금지 (엔진과 동일 규칙)
closed += 1
if pnl > 0:
wins += 1
else:
pos['qty'] = total - qty
if pnl > 0:
gross_win += pnl
else:
gross_loss += -pnl
elif kind == 'add':
pos = positions.get(code)
if not pos:
continue
fill = open_px * (1 + slip)
tranche_val = pos.get('tranche_value') or (pos['entry_price'] * pos['qty'])
qty = _qty_for(fill, tranche_val)
cost = qty * fill * (1 + comm)
if qty >= 1 and cost <= cash:
new_qty = pos['qty'] + qty
pos['entry_price'] = (pos['entry_price'] * pos['qty'] + fill * qty) / new_qty
pos['qty'] = new_qty
pos['tranches'] = pos.get('tranches', 1) + 1
pos['last_add_price'] = fill
cash -= cost
nstop, ntarget = signals.compute_stop_target(pos['entry_price'], od['atr'], od['recent_low'])
pos['stop'] = max(pos['stop'], nstop)
pos['target'] = ntarget
elif kind == 'buy':
if code in positions or len(positions) >= config.MAX_POSITIONS or exited.get(code) == day:
continue
fill = open_px * (1 + slip)
stop, target = signals.compute_stop_target(fill, od['atr'], od['recent_low'])
eq = equity(day_prices)
tranche_val = signals.target_value(eq, fill, stop) / max(1, config.ENTRY_TRANCHES)
qty = _qty_for(fill, tranche_val)
cost = qty * fill * (1 + comm)
if qty < 1 or cost > cash:
continue
cash -= cost
positions[code] = {'code': code, 'name': code, 'qty': qty, 'entry_price': fill,
'stop': stop, 'target': target, 'peak': fill, 'trailing_on': False,
'scaled_out': False, 'tranches': 1, 'tranche_value': tranche_val,
'last_add_price': fill, 'entry_date': day}
pending = []
# ---- 1. 보유 판단 (당일 종가 기준) → 내일 시가 주문 큐잉 ----
for code in list(positions.keys()): for code in list(positions.keys()):
h = hist.get(code) h = hist.get(code)
i = idx_map[code].get(day) i = idx_map[code].get(day)
@@ -129,46 +221,17 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to:
pos.update(dec['position_update']) pos.update(dec['position_update'])
act = dec['action'] act = dec['action']
if act in ('sell', 'scale_out'): if act in ('sell', 'scale_out'):
fill = ind['price'] pending.append({'kind': act, 'code': code, 'frac': dec.get('sell_frac', 1.0)})
total = pos['qty']
qty = total if dec.get('sell_frac', 1.0) >= 1.0 else max(1, int(total * dec['sell_frac']))
qty = min(qty, total)
proceeds = qty * fill * (1 - comm - tax)
cost = qty * pos['entry_price'] * (1 + comm)
pnl = proceeds - cost
cash += proceeds
realized += pnl
if qty >= total:
positions.pop(code)
closed += 1
if pnl > 0:
wins += 1
else:
pos['qty'] = total - qty
if pnl > 0:
gross_win += pnl
else:
gross_loss += -pnl
elif act == 'add': elif act == 'add':
fill = ind['price'] pending.append({'kind': 'add', 'code': code,
tranche_val = pos.get('tranche_value') or (pos['entry_price'] * pos['qty']) 'atr': ind['atr'], 'recent_low': ind['recent_low']})
qty = _qty_for(fill, tranche_val)
cost = qty * fill * (1 + comm)
if qty >= 1 and cost <= cash:
new_qty = pos['qty'] + qty
pos['entry_price'] = (pos['entry_price'] * pos['qty'] + fill * qty) / new_qty
pos['qty'] = new_qty
pos['tranches'] = pos.get('tranches', 1) + 1
pos['last_add_price'] = fill
cash -= cost
nstop, ntarget = signals.compute_stop_target(pos['entry_price'], ind['atr'], ind['recent_low'])
pos['stop'] = max(pos['stop'], nstop)
pos['target'] = ntarget
# ---- 신규 매수 판단 (1차 트랜치) ---- # ---- 2. 신규 매수 판단 (당일 종가) → 내일 시가 주문 큐잉 ----
for code, h in hist.items(): for code, h in hist.items():
if code in positions or len(positions) >= config.MAX_POSITIONS: if code in positions or len(positions) >= config.MAX_POSITIONS:
continue continue
if exited.get(code) == day: # 당일 청산 종목 재매수 금지
continue
i = idx_map[code].get(day) i = idx_map[code].get(day)
if i is None: if i is None:
continue continue
@@ -183,19 +246,8 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to:
flow = _flow_net_upto(flows.get(code), dates_seen) flow = _flow_net_upto(flows.get(code), dates_seen)
dec = signals.evaluate_candidate(code, code, [], ind, flow, None, True) dec = signals.evaluate_candidate(code, code, [], ind, flow, None, True)
if dec['action'] == 'buy': if dec['action'] == 'buy':
fill = dec['buy_price'] pending.append({'kind': 'buy', 'code': code,
stop, target = signals.compute_stop_target(fill, ind['atr'], ind['recent_low']) 'atr': ind['atr'], 'recent_low': ind['recent_low']})
eq = equity(day_prices)
tranche_val = signals.target_value(eq, fill, stop) / max(1, config.ENTRY_TRANCHES)
qty = _qty_for(fill, tranche_val)
cost = qty * fill * (1 + comm)
if qty < 1 or cost > cash:
continue
cash -= cost
positions[code] = {'code': code, 'name': code, 'qty': qty, 'entry_price': fill,
'stop': stop, 'target': target, 'peak': fill, 'trailing_on': False,
'scaled_out': False, 'tranches': 1, 'tranche_value': tranche_val,
'last_add_price': fill, 'entry_date': day}
eq = equity(day_prices) eq = equity(day_prices)
if eq > peak_eq: if eq > peak_eq:
+2 -1
View File
@@ -22,9 +22,10 @@ LAST_SCAN_PATH = STATE_DIR / 'last_scan.json'
OWNER_ACCOUNT_LABELS = ['일반', 'ISA'] OWNER_ACCOUNT_LABELS = ['일반', 'ISA']
# ---- 자본·체결·비용 ---- # ---- 자본·체결·비용 ----
INITIAL_CAPITAL = 10_000_000 # 가상 자본 INITIAL_CAPITAL = 100_000_000 # 가상 자본 (기초 예수금 1억)
COMMISSION_RATE = 0.00015 # 매매 수수료 (편도, 키움 ~0.015%) COMMISSION_RATE = 0.00015 # 매매 수수료 (편도, 키움 ~0.015%)
SELL_TAX_RATE = 0.0018 # 매도 증권거래세 (~0.18%) SELL_TAX_RATE = 0.0018 # 매도 증권거래세 (~0.18%)
BT_SLIPPAGE = 0.002 # 백테스트 슬리피지 (편도 0.2% — 매수는 비싸게, 매도는 싸게. 라이브는 호가 체결이라 미적용)
# ---- 포지션 사이징 ---- # ---- 포지션 사이징 ----
MAX_POSITIONS = 8 # 동시 보유 최대 종목수 MAX_POSITIONS = 8 # 동시 보유 최대 종목수
+34 -7
View File
@@ -88,14 +88,19 @@ def _flow_cache_path(code: str):
def investor_flow(code: str, days: int = config.FLOW_DAYS) -> list[dict]: def investor_flow(code: str, days: int = config.FLOW_DAYS) -> list[dict]:
"""최근 days 일 외국인·기관·개인 순매수 (최신순). 60분 TTL 캐시.""" """최근 days 일 외국인·기관·개인 순매수 (최신순). 60분 TTL 캐시 + 스캔 내 메모."""
mk = (code, days)
if mk in _FLOW_MEMO_SCAN:
return _FLOW_MEMO_SCAN[mk]
path = _flow_cache_path(code) path = _flow_cache_path(code)
now = time.time() now = time.time()
if path.exists(): if path.exists():
try: try:
cached = json.loads(path.read_text()) cached = json.loads(path.read_text())
if now - cached.get('ts', 0) < FLOW_TTL_SEC: if now - cached.get('ts', 0) < FLOW_TTL_SEC:
return cached.get('rows', [])[:days] rows = cached.get('rows', [])[:days]
_FLOW_MEMO_SCAN[mk] = rows
return rows
except Exception: except Exception:
pass pass
try: try:
@@ -105,6 +110,7 @@ def investor_flow(code: str, days: int = config.FLOW_DAYS) -> list[dict]:
return [] return []
FLOW_CACHE_DIR.mkdir(parents=True, exist_ok=True) FLOW_CACHE_DIR.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({'ts': now, 'rows': rows}, ensure_ascii=False)) path.write_text(json.dumps({'ts': now, 'rows': rows}, ensure_ascii=False))
_FLOW_MEMO_SCAN[mk] = rows[:days]
return rows[:days] return rows[:days]
@@ -116,11 +122,15 @@ def flow_net(rows: list[dict]) -> dict:
def analyst(code: str, price: int) -> dict | None: def analyst(code: str, price: int) -> dict | None:
"""애널 게이트/가점용. 캐시 미스(네트워크 발생) 시에만 페이싱 — 연속 크롤링 차단 회피.""" """애널 게이트/가점용. 캐시 미스(네트워크 발생) 시에만 페이싱 + 스캔 내 메모."""
if code in _ANL_MEMO_SCAN:
return _ANL_MEMO_SCAN[code]
fresh = (_cache_fresh(FNGUIDE_CACHE_DIR / f'{code}.json') fresh = (_cache_fresh(FNGUIDE_CACHE_DIR / f'{code}.json')
and _cache_fresh(WISEREPORT_CACHE_DIR / f'{code}.json')) and _cache_fresh(WISEREPORT_CACHE_DIR / f'{code}.json'))
try: try:
return _analyst_query(code, price) out = _analyst_query(code, price)
_ANL_MEMO_SCAN[code] = out
return out
finally: finally:
if not fresh: if not fresh:
time.sleep(ANALYST_PACING_SEC) time.sleep(ANALYST_PACING_SEC)
@@ -168,10 +178,27 @@ def _analyst_query(code: str, price: int) -> dict | None:
} }
# 스캔 1회 동안 호가 공유 — 같은 스캔에서 같은 종목은 같은 체결가 (선수 간 체결 공정성 + API 절감).
# engine._gather 가 스캔 시작마다 reset.
_BOOK_MEMO: dict = {}
_FLOW_MEMO_SCAN: dict = {} # 스캔 1회 동안 종목별 수급 공유 (파라미터 무관)
_ANL_MEMO_SCAN: dict = {} # 스캔 1회 동안 종목별 애널 공유 (파라미터 무관)
def reset_book_memo():
_BOOK_MEMO.clear()
_FLOW_MEMO_SCAN.clear()
_ANL_MEMO_SCAN.clear()
def quote_book(code: str) -> dict | None: def quote_book(code: str) -> dict | None:
"""체결가용 호가 (매도1/매수1). 실패 시 None.""" """체결가용 호가 (매도1/매수1). 스캔 내 종목당 1회 조회 후 공유. 실패 시 None."""
if code in _BOOK_MEMO:
return _BOOK_MEMO[code]
try: try:
return kc.get_quote_book(code) book = kc.get_quote_book(code)
except Exception as e: except Exception as e:
sys.stderr.write(f'[data] quote_book {code} 실패: {e}\n') sys.stderr.write(f'[data] quote_book {code} 실패: {e}\n')
return None book = None
_BOOK_MEMO[code] = book
return book
+116 -12
View File
@@ -41,6 +41,29 @@ def _index_trend_ok() -> dict:
return out return out
def _session_elapsed_frac(now) -> float:
"""장 시작 후 경과 비율 (0.15~1.0) — 장중 부분 거래량을 하루치로 환산하는 분모.
장외면 1.0(완성봉 그대로). 하한 0.15 초반 과대 환산 폭주 방지."""
if not is_market_session(now):
return 1.0
o = now.replace(hour=config.MARKET_OPEN[0], minute=config.MARKET_OPEN[1], second=0, microsecond=0)
c = now.replace(hour=config.MARKET_CLOSE[0], minute=config.MARKET_CLOSE[1], second=0, microsecond=0)
total = (c - o).total_seconds()
return min(1.0, max(0.15, (now - o).total_seconds() / total))
def _watching(snap) -> list:
"""스캔 시점 '매수로 노리는' 종목 — 즉시매수(BUY)·추격(ADD)·눌림목 대기(WAIT+지정가). 비교탭 표시용."""
out = []
for d in snap.get('decisions', []):
st = d.get('state')
if st in ('BUY', 'ADD') or (st == 'WAIT' and d.get('watch_price')):
out.append({'name': d.get('name'), 'code': d.get('code'), 'state': st,
'reason': d.get('reason', ''), 'price': d.get('price'),
'watch_price': d.get('watch_price')})
return out
def _index_return(mk: str, days: int): def _index_return(mk: str, days: int):
"""지수 N거래일 수익률(%) — 상대강도 비교용. 데이터 부족 시 None.""" """지수 N거래일 수익률(%) — 상대강도 비교용. 데이터 부족 시 None."""
s = sorted(benchmark.series(mk).items()) s = sorted(benchmark.series(mk).items())
@@ -192,11 +215,24 @@ def _fill_sell_price(code: str, fallback: int) -> int:
return int(fallback) return int(fallback)
_SCAN_CANDLES: dict = {} # 스캔 1회 동안 종목별 일봉 공유
_IND_MEMO: dict = {} # (code, 지표 파라미터) → 지표 묶음 — 같은 지표 설정 변이끼리 공유
def _ind_key(code):
return (code, config.SMA_SHORT, config.SMA_LONG, config.SMA_MID, config.ATR_PERIOD,
config.BREAKOUT_LOOKBACK, config.SWING_LOW_LOOKBACK, config.VOLUME_AVG_PERIOD,
config.RSI_PERIOD, config.TREND_SLOPE_DAYS)
def _gather(extra_codes=None): def _gather(extra_codes=None):
"""파라미터 무관한 라이브 데이터 1회 수집 (메인·변이 공유). 종목·시세·시장매핑. """파라미터 무관한 라이브 데이터 1회 수집 (메인·변이 공유). 종목·시세·시장매핑.
extra_codes: universe에 없지만 청산 판단을 위해 포함해야 보유 코드(수동 삭제된 보유분). extra_codes: universe에 없지만 청산 판단을 위해 포함해야 보유 코드(수동 삭제된 보유분).
""" """
data.reset_book_memo() # 호가 공유 캐시 리셋 — 이번 스캔의 체결가를 전 선수가 공유
_SCAN_CANDLES.clear()
_IND_MEMO.clear()
uni = universe.build_universe() uni = universe.build_universe()
codes = [e['code'] for e in uni] codes = [e['code'] for e in uni]
seen = set(codes) seen = set(codes)
@@ -210,10 +246,13 @@ def _gather(extra_codes=None):
return uni, quotes, cmkt return uni, quotes, cmkt
def _run_scan(pf, uni, quotes, cmkt, now) -> dict: def _run_scan(pf, uni, quotes, cmkt, now, execute: bool = True) -> dict:
"""주어진 포트폴리오에 대해 현재 config(파라미터) 기준 스캔·체결. 스냅샷 반환(파일 기록 X).""" """주어진 포트폴리오에 대해 현재 config(파라미터) 기준 스캔·체결. 스냅샷 반환(파일 기록 X).
execute=False 판단(decisions) 만들고 체결·계좌 저장을 모두 생략 시각 전환 read-only ."""
mkt_ok = market_ok_map() # MARKET_BREADTH_MIN 등 파라미터 의존 → 변이마다 재계산 mkt_ok = market_ok_map() # MARKET_BREADTH_MIN 등 파라미터 의존 → 변이마다 재계산
idx_ret = {mk: _index_return(mk, config.REL_STRENGTH_DAYS) for mk in ('KOSPI', 'KOSDAQ')} idx_ret = {mk: _index_return(mk, config.REL_STRENGTH_DAYS) for mk in ('KOSPI', 'KOSDAQ')}
vfrac = _session_elapsed_frac(now) # 장중 부분 거래량 → 하루치 환산 분모
decisions: list[dict] = [] decisions: list[dict] = []
buys: list[dict] = [] buys: list[dict] = []
sells: list[dict] = [] sells: list[dict] = []
@@ -223,9 +262,17 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
if not name and code in pf.positions: # 보유 보강분(universe에서 빠진)은 이름 보완 if not name and code in pf.positions: # 보유 보강분(universe에서 빠진)은 이름 보완
name = pf.positions[code].get('name', code) name = pf.positions[code].get('name', code)
quote = quotes.get(code) quote = quotes.get(code)
hist = _SCAN_CANDLES.get(code)
if hist is None:
hist = data.candles(code, 80) hist = data.candles(code, 80)
_SCAN_CANDLES[code] = hist
series = data.build_series(hist, quote) series = data.build_series(hist, quote)
ind = signals.compute_indicators(series) _ik = _ind_key(code)
_cached = _IND_MEMO.get(_ik)
if _cached is None:
_cached = signals.compute_indicators(series) or False
_IND_MEMO[_ik] = _cached
ind = dict(_cached) if _cached else None # 사본 — 변이별 주입값(rel_strength 등) 오염 방지
if ind is None: if ind is None:
decisions.append({'code': code, 'name': name, 'sources': sources, decisions.append({'code': code, 'name': name, 'sources': sources,
'state': 'NODATA', 'reason': '데이터 부족', 'checks': [], 'state': 'NODATA', 'reason': '데이터 부족', 'checks': [],
@@ -244,14 +291,14 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
dec.update({k: pf.positions[code].get(k) for k in dec.update({k: pf.positions[code].get(k) for k in
('entry_price', 'qty', 'stop', 'target', 'trailing_on', 'entry_at')}) ('entry_price', 'qty', 'stop', 'target', 'trailing_on', 'entry_at')})
act = dec['action'] act = dec['action']
if act in ('sell', 'scale_out'): if execute and act in ('sell', 'scale_out'):
fill = _fill_sell_price(code, cur_price) fill = _fill_sell_price(code, cur_price)
rec = pf.sell(code, fill, dec['reason'], signals=dec.get('checks'), rec = pf.sell(code, fill, dec['reason'], signals=dec.get('checks'),
frac=dec.get('sell_frac', 1.0)) frac=dec.get('sell_frac', 1.0))
if rec: if rec:
sells.append(rec) sells.append(rec)
dec['fill_price'] = fill dec['fill_price'] = fill
elif act == 'add': elif execute and act == 'add':
fill = _fill_buy_price(code, cur_price) fill = _fill_buy_price(code, cur_price)
rec = pf.add_tranche(code, fill, dec['reason'], signals=dec.get('checks')) rec = pf.add_tranche(code, fill, dec['reason'], signals=dec.get('checks'))
if rec: if rec:
@@ -292,6 +339,7 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
anl = data.analyst(code, cur_price) anl = data.analyst(code, cur_price)
market = cmkt.get(code, '') market = cmkt.get(code, '')
m_ok = mkt_ok.get(market, True) m_ok = mkt_ok.get(market, True)
ind['vol_time_frac'] = vfrac
# 상대강도(라이브 전용): 종목 N일 수익률 − 시장지수 N일 수익률 # 상대강도(라이브 전용): 종목 N일 수익률 − 시장지수 N일 수익률
n = config.REL_STRENGTH_DAYS n = config.REL_STRENGTH_DAYS
closes_c = [c['close'] for c in series] closes_c = [c['close'] for c in series]
@@ -300,7 +348,11 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
ind['rel_strength'] = round(stock_ret - idx_ret[market], 2) ind['rel_strength'] = round(stock_ret - idx_ret[market], 2)
dec = signals.evaluate_candidate(code, name, sources, ind, flow, anl, m_ok) dec = signals.evaluate_candidate(code, name, sources, ind, flow, anl, m_ok)
if dec['action'] == 'buy' and pf.can_open(): if dec['action'] == 'buy' and pf.exited_today(code):
# 당일 전량청산 종목 재매수 금지 — 휩쏘 churn(손절→5분 뒤 재진입 반복) 방지
dec.update({'state': 'WAIT', 'action': None,
'reason': '당일 청산 종목 — 재진입 쿨다운(내일부터 가능)'})
if execute and dec['action'] == 'buy' and pf.can_open():
fill = _fill_buy_price(code, dec['buy_price']) fill = _fill_buy_price(code, dec['buy_price'])
stop, target = signals.compute_stop_target(fill, ind['atr'], ind['recent_low']) stop, target = signals.compute_stop_target(fill, ind['atr'], ind['recent_low'])
rec = pf.buy(code, name, fill, sources, stop, target, dec['buy_path'], dec['reason'], rec = pf.buy(code, name, fill, sources, stop, target, dec['buy_path'], dec['reason'],
@@ -313,6 +365,7 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
dec['reason'] = '매수 신호 — 현금/한도 부족' dec['reason'] = '매수 신호 — 현금/한도 부족'
decisions.append(dec) decisions.append(dec)
if execute:
pf.update_equity_metrics() pf.update_equity_metrics()
pf.save() pf.save()
@@ -330,6 +383,30 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
} }
def judge_view(vid: str) -> dict | None:
"""선택한 계좌('main'/'vN') 시각으로 **매매 없이 판단만** — 관심종목 탭 시각 전환용.
execute=False 체결·계좌 저장이 전혀 없다. 끝나면 config 메인 파라미터로 복원."""
from . import backtest, variants as variants_mod
now = datetime.now(config.KST)
if vid in ('main', 'M', ''):
params = config.load_params()
pf = Portfolio.load()
else:
v = next((x for x in variants_mod.load_variants() if x['id'] == vid), None)
if not v:
return None
pp, tp = variants_mod.variant_paths(vid)
params = v.get('params') or {}
pf = Portfolio.load(pp, tp)
try:
backtest.apply_params(params)
uni, quotes, cmkt = _gather(set(pf.positions))
return _run_scan(pf, uni, quotes, cmkt, now, execute=False)
finally:
backtest.apply_params(config.load_params()) # 전역 config 원복 (웹 데몬 잔류 방지)
def _attach_benchmark(snap: dict, pf, now, refresh: bool = True): def _attach_benchmark(snap: dict, pf, now, refresh: bool = True):
"""가상계좌 시작일 대비 기준지수(KOSPI/KOSDAQ) 수익·알파를 스냅샷에 부착.""" """가상계좌 시작일 대비 기준지수(KOSPI/KOSDAQ) 수익·알파를 스냅샷에 부착."""
from . import benchmark from . import benchmark
@@ -374,9 +451,10 @@ def scan_all(force: bool = False) -> dict:
held_union |= set(vpf.positions) held_union |= set(vpf.positions)
uni, quotes, cmkt = _gather(held_union) uni, quotes, cmkt = _gather(held_union)
# 메인 (현재 params.json 튜닝) # 기준 판단 (params.json) — 2026-06-10 기준전략 폐지: 매매 없이 판단만(last_scan = 화면·상태점용).
# 실제 매매는 전부 변이들이 한다 (메인 계좌 동결).
backtest.apply_params(main_params) backtest.apply_params(main_params)
snap = _run_scan(pf, uni, quotes, cmkt, now) snap = _run_scan(pf, uni, quotes, cmkt, now, execute=False)
_attach_benchmark(snap, pf, now) # 지수 캐시 갱신 1회 (변이는 캐시 재사용) _attach_benchmark(snap, pf, now) # 지수 캐시 갱신 1회 (변이는 캐시 재사용)
config.STATE_DIR.mkdir(parents=True, exist_ok=True) config.STATE_DIR.mkdir(parents=True, exist_ok=True)
config.LAST_SCAN_PATH.write_text(json.dumps(snap, ensure_ascii=False, indent=2)) config.LAST_SCAN_PATH.write_text(json.dumps(snap, ensure_ascii=False, indent=2))
@@ -388,24 +466,50 @@ def scan_all(force: bool = False) -> dict:
s = p.summary() s = p.summary()
return benchmark.compare(s.get('total_return_pct'), benchmark.norm_date(p.created_at), today) return benchmark.compare(s.get('total_return_pct'), benchmark.norm_date(p.created_at), today)
compare = [{'id': 'main', 'name': '메인 (현재 튜닝)', 'params': main_params, compare = [] # 기준전략 폐지 — 변이들만 비교 (메인 계좌는 매매 안 함)
'summary': pf.summary(), 'benchmark': _alpha(pf)}]
for v in vdefs: for v in vdefs:
backtest.apply_params(v.get('params') or {}) backtest.apply_params(v.get('params') or {})
vpf = vpfs[v['id']] vpf = vpfs[v['id']]
_run_scan(vpf, uni, quotes, cmkt, now) vsnap = _run_scan(vpf, uni, quotes, cmkt, now)
compare.append({'id': v['id'], 'name': v.get('name', v['id']), compare.append({'id': v['id'], 'name': v.get('name', v['id']),
'params': v.get('params') or {}, 'summary': vpf.summary(), 'params': v.get('params') or {}, 'summary': vpf.summary(),
'benchmark': _alpha(vpf)}) 'benchmark': _alpha(vpf), 'watching': _watching(vsnap),
'held': sorted(vpf.positions)})
compare.sort(key=lambda c: c['summary'].get('total_return_pct', 0) or 0, reverse=True) compare.sort(key=lambda c: c['summary'].get('total_return_pct', 0) or 0, reverse=True)
variants_mod.COMPARE_PATH.write_text(json.dumps({ variants_mod.COMPARE_PATH.write_text(json.dumps({
'scanned_at': now.isoformat(), 'session': is_market_session(now), 'scanned_at': now.isoformat(), 'session': is_market_session(now),
'variants': compare, 'variants': compare,
}, ensure_ascii=False, indent=2)) }, ensure_ascii=False, indent=2))
_record_equity_history(compare, now)
return snap return snap
EQUITY_HISTORY_PATH = config.STATE_DIR / 'equity_history.json'
def _record_equity_history(compare: list, now) -> None:
"""일별 계좌 성과 이력 — {date: [{id, ph(파라미터 해시), equity, ret}]}.
스캔 당일 항목을 덮어써 그날 마지막 스캔( 마감) 값이 남는다. 변이 재구성으로
id가 재사용돼도 ph로 어느 전략이었는지 식별 가능. 사후 국면별 분석용 누적."""
import hashlib
try:
hist = json.loads(EQUITY_HISTORY_PATH.read_text()) if EQUITY_HISTORY_PATH.exists() else {}
except Exception:
hist = {}
today = now.strftime('%Y%m%d')
hist[today] = [{
'id': c['id'],
'ph': hashlib.md5(json.dumps(c.get('params') or {}, sort_keys=True).encode()).hexdigest()[:8],
'equity': c['summary'].get('equity'),
'ret': c['summary'].get('total_return_pct'),
} for c in compare]
try:
EQUITY_HISTORY_PATH.write_text(json.dumps(hist, ensure_ascii=False))
except Exception as e:
sys.stderr.write(f'[engine] equity_history 기록 실패: {e}\n')
if __name__ == '__main__': if __name__ == '__main__':
force = '--force' in sys.argv force = '--force' in sys.argv
snap = scan_all(force=force) snap = scan_all(force=force)
+10 -3
View File
@@ -9,7 +9,8 @@ from __future__ import annotations
import json import json
from datetime import datetime from datetime import datetime
from . import config, signals from . import config
from .signals import target_value as _sizing_value # buy()의 signals 파라미터가 모듈명을 가려서 직접 import
def _now_iso() -> str: def _now_iso() -> str:
@@ -28,6 +29,7 @@ class Portfolio:
self.max_drawdown: float = data.get('max_drawdown', 0.0) self.max_drawdown: float = data.get('max_drawdown', 0.0)
self.created_at: str = data.get('created_at', _now_iso()) self.created_at: str = data.get('created_at', _now_iso())
self.updated_at: str = data.get('updated_at', self.created_at) self.updated_at: str = data.get('updated_at', self.created_at)
self.last_exit: dict[str, str] = data.get('last_exit', {}) # code → 전량청산 날짜(YYYY-MM-DD), 당일 재진입 금지용
# 영속화 경로 (변이는 별도 경로, 기본은 메인 sim) # 영속화 경로 (변이는 별도 경로, 기본은 메인 sim)
self._pf_path = config.PORTFOLIO_PATH self._pf_path = config.PORTFOLIO_PATH
self._trades_path = config.TRADES_PATH self._trades_path = config.TRADES_PATH
@@ -55,7 +57,7 @@ class Portfolio:
'cash': self.cash, 'initial': self.initial, 'positions': self.positions, 'cash': self.cash, 'initial': self.initial, 'positions': self.positions,
'realized_pnl': self.realized_pnl, 'closed_trades': self.closed_trades, 'realized_pnl': self.realized_pnl, 'closed_trades': self.closed_trades,
'wins': self.wins, 'peak_equity': self.peak_equity, 'wins': self.wins, 'peak_equity': self.peak_equity,
'max_drawdown': self.max_drawdown, 'max_drawdown': self.max_drawdown, 'last_exit': self.last_exit,
'created_at': self.created_at, 'updated_at': self.updated_at, 'created_at': self.created_at, 'updated_at': self.updated_at,
}, ensure_ascii=False, indent=2)) }, ensure_ascii=False, indent=2))
@@ -83,7 +85,7 @@ class Portfolio:
"""신규 진입(1차 트랜치). 목표비중을 ENTRY_TRANCHES 로 나눈 만큼만 매수. 체결 dict 또는 None.""" """신규 진입(1차 트랜치). 목표비중을 ENTRY_TRANCHES 로 나눈 만큼만 매수. 체결 dict 또는 None."""
if code in self.positions or not self.can_open(): if code in self.positions or not self.can_open():
return None return None
full_target = signals.target_value(self.equity_estimate(), fill_price, stop) full_target = _sizing_value(self.equity_estimate(), fill_price, stop)
tranche_val = full_target / max(1, config.ENTRY_TRANCHES) tranche_val = full_target / max(1, config.ENTRY_TRANCHES)
qty = self._qty_for(fill_price, tranche_val) qty = self._qty_for(fill_price, tranche_val)
if qty < 1: if qty < 1:
@@ -169,8 +171,13 @@ class Portfolio:
if pnl > 0: if pnl > 0:
self.wins += 1 self.wins += 1
del self.positions[code] del self.positions[code]
self.last_exit[code] = _now_iso()[:10] # 당일 재진입 금지 기준
return rec return rec
def exited_today(self, code: str) -> bool:
"""오늘 전량청산한 종목인지 — 당일 재진입(휩쏘 churn) 방지."""
return self.last_exit.get(code) == _now_iso()[:10]
def apply_position_update(self, code: str, upd: dict): def apply_position_update(self, code: str, upd: dict):
if code in self.positions: if code in self.positions:
self.positions[code].update(upd) self.positions[code].update(upd)
+10 -4
View File
@@ -171,28 +171,34 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di
return {**base, 'state': 'WAIT', 'reason': '수급 미충족'} return {**base, 'state': 'WAIT', 'reason': '수급 미충족'}
# 4. 과열 보류 — 회전율이 평균 대비 과도하면 돌파·눌림목 불문 신규매수 보류 # 4. 과열 보류 — 회전율이 평균 대비 과도하면 돌파·눌림목 불문 신규매수 보류
# 장중엔 부분 누적 거래량을 경과시간 비례로 하루치 환산(vol_time_frac, engine 주입·백테스트는 1.0)
vfrac = ind.get('vol_time_frac') or 1.0
vol_today = ind['today_volume'] / vfrac
t_today, t_avg = ind.get('turnover_today'), ind.get('turnover_avg') t_today, t_avg = ind.get('turnover_today'), ind.get('turnover_avg')
if t_today is not None:
t_today = t_today / vfrac
proj = ' (환산)' if vfrac < 1.0 else ''
overheated = bool(t_today and t_avg and t_today >= t_avg * config.TURNOVER_OVERHEAT_MULT) overheated = bool(t_today and t_avg and t_today >= t_avg * config.TURNOVER_OVERHEAT_MULT)
if t_today is not None and t_avg: if t_today is not None and t_avg:
checks.append({'label': '과열 아님(회전율)', 'ok': not overheated, checks.append({'label': '과열 아님(회전율)', 'ok': not overheated,
'detail': f"{t_today:.2f}% vs 평균 {t_avg:.2f}% (보류 {config.TURNOVER_OVERHEAT_MULT:.0f}배↑)"}) 'detail': f"{t_today:.2f}%{proj} vs 평균 {t_avg:.2f}% (보류 {config.TURNOVER_OVERHEAT_MULT:.0f}배↑)"})
if overheated: if overheated:
return {**base, 'state': 'WAIT', return {**base, 'state': 'WAIT',
'reason': f'과열(회전율 {t_today:.1f}% ≥ 평균×{config.TURNOVER_OVERHEAT_MULT:.0f}) — 신규매수 보류'} 'reason': f'과열(회전율 {t_today:.1f}% ≥ 평균×{config.TURNOVER_OVERHEAT_MULT:.0f}) — 신규매수 보류'}
# 5. 매수 경로 — 돌파 우선 (전고점 + 거래량 급증 + 회전율 급증 모두 충족) # 5. 매수 경로 — 돌파 우선 (전고점 + 거래량 급증 + 회전율 급증 모두 충족)
price_break = bool(ind['recent_high'] and price > ind['recent_high']) price_break = bool(ind['recent_high'] and price > ind['recent_high'])
vol_mult_ok = bool(ind['volume_avg'] and ind['today_volume'] >= ind['volume_avg'] * config.VOLUME_BREAKOUT_MULT) vol_mult_ok = bool(ind['volume_avg'] and vol_today >= ind['volume_avg'] * config.VOLUME_BREAKOUT_MULT)
turn_ok = (t_today is None or not t_avg) or (t_today >= t_avg * config.TURNOVER_BREAKOUT_MULT) turn_ok = (t_today is None or not t_avg) or (t_today >= t_avg * config.TURNOVER_BREAKOUT_MULT)
breakout = price_break and vol_mult_ok and turn_ok breakout = price_break and vol_mult_ok and turn_ok
checks.append({'label': '돌파(전고점)', 'ok': price_break, checks.append({'label': '돌파(전고점)', 'ok': price_break,
'detail': f"고점 {ind['recent_high']:,.0f}" if ind['recent_high'] else ''}) 'detail': f"고점 {ind['recent_high']:,.0f}" if ind['recent_high'] else ''})
checks.append({'label': '거래량 급증', 'ok': vol_mult_ok, checks.append({'label': '거래량 급증', 'ok': vol_mult_ok,
'detail': f"{ind['today_volume']:,} vs 평균 {ind['volume_avg']:,.0f}" if ind['volume_avg'] else ''}) 'detail': f"{vol_today:,.0f}{proj} vs 평균 {ind['volume_avg']:,.0f}" if ind['volume_avg'] else ''})
if t_today is not None and t_avg: if t_today is not None and t_avg:
checks.append({'label': '회전율 급증', 'ok': bool(turn_ok), checks.append({'label': '회전율 급증', 'ok': bool(turn_ok),
'detail': f"{t_today:.2f}% vs 평균 {t_avg:.2f}%"}) 'detail': f"{t_today:.2f}%{proj} vs 평균 {t_avg:.2f}%"})
else: else:
checks.append({'label': '회전율', 'ok': None, 'detail': '데이터 없음(통과)'}) checks.append({'label': '회전율', 'ok': None, 'detail': '데이터 없음(통과)'})
File diff suppressed because it is too large Load Diff
+48 -10
View File
@@ -10,10 +10,29 @@ from __future__ import annotations
import itertools import itertools
import json import json
import os
from datetime import datetime from datetime import datetime
from multiprocessing import get_context
from . import backtest, config, universe from . import backtest, config, universe
def _eval_combo(args):
"""워커 1개가 조합 1개를 평가 — 학습/검증 + 전체기간 3분할 구간(꾸준함 측정).
파라미터는 학습(fit) 없으므로 3분할은 '한 구간 운빨' 거르는 일관성 검증."""
keys, combo, codes, trf, trt, tef, tet, windows = args
params = dict(zip(keys, combo))
tr = backtest.run(params, codes, date_from=trf, date_to=trt)
te = backtest.run(params, codes, date_from=tef, date_to=tet)
wins = []
for wf, wt in windows:
w = backtest.run(params, codes, date_from=wf, date_to=wt)
wins.append({'from': wf, 'to': wt,
'ret': w.get('total_return_pct'), 'trades': w.get('trades', 0)})
rets = [w['ret'] for w in wins if w['ret'] is not None]
return {'params': params, 'train': tr, 'test': te, 'windows': wins,
'worst_ret': min(rets) if rets else None}
# 백테스트에서 실제 영향 있는 파라미터만 스윕 (애널 게이트·시장필터는 backtest 중립이라 제외). # 백테스트에서 실제 영향 있는 파라미터만 스윕 (애널 게이트·시장필터는 backtest 중립이라 제외).
# 기존 5 + 신규 4(RSI·시간손절·리스크) 탐색. 조합폭발 억제 위해 기존 그리드는 핵심값으로 축소. # 기존 5 + 신규 4(RSI·시간손절·리스크) 탐색. 조합폭발 억제 위해 기존 그리드는 핵심값으로 축소.
# base 3×3×2×2×2 = 72 × 신규 2×2×2×2 = 16 → 1152 조합. # base 3×3×2×2×2 = 72 × 신규 2×2×2×2 = 16 → 1152 조합.
@@ -51,22 +70,39 @@ def run_sweep(codes: list[str] | None = None, train_frac: float = 0.7, grid: dic
train_to, test_from, test_to = dates[split - 1], dates[split], dates[-1] train_to, test_from, test_to = dates[split - 1], dates[split], dates[-1]
train_from = dates[0] train_from = dates[0]
# 전체기간 3분할 — 구간별 수익으로 '꾸준함'(최악 구간) 측정
third = len(dates) // 3
windows = [(dates[0], dates[third - 1]),
(dates[third], dates[2 * third - 1]),
(dates[2 * third], dates[-1])]
keys = list(grid.keys()) keys = list(grid.keys())
combos = list(itertools.product(*[grid[k] for k in keys])) combos = list(itertools.product(*[grid[k] for k in keys]))
results = [] args = [(keys, combo, codes, train_from, train_to, test_from, test_to, windows) for combo in combos]
for combo in combos:
params = dict(zip(keys, combo)) # 멀티프로세싱 — 조합을 코어에 분배. macOS는 fork가 데드락 위험이라 spawn 사용
tr = backtest.run(params, codes, date_from=train_from, date_to=train_to) # (워커가 메인 재import → sim/__main__.py의 `if __name__=='__main__'` 가드로 재귀 방지).
te = backtest.run(params, codes, date_from=test_from, date_to=test_to) # chunksize 크게 → 워커당 연속 조합 처리로 종목 일봉 메모이즈 재활용 극대화. pool.map은 순서 유지(결정론).
results.append({'params': params, 'train': tr, 'test': te}) nproc = max(1, (os.cpu_count() or 4) - 2)
if len(args) <= 1 or nproc == 1:
results = [_eval_combo(a) for a in args]
else:
chunk = max(1, len(args) // nproc)
with get_context('spawn').Pool(nproc) as pool:
results = pool.map(_eval_combo, args, chunksize=chunk)
def sort_key(r): def sort_key(r):
te = r['test'] te = r['test']
enough = (te.get('trades', 0) or 0) >= MIN_TRADES enough = (te.get('trades', 0) or 0) >= MIN_TRADES
return (1 if enough else 0, te.get('total_return_pct', -999) or -999) worst = r.get('worst_ret')
# 1순위 최악 구간 수익(꾸준함 — 한 구간 운빨 배제), 2순위 검증수익
return (1 if enough else 0,
worst if worst is not None else -999,
te.get('total_return_pct', -999) or -999)
results.sort(key=sort_key, reverse=True) results.sort(key=sort_key, reverse=True)
out = { out = {
'generated_at': datetime.now(config.KST).isoformat(), 'generated_at': datetime.now(config.KST).isoformat(),
'universe_size': len(codes), 'universe_size': len(codes),
@@ -81,6 +117,8 @@ def run_sweep(codes: list[str] | None = None, train_frac: float = 0.7, grid: dic
} }
config.STATE_DIR.mkdir(parents=True, exist_ok=True) config.STATE_DIR.mkdir(parents=True, exist_ok=True)
(config.STATE_DIR / 'backtest_results.json').write_text(json.dumps(out, ensure_ascii=False, indent=2)) (config.STATE_DIR / 'backtest_results.json').write_text(json.dumps(out, ensure_ascii=False, indent=2))
# ⚠️ 비교군 자동 동기화는 2026-06-10 관리자님 지시로 끔 — sweep은 결과 파일만 갱신.
# 비교군 변경은 명시 요청 시에만: `python3 -m sim variants sync` 또는 variants.sync_label_tops().
return out return out
@@ -91,13 +129,13 @@ if __name__ == '__main__':
if res.get('error'): if res.get('error'):
print('스윕 실패:', res) print('스윕 실패:', res)
else: else:
print(f"스윕 완료 — {res['count']}개 조합 · 검증기간 {res['split']['test']}") print(f"스윕 완료 — {res['count']}개 조합 · 검증기간 {res['split']['test']} · 정렬=최악구간 수익")
print(f"{'RR':>4} {'STOP':>5} {'SMA':>4} {'PULL':>5} {'RSI±':>8} {'HOLD':>5} {'RISK':>5} | " print(f"{'RR':>4} {'STOP':>5} {'SMA':>4} {'PULL':>5} {'RSI±':>8} {'HOLD':>5} {'RISK':>5} | "
f"{'검증수익%':>8} {'검증MDD%':>8} {'거래':>5} {'승률%':>6}") f"{'최악구간%':>8} {'검증수익%':>8} {'거래':>5} {'승률%':>6}")
for r in res['results'][:12]: for r in res['results'][:12]:
p, te = r['params'], r['test'] p, te = r['params'], r['test']
rsi = f"{p.get('RSI_OVERBOUGHT','-')}/{p.get('RSI_OVERSOLD','-')}" rsi = f"{p.get('RSI_OVERBOUGHT','-')}/{p.get('RSI_OVERSOLD','-')}"
print(f"{p['RR_RATIO']:>4} {p['STOP_ATR_MULT']:>5} {p['SMA_LONG']:>4} {p['PULLBACK_ATR_MULT']:>5} " print(f"{p['RR_RATIO']:>4} {p['STOP_ATR_MULT']:>5} {p['SMA_LONG']:>4} {p['PULLBACK_ATR_MULT']:>5} "
f"{rsi:>8} {p.get('MAX_HOLD_DAYS','-'):>5} {p.get('RISK_PER_TRADE_PCT','-'):>5} | " f"{rsi:>8} {p.get('MAX_HOLD_DAYS','-'):>5} {p.get('RISK_PER_TRADE_PCT','-'):>5} | "
f"{te['total_return_pct']:>8} {te['mdd_pct']:>8} " f"{str(r.get('worst_ret')):>8} {te['total_return_pct']:>8} "
f"{te['trades']:>5} {str(te['win_rate_pct']):>6}") f"{te['trades']:>5} {str(te['win_rate_pct']):>6}")
+38
View File
@@ -65,6 +65,44 @@ def seed_from_sweep(n: int = 3) -> list[dict]:
return variants return variants
def sync_label_tops(results: dict) -> dict:
"""sweep 결과에서 세부 유형(시장적합 라벨)별 1위를 비교군과 동기화 — 수동 추가/제거 불필요.
파라미터가 같은 변이는 계좌 유지, 빠진 유형 대표는 추가, 더는 대표가 아닌 변이는 삭제.
(2026-06-10 기준전략 폐지 메인 제외 규칙 삭제, 유형별 1 전부 변이로.)"""
def _k(p):
return json.dumps({k: float(v) for k, v in (p or {}).items()}, sort_keys=True)
def _sk(r):
w = r.get('worst_ret')
return (w if w is not None else -999,
r['test'].get('total_return_pct', -999) or -999)
from .sim_web import _market_fit_summary # 함수 레벨 import (순환 회피)
uniq = {}
for r in results.get('results', []):
uniq.setdefault(_k(r['params']), r)
by_label = {}
for r in uniq.values():
lbl = _market_fit_summary(r['params'])
if lbl not in by_label or _sk(r) > _sk(by_label[lbl]):
by_label[lbl] = r
desired = {_k(r['params']): r['params'] for r in by_label.values()}
current = {_k(v['params']): v for v in load_variants()}
removed = added = 0
for k, v in current.items():
if k not in desired:
remove_variant(v['id'])
removed += 1
for k, p in desired.items():
if k not in current:
if add_variant(p):
added += 1
return {'labels': len(by_label), 'added': added, 'removed': removed,
'kept': len(current) - removed}
def add_variant(params: dict) -> dict | None: def add_variant(params: dict) -> dict | None:
"""단일 params 조합을 변이로 추가 (백테스트 행 → 비교군). 이미 있으면 None.""" """단일 params 조합을 변이로 추가 (백테스트 행 → 비교군). 이미 있으면 None."""
variants = load_variants() variants = load_variants()
@@ -24,3 +24,5 @@
{"date": "2026-06-08", "market": "KOSPI", "market_label": "코스피", "rise": 42, "upper": 1, "fall": 876, "lower": 0, "steady": 3, "adr": 4.91, "personal": 17628, "foreign": -2644, "institutional": -17164, "captured_at": "2026-06-08T21:00:04.897696+09:00"} {"date": "2026-06-08", "market": "KOSPI", "market_label": "코스피", "rise": 42, "upper": 1, "fall": 876, "lower": 0, "steady": 3, "adr": 4.91, "personal": 17628, "foreign": -2644, "institutional": -17164, "captured_at": "2026-06-08T21:00:04.897696+09:00"}
{"date": "2026-06-09", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1435, "upper": 6, "fall": 258, "lower": 1, "steady": 44, "adr": 556.37, "personal": -5112, "foreign": 3126, "institutional": 1996, "captured_at": "2026-06-09T21:00:02.663326+09:00"} {"date": "2026-06-09", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1435, "upper": 6, "fall": 258, "lower": 1, "steady": 44, "adr": 556.37, "personal": -5112, "foreign": 3126, "institutional": 1996, "captured_at": "2026-06-09T21:00:02.663326+09:00"}
{"date": "2026-06-09", "market": "KOSPI", "market_label": "코스피", "rise": 774, "upper": 1, "fall": 133, "lower": 1, "steady": 15, "adr": 578.36, "personal": -6167, "foreign": -20064, "institutional": 25048, "captured_at": "2026-06-09T21:00:02.663326+09:00"} {"date": "2026-06-09", "market": "KOSPI", "market_label": "코스피", "rise": 774, "upper": 1, "fall": 133, "lower": 1, "steady": 15, "adr": 578.36, "personal": -6167, "foreign": -20064, "institutional": 25048, "captured_at": "2026-06-09T21:00:02.663326+09:00"}
{"date": "2026-06-10", "market": "KOSDAQ", "market_label": "코스닥", "rise": 499, "upper": 6, "fall": 1176, "lower": 1, "steady": 61, "adr": 42.91, "personal": 1167, "foreign": 1420, "institutional": -1125, "captured_at": "2026-06-10T21:00:04.276904+09:00"}
{"date": "2026-06-10", "market": "KOSPI", "market_label": "코스피", "rise": 343, "upper": 2, "fall": 550, "lower": 0, "steady": 28, "adr": 62.73, "personal": 48643, "foreign": -27754, "institutional": -22665, "captured_at": "2026-06-10T21:00:04.276904+09:00"}
@@ -78,3 +78,5 @@
{"date": "2026-06-08", "account": "가희_ISA", "owner": "가희", "code": "005930", "name": "삼성전자", "buy_qty": 10, "buy_avg": 299750, "buy_amt": 2997500, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 440, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"} {"date": "2026-06-08", "account": "가희_ISA", "owner": "가희", "code": "005930", "name": "삼성전자", "buy_qty": 10, "buy_avg": 299750, "buy_amt": 2997500, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 440, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"}
{"date": "2026-06-09", "account": "일반", "owner": "본인", "code": "0193W0", "name": "KODEX 삼성전자단일종목레버리지", "buy_qty": 0, "buy_avg": 0, "buy_amt": 0, "sell_qty": 12, "sell_avg": 22000, "sell_amt": 264000, "pl_amt": 9570, "cmsn_tax": 30, "prft_rt": 3.76, "collected_at": "2026-06-09T23:19:23.494299+09:00"} {"date": "2026-06-09", "account": "일반", "owner": "본인", "code": "0193W0", "name": "KODEX 삼성전자단일종목레버리지", "buy_qty": 0, "buy_avg": 0, "buy_amt": 0, "sell_qty": 12, "sell_avg": 22000, "sell_amt": 264000, "pl_amt": 9570, "cmsn_tax": 30, "prft_rt": 3.76, "collected_at": "2026-06-09T23:19:23.494299+09:00"}
{"date": "2026-06-09", "account": "ISA", "owner": "본인", "code": "0193W0", "name": "KODEX 삼성전자단일종목레버리지", "buy_qty": 0, "buy_avg": 0, "buy_amt": 0, "sell_qty": 12, "sell_avg": 22070, "sell_amt": 264840, "pl_amt": 11610, "cmsn_tax": 30, "prft_rt": 4.59, "collected_at": "2026-06-09T23:19:23.494299+09:00"} {"date": "2026-06-09", "account": "ISA", "owner": "본인", "code": "0193W0", "name": "KODEX 삼성전자단일종목레버리지", "buy_qty": 0, "buy_avg": 0, "buy_amt": 0, "sell_qty": 12, "sell_avg": 22070, "sell_amt": 264840, "pl_amt": 11610, "cmsn_tax": 30, "prft_rt": 4.59, "collected_at": "2026-06-09T23:19:23.494299+09:00"}
{"date": "2026-06-10", "account": "일반", "owner": "본인", "code": "0193T0", "name": "KODEX SK하이닉스단일종목레버리지", "buy_qty": 12, "buy_avg": 20880, "buy_amt": 250560, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 30, "prft_rt": 0.0, "collected_at": "2026-06-10T21:00:04.035276+09:00"}
{"date": "2026-06-10", "account": "ISA", "owner": "본인", "code": "0193T0", "name": "KODEX SK하이닉스단일종목레버리지", "buy_qty": 13, "buy_avg": 20855, "buy_amt": 271115, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 40, "prft_rt": 0.0, "collected_at": "2026-06-10T21:00:04.035276+09:00"}