diff --git a/CLAUDE.md b/CLAUDE.md index 32f31bc..17ae205 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). 전략: 방향=애널 약한게이트+가점, 타이밍=수급·기술. 매수=거래량+전고점 돌파 즉시 / 평소 눌림목(max(20일선, 전일종가−ATR)) 지정가. **분할매수·추격매수**: 목표비중을 `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` 22개 파라미터(분할매수 횟수·추격 ATR 배수·분할매도 익절 비율 포함)를 `state/sim/params.json`로 오버라이드(웹 튜닝 탭에서 저장). config import 시 기본값 위에 덮어써 엔진이 다음 스캔부터 반영. `config._DEFAULTS`가 원본 기본값. `load_params/save_params/reset_params`. - - **백테스트·스윕** (`backtest.py`/`sweep.py`): signals.py 그대로 재사용해 과거 일봉 되감기(지표 t까지·종가체결·애널 중립·수급은 flow_history 사용). `sweep`은 5파라미터(SMA_LONG 5단계·RR·STOP_ATR·PULLBACK_ATR·VOLUME_BREAKOUT) 270조합을 학습/검증(out-of-sample 0.7) 분리 비교 (애널·시장필터는 backtest 중립이라 스윕 제외) → `state/sim/backtest_results.json`. ⚠️ 종가체결·현 워치리스트(생존편향)라 절대수익 아닌 상대순위로만 해석. +- `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`(상시). + - **런타임 튜닝**: `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). - **수급 백필** (`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=`): **내 계좌**(KPI·보유/거래 펼치기) · **관심종목**(종목별 신호 체크리스트 + 상단 **종목 추가 폼**〔코드/이름〕 + 카드별 **✕ 삭제**〔수동만, 보유 중이면 청산 추적〕 + origin 라벨〔워치/관심/보유/수동〕 + 미스캔분 '스캔 대기' 표시) · **시장**(지수·ADR·상승하락·투자자 순매수, 네이버 60s 캐시) · **전략**(보조탭 3: **튜닝**〔파라미터 폼 저장〕/**백테스트**〔스윕 순위표 + 지표별 정렬바〔검증·학습·MDD·승률·손익비, sessionStorage 유지〕 + '이 튜닝 적용' + '비교군 추가/제거' 토글〔등록조합은 제거버튼+비교군 뱃지, 메인조합은 '메인' 뱃지·추가차단〕 + **'↻ 다시 계산'**〔백그라운드 sweep ~80s, 진행배너+자동갱신〕/**비교**〔병렬 변이 실시간 성적, 변이별 성적초기화/삭제〕). sticky 헤더, 당겨서 새로고침. `do_POST`: `/save_params`·`/run_sweep`·`/variants_{add,reset_one,remove}`·`/watch_{add,remove}` (그 외 읽기전용) + - **웹 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}` (그 외 읽기전용) - 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 @@ -139,7 +139,7 @@ OpenClaw 자동화는 두 갈래로 동작한다 (모두 Asia/Seoul): - **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–15:30 매 15분 (`StartInterval` 900s, 엔진이 장외/휴장 self-skip) — **자동매매 시뮬(페이퍼)** 1회 스캔. `python3 -m sim scan` (cwd `agents/stock/workspace`). 가상자본 1천만, 규칙 엔진(LLM 미경유). universe=누적 관찰목록(워치+관심+보유 자동편입·수동삭제), 방향=애널 약한게이트+가점, 타이밍=수급·기술. 결과 `state/sim/`. 실주문 `orders/` 불가침. 상세는 아래 sim 모듈 섹션·레이 MEMORY.md +- **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 ff781c7..0d45c11 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–15:30 매 15분 (`StartInterval` 900s, 엔진이 장외/휴장 self-skip) | `python3 -m sim scan` (내부 `scan_all` — 메인+병렬변이 동시) | **자동매매 시뮬(페이퍼)** 1회 스캔. 가상자본 1천만, 규칙 엔진(LLM 미경유). 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`만 쓰기. 시장 탭은 네이버 지수 60s 캐시 fetch(유일한 네트워크) | -| (트리거 없음) | on-demand | `python3 -m sim {sweep\|backtest}` | 튜닝 비교용 백테스트 스윕. signals.py 재사용·과거 일봉 되감기. 4핵심(RR·STOP_ATR·SMA_LONG·PULLBACK_ATR) 81조합 학습/검증 분리 → `state/sim/backtest_results.json`. ⚠️ 종가체결·현 워치리스트(생존편향) → 절대수익 아닌 **상대순위**로만. 수급은 `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이 매 스캔 메인+변이 함께 갱신 | +| `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-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이 매 스캔 메인+변이 함께 갱신 | 휴장일 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/backtest.py b/agents/stock/workspace/sim/backtest.py index 32cca94..25bb93e 100644 --- a/agents/stock/workspace/sim/backtest.py +++ b/agents/stock/workspace/sim/backtest.py @@ -14,9 +14,22 @@ from __future__ import annotations import math import sqlite3 import sys +from datetime import datetime from . import config, signals + +def _cal_days(entry_date: str | None, cur_date: str) -> int | None: + """두 'YYYYMMDD' 사이 달력 일수 — 시간손절용. 파싱 실패 시 None.""" + if not entry_date: + return None + try: + d0 = datetime.strptime(entry_date, '%Y%m%d') + d1 = datetime.strptime(cur_date, '%Y%m%d') + return max(0, (d1 - d0).days) + except Exception: + return None + sys.path.insert(0, str(config.SCRIPTS)) import daily_candles_cache as dcc # noqa: E402 @@ -111,7 +124,8 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to: dates_seen = [c['date'] for c in h[:i + 1]] flow = _flow_net_upto(flows.get(code), dates_seen) pos = positions[code] - dec = signals.evaluate_holding(pos, ind, flow, None) + dec = signals.evaluate_holding(pos, ind, flow, None, + held_days=_cal_days(pos.get('entry_date'), day)) pos.update(dec['position_update']) act = dec['action'] if act in ('sell', 'scale_out'): @@ -170,18 +184,18 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to: 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 = (eq / config.MAX_POSITIONS) / max(1, config.ENTRY_TRANCHES) + 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 - stop, target = signals.compute_stop_target(fill, ind['atr'], ind['recent_low']) 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} + 'last_add_price': fill, 'entry_date': day} 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 20ba49a..08931ed 100644 --- a/agents/stock/workspace/sim/config.py +++ b/agents/stock/workspace/sim/config.py @@ -28,11 +28,15 @@ SELL_TAX_RATE = 0.0018 # 매도 증권거래세 (~0.18%) # ---- 포지션 사이징 ---- MAX_POSITIONS = 8 # 동시 보유 최대 종목수 -# 종목당 목표 비중 = 자산 / MAX_POSITIONS +# 종목당 목표 비중 = 자산 / MAX_POSITIONS (= 균등비중 상한) +RISK_PER_TRADE_PCT = 0.01 # 리스크 기반 사이징: 종목당 손실 위험 = 자산 × 이 값. 수량 = 자산×이값 ÷ (진입가−손절가), 균등비중 상한으로 캡. 0=끔(순수 균등비중) # ---- 지표 기간 ---- SMA_SHORT = 5 SMA_LONG = 20 +SMA_MID = 60 # 중기 추세선 — 정배열 확인(5>20>60). 데이터 부족 시 게이트 미적용 +TREND_SLOPE_DAYS = 5 # 20일선 기울기 판정: 현재 20일선 vs N일 전 20일선(우상향 여부) +REL_STRENGTH_DAYS = 20 # 상대강도: 종목 N일 수익률 − 시장지수 N일 수익률 (라이브 전용) ATR_PERIOD = 14 BREAKOUT_LOOKBACK = 20 # 돌파 판정용 최근 고점 구간 SWING_LOW_LOOKBACK = 10 # 손절선 후보 스윙 저점 구간 @@ -44,6 +48,9 @@ VOLUME_BREAKOUT_MULT = 1.5 # 돌파 매수: 거래량 ≥ 평균 × 이 TURNOVER_BREAKOUT_MULT = 1.5 # 돌파 확인: 오늘 회전율 ≥ 20일 평균 회전율 × 이 배수 (데이터 없으면 통과) TURNOVER_OVERHEAT_MULT = 3.0 # 과열 보류: 오늘 회전율 ≥ 평균 × 이 배수면 신규매수 보류 (돌파 배수보다 높게) PULLBACK_ATR_MULT = 1.0 # 눌림목 지정가 = 전일종가 − ATR × 이 배수 (20일선과 비교 후 위쪽) +RSI_PERIOD = 14 # RSI 계산 기간 (고정 상수 — 튜닝 비노출) +RSI_OVERBOUGHT = 78.0 # 돌파 매수 시 RSI ≥ 이 값이면 과열 추격으로 보고 보류 +RSI_OVERSOLD = 25.0 # 눌림목 매수 시 RSI < 이 값이면 낙하 중(떨어지는 칼)으로 보고 보류 # ---- 분할매수·추격매수 ---- ENTRY_TRANCHES = 2 # 종목당 목표비중을 이 횟수로 나눠 진입 (1=전량 1회). 1차 = 목표/이 값 @@ -55,6 +62,7 @@ RR_RATIO = 2.0 # 목표 = 진입가 + (진입가−손절) × SCALE_OUT_FRAC = 0.0 # 분할매도: 목표 도달 시 보유량의 이 비율 익절 후 잔량 트레일링 (0=분할매도 끔, 목표 도달 시 전량 트레일링 전환) TRAIL_ATR_MULT = 1.5 # 트레일링: 고점 − ATR × 이 배수 TRAIL_ATR_MULT_TIGHT = 1.0 # 컨센 목표가 부근에서 트레일링 강화 +MAX_HOLD_DAYS = 20 # 시간손절: 보유 N일(달력) 경과 & 미진전(트레일링 전 + 진입가 이하)이면 청산해 자본 회전. 0=끔 # ---- 애널리스트 게이트 (약한 게이트) ---- ANALYST_MIN_UPSIDE_PCT = 5.0 # 컨센 목표주가 상승여력 < 이 값이면 신규매수 제외 @@ -62,6 +70,8 @@ ANALYST_SELL_OPINION_MAX = 2.5 # 투자의견(1매도~5매수) 평균이 이 # ---- 시장 필터 (소프트) ---- MARKET_BREADTH_MIN = 0.15 # 해당 시장 당일 상승비율 < 이 값이면 그 시장 신규매수 보류 +MARKET_BREADTH_STRONG = 0.60 # 상승비율 ≥ 이 값이면 지수가 20일선 아래여도 강세 인정 (지수추세 소프트 완화) +BEAR_ENTRY_MODE = 1 # 약세 국면(혼조·약세·급락) 신규매수: 0=완전 현금화(돌파도 보류) / 1=돌파만 허용 / 2=눌림목도 허용 # ---- 운영 시간 (KST) ---- MARKET_OPEN = (9, 0) @@ -87,15 +97,20 @@ TUNABLE = [ {'key': 'PULLBACK_ATR_MULT', 'label': '눌림목 ATR 배수', 'type': 'float', 'min': 0.0, 'max': 3.0, 'step': 0.1, 'group': '매수'}, {'key': 'ENTRY_TRANCHES', 'label': '분할매수 횟수', 'type': 'int', 'min': 1, 'max': 5, 'step': 1, 'group': '매수'}, {'key': 'ADD_ATR_MULT', 'label': '추격매수 ATR 배수', 'type': 'float', 'min': 0.3, 'max': 3.0, 'step': 0.1, 'group': '매수'}, + {'key': 'RSI_OVERBOUGHT', 'label': 'RSI 과열 컷(돌파)', 'type': 'float', 'min': 50.0, 'max': 95.0, 'step': 1.0, 'group': '매수'}, + {'key': 'RSI_OVERSOLD', 'label': 'RSI 과매도 컷(눌림목)', 'type': 'float', 'min': 5.0, 'max': 50.0, 'step': 1.0, 'group': '매수'}, {'key': 'STOP_ATR_MULT', 'label': '손절 ATR 배수', 'type': 'float', 'min': 0.5, 'max': 5.0, 'step': 0.1, 'group': '매도'}, {'key': 'RR_RATIO', 'label': '손익비(목표/손절)', 'type': 'float', 'min': 1.0, 'max': 5.0, 'step': 0.1, 'group': '매도'}, {'key': 'SCALE_OUT_FRAC', 'label': '분할매도 익절 비율', 'type': 'float', 'min': 0.0, 'max': 1.0, 'step': 0.05, 'group': '매도'}, {'key': 'TRAIL_ATR_MULT', 'label': '트레일링 ATR 배수', 'type': 'float', 'min': 0.5, 'max': 5.0, 'step': 0.1, 'group': '매도'}, {'key': 'TRAIL_ATR_MULT_TIGHT', 'label': '트레일링 강화 배수', 'type': 'float', 'min': 0.3, 'max': 3.0, 'step': 0.1, 'group': '매도'}, + {'key': 'MAX_HOLD_DAYS', 'label': '시간손절(보유 최대 일)', 'type': 'int', 'min': 0, 'max': 120, 'step': 1, 'group': '매도'}, {'key': 'ANALYST_MIN_UPSIDE_PCT', 'label': '최소 상승여력(%)', 'type': 'float', 'min': -50.0, 'max': 100.0, 'step': 1.0, 'group': '애널·시장'}, {'key': 'ANALYST_SELL_OPINION_MAX', 'label': '매도의견 컷(1~5)', 'type': 'float', 'min': 1.0, 'max': 5.0, 'step': 0.1, 'group': '애널·시장'}, {'key': 'MARKET_BREADTH_MIN', 'label': '시장 상승비율 하한', 'type': 'float', 'min': 0.0, 'max': 1.0, 'step': 0.01, 'group': '애널·시장'}, + {'key': 'BEAR_ENTRY_MODE', 'label': '약세장 매수(0현금/1돌파/2눌림)', 'type': 'int', 'min': 0, 'max': 2, 'step': 1, 'group': '애널·시장'}, {'key': 'MAX_POSITIONS', 'label': '동시 보유 최대 종목', 'type': 'int', 'min': 1, 'max': 20, 'step': 1, 'group': '포지션'}, + {'key': 'RISK_PER_TRADE_PCT', 'label': '종목당 리스크 비율', 'type': 'float', 'min': 0.0, 'max': 0.1, 'step': 0.005, 'group': '포지션'}, ] # 오버라이드 적용 전 원본 기본값 캡처 (튜닝 화면의 '기본값' 표시·복원용) diff --git a/agents/stock/workspace/sim/engine.py b/agents/stock/workspace/sim/engine.py index e40d32f..362da67 100644 --- a/agents/stock/workspace/sim/engine.py +++ b/agents/stock/workspace/sim/engine.py @@ -7,12 +7,94 @@ from __future__ import annotations import json import sys +import time from datetime import datetime, timedelta -from . import config, data, signals, universe +from . import benchmark, config, data, signals, universe from .portfolio import Portfolio +def _held_days(position, now) -> int | None: + """보유 경과 일수(달력) — entry_at 기준. 시간손절 판단용. 파싱 실패 시 None.""" + ts = position.get('entry_at') + if not ts: + return None + try: + return max(0, (now - datetime.fromisoformat(ts)).days) + except Exception: + return None + + +def _index_trend_ok() -> dict: + """지수별 추세 — 지수 종가가 자기 SMA_LONG 위면 True. 데이터 부족 시 True(비차단).""" + out = {} + for mk in ('KOSPI', 'KOSDAQ'): + store = benchmark.series(mk) + if not store: + continue + closes = [v for _, v in sorted(store.items())] + if len(closes) < config.SMA_LONG + 1: + out[mk] = True + continue + sma = sum(closes[-config.SMA_LONG:]) / config.SMA_LONG + out[mk] = closes[-1] >= sma + return out + + +def _index_return(mk: str, days: int): + """지수 N거래일 수익률(%) — 상대강도 비교용. 데이터 부족 시 None.""" + s = sorted(benchmark.series(mk).items()) + if len(s) > days and s[-(days + 1)][1]: + return (s[-1][1] / s[-(days + 1)][1] - 1) * 100 + return None + + +def _index_daily_change() -> dict: + """지수별 당일 등락률(%) — 전일 종가 대비. 데이터 부족 시 키 없음.""" + out = {} + for mk in ('KOSPI', 'KOSDAQ'): + s = sorted(benchmark.series(mk).items()) + if len(s) >= 2 and s[-2][1]: + out[mk] = (s[-1][1] / s[-2][1] - 1) * 100 + return out + + +# 시장 국면 6단계 — (이모지, 라벨, 매수허용, 설명, 색상). market_ok·시장탭 배지 공용. +REGIME = { + 'strong': ('🔥', '강세', True, '상승 종목 많고 지수도 20일선 위 — 추세 양호', '#16a34a'), + 'rebound': ('📈', '반등', True, '상승 종목 많지만 지수는 아직 20일선 아래 — 약세 탈출 시도', '#22c55e'), + 'mild': ('🌤', '완만 상승', True, '상승이 우위지만 강하진 않음', '#65a30d'), + 'mixed': ('➖', '혼조', False, '상승·하락 비슷 — 방향 불명, 신규매수 보류', '#6b7280'), + 'weak': ('📉', '약세', False, '하락 종목 우위 — 신규매수 보류', '#ef4444'), + 'crash': ('❄️', '급락', False, '대부분 하락 + 지수 급락 — 신규매수 보류', '#b91c1c'), +} + + +def market_regime_map() -> dict: + """시장별 국면(6단계) — 상승비율(폭) + 지수추세(방향) + 당일등락(강도) 조합. + 상승비율 항상 최신(네이버, 실패 시 EOD). market_ok·시장탭 배지가 공용으로 사용.""" + breadth = _live_breadth() + if breadth is None: + breadth = _eod_breadth() + chg = _index_daily_change() + trend = _index_trend_ok() + out = {} + for mk, v in breadth.items(): + c = chg.get(mk) + up = trend.get(mk, True) + if v < config.MARKET_BREADTH_MIN: # <15% + out[mk] = 'crash' if (c is not None and c <= -2.0) else 'weak' + elif v >= config.MARKET_BREADTH_STRONG: # ≥60% + out[mk] = 'strong' if up else 'rebound' + elif v >= 0.40: + out[mk] = 'mild' + elif v >= 0.25: + out[mk] = 'mixed' + else: + out[mk] = 'weak' + return out + + def _load_jsonl_last_per_market(path) -> dict[str, dict]: out: dict[str, dict] = {} if not path.exists(): @@ -28,22 +110,52 @@ def _load_jsonl_last_per_market(path) -> dict[str, dict]: return out -def market_ok_map() -> dict[str, bool]: - """시장별 신규매수 허용 여부 — 당일 상승비율 기반 소프트 필터 (EOD 데이터라 보수적).""" +_LIVE_BREADTH_CACHE: dict[str, float] | None = None +_LIVE_BREADTH_AT: float = 0.0 +_LIVE_BREADTH_TTL = 60.0 + + +def _live_breadth() -> dict[str, float] | None: + """장중 실시간 상승비율(rise/total) — 네이버 m.stock 1콜/시장. 60s 캐시, 실패 시 None.""" + global _LIVE_BREADTH_CACHE, _LIVE_BREADTH_AT + now = time.time() + if _LIVE_BREADTH_CACHE is not None and (now - _LIVE_BREADTH_AT) < _LIVE_BREADTH_TTL: + return _LIVE_BREADTH_CACHE + try: + sys.path.insert(0, str(config.SCRIPTS)) + import market_indicators_sync as mis + out: dict[str, float] = {} + for sym, lab in (('KOSPI', '코스피'), ('KOSDAQ', '코스닥')): + r = mis.fetch_market(sym, lab) + total = (r.get('rise', 0) + r.get('fall', 0) + r.get('steady', 0)) if r else 0 + if not total: + return None + out[sym] = r['rise'] / total + _LIVE_BREADTH_CACHE, _LIVE_BREADTH_AT = out, now + return out + except Exception: + return None + + +def _eod_breadth() -> dict[str, float]: + """장외 폴백 — 마지막 EOD 누적 상승비율.""" latest = _load_jsonl_last_per_market(config.MARKET_HISTORY) - out: dict[str, bool] = {} + out: dict[str, float] = {} for label, rec in latest.items(): - rise = rec.get('rise', 0) - fall = rec.get('fall', 0) - steady = rec.get('steady', 0) - total = rise + fall + steady - breadth = rise / total if total else 1.0 + total = rec.get('rise', 0) + rec.get('fall', 0) + rec.get('steady', 0) + breadth = rec.get('rise', 0) / total if total else 1.0 key = 'KOSPI' if '코스피' in (rec.get('market_label') or '') or label == 'KOSPI' else \ 'KOSDAQ' if '코스닥' in (rec.get('market_label') or '') or label == 'KOSDAQ' else label - out[key] = breadth >= config.MARKET_BREADTH_MIN + out[key] = breadth return out +def market_ok_map() -> dict[str, bool]: + """시장별 신규매수 허용 여부 — 6단계 국면(market_regime_map) 기반. + 강세·반등·완만상승 = 매수 허용 / 혼조·약세·급락 = 보류.""" + return {mk: REGIME[g][2] for mk, g in market_regime_map().items()} + + def _is_holiday(d: datetime) -> bool: try: hol = json.loads(config.HOLIDAYS_PATH.read_text()) @@ -101,6 +213,7 @@ def _gather(extra_codes=None): def _run_scan(pf, uni, quotes, cmkt, now) -> dict: """주어진 포트폴리오에 대해 현재 config(파라미터) 기준 스캔·체결. 스냅샷 반환(파일 기록 X).""" mkt_ok = market_ok_map() # MARKET_BREADTH_MIN 등 파라미터 의존 → 변이마다 재계산 + idx_ret = {mk: _index_return(mk, config.REL_STRENGTH_DAYS) for mk in ('KOSPI', 'KOSDAQ')} decisions: list[dict] = [] buys: list[dict] = [] sells: list[dict] = [] @@ -125,7 +238,8 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict: pf.mark(code, cur_price) flow = data.flow_net(data.investor_flow(code)) anl = data.analyst(code, cur_price) - dec = signals.evaluate_holding(pf.positions[code], ind, flow, anl) + dec = signals.evaluate_holding(pf.positions[code], ind, flow, anl, + held_days=_held_days(pf.positions[code], now)) pf.apply_position_update(code, dec['position_update']) dec.update({k: pf.positions[code].get(k) for k in ('entry_price', 'qty', 'stop', 'target', 'trailing_on', 'entry_at')}) @@ -154,6 +268,13 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict: continue # ---- 미보유: 추세 스크린 → 수급·애널 → 매수 판단 ---- + if universe.is_etf(name): + decisions.append({ + 'code': code, 'name': name, 'sources': sources, 'price': cur_price, + 'state': 'SKIP', 'reason': 'ETF — 자동매매 제외(방향성 애널 부재)', 'action': None, + 'checks': [], + }) + continue if not signals.trend_ok(ind): decisions.append({ 'code': code, 'name': name, 'sources': sources, 'price': cur_price, @@ -171,6 +292,12 @@ 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) + # 상대강도(라이브 전용): 종목 N일 수익률 − 시장지수 N일 수익률 + n = config.REL_STRENGTH_DAYS + closes_c = [c['close'] for c in series] + if len(closes_c) > n and closes_c[-(n + 1)] and idx_ret.get(market) is not None: + stock_ret = (closes_c[-1] / closes_c[-(n + 1)] - 1) * 100 + 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(): diff --git a/agents/stock/workspace/sim/portfolio.py b/agents/stock/workspace/sim/portfolio.py index aee1446..59968e1 100644 --- a/agents/stock/workspace/sim/portfolio.py +++ b/agents/stock/workspace/sim/portfolio.py @@ -9,7 +9,7 @@ from __future__ import annotations import json from datetime import datetime -from . import config +from . import config, signals def _now_iso() -> str: @@ -83,7 +83,7 @@ class Portfolio: """신규 진입(1차 트랜치). 목표비중을 ENTRY_TRANCHES 로 나눈 만큼만 매수. 체결 dict 또는 None.""" if code in self.positions or not self.can_open(): return None - full_target = self.target_position_value() + full_target = signals.target_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: diff --git a/agents/stock/workspace/sim/signals.py b/agents/stock/workspace/sim/signals.py index b6a7fef..083ec36 100644 --- a/agents/stock/workspace/sim/signals.py +++ b/agents/stock/workspace/sim/signals.py @@ -18,6 +18,13 @@ def compute_indicators(series: list[dict]) -> dict | None: atr = ind_mod.atr(series, config.ATR_PERIOD) if sma_long is None or sma_short is None or atr is None: return None + # 중기선(60) — 데이터 부족 시 None + sma_mid = ind_mod.sma(closes, config.SMA_MID) + # 20일선 기울기(우상향?) — N일 전 20일선과 비교. 데이터 부족 시 None + slope_days = config.TREND_SLOPE_DAYS + sma_long_prev = (ind_mod.sma(closes[:-slope_days], config.SMA_LONG) + if len(closes) > config.SMA_LONG + slope_days else None) + sma_long_slope_up = (sma_long > sma_long_prev) if sma_long_prev is not None else None # 회전율 — 오늘 봉(라이브 시세)엔 turnover_rate 없음 → 최근 봉의 거래량/회전율로 유통주식 추정해 환산 hist = series[:-1] @@ -38,7 +45,11 @@ def compute_indicators(series: list[dict]) -> dict | None: 'today_volume': series[-1]['volume'], 'sma_short': sma_short, 'sma_long': sma_long, + 'sma_mid': sma_mid, + 'sma_long_slope_up': sma_long_slope_up, + 'rel_strength': None, # 라이브 engine이 주입(종목−지수 N일 수익률). backtest는 None(중립) 'atr': atr, + 'rsi': ind_mod.rsi(closes, config.RSI_PERIOD), 'recent_high': ind_mod.recent_high(series, config.BREAKOUT_LOOKBACK), 'recent_low': ind_mod.recent_low(series, config.SWING_LOW_LOOKBACK), 'volume_avg': ind_mod.volume_avg(series, config.VOLUME_AVG_PERIOD), @@ -47,9 +58,28 @@ def compute_indicators(series: list[dict]) -> dict | None: } +def target_value(equity: float, fill_price: float, stop: float) -> float: + """종목당 목표 진입금액 — 리스크 기반(자산×RISK% ÷ 손절폭)을 균등비중(자산/MAX_POSITIONS) 상한으로 캡. + RISK_PER_TRADE_PCT=0 또는 손절 정보 없으면 균등비중으로 폴백. 엔진·백테스트 공용(같은 두뇌).""" + cap = equity / max(1, config.MAX_POSITIONS) + risk_pct = getattr(config, 'RISK_PER_TRADE_PCT', 0) or 0 + if risk_pct > 0 and stop and fill_price > stop: + risk_value = (equity * risk_pct) / (fill_price - stop) * fill_price + return min(cap, risk_value) + return cap + + def trend_ok(ind: dict) -> bool: - """추세 1차 스크린 — 비싼 수급·애널 호출 전 게이트.""" - return ind['price'] > ind['sma_long'] and ind['sma_short'] > ind['sma_long'] + """추세 1차 스크린 — 비싼 수급·애널 호출 전 게이트. + 기본: 현재가>20일선 & 정배열(5>20). 보강: ①20일선 우상향 ②5>중기선(60) — 데이터 있을 때만.""" + if not (ind['price'] > ind['sma_long'] and ind['sma_short'] > ind['sma_long']): + return False + if ind.get('sma_long_slope_up') is False: # ① 20일선이 꺾여 내려오면 제외 + return False + sm = ind.get('sma_mid') + if sm is not None and ind['sma_short'] <= sm: # ② 단기선이 중기선 아래면(역배열) 제외 + return False + return True def compute_stop_target(entry: float, atr: float, recent_low: float | None) -> tuple[int, int]: @@ -102,6 +132,19 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di 'detail': f"{price:,} vs {ind['sma_long']:,.0f}"}) checks.append({'label': '정배열(5>20)', 'ok': ind['sma_short'] > ind['sma_long'], 'detail': f"{ind['sma_short']:,.0f} / {ind['sma_long']:,.0f}"}) + # 1-b. 추세 질 보강 — 기울기 / 중기선 / 상대강도 (데이터 없으면 통과 표시) + slope = ind.get('sma_long_slope_up') + checks.append({'label': '추세 기울기(20일선↑)', + 'ok': None if slope is None else slope, + 'detail': '데이터 없음(통과)' if slope is None else ('우상향' if slope else '하향')}) + sm = ind.get('sma_mid') + checks.append({'label': '중기 정배열(5>60)', + 'ok': None if sm is None else ind['sma_short'] > sm, + 'detail': '데이터 없음(통과)' if sm is None else f"{ind['sma_short']:,.0f} / {sm:,.0f}"}) + rel = ind.get('rel_strength') + checks.append({'label': '상대강도(시장대비)', + 'ok': None if rel is None else rel > 0, + 'detail': '데이터 없음(통과)' if rel is None else f'{rel:+.1f}%p'}) # 2. 수급 fnet = flow.get('foreign', 0) if flow else 0 @@ -114,14 +157,16 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di gate_ok, acks, bonus = _analyst_checks(analyst, price) checks.extend(acks) + # 시장 강약 — 소프트 필터(차단 X). 약세장이면 눌림목 진입만 보류, 돌파 진입은 허용 + checks.append({'label': '시장 강세', 'ok': market_ok, + 'detail': '강세' if market_ok else '약세 — 돌파만 허용'}) + base = {'code': code, 'name': name, 'sources': sources, 'price': price, 'checks': checks, 'action': None, 'buy_path': None, 'buy_price': None, 'plan_stop': None, 'plan_target': None, 'bonus': bonus} if not gate_ok: return {**base, 'state': 'SKIP', 'reason': '애널 게이트 제외'} - if not market_ok: - return {**base, 'state': 'WAIT', 'reason': '시장 약세 — 신규매수 보류'} if not supply_ok: return {**base, 'state': 'WAIT', 'reason': '수급 미충족'} @@ -151,7 +196,21 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di else: checks.append({'label': '회전율', 'ok': None, 'detail': '데이터 없음(통과)'}) + # RSI — 돌파 과열 추격 / 눌림목 낙하 진입 방지 (데이터 없으면 통과) + rsi = ind.get('rsi') + rsi_overbought = rsi is not None and rsi >= config.RSI_OVERBOUGHT + rsi_oversold = rsi is not None and rsi < config.RSI_OVERSOLD + checks.append({'label': 'RSI', + 'ok': None if rsi is None else not (rsi_overbought or rsi_oversold), + 'detail': '데이터 없음(통과)' if rsi is None else f'{rsi:.0f}'}) + if breakout: + if not market_ok and config.BEAR_ENTRY_MODE == 0: + return {**base, 'state': 'WAIT', + 'reason': '약세장 — 완전 현금화(돌파도 보류)'} + if rsi_overbought: + return {**base, 'state': 'WAIT', + 'reason': f'돌파했지만 RSI 과열({rsi:.0f} ≥ {config.RSI_OVERBOUGHT:g}) — 추격 보류'} return {**base, 'state': 'BUY', 'action': 'buy', 'buy_path': 'breakout', 'buy_price': price, 'reason': '거래량·회전율 동반 전고점 돌파'} @@ -162,6 +221,15 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di checks.append({'label': '눌림목 도달', 'ok': pulled, 'detail': f'지정가 {limit:,} (현재 {price:,})'}) if pulled: + if not market_ok and config.BEAR_ENTRY_MODE != 2: + # 약세장에선 바닥 잡기식 눌림목 진입 보류 (모드 2=눌림목 허용이면 통과) + mode_msg = '완전 현금화' if config.BEAR_ENTRY_MODE == 0 else '돌파만 허용' + return {**base, 'state': 'WAIT', 'watch_price': limit, + 'reason': f'약세장 — 눌림목({limit:,}) 보류 ({mode_msg})'} + if rsi_oversold: + # 과매도 = 아직 떨어지는 칼 — 반등 확인 전 진입 보류 + return {**base, 'state': 'WAIT', 'watch_price': limit, + 'reason': f'눌림목이지만 RSI 과매도({rsi:.0f} < {config.RSI_OVERSOLD:g}) — 낙하 중 보류'} # 현재가가 이미 지정가 이하 → 시장에서 현재가(체결가)로 매수 return {**base, 'state': 'BUY', 'action': 'buy', 'buy_path': 'pullback', 'buy_price': price, 'watch_price': limit, @@ -171,10 +239,11 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di 'reason': f'눌림목 대기 — 지정가 {limit:,}'} -def evaluate_holding(position, ind, flow, analyst) -> dict: - """보유 종목 판단. 우선순위: 손절/추세이탈(전량) → 분할익절(scale_out) → 추격매수(add) → 유지. +def evaluate_holding(position, ind, flow, analyst, held_days=None) -> dict: + """보유 종목 판단. 우선순위: 손절/추세이탈(전량) → 시간손절 → 분할익절(scale_out) → 추격매수(add) → 유지. position 의 stop/peak/trailing/scaled_out 갱신값도 position_update 로 함께 반환. + held_days: 보유 경과 일수(달력). None이면 시간손절 미적용(엔진·백테스트가 산정해 전달). """ price = ind['price'] entry = position['entry_price'] @@ -225,6 +294,13 @@ def evaluate_holding(position, ind, flow, analyst) -> dict: if price < ind['sma_long'] and fnet < 0 and inet < 0: return {**base, 'state': 'SELL', 'action': 'sell', 'sell_frac': 1.0, 'reason': '추세·수급 이탈'} + # 1-b. 시간손절 — N일 보유했는데 트레일링 전이고 진입가도 못 넘으면(진전 없음) 청산해 자본 회전 + max_hold = getattr(config, 'MAX_HOLD_DAYS', 0) or 0 + if (held_days is not None and max_hold > 0 and held_days >= max_hold + and not trailing_on and not scaled_out and price <= entry): + return {**base, 'state': 'SELL', 'action': 'sell', 'sell_frac': 1.0, + 'reason': f'시간손절 — {held_days}일 보유·진전 없음(자본 회전)'} + # 2. 분할매도 — 목표 도달 & 미익절. 일부 익절 후 잔량은 트레일링으로 전환 if target and price >= target and not scaled_out: upd['trailing_on'] = True diff --git a/agents/stock/workspace/sim/sim_web.py b/agents/stock/workspace/sim/sim_web.py index 01b0bff..f63a602 100644 --- a/agents/stock/workspace/sim/sim_web.py +++ b/agents/stock/workspace/sim/sim_web.py @@ -19,7 +19,7 @@ import urllib.request from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from . import config +from . import config, engine from .portfolio import Portfolio BIND_HOST = '' @@ -81,6 +81,7 @@ REFRESH_JS = """ if(p.get('werr')) banner('추가 실패: "'+p.get('werr')+'" 종목을 못 찾았어요','#ef4444'); if(p.get('wrem')) banner('삭제됨: '+p.get('wrem'),'#6b7280'); setInterval(function(){ + if(document.getElementById('chk-toast')) return; /* 설명 토스트 열려 있으면 새로고침 멈춤 */ if(document.getElementById('tab-lab').checked&&document.getElementById('sub-tune').checked) return; var cur=TABS.find(function(x){var e=document.getElementById('tab-'+x);return e&&e.checked;}); var sub=SUBS.find(function(x){var e=document.getElementById('sub-'+x);return e&&e.checked;}); @@ -227,20 +228,128 @@ def _pkey(params) -> str: _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'} + 'VOLUME_BREAKOUT_MULT': 'V', 'TURNOVER_BREAKOUT_MULT': 'TB', 'TURNOVER_OVERHEAT_MULT': 'OH', + 'RSI_OVERBOUGHT': 'RSI↑', 'RSI_OVERSOLD': 'RSI↓', 'MAX_HOLD_DAYS': 'HOLD', + 'RISK_PER_TRADE_PCT': 'RISK'} def _plabel(params) -> str: return '·'.join(f'{_PSHORT.get(k, k)}{v:g}' for k, v in (params or {}).items()) +# 평가 항목 설명 — 관심종목/판단근거 칩의 ⓘ 버튼 클릭 시 토스트로 표시 (쉬운 말로) +_CHK_HELP = { + '추세': {'t': '추세 (20일선 위)', 'd': '현재가가 최근 20일 평균값(20일 이동평균선)보다 위에 있는지 봐요. 위에 있으면 최근 한 달 흐름이 상승 쪽이라는 뜻이에요. 자동매매는 떨어지는 칼을 잡지 않으려고, 추세가 위로 향한 종목만 매수 후보로 봅니다.'}, + '정배열': {'t': '정배열 (5일선 > 20일선)', 'd': '단기 평균(5일선)이 장기 평균(20일선)보다 위에 있는 상태예요. 최근 5일 기세가 한 달 평균보다 강하다 = 상승에 가속이 붙은 모양이에요. 정배열이면 추세가 살아있다고 봅니다.'}, + '수급': {'t': '수급 (외국인·기관 순매수)', 'd': '최근 5일 동안 외국인과 기관이 이 종목을 순매수(더 많이 사들임)했는지 봐요. 돈이 큰 큰손이 사면 주가가 잘 받쳐지는 경향이 있어요. 외국인·기관 둘 중 하나라도 순매수면 통과예요. (숫자 단위는 천 주)'}, + '애널': {'t': '애널리스트 의견', 'd': '증권사 애널리스트들의 종합 의견이에요. 보통 5점 만점에 높을수록 매수 추천이에요. ETF처럼 분석 대상이 아닌 종목은 데이터가 없어서 그냥 통과 처리해요.'}, + '투자의견': {'t': '투자의견 (5점 만점)', 'd': '증권사 애널리스트들의 평균 투자의견 점수예요. 5점에 가까울수록 "사라"는 의견이 많다는 뜻이고, 보통 4점 이상이면 전문가들이 긍정적으로 본다고 판단해요.'}, + '상승여력': {'t': '상승여력 (목표가 대비)', 'd': '애널리스트 평균 목표주가가 지금 가격보다 몇 % 높은지예요. +20%면 전문가들이 20% 더 오를 여지를 본다는 의미예요. 이미 목표가 근처라 여력이 적으면 매력이 줄어요.'}, + '목표가리비전': {'t': '목표가 리비전 (상향/하향)', 'd': '최근 애널리스트들이 목표주가를 올리는 중인지 내리는 중인지 봐요. "상향"이면 회사 전망이 좋아지는 방향이라 긍정 신호예요. "하향"이면 반대고요.'}, + '어닝서프라이즈': {'t': '어닝 서프라이즈', 'd': '가장 최근 실적이 시장이 예상했던 것(컨센서스)보다 좋았는지(+) 나빴는지(−)예요. 예상보다 잘 나오면(서프라이즈 +) 주가에 긍정적으로 작용하는 경우가 많아요.'}, + '시장강세': {'t': '시장 강세 (장 전체 분위기)', 'd': '그날 시장 전체에서 오른 종목의 비율이에요. 오른 종목이 기준(15%) 이상이면 "강세장"으로 봐요. 시장이 무너지는 날엔 좋은 종목도 같이 빠지기 쉬워서, 약세장에선 바닥잡기 매수는 잠깐 미루고 강한 돌파만 허용해요.'}, + '과열': {'t': '과열 아님 (회전율)', 'd': '그날 거래 회전율(거래량을 상장주식 수로 나눈 값)이 평소 평균보다 지나치게(3배 이상) 높지 않은지 봐요. 너무 과열되면 단기 꼭대기일 위험이 커서, 그럴 땐 신규매수를 잠깐 보류해요.'}, + '돌파': {'t': '돌파 (전고점)', 'd': '현재가가 최근 고점을 뚫고 올라갔는지예요. 전고점 돌파는 새로운 상승의 출발 신호로 봐요. 거래량·회전율 급증이 함께 나오면 "진짜 돌파"로 판단해 바로 매수합니다.'}, + '거래량': {'t': '거래량 급증', 'd': '그날 거래량이 평소 평균보다 크게 많은지 봐요. 거래가 확 늘었다는 건 사람들의 관심이 몰렸다는 뜻이라, 돌파에 힘이 실렸는지 확인하는 신호예요.'}, + '회전율': {'t': '회전율 급증', 'd': '회전율(거래량 ÷ 상장주식 수)이 평소보다 크게 늘었는지예요. 거래량 급증과 함께 보면 돌파의 신뢰도를 높여줘요. 데이터가 없으면 그냥 통과 처리해요.'}, + '눌림목': {'t': '눌림목 도달', 'd': '강한 종목이 잠깐 쉬며 적정 매수가까지 내려왔는지 봐요. 적정가는 "20일선"과 "전일 종가 − 하루 변동폭(ATR)" 중 더 높은 값이에요. 비싸게 쫓아 사지 않고, 추세는 살아있되 잠깐 눌린 자리에서 싸게 담으려는 지정가 진입 자리예요.'}, + 'RSI': {'t': 'RSI (과매수·과매도)', 'd': '최근 오른 폭과 내린 폭을 견줘 0~100으로 나타낸 지표예요. 70~80 위면 단기 과열(너무 많이 올랐다), 20~30 아래면 과매도(너무 많이 빠졌다)로 봐요. 돌파 매수는 과열이면 추격을 피하고, 눌림목 매수는 과매도(아직 떨어지는 중)면 반등을 기다려요.'}, + '손절선': {'t': '손절선', 'd': '이 가격 아래로 내려가면 손실을 확정하고 파는 기준선이에요. 진입가에서 변동폭(ATR)이나 최근 스윙 저점을 빼서 정해요. 트레일링이 켜지면 고점을 따라 위로 올라가며 번 수익을 지켜요.'}, +} + + +def _chk_key(label) -> str: + """체크 항목 라벨 → 설명 키 (라벨에 숫자·괄호가 섞여 있어 접두사로 매칭).""" + l = (label or '').strip() + for pre, key in (('추세', '추세'), ('정배열', '정배열'), ('수급', '수급'), ('애널', '애널'), + ('투자의견', '투자의견'), ('상승여력', '상승여력'), ('목표가', '목표가리비전'), + ('어닝', '어닝서프라이즈'), ('시장', '시장강세'), ('과열', '과열'), + ('돌파', '돌파'), ('거래량', '거래량'), ('회전율', '회전율'), ('눌림목', '눌림목'), + ('RSI', 'RSI'), ('손절', '손절선')): + if l.startswith(pre): + return key + return '' + + def _chips(checks) -> str: - """신호 체크리스트 → 칩 HTML (판단근거 표시 공용).""" - return ''.join( - f'' - f'{_check_mark(c.get("ok"))} {_esc(c.get("label"))}{_esc(c.get("detail"))}' - for c in (checks or []) - ) + """신호 체크리스트 → 칩 HTML (판단근거 표시 공용). 각 항목에 ⓘ 버튼 — 클릭 시 설명 토스트.""" + out = [] + for c in (checks or []): + k = _chk_key(c.get('label')) + info = f'' if k else '' + out.append( + f'' + f'{_check_mark(c.get("ok"))} {_esc(c.get("label"))}' + f'{_esc(c.get("detail"))}{info}' + ) + return ''.join(out) + + +# 전략 튜닝 탭 파라미터 설명 — 같은 ⓘ 토스트 핸들러 재사용 (data-k = 파라미터 key) +_TUNE_HELP = { + 'SMA_SHORT': {'t': '단기 이동평균 (일)', 'd': '단기 추세를 보는 평균선 기간이에요. 5면 최근 5일 평균이에요. 짧을수록 가격 변화에 더 민감하게(빠르게) 반응해요. "정배열(단기선>장기선)" 판단에 쓰여요.'}, + 'SMA_LONG': {'t': '장기 이동평균 (일)', 'd': '큰 추세를 보는 평균선 기간이에요. 20이면 최근 20일(약 한 달) 평균이에요. "추세(이 선 위인지)"와 "정배열"의 기준선이에요. 길수록 큰 흐름만 봐요.'}, + 'ATR_PERIOD': {'t': 'ATR 기간 (일)', 'd': 'ATR은 하루에 가격이 보통 얼마나 출렁이는지(평균 변동폭)예요. 이 기간만큼의 변동폭을 평균내요. 손절·눌림목·트레일링 폭을 계산하는 데 쓰여요.'}, + 'BREAKOUT_LOOKBACK': {'t': '돌파 고점 구간 (일)', 'd': '"전고점 돌파"를 따질 때 며칠간의 최고가를 기준으로 할지예요. 20이면 최근 20일 고점을 넘어야 돌파로 봐요. 길수록 더 의미 있는 고점을 요구해요.'}, + 'SWING_LOW_LOOKBACK': {'t': '스윙 저점 구간 (일)', 'd': '손절선을 잡을 때 참고하는 최근 저점 구간이에요. 이 기간의 저점 근처를 손절 후보로 써서, 의미 있는 지지선 아래로 빠지면 정리해요.'}, + 'VOLUME_AVG_PERIOD': {'t': '거래량 평균 구간 (일)', 'd': '"거래량 급증"을 판단할 때 비교 기준이 되는 평소 평균 거래량을 며칠로 낼지예요. 이 평균보다 확 늘면 관심이 몰린 걸로 봐요.'}, + 'FLOW_DAYS': {'t': '수급 합산 (일)', 'd': '외국인·기관 순매수를 며칠치 합쳐서 볼지예요. 5면 최근 5일 합계예요. 길수록 하루 변동이 아닌 꾸준한 큰손 매수를 봐요.'}, + 'VOLUME_BREAKOUT_MULT': {'t': '돌파 거래량 배수', 'd': '돌파로 인정하려면 그날 거래량이 평소 평균의 몇 배 이상이어야 하는지예요. 2면 평소 2배 넘게 거래돼야 "진짜 돌파"로 봐요. 높일수록 조건이 까다로워져요.'}, + 'TURNOVER_BREAKOUT_MULT': {'t': '돌파 회전율 배수', 'd': '돌파 인정에 필요한 회전율(거래 활발도) 배수예요. 거래량 배수와 함께 봐서 돌파에 힘이 실렸는지 확인해요.'}, + 'TURNOVER_OVERHEAT_MULT': {'t': '과열 보류 회전율 배수', 'd': '회전율이 평소의 몇 배를 넘으면 "과열"로 보고 신규매수를 보류할지예요. 3이면 평소 3배 넘게 과열되면 단기 꼭대기 위험으로 잠깐 쉬어요.'}, + 'PULLBACK_ATR_MULT': {'t': '눌림목 ATR 배수', 'd': '눌림목 매수가를 "전일 종가 − (변동폭 ATR × 이 배수)"로 잡아요. 클수록 더 깊이 떨어진 자리에서 사려고 해요 — 더 싸게 사지만 기회는 줄어들어요.'}, + 'ENTRY_TRANCHES': {'t': '분할매수 횟수', 'd': '한 종목 목표 비중을 몇 번에 나눠 살지예요. 3이면 1/3씩 세 번에 걸쳐 들어가요. 한 번에 다 사지 않아 평균 단가 위험을 줄여요.'}, + 'ADD_ATR_MULT': {'t': '추격매수 ATR 배수', 'd': '추가 매수(추격)할 때, 직전 매수가보다 "변동폭 ATR × 이 배수"만큼 더 오르면 다음 분할을 사요. 추세가 계속될 때만 따라 올라타는 기준이에요.'}, + 'RSI_OVERBOUGHT': {'t': 'RSI 과열 컷 (돌파)', 'd': '돌파 매수할 때 RSI가 이 값 이상이면 단기 과열로 보고 추격을 보류해요. 78이면 RSI 78 이상일 때 안 사요. 낮출수록 더 깐깐하게(덜) 사고, 너무 높이면 과열 종목도 사게 돼요.'}, + 'RSI_OVERSOLD': {'t': 'RSI 과매도 컷 (눌림목)', 'd': '눌림목 매수할 때 RSI가 이 값 미만이면 아직 떨어지는 중(과매도)으로 보고 진입을 미뤄요. 25면 RSI 25 미만일 때 기다려요. "떨어지는 칼을 잡지 않으려는" 안전장치예요.'}, + 'STOP_ATR_MULT': {'t': '손절 ATR 배수', 'd': '손절선을 "매수가 − (변동폭 ATR × 이 배수)"로 잡아요. 2면 하루 변동폭의 2배까지 빠지면 손절이에요. 클수록 여유롭게 두지만(잘 안 잘림) 손실은 커질 수 있어요.'}, + 'RR_RATIO': {'t': '손익비 (목표 ÷ 손절)', 'd': '목표 수익이 손절 위험의 몇 배인지예요. 2면 1 잃을 위험에 2 벌 목표를 둬요. 높일수록 큰 수익을 노리지만 목표 도달은 어려워져요.'}, + 'SCALE_OUT_FRAC': {'t': '분할매도 익절 비율', 'd': '목표가에 닿으면 보유 수량 중 몇 %를 먼저 익절할지예요. 0.5면 절반을 팔아 수익을 챙기고, 나머지는 더 끌고 가며 트레일링으로 관리해요.'}, + 'TRAIL_ATR_MULT': {'t': '트레일링 ATR 배수', 'd': '익절 후 남은 물량의 "따라가는 손절선"을 고점에서 "변동폭 ATR × 이 배수"만큼 아래에 둬요. 주가가 오르면 같이 올라가고, 떨어지면 그 선에서 정리해요.'}, + 'TRAIL_ATR_MULT_TIGHT': {'t': '트레일링 강화 배수', 'd': '수익이 충분히 났을 때 트레일링 폭을 더 좁게(타이트하게) 조이는 배수예요. 번 것을 더 꼭 지키려고 고점에 더 가깝게 따라붙어요.'}, + 'MAX_HOLD_DAYS': {'t': '시간손절 (보유 최대 일)', 'd': '한 종목을 산 뒤 이 일수(달력 기준)가 지나도 진전이 없으면(트레일링 시작 전이고 진입가도 못 넘김) 정리해서 자본을 다른 기회로 돌려요. 20이면 약 한 달이에요. 0이면 시간손절을 끕니다.'}, + 'ANALYST_MIN_UPSIDE_PCT': {'t': '최소 상승여력 (%)', 'd': '매수 후보가 되려면 애널리스트 목표가까지 최소 몇 %의 여력이 있어야 하는지예요. 이보다 낮으면(이미 목표가 근처면) 매력이 부족하다고 봐요.'}, + 'ANALYST_SELL_OPINION_MAX': {'t': '매도의견 컷 (1~5)', 'd': '애널리스트 평균 의견 점수가 이 값 이하로 낮으면(매도에 가까우면) 매수 후보에서 빼요. 전문가가 부정적인 종목을 거르는 방향성 안전장치예요.'}, + 'MARKET_BREADTH_MIN': {'t': '시장 상승비율 하한', 'd': '그날 오른 종목 비율이 이 값 미만이면 "약세장"으로 봐요. 0.15면 15%예요. 약세장에선 바닥잡기 매수는 미루고 강한 돌파만 허용해요.'}, + 'BEAR_ENTRY_MODE': {'t': '약세장 매수 모드', 'd': '시장이 약세 국면(혼조·약세·급락)일 때 어떻게 살지예요. 0=완전 현금화(돌파도 안 사고 무조건 쉼) · 1=돌파만 허용(강하게 치고 오르는 종목만, 바닥잡기는 안 함) · 2=눌림목도 허용(약세장에도 적정가 오면 저가 매수). 숫자가 클수록 약세장에 적극적이에요. 기본 1.'}, + 'MAX_POSITIONS': {'t': '동시 보유 최대 종목', 'd': '한 번에 들고 갈 수 있는 최대 종목 수예요. 분산은 되지만 너무 많으면 관리가 어려워요. 이 수에 차면 신규매수를 멈춰요. "종목당 균등비중 = 자산 ÷ 이 수"의 기준이기도 해요.'}, + 'RISK_PER_TRADE_PCT': {'t': '종목당 리스크 비율', 'd': '한 종목에서 감수할 손실(손절까지 갔을 때)을 자산의 몇 %로 제한할지예요. 0.01이면 1%. 수량 = 자산 × 이 비율 ÷ 손절폭으로 정해서, 손절폭이 큰(변동성 큰) 종목은 자동으로 적게 사요. 종목마다 위험을 균등하게 맞춰 큰 손실 한 방을 막아요. 0이면 끄고 균등비중으로 사요.'}, +} + + +_CHK_HELP_JS = (""" + +""").replace('__HELP_JSON__', json.dumps({**_CHK_HELP, **_TUNE_HELP}, ensure_ascii=False)) def _kpi(label, value, sub=''): @@ -248,6 +357,11 @@ def _kpi(label, value, sub=''): return f'
{_esc(label)}
{_esc(value)}
{sub_h}
' +def _kvline(k, v): + """한 줄 항목(라벨 좌 / 값 우). v는 신뢰 HTML(색상·muted 등 허용).""" + return f'
{k}{v}
' + + def _signed(v, suffix='억'): c = '#22c55e' if (v or 0) > 0 else '#ef4444' if (v or 0) < 0 else '#9ca3af' return f'{(v or 0):+,}{suffix}' @@ -256,6 +370,32 @@ def _signed(v, suffix='억'): _idx_cache = {'data': {}, 'ts': 0.0} _IDX_TTL = 60.0 +_mkt_cache = {'data': None, 'ts': 0.0} +_MKT_TTL = 60.0 + + +def _get_live_market() -> dict | None: + """장중 실시간 시장통계(상승하락·ADR·투자자 순매수) — 네이버 m.stock, 60s 캐시. 실패 시 None.""" + now = time.time() + if _mkt_cache['data'] and now - _mkt_cache['ts'] < _MKT_TTL: + return _mkt_cache['data'] + try: + sys.path.insert(0, str(config.SCRIPTS)) + import market_indicators_sync as mis + out = {} + for sym, lab in (('KOSPI', '코스피'), ('KOSDAQ', '코스닥')): + rr = mis.fetch_market(sym, lab) + if rr: + rr['date'] = rr.get('bizdate') or '' + out[sym] = rr + if out: + _mkt_cache['data'] = out + _mkt_cache['ts'] = now + return out + except Exception as e: + sys.stderr.write(f'[sim_web] 실시간 시장통계 fetch 실패: {e}\n') + return None + def _fetch_naver_index(code: str) -> dict | None: """네이버 지수 1개 (코스피/코스닥) closePrice·등락. 실패 시 None.""" @@ -306,12 +446,14 @@ def _market_panel() -> str: return '
시장 데이터 읽기 실패
' idx = _get_indices() + live = _get_live_market() # 세션 무관 항상 최신(네이버는 장 마감 후에도 당일 최종값 제공), 실패 시만 EOD 폴백 + regimes = engine.market_regime_map() # 6단계 국면 (매수판단과 동일 소스) order = ['KOSPI', 'KOSDAQ'] keys = [k for k in order if k in by_market] + [k for k in by_market if k not in order] out = '' for k in keys: recs = by_market[k] - r = recs[-1] + r = {**recs[-1], **live[k]} if live and k in live else recs[-1] ix = idx.get(k) if ix: ic = '#22c55e' if ix['change'] > 0 else '#ef4444' if ix['change'] < 0 else '#9ca3af' @@ -324,8 +466,9 @@ def _market_panel() -> str: rise, fall, steady = r.get('rise', 0), r.get('fall', 0), r.get('steady', 0) total = rise + fall + steady breadth = (rise / total * 100) if total else 0 - ok = total and (rise / total) >= config.MARKET_BREADTH_MIN - vlabel, vcolor = ('양호', '#22c55e') if ok else ('약세', '#ef4444') + grade = regimes.get(k, 'mixed') + r_em, r_lbl, r_buy, r_desc, r_color = engine.REGIME[grade] + vlabel, vcolor = f'{r_em} {r_lbl}', r_color adr = r.get('adr') adr_s = f'{adr:.1f}' if isinstance(adr, (int, float)) else '—' recent = [rr.get('adr') for rr in recs[-5:] if isinstance(rr.get('adr'), (int, float))] @@ -338,15 +481,20 @@ def _market_panel() -> str: f'
ADR{adr_s}
' f'
상승 / 하락{rise:,} / {fall:,} (보합 {steady:,})
' f'
상한 / 하한{r.get("upper",0):,} / {r.get("lower",0):,}
' - f'
상승비율{breadth:.1f}% · {vlabel} (기준 {config.MARKET_BREADTH_MIN*100:.0f}%)
' + f'
상승비율{breadth:.1f}%
' + f'
국면{vlabel}
' + f'
{r_desc}{" · 신규매수 허용" if r_buy else ""}
' f'
개인 순매수{_signed(r.get("personal"))}
' f'
외국인 순매수{_signed(r.get("foreign"))}
' f'
기관 순매수{_signed(r.get("institutional"))}
' f'
최근 ADR(5일){recent_s}
' f'' ) - note = ('
EOD(장 마감 후 수집) 데이터 · 신규 매수 필터에만 반영 ' - '· 순매수 단위 억원
') + if live: + live_lbl = '장중 실시간' if engine.is_market_session() else '직전 장 마감 기준' + note = f'
{live_lbl}(네이버 m.stock 실시간) · 순매수 단위 억원
' + else: + note = ('
EOD 폴백(실시간 조회 실패) · 순매수 단위 억원
') return out + note @@ -364,6 +512,7 @@ def _tune_panel() -> str: body += ( f'
' + f'' f'
' ) @@ -395,17 +544,112 @@ def _bt_alpha_row(res: dict) -> str: f'{_alpha_inline(bench)}') +def _market_fit(p: dict) -> tuple[str, str]: + """조합(설정값 세트)이 어떤 시장에서 강세/약세인지 — 파라미터 성격 기반 결정론적 설명.""" + sma = p.get('SMA_LONG', 20); rr = p.get('RR_RATIO', 2.0); stop = p.get('STOP_ATR_MULT', 2.0) + vol = p.get('VOLUME_BREAKOUT_MULT', 1.5); pull = p.get('PULLBACK_ATR_MULT', 1.0) + risk = p.get('RISK_PER_TRADE_PCT', 0.01); hold = p.get('MAX_HOLD_DAYS', 0) + tags, strong, weak = [], [], [] + tags.append('크게 먹으려 오래 들고가는 편' if rr >= 2.5 else + '조금씩 빨리 먹고 빠지는 편' if rr <= 1.5 else '적당히 균형 잡힌 편') + if sma <= 15: tags.append('시세에 빠르게 반응') + elif sma >= 40: tags.append('큰 흐름만 천천히') + if risk >= 0.02: tags.append('한 종목에 크게 베팅') + elif risk <= 0.005: tags.append('조심조심 적게 베팅') + + if vol <= 1.3: + strong.append('거래가 활발하고 쭉쭉 오르는 장 — 치고 올라갈 때 잘 따라붙어요') + weak.append('거래도 뜸하고 옆으로 횡보하는 장 — 오르는 척하다 도로 빠질 때 속아요') + elif vol >= 2.0: + strong.append('확실하게 크게 터지며 오르는 장') + weak.append('조용한 장 — 살 만한 신호가 잘 안 나와서 기회가 적어요') + if rr >= 2.5: + strong.append('한 방향으로 길게 쭉 가는 장 — 오래 들고 크게 먹어요') + weak.append('올랐다 내렸다 왔다갔다 하는 장 — 목표에 닿기 전에 꺾여요') + elif rr <= 1.5: + strong.append('출렁출렁 자주 움직이는 장 — 조금씩 빨리 먹기 좋아요') + weak.append('한 방향으로 크게 가는 장 — 너무 일찍 팔아서 아쉬워요') + if stop <= 1.5: + strong.append('완만하게 꾸준히 오르는 장 — 손실을 작게 끊어요') + weak.append('출렁임이 심한 장 — 자꾸 손절에 걸려서 잘려요') + elif stop >= 2.5: + strong.append('흔들려도 결국 오르는 장 — 출렁임을 버텨내요') + weak.append('확 빠지는 급락장 — 한 번에 크게 손해 봐요') + if pull >= 1.5: + strong.append('올랐다 잠깐 쉬며 내려주는 장 — 싸게 살 기회가 생겨요') + weak.append('쉬지 않고 곧장 오르기만 하는 장 — 살 자리를 안 줘서 못 사요') + if risk >= 0.02: + strong.append('쭉 오르는 장 — 크게 베팅한 만큼 수익도 커요') + weak.append('내리거나 출렁이는 장 — 손실도 그만큼 커서 계좌가 휘청여요') + if sma <= 15: + weak.append('자잘하게 흔들리기만 하는 장 — 빠른 신호가 자꾸 헛다리 짚어요') + elif sma >= 40: + weak.append('빠르게 휙휙 바뀌는 장 — 굼떠서 한발 늦게 들어가요') + if hold and hold <= 10: + strong.append('짧게 끝나는 장 — 돈을 빨리 돌려 다음 기회로 넘어가요') + + def _dedup(lst): + seen, out = set(), [] + for x in lst: + if x not in seen: + seen.add(x); out.append(x) + return out + strong, weak = _dedup(strong), _dedup(weak) + body = ('한마디로: ' + ', '.join(tags) + ' 스타일이에요.\n\n' + '💪 이런 장에서 잘 벌어요\n' + '\n'.join('· ' + x for x in strong) + + '\n\n⚠️ 이런 장에선 약해요\n' + '\n'.join('· ' + x for x in weak) + + '\n\n※ 설정값 성격으로 따져본 거예요 — 참고만 하세요') + return '🌐 어떤 시장에 강한가', body + + +def _market_fit_summary(p: dict) -> str: + """조합 성격을 한 줄로 — 행 제목용. 예: '상승장에 강한 빠른·공격 단타형'.""" + 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 '균형형' + 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}' + + +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' + + +_BT_TYPES = [ + ('trend', '📈 추세추종형', '손익비 높게(RR 2.5↑) — 큰 추세를 끝까지 먹는 형, 강한 상승장 특화'), + ('swing', '⚖️ 균형형', '손익비 중간(RR 2.0) — 적당히 먹고 나오는 무난한 중도'), + ('scalp', '⚡ 단타·회전형', '손익비 낮게(RR 1.5↓) — 짧게 먹고 빠르게 회전, 출렁이는 장 특화'), +] +_BT_TOPN = 8 + + def _backtest_panel() -> str: """백테스트 스윕 순위표 — 검증기간(out-of-sample) 성적순. 행마다 '이 튜닝 적용'.""" 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("train","?")} → 검증 {sp.get("test","?")} · ' - f'{res.get("count")}개 조합 · 수급반영 {res.get("flow_used")} · ' - f'생성 {res.get("generated_at","")[:16].replace("T"," ")}
' - '
⚠ 절대수익보다 상대순위·검증기간 일관성으로 판단 ' - '(수급 중립·종가체결·현 워치리스트 기준)
') + head = f'
검증 {sp.get("test","?")} · {res.get("count")}개 조합
' st = _sweep_status() if st.get('state') == 'running': ctrl = ('
' @@ -416,35 +660,26 @@ def _backtest_panel() -> str: if st.get('state') == 'error' else '') ctrl = (err + '
' '
') - sortbar = ( - '
' - '' - '' - '' - '' - '' - '
' - ) from . import variants as vmod registered = {_pkey(v['params']): v['id'] for v in vmod.load_variants()} main_p = config.load_params() main_pkey = _pkey({k: main_p[k] for k in res.get('swept_params', []) if k in main_p}) - rows = '' - for i, r in enumerate(res['results'][:40]): + min_tr = res.get('min_trades', 0) or 0 + + 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 - main_badge = ' 메인' if is_main else '' - cmp_badge = (' 비교군' - if vid else '') - if vid: # 비교군 등록됨 → 제거 (메인 중복이라도 정리 가능) + main_badge = ' ' if is_main else '' + cmp_badge = ' 🔖' if vid else '' + if vid: cmp_btn = (f'
' f'' f'' f'
') - elif is_main: # 메인은 비교 탭에 항상 포함 → 추가 불가 + elif is_main: cmp_btn = '
메인 — 비교 탭에 항상 포함
' else: cmp_btn = (f'
{hidden}' @@ -452,20 +687,14 @@ def _backtest_panel() -> str: teret = te.get('total_return_pct', 0) trret = tr.get('total_return_pct', 0) tc = '#22c55e' if teret >= 0 else '#ef4444' - overfit = (trret - teret) > 40 # 학습≫검증 → 과최적 의심 + overfit = (trret - teret) > 40 of_tag = ' 과최적?' if overfit else '' - rank = f'#{i+1}' - # 정렬용 수치 (없는 값은 최하위로 — MDD는 낮을수록 좋아 큰값, 나머지는 작은값) mdd, wr, pf = te.get('mdd_pct'), te.get('win_rate_pct'), te.get('profit_factor') - d_te = teret if isinstance(teret, (int, float)) else -999 - d_tr = trret if isinstance(trret, (int, float)) else -999 - d_mdd = mdd if isinstance(mdd, (int, float)) else 9999 - d_wr = wr if isinstance(wr, (int, float)) else -1 - d_pf = 9e9 if pf == 'inf' else (pf if isinstance(pf, (int, float)) else -1) - rows += ( - f'
{rank} ' - f'{_esc(_plabel(p))}{main_badge}{cmp_badge}' + mf_t, mf_b = _market_fit(p) + mf_sum = _market_fit_summary(p) + return ( + f'
{rank_label} ' + f'{_esc(mf_sum)}{main_badge}{cmp_badge}' f'검증 {teret:+g}%' f'
' f'
검증 수익 / 학습 수익' @@ -474,13 +703,49 @@ def _backtest_panel() -> str: f'
검증 MDD{mdd}%
' f'
검증 거래수 / 승률{te.get("trades")}건 · {wr}%
' f'
검증 손익비(PF){pf}
' + f'
파라미터{_esc(_plabel(p))}
' + f'' f'
' f'{hidden}' f'' f'{cmp_btn}' f'
' ) - return head + ctrl + sortbar + f'
{rows}
' + _BT_SORT_JS + + # 중복(동일 params) 제거 → 타입 분류 → 타입별 검증수익 상위 N + 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} + for r in uniq.values(): + grouped.setdefault(_strategy_type(r['params']), []).append(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}') + + 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}
' def _compare_panel() -> str: @@ -512,14 +777,22 @@ def _compare_panel() -> str: f'· {len(rows_data)}개 (메인 포함)
' '
⚠ 같은 라이브 시세에 서로 다른 튜닝 동시 운영. ' '기간 길수록·거래 많을수록 신뢰 · 변이는 백테스트 탭의 「+ 비교군에 추가」로 등록
') + clear_btn = ('' if not defs else + '
' + f'
') + title = f'

병렬 페이퍼 비교{clear_btn}

' rows = '' for i, v in enumerate(rows_data): 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 '' + 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() + mf_t, mf_b = _market_fit(mp) + mf_sum = _market_fit_summary(mp) actions = '' if is_main else ( f'
' f'
str: ) rows += ( f'
' - f'#{i+1} {_esc(v["name"])}{tag}' + f'#{i+1} {_esc(mf_sum)}{tag}' f'{ret:+g}%' f'
' f'
평가자산{s.get("equity",0):,}원
' @@ -545,10 +818,37 @@ def _compare_panel() -> str: 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'{actions}' f'
' ) - return head + rows + return title + head + rows + + +def _engine_status(snap, summ) -> tuple[str, str]: + """스캔 엔진 헬스 → (점 클래스 ok/idle/bad, 토스트 상세). 장중 최근스캔=초록, 장중 멈춤=빨강, 장외=대기.""" + now = datetime.now(config.KST) + session = engine.is_market_session(now) + ts = snap.get('scanned_at') + age_min, last_str = None, '—' + if ts: + try: + dt = datetime.fromisoformat(ts) + age_min = (now - dt).total_seconds() / 60 + 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):,}원") + if session: + if age_min is not None and age_min <= 12: + return 'ok', (f"🟢 시뮬 가동 중\n장중 · 5분 주기 자동 스캔\n" + f"마지막 스캔 {last_str} ({age_min:.0f}분 전)\n{asset}") + agetxt = f'{age_min:.0f}분 전' if age_min is not None else '기록 없음' + return 'bad', (f"🔴 스캔 멈춤 의심\n장중인데 마지막 스캔이 {agetxt}입니다.\n" + f"sim-scan launchd 점검이 필요할 수 있어요.\n{asset}") + return 'idle', (f"🟡 대기 중 (장 마감/장외)\n다음 개장(평일 09:00)에 자동 재개돼요.\n" + f"마지막 스캔 {last_str}\n{asset}") def render_html() -> str: @@ -565,21 +865,21 @@ def render_html() -> str: 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' - # ---- KPI ---- + # ---- 계좌 요약 (한 줄씩 나열) ---- + wr = s.get('win_rate_pct') kpis = ( - _kpi('평가자산', f"{s.get('equity', 0):,}원", f"초기 {s.get('initial', 0):,}") + - _kpi('총보유(주식)', f"{s.get('held_value', 0):,}원") + - _kpi('예수금', f"{s.get('cash', 0):,}원") + - f'
수익률
' - f'
{ret:+.2f}%
' + - _kpi('실현손익', f"{s.get('realized_pnl', 0):,}") + - _kpi('승률', f"{s.get('win_rate_pct')}%" if s.get('win_rate_pct') is not None else '—', - f"{s.get('closed_trades', 0)}건 청산") + - _kpi('MDD', f"{s.get('max_drawdown_pct', 0)}%") + - _kpi('보유', f"{s.get('open_positions', 0)}종목") + _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 {} @@ -587,12 +887,11 @@ def render_html() -> str: b = (bench.get('indices') or {}).get(idx, {}) a, ip = b.get('alpha'), b.get('pct') if a is None: - kpis += _kpi(f'{lbl} 대비', '—', '지수 데이터 없음') + kpis += _kvline(f'{lbl} 대비(알파)', '— (지수 데이터 없음)') else: ac = '#22c55e' if a >= 0 else '#ef4444' - kpis += (f'
{lbl} 대비(알파)
' - f'
{a:+.2f}%p
' - f'
지수 {ip:+.2f}%
') + kpis += _kvline(f'{lbl} 대비(알파)', + f'{a:+.2f}%p (지수 {ip:+.2f}%)') # ---- 보유 포지션 ---- pos_rows = '' @@ -734,10 +1033,10 @@ h2{{font-size:15px;margin:22px 0 8px;border-bottom:1px solid #1f2937;padding-bot h3.grp{{font-size:13px;margin:16px 0 8px;color:#cbd5e1}} .badge{{color:#fff;border-radius:6px;padding:1px 8px;font-size:12px;font-weight:600}} .row{{background:#141b24;border:1px solid #1f2937;border-radius:10px;margin-bottom:8px;overflow:hidden}} -.row>summary{{list-style:none;cursor:pointer;padding:10px 12px;display:flex;align-items:center;gap:8px;font-size:13px}} +.row>summary{{list-style:none;cursor:pointer;padding:10px 12px;display:flex;flex-wrap:nowrap;align-items:center;gap:8px;font-size:13px;overflow:hidden}} .row>summary::-webkit-details-marker{{display:none}} -.row>summary .nm{{font-weight:600}} -.row>summary .rt{{margin-left:auto;display:flex;align-items:center;gap:8px;font-variant-numeric:tabular-nums}} +.row>summary .nm{{font-weight:600;flex:1 1 auto;min-width:0;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}} +.row>summary .rt{{margin-left:auto;display:flex;align-items:center;gap:8px;font-variant-numeric:tabular-nums;flex:0 0 auto;white-space:nowrap;font-size:11px}} .row>summary::after{{content:'›';color:#6b7280;font-size:16px;line-height:1;transition:transform .15s}} .row[open]>summary::after{{transform:rotate(90deg)}} .row .body{{padding:2px 12px 10px;border-top:1px solid #161e29}} @@ -753,6 +1052,24 @@ 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}} +.chk-i{{background:none;border:none;color:#60a5fa;font-size:11px;cursor:pointer;padding:0 0 0 4px;line-height:1;vertical-align:baseline}} +.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}} +.hdot.bad{{background:#ef4444;box-shadow:0 0 7px #ef4444;animation:hdot-bl 1s infinite}} +@keyframes hdot-bl{{50%{{opacity:.35}}}} +.mfit{{display:block;width:100%;margin:8px 0 2px;background:#0e2236;color:#7dd3fc;border:1px solid #1e3a5f;border-radius:8px;padding:8px;font-size:12px;font-weight:600;cursor:pointer}} +.bt-type{{margin:18px 0 8px;font-size:14px;font-weight:700;border-bottom:1px solid #1f2937;padding-bottom:5px}} +.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}} +.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}} +@keyframes ct-up{{from{{transform:translateY(100%)}}to{{transform:translateY(0)}}}} +#chk-toast .ct-t{{font-size:15px;font-weight:700;color:#fff;margin-bottom:8px}} +#chk-toast .ct-d{{font-size:13px;line-height:1.6;color:#cbd5e1;white-space:pre-line}} +#chk-toast .ct-x{{margin-top:14px;width:100%;background:#1e3a8a;color:#fff;border:1px solid #2952cc;border-radius:9px;padding:10px;font-size:13px;font-weight:600;cursor:pointer}} input[name="tab"],input[name="subtab"]{{display:none}} .hd{{position:sticky;top:0;z-index:30;background:#0b0f14;padding:6px 0;border-bottom:1px solid #161e29}} #page.dragging .hd{{position:static}} @@ -827,16 +1144,16 @@ input[name="tab"],input[name="subtab"]{{display:none}} - + - +
-

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

+

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

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

보유 포지션

{pos_table}

최근 거래

{tr_table}
@@ -853,16 +1170,17 @@ input[name="tab"],input[name="subtab"]{{display:none}}

시장 동향

{market_html}
- -

파라미터 튜닝

{tune_html}
+ +
{cmp_html}

백테스트 순위표

{bt_html}
-

병렬 페이퍼 비교

{cmp_html}
+

파라미터 튜닝

{tune_html}
60초 자동 새로고침 · 당겨서 새로고침 · sim/ 가상 엔진 (실주문 아님)
{PTR_HTML} {REFRESH_JS} {_WATCH_SEARCH_JS} +{_CHK_HELP_JS} """ @@ -902,7 +1220,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header('Location', '/?t=lab&s=bt') self.end_headers() return - if self.path in ('/variants_add', '/variants_reset_one', '/variants_remove'): + if self.path in ('/variants_add', '/variants_reset_one', '/variants_remove', '/variants_clear_all'): self._handle_variants() return if self.path in ('/watch_add', '/watch_remove'): @@ -948,6 +1266,8 @@ class Handler(BaseHTTPRequestHandler): vmod.reset_variant(form['id']) elif self.path == '/variants_remove' and form.get('id'): vmod.remove_variant(form['id']) + elif self.path == '/variants_clear_all': + vmod.clear() # 등록 변이 전부 삭제 (메인은 variants.json에 없어 보존) except Exception as e: sys.stderr.write(f'[sim_web] variants {self.path} 실패: {e}\n') self.send_response(303) diff --git a/agents/stock/workspace/sim/sweep.py b/agents/stock/workspace/sim/sweep.py index 7b89d41..a55b4d1 100644 --- a/agents/stock/workspace/sim/sweep.py +++ b/agents/stock/workspace/sim/sweep.py @@ -15,13 +15,18 @@ from datetime import datetime from . import backtest, config, universe # 백테스트에서 실제 영향 있는 파라미터만 스윕 (애널 게이트·시장필터는 backtest 중립이라 제외). -# SMA_LONG 5단계로 추세 속도 다양화 + 돌파 강도 추가. 5×3×3×3×2 = 270 조합. +# 기존 5 + 신규 4(RSI·시간손절·리스크) 탐색. 조합폭발 억제 위해 기존 그리드는 핵심값으로 축소. +# base 3×3×2×2×2 = 72 × 신규 2×2×2×2 = 16 → 1152 조합. GRID = { - 'SMA_LONG': [10, 20, 40, 60, 120], # 추세 속도 — 빠른/느린 (다양성 핵심) + 'SMA_LONG': [10, 20, 40], # 추세 속도 — 빠른/느린 (다양성 핵심) 'RR_RATIO': [1.5, 2.0, 3.0], # 손익비 - 'STOP_ATR_MULT': [1.5, 2.0, 3.0], # 손절 폭 - 'PULLBACK_ATR_MULT': [0.5, 1.0, 1.5], # 눌림 깊이 + 'STOP_ATR_MULT': [1.5, 2.0], # 손절 폭 + 'PULLBACK_ATR_MULT': [1.0, 1.5], # 눌림 깊이 'VOLUME_BREAKOUT_MULT': [1.3, 2.0], # 돌파 거래량 강도 + 'RSI_OVERBOUGHT': [75, 88], # 돌파 과열 컷 (엄격/느슨) + 'RSI_OVERSOLD': [20, 35], # 눌림목 과매도 컷 (느슨/엄격) + 'MAX_HOLD_DAYS': [0, 20], # 시간손절 끔/20일 + 'RISK_PER_TRADE_PCT': [0.01, 0.02], # 종목당 리스크 1%/2% } MIN_TRADES = 10 # 검증기간 거래 이 미만이면 표본 부족 → 하위로 @@ -87,9 +92,12 @@ if __name__ == '__main__': print('스윕 실패:', res) else: print(f"스윕 완료 — {res['count']}개 조합 · 검증기간 {res['split']['test']}") - print(f"{'RR':>4} {'STOP':>5} {'SMA':>4} {'PULL':>5} | {'학습수익%':>8} {'검증수익%':>8} {'검증MDD%':>8} {'검증거래':>6} {'승률%':>6}") + print(f"{'RR':>4} {'STOP':>5} {'SMA':>4} {'PULL':>5} {'RSI±':>8} {'HOLD':>5} {'RISK':>5} | " + f"{'검증수익%':>8} {'검증MDD%':>8} {'거래':>5} {'승률%':>6}") for r in res['results'][:12]: - p, te, tr = r['params'], r['test'], r['train'] - print(f"{p['RR_RATIO']:>4} {p['STOP_ATR_MULT']:>5} {p['SMA_LONG']:>4} {p['PULLBACK_ATR_MULT']:>5} | " - f"{tr['total_return_pct']:>8} {te['total_return_pct']:>8} {te['mdd_pct']:>8} " - f"{te['trades']:>6} {str(te['win_rate_pct']):>6}") + 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"{te['trades']:>5} {str(te['win_rate_pct']):>6}") diff --git a/agents/stock/workspace/sim/universe.py b/agents/stock/workspace/sim/universe.py index 71c3fed..adb70aa 100644 --- a/agents/stock/workspace/sim/universe.py +++ b/agents/stock/workspace/sim/universe.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +import re import sys from datetime import datetime @@ -18,6 +19,16 @@ sys.path.insert(0, str(config.SCRIPTS)) import kiwoom_client as kc # noqa: E402 WATCHLIST_PATH = config.WORKSPACE / 'state' / 'behive_watchlist.json' + +# ETF/ETN 운용사 브랜드 프리픽스 — 자동매수 후보에서 제외 (방향성=애널 컨센서스가 ETF엔 없어 전략 부정합) +_ETF_PREFIX = re.compile( + r'^(KODEX|TIGER|KBSTAR|ARIRANG|HANARO|ACE|SOL|PLUS|RISE|KOSEF|TIMEFOLIO|히어로즈|FOCUS|TREX|BNK|WON|마이다스|에셋플러스|KIWOOM|파워|마이티)', + re.IGNORECASE) + + +def is_etf(name: str) -> bool: + """종목명 운용사 프리픽스로 ETF/ETN 판별 (보유분 청산은 별도라 매수 후보 필터 전용).""" + return bool(name and _ETF_PREFIX.match(name.strip())) INTERESTS_PATH = config.WORKSPACE / 'state' / 'behive_interests.json' SIM_WATCHLIST_PATH = config.STATE_DIR / 'watchlist.json' # sim 누적 관찰목록 diff --git a/agents/stock/workspace/sim/variants.py b/agents/stock/workspace/sim/variants.py index 82d55fe..0d3e706 100644 --- a/agents/stock/workspace/sim/variants.py +++ b/agents/stock/workspace/sim/variants.py @@ -35,7 +35,8 @@ def variant_paths(vid: str): def _label(params: dict) -> str: short = {'RR_RATIO': 'RR', 'STOP_ATR_MULT': 'S', 'SMA_LONG': 'SMA', 'PULLBACK_ATR_MULT': 'P', 'VOLUME_BREAKOUT_MULT': 'V', - 'TURNOVER_OVERHEAT_MULT': 'OH'} + 'TURNOVER_OVERHEAT_MULT': 'OH', 'RSI_OVERBOUGHT': 'RSI↑', + 'RSI_OVERSOLD': 'RSI↓', 'MAX_HOLD_DAYS': 'HOLD', 'RISK_PER_TRADE_PCT': 'RISK'} return '·'.join(f'{short.get(k, k)}{v:g}' for k, v in params.items()) diff --git a/agents/stock/workspace/state/market_indicators_history.jsonl b/agents/stock/workspace/state/market_indicators_history.jsonl index 39ffee3..962ef35 100644 --- a/agents/stock/workspace/state/market_indicators_history.jsonl +++ b/agents/stock/workspace/state/market_indicators_history.jsonl @@ -22,3 +22,5 @@ {"date": "2026-06-05", "market": "KOSPI", "market_label": "코스피", "rise": 225, "upper": 1, "fall": 672, "lower": 0, "steady": 26, "adr": 33.63, "personal": 42240, "foreign": -27638, "institutional": -13809, "captured_at": "2026-06-05T21:00:05.007446+09:00"} {"date": "2026-06-08", "market": "KOSDAQ", "market_label": "코스닥", "rise": 75, "upper": 7, "fall": 1634, "lower": 1, "steady": 27, "adr": 5.02, "personal": -1245, "foreign": 2976, "institutional": -1466, "captured_at": "2026-06-08T21:00:04.897696+09:00"} {"date": "2026-06-08", "market": "KOSPI", "market_label": "코스피", "rise": 42, "upper": 1, "fall": 876, "lower": 0, "steady": 3, "adr": 4.91, "personal": 17628, "foreign": -2644, "institutional": -17164, "captured_at": "2026-06-08T21:00:04.897696+09:00"} +{"date": "2026-06-09", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1435, "upper": 6, "fall": 258, "lower": 1, "steady": 44, "adr": 556.37, "personal": -5112, "foreign": 3126, "institutional": 1996, "captured_at": "2026-06-09T21:00:02.663326+09:00"} +{"date": "2026-06-09", "market": "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"} diff --git a/agents/stock/workspace/state/trade_journal.jsonl b/agents/stock/workspace/state/trade_journal.jsonl index 6004742..84722b1 100644 --- a/agents/stock/workspace/state/trade_journal.jsonl +++ b/agents/stock/workspace/state/trade_journal.jsonl @@ -76,3 +76,5 @@ {"date": "2026-06-08", "account": "ISA", "owner": "본인", "code": "0193W0", "name": "KODEX 삼성전자단일종목레버리지", "buy_qty": 12, "buy_avg": 21100, "buy_amt": 253200, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 30, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"} {"date": "2026-06-08", "account": "가희_ISA", "owner": "가희", "code": "000660", "name": "SK하이닉스", "buy_qty": 1, "buy_avg": 1970000, "buy_amt": 1970000, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 290, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"} {"date": "2026-06-08", "account": "가희_ISA", "owner": "가희", "code": "005930", "name": "삼성전자", "buy_qty": 10, "buy_avg": 299750, "buy_amt": 2997500, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 440, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"} +{"date": "2026-06-09", "account": "일반", "owner": "본인", "code": "0193W0", "name": "KODEX 삼성전자단일종목레버리지", "buy_qty": 0, "buy_avg": 0, "buy_amt": 0, "sell_qty": 12, "sell_avg": 22000, "sell_amt": 264000, "pl_amt": 9570, "cmsn_tax": 30, "prft_rt": 3.76, "collected_at": "2026-06-09T23:19:23.494299+09:00"} +{"date": "2026-06-09", "account": "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"}