From a5e52dc69b43b7a89f6cae982a04ce4bae759661 Mon Sep 17 00:00:00 2001 From: hyowons Date: Mon, 15 Jun 2026 19:03:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(sim):=20=EA=B4=80=EC=B0=B0=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=9E=90=EB=8F=99=ED=8E=B8=EC=9E=85/=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=20=E2=80=94=20=ED=82=A4=EC=9B=80=20=EC=88=9C=EC=9C=84?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A1=9C=20=EC=A3=BC=EB=8F=84=EC=A3=BC=20?= =?UTF-8?q?=EB=A7=A4=EC=9D=BC=20=ED=8E=B8=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kiwoom_client: get_volume_surge(ka10023)·get_foreign_inst_netbuy(ka90009) 순위 조회 추가 (rkinfo endpoint) - universe: auto_import(외인·기관 순매수+거래량급증 상위, ETF·하락·급증률이상치 제외, 각15·총120 상한, origin=auto) + auto_prune(auto·미보유·10거래일 무신호만, 보유/수동/비하이브 불가침) + mark_signals - engine.scan_all: 변이 watching 종목을 mark_signals로 신호 추적(auto_seen.json) - CLI: python3 -m sim universe {import|prune|all} - trade-journal plist 평일21:00에 'sim universe all' 4번째 명령 추가 - 첫 실행: 후보38 → 32편입 (59→91종목) Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- agents/stock/workspace/MEMORY.md | 2 +- .../stock/workspace/scripts/kiwoom_client.py | 44 +++++ agents/stock/workspace/sim/__main__.py | 12 +- agents/stock/workspace/sim/engine.py | 10 ++ agents/stock/workspace/sim/universe.py | 102 +++++++++++ .../stock/workspace/state/sim/watchlist.json | 160 ++++++++++++++++++ 7 files changed, 329 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b4c4bf9..4376ca9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,7 +142,7 @@ OpenClaw 자동화는 두 갈래로 동작한다 (모두 Asia/Seoul): - **stock.ipo-calendar-sync** — 매주 금요일 17:00 — IPO 청약·상장 일정 캘린더 등록 - **stock.holiday-sync** — 매주 일요일 03:00 — investing.com KRX 휴장일 → `state/market_holidays.json` (behive_web 자동갱신 토글이 참조) - **stock.send-balance** — 매월 1일 04:30 — 본인 잔액 → 골디 inbox (`securities_balance`) -- **stock.trade-journal** — 평일 21:00 — EOD 데이터 누적 묶음. ProgramArguments는 `/bin/sh -c` wrapper로 세 명령 sequential 실행: ①`trade_journal.py collect` (ka10170 4계좌 → `state/trade_journal.jsonl`) ②`market_indicators_sync.py collect` (네이버 m.stock KOSPI/KOSDAQ ADR·투자자별 매매 → `state/market_indicators_history.jsonl`) ③`cd workspace && python3 -m sim.backfill_flow --pages 1 --force` (수급 DB `flow_history.sqlite` 일일 증분 — 2026-06-10 추가, 1회성 백필 후 stale해져 백테스트 수급게이트가 중립으로 비활성되던 문제 해소. INSERT OR REPLACE idempotent, 행수기준 skip이라 `--force` 필수). 하나 실패해도 나머지 시도. 로그는 `logs/stock-trade-journal.{log,err.log}` 한 곳에 합쳐짐. +- **stock.trade-journal** — 평일 21:00 — EOD 데이터 누적 묶음. ProgramArguments는 `/bin/sh -c` wrapper로 세 명령 sequential 실행: ①`trade_journal.py collect` (ka10170 4계좌 → `state/trade_journal.jsonl`) ②`market_indicators_sync.py collect` (네이버 m.stock KOSPI/KOSDAQ ADR·투자자별 매매 → `state/market_indicators_history.jsonl`) ③`cd workspace && python3 -m sim.backfill_flow --pages 1 --force` (수급 DB `flow_history.sqlite` 일일 증분 — 2026-06-10 추가, 1회성 백필 후 stale해져 백테스트 수급게이트가 중립으로 비활성되던 문제 해소. INSERT OR REPLACE idempotent, 행수기준 skip이라 `--force` 필수) ④`python3 -m sim universe all` (2026-06-15 추가 — sim 관찰목록 자동편입+자동제외. 키움 순위정보 ka90009 외인·기관 순매수 + ka10023 거래량급증 상위에서 ETF·하락·급증률 이상치 제외하고 각 15개·총 120 상한 편입(origin='auto'), origin=auto·미보유·10거래일 무신호는 자동 제외. 보유·watch·interest·manual은 불가침. 신호추적은 scan_all이 `universe.mark_signals`로 `state/sim/auto_seen.json`에 기록). 하나 실패해도 나머지 시도. 로그는 `logs/stock-trade-journal.{log,err.log}` 한 곳에 합쳐짐. - **stock.sim-scan** — 평일 09:00–15:30 **매 5분** (`StartCalendarInterval` 79엔트리, 엔진이 장외/휴장 self-skip) — **자동매매 시뮬(페이퍼)** 1회 스캔. (2026-06-09 15분→5분 단축) ⚠️ 2026-06-09 `StartInterval` 900s → `StartCalendarInterval` 전환: GUI LaunchAgent의 StartInterval은 세션 idle/디스플레이 sleep 시 timer coalescing으로 발화가 보류(`pended`)돼 장중에 안 도는 문제 발견 (calendar 잡인 git-autopush는 새벽 02:00에도 정상 발화하는 게 비교 증거). 월시간 기준이라 idle 지연 없음. `python3 -m sim scan` (cwd `agents/stock/workspace`). 가상자본 1억, 규칙 엔진(LLM 미경유). universe=누적 관찰목록(워치+관심+보유 자동편입·수동삭제), 방향=애널 약한게이트+가점, 타이밍=수급·기술. 결과 `state/sim/`. 실주문 `orders/` 불가침. 상세는 아래 sim 모듈 섹션·레이 MEMORY.md - **stock.sim-web** — 상시 (`KeepAlive`) — 시뮬 대시보드 **별도 서버 포트 18792** (behive-web 18790과 분리). `python3 -m sim.sim_web serve`. sim state 읽기전용 렌더. `sim.hyowons.net` → mac:18792 (Synology reverse proxy, **수동 등록 대기**). ⚠️ 18791은 node 점유라 18792 사용 - **git-autopush** — 매일 02:00 — `scripts/git_autopush.sh`: 변경 있으면 `git add -A` + 자동 커밋 + `git push origin main`. 변경 없으면 skip(빈 커밋 X). 인증은 HTTPS + `credential.helper=store`(`~/.git-credentials`), GUI 키체인 불필요. 로그 `logs/git-autopush.{log,err.log}`. 워크스페이스 버전관리 백업용 (시크릿·sqlite는 .gitignore 제외라 별도 백업 필요) diff --git a/agents/stock/workspace/MEMORY.md b/agents/stock/workspace/MEMORY.md index 0538e4f..6f4add8 100644 --- a/agents/stock/workspace/MEMORY.md +++ b/agents/stock/workspace/MEMORY.md @@ -49,7 +49,7 @@ | `ipo-calendar-sync` | 매주 금요일 17:00 | `ipo_calendar_sync.py` | IPO 청약·상장 일정 → Google Calendar | | `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` | +| `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`. **2026-06-15 4번째 명령 추가**: `python3 -m sim universe all` — sim 관찰목록 자동편입/제외. 키움 순위정보(ka90009 외인·기관순매수 + ka10023 거래량급증)에서 ETF·하락·급증률 이상치 거르고 각 15·총 120 상한 편입(origin='auto'), origin=auto·미보유·10거래일 무신호만 자동제외(보유/watch/interest/manual 불가침). 신호추적=scan_all `universe.mark_signals`→`auto_seen.json` | | `sim-scan` | 평일 09:00–15:30 **매 5분** (`StartCalendarInterval` 79엔트리, 엔진이 장외/휴장 self-skip) ⚠️2026-06-09 15분→5분 단축 + `StartInterval` 900s→`StartCalendarInterval` 전환(StartInterval은 GUI세션 idle/디스플레이 sleep 시 timer coalescing으로 발화 보류돼 장중 안 돎. calendar는 git-autopush가 새벽 02:00 정상발화로 검증됨) | `python3 -m sim scan` (내부 `scan_all` — 메인+병렬변이 동시) | **자동매매 시뮬(페이퍼)** 1회 스캔. 가상자본 1억(2026-06-10 1천만→1억), 규칙 엔진(LLM 미경유). universe=비하이브 워치+관심+보유(**ETF/ETN은 2026-06-09부터 자동매수 제외** — 애널 방향성 부재로 전략 부정합, `universe.is_etf()` 프리픽스 판별, 보유분은 청산까지 추적). 방향=애널 약한게이트+가점, 타이밍=수급·기술(2026-06-09 시장강약 필터: 하드차단→소프트게이트+장중 실시간 상승비율 + **지수추세 이중확인**). **2026-06-09 4종 추가**: 리스크 기반 사이징(`signals.target_value` 수량=자산×`RISK_PER_TRADE_PCT`÷손절폭, 균등비중 캡)·RSI 필터(돌파 과열/눌림목 과매도 보류)·**시장 국면 6단계**(`market_regime_map`→`engine.REGIME`: 🔥강세·📈반등·🌤완만상승=매수허용 / ➖혼조·📉약세·❄️급락=보류. 상승비율+지수추세+당일등락 조합. market_ok·시장탭 공용. 강세/약세 2단계가 84% 반등을 약세로 오판해 세분화)·**약세장 매수모드**(`BEAR_ENTRY_MODE` 0현금화/1돌파만/2눌림허용, market_ok False일 때 signals 분기. backtest는 시장중립이라 무영향→병렬변이로 라이브 비교. 2026-06-10 **유형36×모드3=108변이 풀매트릭스**로 확장, 첫 라이브 2026-06-11)·시간손절(`MAX_HOLD_DAYS` 미진전 청산)·**추세 질 보강**(2026-06-09 trend_ok 게이트에 ①20일선 우상향 기울기 ②5>60 중기정배열 추가〔종목데이터, backtest 반영〕 + ③상대강도〔종목−지수 N일수익률, 라이브 전용 체크〕). 사이징·시간손절은 엔진·백테스트 공용 헬퍼. TUNABLE 27개. **2026-06-10 코드리뷰 보강**: ①수급DB 일일증분(trade-journal 21시 묶음 3번째 명령 `-m sim.backfill_flow --pages 1 --force` — stale시 백테스트 수급게이트 중립화됨) ②일별 성과이력 `state/sim/equity_history.json`(EOD 덮어쓰기, ph=파라미터해시) ③당일 재진입 쿨다운(`Portfolio.last_exit`, backtest 동일) ④장중 거래량 하루치 환산(`vol_time_frac` — 이전엔 오전 돌파 불가). ⑤백테스트 체결 현실화(당일종가 판단→다음날 시가 체결 pending큐 + `BT_SLIPPAGE` 0.2% — 낙관분 ~5%p 제거) ⑥3분할 꾸준함 순위(`worst_ret` 1순위 — 한 구간 운빨 배제) ⑧**기준전략(M) 폐지**(2026-06-10 — scan_all 메인 패스 execute=False 판단전용·계좌 동결, 비교/매매는 변이 36개만, 메인 제외 규칙 삭제, 마킹 아이콘 6종 `favorites.json {아이콘:vid}`+`/fav_set`〔2026-06-11 ☆토글→아이콘 피커, 아이콘당 전략 1개〕, 관심종목 기본 시각=첫 마킹→1위) ⑦비교군 동기화는 **수동 전용**(2026-06-10 자동 동기화 끔 — sweep은 결과 파일만 갱신, 비교군 변경은 관리자님 명시 요청 시만 `python3 -m sim variants sync`. `sync_label_tops`=유형(~36종)별 1위 동기화, 동일params 계좌 유지. 백테스트 탭은 유형별 대표 읽기전용 요약, 수동 추가/제거·튜닝적용 버튼 제거). 잔여 한계: 검증 데이터가 여전히 ~1년 강세 위주(진짜 하락장 검증은 라이브 누적)·생존편향 universe(의도된 설계). 매수=돌파 즉시/눌림목 지정가, 매도=손절·2:1 후 트레일링·추세수급 이탈. 결과 `state/sim/`(portfolio.json·trades.jsonl·last_scan.json). 실주문 `orders/` 불가침. 일봉은 캐시 우선 읽기(ka10081 rate limit 회피). cwd=`agents/stock/workspace` | | `sim-web` | 상시 (`KeepAlive`) | `python3 -m sim.sim_web serve` | 시뮬 대시보드 **별도 서버 포트 18792** (behive-web 18790과 분리). `sim.hyowons.net` → mac:18792 (Synology reverse proxy, **관리자님 수동 등록 대기**). Tailnet 한정 무인증. ⚠️ 18791은 node 점유라 18792. **3탭**(2026-06-10~11 개편, 내계좌·튜닝 탭 삭제): **전략**(기본=비교만, 서브탭 버튼 없음 — 백테스트 순위표는 [📊 모의결과보기] 토글)·**관심종목**(전 전략 합산 상태그룹 + 시각 드롭다운 + 👁토스트 맨 위 '▶ 시각' 판단 표시)·**시장**(지수/ADR/투자자). 비교 행 표기: 순위배지 `N위` → 약세장대처 칩(회피/안정/적극 — 2026-06-11 '돌파'→'안정' 개명, 칩 클릭=설명 토스트) → 고유번호 `#N` → 짧은 유형명(`상승장·빠른·공격·추세추종`) → 마킹 아이콘(6종 ⭐🔥💎🎯👀🚀, **아이콘당 전략 1개**, `favorites.json {아이콘:vid}`, `/fav_set`). 펼침 body: 유형(ⓘ=시장적합)→약세장대처(ⓘ)→성적→모의평가(`btN위/36` — 약세모드 3형제는 같은 bt등수 공유). **108변이 = 유형36 × 약세모드3**(2026-06-10 풀매트릭스, v37/38=bt1위의 회피/적극 쌍 순서). 새로고침 탭 보존(라디오↔URL 동기화 + `Cache-Control: no-store`, 2026-06-11). 읽기전용 원칙, 쓰기는 `do_POST` save_params/run_sweep/variants_*/watch_*/fav_set만. 시장 탭은 장중엔 네이버 m.stock 실시간(지수+상승하락·ADR·투자자 순매수, `_get_live_market` 60s 캐시), 장외엔 EOD jsonl 폴백 (2026-06-09: 이전엔 상승하락·투자자가 EOD 고정이라 장중 안 바뀌던 것 수정) | | (트리거 없음) | on-demand | `python3 -m sim {sweep\|backtest}` | 튜닝 비교용 백테스트 스윕. signals.py 재사용·과거 일봉 되감기. 9파라미터(RR·STOP·SMA·PULLBACK·VOLUME + RSI과열/과매도·시간손절·리스크비율) 1152조합 학습/검증 분리 → `state/sim/backtest_results.json`. ⚡멀티프로세싱(2026-06-10 spawn, 워커=코어−2)+일봉/수급 프로세스 메모이즈 → 1152조합 44분→~5-7분. 데이터는 sqlite캐시 전용(키움API 0), 병목은 지표 재계산이라 캐싱 아닌 병렬화가 답. GRID 곱셈 폭발 주의. ⚠️ 종가체결·생존편향 → **상대순위**로만. 2026-06-09 스윕: 핵심 레버 `VOLUME_BREAKOUT=1.3`, 신규4종은 강세창선 미미(하락장 보험). 균형값 params.json 적용(RISK 0.01 유지). 수급은 `backfill_flow.py`(ka10059 다년치 → `flow_history.sqlite`, 2026-06-08 54종목 3만행 적재)로 반영됨(`flow_used:true`). 런타임 튜닝은 `params.json`이 config 기본값 오버라이드(엔진 매 스캔 새프로세스라 즉시 반영). **병렬 페이퍼**: `variants.json`+`state/sim/variants//` — 스윕 상위 튜닝을 각자 가상계좌로 실시간 동시 운영, 같은 라이브 시세에 다른 파라미터(애널 게이트까지 실반영), `variants_compare.json`→웹 비교 탭. `python3 -m sim variants seed N`. sim-scan이 매 스캔 메인+변이 함께 갱신 | diff --git a/agents/stock/workspace/scripts/kiwoom_client.py b/agents/stock/workspace/scripts/kiwoom_client.py index f857c66..ca0e1d2 100644 --- a/agents/stock/workspace/scripts/kiwoom_client.py +++ b/agents/stock/workspace/scripts/kiwoom_client.py @@ -32,6 +32,7 @@ ENDPOINT_ACNT = '/api/dostk/acnt' ENDPOINT_STKINFO = '/api/dostk/stkinfo' ENDPOINT_MRKCOND = '/api/dostk/mrkcond' # 시세(market condition) — ka10004 호가 등 ENDPOINT_CHART = '/api/dostk/chart' # 차트 — ka10081 일봉 +ENDPOINT_RKINFO = '/api/dostk/rkinfo' # 순위정보 — ka10023 거래량급증, ka90009 외인기관매매상위 TR_DEPOSIT = 'kt00001' # 예수금상세현황요청 TR_ACNT_EVAL = 'kt00004' # 계좌평가현황요청 (acnt_nm·brch_nm 포함) TR_TRUST_TRADE = 'kt00015' # 위탁종합거래내역요청 (tp='1' 입출금 — 당일 추가입금/인출 추출용) @@ -1051,6 +1052,49 @@ def _load_code_cache() -> dict: return {} +def get_volume_surge(account_label: str | None = None, market: str = '000', + min_qty: str = '50') -> list[dict]: + """거래량 급증 종목 (ka10023). 급증률 정렬·ETF+ETN+스팩 제외·5천원↑·KRX. + 반환 [{code, name, price, flu_rt(등락률%), surge_rt(급증률%)}] — 조회 전용.""" + label = account_label or _default_account_label() + body = {'mrkt_tp': market, 'sort_tp': '2', 'tm_tp': '2', 'trde_qty_tp': min_qty, + 'tm': '', 'stk_cnd': '20', 'pric_tp': '6', 'stex_tp': '1'} # stk_cnd 20=ETF+ETN+스팩제외 + resp = _call(label, 'ka10023', body, ENDPOINT_RKINFO) + out = [] + for x in resp.get('trde_qty_sdnin', []) or []: + code = _clean_code(x.get('stk_cd', '')) + if not code: + continue + out.append({'code': code, 'name': x.get('stk_nm', ''), + 'price': abs(_to_int(x.get('cur_prc'))), + 'flu_rt': _to_float(x.get('flu_rt')), + 'surge_rt': _to_float(x.get('sdnin_rt'))}) + return out + + +def get_foreign_inst_netbuy(account_label: str | None = None, market: str = '000', + date: str | None = None) -> dict: + """외국인·기관 순매수 상위 (ka90009). 금액(천만) 기준. + 반환 {'foreign': [{code,name,amt}], 'inst': [{code,name,amt}]} — 조회 전용.""" + label = account_label or _default_account_label() + if not date: + date = datetime.now(KST).strftime('%Y%m%d') + body = {'mrkt_tp': market, 'amt_qty_tp': '1', 'qry_dt_tp': '1', 'date': date, 'stex_tp': '1'} + resp = _call(label, 'ka90009', body, ENDPOINT_RKINFO) + rows = resp.get('frgnr_orgn_trde_upper', []) or [] + foreign, inst = [], [] + for x in rows: + fc = _clean_code(x.get('for_netprps_stk_cd', '')) + if fc: + foreign.append({'code': fc, 'name': x.get('for_netprps_stk_nm', ''), + 'amt': _to_int(x.get('for_netprps_amt'))}) + ic = _clean_code(x.get('orgn_netprps_stk_cd', '')) + if ic: + inst.append({'code': ic, 'name': x.get('orgn_netprps_stk_nm', ''), + 'amt': _to_int(x.get('orgn_netprps_amt'))}) + return {'foreign': foreign, 'inst': inst} + + def refresh_stock_codes(account_label: str | None = None) -> dict: """ka10099로 KOSPI+KOSDAQ 전체 상장종목 리스트 캐싱. 페이지네이션 처리. diff --git a/agents/stock/workspace/sim/__main__.py b/agents/stock/workspace/sim/__main__.py index bfd63cd..cba02ba 100644 --- a/agents/stock/workspace/sim/__main__.py +++ b/agents/stock/workspace/sim/__main__.py @@ -136,13 +136,23 @@ def _cmd_reset(argv): print(f'초기화 완료 — 가상자본 {config.INITIAL_CAPITAL:,}원') +def _cmd_universe(argv): + """관찰목록 자동편입/제외 — 매일 장 마감 후 1회. 'import'=주도주 편입, 'prune'=무신호 auto 제외, 둘 다 기본.""" + from . import universe + sub = argv[0] if argv else 'all' + if sub in ('import', 'all'): + print('자동편입:', universe.auto_import()) + if sub in ('prune', 'all'): + print('자동제외:', universe.auto_prune()) + + 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, + 'backfill-index': _cmd_backfill_index, 'universe': _cmd_universe, 'variants': _cmd_variants, 'reset': _cmd_reset}.get(cmd, lambda a: print(__doc__))(rest) diff --git a/agents/stock/workspace/sim/engine.py b/agents/stock/workspace/sim/engine.py index 6f36423..9833010 100644 --- a/agents/stock/workspace/sim/engine.py +++ b/agents/stock/workspace/sim/engine.py @@ -472,6 +472,16 @@ def scan_all(force: bool = False) -> dict: 'held': sorted(vpf.positions)}) compare.sort(key=lambda c: c['summary'].get('total_return_pct', 0) or 0, reverse=True) + + # 자동편입(auto) 종목 신호 추적 — 한 변이라도 노리는(매수/대기) 종목은 '살아있음' 기록 (auto_prune 판단용) + try: + from . import universe as _um + sig = {it['code'] for c in compare for it in (c.get('watching') or []) if it.get('code')} + if sig: + _um.mark_signals(sorted(sig)) + except Exception: + pass + variants_mod.COMPARE_PATH.write_text(json.dumps({ 'scanned_at': now.isoformat(), 'session': is_market_session(now), 'variants': compare, diff --git a/agents/stock/workspace/sim/universe.py b/agents/stock/workspace/sim/universe.py index adb70aa..10d7f42 100644 --- a/agents/stock/workspace/sim/universe.py +++ b/agents/stock/workspace/sim/universe.py @@ -151,6 +151,108 @@ def remove(code: str): _save_watchlist(wl) +# ── 자동편입(auto) — 키움 순위정보로 주도주를 매일 후보에 편입 ────────────── +# 소스: ka90009 외인·기관 순매수(수급 좋은 종목, 우리 전략 수급게이트와 궁합) + ka10023 거래량급증(관심 폭발). +# 규칙: ETF/ETN·급증률 이상치·하락 제외, 각 소스 상위 N, 총 상한. 자동제외는 origin=auto·미보유·N거래일 무신호만. +AUTO_TOP_N = 15 # 각 소스 상위 N 편입 +AUTO_TOTAL_CAP = 120 # 관찰목록 총 상한 (auto 편입은 이 한도 내에서만) +AUTO_SURGE_CAP = 1000.0 # 거래량 급증률 이 % 초과는 노이즈로 제외 +AUTO_PRUNE_DAYS = 10 # auto 종목이 이 거래일 수 동안 무신호면 자동 제외 (보유·수동 등은 제외 안 함) + + +def _held_codes() -> set: + try: + held = kc.get_positions_all(labels=config.OWNER_ACCOUNT_LABELS) + return {(p.get('code') or '').strip().zfill(6) + for poss in held.values() for p in poss if p.get('qty', 0) > 0} + except Exception: + return set() + + +def auto_import() -> dict: + """키움 순위정보로 주도주 자동 편입(origin='auto'). 총 상한 내에서만. 결과 요약 반환.""" + cands: dict[str, str] = {} # code -> name (중복 자동 dedup) + + def take(rows, key_name='name', n=AUTO_TOP_N): + c = 0 + for x in rows: + if c >= n: + break + code = (x.get('code') or '').strip().zfill(6) + nm = x.get(key_name, '') + if not code or is_etf(nm) or code in cands: + continue + cands[code] = nm + c += 1 + + try: + nb = kc.get_foreign_inst_netbuy() + take([x for x in nb['foreign'] if x['amt'] > 0]) # 외인 순매수 + take([x for x in nb['inst'] if x['amt'] > 0]) # 기관 순매수 + except Exception as e: + sys.stderr.write(f'[universe] 외인기관 순매수 조회 실패: {e}\n') + try: + vs = kc.get_volume_surge() + take([x for x in vs if x['flu_rt'] > 0 and 0 < x['surge_rt'] <= AUTO_SURGE_CAP]) # 거래량 급증 + except Exception as e: + sys.stderr.write(f'[universe] 거래량급증 조회 실패: {e}\n') + + wl = _load_watchlist() + now = datetime.now(config.KST).isoformat() + added = 0 + for code, nm in cands.items(): + if code in wl: + continue + if len(wl) >= AUTO_TOTAL_CAP: # 상한 도달 시 신규 auto 편입 중단 + break + wl[code] = {'name': nm, 'origin': 'auto', 'added_at': now} + added += 1 + if added: + _save_watchlist(wl) + return {'candidates': len(cands), 'added': added, 'total': len(wl)} + + +def auto_prune(no_signal_days: int = AUTO_PRUNE_DAYS) -> dict: + """origin='auto'·미보유 종목 중 최근 no_signal_days 거래일 동안 한 번도 매수신호(BUY/ADD/WAIT-watch) 없던 것 제거. + 보유·수동·비하이브(watch/interest/held/manual)는 절대 건드리지 않는다.""" + wl = _load_watchlist() + auto_codes = {c for c, e in wl.items() if e.get('origin') == 'auto'} + if not auto_codes: + return {'pruned': 0, 'total': len(wl)} + held = _held_codes() + # 최근 신호 추적: state/sim/auto_seen.json {code: last_signal_iso}. 매 import 시 갱신. + seen = _load_json(config.STATE_DIR / 'auto_seen.json', {}) + now = datetime.now(config.KST) + pruned = 0 + for code in list(auto_codes): + if code in held: + continue + last = seen.get(code) + ent_added = wl[code].get('added_at', '') + ref = last or ent_added + try: + days = (now - datetime.fromisoformat(ref)).days + except Exception: + days = 0 + if days >= no_signal_days: + del wl[code] + pruned += 1 + if pruned: + _save_watchlist(wl) + return {'pruned': pruned, 'total': len(wl)} + + +def mark_signals(codes_with_signal: list[str]): + """이번 스캔에서 매수신호(BUY/ADD/대기) 든 종목의 '마지막 신호 시각' 기록 — auto_prune 판단용.""" + path = config.STATE_DIR / 'auto_seen.json' + seen = _load_json(path, {}) + now = datetime.now(config.KST).isoformat() + for c in codes_with_signal: + seen[(c or '').strip().zfill(6)] = now + config.STATE_DIR.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(seen, ensure_ascii=False)) + + def build_universe() -> list[dict]: """누적 관찰목록을 동기화 후 [{code, name, sources:[origin]}]로 반환. code 6자리 정규화.""" wl = sync_watchlist() diff --git a/agents/stock/workspace/state/sim/watchlist.json b/agents/stock/workspace/state/sim/watchlist.json index ef9c510..0c7647a 100644 --- a/agents/stock/workspace/state/sim/watchlist.json +++ b/agents/stock/workspace/state/sim/watchlist.json @@ -293,5 +293,165 @@ "name": "삼성생명", "origin": "interest", "added_at": "2026-06-15T13:15:03.591662+09:00" + }, + "003490": { + "name": "대한항공", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "036460": { + "name": "한국가스공사", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "020560": { + "name": "아시아나항공", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "089590": { + "name": "제주항공", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "057050": { + "name": "현대홈쇼핑", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "039290": { + "name": "인포뱅크", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "127710": { + "name": "아시아경제", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "403870": { + "name": "HPSP", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "267260": { + "name": "HD현대일렉트릭", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "240810": { + "name": "원익IPS", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "015760": { + "name": "한국전력", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "011070": { + "name": "LG이노텍", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "329180": { + "name": "HD현대중공업", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "012330": { + "name": "현대모비스", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "093370": { + "name": "후성", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "005490": { + "name": "POSCO홀딩스", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "178320": { + "name": "서진시스템", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "051910": { + "name": "LG화학", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "272450": { + "name": "진에어", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "153460": { + "name": "네이블", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "206650": { + "name": "유바이오로직스", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "014950": { + "name": "삼익제약", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "267850": { + "name": "아시아나IDT", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "204320": { + "name": "HL만도", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "311320": { + "name": "지오엘리먼트", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "0004V0": { + "name": "엔비알모션", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "108380": { + "name": "대양전기공업", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "477850": { + "name": "마키나락스", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "007690": { + "name": "국도화학", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "036010": { + "name": "아비코전자", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "006840": { + "name": "AK홀딩스", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" + }, + "004310": { + "name": "현대약품", + "origin": "auto", + "added_at": "2026-06-15T19:01:24.500328+09:00" } } \ No newline at end of file