auto: 일일 백업 2026-06-11 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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:00–15:30 **매 5분** (`StartCalendarInterval` 79엔트리, 엔진이 장외/휴장 self-skip) ⚠️2026-06-09 15분→5분 단축 + `StartInterval` 900s→`StartCalendarInterval` 전환(StartInterval은 GUI세션 idle/디스플레이 sleep 시 timer coalescing으로 발화 보류돼 장중 안 돎. calendar는 git-autopush가 새벽 02:00 정상발화로 검증됨) | `python3 -m sim scan` (내부 `scan_all` — 메인+병렬변이 동시) | **자동매매 시뮬(페이퍼)** 1회 스캔. 가상자본 1천만, 규칙 엔진(LLM 미경유). universe=비하이브 워치+관심+보유(**ETF/ETN은 2026-06-09부터 자동매수 제외** — 애널 방향성 부재로 전략 부정합, `universe.is_etf()` 프리픽스 판별, 보유분은 청산까지 추적). 방향=애널 약한게이트+가점, 타이밍=수급·기술(2026-06-09 시장강약 필터: 하드차단→소프트게이트+장중 실시간 상승비율 + **지수추세 이중확인**). **2026-06-09 4종 추가**: 리스크 기반 사이징(`signals.target_value` 수량=자산×`RISK_PER_TRADE_PCT`÷손절폭, 균등비중 캡)·RSI 필터(돌파 과열/눌림목 과매도 보류)·**시장 국면 6단계**(`market_regime_map`→`engine.REGIME`: 🔥강세·📈반등·🌤완만상승=매수허용 / ➖혼조·📉약세·❄️급락=보류. 상승비율+지수추세+당일등락 조합. market_ok·시장탭 공용. 강세/약세 2단계가 84% 반등을 약세로 오판해 세분화)·**약세장 매수모드**(`BEAR_ENTRY_MODE` 0현금화/1돌파만/2눌림허용, market_ok False일 때 signals 분기. backtest는 시장중립이라 무영향→병렬변이로 라이브 비교. 2026-06-09 메인(mode1)+약세현금화·약세눌림허용 변이로 비교군 구성)·시간손절(`MAX_HOLD_DAYS` 미진전 청산)·**추세 질 보강**(2026-06-09 trend_ok 게이트에 ①20일선 우상향 기울기 ②5>60 중기정배열 추가〔종목데이터, backtest 반영〕 + ③상대강도〔종목−지수 N일수익률, 라이브 전용 체크〕). 사이징·시간손절은 엔진·백테스트 공용 헬퍼. TUNABLE 26개. 매수=돌파 즉시/눌림목 지정가, 매도=손절·2:1 후 트레일링·추세수급 이탈. 결과 `state/sim/`(portfolio.json·trades.jsonl·last_scan.json). 실주문 `orders/` 불가침. 일봉은 캐시 우선 읽기(ka10081 rate limit 회피). cwd=`agents/stock/workspace` |
|
||||
| `sim-scan` | 평일 09:00–15:30 **매 5분** (`StartCalendarInterval` 79엔트리, 엔진이 장외/휴장 self-skip) ⚠️2026-06-09 15분→5분 단축 + `StartInterval` 900s→`StartCalendarInterval` 전환(StartInterval은 GUI세션 idle/디스플레이 sleep 시 timer coalescing으로 발화 보류돼 장중 안 돎. calendar는 git-autopush가 새벽 02:00 정상발화로 검증됨) | `python3 -m sim scan` (내부 `scan_all` — 메인+병렬변이 동시) | **자동매매 시뮬(페이퍼)** 1회 스캔. 가상자본 1억(2026-06-10 1천만→1억), 규칙 엔진(LLM 미경유). universe=비하이브 워치+관심+보유(**ETF/ETN은 2026-06-09부터 자동매수 제외** — 애널 방향성 부재로 전략 부정합, `universe.is_etf()` 프리픽스 판별, 보유분은 청산까지 추적). 방향=애널 약한게이트+가점, 타이밍=수급·기술(2026-06-09 시장강약 필터: 하드차단→소프트게이트+장중 실시간 상승비율 + **지수추세 이중확인**). **2026-06-09 4종 추가**: 리스크 기반 사이징(`signals.target_value` 수량=자산×`RISK_PER_TRADE_PCT`÷손절폭, 균등비중 캡)·RSI 필터(돌파 과열/눌림목 과매도 보류)·**시장 국면 6단계**(`market_regime_map`→`engine.REGIME`: 🔥강세·📈반등·🌤완만상승=매수허용 / ➖혼조·📉약세·❄️급락=보류. 상승비율+지수추세+당일등락 조합. market_ok·시장탭 공용. 강세/약세 2단계가 84% 반등을 약세로 오판해 세분화)·**약세장 매수모드**(`BEAR_ENTRY_MODE` 0현금화/1돌파만/2눌림허용, market_ok False일 때 signals 분기. backtest는 시장중립이라 무영향→병렬변이로 라이브 비교. 2026-06-09 메인(mode1)+약세현금화·약세눌림허용 변이로 비교군 구성)·시간손절(`MAX_HOLD_DAYS` 미진전 청산)·**추세 질 보강**(2026-06-09 trend_ok 게이트에 ①20일선 우상향 기울기 ②5>60 중기정배열 추가〔종목데이터, backtest 반영〕 + ③상대강도〔종목−지수 N일수익률, 라이브 전용 체크〕). 사이징·시간손절은 엔진·백테스트 공용 헬퍼. TUNABLE 27개. **2026-06-10 코드리뷰 보강**: ①수급DB 일일증분(trade-journal 21시 묶음 3번째 명령 `-m sim.backfill_flow --pages 1 --force` — stale시 백테스트 수급게이트 중립화됨) ②일별 성과이력 `state/sim/equity_history.json`(EOD 덮어쓰기, ph=파라미터해시) ③당일 재진입 쿨다운(`Portfolio.last_exit`, backtest 동일) ④장중 거래량 하루치 환산(`vol_time_frac` — 이전엔 오전 돌파 불가). ⑤백테스트 체결 현실화(당일종가 판단→다음날 시가 체결 pending큐 + `BT_SLIPPAGE` 0.2% — 낙관분 ~5%p 제거) ⑥3분할 꾸준함 순위(`worst_ret` 1순위 — 한 구간 운빨 배제) ⑧**기준전략(M) 폐지**(2026-06-10 — scan_all 메인 패스 execute=False 판단전용·계좌 동결, 비교/매매는 변이 36개만, 메인 제외 규칙 삭제, ⭐즐겨찾기 `favorites.json`+`/fav_toggle`, 관심종목 기본 시각=첫⭐→1위) ⑦비교군 동기화는 **수동 전용**(2026-06-10 자동 동기화 끔 — sweep은 결과 파일만 갱신, 비교군 변경은 관리자님 명시 요청 시만 `python3 -m sim variants sync`. `sync_label_tops`=유형(~36종)별 1위 동기화, 동일params 계좌 유지. 백테스트 탭은 유형별 대표 읽기전용 요약, 수동 추가/제거·튜닝적용 버튼 제거). 잔여 한계: 검증 데이터가 여전히 ~1년 강세 위주(진짜 하락장 검증은 라이브 누적)·생존편향 universe(의도된 설계). 매수=돌파 즉시/눌림목 지정가, 매도=손절·2:1 후 트레일링·추세수급 이탈. 결과 `state/sim/`(portfolio.json·trades.jsonl·last_scan.json). 실주문 `orders/` 불가침. 일봉은 캐시 우선 읽기(ka10081 rate limit 회피). cwd=`agents/stock/workspace` |
|
||||
| `sim-web` | 상시 (`KeepAlive`) | `python3 -m sim.sim_web serve` | 시뮬 대시보드 **별도 서버 포트 18792** (behive-web 18790과 분리). `sim.hyowons.net` → mac:18792 (Synology reverse proxy, **관리자님 수동 등록 대기**). Tailnet 한정 무인증. ⚠️ 18791은 node 점유라 18792. **5탭**: 내 계좌·관심종목·시장(지수/ADR/투자자)·튜닝(파라미터 저장 → `params.json`)·백테스트(스윕 순위표+적용). 읽기전용 원칙이나 `do_POST /save_params`만 쓰기. 시장 탭은 장중엔 네이버 m.stock 실시간(지수+상승하락·ADR·투자자 순매수, `_get_live_market` 60s 캐시), 장외엔 EOD jsonl 폴백 (2026-06-09: 이전엔 상승하락·투자자가 EOD 고정이라 장중 안 바뀌던 것 수정) |
|
||||
| (트리거 없음) | on-demand | `python3 -m sim {sweep\|backtest}` | 튜닝 비교용 백테스트 스윕. signals.py 재사용·과거 일봉 되감기. 9파라미터(RR·STOP·SMA·PULLBACK·VOLUME + RSI과열/과매도·시간손절·리스크비율) 1152조합 학습/검증 분리 → `state/sim/backtest_results.json`. ⚠️ 조합당 ~2.3s → 1152조합 ≈ 44분(GRID 곱셈 폭발 주의). ⚠️ 종가체결·생존편향 → **상대순위**로만. 2026-06-09 스윕: 핵심 레버 `VOLUME_BREAKOUT=1.3`, 신규4종은 강세창선 미미(하락장 보험). 균형값 params.json 적용(RISK 0.01 유지). 수급은 `backfill_flow.py`(ka10059 다년치 → `flow_history.sqlite`, 2026-06-08 54종목 3만행 적재)로 반영됨(`flow_used:true`). 런타임 튜닝은 `params.json`이 config 기본값 오버라이드(엔진 매 스캔 새프로세스라 즉시 반영). **병렬 페이퍼**: `variants.json`+`state/sim/variants/<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` 등.
|
||||
|
||||
|
||||
@@ -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('변이 계좌 초기화 (정의 유지)')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 # 동시 보유 최대 종목수
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user