From 69ef9c09e87b6d48056d0f72d79fa1774993729f Mon Sep 17 00:00:00 2001 From: hyowons Date: Tue, 9 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-09=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 | 8 + TASKS.md | 3 + agents/stock/workspace/MEMORY.md | 3 + agents/stock/workspace/sim/__init__.py | 4 + agents/stock/workspace/sim/__main__.py | 144 +++ agents/stock/workspace/sim/backfill_flow.py | 124 ++ agents/stock/workspace/sim/backtest.py | 226 ++++ agents/stock/workspace/sim/benchmark.py | 115 ++ agents/stock/workspace/sim/config.py | 158 +++ agents/stock/workspace/sim/data.py | 177 +++ agents/stock/workspace/sim/engine.py | 294 +++++ agents/stock/workspace/sim/indicators.py | 83 ++ agents/stock/workspace/sim/portfolio.py | 208 ++++ agents/stock/workspace/sim/signals.py | 243 ++++ agents/stock/workspace/sim/sim_web.py | 1003 +++++++++++++++++ agents/stock/workspace/sim/sweep.py | 95 ++ agents/stock/workspace/sim/universe.py | 165 +++ agents/stock/workspace/sim/variants.py | 109 ++ .../state/market_indicators_history.jsonl | 2 + .../stock/workspace/state/trade_journal.jsonl | 6 + 20 files changed, 3170 insertions(+) create mode 100644 agents/stock/workspace/sim/__init__.py create mode 100644 agents/stock/workspace/sim/__main__.py create mode 100644 agents/stock/workspace/sim/backfill_flow.py create mode 100644 agents/stock/workspace/sim/backtest.py create mode 100644 agents/stock/workspace/sim/benchmark.py create mode 100644 agents/stock/workspace/sim/config.py create mode 100644 agents/stock/workspace/sim/data.py create mode 100644 agents/stock/workspace/sim/engine.py create mode 100644 agents/stock/workspace/sim/indicators.py create mode 100644 agents/stock/workspace/sim/portfolio.py create mode 100644 agents/stock/workspace/sim/signals.py create mode 100644 agents/stock/workspace/sim/sim_web.py create mode 100644 agents/stock/workspace/sim/sweep.py create mode 100644 agents/stock/workspace/sim/universe.py create mode 100644 agents/stock/workspace/sim/variants.py diff --git a/CLAUDE.md b/CLAUDE.md index e04ca84..32f31bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,6 +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`. ⚠️ 종가체결·현 워치리스트(생존편향)라 절대수익 아닌 상대순위로만 해석. + - **수급 백필** (`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}` (그 외 읽기전용) - 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 @@ -133,6 +139,8 @@ 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-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/TASKS.md b/TASKS.md index 224402e..e387837 100644 --- a/TASKS.md +++ b/TASKS.md @@ -21,6 +21,9 @@ | **공모주 캘린더 동기화** | `python3 ~/.openclaw/agents/stock/workspace/scripts/ipo_calendar_sync.py` | launchd `ai.openclaw.stock.ipo-calendar-sync` (매월 1일 09:00) | | **휴장일 동기화** | `python3 ~/.openclaw/agents/stock/workspace/scripts/holiday_sync.py [--show]` | launchd `ai.openclaw.stock.holiday-sync` (매주 일요일 03:00) | | **매매 기록 적재** | `python3 ~/.openclaw/agents/stock/workspace/scripts/trade_journal.py {collect\|seed\|show\|query} ...` | launchd `ai.openclaw.stock.trade-journal` (평일 21:00 — NXT 야간 마감 후 ka10170 4계좌 적재). `seed`는 적재 시작일 이전 보유분을 현재 평단가로 1회 시드 (idempotent) | +| **자동매매 시뮬 스캔** | `cd ~/.openclaw/agents/stock/workspace && python3 -m sim {scan [--force]\|status\|report\|reset --yes}` | launchd `ai.openclaw.stock.sim-scan` (평일 09–15:30 매 15분, 엔진이 장외 self-skip). 가상자본 1천만 페이퍼 트레이딩, 규칙 엔진(LLM 미경유). 결과 `state/sim/`. 실주문 아님 | +| **자동매매 시뮬 대시보드** | `cd ~/.openclaw/agents/stock/workspace && python3 -m sim.sim_web {serve\|render}` | launchd `ai.openclaw.stock.sim-web` (상시). 포트 18792 → `sim.hyowons.net`(reverse proxy 수동 등록 대기). 5탭(계좌·관심종목·시장·튜닝·백테스트), 읽기전용+`/save_params`만 쓰기 | +| **시뮬 파라미터 스윕(튜닝 비교)** | `cd ~/.openclaw/agents/stock/workspace && python3 -m sim {sweep [frac]\|backtest}` | 트리거 없음(on-demand). 81조합 백테스트 학습/검증 비교 → `state/sim/backtest_results.json`(웹 백테스트 탭 표시). 튜닝값은 `state/sim/params.json`(웹 튜닝 탭) | | **Claude Code 원격 세션** (on-demand, 다중) | `python3 ~/.openclaw/workspace/skills/claude-code-session/scripts/session_tool.py {profile\|session} ...` (legacy 단일: `ensure_session.sh {open\|close\|status}`) | 세션 plist는 `~/Library/LaunchAgents/ai.claude-session.-.plist`로 ephemeral, 레거시 단일은 `ai.openclaw.claude-remote-control`. 모두 RunAtLoad=false, KeepAlive=false. 클로 `claude-code-session` 스킬이 자연어 라우팅. | 수동 실행: 진입점 명령만 그대로 터미널에서 실행하면 된다. launchctl 만지지 마라. diff --git a/agents/stock/workspace/MEMORY.md b/agents/stock/workspace/MEMORY.md index 4b839bb..ff781c7 100644 --- a/agents/stock/workspace/MEMORY.md +++ b/agents/stock/workspace/MEMORY.md @@ -50,6 +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이 매 스캔 메인+변이 함께 갱신 | 휴장일 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/__init__.py b/agents/stock/workspace/sim/__init__.py new file mode 100644 index 0000000..1347d72 --- /dev/null +++ b/agents/stock/workspace/sim/__init__.py @@ -0,0 +1,4 @@ +"""주식 자동매매 시뮬레이션(페이퍼 트레이딩) 엔진. + +실주문 orders/ 와 완전 분리 — 가상 자본으로만 동작한다. +""" diff --git a/agents/stock/workspace/sim/__main__.py b/agents/stock/workspace/sim/__main__.py new file mode 100644 index 0000000..43661cd --- /dev/null +++ b/agents/stock/workspace/sim/__main__.py @@ -0,0 +1,144 @@ +"""sim CLI. + + 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천만 리셋) +""" +from __future__ import annotations + +import json +import sys + +from . import config, engine +from .portfolio import Portfolio + + +def _cmd_scan(argv): + snap = engine.scan_all(force='--force' in argv) + if snap.get('skipped'): + print(f"[skip] {snap['reason']} @ {snap['at']}") + return + s = snap['summary'] + print(f"자산 {s['equity']:,}원 ({s['total_return_pct']:+.2f}%) · 현금 {s['cash']:,} · " + f"보유 {s['open_positions']} · 매수 {snap['counts']['buys']} 매도 {snap['counts']['sells']}") + + +def _cmd_status(argv): + pf = Portfolio.load() + s = pf.summary() + print(f"가상계좌 (초기 {s['initial']:,}원)") + print(f" 평가자산 {s['equity']:,}원 ({s['total_return_pct']:+.2f}%)") + print(f" 현금 {s['cash']:,} · 주식 {s['held_value']:,}") + print(f" 실현손익 {s['realized_pnl']:,} · 청산 {s['closed_trades']}건 " + f"승률 {s['win_rate_pct']}% · MDD {s['max_drawdown_pct']}%") + if pf.positions: + print(' 보유:') + for p in pf.positions.values(): + cur = p.get('cur_price', p['entry_price']) + pnl = (cur / p['entry_price'] - 1) * 100 + tr = ' [트레일링]' if p.get('trailing_on') else '' + print(f" {p['name']:16s} {p['qty']}주 @{p['entry_price']:,} → {cur:,} " + f"({pnl:+.1f}%) 손절 {p['stop']:,} 목표 {p['target']:,}{tr}") + + +def _cmd_report(argv): + n = next((int(a) for a in argv if a.isdigit()), 20) + if not config.TRADES_PATH.exists(): + print('거래 없음') + return + lines = config.TRADES_PATH.read_text().splitlines()[-n:] + for line in lines: + r = json.loads(line) + if r['side'] == 'BUY': + print(f"{r['ts'][:16]} 매수 {r['name']:16s} {r['qty']}주 @{r['price']:,} · {r['reason']}") + else: + print(f"{r['ts'][:16]} 매도 {r['name']:16s} {r['qty']}주 @{r['price']:,} " + f"손익 {r['pnl']:,}({r['pnl_pct']:+.1f}%) · {r['reason']}") + + +def _cmd_sweep(argv): + from . import sweep + frac = next((float(a) for a in argv if a.replace('.', '').isdigit()), 0.7) + res = sweep.run_sweep(train_frac=frac) + if res.get('error'): + print('스윕 실패:', res) + return + print(f"스윕 완료 — {res['count']}개 조합 · 검증 {res['split']['test']} " + f"· 수급반영 {res['flow_used']}") + for r in res['results'][:10]: + p, te = r['params'], r['test'] + print(f" RR{p['RR_RATIO']} S{p['STOP_ATR_MULT']} SMA{p['SMA_LONG']} P{p['PULLBACK_ATR_MULT']} → " + f"검증 {te['total_return_pct']:+}% MDD {te['mdd_pct']}% 거래 {te['trades']} 승률 {te['win_rate_pct']}%") + + +def _cmd_backfill(argv): + from . import backfill_flow + pages = int(argv[argv.index('--pages') + 1]) if '--pages' in argv else backfill_flow.DEFAULT_PAGES + res = backfill_flow.backfill(pages=pages, force='--force' in argv) + print(f"수급 백필 — 신규 {res['fetched']} · 스킵 {res['skipped']} · 실패 {res['failed']} " + f"· DB {res['codes_in_db']}종목 {res['total_rows']}행") + + +def _cmd_backtest(argv): + from . import backtest, universe + codes = [e['code'] for e in universe.build_universe()] + import json + print(json.dumps(backtest.run(None, codes), ensure_ascii=False, indent=2)) + + +def _cmd_backfill_index(argv): + from . import benchmark + pages = int(argv[argv.index('--pages') + 1]) if '--pages' in argv else 8 + res = benchmark.backfill(pages=pages) + print(f"지수 백필 — 신규 {res}") + for idx in benchmark.INDICES: + ks = sorted(benchmark.series(idx)) + if ks: + print(f" {idx}: {ks[0]} ~ {ks[-1]} ({len(ks)}일)") + + +def _cmd_variants(argv): + from . import variants + sub = argv[0] if argv else 'list' + if sub == 'seed': + n = next((int(a) for a in argv[1:] if a.isdigit()), 3) + vs = variants.seed_from_sweep(n) + print(f'변이 {len(vs)}개 등록:') + for v in vs: + print(' ', v['id'], v['name']) + elif sub == 'reset': + variants.reset_all() + print('변이 계좌 초기화 (정의 유지)') + elif sub == 'clear': + variants.clear() + print('변이 정의·계좌 전부 삭제') + else: + vs = variants.load_variants() + print(f'변이 {len(vs)}개:') + for v in vs: + print(' ', v['id'], v['name'], v['params']) + + +def _cmd_reset(argv): + if '--yes' not in argv: + print('정말 초기화하려면 --yes 를 붙이세요 (가상계좌·거래·스냅샷 삭제)') + return + for p in (config.PORTFOLIO_PATH, config.TRADES_PATH, config.LAST_SCAN_PATH): + if p.exists(): + p.unlink() + print(f'초기화 완료 — 가상자본 {config.INITIAL_CAPITAL:,}원') + + +def main(): + argv = sys.argv[1:] + cmd = argv[0] if argv else 'status' + rest = argv[1:] + {'scan': _cmd_scan, 'status': _cmd_status, 'report': _cmd_report, + 'sweep': _cmd_sweep, 'backtest': _cmd_backtest, 'backfill': _cmd_backfill, + 'backfill-index': _cmd_backfill_index, + 'variants': _cmd_variants, 'reset': _cmd_reset}.get(cmd, lambda a: print(__doc__))(rest) + + +if __name__ == '__main__': + main() diff --git a/agents/stock/workspace/sim/backfill_flow.py b/agents/stock/workspace/sim/backfill_flow.py new file mode 100644 index 0000000..a2561da --- /dev/null +++ b/agents/stock/workspace/sim/backfill_flow.py @@ -0,0 +1,124 @@ +"""수급 백필 — ka10059 페이지네이션으로 종목별 투자자 순매수 다년치 → flow_history.sqlite. + +백테스트가 수급(외국인·기관)을 반영하려면 과거 일자별 순매수가 필요한데 키움은 단일 콜 100일뿐 +이라 연속조회로 깊이 받아 로컬 적재한다. 1회성(또는 가끔) 실행, idempotent(코드+일자 upsert). + +스키마: flow(code, date, foreign_net, inst_net, indiv_net) 단위 천주, +순매수/−순매도. +backtest.load_flow 가 (foreign_net, inst_net) 를 읽는다. + +rate limit(ka10059 stkinfo): 콜·종목 간 sleep + 429 백오프 재시도. + +CLI: + python3 -m sim.backfill_flow [--pages N] [--force] [code ...] + --pages N : 종목당 페이지수(100거래일/페이지, 기본 6 ≈ 2.4년) + --force : 이미 충분히 쌓인 종목도 다시 받음 +""" +from __future__ import annotations + +import sqlite3 +import sys +import time +from datetime import datetime + +from . import config, universe + +sys.path.insert(0, str(config.SCRIPTS)) +import kiwoom_client as kc # noqa: E402 + +FLOW_DB = config.STATE_DIR / 'flow_history.sqlite' +DEFAULT_PAGES = 6 # 100거래일 × 6 ≈ 2.4년 +PAGE_SLEEP = 0.35 # 페이지 간 간격 +CODE_SLEEP = 0.5 # 종목 간 간격 +RATE_BACKOFF = 3.0 # 429 시 추가 대기 + + +def _conn() -> sqlite3.Connection: + FLOW_DB.parent.mkdir(parents=True, exist_ok=True) + c = sqlite3.connect(FLOW_DB, isolation_level=None) + c.execute(""" + CREATE TABLE IF NOT EXISTS flow ( + code TEXT NOT NULL, date TEXT NOT NULL, + foreign_net INTEGER, inst_net INTEGER, indiv_net INTEGER, + PRIMARY KEY (code, date) + )""") + return c + + +def _is_rate_limit(resp: dict) -> bool: + return resp.get('return_code', 0) != 0 and ( + '허용된 요청' in str(resp.get('return_msg') or '') or resp.get('return_code') == 5) + + +def fetch_code(code: str, pages: int = DEFAULT_PAGES) -> list[dict]: + """ka10059 연속조회 — 최신부터 pages 페이지(페이지당 100거래일) 누적. 최신순 list.""" + label = kc._default_account_label() + today = datetime.now(config.KST).strftime('%Y%m%d') + body = {'dt': today, 'stk_cd': kc._clean_code(code), + 'amt_qty_tp': '2', 'trde_tp': '0', 'unit_tp': '1000'} + url = kc.base_url() + kc.ENDPOINT_STKINFO + cont, nk = 'N', '' + out: list[dict] = [] + for _page in range(pages): + headers = kc.auth_headers(label, tr_id=kc.TR_INVESTOR_FLOW, cont_yn=cont, next_key=nk) + resp, hd = kc._http_post_full(url, body, headers) + if _is_rate_limit(resp): + time.sleep(RATE_BACKOFF) + resp, hd = kc._http_post_full(url, body, headers) + if resp.get('return_code', 0) != 0: + sys.stderr.write(f'[backfill] {code} page 실패: {resp.get("return_msg")}\n') + break + for r in resp.get('stk_invsr_orgn') or []: + d = (r.get('dt') or '').strip() + if d: + out.append({'date': d, 'foreign': kc._to_int(r.get('frgnr_invsr')), + 'institution': kc._to_int(r.get('orgn')), + 'individual': kc._to_int(r.get('ind_invsr'))}) + cy = (hd.get('cont-yn') or '').strip().upper() + nkk = (hd.get('next-key') or '').strip() + if cy == 'Y' and nkk: + cont, nk = 'Y', nkk + time.sleep(PAGE_SLEEP) + else: + break + return out + + +def _existing_count(c: sqlite3.Connection, code: str) -> int: + return c.execute('SELECT COUNT(*) FROM flow WHERE code=?', (code,)).fetchone()[0] + + +def backfill(codes: list[str] | None = None, pages: int = DEFAULT_PAGES, force: bool = False) -> dict: + codes = codes or [e['code'] for e in universe.build_universe()] + c = _conn() + target = pages * 100 + done = skipped = failed = 0 + for code in codes: + if not force and _existing_count(c, code) >= target * 0.9: + skipped += 1 + continue + rows = fetch_code(code, pages) + if not rows: + failed += 1 + continue + c.executemany('INSERT OR REPLACE INTO flow(code,date,foreign_net,inst_net,indiv_net) VALUES (?,?,?,?,?)', + [(code, r['date'], r['foreign'], r['institution'], r['individual']) for r in rows]) + done += 1 + sys.stderr.write(f'[backfill] {code}: {len(rows)}행 ({rows[-1]["date"]}~{rows[0]["date"]})\n') + time.sleep(CODE_SLEEP) + total = c.execute('SELECT COUNT(*) FROM flow').fetchone()[0] + n_codes = c.execute('SELECT COUNT(DISTINCT code) FROM flow').fetchone()[0] + c.close() + return {'fetched': done, 'skipped': skipped, 'failed': failed, + 'total_rows': total, 'codes_in_db': n_codes} + + +if __name__ == '__main__': + argv = sys.argv[1:] + force = '--force' in argv + pages = DEFAULT_PAGES + if '--pages' in argv: + pages = int(argv[argv.index('--pages') + 1]) + explicit = [a for a in argv if a.isdigit() and len(a) >= 4] + res = backfill(codes=explicit or None, pages=pages, force=force) + print(f"백필 완료 — 신규 {res['fetched']} · 스킵 {res['skipped']} · 실패 {res['failed']} " + f"· DB {res['codes_in_db']}종목 {res['total_rows']}행") diff --git a/agents/stock/workspace/sim/backtest.py b/agents/stock/workspace/sim/backtest.py new file mode 100644 index 0000000..32cca94 --- /dev/null +++ b/agents/stock/workspace/sim/backtest.py @@ -0,0 +1,226 @@ +"""백테스트 엔진 — 과거 일봉을 하루씩 되감으며 signals.py 규칙으로 가상 매매. + +미래정보 차단: + - 지표는 그날(t)까지의 봉으로만 계산, 체결은 당일 종가 (다음 봉 미참조). + - 애널 게이트는 과거 재현 불가 → 중립(analyst=None, 자동 통과). + - 수급은 flow_history(있으면) 사용, 없으면 중립(통과)으로 표시. + - 시장 필터(ADR)는 데이터가 짧아 backtest 에선 통과 처리. + +엔진과 동일한 signals.compute_indicators / evaluate_candidate / evaluate_holding / +compute_stop_target 를 그대로 재사용한다 (페이퍼와 같은 두뇌). +""" +from __future__ import annotations + +import math +import sqlite3 +import sys + +from . import config, signals + +sys.path.insert(0, str(config.SCRIPTS)) +import daily_candles_cache as dcc # noqa: E402 + +FLOW_DB = config.STATE_DIR / 'flow_history.sqlite' +WINDOW = 140 # 지표 계산에 넘길 최근 봉 수 (성능 상한 — 모든 지표 기간 + 버퍼 충분) + + +def apply_params(overrides: dict | None): + """튜닝 오버라이드를 config 전역에 반영 (없는 키는 기본값). 각 run 독립 보장.""" + overrides = overrides or {} + for spec in config.TUNABLE: + k = spec['key'] + v = overrides.get(k, config._DEFAULTS[k]) + setattr(config, k, v) + + +def load_history(code: str, count: int = 600) -> list[dict]: + """캐시(sqlite)에서 일봉 오름차순. 네트워크 X.""" + return list(reversed(dcc._select_latest(code, count))) + + +def load_flow(code: str) -> dict | None: + """flow_history.sqlite 에서 {date: (foreign, institution)}. 없으면 None (수급 중립).""" + if not FLOW_DB.exists(): + return None + try: + c = sqlite3.connect(FLOW_DB) + rows = c.execute('SELECT date, foreign_net, inst_net FROM flow WHERE code=? ORDER BY date', (code,)).fetchall() + c.close() + return {r[0]: (r[1], r[2]) for r in rows} or None + except Exception: + return None + + +def _flow_net_upto(flow: dict | None, dates_seen: list[str]) -> dict: + """최근 FLOW_DAYS 일 외국인·기관 누적. flow 없으면 중립(둘 다 +1 → 수급 통과).""" + if flow is None: + return {'foreign': 1, 'institution': 1, 'days': 0, 'neutral': True} + recent = dates_seen[-config.FLOW_DAYS:] + f = sum(flow.get(d, (0, 0))[0] for d in recent) + i = sum(flow.get(d, (0, 0))[1] for d in recent) + return {'foreign': f, 'institution': i, 'days': len(recent)} + + +def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to: str = '9', + capital: float | None = None) -> dict: + """백테스트 1회. 반환: 지표 dict (+ trades 수).""" + apply_params(overrides) + capital = capital or config.INITIAL_CAPITAL + + hist = {} + for code in codes: + h = [c for c in load_history(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} + + # code별 date→index, 전체 거래일 축 + idx_map = {code: {c['date']: i for i, c in enumerate(h)} for code, h in hist.items()} + all_dates = sorted({c['date'] for h in hist.values() for c in h}) + + cash = capital + positions: dict[str, dict] = {} + realized = 0.0 + wins = closed = 0 + gross_win = gross_loss = 0.0 + peak_eq = capital + max_dd = 0.0 + comm, tax = config.COMMISSION_RATE, config.SELL_TAX_RATE + + def equity(day_prices): + return cash + sum(p['qty'] * day_prices.get(c, p['entry_price']) for c, p in positions.items()) + + def _qty_for(fill, value): + return int(value // (fill * (1 + comm))) + + for day in all_dates: + day_prices = {} + # ---- 보유 판단 (당일 종가 기준): 손절/익절(전량·분할) → 추격매수 ---- + for code in list(positions.keys()): + h = hist.get(code) + i = idx_map[code].get(day) + if i is None: + continue + window = h[max(0, i - WINDOW):i + 1] + ind = signals.compute_indicators(window) + if ind is None: + continue + day_prices[code] = ind['price'] + 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) + 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 + 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 + + # ---- 신규 매수 판단 (1차 트랜치) ---- + for code, h in hist.items(): + if code in positions or len(positions) >= config.MAX_POSITIONS: + continue + i = idx_map[code].get(day) + if i is None: + continue + window = h[max(0, i - WINDOW):i + 1] + ind = signals.compute_indicators(window) + if ind is None: + continue + day_prices[code] = ind['price'] + if not signals.trend_ok(ind): + continue + dates_seen = [c['date'] for c in h[:i + 1]] + 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'] + eq = equity(day_prices) + tranche_val = (eq / config.MAX_POSITIONS) / 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} + + eq = equity(day_prices) + if eq > peak_eq: + peak_eq = eq + dd = (peak_eq - eq) / peak_eq if peak_eq else 0 + if dd > max_dd: + max_dd = dd + + # 마지막 날 종가로 잔여 포지션 청산 평가 (미실현 포함 최종자산) + final_prices = {} + for code, p in positions.items(): + h = hist[code] + final_prices[code] = h[-1]['close'] + final_eq = cash + sum(p['qty'] * final_prices[c] for c, p in positions.items()) + + n_days = len(all_dates) + total_ret = (final_eq / capital - 1) * 100 + years = n_days / 252 if n_days else 0 + cagr = ((final_eq / capital) ** (1 / years) - 1) * 100 if years > 0 and final_eq > 0 else 0 + pf = (gross_win / gross_loss) if gross_loss > 0 else (math.inf if gross_win > 0 else 0) + + d_from = all_dates[0] if all_dates else None + d_to = all_dates[-1] if all_dates else None + from . import benchmark + bench = benchmark.compare(round(total_ret, 2), d_from, d_to) if d_from else {} + + return { + 'final_equity': round(final_eq), + 'total_return_pct': round(total_ret, 2), + 'cagr_pct': round(cagr, 2), + 'mdd_pct': round(max_dd * 100, 2), + 'trades': closed, + 'open_positions': len(positions), + 'win_rate_pct': round(wins / closed * 100, 1) if closed else None, + 'profit_factor': round(pf, 2) if pf != math.inf else 'inf', + 'days': n_days, + 'from': d_from, + 'to': d_to, + 'flow_used': any(f is not None for f in flows.values()), + 'benchmark': bench, + 'alpha_pct': bench.get('KOSPI', {}).get('alpha'), + } diff --git a/agents/stock/workspace/sim/benchmark.py b/agents/stock/workspace/sim/benchmark.py new file mode 100644 index 0000000..359584d --- /dev/null +++ b/agents/stock/workspace/sim/benchmark.py @@ -0,0 +1,115 @@ +"""기준지수(KOSPI/KOSDAQ) 일별 종가 캐시 + 벤치마크 수익률·알파. + +네이버 m.stock 지수 일별시세 API에서 받아 state/sim/index_history.json 에 누적. +sim/백테스트 수익률을 '같은 기간 지수 매수후보유' 대비(알파)로 평가하기 위한 데이터원. +실패해도 raise 하지 않고 None 으로 흘려보낸다 (지표 부재 ≠ 엔진 중단). +""" +from __future__ import annotations + +import json +import urllib.request + +from . import config + +INDEX_PATH = config.STATE_DIR / 'index_history.json' +INDICES = ('KOSPI', 'KOSDAQ') +INDEX_LABEL = {'KOSPI': '코스피', 'KOSDAQ': '코스닥'} + + +def norm_date(d: str) -> str: + """'2026-06-09', '2026-06-09T...', '20260609' → 'YYYYMMDD'.""" + return (d or '').replace('-', '')[:8] + + +def _load() -> dict: + try: + return json.loads(INDEX_PATH.read_text()) + except Exception: + return {} + + +def _save(data: dict) -> None: + config.STATE_DIR.mkdir(parents=True, exist_ok=True) + INDEX_PATH.write_text(json.dumps(data, ensure_ascii=False)) + + +def _fetch_page(index: str, page: int, size: int) -> list[dict]: + url = f'https://m.stock.naver.com/api/index/{index}/price?pageSize={size}&page={page}' + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + with urllib.request.urlopen(req, timeout=6.0) as r: + return json.loads(r.read().decode('utf-8', 'ignore')) or [] + + +def backfill(pages: int = 8, size: int = 50) -> dict: + """KOSPI/KOSDAQ 일별 종가를 pages×size 만큼 받아 캐시에 병합(idempotent). {index: 신규건수}.""" + data = _load() + added: dict[str, int] = {} + for idx in INDICES: + store = data.setdefault(idx, {}) + n0 = len(store) + for p in range(1, pages + 1): + try: + rows = _fetch_page(idx, p, size) + except Exception: + break + if not rows: + break + for row in rows: + d = norm_date(row.get('localTradedAt')) + c = row.get('closePrice') + if not d or not c: + continue + try: + store[d] = float(str(c).replace(',', '')) + except (ValueError, TypeError): + pass + added[idx] = len(store) - n0 + _save(data) + return added + + +def update_today() -> None: + """최신 1페이지만 받아 오늘 종가 갱신 (스캔마다 저비용 호출, 실패 무시).""" + try: + backfill(pages=1, size=10) + except Exception: + pass + + +def series(index: str) -> dict: + return _load().get(index, {}) + + +def _nearest(store: dict, date: str, after: bool): + """date 기준 on-or-after(after=True) / on-or-before 가장 가까운 (date, close). 없으면 None.""" + if not store: + return None + d = norm_date(date) + keys = sorted(store) + if after: + cand = [k for k in keys if k >= d] + k = cand[0] if cand else None + else: + cand = [k for k in keys if k <= d] + k = cand[-1] if cand else None + return (k, store[k]) if k else None + + +def benchmark_return(index: str, date_from: str, date_to: str): + """[from, to] 구간 지수 등락률(%). from=on-or-after, to=on-or-before 종가. 데이터 없으면 None.""" + store = series(index) + a = _nearest(store, date_from, after=True) + b = _nearest(store, date_to, after=False) + if not a or not b or a[1] <= 0 or b[0] <= a[0]: + return None + return round((b[1] / a[1] - 1) * 100, 2) + + +def compare(return_pct, date_from: str, date_to: str) -> dict: + """기간 수익률(return_pct)을 지수 대비로 비교. {index: {'pct':지수등락, 'alpha':초과}}.""" + out: dict[str, dict] = {} + for idx in INDICES: + b = benchmark_return(idx, date_from, date_to) + alpha = round(return_pct - b, 2) if (b is not None and return_pct is not None) else None + out[idx] = {'pct': b, 'alpha': alpha} + return out diff --git a/agents/stock/workspace/sim/config.py b/agents/stock/workspace/sim/config.py new file mode 100644 index 0000000..20ba49a --- /dev/null +++ b/agents/stock/workspace/sim/config.py @@ -0,0 +1,158 @@ +"""sim 엔진 설정 — 상수·경로. 기본값은 여기, 런타임 튜닝은 state/sim/params.json 오버라이드.""" +from __future__ import annotations + +import json +from pathlib import Path +from zoneinfo import ZoneInfo + +KST = ZoneInfo('Asia/Seoul') + +WORKSPACE = Path(__file__).resolve().parent.parent # agents/stock/workspace +SCRIPTS = WORKSPACE / 'scripts' +STATE_DIR = WORKSPACE / 'state' / 'sim' +HOLIDAYS_PATH = WORKSPACE / 'state' / 'market_holidays.json' +MARKET_HISTORY = WORKSPACE / 'state' / 'market_indicators_history.jsonl' + +# sim state 파일 +PORTFOLIO_PATH = STATE_DIR / 'portfolio.json' +TRADES_PATH = STATE_DIR / 'trades.jsonl' +LAST_SCAN_PATH = STATE_DIR / 'last_scan.json' + +# 보유종목 universe 수집 대상 계좌 (본인만) +OWNER_ACCOUNT_LABELS = ['일반', 'ISA'] + +# ---- 자본·체결·비용 ---- +INITIAL_CAPITAL = 10_000_000 # 가상 자본 +COMMISSION_RATE = 0.00015 # 매매 수수료 (편도, 키움 ~0.015%) +SELL_TAX_RATE = 0.0018 # 매도 증권거래세 (~0.18%) + +# ---- 포지션 사이징 ---- +MAX_POSITIONS = 8 # 동시 보유 최대 종목수 +# 종목당 목표 비중 = 자산 / MAX_POSITIONS + +# ---- 지표 기간 ---- +SMA_SHORT = 5 +SMA_LONG = 20 +ATR_PERIOD = 14 +BREAKOUT_LOOKBACK = 20 # 돌파 판정용 최근 고점 구간 +SWING_LOW_LOOKBACK = 10 # 손절선 후보 스윙 저점 구간 +VOLUME_AVG_PERIOD = 20 +FLOW_DAYS = 5 # 수급 합산 일수 (외국인·기관 N일 순매수) + +# ---- 매수 ---- +VOLUME_BREAKOUT_MULT = 1.5 # 돌파 매수: 거래량 ≥ 평균 × 이 배수 +TURNOVER_BREAKOUT_MULT = 1.5 # 돌파 확인: 오늘 회전율 ≥ 20일 평균 회전율 × 이 배수 (데이터 없으면 통과) +TURNOVER_OVERHEAT_MULT = 3.0 # 과열 보류: 오늘 회전율 ≥ 평균 × 이 배수면 신규매수 보류 (돌파 배수보다 높게) +PULLBACK_ATR_MULT = 1.0 # 눌림목 지정가 = 전일종가 − ATR × 이 배수 (20일선과 비교 후 위쪽) + +# ---- 분할매수·추격매수 ---- +ENTRY_TRANCHES = 2 # 종목당 목표비중을 이 횟수로 나눠 진입 (1=전량 1회). 1차 = 목표/이 값 +ADD_ATR_MULT = 0.5 # 추격매수: 직전 진입가 + ATR × 이 배수 돌파 & 추세 지속 시 다음 트랜치 추가 + +# ---- 매도 ---- +STOP_ATR_MULT = 2.0 # 손절 = 진입가 − ATR × 이 배수 (스윙저점과 비교 후 위쪽) +RR_RATIO = 2.0 # 목표 = 진입가 + (진입가−손절) × 이 배수 +SCALE_OUT_FRAC = 0.0 # 분할매도: 목표 도달 시 보유량의 이 비율 익절 후 잔량 트레일링 (0=분할매도 끔, 목표 도달 시 전량 트레일링 전환) +TRAIL_ATR_MULT = 1.5 # 트레일링: 고점 − ATR × 이 배수 +TRAIL_ATR_MULT_TIGHT = 1.0 # 컨센 목표가 부근에서 트레일링 강화 + +# ---- 애널리스트 게이트 (약한 게이트) ---- +ANALYST_MIN_UPSIDE_PCT = 5.0 # 컨센 목표주가 상승여력 < 이 값이면 신규매수 제외 +ANALYST_SELL_OPINION_MAX = 2.5 # 투자의견(1매도~5매수) 평균이 이 값 이하면 매도 의견으로 보고 제외 + +# ---- 시장 필터 (소프트) ---- +MARKET_BREADTH_MIN = 0.15 # 해당 시장 당일 상승비율 < 이 값이면 그 시장 신규매수 보류 + +# ---- 운영 시간 (KST) ---- +MARKET_OPEN = (9, 0) +MARKET_CLOSE = (15, 30) + + +# ==== 런타임 튜닝 ==== +# 웹 튜닝 화면이 노출·수정하는 파라미터. state/sim/params.json 에 저장되며 config import 시 위 기본값을 덮어쓴다. +# 엔진은 매 스캔마다 새 프로세스라 저장 즉시(다음 스캔부터) 반영된다. +PARAMS_PATH = STATE_DIR / 'params.json' + +TUNABLE = [ + {'key': 'SMA_SHORT', 'label': '단기 이동평균(일)', 'type': 'int', 'min': 2, 'max': 60, 'step': 1, 'group': '추세'}, + {'key': 'SMA_LONG', 'label': '장기 이동평균(일)', 'type': 'int', 'min': 5, 'max': 200, 'step': 1, 'group': '추세'}, + {'key': 'ATR_PERIOD', 'label': 'ATR 기간(일)', 'type': 'int', 'min': 5, 'max': 60, 'step': 1, 'group': '변동성·기간'}, + {'key': 'BREAKOUT_LOOKBACK', 'label': '돌파 고점 구간(일)', 'type': 'int', 'min': 5, 'max': 120, 'step': 1, 'group': '변동성·기간'}, + {'key': 'SWING_LOW_LOOKBACK', 'label': '스윙 저점 구간(일)', 'type': 'int', 'min': 3, 'max': 60, 'step': 1, 'group': '변동성·기간'}, + {'key': 'VOLUME_AVG_PERIOD', 'label': '거래량 평균 구간(일)', 'type': 'int', 'min': 5, 'max': 120, 'step': 1, 'group': '변동성·기간'}, + {'key': 'FLOW_DAYS', 'label': '수급 합산(일)', 'type': 'int', 'min': 1, 'max': 20, 'step': 1, 'group': '변동성·기간'}, + {'key': 'VOLUME_BREAKOUT_MULT', 'label': '돌파 거래량 배수', 'type': 'float', 'min': 1.0, 'max': 5.0, 'step': 0.1, 'group': '매수'}, + {'key': 'TURNOVER_BREAKOUT_MULT', 'label': '돌파 회전율 배수', 'type': 'float', 'min': 1.0, 'max': 5.0, 'step': 0.1, 'group': '매수'}, + {'key': 'TURNOVER_OVERHEAT_MULT', 'label': '과열 보류 회전율 배수', 'type': 'float', 'min': 1.5, 'max': 10.0, 'step': 0.1, 'group': '매수'}, + {'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': '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': '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': 'MAX_POSITIONS', 'label': '동시 보유 최대 종목', 'type': 'int', 'min': 1, 'max': 20, 'step': 1, 'group': '포지션'}, +] + +# 오버라이드 적용 전 원본 기본값 캡처 (튜닝 화면의 '기본값' 표시·복원용) +_DEFAULTS = {spec['key']: globals()[spec['key']] for spec in TUNABLE} + + +def _coerce(spec: dict, v): + """문자열/숫자를 spec 타입으로 변환 + min/max clamp. 실패 시 None.""" + try: + v = int(float(v)) if spec['type'] == 'int' else float(v) + except (TypeError, ValueError): + return None + return max(spec['min'], min(spec['max'], v)) + + +def load_params() -> dict: + """현재 유효 파라미터 {key: value} — 저장값(clamp) 우선, 없으면 기본값.""" + try: + saved = json.loads(PARAMS_PATH.read_text()) + except Exception: + saved = {} + out = {} + for spec in TUNABLE: + k = spec['key'] + cv = _coerce(spec, saved[k]) if k in saved else None + out[k] = cv if cv is not None else _DEFAULTS[k] + return out + + +def save_params(raw: dict) -> dict: + """raw(폼 문자열 dict) 검증·clamp 후 params.json 병합 저장.""" + try: + cur = json.loads(PARAMS_PATH.read_text()) + except Exception: + cur = {} + for spec in TUNABLE: + k = spec['key'] + if k in raw and str(raw[k]).strip() != '': + cv = _coerce(spec, raw[k]) + if cv is not None: + cur[k] = cv + STATE_DIR.mkdir(parents=True, exist_ok=True) + PARAMS_PATH.write_text(json.dumps(cur, ensure_ascii=False, indent=2)) + return cur + + +def reset_params(): + """저장된 오버라이드 삭제 → 전부 기본값.""" + if PARAMS_PATH.exists(): + PARAMS_PATH.unlink() + + +def _apply_overrides(): + """import 시 유효 파라미터를 모듈 전역에 반영 (엔진·신호가 config.X 로 참조).""" + g = globals() + for k, v in load_params().items(): + g[k] = v + + +_apply_overrides() diff --git a/agents/stock/workspace/sim/data.py b/agents/stock/workspace/sim/data.py new file mode 100644 index 0000000..e5966f9 --- /dev/null +++ b/agents/stock/workspace/sim/data.py @@ -0,0 +1,177 @@ +"""종목별 데이터 수집 — 키움 시세·일봉·수급 + FnGuide/WISEreport 애널. + +비용 절약: + - 시세: get_watchlist_quotes 로 universe 전체 1콜 batch. + - 일봉: daily_candles_cache (어제까지 캐시 재활용). + - 수급: ka10059, state/sim/flow_cache/{code}.json 에 60분 TTL 캐시 (5일 net 은 장중 거의 불변). + - 애널: fnguide/wisereport 클라이언트 자체 디스크 캐시(12h) 사용. + - 호가: 체결 직전 종목만 on-demand. +""" +from __future__ import annotations + +import json +import sys +import time + +from . import config + +sys.path.insert(0, str(config.SCRIPTS)) +import kiwoom_client as kc # noqa: E402 +import daily_candles_cache as dcc # noqa: E402 + +FLOW_CACHE_DIR = config.STATE_DIR / 'flow_cache' +FLOW_TTL_SEC = 3600 + +# 애널 크롤링 페이싱 — 캐시 미스(실제 네트워크)일 때만 종목 간 간격을 둬 fnguide/wisereport 연속요청 차단 회피. +FNGUIDE_CACHE_DIR = config.WORKSPACE / 'state' / 'fnguide_cache' +WISEREPORT_CACHE_DIR = config.WORKSPACE / 'state' / 'wisereport_cache' +ANALYST_CACHE_TTL = 12 * 3600 # fnguide/wisereport 클라이언트 TTL과 동일 +ANALYST_PACING_SEC = 0.4 + + +def _cache_fresh(path, ttl: int = ANALYST_CACHE_TTL) -> bool: + """캐시 파일 mtime이 TTL 이내면 True(히트 예상). 없으면 False(곧 네트워크).""" + try: + return (time.time() - path.stat().st_mtime) < ttl + except OSError: + return False + + +def batch_quotes(codes: list[str]) -> dict[str, dict]: + """universe 전체 오늘 시세 1콜 (가격·시고저·거래량).""" + if not codes: + return {} + try: + return kc.get_watchlist_quotes(codes) + except Exception as e: + sys.stderr.write(f'[data] batch_quotes 실패: {e}\n') + return {} + + +MIN_BARS = config.SMA_LONG + 5 # 지표 계산 최소 봉수 + + +def candles(code: str, count: int = 80) -> list[dict]: + """어제까지 일봉 (오름차순). + + 캐시 우선 — 충분히 쌓여 있으면 sqlite 직접 읽기(네트워크 X)로 ka10081 rate limit 회피. + 캐시가 부족한 종목만 1회 네트워크 fetch(캐시 warm). 약간 stale 해도 SMA/ATR엔 무방하며 + 오늘 봉은 engine 이 라이브 시세로 따로 결합한다. + """ + cached_desc = dcc._select_latest(code, count) + if len(cached_desc) >= MIN_BARS: + return list(reversed(cached_desc)) + try: + return dcc.get_candles(code, count=count) + except Exception as e: + sys.stderr.write(f'[data] candles {code} 실패: {e}\n') + return list(reversed(cached_desc)) + + +def build_series(hist: list[dict], today_quote: dict | None) -> list[dict]: + """어제까지 일봉 + 오늘 라이브 봉 결합. 오늘 시세 없으면 hist 그대로.""" + if not today_quote or not today_quote.get('price'): + return hist + today_bar = { + 'date': 'TODAY', + 'open': today_quote.get('open') or today_quote['price'], + 'high': today_quote.get('high') or today_quote['price'], + 'low': today_quote.get('low') or today_quote['price'], + 'close': today_quote['price'], + 'volume': today_quote.get('volume') or 0, + } + return hist + [today_bar] + + +def _flow_cache_path(code: str): + return FLOW_CACHE_DIR / f'{code}.json' + + +def investor_flow(code: str, days: int = config.FLOW_DAYS) -> list[dict]: + """최근 days 일 외국인·기관·개인 순매수 (최신순). 60분 TTL 캐시.""" + 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] + except Exception: + pass + try: + rows = kc.get_investor_flow(code, days=max(days, config.FLOW_DAYS)) + except Exception as e: + sys.stderr.write(f'[data] investor_flow {code} 실패: {e}\n') + return [] + FLOW_CACHE_DIR.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({'ts': now, 'rows': rows}, ensure_ascii=False)) + return rows[:days] + + +def flow_net(rows: list[dict]) -> dict: + """수급 행 → 외국인·기관 누적 순매수(천주). 양수=순매수.""" + foreign = sum(r.get('foreign', 0) for r in rows) + institution = sum(r.get('institution', 0) for r in rows) + return {'foreign': foreign, 'institution': institution, 'days': len(rows)} + + +def analyst(code: str, price: int) -> dict | None: + """애널 게이트/가점용. 캐시 미스(네트워크 발생) 시에만 페이싱 — 연속 크롤링 차단 회피.""" + fresh = (_cache_fresh(FNGUIDE_CACHE_DIR / f'{code}.json') + and _cache_fresh(WISEREPORT_CACHE_DIR / f'{code}.json')) + try: + return _analyst_query(code, price) + finally: + if not fresh: + time.sleep(ANALYST_PACING_SEC) + + +def _analyst_query(code: str, price: int) -> dict | None: + """target_price·opinion·upside·revision·surprise. ETF·실패 시 None.""" + target_price = opinion = None + try: + import fnguide_client as fg + f = fg.get_fundamentals(code) + cons = (f or {}).get('consensus') or {} + target_price = cons.get('target_price') + opinion = cons.get('opinion') + except Exception: + pass + + revision_up = surprise_pos = None + try: + import wisereport_client as wr + c = wr.get_consensus(code) + if c: + rev = c.get('revision') or {} + if rev.get('target_change_pct') is not None: + revision_up = rev['target_change_pct'] > 0 + if not target_price and rev.get('target_last'): + target_price = rev['target_last'] + surp = c.get('surprise') or {} + items = surp.get('items') if isinstance(surp, dict) else None + op_sp = (items or {}).get('영업이익', {}).get('fy0_surprise_pct') if items else None + if op_sp is not None: + surprise_pos = op_sp > 0 + except Exception: + pass + + if not target_price and opinion is None and revision_up is None: + return None + upside = ((target_price / price - 1) * 100) if (target_price and price) else None + return { + 'target_price': target_price, + 'opinion': opinion, + 'upside_pct': upside, + 'revision_up': revision_up, + 'surprise_pos': surprise_pos, + } + + +def quote_book(code: str) -> dict | None: + """체결가용 호가 (매도1/매수1). 실패 시 None.""" + try: + return kc.get_quote_book(code) + except Exception as e: + sys.stderr.write(f'[data] quote_book {code} 실패: {e}\n') + return None diff --git a/agents/stock/workspace/sim/engine.py b/agents/stock/workspace/sim/engine.py new file mode 100644 index 0000000..e40d32f --- /dev/null +++ b/agents/stock/workspace/sim/engine.py @@ -0,0 +1,294 @@ +"""스캔 엔진 — 15분마다 universe 를 훑어 가상 매수/매도하고 판단 스냅샷을 남긴다. + +흐름: universe → 배치 시세 → (보유=청산판단 / 미보유=추세스크린→수급·애널→매수판단) + → 가상 체결 → portfolio 저장 → last_scan.json (대시보드용) 기록. +""" +from __future__ import annotations + +import json +import sys +from datetime import datetime, timedelta + +from . import config, data, signals, universe +from .portfolio import Portfolio + + +def _load_jsonl_last_per_market(path) -> dict[str, dict]: + out: dict[str, dict] = {} + if not path.exists(): + return out + try: + for line in path.read_text().splitlines(): + if not line.strip(): + continue + rec = json.loads(line) + out[rec.get('market', '')] = rec + except Exception: + pass + return out + + +def market_ok_map() -> dict[str, bool]: + """시장별 신규매수 허용 여부 — 당일 상승비율 기반 소프트 필터 (EOD 데이터라 보수적).""" + latest = _load_jsonl_last_per_market(config.MARKET_HISTORY) + out: dict[str, bool] = {} + 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 + 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 + return out + + +def _is_holiday(d: datetime) -> bool: + try: + hol = json.loads(config.HOLIDAYS_PATH.read_text()) + days = hol if isinstance(hol, list) else hol.get('holidays', []) + return d.strftime('%Y-%m-%d') in set(days) or d.strftime('%Y%m%d') in set(days) + except Exception: + return False + + +def is_market_session(now: datetime | None = None) -> bool: + now = now or datetime.now(config.KST) + if now.weekday() >= 5 or _is_holiday(now): + return False + 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) + return o <= now <= c + + +def _fill_buy_price(code: str, suggested: int) -> int: + book = data.quote_book(code) + if book and book.get('asks'): + ask1 = book['asks'][0].get('price') + if ask1: + return int(ask1) + return int(suggested) + + +def _fill_sell_price(code: str, fallback: int) -> int: + book = data.quote_book(code) + if book and book.get('bids'): + bid1 = book['bids'][0].get('price') + if bid1: + return int(bid1) + return int(fallback) + + +def _gather(extra_codes=None): + """파라미터 무관한 라이브 데이터 1회 수집 (메인·변이 공유). 종목·시세·시장매핑. + + extra_codes: universe에 없지만 청산 판단을 위해 포함해야 할 보유 코드(수동 삭제된 보유분). + """ + uni = universe.build_universe() + codes = [e['code'] for e in uni] + seen = set(codes) + for c in (extra_codes or set()): + if c and c not in seen: + uni = uni + [{'code': c, 'name': '', 'sources': ['held']}] + codes.append(c) + seen.add(c) + quotes = data.batch_quotes(codes) + cmkt = universe.code_market_map() + return uni, quotes, cmkt + + +def _run_scan(pf, uni, quotes, cmkt, now) -> dict: + """주어진 포트폴리오에 대해 현재 config(파라미터) 기준 스캔·체결. 스냅샷 반환(파일 기록 X).""" + mkt_ok = market_ok_map() # MARKET_BREADTH_MIN 등 파라미터 의존 → 변이마다 재계산 + decisions: list[dict] = [] + buys: list[dict] = [] + sells: list[dict] = [] + + for e in uni: + code, name, sources = e['code'], e['name'], e['sources'] + 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) + series = data.build_series(hist, quote) + ind = signals.compute_indicators(series) + if ind is None: + decisions.append({'code': code, 'name': name, 'sources': sources, + 'state': 'NODATA', 'reason': '데이터 부족', 'checks': [], + 'price': (quote or {}).get('price')}) + continue + cur_price = ind['price'] + + # ---- 보유: 청산 판단 ---- + if code in pf.positions: + 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) + 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')}) + act = dec['action'] + if 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': + fill = _fill_buy_price(code, cur_price) + rec = pf.add_tranche(code, fill, dec['reason'], signals=dec.get('checks')) + if rec: + # 새 평단 기준 손절·목표 재산정 (손절은 위로만 래칫) + new_entry = pf.positions[code]['entry_price'] + nstop, ntarget = signals.compute_stop_target(new_entry, ind['atr'], ind['recent_low']) + pf.positions[code]['stop'] = max(pf.positions[code]['stop'], nstop) + pf.positions[code]['target'] = ntarget + buys.append(rec) + dec.update({'fill_price': fill, 'entry_price': new_entry, + 'qty': pf.positions[code]['qty'], 'stop': pf.positions[code]['stop'], + 'target': ntarget}) + decisions.append(dec) + continue + + # ---- 미보유: 추세 스크린 → 수급·애널 → 매수 판단 ---- + if not signals.trend_ok(ind): + decisions.append({ + 'code': code, 'name': name, 'sources': sources, 'price': cur_price, + 'state': 'SKIP', 'reason': '추세 미충족', 'action': None, + 'checks': [ + {'label': '추세(20일선 위)', 'ok': cur_price > ind['sma_long'], + 'detail': f"{cur_price:,} vs {ind['sma_long']:,.0f}"}, + {'label': '정배열(5>20)', 'ok': ind['sma_short'] > ind['sma_long'], + 'detail': f"{ind['sma_short']:,.0f} / {ind['sma_long']:,.0f}"}, + ], + }) + continue + + flow = data.flow_net(data.investor_flow(code)) + anl = data.analyst(code, cur_price) + market = cmkt.get(code, '') + m_ok = mkt_ok.get(market, True) + dec = signals.evaluate_candidate(code, name, sources, ind, flow, anl, m_ok) + + if 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'], + signals=dec.get('checks')) + if rec: + buys.append(rec) + dec.update({'fill_price': fill, 'plan_stop': stop, 'plan_target': target}) + else: + dec['state'] = 'WAIT' + dec['reason'] = '매수 신호 — 현금/한도 부족' + decisions.append(dec) + + 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', ''))) + + return { + 'scanned_at': now.isoformat(), + 'next_scan_hint': (now + timedelta(minutes=15)).isoformat(), + 'session': is_market_session(now), + 'market_ok': mkt_ok, + 'summary': pf.summary(), + 'counts': {'buys': len(buys), 'sells': len(sells), 'universe': len(uni)}, + 'decisions': decisions, + } + + +def _attach_benchmark(snap: dict, pf, now, refresh: bool = True): + """가상계좌 시작일 대비 기준지수(KOSPI/KOSDAQ) 수익·알파를 스냅샷에 부착.""" + from . import benchmark + if refresh: + benchmark.update_today() + start = benchmark.norm_date(pf.created_at) + today = now.strftime('%Y%m%d') + snap['benchmark'] = { + 'since': start, 'to': today, + 'indices': benchmark.compare(snap['summary'].get('total_return_pct'), start, today), + } + + +def scan(force: bool = False) -> dict: + """메인 sim 1회 스캔 — last_scan.json 기록.""" + now = datetime.now(config.KST) + if not force and not is_market_session(now): + return {'skipped': True, 'reason': '장외/휴장', 'at': now.isoformat()} + pf = Portfolio.load() + uni, quotes, cmkt = _gather(set(pf.positions)) # 보유분은 universe에서 빠져도 청산 위해 포함 + snap = _run_scan(pf, uni, quotes, cmkt, now) + _attach_benchmark(snap, pf, now) + config.STATE_DIR.mkdir(parents=True, exist_ok=True) + config.LAST_SCAN_PATH.write_text(json.dumps(snap, ensure_ascii=False, indent=2)) + return snap + + +def scan_all(force: bool = False) -> dict: + """메인 + 병렬 페이퍼 변이 전부 스캔 (라이브 데이터 1회 공유). 비교 스냅샷 기록.""" + from . import backtest, variants as variants_mod + now = datetime.now(config.KST) + if not force and not is_market_session(now): + return {'skipped': True, 'reason': '장외/휴장', 'at': now.isoformat()} + + # 포트폴리오 먼저 로드해 보유 코드 합집합 수집 (universe에서 빠진 보유분도 청산 위해 포함) + main_params = config.load_params() + pf = Portfolio.load() + vdefs = variants_mod.load_variants() + vpfs = {v['id']: Portfolio.load(*variants_mod.variant_paths(v['id'])) for v in vdefs} + held_union = set(pf.positions) + for vpf in vpfs.values(): + held_union |= set(vpf.positions) + uni, quotes, cmkt = _gather(held_union) + + # 메인 (현재 params.json 튜닝) + backtest.apply_params(main_params) + snap = _run_scan(pf, uni, quotes, cmkt, now) + _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)) + + from . import benchmark + today = now.strftime('%Y%m%d') + + def _alpha(p): + 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)}] + for v in vdefs: + backtest.apply_params(v.get('params') or {}) + vpf = vpfs[v['id']] + _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)}) + + 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)) + return snap + + +if __name__ == '__main__': + force = '--force' in sys.argv + snap = scan_all(force=force) + if snap.get('skipped'): + print(f"[skip] {snap['reason']} @ {snap['at']}") + else: + s = snap['summary'] + print(f"자산 {s['equity']:,}원 ({s['total_return_pct']:+.2f}%) · " + f"현금 {s['cash']:,} · 보유 {s['open_positions']} · " + f"매수 {snap['counts']['buys']} 매도 {snap['counts']['sells']}") + for d in snap['decisions']: + if d['state'] in ('BUY', 'ADD', 'SELL', 'HOLD'): + print(f" [{d['state']}] {d['name']:12s} {d.get('reason','')}") diff --git a/agents/stock/workspace/sim/indicators.py b/agents/stock/workspace/sim/indicators.py new file mode 100644 index 0000000..9d050d9 --- /dev/null +++ b/agents/stock/workspace/sim/indicators.py @@ -0,0 +1,83 @@ +"""기술지표 — 순수 함수. 일봉 리스트(시간 오름차순)나 종가 리스트를 받아 계산만 한다. + +입력 candle dict 키: open/high/low/close/volume (daily_candles_cache.get_candles 형식). +모든 함수는 데이터 부족 시 None 을 반환한다 (raise X). +""" +from __future__ import annotations + + +def sma(values: list[float], period: int) -> float | None: + """최근 period 개 단순이동평균.""" + if period <= 0 or len(values) < period: + return None + return sum(values[-period:]) / period + + +def rsi(closes: list[float], period: int = 14) -> float | None: + """Wilder RSI. closes 오름차순, period+1 개 이상 필요.""" + if len(closes) < period + 1: + return None + gains = losses = 0.0 + for i in range(1, period + 1): + diff = closes[i] - closes[i - 1] + if diff >= 0: + gains += diff + else: + losses -= diff + avg_gain = gains / period + avg_loss = losses / period + for i in range(period + 1, len(closes)): + diff = closes[i] - closes[i - 1] + gain = diff if diff > 0 else 0.0 + loss = -diff if diff < 0 else 0.0 + avg_gain = (avg_gain * (period - 1) + gain) / period + avg_loss = (avg_loss * (period - 1) + loss) / period + if avg_loss == 0: + return 100.0 + rs = avg_gain / avg_loss + return 100.0 - (100.0 / (1.0 + rs)) + + +def atr(candles: list[dict], period: int = 14) -> float | None: + """Average True Range (Wilder). candles 오름차순, period+1 개 이상 필요.""" + if len(candles) < period + 1: + return None + trs: list[float] = [] + for i in range(1, len(candles)): + h = candles[i]['high'] + lo = candles[i]['low'] + prev_close = candles[i - 1]['close'] + trs.append(max(h - lo, abs(h - prev_close), abs(lo - prev_close))) + if len(trs) < period: + return None + atr_val = sum(trs[:period]) / period + for tr in trs[period:]: + atr_val = (atr_val * (period - 1) + tr) / period + return atr_val + + +def recent_high(candles: list[dict], period: int, exclude_last: bool = True) -> float | None: + """최근 period 봉 최고가. exclude_last 면 마지막(오늘) 봉 제외 → 돌파 판정용.""" + series = candles[:-1] if exclude_last else candles + window = series[-period:] + if not window: + return None + return max(c['high'] for c in window) + + +def recent_low(candles: list[dict], period: int, exclude_last: bool = True) -> float | None: + """최근 period 봉 최저가 — 손절선 후보.""" + series = candles[:-1] if exclude_last else candles + window = series[-period:] + if not window: + return None + return min(c['low'] for c in window) + + +def volume_avg(candles: list[dict], period: int, exclude_last: bool = True) -> float | None: + """최근 period 봉 평균 거래량. exclude_last 면 오늘 제외.""" + series = candles[:-1] if exclude_last else candles + window = series[-period:] + if len(window) < period: + return None + return sum(c['volume'] for c in window) / period diff --git a/agents/stock/workspace/sim/portfolio.py b/agents/stock/workspace/sim/portfolio.py new file mode 100644 index 0000000..aee1446 --- /dev/null +++ b/agents/stock/workspace/sim/portfolio.py @@ -0,0 +1,208 @@ +"""가상 포트폴리오 — 현금·포지션·체결·비용·손익·MDD. + +상태: state/sim/portfolio.json +거래로그: state/sim/trades.jsonl (append) +실제 주문 X — 전부 장부상 가상 체결. +""" +from __future__ import annotations + +import json +from datetime import datetime + +from . import config + + +def _now_iso() -> str: + return datetime.now(config.KST).isoformat() + + +class Portfolio: + def __init__(self, data: dict): + self.cash: float = data.get('cash', config.INITIAL_CAPITAL) + self.initial: float = data.get('initial', config.INITIAL_CAPITAL) + self.positions: dict[str, dict] = data.get('positions', {}) + self.realized_pnl: float = data.get('realized_pnl', 0.0) + self.closed_trades: int = data.get('closed_trades', 0) + self.wins: int = data.get('wins', 0) + self.peak_equity: float = data.get('peak_equity', self.initial) + 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) + # 영속화 경로 (변이는 별도 경로, 기본은 메인 sim) + self._pf_path = config.PORTFOLIO_PATH + self._trades_path = config.TRADES_PATH + + # ---- 영속화 ---- + @classmethod + def load(cls, portfolio_path=None, trades_path=None) -> 'Portfolio': + pp = portfolio_path or config.PORTFOLIO_PATH + tp = trades_path or config.TRADES_PATH + data = {} + if pp.exists(): + try: + data = json.loads(pp.read_text()) + except Exception: + data = {} + obj = cls(data) + obj._pf_path = pp + obj._trades_path = tp + return obj + + def save(self): + self._pf_path.parent.mkdir(parents=True, exist_ok=True) + self.updated_at = _now_iso() + self._pf_path.write_text(json.dumps({ + '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, + 'created_at': self.created_at, 'updated_at': self.updated_at, + }, ensure_ascii=False, indent=2)) + + def _log_trade(self, rec: dict): + self._trades_path.parent.mkdir(parents=True, exist_ok=True) + with self._trades_path.open('a') as f: + f.write(json.dumps(rec, ensure_ascii=False) + '\n') + + # ---- 체결 ---- + def can_open(self) -> bool: + return len(self.positions) < config.MAX_POSITIONS + + def target_position_value(self) -> float: + return self.equity_estimate() / config.MAX_POSITIONS + + def equity_estimate(self) -> float: + """현재 포지션의 진입가 기준 추정 자산 (사이징용 — mark 전 호출 대비).""" + held = sum(p['qty'] * p.get('cur_price', p['entry_price']) for p in self.positions.values()) + return self.cash + held + + def _qty_for(self, fill_price: float, value: float) -> int: + return int(value // (fill_price * (1 + config.COMMISSION_RATE))) + + def buy(self, code, name, fill_price, sources, stop, target, path, reason, signals=None) -> dict | None: + """신규 진입(1차 트랜치). 목표비중을 ENTRY_TRANCHES 로 나눈 만큼만 매수. 체결 dict 또는 None.""" + if code in self.positions or not self.can_open(): + return None + full_target = self.target_position_value() + tranche_val = full_target / max(1, config.ENTRY_TRANCHES) + qty = self._qty_for(fill_price, tranche_val) + if qty < 1: + return None + cost = qty * fill_price * (1 + config.COMMISSION_RATE) + if cost > self.cash: + qty = self._qty_for(fill_price, self.cash) + if qty < 1: + return None + cost = qty * fill_price * (1 + config.COMMISSION_RATE) + self.cash -= cost + self.positions[code] = { + 'code': code, 'name': name, 'sources': sources, + 'qty': qty, 'entry_price': fill_price, 'entry_at': _now_iso(), + 'stop': stop, 'target': target, 'peak': fill_price, + 'trailing_on': False, 'scaled_out': False, 'entry_path': path, + 'entry_reason': reason, 'cur_price': fill_price, + 'tranches': 1, 'tranche_value': tranche_val, 'last_add_price': fill_price, + } + rec = {'ts': _now_iso(), 'side': 'BUY', 'code': code, 'name': name, + 'price': fill_price, 'qty': qty, 'cost': round(cost), + 'path': path, 'reason': reason, 'stop': stop, 'target': target, + 'signals': signals or []} + self._log_trade(rec) + return rec + + def add_tranche(self, code, fill_price, reason, signals=None) -> dict | None: + """추격매수 — 보유 종목에 다음 트랜치 추가. 평단·수량 갱신. 체결 dict 또는 None.""" + pos = self.positions.get(code) + if not pos or pos.get('tranches', 1) >= config.ENTRY_TRANCHES: + return None + tranche_val = pos.get('tranche_value') or (pos['entry_price'] * pos['qty']) + qty = self._qty_for(fill_price, tranche_val) + if qty < 1: + return None + cost = qty * fill_price * (1 + config.COMMISSION_RATE) + if cost > self.cash: + qty = self._qty_for(fill_price, self.cash) + if qty < 1: + return None + cost = qty * fill_price * (1 + config.COMMISSION_RATE) + new_qty = pos['qty'] + qty + pos['entry_price'] = (pos['entry_price'] * pos['qty'] + fill_price * qty) / new_qty + pos['qty'] = new_qty + pos['tranches'] = pos.get('tranches', 1) + 1 + pos['last_add_price'] = fill_price + pos['cur_price'] = fill_price + self.cash -= cost + rec = {'ts': _now_iso(), 'side': 'BUY', 'code': code, 'name': pos['name'], + 'price': fill_price, 'qty': qty, 'cost': round(cost), + 'path': 'add', 'reason': reason, 'stop': pos['stop'], 'target': pos['target'], + 'signals': signals or []} + self._log_trade(rec) + return rec + + def sell(self, code, fill_price, reason, signals=None, frac: float = 1.0) -> dict | None: + """매도. frac<1 이면 부분(분할)매도 — 포지션 유지하고 수량만 차감.""" + pos = self.positions.get(code) + if not pos: + return None + total = pos['qty'] + qty = total if frac >= 1.0 else max(1, int(total * frac)) + if qty >= total: + qty = total + entry = pos['entry_price'] + proceeds = qty * fill_price * (1 - config.COMMISSION_RATE - config.SELL_TAX_RATE) + entry_cost = qty * entry * (1 + config.COMMISSION_RATE) + pnl = proceeds - entry_cost + self.cash += proceeds + self.realized_pnl += pnl + partial = qty < total + rec = {'ts': _now_iso(), 'side': 'SELL', 'code': code, 'name': pos['name'], + 'price': fill_price, 'qty': qty, 'proceeds': round(proceeds), + 'entry_price': round(entry), 'pnl': round(pnl), + 'pnl_pct': round((fill_price / entry - 1) * 100, 2), + 'hold_from': pos['entry_at'], 'reason': reason, 'partial': partial, + 'signals': signals or []} + self._log_trade(rec) + if partial: + pos['qty'] = total - qty + else: + self.closed_trades += 1 + if pnl > 0: + self.wins += 1 + del self.positions[code] + return rec + + def apply_position_update(self, code: str, upd: dict): + if code in self.positions: + self.positions[code].update(upd) + + def mark(self, code: str, cur_price: int): + if code in self.positions: + self.positions[code]['cur_price'] = cur_price + + # ---- 지표 ---- + def update_equity_metrics(self): + eq = self.equity_estimate() + if eq > self.peak_equity: + self.peak_equity = eq + dd = (self.peak_equity - eq) / self.peak_equity if self.peak_equity else 0.0 + if dd > self.max_drawdown: + self.max_drawdown = dd + return eq + + def summary(self) -> dict: + eq = self.equity_estimate() + held_val = eq - self.cash + return { + 'initial': self.initial, + 'cash': round(self.cash), + 'held_value': round(held_val), + 'equity': round(eq), + 'total_return_pct': round((eq / self.initial - 1) * 100, 2), + 'realized_pnl': round(self.realized_pnl), + 'closed_trades': self.closed_trades, + 'wins': self.wins, + 'win_rate_pct': round(self.wins / self.closed_trades * 100, 1) if self.closed_trades else None, + 'open_positions': len(self.positions), + 'max_drawdown_pct': round(self.max_drawdown * 100, 2), + 'updated_at': self.updated_at, + } diff --git a/agents/stock/workspace/sim/signals.py b/agents/stock/workspace/sim/signals.py new file mode 100644 index 0000000..b6a7fef --- /dev/null +++ b/agents/stock/workspace/sim/signals.py @@ -0,0 +1,243 @@ +"""매수/매도 판단 — 결정론적 규칙. LLM 미경유. + +방향(애널·펀더) 약한 게이트 + 가점 → 타이밍(수급·기술)으로 진입/청산. +각 판단은 dashboard 용 체크리스트(checks)를 함께 반환한다. +""" +from __future__ import annotations + +import math + +from . import config, indicators as ind_mod + + +def compute_indicators(series: list[dict]) -> dict | None: + """series(어제까지+오늘) → 지표 묶음. 데이터 부족 시 None.""" + closes = [c['close'] for c in series] + sma_long = ind_mod.sma(closes, config.SMA_LONG) + sma_short = ind_mod.sma(closes, config.SMA_SHORT) + atr = ind_mod.atr(series, config.ATR_PERIOD) + if sma_long is None or sma_short is None or atr is None: + return None + + # 회전율 — 오늘 봉(라이브 시세)엔 turnover_rate 없음 → 최근 봉의 거래량/회전율로 유통주식 추정해 환산 + hist = series[:-1] + turns = [c['turnover_rate'] for c in hist[-config.VOLUME_AVG_PERIOD:] + if c.get('turnover_rate')] + turnover_avg = sum(turns) / len(turns) if turns else None + est_float = None + for c in reversed(hist): + tr, v = c.get('turnover_rate'), c.get('volume') + if tr and v: + est_float = v / (tr / 100.0) + break + turnover_today = (series[-1]['volume'] / est_float * 100.0) if est_float else None + + return { + 'price': closes[-1], + 'prev_close': closes[-2] if len(closes) >= 2 else closes[-1], + 'today_volume': series[-1]['volume'], + 'sma_short': sma_short, + 'sma_long': sma_long, + 'atr': atr, + '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), + 'turnover_today': turnover_today, + 'turnover_avg': turnover_avg, + } + + +def trend_ok(ind: dict) -> bool: + """추세 1차 스크린 — 비싼 수급·애널 호출 전 게이트.""" + return ind['price'] > ind['sma_long'] and ind['sma_short'] > ind['sma_long'] + + +def compute_stop_target(entry: float, atr: float, recent_low: float | None) -> tuple[int, int]: + """손절 = max(진입−ATR×2, 스윙저점) → 둘 중 위쪽(덜 손해). 목표 = 진입 + (진입−손절)×RR.""" + atr_stop = entry - atr * config.STOP_ATR_MULT + stop = max(atr_stop, recent_low) if recent_low else atr_stop + stop = min(stop, entry - 1) # 손절은 진입가보다 아래여야 함 + target = entry + (entry - stop) * config.RR_RATIO + return int(round(stop)), int(round(target)) + + +def _analyst_checks(analyst: dict | None, price: float) -> tuple[bool, list[dict], int]: + """애널 약한 게이트 + 가점. 반환: (게이트 통과, checks, 가점).""" + checks: list[dict] = [] + bonus = 0 + if not analyst: + checks.append({'label': '애널', 'ok': None, 'detail': '데이터 없음(통과)'}) + return True, checks, 0 + + gate_ok = True + op = analyst.get('opinion') + if op is not None: + sell = op <= config.ANALYST_SELL_OPINION_MAX + checks.append({'label': '투자의견', 'ok': not sell, + 'detail': f'{op:.1f}/5' + (' 매도권' if sell else '')}) + if sell: + gate_ok = False + up = analyst.get('upside_pct') + if up is not None: + ok = up >= config.ANALYST_MIN_UPSIDE_PCT + checks.append({'label': '상승여력', 'ok': ok, 'detail': f'{up:+.0f}%'}) + if not ok: + gate_ok = False + if analyst.get('revision_up'): + checks.append({'label': '목표가 리비전', 'ok': True, 'detail': '상향 ➕'}) + bonus += 1 + if analyst.get('surprise_pos'): + checks.append({'label': '어닝 서프라이즈', 'ok': True, 'detail': '+ ➕'}) + bonus += 1 + return gate_ok, checks, bonus + + +def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> dict: + """미보유 종목 매수 판단. flow/analyst 는 engine 이 trend 통과 후 주입.""" + price = ind['price'] + checks: list[dict] = [] + + # 1. 추세 + checks.append({'label': '추세(20일선 위)', 'ok': price > ind['sma_long'], + '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}"}) + + # 2. 수급 + fnet = flow.get('foreign', 0) if flow else 0 + inet = flow.get('institution', 0) if flow else 0 + supply_ok = fnet > 0 or inet > 0 + checks.append({'label': f'수급({config.FLOW_DAYS}일 외/기)', 'ok': supply_ok, + 'detail': f'외 {fnet:+,} · 기 {inet:+,} (천주)'}) + + # 3. 애널 게이트+가점 + gate_ok, acks, bonus = _analyst_checks(analyst, price) + checks.extend(acks) + + 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': '수급 미충족'} + + # 4. 과열 보류 — 회전율이 평균 대비 과도하면 돌파·눌림목 불문 신규매수 보류 + t_today, t_avg = ind.get('turnover_today'), ind.get('turnover_avg') + 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}배↑)"}) + 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) + 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 '—'}) + 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}%"}) + else: + checks.append({'label': '회전율', 'ok': None, 'detail': '데이터 없음(통과)'}) + + if breakout: + return {**base, 'state': 'BUY', 'action': 'buy', 'buy_path': 'breakout', + 'buy_price': price, 'reason': '거래량·회전율 동반 전고점 돌파'} + + # 6. 눌림목 지정가 + limit = max(ind['sma_long'], ind['prev_close'] - ind['atr'] * config.PULLBACK_ATR_MULT) + limit = int(round(limit)) + pulled = price <= limit + checks.append({'label': '눌림목 도달', 'ok': pulled, + 'detail': f'지정가 {limit:,} (현재 {price:,})'}) + if pulled: + # 현재가가 이미 지정가 이하 → 시장에서 현재가(체결가)로 매수 + return {**base, 'state': 'BUY', 'action': 'buy', 'buy_path': 'pullback', + 'buy_price': price, 'watch_price': limit, + 'reason': f'눌림목 지정가 {limit:,} 도달'} + + return {**base, 'state': 'WAIT', 'watch_price': limit, + 'reason': f'눌림목 대기 — 지정가 {limit:,}'} + + +def evaluate_holding(position, ind, flow, analyst) -> dict: + """보유 종목 판단. 우선순위: 손절/추세이탈(전량) → 분할익절(scale_out) → 추격매수(add) → 유지. + + position 의 stop/peak/trailing/scaled_out 갱신값도 position_update 로 함께 반환. + """ + price = ind['price'] + entry = position['entry_price'] + stop = position['stop'] + target = position['target'] + peak = max(position.get('peak', entry), price) + trailing_on = position.get('trailing_on', False) + scaled_out = position.get('scaled_out', False) + tranches = position.get('tranches', 1) + last_add = position.get('last_add_price', entry) + + if trailing_on: + atarget = (analyst or {}).get('target_price') + mult = config.TRAIL_ATR_MULT_TIGHT if (atarget and price >= atarget) else config.TRAIL_ATR_MULT + stop = max(stop, int(round(peak - ind['atr'] * mult))) + + fnet = flow.get('foreign', 0) if flow else 0 + inet = flow.get('institution', 0) if flow else 0 + supply_ok = fnet > 0 or inet > 0 + trend_up = price > ind['sma_long'] and ind['sma_short'] > ind['sma_long'] + + add_trigger = int(round(last_add + ind['atr'] * config.ADD_ATR_MULT)) + can_add = tranches < config.ENTRY_TRANCHES and not trailing_on and not scaled_out + + checks = [ + {'label': '손절선', 'ok': price > stop, 'detail': f'{stop:,} (현재 {price:,})'}, + {'label': '목표/트레일링', 'ok': None, + 'detail': ('트레일링 ON' if trailing_on else + ('일부익절 완료' if scaled_out else f'목표 {target:,}'))}, + {'label': '추세(20일선)', 'ok': price >= ind['sma_long'], + 'detail': f"{ind['sma_long']:,.0f}"}, + {'label': '수급(외/기)', 'ok': supply_ok, 'detail': f'외 {fnet:+,} · 기 {inet:+,}'}, + {'label': f'분할({tranches}/{config.ENTRY_TRANCHES})', 'ok': None, + 'detail': (f'추가 트리거 {add_trigger:,}' if can_add else '추가 종료')}, + ] + + upd = {'stop': stop, 'peak': peak, 'trailing_on': trailing_on, 'scaled_out': scaled_out} + pnl_pct = (price / entry - 1) * 100 + + base = {'code': position['code'], 'name': position['name'], 'sources': position.get('sources', []), + 'price': price, 'checks': checks, 'action': None, 'state': 'HOLD', + 'position_update': upd, 'pnl_pct': pnl_pct} + + # 1. 손절·추세이탈 — 전량 + if price <= stop: + reason = '트레일링 익절' if (trailing_on and price > entry) else '손절' + return {**base, 'state': 'SELL', 'action': 'sell', 'sell_frac': 1.0, 'reason': reason} + if price < ind['sma_long'] and fnet < 0 and inet < 0: + return {**base, 'state': 'SELL', 'action': 'sell', 'sell_frac': 1.0, 'reason': '추세·수급 이탈'} + + # 2. 분할매도 — 목표 도달 & 미익절. 일부 익절 후 잔량은 트레일링으로 전환 + if target and price >= target and not scaled_out: + upd['trailing_on'] = True + if config.SCALE_OUT_FRAC > 0: + upd['scaled_out'] = True + return {**base, 'state': 'SELL', 'action': 'scale_out', + 'sell_frac': config.SCALE_OUT_FRAC, + 'reason': f'목표 도달 — {config.SCALE_OUT_FRAC * 100:.0f}% 분할익절'} + return {**base, 'reason': '목표 도달 — 트레일링 전환'} + + # 3. 추격매수 — 트랜치 여유 & 추세 지속 & 수급 & 직전 진입가 + ATR 돌파 + if can_add and trend_up and supply_ok and price >= add_trigger: + return {**base, 'state': 'ADD', 'action': 'add', + 'reason': f'추격매수 — {tranches + 1}/{config.ENTRY_TRANCHES}차 (추세 지속)'} + + return {**base, 'reason': '보유 유지'} diff --git a/agents/stock/workspace/sim/sim_web.py b/agents/stock/workspace/sim/sim_web.py new file mode 100644 index 0000000..01b0bff --- /dev/null +++ b/agents/stock/workspace/sim/sim_web.py @@ -0,0 +1,1003 @@ +"""시뮬 대시보드 — 별도 읽기전용 웹서버 (포트 18792). + +sim state 파일(last_scan.json·portfolio.json·trades.jsonl)만 읽어 렌더. 키움 호출 없음. +외부 노출은 NAS reverse proxy(sim.hyowons.net → mac:18792) 경유. Tailnet 내부망 한정, 인증 없음. + + python3 -m sim.sim_web serve # launchd 상시 + python3 -m sim.sim_web render # 1회 HTML stdout (디버깅) +""" +from __future__ import annotations + +import html +import json +import subprocess +import sys +import threading +import time +import urllib.parse +import urllib.request +from datetime import datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from . import config +from .portfolio import Portfolio + +BIND_HOST = '' +BIND_PORT = 18792 + +STATE_BADGE = { + 'SELL': ('매도', '#ef4444'), 'ADD': ('추격매수', '#16a34a'), 'BUY': ('매수', '#22c55e'), + 'HOLD': ('보유', '#3b82f6'), 'WAIT': ('대기', '#eab308'), 'SKIP': ('제외', '#6b7280'), + 'NODATA': ('데이터없음', '#374151'), +} +STATE_ORDER = ['HOLD', 'ADD', 'BUY', 'SELL', 'WAIT', 'SKIP', 'NODATA'] +ORIGIN_LABEL = {'watch': '워치', 'interest': '관심', 'held': '보유', 'manual': '수동'} + +# 모바일 당겨서 새로고침 (behive_web 방식 차용 — 별도 상수로 템플릿 f-string 중괄호 충돌 회피) +# #page를 손가락 따라 translateY, #ptr 원형(화살표→스피너), resistance 0.55·임계 70px. 커밋 시 reload. +PTR_HTML = """ + +""" + +# 탭 복원(?t=) + 저장 배너(?saved=1) + 자동새로고침(튜닝 탭/입력 중엔 멈춤, 현재 탭 유지) +REFRESH_JS = """ + +""" + +# 관심종목 탭 종목 검색 — 입력(디바운스)·검색 버튼·Enter로 /api/stock_search 후보 칩. 칩 클릭=폼 POST로 추가. +_WATCH_SEARCH_JS = """ + +""" + + +# 백테스트 순위표 클라이언트 정렬 — 행의 data-* 수치로 재배치. 상태는 sessionStorage 유지. +# 기본 방향: best-first (검증·학습·승률·손익비 내림차순 / MDD 오름차순). 같은 항목 재클릭 시 토글. +_BT_SORT_JS = """ + +""" + + +def _load(path, default): + try: + return json.loads(path.read_text()) + except Exception: + return default + + +# ---- 스윕(백테스트 재계산) 백그라운드 트리거 ---- +# sweep은 ~80초라 동기 응답 불가 → subprocess 백그라운드 + 진행상태 파일로 표시. +SWEEP_STATUS = config.STATE_DIR / 'sweep_status.json' +_WORKSPACE = config.STATE_DIR.parent.parent # `-m sim` 실행 cwd + + +def _sweep_status() -> dict: + return _load(SWEEP_STATUS, {}) + + +def _write_sweep_status(d: dict): + config.STATE_DIR.mkdir(parents=True, exist_ok=True) + SWEEP_STATUS.write_text(json.dumps(d, ensure_ascii=False)) + + +def _trigger_sweep(): + """실행 중이 아니면 백그라운드로 `python3 -m sim sweep`. 진행상태를 파일에 남김.""" + st = _sweep_status() + if st.get('state') == 'running': + try: # 5분 넘게 running이면 stale로 보고 재시작 허용 + started = datetime.fromisoformat(st.get('started', '')) + if (datetime.now(config.KST) - started).total_seconds() < 300: + return + except Exception: + pass + _write_sweep_status({'state': 'running', 'started': datetime.now(config.KST).isoformat()}) + + def _run(): + try: + p = subprocess.Popen([sys.executable, '-m', 'sim', 'sweep'], cwd=str(_WORKSPACE), + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + _, err = p.communicate() + if p.returncode == 0: + _write_sweep_status({'state': 'done', 'finished': datetime.now(config.KST).isoformat()}) + else: + _write_sweep_status({'state': 'error', 'finished': datetime.now(config.KST).isoformat(), + 'msg': (err or b'').decode('utf-8', 'ignore')[-300:]}) + except Exception as e: + _write_sweep_status({'state': 'error', 'finished': datetime.now(config.KST).isoformat(), + 'msg': str(e)}) + + threading.Thread(target=_run, daemon=True).start() + + +def _check_mark(ok): + return {True: '✓', False: '✗', None: '·'}.get(ok, '·') + + +def _check_color(ok): + return {True: '#22c55e', False: '#ef4444', None: '#9ca3af'}.get(ok, '#9ca3af') + + +def _esc(v): + return html.escape(str(v if v is not None else '')) + + +def _num(s): + """폼 문자열 → 정수면 int, 아니면 float (변이 params 타입 보존).""" + f = float(s) + return int(f) if f == int(f) else f + + +def _pkey(params) -> str: + """params 비교 키 — 값을 float로 정규화 (int 3 vs float 3.0 매칭 일치).""" + return json.dumps({k: float(v) for k, v in (params or {}).items()}, sort_keys=True) + + +_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'} + + +def _plabel(params) -> str: + return '·'.join(f'{_PSHORT.get(k, k)}{v:g}' for k, v in (params or {}).items()) + + +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 []) + ) + + +def _kpi(label, value, sub=''): + sub_h = f'
{_esc(sub)}
' if sub else '' + return f'
{_esc(label)}
{_esc(value)}
{sub_h}
' + + +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}' + + +_idx_cache = {'data': {}, 'ts': 0.0} +_IDX_TTL = 60.0 + + +def _fetch_naver_index(code: str) -> dict | None: + """네이버 지수 1개 (코스피/코스닥) closePrice·등락. 실패 시 None.""" + try: + url = f'https://m.stock.naver.com/api/index/{code}/basic' + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + with urllib.request.urlopen(req, timeout=3.0) as resp: + data = json.loads(resp.read().decode('utf-8', 'ignore')) + price = str(data.get('closePrice') or '').replace(',', '') + if not price: + return None + return {'price': float(price), + 'change': float(str(data.get('compareToPreviousClosePrice') or '0').replace(',', '')), + 'pct': float(str(data.get('fluctuationsRatio') or '0').replace(',', ''))} + except Exception as e: + sys.stderr.write(f'[sim_web] index {code} fetch 실패: {e}\n') + return None + + +def _get_indices() -> dict: + """KOSPI·KOSDAQ 지수값 (in-process 60s 캐시 — sim_web은 상시 데몬).""" + now = time.time() + if _idx_cache['data'] and now - _idx_cache['ts'] < _IDX_TTL: + return _idx_cache['data'] + out = {} + for code in ('KOSPI', 'KOSDAQ'): + r = _fetch_naver_index(code) + if r: + out[code] = r + if out: + _idx_cache['data'] = out + _idx_cache['ts'] = now + return out + + +def _market_panel() -> str: + """시장 탭 — market_indicators_history.jsonl에서 시장별 ADR·상승하락·투자자 순매수.""" + path = config.MARKET_HISTORY + if not path.exists(): + return '
시장 데이터 없음
' + by_market: dict[str, list] = {} + try: + for line in path.read_text().splitlines(): + if line.strip(): + r = json.loads(line) + by_market.setdefault(r.get('market', ''), []).append(r) + except Exception: + return '
시장 데이터 읽기 실패
' + + idx = _get_indices() + 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] + ix = idx.get(k) + if ix: + ic = '#22c55e' if ix['change'] > 0 else '#ef4444' if ix['change'] < 0 else '#9ca3af' + idx_row = (f'
지수' + f'{ix["price"]:,.2f} ' + f'{ix["change"]:+,.2f} ({ix["pct"]:+.2f}%)
') + else: + idx_row = '' + label = _esc(r.get('market_label') or k) + 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') + 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))] + recent_s = ' → '.join(f'{a:.0f}' for a in recent) if recent else '—' + out += ( + f'
{label} ' + f'{_esc(r.get("date",""))}' + f'{vlabel}
' + f'{idx_row}' + 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'
개인 순매수{_signed(r.get("personal"))}
' + f'
외국인 순매수{_signed(r.get("foreign"))}
' + f'
기관 순매수{_signed(r.get("institutional"))}
' + f'
최근 ADR(5일){recent_s}
' + f'
' + ) + note = ('
EOD(장 마감 후 수집) 데이터 · 신규 매수 필터에만 반영 ' + '· 순매수 단위 억원
') + return out + note + + +def _tune_panel() -> str: + """파라미터 튜닝 폼 — 그룹별 number 입력. 저장 시 params.json 반영(다음 스캔부터).""" + eff = config.load_params() + groups: dict[str, list] = {} + for spec in config.TUNABLE: + groups.setdefault(spec['group'], []).append(spec) + body = '
' + for gname, specs in groups.items(): + body += f'

{_esc(gname)}

' + for spec in specs: + k = spec['key'] + body += ( + f'
' + f'
' + ) + body += ('
' + '
' + '' + '
' + '
저장하면 다음 스캔(최대 15분 뒤)부터 적용 · ' + '범위 벗어난 값은 자동 보정
') + return body + + +def _alpha_inline(bench: dict) -> str: + """benchmark dict({KOSPI/KOSDAQ:{pct,alpha}}) → '코스피 +5.2%p · 코스닥 -1.1%p' 문자열.""" + parts = [] + for idx, lbl in (('KOSPI', '코스피'), ('KOSDAQ', '코스닥')): + a = (bench.get(idx) or {}).get('alpha') + if a is None: + continue + c = '#22c55e' if a >= 0 else '#ef4444' + parts.append(f'{lbl} {a:+g}%p') + return ' · '.join(parts) if parts else '지수 데이터 없음' + + +def _bt_alpha_row(res: dict) -> str: + bench = res.get('benchmark') or {} + return (f'
지수대비(알파)' + f'{_alpha_inline(bench)}
') + + +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"," ")}
' + '
⚠ 절대수익보다 상대순위·검증기간 일관성으로 판단 ' + '(수급 중립·종가체결·현 워치리스트 기준)
') + st = _sweep_status() + if st.get('state') == 'running': + ctrl = ('
' + '스윕 계산 중… (약 1~2분 소요 · 끝나면 자동 갱신)
' + '') + else: + err = ('
⚠ 직전 스윕 실패 — 로그 확인 필요
' + 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]): + 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: # 비교군 등록됨 → 제거 (메인 중복이라도 정리 가능) + 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' + 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}' + f'검증 {teret:+g}%' + f'
' + f'
검증 수익 / 학습 수익' + f'{teret:+g}% / {trret:+g}%{of_tag}
' + f'{_bt_alpha_row(te)}' + f'
검증 MDD{mdd}%
' + f'
검증 거래수 / 승률{te.get("trades")}건 · {wr}%
' + f'
검증 손익비(PF){pf}
' + f'
' + f'
{hidden}' + f'
' + f'{cmp_btn}' + f'
' + ) + return head + ctrl + sortbar + f'
{rows}
' + _BT_SORT_JS + + +def _compare_panel() -> str: + """병렬 페이퍼 비교 — 메인 + 변이들의 실시간 누적 성적. + + 변이 정의(variants.json) + 각 계좌(portfolio.json)에서 직접 읽어 렌더 → + 추가/초기화 버튼이 다음 스캔을 기다리지 않고 즉시 반영된다. + """ + from . import variants as vmod, benchmark + defs = vmod.load_variants() + today = datetime.now(config.KST).strftime('%Y%m%d') + + def _row(rid, name, params, pf): + s = pf.summary() + return {'id': rid, 'name': name, 'params': params, 'summary': s, + 'benchmark': benchmark.compare(s.get('total_return_pct'), + benchmark.norm_date(pf.created_at), today)} + + # 메인 + 변이 계좌를 소스에서 직접 수집 + rows_data = [_row('main', '메인 (현재 튜닝)', config.load_params(), Portfolio.load())] + 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) + + scanned = _load(config.LAST_SCAN_PATH, {}).get('scanned_at', '')[:16].replace('T', ' ') + head = (f'
실시간 누적 비교 · 마지막 스캔 {scanned or "—"} ' + f'· {len(rows_data)}개 (메인 포함)
' + '
⚠ 같은 라이브 시세에 서로 다른 튜닝 동시 운영. ' + '기간 길수록·거래 많을수록 신뢰 · 변이는 백테스트 탭의 「+ 비교군에 추가」로 등록
') + 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 '' + plabel = '·'.join(f'{k.split("_")[0]}{val:g}' for k, val in (v.get('params') or {}).items()) or '기본값' + actions = '' if is_main else ( + f'
' + f'
' + f'' + f'
' + f'
' + f'' + f'
' + f'
' + ) + rows += ( + f'
' + f'#{i+1} {_esc(v["name"])}{tag}' + f'{ret:+g}%' + f'
' + f'
평가자산{s.get("equity",0):,}원
' + f'
예수금 / 주식{s.get("cash",0):,} / {s.get("held_value",0):,}
' + f'
수익률{ret:+g}%
' + f'
실현손익{s.get("realized_pnl",0):,}원
' + 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'{actions}' + f'
' + ) + return head + rows + + +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', ' ') + ret = s.get('total_return_pct', 0) or 0 + ret_color = '#22c55e' if ret >= 0 else '#ef4444' + + # ---- KPI ---- + 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)}종목") + ) + # 기준지수 대비(알파) — 시작일 대비 같은 기간 지수 매수후보유와 비교 + 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 += _kpi(f'{lbl} 대비', '—', '지수 데이터 없음') + else: + ac = '#22c55e' if a >= 0 else '#ef4444' + kpis += (f'
{lbl} 대비(알파)
' + f'
{a:+.2f}%p
' + f'
지수 {ip:+.2f}%
') + + # ---- 보유 포지션 ---- + pos_rows = '' + for p in pf.positions.values(): + entry, qty = p['entry_price'], p['qty'] + cur = p.get('cur_price', entry) + pnl = (cur / entry - 1) * 100 + pnl_amt = int(round((cur - entry) * qty)) + pc = '#22c55e' if pnl >= 0 else '#ef4444' + evalv = cur * qty + trail = ' 트레일링' if p.get('trailing_on') else '' + 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 += ( + f'
' + f'{_esc(p["name"])}' + f'{cur:,} {pnl:+.1f}%' + f'
' + f'
수량{qty:,}주 ({tr_n}/{tr_max}분할){scaled}
' + f'
평단{entry:,.0f}원
' + f'
평가금액{evalv:,}원
' + f'
평가손익{pnl_amt:+,}원 ({pnl:+.1f}%)
' + f'
손절{p["stop"]:,}원
' + f'
목표{p["target"]:,}원{trail}
' + f'
진입사유{_esc(p.get("entry_reason",""))}
' + f'
진입시각{entry_at}
' + f'
' + ) + pos_table = pos_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}
' + ) + + # 관찰목록 중 아직 스캔 안 된(방금 추가 등) 종목 → '스캔 대기'로 즉시 표시 + 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() + + # ---- 거래내역 ---- + tr_rows = '' + for r in trades: + ts = _esc(r["ts"][5:16].replace('T', ' ')) + if r['side'] == 'BUY': + st_line = (f'
손절/목표' + f'{r["stop"]:,} / {r["target"]:,}
') if r.get('stop') else '' + tr_rows += ( + f'
' + f'매수 {_esc(r["name"])}' + f'{ts}' + f'
' + f'
체결{r["qty"]}주 @ {r["price"]:,}원
' + f'
매수금액{r.get("cost",0):,}원
' + f'
사유{_esc(r.get("reason",""))}
' + f'{st_line}' + + (f'
판단근거
{_chips(r.get("signals"))}
' + if r.get('signals') else '') + + '
' + ) + else: + pc = '#22c55e' if r.get('pnl', 0) >= 0 else '#ef4444' + tr_rows += ( + f'
' + f'매도 {_esc(r["name"])}' + f'{r.get("pnl_pct",0):+.1f}% {ts}' + f'
' + f'
체결{r["qty"]}주 @ {r["price"]:,}원
' + f'
실현손익{r.get("pnl",0):+,}원 ({r.get("pnl_pct",0):+.1f}%)
' + f'
평단{r.get("entry_price",0):,}원
' + f'
사유{_esc(r.get("reason",""))}
' + + (f'
판단근거
{_chips(r.get("signals"))}
' + if r.get('signals') else '') + + '
' + ) + tr_table = tr_rows or '
거래 없음
' + + return f""" + +레이 시뮬 — 자동매매 판단 + +
+
+ + + + + + + +
+

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

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

보유 포지션

{pos_table} +

최근 거래

{tr_table} +
+
+

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

+
+ +
+ +
종목명 일부만 입력해도 후보가 떠요 · 후보를 누르면 추가 · 자산(비하이브 워치·관심·보유)은 자동 편입 · 삭제는 카드의 ✕ (수동만) · 보유 중이면 삭제해도 청산까지 추적
+{judge_html} +
+
+

시장 동향

{market_html} +
+
+ +

파라미터 튜닝

{tune_html}
+

백테스트 순위표

{bt_html}
+

병렬 페이퍼 비교

{cmp_html}
+
+
60초 자동 새로고침 · 당겨서 새로고침 · sim/ 가상 엔진 (실주문 아님)
+
+{PTR_HTML} +{REFRESH_JS} +{_WATCH_SEARCH_JS} +""" + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + parsed = urllib.parse.urlparse(self.path) + path = parsed.path + if path == '/api/stock_search': + from . import universe as umod + q = (urllib.parse.parse_qs(parsed.query).get('q') or [''])[0] + try: + results = umod.search_stocks(q) + except Exception: + results = [] + body = json.dumps({'results': results}, ensure_ascii=False).encode('utf-8') + self.send_response(200) + self.send_header('Content-Type', 'application/json; charset=utf-8') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + return + if path not in ('/', '/index.html'): + self.send_response(404) + self.end_headers() + return + body = render_html().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))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + if self.path == '/run_sweep': + _trigger_sweep() + self.send_response(303) + self.send_header('Location', '/?t=lab&s=bt') + self.end_headers() + return + if self.path in ('/variants_add', '/variants_reset_one', '/variants_remove'): + self._handle_variants() + return + if self.path in ('/watch_add', '/watch_remove'): + self._handle_watch() + return + if self.path != '/save_params': + self.send_response(404) + self.end_headers() + return + 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()} + try: + if form.get('reset'): + config.reset_params() + else: + config.save_params(form) + 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.end_headers() + + def _handle_variants(self): + """비교군 관리 — 추가(백테스트 행)·성적 초기화·삭제(비교 행). 파일 IO만이라 즉시 완료.""" + from . import variants as vmod + 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()} + # 어느 탭에서 눌렀는지 (back=bt → 백테스트 탭 유지, 없으면 비교 탭). add는 항상 bt. + back = form.get('back') + dest = f'/?t=lab&s={back}' if back in ('bt', 'cmp') else '/?t=lab&s=cmp' + try: + if self.path == '/variants_add': + keys = [spec['key'] for spec in config.TUNABLE] + params = {k: _num(form[k]) for k in keys if k in form} + main_p = config.load_params() + is_main = params and _pkey(params) == _pkey({k: main_p[k] for k in params if k in main_p}) + if params and not is_main: # 메인과 동일 조합은 중복이라 추가 X + vmod.add_variant(params) + dest = '/?t=lab&s=bt' # 백테스트 탭에서 연속 추가 가능하도록 유지 + elif self.path == '/variants_reset_one' and form.get('id'): + vmod.reset_variant(form['id']) + elif self.path == '/variants_remove' and form.get('id'): + vmod.remove_variant(form['id']) + except Exception as e: + sys.stderr.write(f'[sim_web] variants {self.path} 실패: {e}\n') + self.send_response(303) + self.send_header('Location', dest) + self.end_headers() + + def _handle_watch(self): + """관찰목록 관리 — 수동 추가(/watch_add)·삭제(/watch_remove). 결과를 쿼리로 전달해 배너 피드백.""" + from . import universe as umod + 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()} + dest = '/?t=watch' + try: + if self.path == '/watch_add' and (form.get('q') or '').strip(): + q = form['q'].strip() + before = set(umod._load_watchlist()) + res = umod.add_manual(q) + if res is None: + dest += '&werr=' + urllib.parse.quote(q) # 종목 못 찾음 + elif res['code'] in before: + dest += '&wdup=' + urllib.parse.quote(res.get('name') or res['code']) # 이미 있음 + else: + dest += '&wadd=' + urllib.parse.quote(res.get('name') or res['code']) # 추가됨 + elif self.path == '/watch_remove' and form.get('code'): + code = form['code'] + nm = umod._load_watchlist().get(code, {}).get('name') or code + umod.remove(code) + dest += '&wrem=' + urllib.parse.quote(nm) + except Exception as e: + sys.stderr.write(f'[sim_web] watch {self.path} 실패: {e}\n') + dest += '&werr=' + urllib.parse.quote('처리 오류') + self.send_response(303) + self.send_header('Location', dest) + self.end_headers() + + def log_message(self, *a): + pass + + +def main(): + cmd = sys.argv[1] if len(sys.argv) > 1 else 'serve' + if cmd == 'render': + sys.stdout.write(render_html()) + return + server = ThreadingHTTPServer((BIND_HOST, BIND_PORT), Handler) + print(f'sim dashboard on http://{BIND_HOST or "0.0.0.0"}:{BIND_PORT} @ {datetime.now(config.KST).isoformat()}', + flush=True) + server.serve_forever() + + +if __name__ == '__main__': + main() diff --git a/agents/stock/workspace/sim/sweep.py b/agents/stock/workspace/sim/sweep.py new file mode 100644 index 0000000..7b89d41 --- /dev/null +++ b/agents/stock/workspace/sim/sweep.py @@ -0,0 +1,95 @@ +"""파라미터 스윕 — 여러 튜닝을 백테스트로 동시 비교 → 순위표. + +과최적화 방어: 기간을 앞(학습)/뒤(검증)로 쪼개 둘 다 측정. + - 학습기간 성적만 높고 검증기간 무너지면 = 과최적화 의심. + - 검증기간(out-of-sample) 성적으로 순위. + +결과: state/sim/backtest_results.json (웹 백테스트 탭이 읽음). +""" +from __future__ import annotations + +import itertools +import json +from datetime import datetime + +from . import backtest, config, universe + +# 백테스트에서 실제 영향 있는 파라미터만 스윕 (애널 게이트·시장필터는 backtest 중립이라 제외). +# SMA_LONG 5단계로 추세 속도 다양화 + 돌파 강도 추가. 5×3×3×3×2 = 270 조합. +GRID = { + 'SMA_LONG': [10, 20, 40, 60, 120], # 추세 속도 — 빠른/느린 (다양성 핵심) + '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], # 눌림 깊이 + 'VOLUME_BREAKOUT_MULT': [1.3, 2.0], # 돌파 거래량 강도 +} + +MIN_TRADES = 10 # 검증기간 거래 이 미만이면 표본 부족 → 하위로 + + +def _all_dates(codes: list[str]) -> list[str]: + s = set() + for code in codes: + for c in backtest.load_history(code): + s.add(c['date']) + return sorted(s) + + +def run_sweep(codes: list[str] | None = None, train_frac: float = 0.7, grid: dict | None = None) -> dict: + codes = codes or [e['code'] for e in universe.build_universe()] + grid = grid or GRID + dates = _all_dates(codes) + if len(dates) < 60: + return {'error': 'insufficient_history', 'days': len(dates)} + split = int(len(dates) * train_frac) + split_date = dates[split] + train_to, test_from, test_to = dates[split - 1], dates[split], dates[-1] + train_from = dates[0] + + 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}) + + 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) + + results.sort(key=sort_key, reverse=True) + + out = { + 'generated_at': datetime.now(config.KST).isoformat(), + 'universe_size': len(codes), + 'swept_params': keys, + 'grid': grid, + 'split': {'train': f'{train_from}~{train_to}', 'test': f'{test_from}~{test_to}', + 'train_frac': train_frac}, + 'min_trades': MIN_TRADES, + 'flow_used': results[0]['test'].get('flow_used', False) if results else False, + 'count': len(results), + 'results': results, + } + 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)) + return out + + +if __name__ == '__main__': + import sys + frac = float(sys.argv[1]) if len(sys.argv) > 1 else 0.7 + res = run_sweep(train_frac=frac) + if res.get('error'): + 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}") + 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}") diff --git a/agents/stock/workspace/sim/universe.py b/agents/stock/workspace/sim/universe.py new file mode 100644 index 0000000..71c3fed --- /dev/null +++ b/agents/stock/workspace/sim/universe.py @@ -0,0 +1,165 @@ +"""종목 풀 조립 — sim 전용 누적 관찰목록(state/sim/watchlist.json). + +자산 소스(비하이브 워치/관심 + 본인 보유)에 새 종목이 생기면 자동 편입하되, +한 번 들어온 종목은 자동으로 빠지지 않는다(삭제는 수동만). 관심종목 탭에서 직접 추가/삭제 가능. + +각 종목에 origin 태깅: watch / interest / held / manual. 가격선(비하이브 buy/target/stop)은 +의도적으로 무시한다 — 진입·손절·목표는 sim 엔진이 데이터로 직접 계산한다. +""" +from __future__ import annotations + +import json +import sys +from datetime import datetime + +from . import config + +sys.path.insert(0, str(config.SCRIPTS)) +import kiwoom_client as kc # noqa: E402 + +WATCHLIST_PATH = config.WORKSPACE / 'state' / 'behive_watchlist.json' +INTERESTS_PATH = config.WORKSPACE / 'state' / 'behive_interests.json' +SIM_WATCHLIST_PATH = config.STATE_DIR / 'watchlist.json' # sim 누적 관찰목록 + + +def _load_json(path, default): + try: + return json.loads(path.read_text()) + except Exception: + return default + + +def _load_watchlist() -> dict: + """sim 누적 관찰목록 {code: {name, origin, added_at}}.""" + wl = _load_json(SIM_WATCHLIST_PATH, {}) + return wl if isinstance(wl, dict) else {} + + +def _save_watchlist(wl: dict): + config.STATE_DIR.mkdir(parents=True, exist_ok=True) + SIM_WATCHLIST_PATH.write_text(json.dumps(wl, ensure_ascii=False, indent=2)) + + +def _collect_sources() -> dict[str, tuple[str, str]]: + """자산 소스(비하이브 워치/관심 + 본인 보유)에서 {code: (name, origin)} 수집. 코드 6자리 정규화.""" + out: dict[str, tuple[str, str]] = {} + + def add(code: str, name: str, origin: str): + code = (code or '').strip().zfill(6) if code else '' + if not code or code in out: + return + out[code] = (name or '', origin) + + watch = _load_json(WATCHLIST_PATH, {}) + for name, info in (watch.items() if isinstance(watch, dict) else []): + add(info.get('code', ''), info.get('stock', name), 'watch') + + interests = _load_json(INTERESTS_PATH, {}) + for name, info in (interests.items() if isinstance(interests, dict) else []): + add(info.get('code', ''), info.get('stock', name), 'interest') + + try: + held = kc.get_positions_all(labels=config.OWNER_ACCOUNT_LABELS) + for _label, positions in held.items(): + for p in positions: + if p.get('qty', 0) > 0: + add(p.get('code', ''), p.get('name', ''), 'held') + except Exception as e: + sys.stderr.write(f'[universe] 보유종목 조회 실패 (무시): {e}\n') + + return out + + +def sync_watchlist() -> dict: + """자산 소스의 새 종목만 누적 관찰목록에 편입(이름 보강). 자동 삭제는 안 함. 갱신된 목록 반환.""" + wl = _load_watchlist() + now = datetime.now(config.KST).isoformat() + changed = False + for code, (name, origin) in _collect_sources().items(): + ent = wl.get(code) + if ent is None: + wl[code] = {'name': name, 'origin': origin, 'added_at': now} + changed = True + elif not ent.get('name') and name: # 이름만 보강 + ent['name'] = name + changed = True + if changed: + _save_watchlist(wl) + return wl + + +def search_stocks(query: str, limit: int = 20) -> list[dict]: + """종목명/코드 부분검색 → [{code, name}] 후보 (자동완성 팝업용). ci-exact > prefix > 부분 정렬.""" + q = (query or '').strip() + if not q: + return [] + cache = kc._load_code_cache() + if q.isdigit() and len(q) == 6: # 6자리 코드 직접 + try: + info = kc.resolve_stock_code(q) + return [{'code': info.get('code', q), 'name': info.get('name', '')}] + except Exception: + return [] + ql = q.lower() + hits = [(name, info) for name, info in cache.items() if ql in (name or '').lower()] + + def sk(item): + n = (item[0] or '').lower() + rank = 0 if n == ql else 1 if n.startswith(ql) else 2 + return (rank, len(n), n) # 같은 등급이면 짧은 이름(핵심 종목) 우선 → '삼성전자'가 ETN보다 위 + + hits.sort(key=sk) + return [{'code': info.get('code', ''), 'name': info.get('name') or name} + for name, info in hits[:limit] if info.get('code')] + + +def add_manual(code_or_name: str) -> dict | None: + """관심종목 탭 수동 추가 — 코드/이름 해석 후 origin='manual'로 편입. 이미 있으면 기존 반환.""" + try: + info = kc.resolve_stock_code(code_or_name) + except Exception as e: + sys.stderr.write(f'[universe] resolve 실패: {e}\n') + return None + code = (info.get('code') or '').strip().zfill(6) + if not code: + return None + wl = _load_watchlist() + if code not in wl: + wl[code] = {'name': info.get('name') or '', 'origin': 'manual', + 'added_at': datetime.now(config.KST).isoformat()} + _save_watchlist(wl) + return {'code': code, **wl[code]} + + +def remove(code: str): + """관심종목 탭 수동 삭제.""" + code = (code or '').strip().zfill(6) + wl = _load_watchlist() + if code in wl: + del wl[code] + _save_watchlist(wl) + + +def build_universe() -> list[dict]: + """누적 관찰목록을 동기화 후 [{code, name, sources:[origin]}]로 반환. code 6자리 정규화.""" + wl = sync_watchlist() + return [{'code': code, 'name': ent.get('name', ''), 'sources': [ent.get('origin', 'manual')]} + for code, ent in wl.items()] + + +def code_market_map() -> dict[str, str]: + """code → 'KOSPI'/'KOSDAQ' 매핑 (종목코드 캐시 기반). 모르면 빈값.""" + cache = kc._load_code_cache() + out: dict[str, str] = {} + for _name, meta in cache.items(): + code = (meta.get('code') or '').strip() + if code: + out[code.zfill(6)] = meta.get('market') or '' + return out + + +if __name__ == '__main__': + u = build_universe() + print(f'universe: {len(u)} 종목') + for e in u: + print(f" {e['code']} {e['name']:12s} {'/'.join(e['sources'])}") diff --git a/agents/stock/workspace/sim/variants.py b/agents/stock/workspace/sim/variants.py new file mode 100644 index 0000000..82d55fe --- /dev/null +++ b/agents/stock/workspace/sim/variants.py @@ -0,0 +1,109 @@ +"""병렬 페이퍼 변이(variant) 관리 — 각 변이는 {이름, 파라미터} + 자체 가상계좌. + +정의: state/sim/variants.json = [{"id":"v1","name":"...","params":{KEY:VAL,...}}] +계좌: state/sim/variants//{portfolio.json, trades.jsonl} +비교: state/sim/variants_compare.json (웹 비교 탭) +""" +from __future__ import annotations + +import json + +from . import config + +VARIANTS_PATH = config.STATE_DIR / 'variants.json' +VARIANTS_DIR = config.STATE_DIR / 'variants' +COMPARE_PATH = config.STATE_DIR / 'variants_compare.json' + + +def load_variants() -> list[dict]: + try: + return json.loads(VARIANTS_PATH.read_text()) + except Exception: + return [] + + +def save_variants(variants: list[dict]): + config.STATE_DIR.mkdir(parents=True, exist_ok=True) + VARIANTS_PATH.write_text(json.dumps(variants, ensure_ascii=False, indent=2)) + + +def variant_paths(vid: str): + d = VARIANTS_DIR / vid + return d / 'portfolio.json', d / 'trades.jsonl' + + +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'} + return '·'.join(f'{short.get(k, k)}{v:g}' for k, v in params.items()) + + +def seed_from_sweep(n: int = 3) -> list[dict]: + """backtest_results.json 검증 상위 n개 → 변이로 등록 (기존 계좌는 유지).""" + try: + res = json.loads((config.STATE_DIR / 'backtest_results.json').read_text()) + except Exception: + return [] + existing = {v['id']: v for v in load_variants()} + variants = list(existing.values()) + seen_params = {json.dumps(v['params'], sort_keys=True) for v in variants} + rank = 0 + for r in res.get('results', []): + if rank >= n: + break + params = r['params'] + key = json.dumps(params, sort_keys=True) + if key in seen_params: + continue + rank += 1 + vid = f'v{len(variants)+1}' + variants.append({'id': vid, 'name': f'#{rank} {_label(params)}', 'params': params}) + seen_params.add(key) + save_variants(variants) + return variants + + +def add_variant(params: dict) -> dict | None: + """단일 params 조합을 변이로 추가 (백테스트 행 → 비교군). 이미 있으면 None.""" + variants = load_variants() + seen = {json.dumps(v['params'], sort_keys=True) for v in variants} + if json.dumps(params, sort_keys=True) in seen: + return None + nums = [int(v['id'][1:]) for v in variants if v['id'][1:].isdigit()] + vid = f'v{(max(nums) + 1) if nums else 1}' + v = {'id': vid, 'name': _label(params), 'params': params} + variants.append(v) + save_variants(variants) + return v + + +def remove_variant(vid: str): + """변이 1개 삭제 (정의 + 계좌).""" + import shutil + save_variants([v for v in load_variants() if v['id'] != vid]) + d = VARIANTS_DIR / vid + if d.exists(): + shutil.rmtree(d) + + +def reset_variant(vid: str): + """변이 1개 성적만 초기화 (정의 유지 — 계좌 디렉터리 삭제 후 다음 스캔에 재생성).""" + import shutil + d = VARIANTS_DIR / vid + if d.exists(): + shutil.rmtree(d) + + +def reset_all(): + """모든 변이 계좌 초기화 (variants.json 정의는 유지).""" + import shutil + if VARIANTS_DIR.exists(): + shutil.rmtree(VARIANTS_DIR) + + +def clear(): + """변이 정의·계좌 전부 삭제.""" + reset_all() + if VARIANTS_PATH.exists(): + VARIANTS_PATH.unlink() diff --git a/agents/stock/workspace/state/market_indicators_history.jsonl b/agents/stock/workspace/state/market_indicators_history.jsonl index 267681a..39ffee3 100644 --- a/agents/stock/workspace/state/market_indicators_history.jsonl +++ b/agents/stock/workspace/state/market_indicators_history.jsonl @@ -20,3 +20,5 @@ {"date": "2026-06-04", "market": "KOSPI", "market_label": "코스피", "rise": 447, "upper": 2, "fall": 446, "lower": 0, "steady": 31, "adr": 100.67, "personal": 50135, "foreign": -66663, "institutional": 15255, "captured_at": "2026-06-04T21:00:02.407555+09:00"} {"date": "2026-06-05", "market": "KOSDAQ", "market_label": "코스닥", "rise": 389, "upper": 4, "fall": 1298, "lower": 0, "steady": 49, "adr": 30.28, "personal": 337, "foreign": -1878, "institutional": 1508, "captured_at": "2026-06-05T21:00:05.007446+09:00"} {"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"} diff --git a/agents/stock/workspace/state/trade_journal.jsonl b/agents/stock/workspace/state/trade_journal.jsonl index 0773348..6004742 100644 --- a/agents/stock/workspace/state/trade_journal.jsonl +++ b/agents/stock/workspace/state/trade_journal.jsonl @@ -70,3 +70,9 @@ {"date": "2026-06-05", "account": "ISA", "owner": "본인", "code": "017670", "name": "SK텔레콤", "buy_qty": 0, "buy_avg": 0, "buy_amt": 0, "sell_qty": 11, "sell_avg": 108300, "sell_amt": 1191300, "pl_amt": 102345, "cmsn_tax": 2551, "prft_rt": 9.42, "collected_at": "2026-06-05T21:06:49.660822+09:00"} {"date": "2026-06-05", "account": "ISA", "owner": "본인", "code": "0177A0", "name": "WON 두산그룹포커스", "buy_qty": 0, "buy_avg": 0, "buy_amt": 0, "sell_qty": 44, "sell_avg": 12111, "sell_amt": 532890, "pl_amt": 31220, "cmsn_tax": 70, "prft_rt": 6.22, "collected_at": "2026-06-05T21:06:49.660822+09:00"} {"date": "2026-06-05", "account": "ISA", "owner": "본인", "code": "233740", "name": "KODEX 코스닥150레버리지", "buy_qty": 116, "buy_avg": 12880, "buy_amt": 1494080, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 220, "prft_rt": 0.0, "collected_at": "2026-06-05T21:06:49.660822+09:00"} +{"date": "2026-06-08", "account": "일반", "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": "일반", "owner": "본인", "code": "005930", "name": "삼성전자", "buy_qty": 1, "buy_avg": 301000, "buy_amt": 301000, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 40, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"} +{"date": "2026-06-08", "account": "일반", "owner": "본인", "code": "0193W0", "name": "KODEX 삼성전자단일종목레버리지", "buy_qty": 12, "buy_avg": 21200, "buy_amt": 254400, "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": "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"}