auto: 일일 백업 2026-06-11 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-06-11 02:00:01 +09:00
parent af058cf36d
commit 81e8847a8e
14 changed files with 1064 additions and 342 deletions
+2 -2
View File
@@ -50,9 +50,9 @@
| `holiday-sync` | 매주 일요일 03:00 | `holiday_sync.py` | investing.com → `state/market_holidays.json`. 실패 시 기존 파일 보존 |
| `behive-web` | 상시 (`KeepAlive`) | `behive_web.py serve` | 3-탭 워치리스트 웹뷰. Tailscale `100.75.148.12:18790``https://stock.hyowons.net/`(Synology reverse proxy). 코드 변경 후 bootout/bootstrap 필요 |
| `trade-journal` | 평일 21:00 (스크립트가 휴장일 self-skip) | `trade_journal.py collect` | ka10170 당일매매일지 4계좌 → `state/trade_journal.jsonl` 누적. NXT 야간 마감 후 발화. 키움이 기간 거래내역 API 미제공이라 일자별 적재만이 유일. `briefing-fallback-2100`(21:00)과 같은 시각이지만 둘 다 read-only 충돌 없음. 재실행 시 (date,account) 단위 idempotent. **시드**: 2026-05-13 1회 `trade_journal.py seed` 로 적재 시작일 이전 보유분을 현재 평단가×(qty - tdy_buyq + tdy_sellq)로 단일 시드 행 압축(28건, seed=true 플래그, `*` 마커). 시드는 가중평균이라 과거 매수 단가와 정확히 일치 X. CLI: `collect`/`seed`/`show <code>`/`query --from --to --account --code` |
| `sim-scan` | 평일 09:0015:30 **매 5분** (`StartCalendarInterval` 79엔트리, 엔진이 장외/휴장 self-skip) ⚠️2026-06-09 15분→5분 단축 + `StartInterval` 900s→`StartCalendarInterval` 전환(StartInterval은 GUI세션 idle/디스플레이 sleep 시 timer coalescing으로 발화 보류돼 장중 안 돎. calendar는 git-autopush가 새벽 02:00 정상발화로 검증됨) | `python3 -m sim scan` (내부 `scan_all` — 메인+병렬변이 동시) | **자동매매 시뮬(페이퍼)** 1회 스캔. 가상자본 1천만, 규칙 엔진(LLM 미경유). universe=비하이브 워치+관심+보유(**ETF/ETN은 2026-06-09부터 자동매수 제외** — 애널 방향성 부재로 전략 부정합, `universe.is_etf()` 프리픽스 판별, 보유분은 청산까지 추적). 방향=애널 약한게이트+가점, 타이밍=수급·기술(2026-06-09 시장강약 필터: 하드차단→소프트게이트+장중 실시간 상승비율 + **지수추세 이중확인**). **2026-06-09 4종 추가**: 리스크 기반 사이징(`signals.target_value` 수량=자산×`RISK_PER_TRADE_PCT`÷손절폭, 균등비중 캡)·RSI 필터(돌파 과열/눌림목 과매도 보류)·**시장 국면 6단계**(`market_regime_map``engine.REGIME`: 🔥강세·📈반등·🌤완만상승=매수허용 / ➖혼조·📉약세·❄️급락=보류. 상승비율+지수추세+당일등락 조합. market_ok·시장탭 공용. 강세/약세 2단계가 84% 반등을 약세로 오판해 세분화)·**약세장 매수모드**(`BEAR_ENTRY_MODE` 0현금화/1돌파만/2눌림허용, market_ok False일 때 signals 분기. backtest는 시장중립이라 무영향→병렬변이로 라이브 비교. 2026-06-09 메인(mode1)+약세현금화·약세눌림허용 변이로 비교군 구성)·시간손절(`MAX_HOLD_DAYS` 미진전 청산)·**추세 질 보강**(2026-06-09 trend_ok 게이트에 ①20일선 우상향 기울기 ②5>60 중기정배열 추가〔종목데이터, backtest 반영〕 + ③상대강도〔종목−지수 N일수익률, 라이브 전용 체크〕). 사이징·시간손절은 엔진·백테스트 공용 헬퍼. TUNABLE 26개. 매수=돌파 즉시/눌림목 지정가, 매도=손절·2:1 후 트레일링·추세수급 이탈. 결과 `state/sim/`(portfolio.json·trades.jsonl·last_scan.json). 실주문 `orders/` 불가침. 일봉은 캐시 우선 읽기(ka10081 rate limit 회피). cwd=`agents/stock/workspace` |
| `sim-scan` | 평일 09:0015:30 **매 5분** (`StartCalendarInterval` 79엔트리, 엔진이 장외/휴장 self-skip) ⚠️2026-06-09 15분→5분 단축 + `StartInterval` 900s→`StartCalendarInterval` 전환(StartInterval은 GUI세션 idle/디스플레이 sleep 시 timer coalescing으로 발화 보류돼 장중 안 돎. calendar는 git-autopush가 새벽 02:00 정상발화로 검증됨) | `python3 -m sim scan` (내부 `scan_all` — 메인+병렬변이 동시) | **자동매매 시뮬(페이퍼)** 1회 스캔. 가상자본 1억(2026-06-10 1천만→1억), 규칙 엔진(LLM 미경유). universe=비하이브 워치+관심+보유(**ETF/ETN은 2026-06-09부터 자동매수 제외** — 애널 방향성 부재로 전략 부정합, `universe.is_etf()` 프리픽스 판별, 보유분은 청산까지 추적). 방향=애널 약한게이트+가점, 타이밍=수급·기술(2026-06-09 시장강약 필터: 하드차단→소프트게이트+장중 실시간 상승비율 + **지수추세 이중확인**). **2026-06-09 4종 추가**: 리스크 기반 사이징(`signals.target_value` 수량=자산×`RISK_PER_TRADE_PCT`÷손절폭, 균등비중 캡)·RSI 필터(돌파 과열/눌림목 과매도 보류)·**시장 국면 6단계**(`market_regime_map``engine.REGIME`: 🔥강세·📈반등·🌤완만상승=매수허용 / ➖혼조·📉약세·❄️급락=보류. 상승비율+지수추세+당일등락 조합. market_ok·시장탭 공용. 강세/약세 2단계가 84% 반등을 약세로 오판해 세분화)·**약세장 매수모드**(`BEAR_ENTRY_MODE` 0현금화/1돌파만/2눌림허용, market_ok False일 때 signals 분기. backtest는 시장중립이라 무영향→병렬변이로 라이브 비교. 2026-06-09 메인(mode1)+약세현금화·약세눌림허용 변이로 비교군 구성)·시간손절(`MAX_HOLD_DAYS` 미진전 청산)·**추세 질 보강**(2026-06-09 trend_ok 게이트에 ①20일선 우상향 기울기 ②5>60 중기정배열 추가〔종목데이터, backtest 반영〕 + ③상대강도〔종목−지수 N일수익률, 라이브 전용 체크〕). 사이징·시간손절은 엔진·백테스트 공용 헬퍼. TUNABLE 27개. **2026-06-10 코드리뷰 보강**: ①수급DB 일일증분(trade-journal 21시 묶음 3번째 명령 `-m sim.backfill_flow --pages 1 --force` — stale시 백테스트 수급게이트 중립화됨) ②일별 성과이력 `state/sim/equity_history.json`(EOD 덮어쓰기, ph=파라미터해시) ③당일 재진입 쿨다운(`Portfolio.last_exit`, backtest 동일) ④장중 거래량 하루치 환산(`vol_time_frac` — 이전엔 오전 돌파 불가). ⑤백테스트 체결 현실화(당일종가 판단→다음날 시가 체결 pending큐 + `BT_SLIPPAGE` 0.2% — 낙관분 ~5%p 제거) ⑥3분할 꾸준함 순위(`worst_ret` 1순위 — 한 구간 운빨 배제) ⑧**기준전략(M) 폐지**(2026-06-10 — scan_all 메인 패스 execute=False 판단전용·계좌 동결, 비교/매매는 변이 36개만, 메인 제외 규칙 삭제, ⭐즐겨찾기 `favorites.json`+`/fav_toggle`, 관심종목 기본 시각=첫⭐→1위) ⑦비교군 동기화는 **수동 전용**(2026-06-10 자동 동기화 끔 — sweep은 결과 파일만 갱신, 비교군 변경은 관리자님 명시 요청 시만 `python3 -m sim variants sync`. `sync_label_tops`=유형(~36종)별 1위 동기화, 동일params 계좌 유지. 백테스트 탭은 유형별 대표 읽기전용 요약, 수동 추가/제거·튜닝적용 버튼 제거). 잔여 한계: 검증 데이터가 여전히 ~1년 강세 위주(진짜 하락장 검증은 라이브 누적)·생존편향 universe(의도된 설계). 매수=돌파 즉시/눌림목 지정가, 매도=손절·2:1 후 트레일링·추세수급 이탈. 결과 `state/sim/`(portfolio.json·trades.jsonl·last_scan.json). 실주문 `orders/` 불가침. 일봉은 캐시 우선 읽기(ka10081 rate limit 회피). cwd=`agents/stock/workspace` |
| `sim-web` | 상시 (`KeepAlive`) | `python3 -m sim.sim_web serve` | 시뮬 대시보드 **별도 서버 포트 18792** (behive-web 18790과 분리). `sim.hyowons.net` → mac:18792 (Synology reverse proxy, **관리자님 수동 등록 대기**). Tailnet 한정 무인증. ⚠️ 18791은 node 점유라 18792. **5탭**: 내 계좌·관심종목·시장(지수/ADR/투자자)·튜닝(파라미터 저장 → `params.json`)·백테스트(스윕 순위표+적용). 읽기전용 원칙이나 `do_POST /save_params`만 쓰기. 시장 탭은 장중엔 네이버 m.stock 실시간(지수+상승하락·ADR·투자자 순매수, `_get_live_market` 60s 캐시), 장외엔 EOD jsonl 폴백 (2026-06-09: 이전엔 상승하락·투자자가 EOD 고정이라 장중 안 바뀌던 것 수정) |
| (트리거 없음) | on-demand | `python3 -m sim {sweep\|backtest}` | 튜닝 비교용 백테스트 스윕. signals.py 재사용·과거 일봉 되감기. 9파라미터(RR·STOP·SMA·PULLBACK·VOLUME + RSI과열/과매도·시간손절·리스크비율) 1152조합 학습/검증 분리 → `state/sim/backtest_results.json`. ⚠️ 조합당 ~2.3s → 1152조합 44분(GRID 곱셈 폭발 주의). ⚠️ 종가체결·생존편향 → **상대순위**로만. 2026-06-09 스윕: 핵심 레버 `VOLUME_BREAKOUT=1.3`, 신규4종은 강세창선 미미(하락장 보험). 균형값 params.json 적용(RISK 0.01 유지). 수급은 `backfill_flow.py`(ka10059 다년치 → `flow_history.sqlite`, 2026-06-08 54종목 3만행 적재)로 반영됨(`flow_used:true`). 런타임 튜닝은 `params.json`이 config 기본값 오버라이드(엔진 매 스캔 새프로세스라 즉시 반영). **병렬 페이퍼**: `variants.json`+`state/sim/variants/<id>/` — 스윕 상위 튜닝을 각자 가상계좌로 실시간 동시 운영, 같은 라이브 시세에 다른 파라미터(애널 게이트까지 실반영), `variants_compare.json`→웹 비교 탭. `python3 -m sim variants seed N`. sim-scan이 매 스캔 메인+변이 함께 갱신 |
| (트리거 없음) | on-demand | `python3 -m sim {sweep\|backtest}` | 튜닝 비교용 백테스트 스윕. signals.py 재사용·과거 일봉 되감기. 9파라미터(RR·STOP·SMA·PULLBACK·VOLUME + RSI과열/과매도·시간손절·리스크비율) 1152조합 학습/검증 분리 → `state/sim/backtest_results.json`. ⚡멀티프로세싱(2026-06-10 spawn, 워커=코어−2)+일봉/수급 프로세스 메모이즈 → 1152조합 44분→~5-7분. 데이터는 sqlite캐시 전용(키움API 0), 병목은 지표 재계산이라 캐싱 아닌 병렬화가 답. GRID 곱셈 폭발 주의. ⚠️ 종가체결·생존편향 → **상대순위**로만. 2026-06-09 스윕: 핵심 레버 `VOLUME_BREAKOUT=1.3`, 신규4종은 강세창선 미미(하락장 보험). 균형값 params.json 적용(RISK 0.01 유지). 수급은 `backfill_flow.py`(ka10059 다년치 → `flow_history.sqlite`, 2026-06-08 54종목 3만행 적재)로 반영됨(`flow_used:true`). 런타임 튜닝은 `params.json`이 config 기본값 오버라이드(엔진 매 스캔 새프로세스라 즉시 반영). **병렬 페이퍼**: `variants.json`+`state/sim/variants/<id>/` — 스윕 상위 튜닝을 각자 가상계좌로 실시간 동시 운영, 같은 라이브 시세에 다른 파라미터(애널 게이트까지 실반영), `variants_compare.json`→웹 비교 탭. `python3 -m sim variants seed N`. sim-scan이 매 스캔 메인+변이 함께 갱신 |
휴장일 self-skip·당일 평가손익 ground truth·3-탭 개편 배경은 일지(2026-05-04·05-06)와 `behive_web.py` 주석 참조. 사고 이력은 `memory/2026-05-07-behive-dup-mail.md` 등.
+7 -1
View File
@@ -3,7 +3,8 @@
python3 -m sim scan [--force] # 1회 스캔 (launchd/cron 진입점)
python3 -m sim status # 가상계좌 요약
python3 -m sim report [N] # 최근 거래 N건 (기본 20)
python3 -m sim reset [--yes] # 가상계좌 초기화 (1천만 리셋)
python3 -m sim reset [--yes] # 가상계좌 초기화
python3 -m sim variants sync # 비교군을 백테스트 유형별 1위로 동기화 (수동 전용)
"""
from __future__ import annotations
@@ -107,6 +108,11 @@ def _cmd_variants(argv):
print(f'변이 {len(vs)}개 등록:')
for v in vs:
print(' ', v['id'], v['name'])
elif sub == 'sync':
import json as _json
from . import config as _cfg
res = _json.loads((_cfg.STATE_DIR / 'backtest_results.json').read_text())
print('동기화:', variants.sync_label_tops(res))
elif sub == 'reset':
variants.reset_all()
print('변이 계좌 초기화 (정의 유지)')
+103 -51
View File
@@ -64,6 +64,24 @@ def load_flow(code: str) -> dict | None:
return None
# 프로세스 메모이즈 — 같은 sweep(한 프로세스) 안에서 종목당 sqlite를 1회만 읽는다.
# backtest.run 은 sweep CLI 프로세스에서만 호출되므로(엔진/웹은 apply_params만 씀) stale 위험 없음.
_HIST_MEMO: dict = {}
_FLOW_MEMO: dict = {}
def _hist_cached(code: str) -> list[dict]:
if code not in _HIST_MEMO:
_HIST_MEMO[code] = load_history(code)
return _HIST_MEMO[code]
def _flow_cached(code: str):
if code not in _FLOW_MEMO:
_FLOW_MEMO[code] = load_flow(code)
return _FLOW_MEMO[code]
def _flow_net_upto(flow: dict | None, dates_seen: list[str]) -> dict:
"""최근 FLOW_DAYS 일 외국인·기관 누적. flow 없으면 중립(둘 다 +1 → 수급 통과)."""
if flow is None:
@@ -82,12 +100,12 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to:
hist = {}
for code in codes:
h = [c for c in load_history(code) if date_from <= c['date'] <= date_to]
h = [c for c in _hist_cached(code) if date_from <= c['date'] <= date_to]
if len(h) > config.SMA_LONG + config.ATR_PERIOD + 5:
hist[code] = h
if not hist:
return {'error': 'no_data', 'trades': 0}
flows = {code: load_flow(code) for code in hist}
flows = {code: _flow_cached(code) for code in hist}
# code별 date→index, 전체 거래일 축
idx_map = {code: {c['date']: i for i, c in enumerate(h)} for code, h in hist.items()}
@@ -99,6 +117,7 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to:
wins = closed = 0
gross_win = gross_loss = 0.0
peak_eq = capital
exited: dict[str, str] = {} # code → 전량청산일 (당일 재진입 금지)
max_dd = 0.0
comm, tax = config.COMMISSION_RATE, config.SELL_TAX_RATE
@@ -108,9 +127,82 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to:
def _qty_for(fill, value):
return int(value // (fill * (1 + comm)))
slip = getattr(config, 'BT_SLIPPAGE', 0.002)
pending: list[dict] = [] # 전일 종가 신호 → 오늘 시가 체결 주문 (look-ahead 제거)
for day in all_dates:
day_prices = {}
# ---- 보유 판단 (당일 종가 기준): 손절/익절(전량·분할) → 추격매수 ----
# ---- 0. 전일 큐잉 주문을 오늘 시가에 체결 (+슬리피지: 매수 비싸게/매도 싸게) ----
for od in pending:
code = od['code']
i = idx_map.get(code, {}).get(day)
if i is None:
continue # 오늘 거래 없으면 주문 소멸 — 신호 지속 시 다음 종가에 재큐잉됨
open_px = hist[code][i].get('open') or hist[code][i]['close']
kind = od['kind']
if kind in ('sell', 'scale_out'):
pos = positions.get(code)
if not pos:
continue
fill = open_px * (1 - slip)
total = pos['qty']
qty = total if od.get('frac', 1.0) >= 1.0 else max(1, int(total * od['frac']))
qty = min(qty, total)
proceeds = qty * fill * (1 - comm - tax)
cost = qty * pos['entry_price'] * (1 + comm)
pnl = proceeds - cost
cash += proceeds
realized += pnl
if qty >= total:
positions.pop(code)
exited[code] = day # 당일 재진입 금지 (엔진과 동일 규칙)
closed += 1
if pnl > 0:
wins += 1
else:
pos['qty'] = total - qty
if pnl > 0:
gross_win += pnl
else:
gross_loss += -pnl
elif kind == 'add':
pos = positions.get(code)
if not pos:
continue
fill = open_px * (1 + slip)
tranche_val = pos.get('tranche_value') or (pos['entry_price'] * pos['qty'])
qty = _qty_for(fill, tranche_val)
cost = qty * fill * (1 + comm)
if qty >= 1 and cost <= cash:
new_qty = pos['qty'] + qty
pos['entry_price'] = (pos['entry_price'] * pos['qty'] + fill * qty) / new_qty
pos['qty'] = new_qty
pos['tranches'] = pos.get('tranches', 1) + 1
pos['last_add_price'] = fill
cash -= cost
nstop, ntarget = signals.compute_stop_target(pos['entry_price'], od['atr'], od['recent_low'])
pos['stop'] = max(pos['stop'], nstop)
pos['target'] = ntarget
elif kind == 'buy':
if code in positions or len(positions) >= config.MAX_POSITIONS or exited.get(code) == day:
continue
fill = open_px * (1 + slip)
stop, target = signals.compute_stop_target(fill, od['atr'], od['recent_low'])
eq = equity(day_prices)
tranche_val = signals.target_value(eq, fill, stop) / max(1, config.ENTRY_TRANCHES)
qty = _qty_for(fill, tranche_val)
cost = qty * fill * (1 + comm)
if qty < 1 or cost > cash:
continue
cash -= cost
positions[code] = {'code': code, 'name': code, 'qty': qty, 'entry_price': fill,
'stop': stop, 'target': target, 'peak': fill, 'trailing_on': False,
'scaled_out': False, 'tranches': 1, 'tranche_value': tranche_val,
'last_add_price': fill, 'entry_date': day}
pending = []
# ---- 1. 보유 판단 (당일 종가 기준) → 내일 시가 주문 큐잉 ----
for code in list(positions.keys()):
h = hist.get(code)
i = idx_map[code].get(day)
@@ -129,46 +221,17 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to:
pos.update(dec['position_update'])
act = dec['action']
if act in ('sell', 'scale_out'):
fill = ind['price']
total = pos['qty']
qty = total if dec.get('sell_frac', 1.0) >= 1.0 else max(1, int(total * dec['sell_frac']))
qty = min(qty, total)
proceeds = qty * fill * (1 - comm - tax)
cost = qty * pos['entry_price'] * (1 + comm)
pnl = proceeds - cost
cash += proceeds
realized += pnl
if qty >= total:
positions.pop(code)
closed += 1
if pnl > 0:
wins += 1
else:
pos['qty'] = total - qty
if pnl > 0:
gross_win += pnl
else:
gross_loss += -pnl
pending.append({'kind': act, 'code': code, 'frac': dec.get('sell_frac', 1.0)})
elif act == 'add':
fill = ind['price']
tranche_val = pos.get('tranche_value') or (pos['entry_price'] * pos['qty'])
qty = _qty_for(fill, tranche_val)
cost = qty * fill * (1 + comm)
if qty >= 1 and cost <= cash:
new_qty = pos['qty'] + qty
pos['entry_price'] = (pos['entry_price'] * pos['qty'] + fill * qty) / new_qty
pos['qty'] = new_qty
pos['tranches'] = pos.get('tranches', 1) + 1
pos['last_add_price'] = fill
cash -= cost
nstop, ntarget = signals.compute_stop_target(pos['entry_price'], ind['atr'], ind['recent_low'])
pos['stop'] = max(pos['stop'], nstop)
pos['target'] = ntarget
pending.append({'kind': 'add', 'code': code,
'atr': ind['atr'], 'recent_low': ind['recent_low']})
# ---- 신규 매수 판단 (1차 트랜치) ----
# ---- 2. 신규 매수 판단 (당일 종가) → 내일 시가 주문 큐잉 ----
for code, h in hist.items():
if code in positions or len(positions) >= config.MAX_POSITIONS:
continue
if exited.get(code) == day: # 당일 청산 종목 재매수 금지
continue
i = idx_map[code].get(day)
if i is None:
continue
@@ -183,19 +246,8 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to:
flow = _flow_net_upto(flows.get(code), dates_seen)
dec = signals.evaluate_candidate(code, code, [], ind, flow, None, True)
if dec['action'] == 'buy':
fill = dec['buy_price']
stop, target = signals.compute_stop_target(fill, ind['atr'], ind['recent_low'])
eq = equity(day_prices)
tranche_val = signals.target_value(eq, fill, stop) / max(1, config.ENTRY_TRANCHES)
qty = _qty_for(fill, tranche_val)
cost = qty * fill * (1 + comm)
if qty < 1 or cost > cash:
continue
cash -= cost
positions[code] = {'code': code, 'name': code, 'qty': qty, 'entry_price': fill,
'stop': stop, 'target': target, 'peak': fill, 'trailing_on': False,
'scaled_out': False, 'tranches': 1, 'tranche_value': tranche_val,
'last_add_price': fill, 'entry_date': day}
pending.append({'kind': 'buy', 'code': code,
'atr': ind['atr'], 'recent_low': ind['recent_low']})
eq = equity(day_prices)
if eq > peak_eq:
+2 -1
View File
@@ -22,9 +22,10 @@ LAST_SCAN_PATH = STATE_DIR / 'last_scan.json'
OWNER_ACCOUNT_LABELS = ['일반', 'ISA']
# ---- 자본·체결·비용 ----
INITIAL_CAPITAL = 10_000_000 # 가상 자본
INITIAL_CAPITAL = 100_000_000 # 가상 자본 (기초 예수금 1억)
COMMISSION_RATE = 0.00015 # 매매 수수료 (편도, 키움 ~0.015%)
SELL_TAX_RATE = 0.0018 # 매도 증권거래세 (~0.18%)
BT_SLIPPAGE = 0.002 # 백테스트 슬리피지 (편도 0.2% — 매수는 비싸게, 매도는 싸게. 라이브는 호가 체결이라 미적용)
# ---- 포지션 사이징 ----
MAX_POSITIONS = 8 # 동시 보유 최대 종목수
+34 -7
View File
@@ -88,14 +88,19 @@ def _flow_cache_path(code: str):
def investor_flow(code: str, days: int = config.FLOW_DAYS) -> list[dict]:
"""최근 days 일 외국인·기관·개인 순매수 (최신순). 60분 TTL 캐시."""
"""최근 days 일 외국인·기관·개인 순매수 (최신순). 60분 TTL 캐시 + 스캔 내 메모."""
mk = (code, days)
if mk in _FLOW_MEMO_SCAN:
return _FLOW_MEMO_SCAN[mk]
path = _flow_cache_path(code)
now = time.time()
if path.exists():
try:
cached = json.loads(path.read_text())
if now - cached.get('ts', 0) < FLOW_TTL_SEC:
return cached.get('rows', [])[:days]
rows = cached.get('rows', [])[:days]
_FLOW_MEMO_SCAN[mk] = rows
return rows
except Exception:
pass
try:
@@ -105,6 +110,7 @@ def investor_flow(code: str, days: int = config.FLOW_DAYS) -> list[dict]:
return []
FLOW_CACHE_DIR.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({'ts': now, 'rows': rows}, ensure_ascii=False))
_FLOW_MEMO_SCAN[mk] = rows[:days]
return rows[:days]
@@ -116,11 +122,15 @@ def flow_net(rows: list[dict]) -> dict:
def analyst(code: str, price: int) -> dict | None:
"""애널 게이트/가점용. 캐시 미스(네트워크 발생) 시에만 페이싱 — 연속 크롤링 차단 회피."""
"""애널 게이트/가점용. 캐시 미스(네트워크 발생) 시에만 페이싱 + 스캔 내 메모."""
if code in _ANL_MEMO_SCAN:
return _ANL_MEMO_SCAN[code]
fresh = (_cache_fresh(FNGUIDE_CACHE_DIR / f'{code}.json')
and _cache_fresh(WISEREPORT_CACHE_DIR / f'{code}.json'))
try:
return _analyst_query(code, price)
out = _analyst_query(code, price)
_ANL_MEMO_SCAN[code] = out
return out
finally:
if not fresh:
time.sleep(ANALYST_PACING_SEC)
@@ -168,10 +178,27 @@ def _analyst_query(code: str, price: int) -> dict | None:
}
# 스캔 1회 동안 호가 공유 — 같은 스캔에서 같은 종목은 같은 체결가 (선수 간 체결 공정성 + API 절감).
# engine._gather 가 스캔 시작마다 reset.
_BOOK_MEMO: dict = {}
_FLOW_MEMO_SCAN: dict = {} # 스캔 1회 동안 종목별 수급 공유 (파라미터 무관)
_ANL_MEMO_SCAN: dict = {} # 스캔 1회 동안 종목별 애널 공유 (파라미터 무관)
def reset_book_memo():
_BOOK_MEMO.clear()
_FLOW_MEMO_SCAN.clear()
_ANL_MEMO_SCAN.clear()
def quote_book(code: str) -> dict | None:
"""체결가용 호가 (매도1/매수1). 실패 시 None."""
"""체결가용 호가 (매도1/매수1). 스캔 내 종목당 1회 조회 후 공유. 실패 시 None."""
if code in _BOOK_MEMO:
return _BOOK_MEMO[code]
try:
return kc.get_quote_book(code)
book = kc.get_quote_book(code)
except Exception as e:
sys.stderr.write(f'[data] quote_book {code} 실패: {e}\n')
return None
book = None
_BOOK_MEMO[code] = book
return book
+119 -15
View File
@@ -41,6 +41,29 @@ def _index_trend_ok() -> dict:
return out
def _session_elapsed_frac(now) -> float:
"""장 시작 후 경과 비율 (0.15~1.0) — 장중 부분 거래량을 하루치로 환산하는 분모.
장외면 1.0(완성봉 그대로). 하한 0.15 초반 과대 환산 폭주 방지."""
if not is_market_session(now):
return 1.0
o = now.replace(hour=config.MARKET_OPEN[0], minute=config.MARKET_OPEN[1], second=0, microsecond=0)
c = now.replace(hour=config.MARKET_CLOSE[0], minute=config.MARKET_CLOSE[1], second=0, microsecond=0)
total = (c - o).total_seconds()
return min(1.0, max(0.15, (now - o).total_seconds() / total))
def _watching(snap) -> list:
"""스캔 시점 '매수로 노리는' 종목 — 즉시매수(BUY)·추격(ADD)·눌림목 대기(WAIT+지정가). 비교탭 표시용."""
out = []
for d in snap.get('decisions', []):
st = d.get('state')
if st in ('BUY', 'ADD') or (st == 'WAIT' and d.get('watch_price')):
out.append({'name': d.get('name'), 'code': d.get('code'), 'state': st,
'reason': d.get('reason', ''), 'price': d.get('price'),
'watch_price': d.get('watch_price')})
return out
def _index_return(mk: str, days: int):
"""지수 N거래일 수익률(%) — 상대강도 비교용. 데이터 부족 시 None."""
s = sorted(benchmark.series(mk).items())
@@ -192,11 +215,24 @@ def _fill_sell_price(code: str, fallback: int) -> int:
return int(fallback)
_SCAN_CANDLES: dict = {} # 스캔 1회 동안 종목별 일봉 공유
_IND_MEMO: dict = {} # (code, 지표 파라미터) → 지표 묶음 — 같은 지표 설정 변이끼리 공유
def _ind_key(code):
return (code, config.SMA_SHORT, config.SMA_LONG, config.SMA_MID, config.ATR_PERIOD,
config.BREAKOUT_LOOKBACK, config.SWING_LOW_LOOKBACK, config.VOLUME_AVG_PERIOD,
config.RSI_PERIOD, config.TREND_SLOPE_DAYS)
def _gather(extra_codes=None):
"""파라미터 무관한 라이브 데이터 1회 수집 (메인·변이 공유). 종목·시세·시장매핑.
extra_codes: universe에 없지만 청산 판단을 위해 포함해야 보유 코드(수동 삭제된 보유분).
"""
data.reset_book_memo() # 호가 공유 캐시 리셋 — 이번 스캔의 체결가를 전 선수가 공유
_SCAN_CANDLES.clear()
_IND_MEMO.clear()
uni = universe.build_universe()
codes = [e['code'] for e in uni]
seen = set(codes)
@@ -210,10 +246,13 @@ def _gather(extra_codes=None):
return uni, quotes, cmkt
def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
"""주어진 포트폴리오에 대해 현재 config(파라미터) 기준 스캔·체결. 스냅샷 반환(파일 기록 X)."""
def _run_scan(pf, uni, quotes, cmkt, now, execute: bool = True) -> dict:
"""주어진 포트폴리오에 대해 현재 config(파라미터) 기준 스캔·체결. 스냅샷 반환(파일 기록 X).
execute=False 판단(decisions) 만들고 체결·계좌 저장을 모두 생략 시각 전환 read-only ."""
mkt_ok = market_ok_map() # MARKET_BREADTH_MIN 등 파라미터 의존 → 변이마다 재계산
idx_ret = {mk: _index_return(mk, config.REL_STRENGTH_DAYS) for mk in ('KOSPI', 'KOSDAQ')}
vfrac = _session_elapsed_frac(now) # 장중 부분 거래량 → 하루치 환산 분모
decisions: list[dict] = []
buys: list[dict] = []
sells: list[dict] = []
@@ -223,9 +262,17 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
if not name and code in pf.positions: # 보유 보강분(universe에서 빠진)은 이름 보완
name = pf.positions[code].get('name', code)
quote = quotes.get(code)
hist = data.candles(code, 80)
hist = _SCAN_CANDLES.get(code)
if hist is None:
hist = data.candles(code, 80)
_SCAN_CANDLES[code] = hist
series = data.build_series(hist, quote)
ind = signals.compute_indicators(series)
_ik = _ind_key(code)
_cached = _IND_MEMO.get(_ik)
if _cached is None:
_cached = signals.compute_indicators(series) or False
_IND_MEMO[_ik] = _cached
ind = dict(_cached) if _cached else None # 사본 — 변이별 주입값(rel_strength 등) 오염 방지
if ind is None:
decisions.append({'code': code, 'name': name, 'sources': sources,
'state': 'NODATA', 'reason': '데이터 부족', 'checks': [],
@@ -244,14 +291,14 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
dec.update({k: pf.positions[code].get(k) for k in
('entry_price', 'qty', 'stop', 'target', 'trailing_on', 'entry_at')})
act = dec['action']
if act in ('sell', 'scale_out'):
if execute and act in ('sell', 'scale_out'):
fill = _fill_sell_price(code, cur_price)
rec = pf.sell(code, fill, dec['reason'], signals=dec.get('checks'),
frac=dec.get('sell_frac', 1.0))
if rec:
sells.append(rec)
dec['fill_price'] = fill
elif act == 'add':
elif execute and act == 'add':
fill = _fill_buy_price(code, cur_price)
rec = pf.add_tranche(code, fill, dec['reason'], signals=dec.get('checks'))
if rec:
@@ -292,6 +339,7 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
anl = data.analyst(code, cur_price)
market = cmkt.get(code, '')
m_ok = mkt_ok.get(market, True)
ind['vol_time_frac'] = vfrac
# 상대강도(라이브 전용): 종목 N일 수익률 − 시장지수 N일 수익률
n = config.REL_STRENGTH_DAYS
closes_c = [c['close'] for c in series]
@@ -300,7 +348,11 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
ind['rel_strength'] = round(stock_ret - idx_ret[market], 2)
dec = signals.evaluate_candidate(code, name, sources, ind, flow, anl, m_ok)
if dec['action'] == 'buy' and pf.can_open():
if dec['action'] == 'buy' and pf.exited_today(code):
# 당일 전량청산 종목 재매수 금지 — 휩쏘 churn(손절→5분 뒤 재진입 반복) 방지
dec.update({'state': 'WAIT', 'action': None,
'reason': '당일 청산 종목 — 재진입 쿨다운(내일부터 가능)'})
if execute and dec['action'] == 'buy' and pf.can_open():
fill = _fill_buy_price(code, dec['buy_price'])
stop, target = signals.compute_stop_target(fill, ind['atr'], ind['recent_low'])
rec = pf.buy(code, name, fill, sources, stop, target, dec['buy_path'], dec['reason'],
@@ -313,8 +365,9 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
dec['reason'] = '매수 신호 — 현금/한도 부족'
decisions.append(dec)
pf.update_equity_metrics()
pf.save()
if execute:
pf.update_equity_metrics()
pf.save()
order = {'SELL': 0, 'ADD': 1, 'BUY': 2, 'HOLD': 3, 'WAIT': 4, 'SKIP': 5, 'NODATA': 6}
decisions.sort(key=lambda d: (order.get(d.get('state'), 9), d.get('name', '')))
@@ -330,6 +383,30 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
}
def judge_view(vid: str) -> dict | None:
"""선택한 계좌('main'/'vN') 시각으로 **매매 없이 판단만** — 관심종목 탭 시각 전환용.
execute=False 체결·계좌 저장이 전혀 없다. 끝나면 config 메인 파라미터로 복원."""
from . import backtest, variants as variants_mod
now = datetime.now(config.KST)
if vid in ('main', 'M', ''):
params = config.load_params()
pf = Portfolio.load()
else:
v = next((x for x in variants_mod.load_variants() if x['id'] == vid), None)
if not v:
return None
pp, tp = variants_mod.variant_paths(vid)
params = v.get('params') or {}
pf = Portfolio.load(pp, tp)
try:
backtest.apply_params(params)
uni, quotes, cmkt = _gather(set(pf.positions))
return _run_scan(pf, uni, quotes, cmkt, now, execute=False)
finally:
backtest.apply_params(config.load_params()) # 전역 config 원복 (웹 데몬 잔류 방지)
def _attach_benchmark(snap: dict, pf, now, refresh: bool = True):
"""가상계좌 시작일 대비 기준지수(KOSPI/KOSDAQ) 수익·알파를 스냅샷에 부착."""
from . import benchmark
@@ -374,9 +451,10 @@ def scan_all(force: bool = False) -> dict:
held_union |= set(vpf.positions)
uni, quotes, cmkt = _gather(held_union)
# 메인 (현재 params.json 튜닝)
# 기준 판단 (params.json) — 2026-06-10 기준전략 폐지: 매매 없이 판단만(last_scan = 화면·상태점용).
# 실제 매매는 전부 변이들이 한다 (메인 계좌 동결).
backtest.apply_params(main_params)
snap = _run_scan(pf, uni, quotes, cmkt, now)
snap = _run_scan(pf, uni, quotes, cmkt, now, execute=False)
_attach_benchmark(snap, pf, now) # 지수 캐시 갱신 1회 (변이는 캐시 재사용)
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
config.LAST_SCAN_PATH.write_text(json.dumps(snap, ensure_ascii=False, indent=2))
@@ -388,24 +466,50 @@ def scan_all(force: bool = False) -> dict:
s = p.summary()
return benchmark.compare(s.get('total_return_pct'), benchmark.norm_date(p.created_at), today)
compare = [{'id': 'main', 'name': '메인 (현재 튜닝)', 'params': main_params,
'summary': pf.summary(), 'benchmark': _alpha(pf)}]
compare = [] # 기준전략 폐지 — 변이들만 비교 (메인 계좌는 매매 안 함)
for v in vdefs:
backtest.apply_params(v.get('params') or {})
vpf = vpfs[v['id']]
_run_scan(vpf, uni, quotes, cmkt, now)
vsnap = _run_scan(vpf, uni, quotes, cmkt, now)
compare.append({'id': v['id'], 'name': v.get('name', v['id']),
'params': v.get('params') or {}, 'summary': vpf.summary(),
'benchmark': _alpha(vpf)})
'benchmark': _alpha(vpf), 'watching': _watching(vsnap),
'held': sorted(vpf.positions)})
compare.sort(key=lambda c: c['summary'].get('total_return_pct', 0) or 0, reverse=True)
variants_mod.COMPARE_PATH.write_text(json.dumps({
'scanned_at': now.isoformat(), 'session': is_market_session(now),
'variants': compare,
}, ensure_ascii=False, indent=2))
_record_equity_history(compare, now)
return snap
EQUITY_HISTORY_PATH = config.STATE_DIR / 'equity_history.json'
def _record_equity_history(compare: list, now) -> None:
"""일별 계좌 성과 이력 — {date: [{id, ph(파라미터 해시), equity, ret}]}.
스캔 당일 항목을 덮어써 그날 마지막 스캔( 마감) 값이 남는다. 변이 재구성으로
id가 재사용돼도 ph로 어느 전략이었는지 식별 가능. 사후 국면별 분석용 누적."""
import hashlib
try:
hist = json.loads(EQUITY_HISTORY_PATH.read_text()) if EQUITY_HISTORY_PATH.exists() else {}
except Exception:
hist = {}
today = now.strftime('%Y%m%d')
hist[today] = [{
'id': c['id'],
'ph': hashlib.md5(json.dumps(c.get('params') or {}, sort_keys=True).encode()).hexdigest()[:8],
'equity': c['summary'].get('equity'),
'ret': c['summary'].get('total_return_pct'),
} for c in compare]
try:
EQUITY_HISTORY_PATH.write_text(json.dumps(hist, ensure_ascii=False))
except Exception as e:
sys.stderr.write(f'[engine] equity_history 기록 실패: {e}\n')
if __name__ == '__main__':
force = '--force' in sys.argv
snap = scan_all(force=force)
+10 -3
View File
@@ -9,7 +9,8 @@ from __future__ import annotations
import json
from datetime import datetime
from . import config, signals
from . import config
from .signals import target_value as _sizing_value # buy()의 signals 파라미터가 모듈명을 가려서 직접 import
def _now_iso() -> str:
@@ -28,6 +29,7 @@ class Portfolio:
self.max_drawdown: float = data.get('max_drawdown', 0.0)
self.created_at: str = data.get('created_at', _now_iso())
self.updated_at: str = data.get('updated_at', self.created_at)
self.last_exit: dict[str, str] = data.get('last_exit', {}) # code → 전량청산 날짜(YYYY-MM-DD), 당일 재진입 금지용
# 영속화 경로 (변이는 별도 경로, 기본은 메인 sim)
self._pf_path = config.PORTFOLIO_PATH
self._trades_path = config.TRADES_PATH
@@ -55,7 +57,7 @@ class Portfolio:
'cash': self.cash, 'initial': self.initial, 'positions': self.positions,
'realized_pnl': self.realized_pnl, 'closed_trades': self.closed_trades,
'wins': self.wins, 'peak_equity': self.peak_equity,
'max_drawdown': self.max_drawdown,
'max_drawdown': self.max_drawdown, 'last_exit': self.last_exit,
'created_at': self.created_at, 'updated_at': self.updated_at,
}, ensure_ascii=False, indent=2))
@@ -83,7 +85,7 @@ class Portfolio:
"""신규 진입(1차 트랜치). 목표비중을 ENTRY_TRANCHES 로 나눈 만큼만 매수. 체결 dict 또는 None."""
if code in self.positions or not self.can_open():
return None
full_target = signals.target_value(self.equity_estimate(), fill_price, stop)
full_target = _sizing_value(self.equity_estimate(), fill_price, stop)
tranche_val = full_target / max(1, config.ENTRY_TRANCHES)
qty = self._qty_for(fill_price, tranche_val)
if qty < 1:
@@ -169,8 +171,13 @@ class Portfolio:
if pnl > 0:
self.wins += 1
del self.positions[code]
self.last_exit[code] = _now_iso()[:10] # 당일 재진입 금지 기준
return rec
def exited_today(self, code: str) -> bool:
"""오늘 전량청산한 종목인지 — 당일 재진입(휩쏘 churn) 방지."""
return self.last_exit.get(code) == _now_iso()[:10]
def apply_position_update(self, code: str, upd: dict):
if code in self.positions:
self.positions[code].update(upd)
+10 -4
View File
@@ -171,28 +171,34 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di
return {**base, 'state': 'WAIT', 'reason': '수급 미충족'}
# 4. 과열 보류 — 회전율이 평균 대비 과도하면 돌파·눌림목 불문 신규매수 보류
# 장중엔 부분 누적 거래량을 경과시간 비례로 하루치 환산(vol_time_frac, engine 주입·백테스트는 1.0)
vfrac = ind.get('vol_time_frac') or 1.0
vol_today = ind['today_volume'] / vfrac
t_today, t_avg = ind.get('turnover_today'), ind.get('turnover_avg')
if t_today is not None:
t_today = t_today / vfrac
proj = ' (환산)' if vfrac < 1.0 else ''
overheated = bool(t_today and t_avg and t_today >= t_avg * config.TURNOVER_OVERHEAT_MULT)
if t_today is not None and t_avg:
checks.append({'label': '과열 아님(회전율)', 'ok': not overheated,
'detail': f"{t_today:.2f}% vs 평균 {t_avg:.2f}% (보류 {config.TURNOVER_OVERHEAT_MULT:.0f}배↑)"})
'detail': f"{t_today:.2f}%{proj} vs 평균 {t_avg:.2f}% (보류 {config.TURNOVER_OVERHEAT_MULT:.0f}배↑)"})
if overheated:
return {**base, 'state': 'WAIT',
'reason': f'과열(회전율 {t_today:.1f}% ≥ 평균×{config.TURNOVER_OVERHEAT_MULT:.0f}) — 신규매수 보류'}
# 5. 매수 경로 — 돌파 우선 (전고점 + 거래량 급증 + 회전율 급증 모두 충족)
price_break = bool(ind['recent_high'] and price > ind['recent_high'])
vol_mult_ok = bool(ind['volume_avg'] and ind['today_volume'] >= ind['volume_avg'] * config.VOLUME_BREAKOUT_MULT)
vol_mult_ok = bool(ind['volume_avg'] and vol_today >= ind['volume_avg'] * config.VOLUME_BREAKOUT_MULT)
turn_ok = (t_today is None or not t_avg) or (t_today >= t_avg * config.TURNOVER_BREAKOUT_MULT)
breakout = price_break and vol_mult_ok and turn_ok
checks.append({'label': '돌파(전고점)', 'ok': price_break,
'detail': f"고점 {ind['recent_high']:,.0f}" if ind['recent_high'] else ''})
checks.append({'label': '거래량 급증', 'ok': vol_mult_ok,
'detail': f"{ind['today_volume']:,} vs 평균 {ind['volume_avg']:,.0f}" if ind['volume_avg'] else ''})
'detail': f"{vol_today:,.0f}{proj} vs 평균 {ind['volume_avg']:,.0f}" if ind['volume_avg'] else ''})
if t_today is not None and t_avg:
checks.append({'label': '회전율 급증', 'ok': bool(turn_ok),
'detail': f"{t_today:.2f}% vs 평균 {t_avg:.2f}%"})
'detail': f"{t_today:.2f}%{proj} vs 평균 {t_avg:.2f}%"})
else:
checks.append({'label': '회전율', 'ok': None, 'detail': '데이터 없음(통과)'})
File diff suppressed because it is too large Load Diff
+48 -10
View File
@@ -10,10 +10,29 @@ from __future__ import annotations
import itertools
import json
import os
from datetime import datetime
from multiprocessing import get_context
from . import backtest, config, universe
def _eval_combo(args):
"""워커 1개가 조합 1개를 평가 — 학습/검증 + 전체기간 3분할 구간(꾸준함 측정).
파라미터는 학습(fit) 없으므로 3분할은 '한 구간 운빨' 거르는 일관성 검증."""
keys, combo, codes, trf, trt, tef, tet, windows = args
params = dict(zip(keys, combo))
tr = backtest.run(params, codes, date_from=trf, date_to=trt)
te = backtest.run(params, codes, date_from=tef, date_to=tet)
wins = []
for wf, wt in windows:
w = backtest.run(params, codes, date_from=wf, date_to=wt)
wins.append({'from': wf, 'to': wt,
'ret': w.get('total_return_pct'), 'trades': w.get('trades', 0)})
rets = [w['ret'] for w in wins if w['ret'] is not None]
return {'params': params, 'train': tr, 'test': te, 'windows': wins,
'worst_ret': min(rets) if rets else None}
# 백테스트에서 실제 영향 있는 파라미터만 스윕 (애널 게이트·시장필터는 backtest 중립이라 제외).
# 기존 5 + 신규 4(RSI·시간손절·리스크) 탐색. 조합폭발 억제 위해 기존 그리드는 핵심값으로 축소.
# base 3×3×2×2×2 = 72 × 신규 2×2×2×2 = 16 → 1152 조합.
@@ -51,22 +70,39 @@ def run_sweep(codes: list[str] | None = None, train_frac: float = 0.7, grid: dic
train_to, test_from, test_to = dates[split - 1], dates[split], dates[-1]
train_from = dates[0]
# 전체기간 3분할 — 구간별 수익으로 '꾸준함'(최악 구간) 측정
third = len(dates) // 3
windows = [(dates[0], dates[third - 1]),
(dates[third], dates[2 * third - 1]),
(dates[2 * third], dates[-1])]
keys = list(grid.keys())
combos = list(itertools.product(*[grid[k] for k in keys]))
results = []
for combo in combos:
params = dict(zip(keys, combo))
tr = backtest.run(params, codes, date_from=train_from, date_to=train_to)
te = backtest.run(params, codes, date_from=test_from, date_to=test_to)
results.append({'params': params, 'train': tr, 'test': te})
args = [(keys, combo, codes, train_from, train_to, test_from, test_to, windows) for combo in combos]
# 멀티프로세싱 — 조합을 코어에 분배. macOS는 fork가 데드락 위험이라 spawn 사용
# (워커가 메인 재import → sim/__main__.py의 `if __name__=='__main__'` 가드로 재귀 방지).
# chunksize 크게 → 워커당 연속 조합 처리로 종목 일봉 메모이즈 재활용 극대화. pool.map은 순서 유지(결정론).
nproc = max(1, (os.cpu_count() or 4) - 2)
if len(args) <= 1 or nproc == 1:
results = [_eval_combo(a) for a in args]
else:
chunk = max(1, len(args) // nproc)
with get_context('spawn').Pool(nproc) as pool:
results = pool.map(_eval_combo, args, chunksize=chunk)
def sort_key(r):
te = r['test']
enough = (te.get('trades', 0) or 0) >= MIN_TRADES
return (1 if enough else 0, te.get('total_return_pct', -999) or -999)
worst = r.get('worst_ret')
# 1순위 최악 구간 수익(꾸준함 — 한 구간 운빨 배제), 2순위 검증수익
return (1 if enough else 0,
worst if worst is not None else -999,
te.get('total_return_pct', -999) or -999)
results.sort(key=sort_key, reverse=True)
out = {
'generated_at': datetime.now(config.KST).isoformat(),
'universe_size': len(codes),
@@ -81,6 +117,8 @@ def run_sweep(codes: list[str] | None = None, train_frac: float = 0.7, grid: dic
}
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
(config.STATE_DIR / 'backtest_results.json').write_text(json.dumps(out, ensure_ascii=False, indent=2))
# ⚠️ 비교군 자동 동기화는 2026-06-10 관리자님 지시로 끔 — sweep은 결과 파일만 갱신.
# 비교군 변경은 명시 요청 시에만: `python3 -m sim variants sync` 또는 variants.sync_label_tops().
return out
@@ -91,13 +129,13 @@ if __name__ == '__main__':
if res.get('error'):
print('스윕 실패:', res)
else:
print(f"스윕 완료 — {res['count']}개 조합 · 검증기간 {res['split']['test']}")
print(f"스윕 완료 — {res['count']}개 조합 · 검증기간 {res['split']['test']} · 정렬=최악구간 수익")
print(f"{'RR':>4} {'STOP':>5} {'SMA':>4} {'PULL':>5} {'RSI±':>8} {'HOLD':>5} {'RISK':>5} | "
f"{'검증수익%':>8} {'검증MDD%':>8} {'거래':>5} {'승률%':>6}")
f"{'최악구간%':>8} {'검증수익%':>8} {'거래':>5} {'승률%':>6}")
for r in res['results'][:12]:
p, te = r['params'], r['test']
rsi = f"{p.get('RSI_OVERBOUGHT','-')}/{p.get('RSI_OVERSOLD','-')}"
print(f"{p['RR_RATIO']:>4} {p['STOP_ATR_MULT']:>5} {p['SMA_LONG']:>4} {p['PULLBACK_ATR_MULT']:>5} "
f"{rsi:>8} {p.get('MAX_HOLD_DAYS','-'):>5} {p.get('RISK_PER_TRADE_PCT','-'):>5} | "
f"{te['total_return_pct']:>8} {te['mdd_pct']:>8} "
f"{str(r.get('worst_ret')):>8} {te['total_return_pct']:>8} "
f"{te['trades']:>5} {str(te['win_rate_pct']):>6}")
+38
View File
@@ -65,6 +65,44 @@ def seed_from_sweep(n: int = 3) -> list[dict]:
return variants
def sync_label_tops(results: dict) -> dict:
"""sweep 결과에서 세부 유형(시장적합 라벨)별 1위를 비교군과 동기화 — 수동 추가/제거 불필요.
파라미터가 같은 변이는 계좌 유지, 빠진 유형 대표는 추가, 더는 대표가 아닌 변이는 삭제.
(2026-06-10 기준전략 폐지 메인 제외 규칙 삭제, 유형별 1 전부 변이로.)"""
def _k(p):
return json.dumps({k: float(v) for k, v in (p or {}).items()}, sort_keys=True)
def _sk(r):
w = r.get('worst_ret')
return (w if w is not None else -999,
r['test'].get('total_return_pct', -999) or -999)
from .sim_web import _market_fit_summary # 함수 레벨 import (순환 회피)
uniq = {}
for r in results.get('results', []):
uniq.setdefault(_k(r['params']), r)
by_label = {}
for r in uniq.values():
lbl = _market_fit_summary(r['params'])
if lbl not in by_label or _sk(r) > _sk(by_label[lbl]):
by_label[lbl] = r
desired = {_k(r['params']): r['params'] for r in by_label.values()}
current = {_k(v['params']): v for v in load_variants()}
removed = added = 0
for k, v in current.items():
if k not in desired:
remove_variant(v['id'])
removed += 1
for k, p in desired.items():
if k not in current:
if add_variant(p):
added += 1
return {'labels': len(by_label), 'added': added, 'removed': removed,
'kept': len(current) - removed}
def add_variant(params: dict) -> dict | None:
"""단일 params 조합을 변이로 추가 (백테스트 행 → 비교군). 이미 있으면 None."""
variants = load_variants()
@@ -24,3 +24,5 @@
{"date": "2026-06-08", "market": "KOSPI", "market_label": "코스피", "rise": 42, "upper": 1, "fall": 876, "lower": 0, "steady": 3, "adr": 4.91, "personal": 17628, "foreign": -2644, "institutional": -17164, "captured_at": "2026-06-08T21:00:04.897696+09:00"}
{"date": "2026-06-09", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1435, "upper": 6, "fall": 258, "lower": 1, "steady": 44, "adr": 556.37, "personal": -5112, "foreign": 3126, "institutional": 1996, "captured_at": "2026-06-09T21:00:02.663326+09:00"}
{"date": "2026-06-09", "market": "KOSPI", "market_label": "코스피", "rise": 774, "upper": 1, "fall": 133, "lower": 1, "steady": 15, "adr": 578.36, "personal": -6167, "foreign": -20064, "institutional": 25048, "captured_at": "2026-06-09T21:00:02.663326+09:00"}
{"date": "2026-06-10", "market": "KOSDAQ", "market_label": "코스닥", "rise": 499, "upper": 6, "fall": 1176, "lower": 1, "steady": 61, "adr": 42.91, "personal": 1167, "foreign": 1420, "institutional": -1125, "captured_at": "2026-06-10T21:00:04.276904+09:00"}
{"date": "2026-06-10", "market": "KOSPI", "market_label": "코스피", "rise": 343, "upper": 2, "fall": 550, "lower": 0, "steady": 28, "adr": 62.73, "personal": 48643, "foreign": -27754, "institutional": -22665, "captured_at": "2026-06-10T21:00:04.276904+09:00"}
@@ -78,3 +78,5 @@
{"date": "2026-06-08", "account": "가희_ISA", "owner": "가희", "code": "005930", "name": "삼성전자", "buy_qty": 10, "buy_avg": 299750, "buy_amt": 2997500, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 440, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"}
{"date": "2026-06-09", "account": "일반", "owner": "본인", "code": "0193W0", "name": "KODEX 삼성전자단일종목레버리지", "buy_qty": 0, "buy_avg": 0, "buy_amt": 0, "sell_qty": 12, "sell_avg": 22000, "sell_amt": 264000, "pl_amt": 9570, "cmsn_tax": 30, "prft_rt": 3.76, "collected_at": "2026-06-09T23:19:23.494299+09:00"}
{"date": "2026-06-09", "account": "ISA", "owner": "본인", "code": "0193W0", "name": "KODEX 삼성전자단일종목레버리지", "buy_qty": 0, "buy_avg": 0, "buy_amt": 0, "sell_qty": 12, "sell_avg": 22070, "sell_amt": 264840, "pl_amt": 11610, "cmsn_tax": 30, "prft_rt": 4.59, "collected_at": "2026-06-09T23:19:23.494299+09:00"}
{"date": "2026-06-10", "account": "일반", "owner": "본인", "code": "0193T0", "name": "KODEX SK하이닉스단일종목레버리지", "buy_qty": 12, "buy_avg": 20880, "buy_amt": 250560, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 30, "prft_rt": 0.0, "collected_at": "2026-06-10T21:00:04.035276+09:00"}
{"date": "2026-06-10", "account": "ISA", "owner": "본인", "code": "0193T0", "name": "KODEX SK하이닉스단일종목레버리지", "buy_qty": 13, "buy_avg": 20855, "buy_amt": 271115, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 40, "prft_rt": 0.0, "collected_at": "2026-06-10T21:00:04.035276+09:00"}