Compare commits
10 Commits
9cc7043490
...
81e8847a8e
| Author | SHA1 | Date | |
|---|---|---|---|
| 81e8847a8e | |||
| af058cf36d | |||
| 8bfaecbd1e | |||
| 51d0384ed4 | |||
| 9c830f58f6 | |||
| e1fd6a7e84 | |||
| 8c891a820a | |||
| 5ee35a0e27 | |||
| 7bb07ea3ad | |||
| f8cd71713b |
@@ -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` 등.
|
||||
|
||||
|
||||
@@ -2592,18 +2592,6 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
star = ''.join(star_parts)
|
||||
day_change = r.get('day_change', 0)
|
||||
day_change_pct = r.get('day_change_pct', 0.0)
|
||||
# 표시 등락 기준을 마크된 시장의 ka10095 전일종가(현재가−전일대비)로 통일 — 감시탭과 일치.
|
||||
# (스냅샷 rebase 값과 50원 단위로 갈려 미리보기·당일종가 등락이 어긋나던 것 해소. 원본 r은 보존.)
|
||||
_ok = r.get('ohlc_krx') or {}
|
||||
_on = r.get('ohlc_nxt') or {}
|
||||
_kp = _ok.get('price', 0) or 0
|
||||
_np = _on.get('price', 0) or 0
|
||||
_md = _on if (_np and _np == price and _np != _kp) else _ok
|
||||
_md_prev = (_md.get('price', 0) or 0) - (_md.get('change', 0) or 0)
|
||||
if _md_prev > 0 and price:
|
||||
day_change = price - _md_prev
|
||||
day_change_pct = (price - _md_prev) / _md_prev * 100.0
|
||||
r = {**r, 'day_change': day_change, 'day_change_pct': day_change_pct}
|
||||
|
||||
candle_summary = ''
|
||||
ohlc = r.get('ohlc') or {}
|
||||
@@ -2679,18 +2667,14 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
if _krx_prev > 0:
|
||||
ohlc_krx_for_render['prev_close'] = _krx_prev
|
||||
ohlc_nxt_for_render = r.get('ohlc_nxt')
|
||||
if ohlc_nxt_for_render:
|
||||
_snap_prev = r.get('pred_close', 0) or 0
|
||||
if ohlc_nxt_for_render and _snap_prev > 0:
|
||||
ohlc_nxt_for_render = dict(ohlc_nxt_for_render)
|
||||
ohlc_nxt_for_render['open'] = _snap_prev
|
||||
ohlc_nxt_for_render['prev_close'] = _snap_prev
|
||||
_nxt_px = ohlc_nxt_for_render.get('price', 0) or 0
|
||||
# NXT 전일종가도 ka10095(현재가−전일대비)로 — 스냅샷(pred_close)과 갈려 등락이 어긋나던 것 통일.
|
||||
_nxt_prev = _nxt_px - (ohlc_nxt_for_render.get('change', 0) or 0)
|
||||
if _nxt_prev <= 0:
|
||||
_nxt_prev = r.get('pred_close', 0) or 0
|
||||
if _nxt_prev > 0:
|
||||
ohlc_nxt_for_render['open'] = _nxt_prev
|
||||
ohlc_nxt_for_render['prev_close'] = _nxt_prev
|
||||
ohlc_nxt_for_render['change'] = _nxt_px - _nxt_prev
|
||||
ohlc_nxt_for_render['change_pct'] = ((_nxt_px - _nxt_prev) / _nxt_prev * 100) if _nxt_prev else 0.0
|
||||
ohlc_nxt_for_render['change'] = _nxt_px - _snap_prev
|
||||
ohlc_nxt_for_render['change_pct'] = ((_nxt_px - _snap_prev) / _snap_prev * 100) if _snap_prev else 0.0
|
||||
# 당일종가: KRX는 정규장 마감(15:30) 후, NXT는 마감(20:00) 후에만 그 시장 종가 표시.
|
||||
_now_ohlc = datetime.now(KST)
|
||||
_hm_ohlc = _now_ohlc.hour * 60 + _now_ohlc.minute
|
||||
@@ -2761,6 +2745,7 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
{candle_summary}
|
||||
</summary>
|
||||
<div class="detail">
|
||||
<div class="detail-name">{stock}{f'<span class="detail-code">{code}</span>' if code else ''}</div>
|
||||
{f'<div class="detail-tag">{tag_chip}</div>' if tag_chip else ''}
|
||||
<dl>{dl_inner}</dl>
|
||||
<dl class="pl-summary">{pl_dl_inner}</dl>
|
||||
@@ -3168,7 +3153,31 @@ def _aggregate_cash_flows_by_unit(daily_cf: dict[str, dict], aggregated_dates: l
|
||||
return out
|
||||
|
||||
|
||||
def _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: str = 'day', with_prev_baseline: bool = False):
|
||||
def _available_chart_periods() -> dict:
|
||||
"""스냅샷에 데이터가 있는 연도·연월 목록 (연도별/월별 드롭다운 채움용). 최신이 앞.
|
||||
반환: {'years': ['2026','2025',...], 'months': {'2026': ['06','05',...], ...}}"""
|
||||
empty = {'years': [], 'months': {}}
|
||||
if not SNAPSHOT_FILE.exists():
|
||||
return empty
|
||||
try:
|
||||
snap = json.loads(SNAPSHOT_FILE.read_text())
|
||||
except Exception:
|
||||
return empty
|
||||
years: set = set()
|
||||
months: dict = {}
|
||||
for date_key in snap.keys():
|
||||
if len(date_key) < 7 or date_key[4] != '-':
|
||||
continue
|
||||
y, m = date_key[:4], date_key[5:7]
|
||||
years.add(y)
|
||||
months.setdefault(y, set()).add(m)
|
||||
return {
|
||||
'years': sorted(years, reverse=True),
|
||||
'months': {y: sorted(ms, reverse=True) for y, ms in months.items()},
|
||||
}
|
||||
|
||||
|
||||
def _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: str = 'day', with_prev_baseline: bool = False, period: str = ''):
|
||||
"""portfolio_daily_snapshot에서 owner별 순자산 시계열을 추출.
|
||||
각 날짜의 net_worth = sum(holdings[*].eval_value) + deposit. 누락 필드는 0으로 처리.
|
||||
unit으로 일/주/월/연 단위 집계 (각 구간 마지막 영업일 점). 기간(days) 필터는 단위 집계 후 적용.
|
||||
@@ -3204,7 +3213,10 @@ def _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: s
|
||||
# filtered 에 baseline 점이 prepend 되었으면 그게 series[0] = PnL baseline 역할 → prev_baseline 별도 안 넘김
|
||||
baseline_prepended = False
|
||||
|
||||
if days == -2: # 이번달 — KST 기준 이번 달 1일 이후만
|
||||
if period: # 절대 선택 — 'YYYY'(연도별·월별집계) 또는 'YYYY-MM'(월별·일별). caller가 unit을 맞춰 호출.
|
||||
range_start = (period + '-01-01') if len(period) == 4 else (period + '-01')
|
||||
filtered = [(d, v) for d, v in aggregated if d.startswith(period)]
|
||||
elif days == -2: # 이번달 — KST 기준 이번 달 1일 이후만
|
||||
prefix = datetime.now(KST).strftime('%Y-%m')
|
||||
range_start = f'{prefix}-01'
|
||||
filtered = [(d, v) for d, v in aggregated if d.startswith(prefix)]
|
||||
@@ -4201,6 +4213,8 @@ details.row + .section-label { margin-top: 14px; padding-top: 24px; border-top-c
|
||||
.tag-chip { display: inline-block; padding: 0 4px; border-radius: 3px; font-size: 8.5px; font-weight: 500; line-height: 1.4; color: #d6b34a; background: rgba(214,179,74,0.10); border: 1px solid rgba(214,179,74,0.30); white-space: nowrap; flex: none; letter-spacing: 0.01em; font-family: inherit; pointer-events: none; user-select: none; }
|
||||
.tag-chip.tag-leader { color: #ff6b78; background: rgba(255,77,94,0.12); border-color: rgba(255,77,94,0.42); font-weight: 600; }
|
||||
/* 펼친 detail의 태그는 크게 (미리보기·관심행 칩은 작게 유지). */
|
||||
.detail-name { font-size: 15px; font-weight: 700; color: #f0f0f0; margin: 0 0 8px; display: flex; align-items: baseline; gap: 8px; }
|
||||
.detail-name .detail-code { font-size: 11px; font-weight: 500; color: #8b8f9a; font-variant-numeric: tabular-nums; }
|
||||
.detail-tag { margin: 0 0 8px; }
|
||||
.detail-tag .tag-chip { font-size: 11px; padding: 2px 8px; border-radius: 4px; }
|
||||
.btn-add-tag { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 500; line-height: 1.5; color: #5a5f6c; background: transparent; border: 1px dashed rgba(120,124,135,0.30); white-space: nowrap; flex: none; cursor: pointer; font-family: inherit; }
|
||||
@@ -4968,7 +4982,9 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
|
||||
.holdings-toggle-wrap { margin-top: 4px; }
|
||||
|
||||
/* 차트 컨트롤 — 자산정보 탭 상단의 공통 기간·단위 select 묶음. */
|
||||
.chart-controls { display: flex; gap: 10px; margin: 10px 14px 4px; flex-wrap: wrap; }
|
||||
.chart-controls { display: flex; flex-direction: column; gap: 6px; margin: 10px 14px 4px; }
|
||||
.chart-controls .cc-line { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||
.chart-controls .cc-line-ym[hidden] { display: none; } /* 지정기간 아닐 때 2번째 줄 숨김 ([hidden] UA 스타일이 flex에 가려지지 않도록 명시) */
|
||||
.chart-controls .cc-field { display: flex; align-items: center; gap: 6px; }
|
||||
.chart-controls .cc-label { color: #8b8f9a; font-size: 11px; }
|
||||
.chart-controls .nw-select {
|
||||
@@ -4980,6 +4996,10 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
|
||||
min-width: 96px;
|
||||
}
|
||||
.chart-controls .nw-select:focus { outline: 1px solid #4a4f5a; outline-offset: 0; }
|
||||
/* 2번째 줄(유형/연도/월) — 연도/월은 내용 크기로(월 줄바뀜 방지), 유형은 기간과 동일 폭. 글씨는 1번째 줄과 동일. */
|
||||
.chart-controls .cc-line-ym { gap: 8px; }
|
||||
.chart-controls .cc-line-ym .nw-select { min-width: 0; }
|
||||
.chart-controls .cc-line-ym .nw-select[data-nw-ymtype] { min-width: 96px; } /* 유형 = 기간 select 와 동일 폭 */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
nav.tabs .tab-label { padding: 10px 12px; font-size: 12px; }
|
||||
@@ -5127,17 +5147,53 @@ def _owner_full_title(owner: str) -> str:
|
||||
return OWNER_LABELS.get(owner, f'{owner} 자산')
|
||||
|
||||
|
||||
def _render_chart_controls(selected_days: int, selected_unit: str, selected_mode: str = NET_WORTH_CHART_MODE) -> str:
|
||||
"""차트용 공통 컨트롤 — 기간 / 단위 / 모드(순자산·손익누적·기간손익) select 3개.
|
||||
자산정보 탭 상단에 1세트만 노출, 본인·가희 차트 둘 다 동시 컨트롤.
|
||||
의미없는 단위(이번주+월별 등)는 disabled."""
|
||||
def _render_chart_controls(selected_days: int, selected_unit: str, selected_mode: str = NET_WORTH_CHART_MODE, chart_ym: str = '', avail: dict | None = None) -> str:
|
||||
"""차트용 공통 컨트롤 — 기간 / 단위 / 모드(순자산·손익누적·기간손익) select.
|
||||
기간에 '지정기간' 추가 → 유형(연도별/월별) + 연도 + 월(월별일 때) 보조 드롭다운.
|
||||
period(chart_ym) 활성 시 단위는 강제·비활성. 의미없는 단위(이번주+월별 등)는 disabled."""
|
||||
avail = avail or {'years': [], 'months': {}}
|
||||
period = (chart_ym or '').strip()
|
||||
is_year = len(period) == 4
|
||||
is_month = len(period) == 7
|
||||
period_active = is_year or is_month
|
||||
years = avail.get('years') or []
|
||||
months_map = avail.get('months') or {}
|
||||
|
||||
allowed_units = NET_WORTH_ALLOWED_UNITS_BY_DAYS.get(
|
||||
selected_days, tuple(u for _, u in NET_WORTH_UNIT_PRESETS)
|
||||
)
|
||||
range_opts = ''.join(
|
||||
f'<option value="{d}"{" selected" if d == selected_days else ""}>{html.escape(lbl)}</option>'
|
||||
f'<option value="{d}"{" selected" if (not period_active and d == selected_days) else ""}>{html.escape(lbl)}</option>'
|
||||
for lbl, d in NET_WORTH_CHART_PRESETS
|
||||
)
|
||||
range_opts += f'<option value="custom"{" selected" if period_active else ""}>지정기간</option>'
|
||||
|
||||
# 유형: 연도별(year) / 월별(month) — period_active 일 때만 노출.
|
||||
type_opts = (
|
||||
f'<option value="year"{" selected" if not is_month else ""}>연도별</option>'
|
||||
f'<option value="month"{" selected" if is_month else ""}>월별</option>'
|
||||
)
|
||||
|
||||
cur_year = period[:4] if period_active else (years[0] if years else '')
|
||||
cur_month = period[5:7] if is_month else ''
|
||||
year_opts = ''.join(
|
||||
f'<option value="{y}"{" selected" if y == cur_year else ""}>{html.escape(y)}년</option>'
|
||||
for y in years
|
||||
)
|
||||
# 월 — 월별일 때만 실제 월 목록(활성), 연도별이면 '-' 한 칸 비활성.
|
||||
if is_month:
|
||||
month_opts = ''.join(
|
||||
f'<option value="{m}"{" selected" if m == cur_month else ""}>{int(m)}월</option>'
|
||||
for m in months_map.get(cur_year, [])
|
||||
)
|
||||
_month_dis = ''
|
||||
else:
|
||||
month_opts = '<option value="">-</option>'
|
||||
_month_dis = ' disabled'
|
||||
_customhide = '' if period_active else ' hidden'
|
||||
months_json = html.escape(json.dumps(months_map, ensure_ascii=False), quote=True)
|
||||
|
||||
# 지정기간은 단위 일별 고정 → 단위 select 비활성, '일별' 선택 표시.
|
||||
unit_opts = ''.join(
|
||||
f'<option value="{u}"'
|
||||
f'{" selected" if u == selected_unit else ""}'
|
||||
@@ -5149,16 +5205,28 @@ def _render_chart_controls(selected_days: int, selected_unit: str, selected_mode
|
||||
f'<option value="{m}"{" selected" if m == selected_mode else ""}>{html.escape(lbl)}</option>'
|
||||
for lbl, m in NET_WORTH_MODE_PRESETS
|
||||
)
|
||||
return (
|
||||
'<div class="chart-controls">'
|
||||
_unit_dis = ' disabled' if period_active else ''
|
||||
# 1번째 줄: 기간 / 단위 / 차트타입(모드). 2번째 줄(지정기간일 때만): 유형 / 연도 / 월.
|
||||
line1 = (
|
||||
f'<div class="cc-line">'
|
||||
f'<label class="cc-field"><span class="cc-label">기간</span>'
|
||||
f'<select class="nw-select" data-nw-range>{range_opts}</select></label>'
|
||||
# 단위·모드 한 cc-field 묶음 — 좁은 화면 줄바꿈 방지 + 시각적 인접 배치.
|
||||
f'<label class="cc-field cc-unit"><span class="cc-label">단위</span>'
|
||||
f'<select class="nw-select" data-nw-unit>{unit_opts}</select>'
|
||||
f'<select class="nw-select" data-nw-unit{_unit_dis}>{unit_opts}</select>'
|
||||
f'<select class="nw-select nw-mode-inline" data-nw-mode aria-label="차트 모드">{mode_opts}</select></label>'
|
||||
'</div>'
|
||||
f'</div>'
|
||||
)
|
||||
line2 = (
|
||||
f'<div class="cc-line cc-line-ym"{_customhide}>'
|
||||
f'<label class="cc-field"><span class="cc-label">유형</span>'
|
||||
f'<select class="nw-select" data-nw-ymtype>{type_opts}</select></label>'
|
||||
f'<label class="cc-field"><span class="cc-label">연도</span>'
|
||||
f'<select class="nw-select" data-nw-year>{year_opts}</select></label>'
|
||||
f'<label class="cc-field"><span class="cc-label">월</span>'
|
||||
f'<select class="nw-select" data-nw-month{_month_dis}>{month_opts}</select></label>'
|
||||
f'</div>'
|
||||
)
|
||||
return f'<div class="chart-controls" data-ym-months="{months_json}">{line1}{line2}</div>'
|
||||
|
||||
|
||||
def _format_signed_billion(v: int | None) -> tuple[str, str]:
|
||||
@@ -5701,7 +5769,7 @@ def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances: dict | None = None, journal_by_label: dict | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS) -> str:
|
||||
def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances: dict | None = None, journal_by_label: dict | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> str:
|
||||
"""자산정보 탭 — sub-tab(자산보기/차트보기/시장정보)로 분리.
|
||||
- 자산보기(기본): owner별 compact KPI 카드 스택 (합산 / 각계좌별도 토글)
|
||||
- 차트보기: 기간·단위·모드 select + owner별 차트 (순자산 / 손익누적 토글)
|
||||
@@ -5713,9 +5781,19 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
|
||||
balances = balances or {}
|
||||
journal_by_label = journal_by_label or {}
|
||||
|
||||
# 연도별/월별 절대 선택 — chart_ym('YYYY'=연도별·월별집계 / 'YYYY-MM'=월별·일별) 있으면 단위 강제 + period 필터.
|
||||
period = (chart_ym or '').strip()
|
||||
eff_unit = 'day' if period else chart_unit # 지정기간(연도별·월별)은 단위 일별 고정.
|
||||
# 절대 선택이 과거 기간(이번달·올해가 아님)이면 오늘 라이브 점·라이브 갱신을 끈다 —
|
||||
# 과거 차트(예: 4월)에 today 순자산이 마지막 점·델타·하단값으로 섞이는 것 방지. 프리셋은 항상 today 포함.
|
||||
_now_dt = datetime.now(KST)
|
||||
period_is_current = True
|
||||
if period:
|
||||
period_is_current = (period == _now_dt.strftime('%Y')) if len(period) == 4 else (period == _now_dt.strftime('%Y-%m'))
|
||||
|
||||
consolidated_parts: list[str] = []
|
||||
by_account_parts: list[str] = []
|
||||
chart_parts: list[str] = [_render_chart_controls(chart_days, chart_unit, chart_mode)]
|
||||
chart_parts: list[str] = [_render_chart_controls(chart_days, eff_unit, chart_mode, chart_ym=period, avail=_available_chart_periods())]
|
||||
for owner in ordered_owners:
|
||||
d = owner_data.get(owner)
|
||||
if not d:
|
||||
@@ -5734,11 +5812,11 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
|
||||
has_prev_snap=has_prev_snap,
|
||||
label_disp=label_disp,
|
||||
))
|
||||
chart_series, prev_baseline = _load_net_worth_series(owner, days=chart_days, unit=chart_unit, with_prev_baseline=True)
|
||||
chart_series, prev_baseline = _load_net_worth_series(owner, days=chart_days, unit=eff_unit, with_prev_baseline=True, period=period)
|
||||
if chart_series:
|
||||
# 스냅샷에 누적된 일자별 입출금 → unit 집계에 맞춰 sum.
|
||||
daily_cf = _load_cash_flow_by_date(owner)
|
||||
cf_by_date = _aggregate_cash_flows_by_unit(daily_cf, [d_ for d_, _ in chart_series], chart_unit) if daily_cf else {}
|
||||
cf_by_date = _aggregate_cash_flows_by_unit(daily_cf, [d_ for d_, _ in chart_series], eff_unit) if daily_cf else {}
|
||||
# 오늘 live 포인트용 — snapshot에 아직 안 들어간 오늘 입출금. owner_data에 이미 합산되어 있음.
|
||||
today_cf = None
|
||||
if d.get('cash_in', 0) or d.get('cash_out', 0):
|
||||
@@ -5752,7 +5830,7 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
|
||||
prev_baseline_point = prev_baseline if prev_baseline else None
|
||||
# live current_pnl = current_net - baseline_nw - (시작점 이후 누적 cf + 오늘 cf).
|
||||
# series 마지막 점이 오늘이면 today_cf 는 baseline 이후 누적에 안 들어있으니 추가로 빼줘야 일관.
|
||||
current_net = d.get('total_net')
|
||||
current_net = d.get('total_net') if period_is_current else None
|
||||
if current_net is None:
|
||||
current_pnl = None
|
||||
else:
|
||||
@@ -5764,25 +5842,25 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
|
||||
pnl_series,
|
||||
current_net=current_pnl,
|
||||
selected_days=chart_days,
|
||||
selected_unit=chart_unit,
|
||||
selected_unit=eff_unit,
|
||||
cash_flow_by_date=None, # pnl·period 모두 cf 영향 이미 제거 — 마커 숨김
|
||||
today_cash_flow=None,
|
||||
mode=chart_mode,
|
||||
prev_baseline_point=prev_baseline,
|
||||
rt_owner=owner, rt_base_net=d.get('total_net'),
|
||||
rt_owner=(owner if period_is_current else None), rt_base_net=d.get('total_net'),
|
||||
))
|
||||
else:
|
||||
# net 모드도 baseline 점(예: 5/1) prepend → 첫 영업일 변동이 0 기점에서 분기되는 모양. baseline 구간은 점선.
|
||||
chart_parts.append(_render_net_worth_chart(
|
||||
chart_series,
|
||||
current_net=d.get('total_net'),
|
||||
current_net=(d.get('total_net') if period_is_current else None),
|
||||
selected_days=chart_days,
|
||||
selected_unit=chart_unit,
|
||||
selected_unit=eff_unit,
|
||||
cash_flow_by_date=cf_by_date,
|
||||
today_cash_flow=today_cf,
|
||||
mode='net',
|
||||
prev_baseline_point=prev_baseline,
|
||||
rt_owner=owner, rt_base_net=d.get('total_net'),
|
||||
rt_owner=(owner if period_is_current else None), rt_base_net=d.get('total_net'),
|
||||
))
|
||||
|
||||
market_card = _render_market_indicators_card()
|
||||
@@ -6206,7 +6284,7 @@ def _render_interests_panel(cards: list[dict]) -> tuple[str, int]:
|
||||
TAB_KEY_TO_OWNER = {'self': '본인', 'gahee': '가희'}
|
||||
|
||||
|
||||
def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS) -> dict:
|
||||
def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> dict:
|
||||
"""fetch_all_data + 탭별 패널 HTML 생성. /api/panels와 캐시의 단위.
|
||||
owner 지정 시: 해당 owner 계좌만 호출하고 응답 tabs는 owner 탭 하나만 (summary·wl 제외).
|
||||
클라이언트가 partial swap으로 사용 — 보지 않는 owner 호출 폭주 방지.
|
||||
@@ -6236,7 +6314,7 @@ def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_
|
||||
# 전체 — 모든 탭 채움. 모두 동일 fetched_at.
|
||||
wl_body, wl_count = _render_watchlist_panel(wl_cards)
|
||||
in_body, in_count = _render_interests_panel(in_cards)
|
||||
summary_body = _render_summary_panel(ordered_owners, owner_data, balances, journal_by_label, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days)
|
||||
summary_body = _render_summary_panel(ordered_owners, owner_data, balances, journal_by_label, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
|
||||
tabs.append({'id': 'tab-summary', 'label': '자산정보', 'html': summary_body, 'fetched_at': fetched_hms})
|
||||
for o in ordered_owners:
|
||||
d = owner_data[o]
|
||||
@@ -6503,10 +6581,21 @@ def render_html() -> str:
|
||||
'if(v!==7&&v!==30&&v!==90&&v!==180&&v!==365&&v!==-1)return null;'
|
||||
'return v;'
|
||||
'}'
|
||||
# chart_ym — 연도별('YYYY')/월별('YYYY-MM') 절대 선택. 빈값이면 프리셋(chartDays) 사용.
|
||||
'function chartYM(){'
|
||||
'var v=localStorage.getItem("behive.chartYM")||"";'
|
||||
'var p=v.split("-");'
|
||||
'if(p.length===1&&/^\\d{4}$/.test(p[0]))return v;'
|
||||
'if(p.length===2&&/^\\d{4}$/.test(p[0])&&/^\\d{2}$/.test(p[1]))return v;'
|
||||
'return "";'
|
||||
'}'
|
||||
'function setYM(v){try{if(v)localStorage.setItem("behive.chartYM",v);else localStorage.removeItem("behive.chartYM");}catch(_){}}'
|
||||
'window.__behive_chart_days=chartDays;'
|
||||
'window.__behive_chart_unit=chartUnit;'
|
||||
'window.__behive_chart_mode=chartMode;'
|
||||
'window.__behive_adr_days=adrDays;'
|
||||
'window.__behive_chart_ym=chartYM;'
|
||||
'window.__behive_set_ym=setYM;'
|
||||
'function load(owner,fresh){'
|
||||
# 5분 이상 stale 면 전체로 승격하되, 화면은 클리어하지 않고 기존 내용 유지 → fetch 완료 시 교체(swap).
|
||||
'if(lastLoadAt&&(Date.now()-lastLoadAt)>STALE_MS){owner=null;}'
|
||||
@@ -6517,6 +6606,7 @@ def render_html() -> str:
|
||||
'var cu=chartUnit();if(cu!==null)qs.push("chart_unit="+cu);'
|
||||
'var cm=chartMode();if(cm!==null)qs.push("chart_mode="+cm);'
|
||||
'var ad=adrDays();if(ad!==null)qs.push("adr_days="+ad);'
|
||||
'var ym=chartYM();if(ym)qs.push("chart_ym="+encodeURIComponent(ym));'
|
||||
'var url="/api/panels"+(qs.length?("?"+qs.join("&")):"");'
|
||||
'var pr=fetch(url,{cache:"no-store",credentials:"same-origin"})'
|
||||
'.then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.json();})'
|
||||
@@ -6865,16 +6955,33 @@ def render_html() -> str:
|
||||
'document.addEventListener("change",function(e){'
|
||||
'var sel=e.target;'
|
||||
'if(!sel||sel.tagName!=="SELECT"||!sel.classList||!sel.classList.contains("nw-select"))return;'
|
||||
'function ymFirstMonth(y){var cc=document.querySelector(".chart-controls");try{var mp=JSON.parse(cc.getAttribute("data-ym-months")||"{}");var a=mp[y]||[];return a.length?a[0]:"";}catch(_){return "";}}'
|
||||
'function ymSet(tp,y){if(!y)return;if(tp==="month"){var mm=ymFirstMonth(y);window.__behive_set_ym(mm?(y+"-"+mm):y);}else{window.__behive_set_ym(y);}}'
|
||||
'function curYear(){var e=document.querySelector(\'select.nw-select[data-nw-year]\');return e&&e.value?e.value:"";}'
|
||||
'function curType(){var e=document.querySelector(\'select.nw-select[data-nw-ymtype]\');return e&&e.value?e.value:"year";}'
|
||||
'if(sel.hasAttribute("data-nw-range")){'
|
||||
'try{localStorage.setItem("behive.chartDaysV2",sel.value);}catch(_){}'
|
||||
'var rv=sel.value;'
|
||||
'if(rv==="custom"){'
|
||||
# 지정기간 진입 — 유형(기본 연도별)·연도(기본 최신)로 chartYM 세팅.
|
||||
'var y=curYear();if(!y)return;' # 데이터 없음
|
||||
'ymSet(curType(),y);'
|
||||
'}else{'
|
||||
'window.__behive_set_ym("");' # 프리셋 → 절대선택 해제
|
||||
'try{localStorage.setItem("behive.chartDaysV2",rv);}catch(_){}'
|
||||
# 기간 변경 시 현재 단위가 비허용이면 첫 허용으로 자동 보정 (서버 sanity와 일치).
|
||||
'var allowedMap={"-3":["day","week"],"-2":["day","week","month"],"-4":["day","week","month","year"],"-1":["day","week","month","year"]};'
|
||||
'var allowed=allowedMap[sel.value]||["day","week","month","year"];'
|
||||
'var allowed=allowedMap[rv]||["day","week","month","year"];'
|
||||
'var unitSel=document.querySelector(\'select.nw-select[data-nw-unit]\');'
|
||||
'var curU=unitSel?unitSel.value:"day";'
|
||||
'if(allowed.indexOf(curU)<0){'
|
||||
'try{localStorage.setItem("behive.chartUnit",allowed[0]);}catch(_){}'
|
||||
'if(allowed.indexOf(curU)<0){try{localStorage.setItem("behive.chartUnit",allowed[0]);}catch(_){}}'
|
||||
'}'
|
||||
'}else if(sel.hasAttribute("data-nw-ymtype")){'
|
||||
'ymSet(sel.value,curYear());'
|
||||
'}else if(sel.hasAttribute("data-nw-year")){'
|
||||
'ymSet(curType(),sel.value);'
|
||||
'}else if(sel.hasAttribute("data-nw-month")){'
|
||||
'var ry=document.querySelector(\'select.nw-select[data-nw-year]\');'
|
||||
'window.__behive_set_ym((ry?ry.value:"")+"-"+sel.value);'
|
||||
'}else if(sel.hasAttribute("data-nw-unit")){'
|
||||
'try{localStorage.setItem("behive.chartUnit",sel.value);}catch(_){}'
|
||||
'}else if(sel.hasAttribute("data-nw-mode")){'
|
||||
@@ -7210,8 +7317,14 @@ def render_html() -> str:
|
||||
'xTipTxt.appendChild(_mkTspan({"font-size":24,"font-weight":500},_pctStr(c.l,c.o),lcol));'
|
||||
'xTipTxt.appendChild(_mkTspan({x:16,dy:34,"font-size":28,"font-weight":700},"종 "+c.c.toLocaleString()+" ",ccol));'
|
||||
'xTipTxt.appendChild(_mkTspan({"font-size":24,"font-weight":700},_pctStr(c.c,c.o),ccol));'
|
||||
# 분봉: 전일종가 대비 등락률 — 그 봉 종가 vs 전일종가 (현재 등락율과 일치).
|
||||
'var hasPC=meta.prevClose&&meta.prevClose>0;'
|
||||
'if(hasPC){xTipTxt.appendChild(_mkTspan({x:16,dy:32,"font-size":23,"font-weight":600},"전일대비 "+_pctStr(c.c,meta.prevClose),_col(c.c,meta.prevClose)));}'
|
||||
# 팝업 위치 — vline 의 반대 쪽으로 자동 배치 (좌/우). 화면 벗어남 방지 clamp.
|
||||
'var tipW=320,tipH=176,gap=10;'
|
||||
'var tipW=340,tipH=hasPC?212:176,gap=10;'
|
||||
# 당일시가 라인 유무에 따라 배경 박스·닫기버튼 위치 동적 조정.
|
||||
'xTipBg.setAttribute("width",tipW);xTipBg.setAttribute("height",tipH);'
|
||||
'closeG.setAttribute("transform","translate("+(tipW-20)+",20)");'
|
||||
'var tipX=xPos+gap;'
|
||||
'if(tipX+tipW>meta.chartW-meta.padR)tipX=xPos-gap-tipW;'
|
||||
'if(tipX<meta.padL)tipX=meta.padL+2;'
|
||||
@@ -9345,11 +9458,11 @@ def _save_owner_slices(owner_data: dict, balances: dict, journal_by_label: dict
|
||||
}
|
||||
|
||||
|
||||
def _patch_all_cache_for_owner(owner: str, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS) -> None:
|
||||
def _patch_all_cache_for_owner(owner: str, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> None:
|
||||
"""partial fetch 직후 'all' 캐시의 summary 패널과 해당 owner 패널만 in-place로 갈아끼운다.
|
||||
다른 owner와 watchlist/interests 슬라이스는 cached 'all'에 있던 그대로 — TTL 만료 시 full fetch로 동기화.
|
||||
핵심 목표: 자산정보 탭의 본인 KPI가 관리자 탭과 같은 시점이 되도록."""
|
||||
all_key = f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}'
|
||||
all_key = f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}'
|
||||
all_lock = _panels_lock_for(all_key)
|
||||
with all_lock:
|
||||
slot = _panels_cache.get(all_key)
|
||||
@@ -9370,7 +9483,7 @@ def _patch_all_cache_for_owner(owner: str, chart_days: int = NET_WORTH_CHART_DAY
|
||||
for e in _owner_data_cache.values():
|
||||
merged_balances.update(e.get('balances') or {})
|
||||
merged_journal.update(e.get('journal_by_label') or {})
|
||||
new_summary = _render_summary_panel(ordered, merged, merged_balances, merged_journal, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days)
|
||||
new_summary = _render_summary_panel(ordered, merged, merged_balances, merged_journal, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
|
||||
entry = _owner_data_cache.get(owner)
|
||||
if not entry:
|
||||
return
|
||||
@@ -9394,7 +9507,7 @@ def _patch_all_cache_for_owner(owner: str, chart_days: int = NET_WORTH_CHART_DAY
|
||||
_panels_cache[all_key] = {'json': new_body, 'expires_at': slot['expires_at']}
|
||||
|
||||
|
||||
def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS) -> bytes:
|
||||
def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> bytes:
|
||||
"""fresh=True 시 캐시 우회 — 명시적 새로고침(?fresh=1)에서 호출.
|
||||
응답을 새로 만든 뒤에는 캐시에 덮어쓰므로 그 후 자동 갱신은 새 캐시를 본다.
|
||||
|
||||
@@ -9404,7 +9517,7 @@ def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, chart
|
||||
갈아끼운다(_patch_all_cache_for_owner). 자산정보 = 본인+가희 KPI 카드 스택이라 owner_data만
|
||||
있으면 재렌더 가능 — 다시 fetch할 필요 없음. 패치 실패 시 안전망으로 'all' 캐시 비워
|
||||
다음 자산정보 진입에서 full fetch."""
|
||||
key = f'{owner or "all"}:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}'
|
||||
key = f'{owner or "all"}:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}'
|
||||
if not fresh:
|
||||
slot = _panels_cache.get(key)
|
||||
if slot and slot['expires_at'] > time.time():
|
||||
@@ -9415,15 +9528,15 @@ def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, chart
|
||||
slot = _panels_cache.get(key)
|
||||
if slot and slot['expires_at'] > time.time():
|
||||
return slot['json']
|
||||
payload = _build_panels_payload(owner=owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days)
|
||||
payload = _build_panels_payload(owner=owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
|
||||
body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
|
||||
_panels_cache[key] = {'json': body, 'expires_at': time.time() + PANELS_CACHE_TTL}
|
||||
if owner is not None:
|
||||
try:
|
||||
_patch_all_cache_for_owner(owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days)
|
||||
_patch_all_cache_for_owner(owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
_panels_cache.pop(f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}', None)
|
||||
_panels_cache.pop(f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}', None)
|
||||
return body
|
||||
|
||||
|
||||
@@ -9928,7 +10041,27 @@ class Handler(BaseHTTPRequestHandler):
|
||||
'sma20': sa.sma(closes, 20),
|
||||
'sma60': sa.sma(closes, 60),
|
||||
}
|
||||
svg = sa.render_svg_chart(snap, range_key='1Y')
|
||||
# 전일종가 — 일봉 캐시에서 오늘 이전 마지막 종가 → 기준선+전일대비 등락률 표시
|
||||
prev_close = None
|
||||
try:
|
||||
import daily_candles_cache as dcc
|
||||
_today = datetime.now(KST).strftime('%Y%m%d')
|
||||
for b in dcc._select_latest(code, 3):
|
||||
if b.get('date') and str(b['date']) < _today and b.get('close'):
|
||||
prev_close = b['close']
|
||||
break
|
||||
except Exception:
|
||||
prev_close = None
|
||||
if not prev_close:
|
||||
# 일봉 캐시 미스 → ka10095(현재가−전일대비)로 전일종가 보강. 모든 종목에 전일대비 % 표시.
|
||||
try:
|
||||
_q = kc.get_watchlist_quotes([code]).get(code) or {}
|
||||
_pc = (_q.get('price') or 0) - (_q.get('change') or 0)
|
||||
if _pc > 0:
|
||||
prev_close = _pc
|
||||
except Exception:
|
||||
pass
|
||||
svg = sa.render_svg_chart(snap, range_key='1Y', ref_price=prev_close, intraday=True)
|
||||
body = svg.encode('utf-8')
|
||||
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
|
||||
self.send_response(200)
|
||||
@@ -10098,8 +10231,17 @@ class Handler(BaseHTTPRequestHandler):
|
||||
adr_days = ADR_TREND_DAYS
|
||||
if adr_days not in {d for _, d in ADR_TREND_PRESETS}:
|
||||
adr_days = ADR_TREND_DAYS
|
||||
# chart_ym — 'YYYY'(연도별) 또는 'YYYY-MM'(월별). 형식 위반은 무시(빈값=프리셋).
|
||||
chart_ym = (qs.get('chart_ym') or [''])[0]
|
||||
_ymp = (chart_ym or '').split('-')
|
||||
_ym_ok = (
|
||||
(len(_ymp) == 1 and len(_ymp[0]) == 4 and _ymp[0].isdigit())
|
||||
or (len(_ymp) == 2 and len(_ymp[0]) == 4 and _ymp[0].isdigit() and len(_ymp[1]) == 2 and _ymp[1].isdigit())
|
||||
)
|
||||
if not _ym_ok:
|
||||
chart_ym = ''
|
||||
try:
|
||||
body = _get_cached_panels_json(owner=owner, fresh=fresh, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days)
|
||||
body = _get_cached_panels_json(owner=owner, fresh=fresh, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
self.send_error(500, 'panels failed')
|
||||
|
||||
@@ -546,10 +546,11 @@ VOL_BOT = VOL_TOP + VOL_PANEL_H
|
||||
CHART_RANGE_DAYS = {'1Y': 250, '6M': 120, '1M': 21}
|
||||
|
||||
|
||||
def render_svg_chart(snap: dict, range_key: str = '1Y') -> str:
|
||||
def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None = None, intraday: bool = False) -> str:
|
||||
"""캔들 + SMA 3선 + 거래량 막대 인라인 SVG.
|
||||
|
||||
range_key: '1Y' (250일) | '6M' (120일) | '1M' (21일). 같은 snapshot 데이터에서 슬라이스만.
|
||||
ref_price: 기준가(전일종가 등) — 지정 시 노란 점선 기준선 + 마지막 종가의 대비 등락률 표시 (분봉용).
|
||||
snap: collect_snapshot 결과. candles_asc·sma5/20/60 사용.
|
||||
외부 JS 의존성 0. CSS는 inline.
|
||||
"""
|
||||
@@ -570,6 +571,9 @@ def render_svg_chart(snap: dict, range_key: str = '1Y') -> str:
|
||||
sma_vals = [v for v in (sma5 + sma20 + sma60) if v is not None]
|
||||
y_max = max(max(highs), max(sma_vals) if sma_vals else 0)
|
||||
y_min = min(min(lows), min(sma_vals) if sma_vals else float('inf'))
|
||||
if ref_price:
|
||||
y_max = max(y_max, ref_price) # 기준선이 항상 보이게 스케일에 포함
|
||||
y_min = min(y_min, ref_price)
|
||||
if y_min == y_max:
|
||||
y_max = y_min + 1
|
||||
# 위·아래 패딩 5%
|
||||
@@ -607,6 +611,8 @@ def render_svg_chart(snap: dict, range_key: str = '1Y') -> str:
|
||||
],
|
||||
'ymin': y_min,
|
||||
'ymax': y_max,
|
||||
# 분봉일 때만 전일종가(ref_price) — 크로스헤어 툴팁의 '전일대비 등락률'용 (현재 등락율과 일치).
|
||||
'prevClose': (int(ref_price) if (intraday and ref_price) else 0),
|
||||
'priceTop': PRICE_TOP,
|
||||
'priceBot': PRICE_BOT,
|
||||
'volTop': VOL_TOP,
|
||||
@@ -696,6 +702,18 @@ def render_svg_chart(snap: dict, range_key: str = '1Y') -> str:
|
||||
parts.append(sma_path(sma20, '#34d399', 'SMA20')) # 초록
|
||||
parts.append(sma_path(sma60, '#a78bfa', 'SMA60')) # 보라
|
||||
|
||||
# ---- 전일종가 기준선 + 대비 등락률 (ref_price 지정 시 — 분봉) ----
|
||||
if ref_price:
|
||||
ry = price_y(ref_price)
|
||||
parts.append(
|
||||
f'<line x1="{CHART_PAD_L}" y1="{ry:.1f}" x2="{CHART_W-CHART_PAD_R}" y2="{ry:.1f}" '
|
||||
f'stroke="#eab308" stroke-width="1.5" stroke-dasharray="7 5" opacity="0.85"/>'
|
||||
)
|
||||
parts.append(
|
||||
f'<text x="{CHART_W - CHART_PAD_R + 6}" y="{ry-4:.1f}" text-anchor="start" fill="#eab308" font-size="24">전일</text>'
|
||||
)
|
||||
# 우상단 '전일대비 X%' 텍스트는 제거(관리자 요청) — 전일종가 기준선만 유지.
|
||||
|
||||
# SMA 범례
|
||||
legend_y = PRICE_TOP + 14
|
||||
legend_items = [('SMA5', '#fbbf24'), ('SMA20', '#34d399'), ('SMA60', '#a78bfa')]
|
||||
|
||||
@@ -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 = _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,6 +365,7 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
|
||||
dec['reason'] = '매수 신호 — 현금/한도 부족'
|
||||
decisions.append(dec)
|
||||
|
||||
if execute:
|
||||
pf.update_equity_metrics()
|
||||
pf.save()
|
||||
|
||||
@@ -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