From 81e8847a8e13a012765c9b5c4d88c0c4f3f8e39a Mon Sep 17 00:00:00 2001 From: hyowons Date: Thu, 11 Jun 2026 02:00:01 +0900 Subject: [PATCH] =?UTF-8?q?auto:=20=EC=9D=BC=EC=9D=BC=20=EB=B0=B1=EC=97=85?= =?UTF-8?q?=202026-06-11=2002:00?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 10 +- agents/stock/workspace/MEMORY.md | 4 +- agents/stock/workspace/sim/__main__.py | 8 +- agents/stock/workspace/sim/backtest.py | 154 ++- agents/stock/workspace/sim/config.py | 3 +- agents/stock/workspace/sim/data.py | 41 +- agents/stock/workspace/sim/engine.py | 134 ++- agents/stock/workspace/sim/portfolio.py | 13 +- agents/stock/workspace/sim/signals.py | 14 +- agents/stock/workspace/sim/sim_web.py | 925 +++++++++++++----- agents/stock/workspace/sim/sweep.py | 58 +- agents/stock/workspace/sim/variants.py | 38 + .../state/market_indicators_history.jsonl | 2 + .../stock/workspace/state/trade_journal.jsonl | 2 + 14 files changed, 1064 insertions(+), 342 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 17ae205..e993ccc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,12 +107,12 @@ Stock agent (`agents/stock/workspace/scripts/`), run with `python3`: - `send_balance_to_budget.py` — 매월 1일 04:30 launchd. 본인 계좌(가희 제외)별 잔액·예수금·평가액을 집계해 `agents/budget/inbox/incoming/`에 envelope(`topic: securities_balance`)로 떨어뜨린다. 골디 월간 결산(05:00) 입력. LLM 미경유, 실패 시 레이 텔레그램으로 자가 알림. - `trade_journal.py` — 종목별 매매 기록 누적. 키움 REST에 기간 거래내역 API 부재 → 평일 21:00 launchd(NXT 야간 마감 후)로 ka10170 4계좌 호출해 `state/trade_journal.jsonl`에 적재. `(date, account)` 단위 idempotent, 휴장일/주말 self-skip(`--force`로 우회). 적재 시작일 2026-05-13 이전 보유분은 `seed` 명령(1회)으로 현재 평단가×(보유-당일매수+당일매도) 단일 행으로 압축 적재됨(`seed=true` 플래그·`*` 마커). CLI: `collect [--date YYYYMMDD]` / `seed` / `show ` / `query [--from --to --account --code]` - `market_indicators_sync.py` — 시장 단위 ADR·투자자별 매매 누적. 네이버 m.stock `/api/index/{KOSPI|KOSDAQ}/integration` 한 콜로 dealTrendInfo·upDownStockInfo 수집 → `state/market_indicators_history.jsonl`. `(date, market)` 단위 idempotent, 휴장/주말 self-skip(`--force`). 평일 21:00 stock.trade-journal launchd에 통합 발화 (별도 plist 없음). KRX 정보데이터시스템은 응답 패턴 변경으로 백필 보류 — 매일 누적만 시작. behive_web 자산정보 탭의 시장정보 sub-tab이 sparkline 데이터원으로 사용 -- `sim/` (스크립트 아닌 **별도 패키지** — `agents/stock/workspace/sim/`) — **자동매매 시뮬레이션(페이퍼 트레이딩) 엔진**. 실주문 `orders/`와 완전 분리, 가상자본 1천만으로만 동작. 구성: `config.py`(파라미터)·`indicators.py`(SMA/RSI/ATR/고저/거래량 순수함수)·`universe.py`(**누적 관찰목록** `state/sim/watchlist.json` — 비하이브 워치+관심+본인보유를 매 스캔 **자동 편입**하되 **자동 삭제 X**(수동만). `sync_watchlist`/`add_manual`/`remove`)·`data.py`(시세 batch·일봉 **캐시 우선**·수급 60m캐시·애널〔fnguide/wisereport 12h 캐시, **캐시 미스 때만 0.4s 페이싱**으로 연속 크롤링 차단 회피〕)·`signals.py`(진입/청산 규칙 + 손절·목표 계산 + dashboard용 체크리스트)·`portfolio.py`(가상 체결·비용·손익·MDD)·`engine.py`(스캔 오케스트레이션 — `_gather(extra_codes)`로 **보유 종목은 관찰목록에서 빠져도 청산 추적**)·`benchmark.py`(**기준지수 대비 알파** — 네이버 m.stock 지수 일별종가를 `state/sim/index_history.json`에 누적〔KOSPI/KOSDAQ, pageSize≤50〕, 구간 등락률·초과수익 계산. 스캔마다 `update_today` 1회 갱신, 실패 무시)·`__main__.py`(CLI)·`sim_web.py`(별도 대시보드 서버 18792). 전략: 방향=애널 약한게이트+가점, 타이밍=수급·기술. **ETF/ETN은 자동매수 제외**(2026-06-09 — 방향성 엔진인 애널 컨센서스가 ETF엔 None이라 전략 부정합. `universe.is_etf()`가 운용사 프리픽스(KODEX/TIGER/ACE…)로 판별, engine 미보유 매수 분기 직전에 `ETF — 자동매매 제외` SKIP. **보유 ETF는 청산까지 추적**(매수 분기 전 처리라 무관)). 매수=거래량+전고점 돌파 즉시 / 평소 눌림목(max(20일선, 전일종가−ATR)) 지정가. **시장 국면 필터(6단계)**: `market_regime_map()`이 상승비율(폭)+지수추세(방향, 20일선)+당일등락(강도)을 조합해 `engine.REGIME` 6단계로 분류 — 🔥강세·📈반등(상승비율↑ & 지수 20일선 아래=약세탈출)·🌤완만상승 / ➖혼조·📉약세·❄️급락. **앞 3개=매수허용, 뒤 3개=보류**(`market_ok_map`이 `REGIME[g][2]`로 파생). 상승비율은 **항상 네이버 실시간**(`_live_breadth` 60s 캐시, 실패 시 EOD). 약세장이어도 **돌파 진입은 허용, 눌림목만 보류**(soft gate). 시장탭에 국면 배지+설명 표시(매수판단과 동일 소스). `code_market_map()`이 종목→KOSPI/KOSDAQ 매핑. (2026-06-09 강세/약세 2단계→6단계 국면으로 세분화) **2026-06-09 4종 추가**: ⓐ **리스크 기반 사이징**(`signals.target_value` — 수량=자산×`RISK_PER_TRADE_PCT` ÷ 손절폭, 균등비중 자산/`MAX_POSITIONS` 상한 캡, 0=끔. 변동성 큰 종목 자동 축소·종목당 리스크 균등화. 엔진·백테스트 공용) ⓑ **RSI 필터**(`indicators.rsi` 활성화 — 돌파 시 `RSI_OVERBOUGHT`↑ 과열 추격 보류 / 눌림목 시 `RSI_OVERSOLD`↓ 낙하 중 보류, 데이터 없으면 통과) ⓒ **시장 국면 6단계**(`market_regime_map`→`engine.REGIME`, 위 시장 국면 필터 참조. `_index_trend_ok`·`_index_daily_change` 보조. ⚠️2026-06-09 강세/약세 2단계가 84% 반등일을 약세로 오판해 6단계 국면으로 세분화) ⓓ **시간손절**(`MAX_HOLD_DAYS` 경과 & 미진전〔트레일링 전+진입가 이하〕 시 청산해 자본 회전, `evaluate_holding(held_days)` — 엔진=달력일·백테스트=`entry_date` 달력일). ⓔ **추세 질 보강**(2026-06-09 `trend_ok` 게이트 강화: 기존 현재가>20일선+정배열(5>20)에 더해 ①**20일선 우상향 기울기**〔`sma_long_slope_up`, `TREND_SLOPE_DAYS`전 대비〕 ②**중기 정배열 5>`SMA_MID`(60)** — 둘 다 종목데이터라 **백테스트 반영**, 데이터 부족 시 통과. ③**상대강도**〔`rel_strength`=종목−지수 `REL_STRENGTH_DAYS`(20)일 수익률, engine이 주입하는 **라이브 전용**, 백테스트 None=중립, 체크표시만〕). **분할매수·추격매수**: 목표비중을 `ENTRY_TRANCHES`(기본 3)로 나눠 1/3씩 진입, 직전 진입가+ATR×`ADD_ATR_MULT` 돌파 & 추세 지속 시 다음 트랜치 추가매수(평단 가중평균, 손절 위로만 래칫)·트레일링/일부익절 시작 후엔 추가 종료. 매도=손절(max(진입−ATR×2, 10일저점))·**분할매도**(목표 도달 시 `SCALE_OUT_FRAC`(기본 50%) 익절 후 잔량 트레일링(고점−ATR×1.5))·추세수급 이탈 시 전량 청산. 부분매도는 closed/win 미카운트(전량 청산 시만 카운트). 결정론적(LLM 미경유). **기준지수 대비(알파)**: 가상계좌/백테스트 수익률을 같은 기간 KOSPI·KOSDAQ '매수후보유' 대비로 평가(수익률만으론 지수 못 이기면 무의미하므로). 라이브는 계좌 시작일~오늘, 백테스트는 윈도우 구간 대비. KPI '코스피/코스닥 대비(알파)' + 백테스트·비교 탭 알파 행. 경과기간 0(시작 당일)·캐시 부재 시 None('데이터 없음'). 상태 `state/sim/{portfolio.json,trades.jsonl,last_scan.json,index_history.json}`. CLI: `python3 -m sim {scan [--force]|status|report|reset --yes|sweep [frac]|backtest|backfill [--pages N] [--force]|backfill-index [--pages N]|variants {seed N|list|reset|clear}}` (cwd=workspace). launchd `stock.sim-scan`(15분)·`stock.sim-web`(상시). +- `sim/` (스크립트 아닌 **별도 패키지** — `agents/stock/workspace/sim/`) — **자동매매 시뮬레이션(페이퍼 트레이딩) 엔진**. 실주문 `orders/`와 완전 분리, 가상자본 1억으로만 동작(`INITIAL_CAPITAL`, 2026-06-10 1천만→1억). 구성: `config.py`(파라미터)·`indicators.py`(SMA/RSI/ATR/고저/거래량 순수함수)·`universe.py`(**누적 관찰목록** `state/sim/watchlist.json` — 비하이브 워치+관심+본인보유를 매 스캔 **자동 편입**하되 **자동 삭제 X**(수동만). `sync_watchlist`/`add_manual`/`remove`)·`data.py`(시세 batch·일봉 **캐시 우선**·수급 60m캐시·애널〔fnguide/wisereport 12h 캐시, **캐시 미스 때만 0.4s 페이싱**으로 연속 크롤링 차단 회피〕)·`signals.py`(진입/청산 규칙 + 손절·목표 계산 + dashboard용 체크리스트)·`portfolio.py`(가상 체결·비용·손익·MDD)·`engine.py`(스캔 오케스트레이션 — `_gather(extra_codes)`로 **보유 종목은 관찰목록에서 빠져도 청산 추적**)·`benchmark.py`(**기준지수 대비 알파** — 네이버 m.stock 지수 일별종가를 `state/sim/index_history.json`에 누적〔KOSPI/KOSDAQ, pageSize≤50〕, 구간 등락률·초과수익 계산. 스캔마다 `update_today` 1회 갱신, 실패 무시)·`__main__.py`(CLI)·`sim_web.py`(별도 대시보드 서버 18792). 전략: 방향=애널 약한게이트+가점, 타이밍=수급·기술. **ETF/ETN은 자동매수 제외**(2026-06-09 — 방향성 엔진인 애널 컨센서스가 ETF엔 None이라 전략 부정합. `universe.is_etf()`가 운용사 프리픽스(KODEX/TIGER/ACE…)로 판별, engine 미보유 매수 분기 직전에 `ETF — 자동매매 제외` SKIP. **보유 ETF는 청산까지 추적**(매수 분기 전 처리라 무관)). 매수=거래량+전고점 돌파 즉시 / 평소 눌림목(max(20일선, 전일종가−ATR)) 지정가. **시장 국면 필터(6단계)**: `market_regime_map()`이 상승비율(폭)+지수추세(방향, 20일선)+당일등락(강도)을 조합해 `engine.REGIME` 6단계로 분류 — 🔥강세·📈반등(상승비율↑ & 지수 20일선 아래=약세탈출)·🌤완만상승 / ➖혼조·📉약세·❄️급락. **앞 3개=매수허용, 뒤 3개=보류**(`market_ok_map`이 `REGIME[g][2]`로 파생). 상승비율은 **항상 네이버 실시간**(`_live_breadth` 60s 캐시, 실패 시 EOD). 약세장이어도 **돌파 진입은 허용, 눌림목만 보류**(soft gate). 시장탭에 국면 배지+설명 표시(매수판단과 동일 소스). `code_market_map()`이 종목→KOSPI/KOSDAQ 매핑. (2026-06-09 강세/약세 2단계→6단계 국면으로 세분화) **2026-06-09 4종 추가**: ⓐ **리스크 기반 사이징**(`signals.target_value` — 수량=자산×`RISK_PER_TRADE_PCT` ÷ 손절폭, 균등비중 자산/`MAX_POSITIONS` 상한 캡, 0=끔. 변동성 큰 종목 자동 축소·종목당 리스크 균등화. 엔진·백테스트 공용) ⓑ **RSI 필터**(`indicators.rsi` 활성화 — 돌파 시 `RSI_OVERBOUGHT`↑ 과열 추격 보류 / 눌림목 시 `RSI_OVERSOLD`↓ 낙하 중 보류, 데이터 없으면 통과) ⓒ **시장 국면 6단계**(`market_regime_map`→`engine.REGIME`, 위 시장 국면 필터 참조. `_index_trend_ok`·`_index_daily_change` 보조. ⚠️2026-06-09 강세/약세 2단계가 84% 반등일을 약세로 오판해 6단계 국면으로 세분화) ⓓ **시간손절**(`MAX_HOLD_DAYS` 경과 & 미진전〔트레일링 전+진입가 이하〕 시 청산해 자본 회전, `evaluate_holding(held_days)` — 엔진=달력일·백테스트=`entry_date` 달력일). ⓖ **스캔 메모 4종**(2026-06-10): 변이 수가 늘어도 스캔이 선형 폭증하지 않게 — 호가(`data._BOOK_MEMO`, 체결 공정성 겸용)·일봉(`engine._SCAN_CANDLES`)·지표(`engine._IND_MEMO`, (code,지표파라미터) 키)·수급/애널(`data._FLOW_MEMO_SCAN`/`_ANL_MEMO_SCAN`)을 스캔 1회 동안 공유, `_gather`가 매 스캔 리셋. 핵심은 애널 캐시 stale 시 0.4s 페이싱이 변이 수만큼 반복되던 것 — 108계좌 판단 38s→2s. ⓕ **2026-06-10 코드리뷰 3종**: 재진입 쿨다운(전량청산 종목 **당일 재매수 금지** — `Portfolio.last_exit`/`exited_today`, backtest 동일 규칙. 휩쏘 churn 방지)·장중 거래량 환산(`_session_elapsed_frac` 0.15~1.0을 `ind['vol_time_frac']`로 주입 → signals가 돌파 거래량·회전율을 하루치로 환산 비교, '(환산)' 표기. 이전엔 오전 돌파가 구조적으로 불가했음. backtest는 1.0=무영향)·일별 성과이력(`state/sim/equity_history.json` — 매 스캔 당일 항목 덮어써 EOD값 보존, `{date:[{id,ph(파라미터해시),equity,ret}]}`. 변이 id 재사용돼도 ph로 식별. 국면별 사후분석용) ⓔ **추세 질 보강**(2026-06-09 `trend_ok` 게이트 강화: 기존 현재가>20일선+정배열(5>20)에 더해 ①**20일선 우상향 기울기**〔`sma_long_slope_up`, `TREND_SLOPE_DAYS`전 대비〕 ②**중기 정배열 5>`SMA_MID`(60)** — 둘 다 종목데이터라 **백테스트 반영**, 데이터 부족 시 통과. ③**상대강도**〔`rel_strength`=종목−지수 `REL_STRENGTH_DAYS`(20)일 수익률, engine이 주입하는 **라이브 전용**, 백테스트 None=중립, 체크표시만〕). **분할매수·추격매수**: 목표비중을 `ENTRY_TRANCHES`(기본 3)로 나눠 1/3씩 진입, 직전 진입가+ATR×`ADD_ATR_MULT` 돌파 & 추세 지속 시 다음 트랜치 추가매수(평단 가중평균, 손절 위로만 래칫)·트레일링/일부익절 시작 후엔 추가 종료. 매도=손절(max(진입−ATR×2, 10일저점))·**분할매도**(목표 도달 시 `SCALE_OUT_FRAC`(기본 50%) 익절 후 잔량 트레일링(고점−ATR×1.5))·추세수급 이탈 시 전량 청산. 부분매도는 closed/win 미카운트(전량 청산 시만 카운트). 결정론적(LLM 미경유). **기준지수 대비(알파)**: 가상계좌/백테스트 수익률을 같은 기간 KOSPI·KOSDAQ '매수후보유' 대비로 평가(수익률만으론 지수 못 이기면 무의미하므로). 라이브는 계좌 시작일~오늘, 백테스트는 윈도우 구간 대비. KPI '코스피/코스닥 대비(알파)' + 백테스트·비교 탭 알파 행. 경과기간 0(시작 당일)·캐시 부재 시 None('데이터 없음'). 상태 `state/sim/{portfolio.json,trades.jsonl,last_scan.json,index_history.json}`. CLI: `python3 -m sim {scan [--force]|status|report|reset --yes|sweep [frac]|backtest|backfill [--pages N] [--force]|backfill-index [--pages N]|variants {seed N|list|reset|clear}}` (cwd=workspace). launchd `stock.sim-scan`(15분)·`stock.sim-web`(상시). - **런타임 튜닝**: `config.TUNABLE` 27개 파라미터(분할매수 횟수·추격 ATR 배수·분할매도 익절 비율·종목당 리스크 비율·RSI 과열/과매도 컷·시간손절 일수·**약세장 매수모드**〔`BEAR_ENTRY_MODE` 0현금화/1돌파만/2눌림허용, signals에서 `market_ok` False일 때 분기, backtest는 market중립이라 무영향, 병렬변이로 라이브 비교〕 포함)를 `state/sim/params.json`로 오버라이드(웹 튜닝 탭에서 저장). config import 시 기본값 위에 덮어써 엔진이 다음 스캔부터 반영. `config._DEFAULTS`가 원본 기본값. `load_params/save_params/reset_params`. - - **백테스트·스윕** (`backtest.py`/`sweep.py`): signals.py 그대로 재사용해 과거 일봉 되감기(지표 t까지·종가체결·애널 중립·수급은 flow_history 사용). `sweep`은 9파라미터(SMA_LONG·RR·STOP_ATR·PULLBACK_ATR·VOLUME_BREAKOUT + 신규 RSI_OVERBOUGHT·RSI_OVERSOLD·MAX_HOLD_DAYS·RISK_PER_TRADE_PCT) **1152조합**을 학습/검증(out-of-sample 0.7) 분리 비교 (애널·시장필터는 backtest 중립이라 스윕 제외) → `state/sim/backtest_results.json`. ⚠️ 조합당 ~2.3s(train 182일+test 79일)라 **1152조합 ≈ 44분** — `GRID` 늘릴 때 곱셈 폭발 주의. ⚠️ 종가체결·현 워치리스트(생존편향)라 절대수익 아닌 상대순위로만 해석. 2026-06-09 스윕: 핵심 레버는 `VOLUME_BREAKOUT=1.3`(거래량 문턱 낮춤, 중앙 +19%) — 신규 4종은 강세 검증창에선 미미(RSI·시간손절=하락장 보험). 적용 균형값 params.json 저장(RISK 0.01 보수 유지, 0.02 고수익은 강세장 레버리지라 채택 X). + - **백테스트·스윕** (`backtest.py`/`sweep.py`): signals.py 그대로 재사용해 과거 일봉 되감기(지표 t까지·종가체결·애널 중립·수급은 flow_history 사용). `sweep`은 9파라미터(SMA_LONG·RR·STOP_ATR·PULLBACK_ATR·VOLUME_BREAKOUT + 신규 RSI_OVERBOUGHT·RSI_OVERSOLD·MAX_HOLD_DAYS·RISK_PER_TRADE_PCT) **1152조합**을 학습/검증(out-of-sample 0.7) 분리 비교 (애널·시장필터는 backtest 중립이라 스윕 제외) → `state/sim/backtest_results.json`. **체결 현실화**(2026-06-10): backtest가 당일 종가 판단→**다음날 시가 체결**(pending 주문 큐, look-ahead 제거) + `BT_SLIPPAGE` 0.2%(매수 비싸게/매도 싸게). 체결 낙관분 ~5%p 제거 확인. 거래 없는 날 주문은 소멸(신호 지속 시 재큐잉). **3분할 꾸준함 순위**(2026-06-10): 전체기간 3분할 구간별 수익(`windows`/`worst_ret`)을 측정, 순위 1순위=최악구간 수익(한 구간 운빨 배제)·2순위=검증수익. 웹 행에 '구간별 검증(3분할)' 표시. **멀티프로세싱**(2026-06-10): `sweep`이 조합을 코어에 분배(`get_context('spawn')`, 워커=코어−2). 일봉·수급은 `_hist_cached`/`_flow_cached` 프로세스 메모이즈로 종목당 sqlite 1회만 읽음. **44분 → 약 5~7분**(10코어 기준). 데이터는 이미 sqlite 캐시 전용(키움 API 0회) — 병목은 네트워크가 아니라 지표 재계산(파이썬). spawn이라 워커가 메인 재import하지만 `sim/__main__.py` 가드로 안전. ⚠️ `GRID` 늘릴 때 곱셈 폭발 주의. ⚠️ 종가체결·현 워치리스트(생존편향)라 절대수익 아닌 상대순위로만 해석. 2026-06-09 스윕: 핵심 레버는 `VOLUME_BREAKOUT=1.3`(거래량 문턱 낮춤, 중앙 +19%) — 신규 4종은 강세 검증창에선 미미(RSI·시간손절=하락장 보험). 적용 균형값 params.json 저장(RISK 0.01 보수 유지, 0.02 고수익은 강세장 레버리지라 채택 X). - **수급 백필** (`backfill_flow.py`): ka10059 연속조회로 종목별 투자자 순매수 다년치(기본 6페이지≈2.4년) → `state/sim/flow_history.sqlite`(code,date,foreign_net,inst_net,indiv_net 천주). idempotent·rate limit 페이싱+429 백오프. backtest가 이 DB로 수급(외/기) 반영 (없으면 중립). 2026-06-08 54종목 3만행 1회 적재 완료. - **병렬 페이퍼** (`variants.py` + `engine.scan_all`): 스윕 상위 튜닝들을 각자 가상계좌(`state/sim/variants//`)로 실시간 동시 운영 → 비교(`variants_compare.json`). `scan_all`이 라이브 데이터 1회 수집 후 메인+모든 변이를 같은 시세로 평가(`_gather`+`_run_scan` 분리, Portfolio가 경로 인자 받음). 변이는 애널 게이트까지 실반영(백테스트가 못 보는 부분). `variants seed N`은 backtest_results 상위 N개 등록. **launchd `sim-scan`은 이제 `scan`→내부적으로 `scan_all` 실행**(메인+변이 매 스캔 갱신, 트리거·명령 동일). - - **웹 4메인탭**(sim_web, 탭 라우팅 `?t=`+보조탭 `?s=`): 헤더 타이틀 옆 **시뮬 상태점**(`_engine_status`→`#health` 점: 🟢 장중 가동(최근 스캔 ≤12분)·🟡 장외 대기·🔴 장중 멈춤 의심, 클릭 시 상태 토스트). **내 계좌**(KPI·보유/거래 펼치기) · **관심종목**(종목별 신호 체크리스트 + 각 평가항목 **ⓘ 버튼 → 쉬운 설명 토스트**〔`_CHK_HELP`/`_chk_key`/`_CHK_HELP_JS`, 바텀시트 `#chk-toast`, 열려있으면 자동새로고침 멈춤〕 + 상단 **종목 추가 폼**〔코드/이름〕 + 카드별 **✕ 삭제**〔수동만, 보유 중이면 청산 추적〕 + origin 라벨〔워치/관심/보유/수동〕 + 미스캔분 '스캔 대기' 표시) · **시장**(지수·ADR·상승하락·투자자 순매수 — **항상 네이버 m.stock 실시간**〔`_get_live_market` 60s 캐시, `market_indicators_sync.fetch_market` 재사용. 네이버가 장 마감 후에도 당일 최종값 제공하므로 세션 무관 최신 사용〕, **조회 실패 시에만** `market_indicators_history.jsonl` EOD 폴백. note는 장중='장중 실시간'·장외='직전 장 마감 기준'. 2026-06-09 초기엔 장중만 실시간이라 마감 후 어제 EOD를 보이던 빈틈을 항상-실시간으로 수정) · **전략**(보조탭 3: **튜닝**〔파라미터 폼 저장 + 26개 파라미터 각각 **ⓘ 설명 토스트**〔`_TUNE_HELP`, `.chk-i` 핸들러〕〕/**백테스트**〔스윕 순위표 — **행 제목 = `_market_fit_summary` 한 줄 요약**(예 "상승장에 강한 빠른·공격 단타형"), 파라미터 라벨은 펼침 본문으로 이동 + 각 조합 **🌐 "어떤 시장에 강한가" 버튼**〔`_market_fit` — 파라미터 성격(추세추종/빠른익절/돌파강도/리스크 등)을 규칙으로 분류해 💪강세 시장·⚠️약세 시장을 쉬운 일상어 토스트로 설명, `data-detail` 통합 핸들러〕 + **매매 스타일 타입별 그룹**〔`_strategy_type`(RR 기준)→`_BT_TYPES`: 📈추세추종형(RR≥2.5)·⚖️균형형(RR2.0)·⚡단타·회전형(RR≤1.5), 각 타입 검증수익 상위 `_BT_TOPN`(8)개씩 섹션. 리스크 성향(공격/보수)은 행 제목에 표시돼 보완. 2026-06-09 단일 정렬바→타입그룹 전환(정렬바·`_BT_SORT_JS` 제거), 리스크축→스타일축 교체〕 + '이 튜닝 적용' + '비교군 추가/제거' 토글〔등록조합은 제거버튼+비교군 뱃지, 메인조합은 '메인' 뱃지·추가차단〕 + **'↻ 다시 계산'**〔백그라운드 sweep ~80s, 진행배너+자동갱신〕/**비교**〔병렬 변이 실시간 성적, 변이별 성적초기화/삭제 + **전체 삭제(메인 제외)** `/variants_clear_all`→`vmod.clear()` + 변이별 **🌐 시장적합 버튼**(`_market_fit` 공용)〕). 🌐는 `data-detail` 통합 토스트 핸들러로 헤더 상태점(`#health`)과 공유. sticky 헤더, 당겨서 새로고침. `do_POST`: `/save_params`·`/run_sweep`·`/variants_{add,reset_one,remove,clear_all}`·`/watch_{add,remove}` (그 외 읽기전용) + - **웹 3메인탭**(sim_web — **전략/관심종목/시장** 순, 기본=전략·비교. **기준전략(M) 폐지**(2026-06-10): scan_all의 메인 패스는 `execute=False` 판단 전용(last_scan=화면·상태점용, 메인 계좌 매매 동결), 실매매·비교는 변이 36개만. `sync_label_tops` 메인 제외 규칙 삭제(유형별 1위 전부 변이). 비교탭 행마다 **마킹 아이콘 피커**(2026-06-10 ☆토글→6종 아이콘 ⭐🔥💎🎯👀🚀 선택, `FAV_ICONS`/`favorites.json`은 `{아이콘: vid}` — **아이콘 하나당 전략 1개**(다른 전략에 쓰면 이동)·전략당 아이콘 1개. 칩 클릭→바텀시트 피커→`POST /fav_set`(fetch). 구버전 list는 자동 마이그레이션), 관심종목 탭 기본 시각 = 첫 마킹(FAV_ICONS 순) → 없으면 1위 변이. **약세모드 단어 칩**(2026-06-10): 비교 행 vid 옆 + 드롭다운 + 펼침 body '약세장 모드' 행에 회피(0 현금화)/돌파(1 기본)/적극(2 눌림허용) 색 칩(`_BEAR_WORD`/`_bear_chip_html`, 클릭 시 설명 토스트. 아이콘→단어는 관리자님 요청). `_market_fit_summary`의 긴 접미사는 제거. **비교 행·드롭다운 이름은 짧은 단어 나열**(`_market_fit_words` — '상승장·빠른·공격·추세추종', `_fit_tokens` 공용 토큰. 긴 문장형 `_market_fit_summary`는 백테스트 탭 라벨·비교 펼침 '유형' 행에 유지). **bt매칭은 `_btkey`(swept_params만)** — BEAR_ENTRY_MODE 등 백테스트 중립 키가 params에 있어도 같은 bt행에 매칭(모드 3형제는 bt순위 공유, 백테스트 탭은 36종만 표시가 정상). 헤더 상태점 자산줄은 '전략 N개 · 평균/1위 수익률'. 2026-06-10 내계좌 탭 삭제〔계좌 정보는 비교탭 메인 행으로〕·튜닝 보조탭 삭제〔params 변경은 백테스트 "이 튜닝 적용"으로만, `/save_params` 리다이렉트도 s=bt〕. 탭 라우팅 `?t=`+보조탭 `?s=`, 레거시 t=acct/tune→lab): 헤더 타이틀 옆 **시뮬 상태점**(`_engine_status`→`#health` 점: 🟢 장중 가동(최근 스캔 ≤12분)·🟡 장외 대기·🔴 장중 멈춤 의심, 클릭 시 상태 토스트). **관심종목**(종목별 신호 체크리스트 + 각 평가항목 **ⓘ 버튼 → 쉬운 설명 토스트**〔`_CHK_HELP`/`_chk_key`/`_CHK_HELP_JS`, 바텀시트 `#chk-toast`, 열려있으면 자동새로고침 멈춤〕 + 상단 **종목 추가 폼**〔코드/이름〕 + 카드별 **✕ 삭제**〔수동만, 보유 중이면 청산 추적〕 + origin 라벨〔워치/관심/보유/수동〕 + 미스캔분 '스캔 대기' 표시) · **시장**(지수·ADR·상승하락·투자자 순매수 — **항상 네이버 m.stock 실시간**〔`_get_live_market` 60s 캐시, `market_indicators_sync.fetch_market` 재사용. 네이버가 장 마감 후에도 당일 최종값 제공하므로 세션 무관 최신 사용〕, **조회 실패 시에만** `market_indicators_history.jsonl` EOD 폴백. note는 장중='장중 실시간'·장외='직전 장 마감 기준'. 2026-06-09 초기엔 장중만 실시간이라 마감 후 어제 EOD를 보이던 빈틈을 항상-실시간으로 수정) · **전략**(보조탭 2: **백테스트**〔**유형별(세부 라벨 ~36종) 대표 요약** — 2026-06-10 단순화: 시장 성격별 그룹(상승장/추세장/조정장/출렁이는 장/보통 장에 강한 유형) 안에 각 유형 1위만 표시(꾸준함=worst_ret 순), 행=제목+최악·검증수익, 펼치면 구간별 검증·MDD·승률·PF·알파·파라미터·🌐. **비교군 추가/제거·이 튜닝 적용 버튼 제거**(읽기전용) — `variants.sync_label_tops()`는 **수동 전용**(2026-06-10 자동 동기화 끔 — sweep은 결과 파일만 갱신, 비교군 변경은 관리자님 명시 요청 시 `python3 -m sim variants sync` 또는 코디가 수동 등록)(같은 params 변이는 계좌 유지·신규 추가·탈락 삭제, 메인 조합 제외). 메인 변경은 코디/CLI로만. + **'↻ 다시 계산'**〔백그라운드 sweep — 결과 파일만 갱신, 비교군은 불변〕〕/**비교**〔병렬 변이 실시간 성적, 변이별 성적초기화/삭제 + **전체 삭제(메인 제외)** `/variants_clear_all`→`vmod.clear()`. 펼침 body 첫 행=**유형**(긴 문장형+ⓘ→🌐 시장적합 토스트, 2026-06-10 하단 🌐 버튼 대체)→둘째 행 **약세장대처**(`돌파 (기본)` 모드명만+ⓘ 토스트). bt 등록근거 행은 **모의평가**로 개명(1행 `N위/36`+2행 최악구간·검증, 모드 3형제는 `_btkey`로 같은 bt행 공유). 행 표기(2026-06-10): **순위 배지 `N위`가 앞, 고유번호는 `#N`**(`_vid_disp` — 'v37'→'#37' 표시 전용, 내부 id·URL은 raw. 드롭다운·👁토스트·사유 라벨 동일 표기)〕). 🌐는 `data-detail` 통합 토스트 핸들러로 헤더 상태점(`#health`)과 공유. sticky 헤더, 당겨서 새로고침. **새로고침 상태 보존**(2026-06-10): 60s 자동새로고침이 전체 리로드지만 펼침(details open)·스크롤을 sessionStorage(`simOpen`/`simScroll`, key=패널id+summary텍스트)로 저장·복원해 보던 화면 유지. 입력 중·토스트 열림엔 새로고침 멈춤. `do_POST`: `/save_params`·`/run_sweep`·`/variants_{add,reset_one,remove,clear_all}`·`/watch_{add,remove}`·`/fav_set`(마킹 아이콘 지정/이동/해제 → `state/sim/favorites.json`) (그 외 읽기전용) - Portfolio data: `memory/portfolio.json` (v2 스키마 참고용 스냅샷, `accounts.{일반,ISA}.positions`), `state/portfolio_daily_snapshot.json`, `state/kiwoom_tokens/{일반,ISA}.json`, `state/stock_codes.json`(키움 ka10099 lazy 캐시), `state/watchlist_alerts.json`(알림 중복 방지), `state/ipo_calendar_sync.json`, `state/behive_*.json`, `state/fnguide_cache/{code}.json`(FnGuide 펀더멘털 12h), `state/wisereport_cache/{code}.json`·`{code}_reports.json`(컨센서스 12h·리포트 6h), `state/stock_reports//`(분석 보고서 HTML) ## Scheduled Jobs @@ -138,8 +138,8 @@ OpenClaw 자동화는 두 갈래로 동작한다 (모두 Asia/Seoul): - **stock.ipo-calendar-sync** — 매주 금요일 17:00 — IPO 청약·상장 일정 캘린더 등록 - **stock.holiday-sync** — 매주 일요일 03:00 — investing.com KRX 휴장일 → `state/market_holidays.json` (behive_web 자동갱신 토글이 참조) - **stock.send-balance** — 매월 1일 04:30 — 본인 잔액 → 골디 inbox (`securities_balance`) -- **stock.trade-journal** — 평일 21:00 — EOD 데이터 누적 묶음. ProgramArguments는 `/bin/sh -c` wrapper로 두 명령 sequential 실행: ①`trade_journal.py collect` (ka10170 4계좌 → `state/trade_journal.jsonl`) ②`market_indicators_sync.py collect` (네이버 m.stock KOSPI/KOSDAQ ADR·투자자별 매매 → `state/market_indicators_history.jsonl`). 둘 중 하나 실패해도 다른 건 시도. 로그는 `logs/stock-trade-journal.{log,err.log}` 한 곳에 합쳐짐. -- **stock.sim-scan** — 평일 09:00–15:30 **매 5분** (`StartCalendarInterval` 79엔트리, 엔진이 장외/휴장 self-skip) — **자동매매 시뮬(페이퍼)** 1회 스캔. (2026-06-09 15분→5분 단축) ⚠️ 2026-06-09 `StartInterval` 900s → `StartCalendarInterval` 전환: GUI LaunchAgent의 StartInterval은 세션 idle/디스플레이 sleep 시 timer coalescing으로 발화가 보류(`pended`)돼 장중에 안 도는 문제 발견 (calendar 잡인 git-autopush는 새벽 02:00에도 정상 발화하는 게 비교 증거). 월시간 기준이라 idle 지연 없음. `python3 -m sim scan` (cwd `agents/stock/workspace`). 가상자본 1천만, 규칙 엔진(LLM 미경유). universe=누적 관찰목록(워치+관심+보유 자동편입·수동삭제), 방향=애널 약한게이트+가점, 타이밍=수급·기술. 결과 `state/sim/`. 실주문 `orders/` 불가침. 상세는 아래 sim 모듈 섹션·레이 MEMORY.md +- **stock.trade-journal** — 평일 21:00 — EOD 데이터 누적 묶음. ProgramArguments는 `/bin/sh -c` wrapper로 세 명령 sequential 실행: ①`trade_journal.py collect` (ka10170 4계좌 → `state/trade_journal.jsonl`) ②`market_indicators_sync.py collect` (네이버 m.stock KOSPI/KOSDAQ ADR·투자자별 매매 → `state/market_indicators_history.jsonl`) ③`cd workspace && python3 -m sim.backfill_flow --pages 1 --force` (수급 DB `flow_history.sqlite` 일일 증분 — 2026-06-10 추가, 1회성 백필 후 stale해져 백테스트 수급게이트가 중립으로 비활성되던 문제 해소. INSERT OR REPLACE idempotent, 행수기준 skip이라 `--force` 필수). 하나 실패해도 나머지 시도. 로그는 `logs/stock-trade-journal.{log,err.log}` 한 곳에 합쳐짐. +- **stock.sim-scan** — 평일 09:00–15:30 **매 5분** (`StartCalendarInterval` 79엔트리, 엔진이 장외/휴장 self-skip) — **자동매매 시뮬(페이퍼)** 1회 스캔. (2026-06-09 15분→5분 단축) ⚠️ 2026-06-09 `StartInterval` 900s → `StartCalendarInterval` 전환: GUI LaunchAgent의 StartInterval은 세션 idle/디스플레이 sleep 시 timer coalescing으로 발화가 보류(`pended`)돼 장중에 안 도는 문제 발견 (calendar 잡인 git-autopush는 새벽 02:00에도 정상 발화하는 게 비교 증거). 월시간 기준이라 idle 지연 없음. `python3 -m sim scan` (cwd `agents/stock/workspace`). 가상자본 1억, 규칙 엔진(LLM 미경유). universe=누적 관찰목록(워치+관심+보유 자동편입·수동삭제), 방향=애널 약한게이트+가점, 타이밍=수급·기술. 결과 `state/sim/`. 실주문 `orders/` 불가침. 상세는 아래 sim 모듈 섹션·레이 MEMORY.md - **stock.sim-web** — 상시 (`KeepAlive`) — 시뮬 대시보드 **별도 서버 포트 18792** (behive-web 18790과 분리). `python3 -m sim.sim_web serve`. sim state 읽기전용 렌더. `sim.hyowons.net` → mac:18792 (Synology reverse proxy, **수동 등록 대기**). ⚠️ 18791은 node 점유라 18792 사용 - **git-autopush** — 매일 02:00 — `scripts/git_autopush.sh`: 변경 있으면 `git add -A` + 자동 커밋 + `git push origin main`. 변경 없으면 skip(빈 커밋 X). 인증은 HTTPS + `credential.helper=store`(`~/.git-credentials`), GUI 키체인 불필요. 로그 `logs/git-autopush.{log,err.log}`. 워크스페이스 버전관리 백업용 (시크릿·sqlite는 .gitignore 제외라 별도 백업 필요) - **budget.whooing-sync** — 매시 0/15/30/45분 — iMessage 결제문자 → 후잉. 매 사이클 끝에 `gahee_reminder.run` 추가 호출 (매월 25일 10:00 KST 이후 가희님께 iMessage 리마인더 1회 발신 → 답신 폴링 → 텍스트면 후잉 `가희주머니` 차액 자동분개, 이미지면 골디 텔레그램 알림). 별도 plist 없음 diff --git a/agents/stock/workspace/MEMORY.md b/agents/stock/workspace/MEMORY.md index 0d45c11..99b4471 100644 --- a/agents/stock/workspace/MEMORY.md +++ b/agents/stock/workspace/MEMORY.md @@ -50,9 +50,9 @@ | `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 필요 | | `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 `/`query --from --to --account --code` | -| `sim-scan` | 평일 09:00–15: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:00–15: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 고정이라 장중 안 바뀌던 것 수정) | -| (트리거 없음) | 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//` — 스윕 상위 튜닝을 각자 가상계좌로 실시간 동시 운영, 같은 라이브 시세에 다른 파라미터(애널 게이트까지 실반영), `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//` — 스윕 상위 튜닝을 각자 가상계좌로 실시간 동시 운영, 같은 라이브 시세에 다른 파라미터(애널 게이트까지 실반영), `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` 등. diff --git a/agents/stock/workspace/sim/__main__.py b/agents/stock/workspace/sim/__main__.py index 43661cd..bfd63cd 100644 --- a/agents/stock/workspace/sim/__main__.py +++ b/agents/stock/workspace/sim/__main__.py @@ -3,7 +3,8 @@ python3 -m sim scan [--force] # 1회 스캔 (launchd/cron 진입점) python3 -m sim status # 가상계좌 요약 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 @@ -107,6 +108,11 @@ def _cmd_variants(argv): print(f'변이 {len(vs)}개 등록:') for v in vs: 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': variants.reset_all() print('변이 계좌 초기화 (정의 유지)') diff --git a/agents/stock/workspace/sim/backtest.py b/agents/stock/workspace/sim/backtest.py index 25bb93e..9bb9a94 100644 --- a/agents/stock/workspace/sim/backtest.py +++ b/agents/stock/workspace/sim/backtest.py @@ -64,6 +64,24 @@ def load_flow(code: str) -> dict | 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: """최근 FLOW_DAYS 일 외국인·기관 누적. flow 없으면 중립(둘 다 +1 → 수급 통과).""" if flow is None: @@ -82,12 +100,12 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to: hist = {} 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: hist[code] = h if not hist: 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, 전체 거래일 축 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 gross_win = gross_loss = 0.0 peak_eq = capital + exited: dict[str, str] = {} # code → 전량청산일 (당일 재진입 금지) max_dd = 0.0 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): return int(value // (fill * (1 + comm))) + slip = getattr(config, 'BT_SLIPPAGE', 0.002) + pending: list[dict] = [] # 전일 종가 신호 → 오늘 시가 체결 주문 (look-ahead 제거) + for day in all_dates: 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()): h = hist.get(code) 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']) act = dec['action'] if act in ('sell', 'scale_out'): - fill = ind['price'] - 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 + pending.append({'kind': act, 'code': code, 'frac': dec.get('sell_frac', 1.0)}) elif act == 'add': - fill = ind['price'] - 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'], ind['atr'], ind['recent_low']) - pos['stop'] = max(pos['stop'], nstop) - pos['target'] = ntarget + pending.append({'kind': 'add', 'code': code, + 'atr': ind['atr'], 'recent_low': ind['recent_low']}) - # ---- 신규 매수 판단 (1차 트랜치) ---- + # ---- 2. 신규 매수 판단 (당일 종가) → 내일 시가 주문 큐잉 ---- for code, h in hist.items(): if code in positions or len(positions) >= config.MAX_POSITIONS: continue + if exited.get(code) == day: # 당일 청산 종목 재매수 금지 + continue i = idx_map[code].get(day) if i is None: 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) dec = signals.evaluate_candidate(code, code, [], ind, flow, None, True) if dec['action'] == 'buy': - fill = dec['buy_price'] - stop, target = signals.compute_stop_target(fill, ind['atr'], 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} + pending.append({'kind': 'buy', 'code': code, + 'atr': ind['atr'], 'recent_low': ind['recent_low']}) eq = equity(day_prices) if eq > peak_eq: diff --git a/agents/stock/workspace/sim/config.py b/agents/stock/workspace/sim/config.py index 08931ed..45bcabf 100644 --- a/agents/stock/workspace/sim/config.py +++ b/agents/stock/workspace/sim/config.py @@ -22,9 +22,10 @@ LAST_SCAN_PATH = STATE_DIR / 'last_scan.json' OWNER_ACCOUNT_LABELS = ['일반', 'ISA'] # ---- 자본·체결·비용 ---- -INITIAL_CAPITAL = 10_000_000 # 가상 자본 +INITIAL_CAPITAL = 100_000_000 # 가상 자본 (기초 예수금 1억) COMMISSION_RATE = 0.00015 # 매매 수수료 (편도, 키움 ~0.015%) SELL_TAX_RATE = 0.0018 # 매도 증권거래세 (~0.18%) +BT_SLIPPAGE = 0.002 # 백테스트 슬리피지 (편도 0.2% — 매수는 비싸게, 매도는 싸게. 라이브는 호가 체결이라 미적용) # ---- 포지션 사이징 ---- MAX_POSITIONS = 8 # 동시 보유 최대 종목수 diff --git a/agents/stock/workspace/sim/data.py b/agents/stock/workspace/sim/data.py index e5966f9..96a83ff 100644 --- a/agents/stock/workspace/sim/data.py +++ b/agents/stock/workspace/sim/data.py @@ -88,14 +88,19 @@ def _flow_cache_path(code: str): 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) now = time.time() if path.exists(): try: cached = json.loads(path.read_text()) 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: pass try: @@ -105,6 +110,7 @@ def investor_flow(code: str, days: int = config.FLOW_DAYS) -> list[dict]: return [] FLOW_CACHE_DIR.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps({'ts': now, 'rows': rows}, ensure_ascii=False)) + _FLOW_MEMO_SCAN[mk] = rows[:days] return rows[:days] @@ -116,11 +122,15 @@ def flow_net(rows: list[dict]) -> dict: 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') and _cache_fresh(WISEREPORT_CACHE_DIR / f'{code}.json')) try: - return _analyst_query(code, price) + out = _analyst_query(code, price) + _ANL_MEMO_SCAN[code] = out + return out finally: if not fresh: 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: - """체결가용 호가 (매도1/매수1). 실패 시 None.""" + """체결가용 호가 (매도1/매수1). 스캔 내 종목당 1회 조회 후 공유. 실패 시 None.""" + if code in _BOOK_MEMO: + return _BOOK_MEMO[code] try: - return kc.get_quote_book(code) + book = kc.get_quote_book(code) except Exception as e: sys.stderr.write(f'[data] quote_book {code} 실패: {e}\n') - return None + book = None + _BOOK_MEMO[code] = book + return book diff --git a/agents/stock/workspace/sim/engine.py b/agents/stock/workspace/sim/engine.py index 362da67..79e6f05 100644 --- a/agents/stock/workspace/sim/engine.py +++ b/agents/stock/workspace/sim/engine.py @@ -41,6 +41,29 @@ def _index_trend_ok() -> dict: 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): """지수 N거래일 수익률(%) — 상대강도 비교용. 데이터 부족 시 None.""" s = sorted(benchmark.series(mk).items()) @@ -192,11 +215,24 @@ def _fill_sell_price(code: str, fallback: int) -> int: 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): """파라미터 무관한 라이브 데이터 1회 수집 (메인·변이 공유). 종목·시세·시장매핑. extra_codes: universe에 없지만 청산 판단을 위해 포함해야 할 보유 코드(수동 삭제된 보유분). """ + data.reset_book_memo() # 호가 공유 캐시 리셋 — 이번 스캔의 체결가를 전 선수가 공유 + _SCAN_CANDLES.clear() + _IND_MEMO.clear() uni = universe.build_universe() codes = [e['code'] for e in uni] seen = set(codes) @@ -210,10 +246,13 @@ def _gather(extra_codes=None): return uni, quotes, cmkt -def _run_scan(pf, uni, quotes, cmkt, now) -> dict: - """주어진 포트폴리오에 대해 현재 config(파라미터) 기준 스캔·체결. 스냅샷 반환(파일 기록 X).""" +def _run_scan(pf, uni, quotes, cmkt, now, execute: bool = True) -> dict: + """주어진 포트폴리오에 대해 현재 config(파라미터) 기준 스캔·체결. 스냅샷 반환(파일 기록 X). + + execute=False 면 판단(decisions)만 만들고 체결·계좌 저장을 모두 생략 — 시각 전환 뷰 등 read-only 용.""" mkt_ok = market_ok_map() # MARKET_BREADTH_MIN 등 파라미터 의존 → 변이마다 재계산 idx_ret = {mk: _index_return(mk, config.REL_STRENGTH_DAYS) for mk in ('KOSPI', 'KOSDAQ')} + vfrac = _session_elapsed_frac(now) # 장중 부분 거래량 → 하루치 환산 분모 decisions: list[dict] = [] buys: 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에서 빠진)은 이름 보완 name = pf.positions[code].get('name', code) quote = quotes.get(code) - hist = data.candles(code, 80) + hist = _SCAN_CANDLES.get(code) + if hist is None: + hist = data.candles(code, 80) + _SCAN_CANDLES[code] = hist 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: decisions.append({'code': code, 'name': name, 'sources': sources, '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 ('entry_price', 'qty', 'stop', 'target', 'trailing_on', 'entry_at')}) act = dec['action'] - if act in ('sell', 'scale_out'): + if execute and act in ('sell', 'scale_out'): fill = _fill_sell_price(code, cur_price) rec = pf.sell(code, fill, dec['reason'], signals=dec.get('checks'), frac=dec.get('sell_frac', 1.0)) if rec: sells.append(rec) dec['fill_price'] = fill - elif act == 'add': + elif execute and act == 'add': fill = _fill_buy_price(code, cur_price) rec = pf.add_tranche(code, fill, dec['reason'], signals=dec.get('checks')) if rec: @@ -292,6 +339,7 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict: anl = data.analyst(code, cur_price) market = cmkt.get(code, '') m_ok = mkt_ok.get(market, True) + ind['vol_time_frac'] = vfrac # 상대강도(라이브 전용): 종목 N일 수익률 − 시장지수 N일 수익률 n = config.REL_STRENGTH_DAYS 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) 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']) 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'], @@ -313,8 +365,9 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict: dec['reason'] = '매수 신호 — 현금/한도 부족' decisions.append(dec) - pf.update_equity_metrics() - pf.save() + if execute: + pf.update_equity_metrics() + pf.save() order = {'SELL': 0, 'ADD': 1, 'BUY': 2, 'HOLD': 3, 'WAIT': 4, 'SKIP': 5, 'NODATA': 6} decisions.sort(key=lambda d: (order.get(d.get('state'), 9), d.get('name', ''))) @@ -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): """가상계좌 시작일 대비 기준지수(KOSPI/KOSDAQ) 수익·알파를 스냅샷에 부착.""" from . import benchmark @@ -374,9 +451,10 @@ def scan_all(force: bool = False) -> dict: held_union |= set(vpf.positions) uni, quotes, cmkt = _gather(held_union) - # 메인 (현재 params.json 튜닝) + # 기준 판단 (params.json) — 2026-06-10 기준전략 폐지: 매매 없이 판단만(last_scan = 화면·상태점용). + # 실제 매매는 전부 변이들이 한다 (메인 계좌 동결). 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회 (변이는 캐시 재사용) config.STATE_DIR.mkdir(parents=True, exist_ok=True) 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() return benchmark.compare(s.get('total_return_pct'), benchmark.norm_date(p.created_at), today) - compare = [{'id': 'main', 'name': '메인 (현재 튜닝)', 'params': main_params, - 'summary': pf.summary(), 'benchmark': _alpha(pf)}] + compare = [] # 기준전략 폐지 — 변이들만 비교 (메인 계좌는 매매 안 함) for v in vdefs: backtest.apply_params(v.get('params') or {}) 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']), '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) variants_mod.COMPARE_PATH.write_text(json.dumps({ 'scanned_at': now.isoformat(), 'session': is_market_session(now), 'variants': compare, }, ensure_ascii=False, indent=2)) + _record_equity_history(compare, now) 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__': force = '--force' in sys.argv snap = scan_all(force=force) diff --git a/agents/stock/workspace/sim/portfolio.py b/agents/stock/workspace/sim/portfolio.py index 59968e1..0339d90 100644 --- a/agents/stock/workspace/sim/portfolio.py +++ b/agents/stock/workspace/sim/portfolio.py @@ -9,7 +9,8 @@ from __future__ import annotations import json 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: @@ -28,6 +29,7 @@ class Portfolio: self.max_drawdown: float = data.get('max_drawdown', 0.0) self.created_at: str = data.get('created_at', _now_iso()) 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) self._pf_path = config.PORTFOLIO_PATH self._trades_path = config.TRADES_PATH @@ -55,7 +57,7 @@ class Portfolio: 'cash': self.cash, 'initial': self.initial, 'positions': self.positions, 'realized_pnl': self.realized_pnl, 'closed_trades': self.closed_trades, '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, }, ensure_ascii=False, indent=2)) @@ -83,7 +85,7 @@ class Portfolio: """신규 진입(1차 트랜치). 목표비중을 ENTRY_TRANCHES 로 나눈 만큼만 매수. 체결 dict 또는 None.""" if code in self.positions or not self.can_open(): 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) qty = self._qty_for(fill_price, tranche_val) if qty < 1: @@ -169,8 +171,13 @@ class Portfolio: if pnl > 0: self.wins += 1 del self.positions[code] + self.last_exit[code] = _now_iso()[:10] # 당일 재진입 금지 기준 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): if code in self.positions: self.positions[code].update(upd) diff --git a/agents/stock/workspace/sim/signals.py b/agents/stock/workspace/sim/signals.py index 083ec36..ca0baa9 100644 --- a/agents/stock/workspace/sim/signals.py +++ b/agents/stock/workspace/sim/signals.py @@ -171,28 +171,34 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di return {**base, 'state': 'WAIT', 'reason': '수급 미충족'} # 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') + 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) if t_today is not None and t_avg: 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: return {**base, 'state': 'WAIT', 'reason': f'과열(회전율 {t_today:.1f}% ≥ 평균×{config.TURNOVER_OVERHEAT_MULT:.0f}) — 신규매수 보류'} # 5. 매수 경로 — 돌파 우선 (전고점 + 거래량 급증 + 회전율 급증 모두 충족) 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) breakout = price_break and vol_mult_ok and turn_ok checks.append({'label': '돌파(전고점)', 'ok': price_break, 'detail': f"고점 {ind['recent_high']:,.0f}" if ind['recent_high'] else '—'}) 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: 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: checks.append({'label': '회전율', 'ok': None, 'detail': '데이터 없음(통과)'}) diff --git a/agents/stock/workspace/sim/sim_web.py b/agents/stock/workspace/sim/sim_web.py index f63a602..faf5d5a 100644 --- a/agents/stock/workspace/sim/sim_web.py +++ b/agents/stock/workspace/sim/sim_web.py @@ -63,13 +63,15 @@ PTR_HTML = """ })(); """ -# 탭 복원(?t=) + 저장 배너(?saved=1) + 자동새로고침(튜닝 탭/입력 중엔 멈춤, 현재 탭 유지) +# 탭 복원(?t=) + 저장 배너 + 자동새로고침(60s) — 펼침(details)·스크롤을 sessionStorage로 보존해 +# 전체 리로드여도 보던 화면이 유지되게 한다 (자동·당겨서·수동 새로고침 공통). REFRESH_JS = """ """ @@ -227,6 +255,12 @@ def _pkey(params) -> str: return json.dumps({k: float(v) for k, v in (params or {}).items()}, sort_keys=True) +def _vid_disp(vid) -> str: + """고유번호 표시용 — 'v37' → '#37' (내부 id·URL은 raw 유지).""" + v = str(vid or '') + return '#' + v[1:] if v[:1] == 'v' and v[1:].isdigit() else v + + _PSHORT = {'SMA_LONG': 'SMA', 'RR_RATIO': 'RR', 'STOP_ATR_MULT': 'S', 'PULLBACK_ATR_MULT': 'P', 'VOLUME_BREAKOUT_MULT': 'V', 'TURNOVER_BREAKOUT_MULT': 'TB', 'TURNOVER_OVERHEAT_MULT': 'OH', 'RSI_OVERBOUGHT': 'RSI↑', 'RSI_OVERSOLD': 'RSI↓', 'MAX_HOLD_DAYS': 'HOLD', @@ -272,17 +306,50 @@ def _chk_key(label) -> str: def _chips(checks) -> str: - """신호 체크리스트 → 칩 HTML (판단근거 표시 공용). 각 항목에 ⓘ 버튼 — 클릭 시 설명 토스트.""" - out = [] - for c in (checks or []): + """신호 체크리스트 → 칩 HTML (판단근거 표시 공용). 각 항목에 ⓘ 버튼. + + 진입경로 칩(돌파 3종 / 눌림목)은 'OR 그룹' 박스로 묶어 표시 — 둘 중 하나만 충족하면 + 매수라는 구조가 화면에서 보이게. 그룹 라벨의 ✓/✗는 그 경로 전체 충족 여부.""" + checks = checks or [] + _BRK = ('돌파(전고점)', '거래량 급증', '회전율 급증', '회전율') + _PUL = ('눌림목 도달',) + + def chip(c): k = _chk_key(c.get('label')) info = f'' if k else '' - out.append( + return ( f'' f'{_check_mark(c.get("ok"))} {_esc(c.get("label"))}' f'{_esc(c.get("detail"))}{info}' ) - return ''.join(out) + + base = [c for c in checks if c.get('label') not in _BRK + _PUL] + brk = [c for c in checks if c.get('label') in _BRK] + pul = [c for c in checks if c.get('label') in _PUL] + out = ''.join(chip(c) for c in base) + if brk or pul: + hint = ('매수 진입은 두 경로 중 하나만 충족하면 돼요.\n' + '① 돌파: 전고점 돌파 + 거래량·회전율 급증이 모두 충족되면 즉시 매수\n' + '② 눌림목: 추세는 살아있는데 적정가까지 내려오면 매수\n' + '각 줄 앞의 ✓/✗는 그 경로 전체의 충족 여부예요. 위쪽 칩들은 필수 조건.') + + def path_line(title, items): + oks = [c.get('ok') for c in items] + ok = all(v in (True, None) for v in oks) and any(v is True for v in oks) + mark, color = ('✓', '#22c55e') if ok else ('✗', '#ef4444') + return (f'
' + f'{mark} {title}' + f'{"".join(chip(c) for c in items)}
') + + lines = '' + if brk: + lines += path_line('① 돌파', brk) + if pul: + lines += path_line('② 눌림목', pul) + out += (f'
' + f'
' + f'진입경로 — 둘 중 하나만 충족하면 매수 ⓘ
{lines}
') + return out # 전략 튜닝 탭 파라미터 설명 — 같은 ⓘ 토스트 핸들러 재사용 (data-k = 파라미터 key) @@ -357,6 +424,28 @@ def _kpi(label, value, sub=''): return f'
{_esc(label)}
{_esc(value)}
{sub_h}
' +FAV_PATH = config.STATE_DIR / 'favorites.json' + +# 관심 전략 마킹 아이콘 — 하나당 전략 1개만 (다른 전략에 쓰면 옮겨짐). 순서 = 우선순위(관심종목 탭 기본 시각). +FAV_ICONS = ['⭐', '🔥', '💎', '🎯', '👀', '🚀'] + + +def _load_favs() -> dict: + """{아이콘: vid} — 아이콘 하나당 전략 1개. 구버전(list)은 순서대로 마이그레이션.""" + try: + d = json.loads(FAV_PATH.read_text()) + except Exception: + return {} + if isinstance(d, list): + return {ic: v for ic, v in zip(FAV_ICONS, d)} + return {ic: v for ic, v in d.items() if ic in FAV_ICONS} + + +def _save_favs(favs: dict): + config.STATE_DIR.mkdir(parents=True, exist_ok=True) + FAV_PATH.write_text(json.dumps(favs, ensure_ascii=False)) + + def _kvline(k, v): """한 줄 항목(라벨 좌 / 값 우). v는 신뢰 HTML(색상·muted 등 허용).""" return f'
{k}{v}
' @@ -602,54 +691,75 @@ def _market_fit(p: dict) -> tuple[str, str]: return '🌐 어떤 시장에 강한가', body -def _market_fit_summary(p: dict) -> str: - """조합 성격을 한 줄로 — 행 제목용. 예: '상승장에 강한 빠른·공격 단타형'.""" +def _fit_tokens(p: dict) -> tuple[str, list[str], str]: + """조합 성격 토큰 (market, 수식어들, 스타일) — 긴 문장/짧은 나열 공용.""" rr = p.get('RR_RATIO', 2.0); risk = p.get('RISK_PER_TRADE_PCT', 0.01) sma = p.get('SMA_LONG', 20); stop = p.get('STOP_ATR_MULT', 2.0) vol = p.get('VOLUME_BREAKOUT_MULT', 1.5); pull = p.get('PULLBACK_ATR_MULT', 1.0) - style = '추세추종형' if rr >= 2.5 else '단타형' if rr <= 1.5 else '균형형' + style = '추세추종' if rr >= 2.5 else '단타' if rr <= 1.5 else '균형' adj = [] if sma <= 15: adj.append('빠른') elif sma >= 40: adj.append('느린') if risk >= 0.02: adj.append('공격') elif risk <= 0.005: adj.append('보수') - style_full = ('·'.join(adj) + ' ' if adj else '') + style if risk >= 0.02 or vol <= 1.3: market = '상승장' elif rr >= 2.5: market = '추세장' elif pull >= 1.5: market = '조정장' elif rr <= 1.5 or stop <= 1.5: market = '출렁이는 장' elif stop >= 2.5: market = '변동성 장' else: market = '보통 장' - bear = p.get('BEAR_ENTRY_MODE', 1) - suffix = ' · 약세현금화' if bear == 0 else ' · 약세눌림허용' if bear == 2 else '' - return f'{market}에 강한 {style_full}{suffix}' + return market, adj, style -def _strategy_type(p: dict) -> str: - """조합을 매매 스타일(손익비)로 대분류 — 추세추종형/균형형/단타형. (리스크 성향은 행 제목에 표시)""" - rr = p.get('RR_RATIO', 2.0) - if rr >= 2.5: - return 'trend' - if rr <= 1.5: - return 'scalp' - return 'swing' +def _market_fit_summary(p: dict) -> str: + """조합 성격을 한 줄로 — 백테스트 탭 행 제목·유형 라벨용. 예: '상승장에 강한 빠른·공격 단타형'.""" + market, adj, style = _fit_tokens(p) + style_full = ('·'.join(adj) + ' ' if adj else '') + style + '형' + return f'{market}에 강한 {style_full}' -_BT_TYPES = [ - ('trend', '📈 추세추종형', '손익비 높게(RR 2.5↑) — 큰 추세를 끝까지 먹는 형, 강한 상승장 특화'), - ('swing', '⚖️ 균형형', '손익비 중간(RR 2.0) — 적당히 먹고 나오는 무난한 중도'), - ('scalp', '⚡ 단타·회전형', '손익비 낮게(RR 1.5↓) — 짧게 먹고 빠르게 회전, 출렁이는 장 특화'), -] -_BT_TOPN = 8 +def _market_fit_words(p: dict) -> str: + """짧은 단어 나열 — 비교탭 행 제목·드롭다운용. 예: '상승장·빠른·공격·추세추종'.""" + market, adj, style = _fit_tokens(p) + m = {'출렁이는 장': '출렁장', '변동성 장': '변동장', '보통 장': '보통장'}.get(market, market) + return '·'.join([m] + adj + [style]) + + +# 약세장 모드 단어 칩 — 비교군 행·드롭다운에서 한눈에 구분 (긴 접미사 대신). +_BEAR_WORD = {0: '회피', 1: '돌파', 2: '적극'} +_BEAR_NAME = {0: '회피 (현금화)', 1: '돌파 (기본)', 2: '적극 (눌림허용)'} +_BEAR_DESC = { + 0: '이 전략은 시장이 약세 국면(혼조·약세·급락)이면 아무것도 안 사고 현금을 지켜요.', + 1: '이 전략은 시장이 약세 국면이어도 강하게 치고 오르는 돌파 종목만 사요. 바닥잡기(눌림목 매수)는 안 해요.', + 2: '이 전략은 시장이 약세 국면이어도 눌림목 저가매수까지 해요. 셋 중 가장 적극적이에요.', +} +_BEAR_LEGEND = '\n\n회피=약세장 매수 안 함 · 돌파=돌파만 매수(기본) · 적극=눌림목 저가매수도 함' + + +def _bear_mode(p) -> int: + """변이 params의 약세장 모드 — 없으면 기본 1(돌파만).""" + try: + return int((p or {}).get('BEAR_ENTRY_MODE', 1)) + except Exception: + return 1 + + +def _bear_chip_html(p) -> str: + """약세모드 단어 칩 (클릭 시 설명 토스트).""" + m = _bear_mode(p) + return (f'{_BEAR_WORD[m]}') def _backtest_panel() -> str: - """백테스트 스윕 순위표 — 검증기간(out-of-sample) 성적순. 행마다 '이 튜닝 적용'.""" + """백테스트 결과 — 유형별(세부 라벨) 대표(1위) 조합 요약. 비교군은 이 대표들로 자동 구성되므로 + 수동 추가/제거 없이 읽기 전용 요약만 보여준다.""" res = _load(config.STATE_DIR / 'backtest_results.json', None) if not res or not res.get('results'): return ('
스윕 미실행 — 터미널에서 python3 -m sim sweep 실행 후 표시됩니다.
') sp = res.get('split', {}) - head = f'
검증 {sp.get("test","?")} · {res.get("count")}개 조합
' + head = (f'
검증 {sp.get("test","?")} · {res.get("count")}개 조합 · ' + f'순위=3분할 최악구간 수익(꾸준함) · 다음날 시가 체결+슬리피지 0.2%
') st = _sweep_status() if st.get('state') == 'running': ctrl = ('
' @@ -658,32 +768,24 @@ def _backtest_panel() -> str: else: err = ('
⚠ 직전 스윕 실패 — 로그 확인 필요
' if st.get('state') == 'error' else '') - ctrl = (err + '
' - '
') - from . import variants as vmod - registered = {_pkey(v['params']): v['id'] for v in vmod.load_variants()} + ctrl = (err + '
' + '
') main_p = config.load_params() main_pkey = _pkey({k: main_p[k] for k in res.get('swept_params', []) if k in main_p}) min_tr = res.get('min_trades', 0) or 0 + def _sk(r): + te = r['test'] + enough = (te.get('trades', 0) or 0) >= min_tr + worst = r.get('worst_ret') + return (1 if enough else 0, + worst if worst is not None else -999, + te.get('total_return_pct', -999) or -999) + def _row_html(r, rank_label): p, te, tr = r['params'], r['test'], r['train'] - hidden = ''.join(f'' for k, v in p.items()) - pk = _pkey(p) - vid = registered.get(pk) - is_main = pk == main_pkey + is_main = _pkey(p) == main_pkey main_badge = ' ' if is_main else '' - cmp_badge = ' 🔖' if vid else '' - if vid: - cmp_btn = (f'
' - f'' - f'' - f'
') - elif is_main: - cmp_btn = '
메인 — 비교 탭에 항상 포함
' - else: - cmp_btn = (f'
{hidden}' - f'
') teret = te.get('total_return_pct', 0) trret = tr.get('total_return_pct', 0) tc = '#22c55e' if teret >= 0 else '#ef4444' @@ -692,60 +794,95 @@ def _backtest_panel() -> str: mdd, wr, pf = te.get('mdd_pct'), te.get('win_rate_pct'), te.get('profit_factor') mf_t, mf_b = _market_fit(p) mf_sum = _market_fit_summary(p) + wins_row = '' + if r.get('windows'): + parts = [] + for wnd in r['windows']: + wret = wnd.get('ret') + wc = '#22c55e' if (wret or 0) >= 0 else '#ef4444' + parts.append(f'{wret:+g}%' if wret is not None else '—') + wins_row = (f'
구간별 검증(3분할)' + f'{" / ".join(parts)}
') + worst = r.get('worst_ret') + worst_h = f'최악 {worst:+g}% · ' if worst is not None else '' return ( f'
{rank_label} ' - f'{_esc(mf_sum)}{main_badge}{cmp_badge}' - f'검증 {teret:+g}%' + f'{_esc(mf_sum)}{main_badge}' + f'{worst_h}검증 {teret:+g}%' f'
' f'
검증 수익 / 학습 수익' f'{teret:+g}% / {trret:+g}%{of_tag}
' f'{_bt_alpha_row(te)}' + f'{wins_row}' f'
검증 MDD{mdd}%
' f'
검증 거래수 / 승률{te.get("trades")}건 · {wr}%
' f'
검증 손익비(PF){pf}
' f'
파라미터{_esc(_plabel(p))}
' f'' - f'
' - f'
{hidden}' - f'
' - f'{cmp_btn}' - f'
' + f'
' ) - # 중복(동일 params) 제거 → 타입 분류 → 타입별 검증수익 상위 N + # 중복(동일 params) 제거 → 세부 유형(라벨)별 대표(1위)만 추려 요약 uniq = {} for r in res['results']: uniq.setdefault(_pkey(r['params']), r) - - def _sk(r): - te = r['test'] - enough = (te.get('trades', 0) or 0) >= min_tr - return (1 if enough else 0, te.get('total_return_pct', -999) or -999) - - grouped = {k: [] for k, _, _ in _BT_TYPES} + by_label = {} for r in uniq.values(): - grouped.setdefault(_strategy_type(r['params']), []).append(r) + lbl = _market_fit_summary(r['params']) + if lbl not in by_label or _sk(r) > _sk(by_label[lbl]): + by_label[lbl] = r - # 비교군 등록 조합 — 성적순위와 무관하게 항상 맨 위에 표시 - reg_items = sorted([r for r in uniq.values() if _pkey(r['params']) in registered], - key=_sk, reverse=True) - cmp_section = '' - if reg_items: - cmp_section = ('

🔖 비교군 등록 조합' - f'현재 병렬 비교 중인 {len(reg_items)}개 (성적순위 무관 항상 표시)

') - for j, r in enumerate(reg_items): - cmp_section += _row_html(r, f'#{j + 1}') + # 시장 성격별 그룹 (각 그룹 안은 꾸준함 순) + by_market = {} + for lbl, r in by_label.items(): + by_market.setdefault(lbl.split('에 강한')[0], []).append(r) - sections = (f'
베팅 성향 타입별 검증수익 상위 {_BT_TOPN}개씩 · ' - f'비교군 추가는 각 행에서 (총 고유 {len(uniq)}조합)
') - for tk, tlabel, tdesc in _BT_TYPES: - grp = sorted(grouped.get(tk, []), key=_sk, reverse=True)[:_BT_TOPN] - if not grp: - continue - sections += (f'

{tlabel}{tdesc}

') - for j, r in enumerate(grp): - sections += _row_html(r, f'#{j + 1}') - return head + ctrl + f'
{cmp_section}{sections}
' + note = (f'
유형별({len(by_label)}종) 대표 조합 — ' + f'각 유형 1위만 요약 · 비교군 반영은 수동(코디에게 요청) (총 고유 {len(uniq)}조합)
' + f'※ 약세장 모드(기본/현금화/눌림허용)는 백테스트에 영향이 없어(시장필터 중립) 여기선 안 갈라져요 — ' + f'비교군에서만 유형당 3종으로 라이브 검증
') + + # 유형별 보기 (시장 성격 그룹 — 접이식, 접힌 줄에 그룹 1위 수익 표시) + type_rows = '' + for mkt in sorted(by_market, key=lambda m: max(_sk(r) for r in by_market[m]), reverse=True): + grp = sorted(by_market[mkt], key=_sk, reverse=True) + best = grp[0] + bret = best['test'].get('total_return_pct', 0) + bw = best.get('worst_ret') + bc = '#22c55e' if (bret or 0) >= 0 else '#ef4444' + bw_h = f'최악 {bw:+g}% · ' if bw is not None else '' + rows_html = ''.join(_row_html(r, f'#{j + 1}') for j, r in enumerate(grp)) + type_rows += ( + f'
' + f'{_esc(mkt)}에 강한 유형 ({len(grp)}종)' + f'1위 {bw_h}{bret:+g}%' + f'
{rows_html}
' + ) + + # 순위별 보기 (전체 36종 통합 꾸준함 순) + flat = sorted(by_label.values(), key=_sk, reverse=True) + rank_rows = ''.join(_row_html(r, f'#{i + 1}') for i, r in enumerate(flat)) + + viewbar = ('
' + '' + '
') + toggle_js = """ +""" + return (head + ctrl + note + viewbar + + f'
{type_rows}
' + + f'' + + toggle_js) def _compare_panel() -> str: @@ -757,26 +894,61 @@ def _compare_panel() -> str: from . import variants as vmod, benchmark defs = vmod.load_variants() today = datetime.now(config.KST).strftime('%Y%m%d') + # 스캔 시점 '노리는 종목' (variants_compare.json) — id별 매핑 + watch_map = {c['id']: c.get('watching', []) + for c in (_load(vmod.COMPARE_PATH, {}) or {}).get('variants', [])} - def _row(rid, name, params, pf): + def _row(rid, name, params, pf, tpath): s = pf.summary() - return {'id': rid, 'name': name, 'params': params, 'summary': s, + return {'id': rid, 'name': name, 'params': params, 'summary': s, 'pf': pf, 'tpath': tpath, 'benchmark': benchmark.compare(s.get('total_return_pct'), benchmark.norm_date(pf.created_at), today)} - # 메인 + 변이 계좌를 소스에서 직접 수집 - rows_data = [_row('main', '메인 (현재 튜닝)', config.load_params(), Portfolio.load())] + # 변이 계좌 수집 (기준전략 폐지 — 변이만 비교) + favs = _load_favs() + icon_of = {v: ic for ic, v in favs.items()} # vid → 아이콘 (전략당 1개) + # 백테스트 성적 맵 — 각 변이가 어떤 성적으로 등록됐는지 (params 정규화 키 → 순위·최악·검증) + bt_map = {} + _btres = _load(config.STATE_DIR / 'backtest_results.json', None) or {} + _seen = set() + _rank = 0 + for r in _btres.get('results', []): + kk = _pkey(r['params']) + if kk in _seen: + continue + _seen.add(kk) + _rank += 1 + bt_map[kk] = {'rank': _rank, 'worst': r.get('worst_ret'), + 'test': r['test'].get('total_return_pct')} + # 백테스트 매칭은 스윕 대상 파라미터만으로 — 약세모드 등 백테스트 중립 파라미터(BEAR_ENTRY_MODE)가 + # 변이 params에 붙어 있어도 같은 백테스트 행에 매칭되게 (모드 3종은 백테스트 성적이 동일). + _swept = _btres.get('swept_params') or [] + + def _btkey(p): + p = p or {} + return _pkey({k: p[k] for k in _swept if k in p} if _swept else p) + + # 비교군 내부 순위 (1~N) — 등록된 변이들끼리만 백테스트 순위 재매김 (전체 순위는 쌍둥이 제외로 번호가 건너뜀) + _vkeys = {_btkey(v.get('params') or {}) for v in defs} # set — 모드 3종이 같은 키를 공유 + _border = sorted([k for k in _vkeys if k in bt_map], key=lambda k: bt_map[k]['rank']) + bt_grank = {k: i + 1 for i, k in enumerate(_border)} + rows_data = [] for v in defs: pp, tp = vmod.variant_paths(v['id']) rows_data.append(_row(v['id'], v.get('name', v['id']), v.get('params') or {}, - Portfolio.load(pp, tp))) - rows_data.sort(key=lambda c: c['summary'].get('total_return_pct', 0) or 0, reverse=True) + Portfolio.load(pp, tp), tp)) + # 표시 순서는 고정(메인→변이 등록순) — 새로고침해도 위치 안 바뀜. 순위(#N)만 수익률로 매겨 라벨 표시. + _ranked = sorted(rows_data, key=lambda c: c['summary'].get('total_return_pct', 0) or 0, reverse=True) + rank_of = {c['id']: n + 1 for n, c in enumerate(_ranked)} scanned = _load(config.LAST_SCAN_PATH, {}).get('scanned_at', '')[:16].replace('T', ' ') head = (f'
실시간 누적 비교 · 마지막 스캔 {scanned or "—"} ' f'· {len(rows_data)}개 (메인 포함)
' '
⚠ 같은 라이브 시세에 서로 다른 튜닝 동시 운영. ' - '기간 길수록·거래 많을수록 신뢰 · 변이는 백테스트 탭의 「+ 비교군에 추가」로 등록
') + '기간 길수록·거래 많을수록 신뢰 · 비교군 변경은 수동(코디에게 요청)' + '
약세장대처: 회피매수 안 함 · ' + '돌파돌파만(기본) · ' + '적극눌림목도 매수 — 칩 클릭 시 설명
') clear_btn = ('' if not defs else '
' @@ -787,29 +959,57 @@ def _compare_panel() -> str: s = v['summary'] ret = s.get('total_return_pct', 0) or 0 rc = '#22c55e' if ret >= 0 else '#ef4444' - is_main = v['id'] == 'main' - tag = ' ' if is_main else '' - plabel = '·'.join(f'{k.split("_")[0]}{val:g}' for k, val in (v.get('params') or {}).items()) or '기본값' - mp = v.get('params') or config.load_params() + fav_ic = icon_of.get(v['id'], '') + tag = (f' ') + mp = v.get('params') or {} mf_t, mf_b = _market_fit(mp) mf_sum = _market_fit_summary(mp) - actions = '' if is_main else ( - f'
' - f'' - f'' - f'' - f'
' - f'' - f'
' - f'
' - ) + mf_w = _market_fit_words(mp) + bm = _bear_mode(mp) + w = watch_map.get(v['id'], []) + _STL = {'BUY': '🟢 매수', 'ADD': '🟢 추격', 'WAIT': '🟡 대기'} + watch_html = ''.join( + f'
{_STL.get(it.get("state"), it.get("state"))} {_esc(it.get("name"))}' + f'{_esc(it.get("reason",""))[:28]}
' + for it in w) or '
노리는 종목 없음
' + reset_btn = (f'
' + f'' + f'
') + del_btn = (f'
' + f'' + f'
') + actions = f'
{reset_btn}{del_btn}
' + vid = v['id'] + _vk = _btkey(v.get('params') or {}) + bt = bt_map.get(_vk) + gr = bt_grank.get(_vk) + bt_attr = gr if gr else 9999 + if bt and gr: + bw = f"{bt['worst']:+g}%" if bt['worst'] is not None else '—' + bt_row = (f'
모의평가' + f'{gr}위/{len(bt_grank)}
' + f'
' + f'최악구간 {bw} · 검증 {bt["test"]:+g}%
') + else: + bt_row = '' rows += ( - f'
' - f'#{i+1} {_esc(mf_sum)}{tag}' - f'{ret:+g}%' + f'
' + f'{rank_of[v["id"]]}위{_bear_chip_html(mp)} {_vid_disp(vid)} ' + f'{_esc(mf_w)}{tag}' + f'🛒{s.get("open_positions", 0)} ' + f'{ret:+g}%' f'
' + f'
유형{_esc(mf_sum)}' + f'
' + f'
약세장대처{_BEAR_NAME[bm]}' + f'
' f'
평가자산{s.get("equity",0):,}원
' f'
예수금 / 주식{s.get("cash",0):,} / {s.get("held_value",0):,}
' f'
수익률{ret:+g}%
' @@ -817,15 +1017,90 @@ def _compare_panel() -> str: f'
거래 / 승률{s.get("closed_trades",0)}건 · {s.get("win_rate_pct")}%
' f'
MDD / 보유{s.get("max_drawdown_pct",0)}% · {s.get("open_positions",0)}종목
' f'
지수대비(알파){_alpha_inline(v.get("benchmark") or {})}
' - f'
파라미터{_esc(plabel)}
' - f'' + f'{bt_row}' + f'
📦 보유종목 ({s.get("open_positions",0)})' + f'
{_pos_table(v["pf"])}
' + f'
📋 거래내역 ({s.get("closed_trades",0)}청산)' + f'
{_trades_table(_read_trades(v["tpath"]))}
' + f'
🎯 관심종목 ({len(w)})' + f'
{watch_html}
' f'{actions}' f'
' ) - return title + head + rows + sortbar = ('
' + '' + '' + '' + '
') + sort_js = """ +""" + fav_js = (""" +""" + .replace('__FAVS__', json.dumps(favs, ensure_ascii=False)) + .replace('__ICONS__', json.dumps(FAV_ICONS, ensure_ascii=False))) + return title + head + sortbar + f'
{rows}
' + sort_js + fav_js -def _engine_status(snap, summ) -> tuple[str, str]: +def _engine_status(snap, asset_line) -> tuple[str, str]: """스캔 엔진 헬스 → (점 클래스 ok/idle/bad, 토스트 상세). 장중 최근스캔=초록, 장중 멈춤=빨강, 장외=대기.""" now = datetime.now(config.KST) session = engine.is_market_session(now) @@ -838,8 +1113,7 @@ def _engine_status(snap, summ) -> tuple[str, str]: last_str = dt.strftime('%m-%d %H:%M:%S') except Exception: pass - asset = (f"자산 {summ.get('equity', 0):,}원 · 보유 {summ.get('open_positions', 0)}종목 " - f"· 현금 {summ.get('cash', 0):,}원") + asset = asset_line if session: if age_min is not None and age_min <= 12: return 'ok', (f"🟢 시뮬 가동 중\n장중 · 5분 주기 자동 스캔\n" @@ -851,50 +1125,9 @@ def _engine_status(snap, summ) -> tuple[str, str]: f"마지막 스캔 {last_str}\n{asset}") -def render_html() -> str: - snap = _load(config.LAST_SCAN_PATH, {}) - pf = Portfolio.load() # 계좌 진실원 — 스캔 스냅샷 없어도 현재 상태 표시 - trades = [] - if config.TRADES_PATH.exists(): - for line in config.TRADES_PATH.read_text().splitlines()[-30:]: - try: - trades.append(json.loads(line)) - except Exception: - pass - trades.reverse() - - s = pf.summary() - scanned = snap.get('scanned_at', '')[:19].replace('T', ' ') - hcls, hdetail = _engine_status(snap, s) - ret = s.get('total_return_pct', 0) or 0 - ret_color = '#22c55e' if ret >= 0 else '#ef4444' - - # ---- 계좌 요약 (한 줄씩 나열) ---- - wr = s.get('win_rate_pct') - kpis = ( - _kvline('평가자산', f"{s.get('equity', 0):,}원 (초기 {s.get('initial', 0):,})") + - _kvline('총보유(주식)', f"{s.get('held_value', 0):,}원") + - _kvline('예수금', f"{s.get('cash', 0):,}원") + - _kvline('수익률', f'{ret:+.2f}%') + - _kvline('실현손익', f"{s.get('realized_pnl', 0):,}원") + - _kvline('승률', f"{wr}% ({s.get('closed_trades', 0)}건 청산)" if wr is not None else '—') + - _kvline('MDD', f"{s.get('max_drawdown_pct', 0)}%") + - _kvline('보유', f"{s.get('open_positions', 0)}종목") - ) - # 기준지수 대비(알파) — 시작일 대비 같은 기간 지수 매수후보유와 비교 - bench = snap.get('benchmark', {}) or {} - for idx, lbl in (('KOSPI', '코스피'), ('KOSDAQ', '코스닥')): - b = (bench.get('indices') or {}).get(idx, {}) - a, ip = b.get('alpha'), b.get('pct') - if a is None: - kpis += _kvline(f'{lbl} 대비(알파)', '— (지수 데이터 없음)') - else: - ac = '#22c55e' if a >= 0 else '#ef4444' - kpis += _kvline(f'{lbl} 대비(알파)', - f'{a:+.2f}%p (지수 {ip:+.2f}%)') - - # ---- 보유 포지션 ---- - pos_rows = '' +def _pos_table(pf) -> str: + """보유 포지션 펼치기 테이블 (내 계좌·비교 변이 공용).""" + rows = '' for p in pf.positions.values(): entry, qty = p['entry_price'], p['qty'] cur = p.get('cur_price', entry) @@ -906,9 +1139,9 @@ def render_html() -> str: tr_n, tr_max = p.get('tranches', 1), config.ENTRY_TRANCHES scaled = ' 일부익절' if p.get('scaled_out') else '' entry_at = _esc((p.get('entry_at') or '')[:16].replace('T', ' ')) - pos_rows += ( + rows += ( f'
' - f'{_esc(p["name"])}' + f'{_esc(p["name"])} {qty:,}주' f'{cur:,} {pnl:+.1f}%' f'
' f'
수량{qty:,}주 ({tr_n}/{tr_max}분할){scaled}
' @@ -921,66 +1154,24 @@ def render_html() -> str: f'
진입시각{entry_at}
' f'
' ) - pos_table = pos_rows or '
보유 종목 없음
' + return rows or '
보유 종목 없음
' - # ---- 판단표 (state 그룹) ---- - decisions = snap.get('decisions', []) - by_state = {} - for d in decisions: - by_state.setdefault(d.get('state', 'NODATA'), []).append(d) - judge_html = '' - for st in STATE_ORDER: - items = by_state.get(st, []) - if not items: - continue - label, color = STATE_BADGE.get(st, (st, '#888')) - judge_html += f'

{label} {len(items)}

' - for d in items: - chips = _chips(d.get('checks', [])) - src = '/'.join(ORIGIN_LABEL.get(s, s) for s in d.get('sources', [])) - price = d.get('price') - price_h = f'{price:,}' if isinstance(price, (int, float)) else '—' - pnl_h = f' · {d["pnl_pct"]:+.1f}%' if d.get('pnl_pct') is not None else '' - del_btn = (f'
' - f'' - f'
') - judge_html += ( - f'
' - f'{_esc(d.get("name"))} {_esc(d.get("code"))} · {_esc(src)}' - f'{price_h}{pnl_h}{del_btn}
' - f'
{_esc(d.get("reason",""))}
' - f'
{chips}
' - ) +def _read_trades(path, limit=30) -> list: + """거래로그(jsonl) 최근 limit건, 최신순.""" + out = [] + if path.exists(): + for line in path.read_text().splitlines()[-limit:]: + try: + out.append(json.loads(line)) + except Exception: + pass + out.reverse() + return out - # 관찰목록 중 아직 스캔 안 된(방금 추가 등) 종목 → '스캔 대기'로 즉시 표시 - from . import universe as umod - wl = umod._load_watchlist() - decided = {d.get('code') for d in decisions} - pending = [(c, e) for c, e in wl.items() if c not in decided] - if pending: - judge_html += '

스캔 대기 {}

'.format(len(pending)) - for code, e in pending: - src = ORIGIN_LABEL.get(e.get('origin', ''), e.get('origin', '')) - del_btn = (f'
' - f'' - f'
') - judge_html += ( - f'
' - f'{_esc(e.get("name") or code)} {_esc(code)} · {_esc(src)}' - f'다음 스캔 대기{del_btn}
' - ) - mkt = snap.get('market_ok', {}) - mkt_h = ' · '.join(f'{k} {"양호" if v else "약세"}' for k, v in mkt.items()) or '—' - market_html = _market_panel() - tune_html = _tune_panel() - bt_html = _backtest_panel() - cmp_html = _compare_panel() - - # ---- 거래내역 ---- +def _trades_table(trades) -> str: + """거래내역 펼치기 테이블 (내 계좌·비교 변이 공용).""" tr_rows = '' for r in trades: ts = _esc(r["ts"][5:16].replace('T', ' ')) @@ -1015,7 +1206,212 @@ def render_html() -> str: if r.get('signals') else '') + '
' ) - tr_table = tr_rows or '
거래 없음
' + return tr_rows or '
거래 없음
' + + +def _decision_card(d, watch_n: int = 0, held_n: int = 0, acc_detail: str = '') -> str: + """관심종목 탭 종목 카드 (일반·제외목록 공용). watch_n/held_n = 이 종목을 관심/보유 중인 계좌 수. + acc_detail 이 있으면 👁🛒 클릭 시 '어느 전략이 보는지' 목록 토스트.""" + chips = _chips(d.get('checks', [])) + src = '/'.join(ORIGIN_LABEL.get(s, s) for s in d.get('sources', [])) + acc = '' + if watch_n or held_n: + parts = ([f'👁{watch_n}'] if watch_n else []) + ([f'🛒{held_n}'] if held_n else []) + attrs = (f' data-title="{_esc(d.get("name"))} — 보는 전략" data-detail="{_esc(acc_detail)}"' + if acc_detail else '') + acc = f' {" ".join(parts)}' + price = d.get('price') + price_h = f'{price:,}' if isinstance(price, (int, float)) else '—' + pnl_h = f' · {d["pnl_pct"]:+.1f}%' if d.get('pnl_pct') is not None else '' + del_btn = (f'
' + f'' + f'
') + return ( + f'
' + f'{_esc(d.get("name"))} {_esc(d.get("code"))} · {_esc(src)}{acc}' + f'{price_h}{pnl_h}{del_btn}
' + f'
{_esc(d.get("reason",""))}
' + f'
{chips}
' + ) + + +def render_html(view: str | None = None) -> str: + snap = _load(config.LAST_SCAN_PATH, {}) + from . import variants as vmod + _cv = [c for c in (_load(vmod.COMPARE_PATH, {}) or {}).get('variants', []) + if c.get('id') != 'main'] # 구버전 스냅샷의 main 잔재 무시 (기준전략 폐지) + _ranked = sorted(_cv, key=lambda c: c['summary'].get('total_return_pct', 0) or 0, reverse=True) + rank_map = {c.get('id'): n + 1 for n, c in enumerate(_ranked)} + fav_map = _load_favs() # {아이콘: vid} + icon_of = {v: ic for ic, v in fav_map.items()} # vid → 아이콘 + favs = [fav_map[ic] for ic in FAV_ICONS if fav_map.get(ic) in rank_map] # 아이콘 순 = 우선순위 + + # 관심종목 탭 시각 — ?view 지정 > 첫 마킹(아이콘 순) > 현재 1위. (기준전략 폐지 — 2026-06-10) + view = (view or '').strip() + if view in ('main', 'M') or view not in rank_map: + view = favs[0] if favs else (_ranked[0]['id'] if _ranked else '') + view_label = _vid_disp(view) if view else '기준판단' + judge_snap = snap # 폴백: params.json 판단 스냅샷 + if view: + try: + js = engine.judge_view(view) + if js: + judge_snap = js + except Exception: + pass + scanned = snap.get('scanned_at', '')[:19].replace('T', ' ') + n_acc = len(_cv) + rets = [c['summary'].get('total_return_pct', 0) or 0 for c in _cv] + asset_line = (f'전략 {n_acc}개 운영 · 평균 {sum(rets)/len(rets):+.2f}% · 1위 {max(rets):+.2f}%' + if rets else '운영 전략 없음') + hcls, hdetail = _engine_status(snap, asset_line) + + # ---- 판단표 (state 그룹) ---- + from . import universe as umod + decisions = judge_snap.get('decisions', []) + + # 종목별 — 이 종목을 관심(매수후보)/보유 중인 계좌(메인+변이) 수 (variants_compare 스냅샷 기준) + watch_n, held_n, buy_n = {}, {}, {} + watchers, holders = {}, {} # code → [(id, state, reason)] / [id] — 👁 클릭 토스트용 + for c in _cv: + acc_id = c.get('id') + for it in c.get('watching', []) or []: + cd = it.get('code') + watch_n[cd] = watch_n.get(cd, 0) + 1 + watchers.setdefault(cd, []).append((acc_id, it.get('state'), it.get('reason', ''))) + if it.get('state') in ('BUY', 'ADD'): + buy_n[cd] = buy_n.get(cd, 0) + 1 + for cd in c.get('held', []) or []: + held_n[cd] = held_n.get(cd, 0) + 1 + holders.setdefault(cd, []).append(acc_id) + + def _acc_sort(vid): + return int(vid[1:]) if vid.startswith('v') and vid[1:].isdigit() else 999 + + _view_dec = {d.get('code'): d for d in decisions} + _STN = {'BUY': '🟢 매수 직전', 'ADD': '🟢 추격 대기', 'WAIT': '🟡 대기', + 'HOLD': '📦 보유', 'SKIP': '⚪ 매수 안 함', 'NODATA': '⚪ 데이터 없음'} + + def _acc_detail(code): + """이 종목을 보유/관심 중인 전략 목록 — 👁🛒 클릭 토스트 본문. + 맨 위에 현재 시각 전략의 판단을 항상 표시 (목록에 없으면 왜 빠졌는지 보이게).""" + lines = [] + vd = _view_dec.get(code) + if view and vd: + rk = rank_map.get(view) + rk_s = f'({rk}위)' if rk else '' + st = vd.get('state') + lines.append(f'▶ 시각 {_vid_disp(view)}{rk_s} — {_STN.get(st, st)}: ' + f'{(vd.get("reason") or "")[:38]}') + lines.append('') + hs = sorted(holders.get(code) or [], key=_acc_sort) + if hs: + lines.append('🛒 보유 ' + str(len(hs)) + '개: ' + + ', '.join(f'{_vid_disp(h)}({rank_map[h]}위)' if h in rank_map else _vid_disp(h) + for h in hs)) + ws = sorted(watchers.get(code) or [], key=lambda x: _acc_sort(x[0])) + if ws: + if lines: + lines.append('') + lines.append(f'👁 관심 {len(ws)}개 전략:') + for vid, st, rsn in ws: + em = '🟢' if st in ('BUY', 'ADD') else '🟡' + rk = rank_map.get(vid) + rk_s = f'({rk}위)' if rk else '' + pre = '▶ ' if vid == view else '' + lines.append(f'{pre}{em} {_vid_disp(vid)}{rk_s} — {(rsn or "")[:38]}') + return '\n'.join(lines) + + # 상태 그룹 = 전체 전략(메인+변이) 합산 — 한 계좌라도 보유→보유 / 매수직전→매수 / 대기→대기. + # 아무 계좌도 안 보면 기준전략(M) 판정. 카드 체크리스트·사유는 기준전략 시각 유지(합산 정보는 사유 앞에 표시). + by_state = {} + excluded = [] # 매매 안 하는 종목(ETF/ETN 등) → 하단 제외목록 + for d in decisions: + if umod.is_etf(d.get('name', '')): + excluded.append(d) + continue + code = d.get('code') + if held_n.get(code): + st = 'HOLD' + elif buy_n.get(code): + st = 'BUY' + elif watch_n.get(code): + st = 'WAIT' + else: + st = d.get('state', 'NODATA') + if st != d.get('state'): + bits = [] + if held_n.get(code): + bits.append(f'{held_n[code]}개 전략 보유') + if buy_n.get(code): + bits.append(f'{buy_n[code]}개 전략 매수 직전') + elif watch_n.get(code): + bits.append(f'{watch_n[code]}개 전략 매수 대기') + d = {**d, 'reason': ' · '.join(bits) + f' — {view_label}: {d.get("reason", "")}'} + by_state.setdefault(st, []).append(d) + + judge_html = '' + for st in STATE_ORDER: + items = by_state.get(st, []) + if not items: + continue + label, color = STATE_BADGE.get(st, (st, '#888')) + judge_html += f'

{label} {len(items)}

' + for d in items: + judge_html += _decision_card(d, watch_n.get(d.get('code'), 0), held_n.get(d.get('code'), 0), + _acc_detail(d.get('code'))) + + # 관찰목록 중 아직 스캔 안 된(방금 추가 등) 종목 → '스캔 대기'로 즉시 표시 + wl = umod._load_watchlist() + decided = {d.get('code') for d in decisions} + pending = [(c, e) for c, e in wl.items() if c not in decided] + if pending: + judge_html += '

스캔 대기 {}

'.format(len(pending)) + for code, e in pending: + src = ORIGIN_LABEL.get(e.get('origin', ''), e.get('origin', '')) + del_btn = (f'
' + f'' + f'
') + judge_html += ( + f'
' + f'{_esc(e.get("name") or code)} {_esc(code)} · {_esc(src)}' + f'다음 스캔 대기{del_btn}
' + ) + + # 매매 안 하는 종목(ETF/ETN 등) → 하단 접이식 제외목록 + if excluded: + judge_html += ( + f'
' + f'🚫 매매 제외 (ETF·ETN 등) {len(excluded)}' + f'스캔은 하되 매수 안 함' + f'
' + ''.join( + _decision_card(d, watch_n.get(d.get('code'), 0), held_n.get(d.get('code'), 0), + _acc_detail(d.get('code'))) + for d in excluded) + '
' + ) + + # 관심종목 탭 시각 선택 드롭다운 (M + 변이 전체) + def _rk(vid): + r = rank_map.get(vid) + return f'{r}위 ' if r else '' + opts = '' + for vv in vmod.load_variants(): + _sel = ' selected' if view == vv['id'] else '' + _fav = f'{icon_of[vv["id"]]} ' if vv['id'] in icon_of else '' + _vp = vv.get('params') or {} + opts += (f'') + view_sel = (f'
체크리스트 시각: ' + f'
') + + mkt = snap.get('market_ok', {}) + mkt_h = ' · '.join(f'{k} {"양호" if v else "약세"}' for k, v in mkt.items()) or '—' + market_html = _market_panel() + bt_html = _backtest_panel() + cmp_html = _compare_panel() return f""" @@ -1042,6 +1438,7 @@ h3.grp{{font-size:13px;margin:16px 0 8px;color:#cbd5e1}} .row .body{{padding:2px 12px 10px;border-top:1px solid #161e29}} .kv{{display:flex;justify-content:space-between;gap:12px;padding:5px 0;font-size:13px;border-bottom:1px solid #10161e;font-variant-numeric:tabular-nums}} .kv:last-child{{border-bottom:none}} +.kv.nb{{border-bottom:none;padding-bottom:0}} .kv .k{{color:#6b7280;font-size:12px;white-space:nowrap}} .empty{{color:#6b7280;font-size:13px;padding:10px 2px}} .muted{{color:#6b7280;font-size:12px}} @@ -1052,7 +1449,15 @@ h3.grp{{font-size:13px;margin:16px 0 8px;color:#cbd5e1}} .chks{{display:flex;flex-wrap:wrap;gap:5px}} .chk{{background:#0f151c;border:1px solid #1f2937;border-radius:6px;padding:2px 7px;font-size:11px}} .chk .cd{{color:#6b7280;margin-left:5px}} +.acc-n[data-detail]{{cursor:pointer;text-decoration:underline dotted #475569;text-underline-offset:3px}} +#chk-toast .ct-d{{max-height:55vh;overflow-y:auto}} .chk-i{{background:none;border:none;color:#60a5fa;font-size:11px;cursor:pointer;padding:0 0 0 4px;line-height:1;vertical-align:baseline}} +.chk-paths{{flex-basis:100%;margin-top:5px;border-top:1px dashed #283347;padding-top:6px}} +.path-cap{{font-size:10px;color:#64748b;margin-bottom:5px;cursor:pointer}} +.path-line{{display:flex;gap:8px;align-items:flex-start;padding:6px 0}} +.path-line + .path-line{{border-top:1px dashed #1f2937}} +.path-line .path-h{{flex:0 0 72px;font-size:11px;font-weight:700;padding-top:3px}} +.path-line .path-items{{display:flex;flex-wrap:wrap;gap:5px;min-width:0}} .hdot{{display:inline-block;width:11px;height:11px;border-radius:50%;margin-left:6px;cursor:pointer;vertical-align:middle;background:#6b7280}} .hdot.ok{{background:#22c55e;box-shadow:0 0 6px #22c55e}} .hdot.idle{{background:#eab308}} @@ -1063,6 +1468,23 @@ h3.grp{{font-size:13px;margin:16px 0 8px;color:#cbd5e1}} .bt-type .bt-type-d{{display:block;font-size:11px;font-weight:400;color:#9ca3af;margin-top:2px}} .cmp-title{{display:flex;align-items:center}} .cmp-clear{{background:#3a1518;color:#fca5a5;border:1px solid #7f1d1d;border-radius:6px;padding:3px 8px;font-size:11px;font-weight:600;cursor:pointer}} +#judge-view{{background:#141b24;border:1px solid #2a3142;color:#e5e7eb;border-radius:6px;padding:3px 6px;font-size:10px;max-width:88%}} +.fav-b{{background:none;border:none;font-size:13px;cursor:pointer;padding:0 2px;line-height:1;flex:0 0 auto}} +.fav-none{{opacity:.35;filter:grayscale(1)}} +.fav-grid{{display:flex;flex-wrap:nowrap;overflow-x:auto;gap:8px;margin:10px 0;padding-bottom:4px}} +.fav-opt{{display:flex;flex-direction:column;align-items:center;gap:2px;background:#1e293b;border:1px solid #334155;border-radius:8px;padding:8px 10px;font-size:18px;cursor:pointer;min-width:52px;flex:0 0 auto}} +.fav-opt small{{font-size:9px;color:#94a3b8;white-space:nowrap}} +.fav-opt.on{{border-color:#7dd3fc;background:#0c2436}} +.fav-opt.fav-off{{font-size:12px;color:#fca5a5;justify-content:center}} +.vid{{flex:0 0 auto;background:#1e293b;color:#7dd3fc;border-radius:5px;padding:1px 6px;font-size:10px;font-weight:700;font-variant-numeric:tabular-nums}} +.bic{{flex:0 0 auto;border-radius:5px;padding:1px 5px;font-size:9px;font-weight:700;margin-left:3px}} +.bic[data-detail]{{cursor:pointer}} +.bic0{{background:#334155;color:#cbd5e1}} +.bic1{{background:#1e3a5f;color:#7dd3fc}} +.bic2{{background:#4a2e12;color:#fbbf24}} +.cmp-sortbar{{display:flex;gap:6px;margin:0 0 10px}} +.cmp-sortbar button{{flex:1;background:#141b24;border:1px solid #1f2937;border-radius:8px;color:#9ca3af;font-size:11px;font-weight:600;padding:7px 4px;cursor:pointer}} +.cmp-sortbar button.active{{background:#15406e;color:#fff;border-color:#2952cc}} .badge-ic{{flex:0 0 auto;font-size:12px;line-height:1}} #chk-toast{{position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center}} #chk-toast .ct-card{{background:#141b24;border:1px solid #2952cc;border-radius:14px 14px 0 0;padding:16px 18px 22px;max-width:520px;width:100%;box-shadow:0 -4px 24px rgba(0,0,0,.5);animation:ct-up .18s ease-out}} @@ -1075,18 +1497,24 @@ input[name="tab"],input[name="subtab"]{{display:none}} #page.dragging .hd{{position:static}} .tabs{{display:flex;gap:6px;margin:0}} .tabs label{{flex:1;text-align:center;padding:9px 6px;border:1px solid #1f2937;border-radius:9px;color:#9ca3af;font-size:13px;font-weight:600;cursor:pointer;background:#141b24}} -#tab-acct:checked~.hd label[for="tab-acct"],#tab-watch:checked~.hd label[for="tab-watch"],#tab-market:checked~.hd label[for="tab-market"],#tab-lab:checked~.hd label[for="tab-lab"]{{background:#1e3a8a;color:#fff;border-color:#2952cc}} +#tab-lab:checked~.hd label[for="tab-lab"],#tab-watch:checked~.hd label[for="tab-watch"],#tab-market:checked~.hd label[for="tab-market"]{{background:#1e3a8a;color:#fff;border-color:#2952cc}} .panel{{display:none}} -#tab-acct:checked~#p-acct,#tab-watch:checked~#p-watch,#tab-market:checked~#p-market,#tab-lab:checked~#p-lab{{display:block}} +#tab-lab:checked~#p-lab,#tab-watch:checked~#p-watch,#tab-market:checked~#p-market{{display:block}} .subtabs{{display:flex;gap:6px;margin:0 0 14px}} .subtabs label{{flex:1;text-align:center;padding:8px 6px;border:1px solid #1f2937;border-radius:8px;color:#9ca3af;font-size:12px;font-weight:600;cursor:pointer;background:#141b24}} -#sub-tune:checked~#p-lab label[for="sub-tune"],#sub-bt:checked~#p-lab label[for="sub-bt"],#sub-cmp:checked~#p-lab label[for="sub-cmp"]{{background:#15406e;color:#fff;border-color:#2952cc}} +#sub-cmp:checked~#p-lab label[for="sub-cmp"],#sub-bt:checked~#p-lab label[for="sub-bt"]{{background:#15406e;color:#fff;border-color:#2952cc}} #p-lab .subpanel{{display:none}} #p-lab .subpanel h2{{margin-top:4px}} .sortbar{{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 12px}} .sortbar button{{background:#141b24;border:1px solid #1f2937;border-radius:7px;color:#9ca3af;font-size:12px;font-weight:600;padding:7px 10px;cursor:pointer;font-variant-numeric:tabular-nums}} .sortbar button.active{{background:#15406e;color:#fff;border-color:#2952cc}} -.btn-sweep{{background:#1e3a8a;color:#fff;border:1px solid #2952cc;border-radius:8px;padding:10px 14px;font-size:13px;font-weight:600;cursor:pointer;width:100%}} +.btn-sweep{{background:#1e3a8a;color:#fff;border:1px solid #2952cc;border-radius:7px;padding:4px 10px;font-size:11px;font-weight:600;cursor:pointer;width:auto}} +.bt-viewbar{{display:flex;gap:6px;margin:0 0 12px}} +.bt-viewbar button{{flex:1;background:#141b24;border:1px solid #1f2937;border-radius:8px;color:#9ca3af;font-size:12px;font-weight:600;padding:8px 6px;cursor:pointer}} +.bt-viewbar button.active{{background:#15406e;color:#fff;border-color:#2952cc}} +.bt-grp{{border-color:#2a3850}} +.bt-grp>summary .nm{{font-size:13px;font-weight:700}} +.bt-grp>.body{{padding-top:8px}} .sweep-bar{{display:flex;align-items:center;gap:9px;background:#13233a;border:1px solid #2952cc;border-radius:8px;padding:11px 13px;margin:0 0 10px;font-size:13px;color:#cbd5e1}} .spin{{width:14px;height:14px;border:2px solid #93c5fd;border-top-color:transparent;border-radius:50%;animation:ptr-spin .7s linear infinite;flex:none}} .bt-actions{{display:flex;gap:8px;margin-top:8px}} @@ -1106,12 +1534,13 @@ input[name="tab"],input[name="subtab"]{{display:none}} .card-del{{margin:0 0 0 8px}} .card-del button{{background:transparent;border:1px solid #2a3142;color:#9ca3af;border-radius:6px;width:24px;height:24px;font-size:12px;line-height:1;cursor:pointer;padding:0}} .card-del button:hover{{background:#3b1213;border-color:#b91c1c;color:#fca5a5}} -.cmp-row-actions{{display:flex;gap:8px;margin-top:10px}} -.cmp-row-actions form{{flex:1;margin:0}} +.cmp-row-actions{{display:flex;justify-content:flex-start;gap:6px;margin-top:10px;opacity:.55}} +.cmp-row-actions form{{flex:0 0 auto;margin:0}} +.cmp-row-actions .btn-mini{{width:auto;padding:3px 8px;font-size:10px}} .btn-mini{{background:#1e3a8a;color:#fff;border:1px solid #2952cc;border-radius:7px;padding:9px 11px;font-size:12px;font-weight:600;cursor:pointer;width:100%}} .btn-mini.btn-warn{{background:#3f2d0a;border-color:#b45309;color:#fcd34d}} .btn-mini.btn-danger{{background:#3b1213;border-color:#b91c1c;color:#fca5a5}} -#sub-tune:checked~#p-lab #ps-tune,#sub-bt:checked~#p-lab #ps-bt,#sub-cmp:checked~#p-lab #ps-cmp{{display:block}} +#sub-cmp:checked~#p-lab #ps-cmp,#sub-bt:checked~#p-lab #ps-bt{{display:block}} .btn-apply{{background:#15803d;color:#fff;border:1px solid #16a34a;border-radius:8px;padding:9px 14px;font-size:13px;font-weight:600;cursor:pointer;width:100%}} .tabs label{{font-size:12px;padding:9px 4px}} .prow{{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid #161e29}} @@ -1140,40 +1569,34 @@ input[name="tab"],input[name="subtab"]{{display:none}}
- + - -

🪎 레이 자동매매 시뮬레이션

마지막 스캔 {scanned or '—'} · 시장 {mkt_h}
- +
-
-
{kpis}
-

보유 포지션

{pos_table} -

최근 거래

{tr_table} -

관찰 중 종목 ({len(decisions)})

+
상태 그룹은 전체 전략(메인+변이) 합산 — 한 전략이라도 보유/매수대기면 그 그룹으로 · 체크리스트는 기준전략(M) 시각 · 👁관심 🛒보유 전략 수
종목명 일부만 입력해도 후보가 떠요 · 후보를 누르면 추가 · 자산(비하이브 워치·관심·보유)은 자동 편입 · 삭제는 카드의 ✕ (수동만) · 보유 중이면 삭제해도 청산까지 추적
+{view_sel} {judge_html}

시장 동향

{market_html}
- +
{cmp_html}

백테스트 순위표

{bt_html}
-

파라미터 튜닝

{tune_html}
60초 자동 새로고침 · 당겨서 새로고침 · sim/ 가상 엔진 (실주문 아님)
@@ -1206,7 +1629,8 @@ class Handler(BaseHTTPRequestHandler): self.send_response(404) self.end_headers() return - body = render_html().encode('utf-8') + _view = (urllib.parse.parse_qs(parsed.query).get('view') or ['main'])[0] + body = render_html(view=_view).encode('utf-8') self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') self.send_header('Content-Length', str(len(body))) @@ -1220,6 +1644,21 @@ class Handler(BaseHTTPRequestHandler): self.send_header('Location', '/?t=lab&s=bt') self.end_headers() return + if self.path == '/fav_set': + # 관심 전략 마킹 — {아이콘: vid}, 아이콘 하나당 전략 1개 (다른 전략에 쓰면 옮겨옴) + length = int(self.headers.get('Content-Length') or 0) + raw = self.rfile.read(length).decode('utf-8') if length else '' + form = {k: v[-1] for k, v in urllib.parse.parse_qs(raw).items()} + vid = (form.get('id') or '').strip() + icon = (form.get('icon') or '').strip() + favs = {ic: v for ic, v in _load_favs().items() if v != vid} # 전략당 아이콘 1개 + if vid and icon in FAV_ICONS: + favs[icon] = vid # 기존 주인 교체(이동) + _save_favs(favs) + self.send_response(303) + self.send_header('Location', '/?t=lab&s=cmp') + self.end_headers() + return if self.path in ('/variants_add', '/variants_reset_one', '/variants_remove', '/variants_clear_all'): self._handle_variants() return @@ -1241,7 +1680,7 @@ class Handler(BaseHTTPRequestHandler): except Exception as e: sys.stderr.write(f'[sim_web] save_params 실패: {e}\n') self.send_response(303) - self.send_header('Location', '/?t=lab&s=tune&saved=1') + self.send_header('Location', '/?t=lab&s=bt&saved=1') self.end_headers() def _handle_variants(self): diff --git a/agents/stock/workspace/sim/sweep.py b/agents/stock/workspace/sim/sweep.py index a55b4d1..10b5bf5 100644 --- a/agents/stock/workspace/sim/sweep.py +++ b/agents/stock/workspace/sim/sweep.py @@ -10,10 +10,29 @@ from __future__ import annotations import itertools import json +import os from datetime import datetime +from multiprocessing import get_context 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 중립이라 제외). # 기존 5 + 신규 4(RSI·시간손절·리스크) 탐색. 조합폭발 억제 위해 기존 그리드는 핵심값으로 축소. # 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_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()) combos = list(itertools.product(*[grid[k] for k in keys])) - results = [] - for combo in combos: - params = dict(zip(keys, combo)) - tr = backtest.run(params, codes, date_from=train_from, date_to=train_to) - te = backtest.run(params, codes, date_from=test_from, date_to=test_to) - results.append({'params': params, 'train': tr, 'test': te}) + args = [(keys, combo, codes, train_from, train_to, test_from, test_to, windows) for combo in combos] + + # 멀티프로세싱 — 조합을 코어에 분배. macOS는 fork가 데드락 위험이라 spawn 사용 + # (워커가 메인 재import → sim/__main__.py의 `if __name__=='__main__'` 가드로 재귀 방지). + # chunksize 크게 → 워커당 연속 조합 처리로 종목 일봉 메모이즈 재활용 극대화. pool.map은 순서 유지(결정론). + 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): te = r['test'] 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) + out = { 'generated_at': datetime.now(config.KST).isoformat(), '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 / '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 @@ -91,13 +129,13 @@ if __name__ == '__main__': if res.get('error'): print('스윕 실패:', res) 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} | " - f"{'검증수익%':>8} {'검증MDD%':>8} {'거래':>5} {'승률%':>6}") + f"{'최악구간%':>8} {'검증수익%':>8} {'거래':>5} {'승률%':>6}") for r in res['results'][:12]: p, te = r['params'], r['test'] 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} " 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}") diff --git a/agents/stock/workspace/sim/variants.py b/agents/stock/workspace/sim/variants.py index 0d3e706..d014662 100644 --- a/agents/stock/workspace/sim/variants.py +++ b/agents/stock/workspace/sim/variants.py @@ -65,6 +65,44 @@ def seed_from_sweep(n: int = 3) -> list[dict]: 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: """단일 params 조합을 변이로 추가 (백테스트 행 → 비교군). 이미 있으면 None.""" variants = load_variants() diff --git a/agents/stock/workspace/state/market_indicators_history.jsonl b/agents/stock/workspace/state/market_indicators_history.jsonl index 962ef35..524f3b1 100644 --- a/agents/stock/workspace/state/market_indicators_history.jsonl +++ b/agents/stock/workspace/state/market_indicators_history.jsonl @@ -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-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-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"} diff --git a/agents/stock/workspace/state/trade_journal.jsonl b/agents/stock/workspace/state/trade_journal.jsonl index 84722b1..a42546f 100644 --- a/agents/stock/workspace/state/trade_journal.jsonl +++ b/agents/stock/workspace/state/trade_journal.jsonl @@ -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-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-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"}