Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cc7043490 | |||
| 0ba61d4d7c | |||
| cbc89a4843 | |||
| 721fe9011c | |||
| 891bc274dc | |||
| 79c6647431 | |||
| 7f1c52a580 |
@@ -50,9 +50,9 @@
|
|||||||
| `holiday-sync` | 매주 일요일 03:00 | `holiday_sync.py` | investing.com → `state/market_holidays.json`. 실패 시 기존 파일 보존 |
|
| `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 필요 |
|
| `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` |
|
| `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–15:30 매 15분 (`StartInterval` 900s, 엔진이 장외/휴장 self-skip) | `python3 -m sim scan` (내부 `scan_all` — 메인+병렬변이 동시) | **자동매매 시뮬(페이퍼)** 1회 스캔. 가상자본 1천만, 규칙 엔진(LLM 미경유). universe=비하이브 워치+관심+보유. 방향=애널 약한게이트+가점, 타이밍=수급·기술. 매수=돌파 즉시/눌림목 지정가, 매도=손절·2:1 후 트레일링·추세수급 이탈. 결과 `state/sim/`(portfolio.json·trades.jsonl·last_scan.json). 실주문 `orders/` 불가침. 일봉은 캐시 우선 읽기(ka10081 rate limit 회피). cwd=`agents/stock/workspace` |
|
| `sim-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-web` | 상시 (`KeepAlive`) | `python3 -m sim.sim_web serve` | 시뮬 대시보드 **별도 서버 포트 18792** (behive-web 18790과 분리). `sim.hyowons.net` → mac:18792 (Synology reverse proxy, **관리자님 수동 등록 대기**). Tailnet 한정 무인증. ⚠️ 18791은 node 점유라 18792. **5탭**: 내 계좌·관심종목·시장(지수/ADR/투자자)·튜닝(파라미터 저장 → `params.json`)·백테스트(스윕 순위표+적용). 읽기전용 원칙이나 `do_POST /save_params`만 쓰기. 시장 탭은 네이버 지수 60s 캐시 fetch(유일한 네트워크) |
|
| `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 재사용·과거 일봉 되감기. 4핵심(RR·STOP_ATR·SMA_LONG·PULLBACK_ATR) 81조합 학습/검증 분리 → `state/sim/backtest_results.json`. ⚠️ 종가체결·현 워치리스트(생존편향) → 절대수익 아닌 **상대순위**로만. 수급은 `backfill_flow.py`(ka10059 다년치 → `flow_history.sqlite`, 2026-06-08 54종목 3만행 적재)로 반영됨(`flow_used:true`). 런타임 튜닝은 `params.json`이 config 기본값 오버라이드(엔진 매 스캔 새프로세스라 즉시 반영). **병렬 페이퍼**: `variants.json`+`state/sim/variants/<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`. ⚠️ 조합당 ~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이 매 스캔 메인+변이 함께 갱신 |
|
||||||
|
|
||||||
휴장일 self-skip·당일 평가손익 ground truth·3-탭 개편 배경은 일지(2026-05-04·05-06)와 `behive_web.py` 주석 참조. 사고 이력은 `memory/2026-05-07-behive-dup-mail.md` 등.
|
휴장일 self-skip·당일 평가손익 ground truth·3-탭 개편 배경은 일지(2026-05-04·05-06)와 `behive_web.py` 주석 참조. 사고 이력은 `memory/2026-05-07-behive-dup-mail.md` 등.
|
||||||
|
|
||||||
|
|||||||
@@ -661,6 +661,35 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
|
|||||||
if not miss_codes:
|
if not miss_codes:
|
||||||
return cards
|
return cards
|
||||||
|
|
||||||
|
# WS 허브 연결 시 — 미스 종목 시세를 허브 메모리(0B 실시간, KRX+NXT 통합)에서 우선 충당.
|
||||||
|
# 서버가 동일 렌더러로 OHLC·마크를 그대로 그린다 (마크는 허브 exchange 필드로 판정).
|
||||||
|
# 허브에 없는(아직 미체결) 종목만 ka10095 폴백으로 남긴다. 캐시엔 안 넣음 — 매 페치 라이브 허브 사용.
|
||||||
|
hub = _RT_HUB
|
||||||
|
if hub is not None and hub.is_connected():
|
||||||
|
hub_q = hub.get_quotes(miss_codes)
|
||||||
|
rest_codes: list[str] = []
|
||||||
|
rest_indices: list[int] = []
|
||||||
|
for idx, code in zip(miss_indices, miss_codes):
|
||||||
|
q = hub_q.get(kc._clean_code(code))
|
||||||
|
price = (q or {}).get('price') or 0
|
||||||
|
if not price:
|
||||||
|
rest_codes.append(code)
|
||||||
|
rest_indices.append(idx)
|
||||||
|
continue
|
||||||
|
out = cards[idx]
|
||||||
|
out['price'] = price
|
||||||
|
out['day_change'] = q.get('change')
|
||||||
|
out['day_change_pct'] = q.get('pct')
|
||||||
|
out['open'] = q.get('open') or None
|
||||||
|
out['high'] = q.get('high') or None
|
||||||
|
out['low'] = q.get('low') or None
|
||||||
|
is_nxt = (q.get('exchange') or '').upper() == 'NXT'
|
||||||
|
out['_krx_price'] = 0 if is_nxt else price
|
||||||
|
out['_nxt_price'] = price if is_nxt else 0
|
||||||
|
miss_codes, miss_indices = rest_codes, rest_indices
|
||||||
|
if not miss_codes:
|
||||||
|
return cards
|
||||||
|
|
||||||
try:
|
try:
|
||||||
batch = kc.get_watchlist_quotes(miss_codes)
|
batch = kc.get_watchlist_quotes(miss_codes)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
@@ -1090,6 +1119,8 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
|
|||||||
held_codes_set.add(_p['code'])
|
held_codes_set.add(_p['code'])
|
||||||
ohlc_map_krx: dict[str, dict] = {}
|
ohlc_map_krx: dict[str, dict] = {}
|
||||||
ohlc_map_nxt: dict[str, dict] = {}
|
ohlc_map_nxt: dict[str, dict] = {}
|
||||||
|
# KRX/NX 2콜은 가격 보정뿐 아니라 보유종목 행의 KRX·NXT 듀얼 OHLC 미니(r['ohlc_krx']·['ohlc_nxt'])
|
||||||
|
# 데이터원이다. _AL 허브는 거래소별 단일 최신값만 들어 두 컬럼을 동시에 못 주므로 skip 불가 — WS 연결과 무관하게 호출.
|
||||||
if held_codes_set:
|
if held_codes_set:
|
||||||
try:
|
try:
|
||||||
ohlc_map_krx = kc.get_watchlist_quotes(list(held_codes_set), exchange='KRX')
|
ohlc_map_krx = kc.get_watchlist_quotes(list(held_codes_set), exchange='KRX')
|
||||||
@@ -2261,7 +2292,7 @@ def _render_account_kpi(owner: str, label: str, owner_d: dict, balances: dict, j
|
|||||||
</section>'''
|
</section>'''
|
||||||
|
|
||||||
|
|
||||||
def _render_ohlc_mini_dual(krx: dict | None, nxt: dict | None, active_market: str = 'none') -> str:
|
def _render_ohlc_mini_dual(krx: dict | None, nxt: dict | None, active_market: str = 'none', al_price: int = 0, krx_closed: bool = False, nxt_closed: bool = False) -> str:
|
||||||
"""보유종목 자세히 보기용 OHLC mini-table — KRX/NXT 두 거래소 가격·등락률 분리 표시.
|
"""보유종목 자세히 보기용 OHLC mini-table — KRX/NXT 두 거래소 가격·등락률 분리 표시.
|
||||||
|
|
||||||
각 dict: {open, high, low, price, change_pct} (price=종가/현재가, change_pct=직전 영업일 종가 대비).
|
각 dict: {open, high, low, price, change_pct} (price=종가/현재가, change_pct=직전 영업일 종가 대비).
|
||||||
@@ -2283,13 +2314,25 @@ def _render_ohlc_mini_dual(krx: dict | None, nxt: dict | None, active_market: st
|
|||||||
nxt = dict(nxt)
|
nxt = dict(nxt)
|
||||||
nxt['open'] = nxt_baseline
|
nxt['open'] = nxt_baseline
|
||||||
|
|
||||||
def _cell(d: dict | None, key: str, dim: bool) -> str:
|
def _cell(d: dict | None, key: str, dim: bool, closed: bool = False) -> str:
|
||||||
dim_cls = ' ohlc-dim' if dim else ''
|
dim_cls = ' ohlc-dim' if dim else ''
|
||||||
if not d:
|
if not d:
|
||||||
return f'<span class="ohlc-cell-empty{dim_cls}">-</span>'
|
return f'<span class="ohlc-cell-empty{dim_cls}">-</span>'
|
||||||
if key == 'price':
|
if key == 'price':
|
||||||
|
# 현재가는 AL(통합) 가격을 양 컬럼 공통으로 표시(동일가). 등락률만 각 시장 전일종가 대비로 분리.
|
||||||
|
price = al_price or d.get('price', 0)
|
||||||
|
base = (d.get('prev_close', 0) or 0) if al_price else 0
|
||||||
|
if al_price and base:
|
||||||
|
pct = (price - base) / base * 100
|
||||||
|
else:
|
||||||
|
pct = d.get('change_pct', 0.0) or 0.0
|
||||||
|
elif key == 'close_today':
|
||||||
|
# 당일종가 — 그 시장이 마감된 뒤에만 그 시장의 종가(=ka10095 price)를 표시. 장중엔 미확정(·).
|
||||||
|
if not closed:
|
||||||
|
return f'<span class="ohlc-cell-empty{dim_cls}">·</span>'
|
||||||
price = d.get('price', 0)
|
price = d.get('price', 0)
|
||||||
pct = d.get('change_pct', 0.0) or 0.0
|
base = d.get('prev_close', 0) or 0
|
||||||
|
pct = ((price - base) / base * 100) if base else (d.get('change_pct', 0.0) or 0.0)
|
||||||
elif key == 'prev_close':
|
elif key == 'prev_close':
|
||||||
# 전 거래일 종가 — baseline 자체라 등락률 표시 없음.
|
# 전 거래일 종가 — baseline 자체라 등락률 표시 없음.
|
||||||
price = d.get('prev_close', 0)
|
price = d.get('prev_close', 0)
|
||||||
@@ -2328,6 +2371,7 @@ def _render_ohlc_mini_dual(krx: dict | None, nxt: dict | None, active_market: st
|
|||||||
('시가', 'open'),
|
('시가', 'open'),
|
||||||
('고가', 'high'),
|
('고가', 'high'),
|
||||||
('저가', 'low'),
|
('저가', 'low'),
|
||||||
|
('당일종가', 'close_today'),
|
||||||
]
|
]
|
||||||
krx_th_dim = ' ohlc-dim' if krx_dim else ''
|
krx_th_dim = ' ohlc-dim' if krx_dim else ''
|
||||||
nxt_th_dim = ' ohlc-dim' if nxt_dim else ''
|
nxt_th_dim = ' ohlc-dim' if nxt_dim else ''
|
||||||
@@ -2339,8 +2383,8 @@ def _render_ohlc_mini_dual(krx: dict | None, nxt: dict | None, active_market: st
|
|||||||
]
|
]
|
||||||
for lbl, key in rows:
|
for lbl, key in rows:
|
||||||
parts.append(f'<span class="ohlc-rl">{lbl}</span>')
|
parts.append(f'<span class="ohlc-rl">{lbl}</span>')
|
||||||
parts.append(_cell(krx if has_krx else None, key, krx_dim))
|
parts.append(_cell(krx if has_krx else None, key, krx_dim, krx_closed))
|
||||||
parts.append(_cell(nxt if has_nxt else None, key, nxt_dim))
|
parts.append(_cell(nxt if has_nxt else None, key, nxt_dim, nxt_closed))
|
||||||
parts.append('</div>')
|
parts.append('</div>')
|
||||||
return ''.join(parts)
|
return ''.join(parts)
|
||||||
|
|
||||||
@@ -2548,6 +2592,18 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
|||||||
star = ''.join(star_parts)
|
star = ''.join(star_parts)
|
||||||
day_change = r.get('day_change', 0)
|
day_change = r.get('day_change', 0)
|
||||||
day_change_pct = r.get('day_change_pct', 0.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 = ''
|
candle_summary = ''
|
||||||
ohlc = r.get('ohlc') or {}
|
ohlc = r.get('ohlc') or {}
|
||||||
@@ -2613,23 +2669,39 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
|||||||
# change/change_pct 도 스냅샷 baseline 기준으로 재계산 → cell 안 pct 표시도 일관.
|
# change/change_pct 도 스냅샷 baseline 기준으로 재계산 → cell 안 pct 표시도 일관.
|
||||||
# 전 거래일 종가 행: KRX = pred_close_krx (KRX 전일종가), NXT = r['pred_close'] (어제 NXT 스냅샷).
|
# 전 거래일 종가 행: KRX = pred_close_krx (KRX 전일종가), NXT = r['pred_close'] (어제 NXT 스냅샷).
|
||||||
ohlc_krx_for_render = r.get('ohlc_krx')
|
ohlc_krx_for_render = r.get('ohlc_krx')
|
||||||
_pred_krx = r.get('pred_close_krx', 0) or 0
|
if ohlc_krx_for_render:
|
||||||
if ohlc_krx_for_render and _pred_krx > 0:
|
|
||||||
ohlc_krx_for_render = dict(ohlc_krx_for_render)
|
ohlc_krx_for_render = dict(ohlc_krx_for_render)
|
||||||
ohlc_krx_for_render['prev_close'] = _pred_krx
|
# KRX 전일종가 = ka10095(현재가 − 전일대비). kt00018 pred_close_krx는 장 마감 후
|
||||||
|
# 현재가와 같은 값으로 채워지는 결함이 있어 등락률이 0%로 깔린다 → ka10095 기준으로 산출.
|
||||||
|
_krx_prev = (ohlc_krx_for_render.get('price', 0) or 0) - (ohlc_krx_for_render.get('change', 0) or 0)
|
||||||
|
if _krx_prev <= 0:
|
||||||
|
_krx_prev = r.get('pred_close_krx', 0) or 0
|
||||||
|
if _krx_prev > 0:
|
||||||
|
ohlc_krx_for_render['prev_close'] = _krx_prev
|
||||||
ohlc_nxt_for_render = r.get('ohlc_nxt')
|
ohlc_nxt_for_render = r.get('ohlc_nxt')
|
||||||
_snap_prev = r.get('pred_close', 0) or 0
|
if ohlc_nxt_for_render:
|
||||||
if ohlc_nxt_for_render and _snap_prev > 0:
|
|
||||||
ohlc_nxt_for_render = dict(ohlc_nxt_for_render)
|
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_px = ohlc_nxt_for_render.get('price', 0) or 0
|
||||||
ohlc_nxt_for_render['change'] = _nxt_px - _snap_prev
|
# NXT 전일종가도 ka10095(현재가−전일대비)로 — 스냅샷(pred_close)과 갈려 등락이 어긋나던 것 통일.
|
||||||
ohlc_nxt_for_render['change_pct'] = ((_nxt_px - _snap_prev) / _snap_prev * 100) if _snap_prev else 0.0
|
_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
|
||||||
|
# 당일종가: KRX는 정규장 마감(15:30) 후, NXT는 마감(20:00) 후에만 그 시장 종가 표시.
|
||||||
|
_now_ohlc = datetime.now(KST)
|
||||||
|
_hm_ohlc = _now_ohlc.hour * 60 + _now_ohlc.minute
|
||||||
|
_trd_ohlc = _market_phase_state().get('phase') not in ('holiday', 'weekend')
|
||||||
ohlc_mini_html = _render_ohlc_mini_dual(
|
ohlc_mini_html = _render_ohlc_mini_dual(
|
||||||
ohlc_krx_for_render,
|
ohlc_krx_for_render,
|
||||||
ohlc_nxt_for_render,
|
ohlc_nxt_for_render,
|
||||||
active_market=mark.lower(),
|
active_market=mark.lower(),
|
||||||
|
al_price=price, # 현재가 행은 AL(통합) 현재가를 양 컬럼 공통 — 등락률만 각 시장 전일종가 대비.
|
||||||
|
krx_closed=_trd_ohlc and _hm_ohlc >= 15 * 60 + 30,
|
||||||
|
nxt_closed=_trd_ohlc and _hm_ohlc >= 20 * 60,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 미리보기 등락은 rebase 후 day_change_pct 사용 — 어제 NXT 종가 baseline.
|
# 미리보기 등락은 rebase 후 day_change_pct 사용 — 어제 NXT 종가 baseline.
|
||||||
@@ -3628,7 +3700,7 @@ def _render_net_worth_chart(series: list[tuple[str, int]], current_net: int | No
|
|||||||
if solid_d:
|
if solid_d:
|
||||||
svg_parts.append(f'<path class="line" d="{solid_d}" stroke="{line_color}" />')
|
svg_parts.append(f'<path class="line" d="{solid_d}" stroke="{line_color}" />')
|
||||||
if dashed_d:
|
if dashed_d:
|
||||||
svg_parts.append(f'<path class="line dashed" d="{dashed_d}" stroke="{line_color}" />')
|
svg_parts.append(f'<path class="line dashed live-seg" d="{dashed_d}" stroke="{line_color}" />')
|
||||||
if point_dots:
|
if point_dots:
|
||||||
svg_parts.append(''.join(point_dots))
|
svg_parts.append(''.join(point_dots))
|
||||||
if has_live:
|
if has_live:
|
||||||
@@ -3697,6 +3769,7 @@ def _render_net_worth_chart(series: list[tuple[str, int]], current_net: int | No
|
|||||||
'<button type="button" class="nw-nav" data-nw-step="next" aria-label="다음 날짜">▶</button>'
|
'<button type="button" class="nw-nav" data-nw-step="next" aria-label="다음 날짜">▶</button>'
|
||||||
f'<span class="net-chart-last"'
|
f'<span class="net-chart-last"'
|
||||||
+ (f' data-rt-nw-owner="{html.escape(rt_owner, quote=True)}" data-rt-nw-basenet="{rt_base_net}" data-rt-nw-baseval="{last_v}" data-rt-nw-pnl="{1 if is_pnl else 0}" data-rt-nw-live="{1 if has_live else 0}"'
|
+ (f' data-rt-nw-owner="{html.escape(rt_owner, quote=True)}" data-rt-nw-basenet="{rt_base_net}" data-rt-nw-baseval="{last_v}" data-rt-nw-pnl="{1 if is_pnl else 0}" data-rt-nw-live="{1 if has_live else 0}"'
|
||||||
|
f' data-rt-nw-ylo="{y_lo}" data-rt-nw-span="{span}" data-rt-nw-top="{T}" data-rt-nw-ih="{ih}" data-rt-nw-x="{last_x:.2f}"'
|
||||||
if (rt_owner is not None and rt_base_net is not None and has_live) else '')
|
if (rt_owner is not None and rt_base_net is not None and has_live) else '')
|
||||||
+ f'>{html.escape(last_label_v)}</span>'
|
+ f'>{html.escape(last_label_v)}</span>'
|
||||||
'</span>'
|
'</span>'
|
||||||
@@ -3953,6 +4026,11 @@ header.top {
|
|||||||
header.top .titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
header.top .titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
header.top h1 { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: -0.01em; color: #f0f0f0; animation: refresh-flash 0.8s ease-out; }
|
header.top h1 { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: -0.01em; color: #f0f0f0; animation: refresh-flash 0.8s ease-out; }
|
||||||
header.top .meta { font-size: 11px; color: #8b8f9a; font-variant-numeric: tabular-nums; animation: refresh-flash-meta 0.8s ease-out; }
|
header.top .meta { font-size: 11px; color: #8b8f9a; font-variant-numeric: tabular-nums; animation: refresh-flash-meta 0.8s ease-out; }
|
||||||
|
/* 실시간(WebSocket) 연결 상태 점 — 24px 탭영역 안에 10px 컬러 점. 초록=연결 / 빨강=끊김 / 회색=확인중 */
|
||||||
|
.ws-dot { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; flex: none; padding: 0; margin: 0; background: transparent; border: 0; cursor: pointer; }
|
||||||
|
.ws-dot::before { content: ''; width: 10px; height: 10px; border-radius: 50%; background: #5a5f6c; transition: background 0.3s, box-shadow 0.3s; }
|
||||||
|
.ws-dot.on::before { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.7); }
|
||||||
|
.ws-dot.off::before { background: #ff4d5e; box-shadow: 0 0 6px rgba(255,77,94,0.65); }
|
||||||
/* 페이지 로드(첫 진입·새로고침) 시 1회 발동. 탭 클릭(hashchange)은 reload 아니므로 발동 안 함. */
|
/* 페이지 로드(첫 진입·새로고침) 시 1회 발동. 탭 클릭(hashchange)은 reload 아니므로 발동 안 함. */
|
||||||
@keyframes refresh-flash {
|
@keyframes refresh-flash {
|
||||||
0%, 20% { color: #ff4d5e; }
|
0%, 20% { color: #ff4d5e; }
|
||||||
@@ -8186,6 +8264,7 @@ function showToast(text, kind, durationMs){
|
|||||||
if(__toastTimer) clearTimeout(__toastTimer);
|
if(__toastTimer) clearTimeout(__toastTimer);
|
||||||
__toastTimer = setTimeout(function(){ if(__toastEl) __toastEl.classList.remove('show'); }, durationMs || 3500);
|
__toastTimer = setTimeout(function(){ if(__toastEl) __toastEl.classList.remove('show'); }, durationMs || 3500);
|
||||||
}
|
}
|
||||||
|
window.showToast = showToast; // 다른 스크립트(실시간 상태 점 등)에서 재사용
|
||||||
function setMsg(text, kind){
|
function setMsg(text, kind){
|
||||||
// 모달 내 메시지 영역은 hide, 알림은 토스트로
|
// 모달 내 메시지 영역은 hide, 알림은 토스트로
|
||||||
var el = $('[data-order-msg]'); if(el) el.classList.add('hidden');
|
var el = $('[data-order-msg]'); if(el) el.classList.add('hidden');
|
||||||
@@ -8968,8 +9047,10 @@ window.openPinModal = openPinModal;
|
|||||||
function codesOnPage(){
|
function codesOnPage(){
|
||||||
var s={};
|
var s={};
|
||||||
document.querySelectorAll('details.row[data-row-key]').forEach(function(r){
|
document.querySelectorAll('details.row[data-row-key]').forEach(function(r){
|
||||||
var k=r.getAttribute('data-row-key')||'';
|
// ':held'·':held:계좌'·':pending-*' 접미사 제거 → 보유행 코드도 실시간/VI 대상에 포함.
|
||||||
if(/^\d{6}$/.test(k)) s[k]=1;
|
var k=(r.getAttribute('data-row-key')||'').split(':')[0];
|
||||||
|
// 숫자+영문 6자리 — 0193W0·0154F0 등 영문 섞인 ETF/ETN 코드까지 포함. 종목명(한글) 키는 자동 제외.
|
||||||
|
if(/^[0-9A-Z]{6}$/.test(k)) s[k]=1;
|
||||||
});
|
});
|
||||||
return Object.keys(s);
|
return Object.keys(s);
|
||||||
}
|
}
|
||||||
@@ -9036,13 +9117,33 @@ window.openPinModal = openPinModal;
|
|||||||
if(td) td.innerHTML='<span class="'+udcls(T.daypl)+'">'+sgn(T.daypl)+'원'+(T.dpct!==null?' ('+(T.dpct>=0?'+':'')+T.dpct.toFixed(2)+'%)':'')+'</span>';
|
if(td) td.innerHTML='<span class="'+udcls(T.daypl)+'">'+sgn(T.daypl)+'원'+(T.dpct!==null?' ('+(T.dpct>=0?'+':'')+T.dpct.toFixed(2)+'%)':'')+'</span>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// 순자산 차트 오늘(장중) 포인트 값 라벨 실시간 — 점 위치(SVG)는 새로고침 시, 값 라벨만 라이브.
|
// 순자산 차트 오늘(장중) 포인트 — 값 라벨 + SVG 점·마지막 선분을 1초마다 함께 이동(라벨↔점 불일치 제거).
|
||||||
document.querySelectorAll('.net-chart-last[data-rt-nw-owner="'+owner+'"][data-rt-nw-live="1"]').forEach(function(el){
|
document.querySelectorAll('.net-chart-last[data-rt-nw-owner="'+owner+'"][data-rt-nw-live="1"]').forEach(function(el){
|
||||||
var basenet=parseFloat(el.getAttribute('data-rt-nw-basenet')||'0');
|
var basenet=parseFloat(el.getAttribute('data-rt-nw-basenet')||'0');
|
||||||
var baseval=parseFloat(el.getAttribute('data-rt-nw-baseval')||'0');
|
var baseval=parseFloat(el.getAttribute('data-rt-nw-baseval')||'0');
|
||||||
var v=Math.round(baseval+(T.net-basenet)); // 순자산 변동분 = 손익 변동분(baseline·cf는 세션 중 고정)
|
var v=Math.round(baseval+(T.net-basenet)); // 순자산 변동분 = 손익 변동분(baseline·cf는 세션 중 고정)
|
||||||
var s=(el.getAttribute('data-rt-nw-pnl')==='1') ? ((v>=0?'+':'−')+Math.abs(v).toLocaleString()+'원') : (v.toLocaleString()+'원');
|
var s=(el.getAttribute('data-rt-nw-pnl')==='1') ? ((v>=0?'+':'−')+Math.abs(v).toLocaleString()+'원') : (v.toLocaleString()+'원');
|
||||||
el.textContent=s+' (장중)';
|
el.textContent=s+' (장중)';
|
||||||
|
// 점 위치도 같은 값으로 재계산 — 서버 렌더와 동일 스케일 y = top + (1 − (v−ylo)/span)·ih.
|
||||||
|
var ylo=parseFloat(el.getAttribute('data-rt-nw-ylo'));
|
||||||
|
var span=parseFloat(el.getAttribute('data-rt-nw-span'));
|
||||||
|
var top=parseFloat(el.getAttribute('data-rt-nw-top'));
|
||||||
|
var ih=parseFloat(el.getAttribute('data-rt-nw-ih'));
|
||||||
|
var lastx=parseFloat(el.getAttribute('data-rt-nw-x'));
|
||||||
|
if([ylo,span,top,ih,lastx].some(isNaN)) return;
|
||||||
|
var chart=el.closest('.net-chart'); if(!chart) return;
|
||||||
|
var svg=chart.querySelector('svg.net-chart-svg'); if(!svg) return;
|
||||||
|
var ny=top+(1-(v-ylo)/span)*ih;
|
||||||
|
ny=Math.max(top, Math.min(top+ih, ny)); // 렌더 당시 범위 밖이면 차트 가장자리로 클램프(다음 페치가 스케일 재계산)
|
||||||
|
var nys=ny.toFixed(2);
|
||||||
|
['.last-dot','.live-halo','.ath-ring'].forEach(function(sel){
|
||||||
|
var c=svg.querySelector(sel); if(c) c.setAttribute('cy', nys);
|
||||||
|
});
|
||||||
|
var al=svg.querySelector('.ath-label'); if(al) al.setAttribute('y', Math.max(ny-14, top+12).toFixed(2));
|
||||||
|
var seg=svg.querySelector('.line.dashed.live-seg'); // 마지막(확정점→라이브) 점선 끝점 y 교체
|
||||||
|
if(seg){ var d=seg.getAttribute('d')||''; seg.setAttribute('d', d.replace(/L\s*([\d.]+),[\d.]+\s*$/, 'L $1,'+nys)); }
|
||||||
|
var dots=svg.querySelectorAll('.pt-dot'); // 라이브 점과 같은 x의 pt-dot도 이동
|
||||||
|
if(dots.length){ var ld=dots[dots.length-1]; if(Math.abs(parseFloat(ld.getAttribute('cx'))-lastx)<0.5) ld.setAttribute('cy', nys); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// KPI 총합 — owner 탭 scope의 consolidated pane 행만 합산(중복합산 방지). 시세 미수신 종목은 data-rt-px(서버값) fallback.
|
// KPI 총합 — owner 탭 scope의 consolidated pane 행만 합산(중복합산 방지). 시세 미수신 종목은 data-rt-px(서버값) fallback.
|
||||||
@@ -9079,19 +9180,42 @@ window.openPinModal = openPinModal;
|
|||||||
setOwnerKpis(scope.getAttribute('data-rt-owner'), T);
|
setOwnerKpis(scope.getAttribute('data-rt-owner'), T);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 수신 상태를 화면에 페인팅 — 1초 틱과 패널 swap 직후 재적용이 공유.
|
||||||
|
function applyState(d){
|
||||||
|
if(!d || !d.connected) return;
|
||||||
|
var codes=codesOnPage();
|
||||||
|
var qs=d.quotes||{}, vis=d.vi||{}, px={};
|
||||||
|
Object.keys(qs).forEach(function(c){ px[c]=qs[c].price; paintPrice(c,qs[c]); paintProfit(c,qs[c].price); paintDay(c,qs[c].price); });
|
||||||
|
codes.forEach(function(c){ paintVI(c, !!(vis[c] && vis[c].active)); });
|
||||||
|
window.__rtPx=px;
|
||||||
|
updateScopes(px);
|
||||||
|
}
|
||||||
|
// 상단 WS 연결 상태 점 — 초록(on)/빨강(off)/회색(미정). tick 성공·실패마다 갱신.
|
||||||
|
function setWsStatus(ok){
|
||||||
|
window.__wsConnected = !!ok;
|
||||||
|
var el=document.getElementById('ws-status'); if(!el) return;
|
||||||
|
el.classList.toggle('on', !!ok);
|
||||||
|
el.classList.toggle('off', !ok);
|
||||||
|
}
|
||||||
|
// 점 탭 → 현재 연결 상태 토스트. 헤더는 패널 swap 대상이 아니라 한 번만 바인딩.
|
||||||
|
(function(){
|
||||||
|
var dot=document.getElementById('ws-status'); if(!dot) return;
|
||||||
|
dot.addEventListener('click',function(){
|
||||||
|
var ok=window.__wsConnected;
|
||||||
|
var msg=ok?'실시간 시세 연결됨 ✓ (WebSocket)':'실시간 시세 끊김 — REST 폴백 중';
|
||||||
|
if(window.showToast) window.showToast(msg, ok?'success':'info');
|
||||||
|
});
|
||||||
|
})();
|
||||||
function tick(){
|
function tick(){
|
||||||
if(document.hidden) return;
|
if(document.hidden) return;
|
||||||
var codes=codesOnPage(); if(!codes.length){ return; }
|
var codes=codesOnPage();
|
||||||
fetch('/api/realtime/quotes?codes='+codes.join(','))
|
fetch('/api/realtime/quotes?codes='+codes.join(','))
|
||||||
.then(function(r){ return r.json(); })
|
.then(function(r){ return r.json(); })
|
||||||
.then(function(d){
|
.then(function(d){
|
||||||
|
setWsStatus(d && d.connected);
|
||||||
if(!d || !d.connected) return;
|
if(!d || !d.connected) return;
|
||||||
window.__rtState = d;
|
window.__rtState = d;
|
||||||
var qs=d.quotes||{}, vis=d.vi||{}, px={};
|
if(codes.length) applyState(d);
|
||||||
Object.keys(qs).forEach(function(c){ px[c]=qs[c].price; paintPrice(c,qs[c]); paintProfit(c,qs[c].price); paintDay(c,qs[c].price); });
|
|
||||||
codes.forEach(function(c){ paintVI(c, !!(vis[c] && vis[c].active)); });
|
|
||||||
window.__rtPx=px;
|
|
||||||
updateScopes(px);
|
|
||||||
// 체결 신호 변화 → 패널 새로고침(수량·예수금·당일정산 등 사건 기반 값). 첫 관측은 기준값만 저장(공회전 방지).
|
// 체결 신호 변화 → 패널 새로고침(수량·예수금·당일정산 등 사건 기반 값). 첫 관측은 기준값만 저장(공회전 방지).
|
||||||
// 빈 문자열('')도 유효 기준으로 취급 — 신호파일이 비어있다 첫 체결로 채워질 때도 reload 되게.
|
// 빈 문자열('')도 유효 기준으로 취급 — 신호파일이 비어있다 첫 체결로 채워질 때도 reload 되게.
|
||||||
var fs=d.fill_seq||'';
|
var fs=d.fill_seq||'';
|
||||||
@@ -9100,8 +9224,11 @@ window.openPinModal = openPinModal;
|
|||||||
window.__rtFillSeq=fs;
|
window.__rtFillSeq=fs;
|
||||||
if(window.__behive_load){ window.__behive_load(window.__behive_active_owner?window.__behive_active_owner():null, true); }
|
if(window.__behive_load){ window.__behive_load(window.__behive_active_owner?window.__behive_active_owner():null, true); }
|
||||||
}
|
}
|
||||||
}).catch(function(){});
|
}).catch(function(){ setWsStatus(false); });
|
||||||
}
|
}
|
||||||
|
// 자동갱신 swap 직후 서버 HTML엔 VI 배지·실시간 가격이 빠져있다 → 다음 1초 틱 전까지 깜빡임.
|
||||||
|
// 마지막 수신 상태를 즉시 재적용해 배지가 사라졌다 나타나는 현상 제거.
|
||||||
|
document.addEventListener('behive:panels-loaded',function(){ if(window.__rtState) applyState(window.__rtState); });
|
||||||
setInterval(tick,1000); tick();
|
setInterval(tick,1000); tick();
|
||||||
})();</script>
|
})();</script>
|
||||||
<style>.rt-vi-badge{display:inline-block;margin-left:6px;padding:0 5px;border-radius:4px;font-size:10px;font-weight:700;color:#1a1205;background:#ffd166;vertical-align:middle}</style>'''
|
<style>.rt-vi-badge{display:inline-block;margin-left:6px;padding:0 5px;border-radius:4px;font-size:10px;font-weight:700;color:#1a1205;background:#ffd166;vertical-align:middle}</style>'''
|
||||||
@@ -9130,6 +9257,7 @@ window.openPinModal = openPinModal;
|
|||||||
<span class="meta">갱신 {now}</span>
|
<span class="meta">갱신 {now}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
|
<button type="button" id="ws-status" class="ws-dot" title="실시간 연결 상태 (탭하면 표시)" aria-label="실시간 연결 상태"></button>
|
||||||
<button type="button" class="account-view-toggle-btn" data-account-view-state="consolidated" aria-label="계좌 보기 방식 (합산 / 각계좌별도) 토글" title="합산 / 각계좌별도 토글">↔</button>
|
<button type="button" class="account-view-toggle-btn" data-account-view-state="consolidated" aria-label="계좌 보기 방식 (합산 / 각계좌별도) 토글" title="합산 / 각계좌별도 토글">↔</button>
|
||||||
<button id="auto-toggle" class="auto-toggle" type="button" aria-pressed="false" title="자동 갱신 사이클: off → 10s → 3s (자산정보·관리자·가희·관심종목 탭에서 동작, 페이지를 보고 있을 때만)"><span class="auto-dot"></span><span>자동</span></button>
|
<button id="auto-toggle" class="auto-toggle" type="button" aria-pressed="false" title="자동 갱신 사이클: off → 10s → 3s (자산정보·관리자·가희·관심종목 탭에서 동작, 페이지를 보고 있을 때만)"><span class="auto-dot"></span><span>자동</span></button>
|
||||||
<a class="refresh" href="#" onclick="event.preventDefault();window.__behive_manual_refresh&&window.__behive_manual_refresh();" aria-label="새로고침" title="새로고침">↻</a>
|
<a class="refresh" href="#" onclick="event.preventDefault();window.__behive_manual_refresh&&window.__behive_manual_refresh();" aria-label="새로고침" title="새로고침">↻</a>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class RealtimeHub:
|
|||||||
def __init__(self, account_label: str | None = None):
|
def __init__(self, account_label: str | None = None):
|
||||||
self._label = account_label or kc._default_account_label()
|
self._label = account_label or kc._default_account_label()
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._quotes: dict[str, dict] = {} # code -> {price, change, pct, ts, exchange}
|
self._quotes: dict[str, dict] = {} # code -> {price, change, pct, open, high, low, ts, exchange}
|
||||||
self._vi: dict[str, dict] = {} # code -> {active, kind, trigger_price, ...}
|
self._vi: dict[str, dict] = {} # code -> {active, kind, trigger_price, ...}
|
||||||
self._subs: set[str] = set() # 현재 구독 종목코드(정규화)
|
self._subs: set[str] = set() # 현재 구독 종목코드(정규화)
|
||||||
self._ws: websocket.WebSocket | None = None
|
self._ws: websocket.WebSocket | None = None
|
||||||
@@ -261,17 +261,23 @@ class RealtimeHub:
|
|||||||
return
|
return
|
||||||
self._ws.send(json.dumps(payload))
|
self._ws.send(json.dumps(payload))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _al(codes: list[str]) -> list[str]:
|
||||||
|
# 통합(SOR) 거래소코드 `_AL` 접미사 — 정규장 KRX 체결 + NXT 시간대 NXT 체결을
|
||||||
|
# 한 구독으로 수신. bare 코드는 KRX 단독이라 NXT(15:30~20:00)에 시세가 끊긴다. (PDF 0B/1h 스펙)
|
||||||
|
return [f'{c}_AL' for c in codes]
|
||||||
|
|
||||||
def _send_reg(self, codes: list[str]) -> None:
|
def _send_reg(self, codes: list[str]) -> None:
|
||||||
# 0B(체결) + 1h(VI) 동시 등록. refresh=1 → 기존 구독 유지하며 추가.
|
# 0B(체결) + 1h(VI) 동시 등록. refresh=1 → 기존 구독 유지하며 추가.
|
||||||
self._send({
|
self._send({
|
||||||
'trnm': 'REG', 'grp_no': '1', 'refresh': '1',
|
'trnm': 'REG', 'grp_no': '1', 'refresh': '1',
|
||||||
'data': [{'item': codes, 'type': ['0B', '1h']}],
|
'data': [{'item': self._al(codes), 'type': ['0B', '1h']}],
|
||||||
})
|
})
|
||||||
|
|
||||||
def _send_remove(self, codes: list[str]) -> None:
|
def _send_remove(self, codes: list[str]) -> None:
|
||||||
self._send({
|
self._send({
|
||||||
'trnm': 'REMOVE', 'grp_no': '1',
|
'trnm': 'REMOVE', 'grp_no': '1',
|
||||||
'data': [{'item': codes, 'type': ['0B', '1h']}],
|
'data': [{'item': self._al(codes), 'type': ['0B', '1h']}],
|
||||||
})
|
})
|
||||||
|
|
||||||
# ---- 수신 처리 ----
|
# ---- 수신 처리 ----
|
||||||
@@ -302,7 +308,9 @@ class RealtimeHub:
|
|||||||
|
|
||||||
def _handle_real(self, blk: dict) -> None:
|
def _handle_real(self, blk: dict) -> None:
|
||||||
typ = blk.get('type')
|
typ = blk.get('type')
|
||||||
code = kc._clean_code(blk.get('item') or '')
|
# 통합(_AL)·NXT(_NX) 구독은 item이 `039490_AL` 형태로 echo됨 → 거래소 접미사를 떼고
|
||||||
|
# bare 코드로 저장해야 get_quotes(bare) 매칭이 유지된다. 키움 종목코드엔 '_'가 없다.
|
||||||
|
code = kc._clean_code((blk.get('item') or '').split('_')[0])
|
||||||
vals = blk.get('values') or {}
|
vals = blk.get('values') or {}
|
||||||
if not code:
|
if not code:
|
||||||
return
|
return
|
||||||
@@ -312,6 +320,9 @@ class RealtimeHub:
|
|||||||
'price': _abs_int(vals.get('10')),
|
'price': _abs_int(vals.get('10')),
|
||||||
'change': kc._to_int(vals.get('11')),
|
'change': kc._to_int(vals.get('11')),
|
||||||
'pct': kc._to_float(vals.get('12')),
|
'pct': kc._to_float(vals.get('12')),
|
||||||
|
'open': _abs_int(vals.get('16')), # 시가
|
||||||
|
'high': _abs_int(vals.get('17')), # 고가
|
||||||
|
'low': _abs_int(vals.get('18')), # 저가
|
||||||
'exchange': (vals.get('9081') or '').strip(),
|
'exchange': (vals.get('9081') or '').strip(),
|
||||||
'ts': datetime.now(KST).isoformat(),
|
'ts': datetime.now(KST).isoformat(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,22 @@ from __future__ import annotations
|
|||||||
import math
|
import math
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from . import config, signals
|
from . import config, signals
|
||||||
|
|
||||||
|
|
||||||
|
def _cal_days(entry_date: str | None, cur_date: str) -> int | None:
|
||||||
|
"""두 'YYYYMMDD' 사이 달력 일수 — 시간손절용. 파싱 실패 시 None."""
|
||||||
|
if not entry_date:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
d0 = datetime.strptime(entry_date, '%Y%m%d')
|
||||||
|
d1 = datetime.strptime(cur_date, '%Y%m%d')
|
||||||
|
return max(0, (d1 - d0).days)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
sys.path.insert(0, str(config.SCRIPTS))
|
sys.path.insert(0, str(config.SCRIPTS))
|
||||||
import daily_candles_cache as dcc # noqa: E402
|
import daily_candles_cache as dcc # noqa: E402
|
||||||
|
|
||||||
@@ -111,7 +124,8 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to:
|
|||||||
dates_seen = [c['date'] for c in h[:i + 1]]
|
dates_seen = [c['date'] for c in h[:i + 1]]
|
||||||
flow = _flow_net_upto(flows.get(code), dates_seen)
|
flow = _flow_net_upto(flows.get(code), dates_seen)
|
||||||
pos = positions[code]
|
pos = positions[code]
|
||||||
dec = signals.evaluate_holding(pos, ind, flow, None)
|
dec = signals.evaluate_holding(pos, ind, flow, None,
|
||||||
|
held_days=_cal_days(pos.get('entry_date'), day))
|
||||||
pos.update(dec['position_update'])
|
pos.update(dec['position_update'])
|
||||||
act = dec['action']
|
act = dec['action']
|
||||||
if act in ('sell', 'scale_out'):
|
if act in ('sell', 'scale_out'):
|
||||||
@@ -170,18 +184,18 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to:
|
|||||||
dec = signals.evaluate_candidate(code, code, [], ind, flow, None, True)
|
dec = signals.evaluate_candidate(code, code, [], ind, flow, None, True)
|
||||||
if dec['action'] == 'buy':
|
if dec['action'] == 'buy':
|
||||||
fill = dec['buy_price']
|
fill = dec['buy_price']
|
||||||
|
stop, target = signals.compute_stop_target(fill, ind['atr'], ind['recent_low'])
|
||||||
eq = equity(day_prices)
|
eq = equity(day_prices)
|
||||||
tranche_val = (eq / config.MAX_POSITIONS) / max(1, config.ENTRY_TRANCHES)
|
tranche_val = signals.target_value(eq, fill, stop) / max(1, config.ENTRY_TRANCHES)
|
||||||
qty = _qty_for(fill, tranche_val)
|
qty = _qty_for(fill, tranche_val)
|
||||||
cost = qty * fill * (1 + comm)
|
cost = qty * fill * (1 + comm)
|
||||||
if qty < 1 or cost > cash:
|
if qty < 1 or cost > cash:
|
||||||
continue
|
continue
|
||||||
stop, target = signals.compute_stop_target(fill, ind['atr'], ind['recent_low'])
|
|
||||||
cash -= cost
|
cash -= cost
|
||||||
positions[code] = {'code': code, 'name': code, 'qty': qty, 'entry_price': fill,
|
positions[code] = {'code': code, 'name': code, 'qty': qty, 'entry_price': fill,
|
||||||
'stop': stop, 'target': target, 'peak': fill, 'trailing_on': False,
|
'stop': stop, 'target': target, 'peak': fill, 'trailing_on': False,
|
||||||
'scaled_out': False, 'tranches': 1, 'tranche_value': tranche_val,
|
'scaled_out': False, 'tranches': 1, 'tranche_value': tranche_val,
|
||||||
'last_add_price': fill}
|
'last_add_price': fill, 'entry_date': day}
|
||||||
|
|
||||||
eq = equity(day_prices)
|
eq = equity(day_prices)
|
||||||
if eq > peak_eq:
|
if eq > peak_eq:
|
||||||
|
|||||||
@@ -28,11 +28,15 @@ SELL_TAX_RATE = 0.0018 # 매도 증권거래세 (~0.18%)
|
|||||||
|
|
||||||
# ---- 포지션 사이징 ----
|
# ---- 포지션 사이징 ----
|
||||||
MAX_POSITIONS = 8 # 동시 보유 최대 종목수
|
MAX_POSITIONS = 8 # 동시 보유 최대 종목수
|
||||||
# 종목당 목표 비중 = 자산 / MAX_POSITIONS
|
# 종목당 목표 비중 = 자산 / MAX_POSITIONS (= 균등비중 상한)
|
||||||
|
RISK_PER_TRADE_PCT = 0.01 # 리스크 기반 사이징: 종목당 손실 위험 = 자산 × 이 값. 수량 = 자산×이값 ÷ (진입가−손절가), 균등비중 상한으로 캡. 0=끔(순수 균등비중)
|
||||||
|
|
||||||
# ---- 지표 기간 ----
|
# ---- 지표 기간 ----
|
||||||
SMA_SHORT = 5
|
SMA_SHORT = 5
|
||||||
SMA_LONG = 20
|
SMA_LONG = 20
|
||||||
|
SMA_MID = 60 # 중기 추세선 — 정배열 확인(5>20>60). 데이터 부족 시 게이트 미적용
|
||||||
|
TREND_SLOPE_DAYS = 5 # 20일선 기울기 판정: 현재 20일선 vs N일 전 20일선(우상향 여부)
|
||||||
|
REL_STRENGTH_DAYS = 20 # 상대강도: 종목 N일 수익률 − 시장지수 N일 수익률 (라이브 전용)
|
||||||
ATR_PERIOD = 14
|
ATR_PERIOD = 14
|
||||||
BREAKOUT_LOOKBACK = 20 # 돌파 판정용 최근 고점 구간
|
BREAKOUT_LOOKBACK = 20 # 돌파 판정용 최근 고점 구간
|
||||||
SWING_LOW_LOOKBACK = 10 # 손절선 후보 스윙 저점 구간
|
SWING_LOW_LOOKBACK = 10 # 손절선 후보 스윙 저점 구간
|
||||||
@@ -44,6 +48,9 @@ VOLUME_BREAKOUT_MULT = 1.5 # 돌파 매수: 거래량 ≥ 평균 × 이
|
|||||||
TURNOVER_BREAKOUT_MULT = 1.5 # 돌파 확인: 오늘 회전율 ≥ 20일 평균 회전율 × 이 배수 (데이터 없으면 통과)
|
TURNOVER_BREAKOUT_MULT = 1.5 # 돌파 확인: 오늘 회전율 ≥ 20일 평균 회전율 × 이 배수 (데이터 없으면 통과)
|
||||||
TURNOVER_OVERHEAT_MULT = 3.0 # 과열 보류: 오늘 회전율 ≥ 평균 × 이 배수면 신규매수 보류 (돌파 배수보다 높게)
|
TURNOVER_OVERHEAT_MULT = 3.0 # 과열 보류: 오늘 회전율 ≥ 평균 × 이 배수면 신규매수 보류 (돌파 배수보다 높게)
|
||||||
PULLBACK_ATR_MULT = 1.0 # 눌림목 지정가 = 전일종가 − ATR × 이 배수 (20일선과 비교 후 위쪽)
|
PULLBACK_ATR_MULT = 1.0 # 눌림목 지정가 = 전일종가 − ATR × 이 배수 (20일선과 비교 후 위쪽)
|
||||||
|
RSI_PERIOD = 14 # RSI 계산 기간 (고정 상수 — 튜닝 비노출)
|
||||||
|
RSI_OVERBOUGHT = 78.0 # 돌파 매수 시 RSI ≥ 이 값이면 과열 추격으로 보고 보류
|
||||||
|
RSI_OVERSOLD = 25.0 # 눌림목 매수 시 RSI < 이 값이면 낙하 중(떨어지는 칼)으로 보고 보류
|
||||||
|
|
||||||
# ---- 분할매수·추격매수 ----
|
# ---- 분할매수·추격매수 ----
|
||||||
ENTRY_TRANCHES = 2 # 종목당 목표비중을 이 횟수로 나눠 진입 (1=전량 1회). 1차 = 목표/이 값
|
ENTRY_TRANCHES = 2 # 종목당 목표비중을 이 횟수로 나눠 진입 (1=전량 1회). 1차 = 목표/이 값
|
||||||
@@ -55,6 +62,7 @@ RR_RATIO = 2.0 # 목표 = 진입가 + (진입가−손절) ×
|
|||||||
SCALE_OUT_FRAC = 0.0 # 분할매도: 목표 도달 시 보유량의 이 비율 익절 후 잔량 트레일링 (0=분할매도 끔, 목표 도달 시 전량 트레일링 전환)
|
SCALE_OUT_FRAC = 0.0 # 분할매도: 목표 도달 시 보유량의 이 비율 익절 후 잔량 트레일링 (0=분할매도 끔, 목표 도달 시 전량 트레일링 전환)
|
||||||
TRAIL_ATR_MULT = 1.5 # 트레일링: 고점 − ATR × 이 배수
|
TRAIL_ATR_MULT = 1.5 # 트레일링: 고점 − ATR × 이 배수
|
||||||
TRAIL_ATR_MULT_TIGHT = 1.0 # 컨센 목표가 부근에서 트레일링 강화
|
TRAIL_ATR_MULT_TIGHT = 1.0 # 컨센 목표가 부근에서 트레일링 강화
|
||||||
|
MAX_HOLD_DAYS = 20 # 시간손절: 보유 N일(달력) 경과 & 미진전(트레일링 전 + 진입가 이하)이면 청산해 자본 회전. 0=끔
|
||||||
|
|
||||||
# ---- 애널리스트 게이트 (약한 게이트) ----
|
# ---- 애널리스트 게이트 (약한 게이트) ----
|
||||||
ANALYST_MIN_UPSIDE_PCT = 5.0 # 컨센 목표주가 상승여력 < 이 값이면 신규매수 제외
|
ANALYST_MIN_UPSIDE_PCT = 5.0 # 컨센 목표주가 상승여력 < 이 값이면 신규매수 제외
|
||||||
@@ -62,6 +70,8 @@ ANALYST_SELL_OPINION_MAX = 2.5 # 투자의견(1매도~5매수) 평균이 이
|
|||||||
|
|
||||||
# ---- 시장 필터 (소프트) ----
|
# ---- 시장 필터 (소프트) ----
|
||||||
MARKET_BREADTH_MIN = 0.15 # 해당 시장 당일 상승비율 < 이 값이면 그 시장 신규매수 보류
|
MARKET_BREADTH_MIN = 0.15 # 해당 시장 당일 상승비율 < 이 값이면 그 시장 신규매수 보류
|
||||||
|
MARKET_BREADTH_STRONG = 0.60 # 상승비율 ≥ 이 값이면 지수가 20일선 아래여도 강세 인정 (지수추세 소프트 완화)
|
||||||
|
BEAR_ENTRY_MODE = 1 # 약세 국면(혼조·약세·급락) 신규매수: 0=완전 현금화(돌파도 보류) / 1=돌파만 허용 / 2=눌림목도 허용
|
||||||
|
|
||||||
# ---- 운영 시간 (KST) ----
|
# ---- 운영 시간 (KST) ----
|
||||||
MARKET_OPEN = (9, 0)
|
MARKET_OPEN = (9, 0)
|
||||||
@@ -87,15 +97,20 @@ TUNABLE = [
|
|||||||
{'key': 'PULLBACK_ATR_MULT', 'label': '눌림목 ATR 배수', 'type': 'float', 'min': 0.0, 'max': 3.0, 'step': 0.1, 'group': '매수'},
|
{'key': 'PULLBACK_ATR_MULT', 'label': '눌림목 ATR 배수', 'type': 'float', 'min': 0.0, 'max': 3.0, 'step': 0.1, 'group': '매수'},
|
||||||
{'key': 'ENTRY_TRANCHES', 'label': '분할매수 횟수', 'type': 'int', 'min': 1, 'max': 5, 'step': 1, 'group': '매수'},
|
{'key': 'ENTRY_TRANCHES', 'label': '분할매수 횟수', 'type': 'int', 'min': 1, 'max': 5, 'step': 1, 'group': '매수'},
|
||||||
{'key': 'ADD_ATR_MULT', 'label': '추격매수 ATR 배수', 'type': 'float', 'min': 0.3, 'max': 3.0, 'step': 0.1, 'group': '매수'},
|
{'key': 'ADD_ATR_MULT', 'label': '추격매수 ATR 배수', 'type': 'float', 'min': 0.3, 'max': 3.0, 'step': 0.1, 'group': '매수'},
|
||||||
|
{'key': 'RSI_OVERBOUGHT', 'label': 'RSI 과열 컷(돌파)', 'type': 'float', 'min': 50.0, 'max': 95.0, 'step': 1.0, 'group': '매수'},
|
||||||
|
{'key': 'RSI_OVERSOLD', 'label': 'RSI 과매도 컷(눌림목)', 'type': 'float', 'min': 5.0, 'max': 50.0, 'step': 1.0, 'group': '매수'},
|
||||||
{'key': 'STOP_ATR_MULT', 'label': '손절 ATR 배수', 'type': 'float', 'min': 0.5, 'max': 5.0, 'step': 0.1, 'group': '매도'},
|
{'key': 'STOP_ATR_MULT', 'label': '손절 ATR 배수', 'type': 'float', 'min': 0.5, 'max': 5.0, 'step': 0.1, 'group': '매도'},
|
||||||
{'key': 'RR_RATIO', 'label': '손익비(목표/손절)', 'type': 'float', 'min': 1.0, 'max': 5.0, 'step': 0.1, 'group': '매도'},
|
{'key': 'RR_RATIO', 'label': '손익비(목표/손절)', 'type': 'float', 'min': 1.0, 'max': 5.0, 'step': 0.1, 'group': '매도'},
|
||||||
{'key': 'SCALE_OUT_FRAC', 'label': '분할매도 익절 비율', 'type': 'float', 'min': 0.0, 'max': 1.0, 'step': 0.05, 'group': '매도'},
|
{'key': 'SCALE_OUT_FRAC', 'label': '분할매도 익절 비율', 'type': 'float', 'min': 0.0, 'max': 1.0, 'step': 0.05, 'group': '매도'},
|
||||||
{'key': 'TRAIL_ATR_MULT', 'label': '트레일링 ATR 배수', 'type': 'float', 'min': 0.5, 'max': 5.0, 'step': 0.1, 'group': '매도'},
|
{'key': 'TRAIL_ATR_MULT', 'label': '트레일링 ATR 배수', 'type': 'float', 'min': 0.5, 'max': 5.0, 'step': 0.1, 'group': '매도'},
|
||||||
{'key': 'TRAIL_ATR_MULT_TIGHT', 'label': '트레일링 강화 배수', 'type': 'float', 'min': 0.3, 'max': 3.0, 'step': 0.1, 'group': '매도'},
|
{'key': 'TRAIL_ATR_MULT_TIGHT', 'label': '트레일링 강화 배수', 'type': 'float', 'min': 0.3, 'max': 3.0, 'step': 0.1, 'group': '매도'},
|
||||||
|
{'key': 'MAX_HOLD_DAYS', 'label': '시간손절(보유 최대 일)', 'type': 'int', 'min': 0, 'max': 120, 'step': 1, 'group': '매도'},
|
||||||
{'key': 'ANALYST_MIN_UPSIDE_PCT', 'label': '최소 상승여력(%)', 'type': 'float', 'min': -50.0, 'max': 100.0, 'step': 1.0, 'group': '애널·시장'},
|
{'key': 'ANALYST_MIN_UPSIDE_PCT', 'label': '최소 상승여력(%)', 'type': 'float', 'min': -50.0, 'max': 100.0, 'step': 1.0, 'group': '애널·시장'},
|
||||||
{'key': 'ANALYST_SELL_OPINION_MAX', 'label': '매도의견 컷(1~5)', 'type': 'float', 'min': 1.0, 'max': 5.0, 'step': 0.1, 'group': '애널·시장'},
|
{'key': 'ANALYST_SELL_OPINION_MAX', 'label': '매도의견 컷(1~5)', 'type': 'float', 'min': 1.0, 'max': 5.0, 'step': 0.1, 'group': '애널·시장'},
|
||||||
{'key': 'MARKET_BREADTH_MIN', 'label': '시장 상승비율 하한', 'type': 'float', 'min': 0.0, 'max': 1.0, 'step': 0.01, 'group': '애널·시장'},
|
{'key': 'MARKET_BREADTH_MIN', 'label': '시장 상승비율 하한', 'type': 'float', 'min': 0.0, 'max': 1.0, 'step': 0.01, 'group': '애널·시장'},
|
||||||
|
{'key': 'BEAR_ENTRY_MODE', 'label': '약세장 매수(0현금/1돌파/2눌림)', 'type': 'int', 'min': 0, 'max': 2, 'step': 1, 'group': '애널·시장'},
|
||||||
{'key': 'MAX_POSITIONS', 'label': '동시 보유 최대 종목', 'type': 'int', 'min': 1, 'max': 20, 'step': 1, 'group': '포지션'},
|
{'key': 'MAX_POSITIONS', 'label': '동시 보유 최대 종목', 'type': 'int', 'min': 1, 'max': 20, 'step': 1, 'group': '포지션'},
|
||||||
|
{'key': 'RISK_PER_TRADE_PCT', 'label': '종목당 리스크 비율', 'type': 'float', 'min': 0.0, 'max': 0.1, 'step': 0.005, 'group': '포지션'},
|
||||||
]
|
]
|
||||||
|
|
||||||
# 오버라이드 적용 전 원본 기본값 캡처 (튜닝 화면의 '기본값' 표시·복원용)
|
# 오버라이드 적용 전 원본 기본값 캡처 (튜닝 화면의 '기본값' 표시·복원용)
|
||||||
|
|||||||
@@ -7,12 +7,94 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from . import config, data, signals, universe
|
from . import benchmark, config, data, signals, universe
|
||||||
from .portfolio import Portfolio
|
from .portfolio import Portfolio
|
||||||
|
|
||||||
|
|
||||||
|
def _held_days(position, now) -> int | None:
|
||||||
|
"""보유 경과 일수(달력) — entry_at 기준. 시간손절 판단용. 파싱 실패 시 None."""
|
||||||
|
ts = position.get('entry_at')
|
||||||
|
if not ts:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return max(0, (now - datetime.fromisoformat(ts)).days)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _index_trend_ok() -> dict:
|
||||||
|
"""지수별 추세 — 지수 종가가 자기 SMA_LONG 위면 True. 데이터 부족 시 True(비차단)."""
|
||||||
|
out = {}
|
||||||
|
for mk in ('KOSPI', 'KOSDAQ'):
|
||||||
|
store = benchmark.series(mk)
|
||||||
|
if not store:
|
||||||
|
continue
|
||||||
|
closes = [v for _, v in sorted(store.items())]
|
||||||
|
if len(closes) < config.SMA_LONG + 1:
|
||||||
|
out[mk] = True
|
||||||
|
continue
|
||||||
|
sma = sum(closes[-config.SMA_LONG:]) / config.SMA_LONG
|
||||||
|
out[mk] = closes[-1] >= sma
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _index_return(mk: str, days: int):
|
||||||
|
"""지수 N거래일 수익률(%) — 상대강도 비교용. 데이터 부족 시 None."""
|
||||||
|
s = sorted(benchmark.series(mk).items())
|
||||||
|
if len(s) > days and s[-(days + 1)][1]:
|
||||||
|
return (s[-1][1] / s[-(days + 1)][1] - 1) * 100
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _index_daily_change() -> dict:
|
||||||
|
"""지수별 당일 등락률(%) — 전일 종가 대비. 데이터 부족 시 키 없음."""
|
||||||
|
out = {}
|
||||||
|
for mk in ('KOSPI', 'KOSDAQ'):
|
||||||
|
s = sorted(benchmark.series(mk).items())
|
||||||
|
if len(s) >= 2 and s[-2][1]:
|
||||||
|
out[mk] = (s[-1][1] / s[-2][1] - 1) * 100
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# 시장 국면 6단계 — (이모지, 라벨, 매수허용, 설명, 색상). market_ok·시장탭 배지 공용.
|
||||||
|
REGIME = {
|
||||||
|
'strong': ('🔥', '강세', True, '상승 종목 많고 지수도 20일선 위 — 추세 양호', '#16a34a'),
|
||||||
|
'rebound': ('📈', '반등', True, '상승 종목 많지만 지수는 아직 20일선 아래 — 약세 탈출 시도', '#22c55e'),
|
||||||
|
'mild': ('🌤', '완만 상승', True, '상승이 우위지만 강하진 않음', '#65a30d'),
|
||||||
|
'mixed': ('➖', '혼조', False, '상승·하락 비슷 — 방향 불명, 신규매수 보류', '#6b7280'),
|
||||||
|
'weak': ('📉', '약세', False, '하락 종목 우위 — 신규매수 보류', '#ef4444'),
|
||||||
|
'crash': ('❄️', '급락', False, '대부분 하락 + 지수 급락 — 신규매수 보류', '#b91c1c'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def market_regime_map() -> dict:
|
||||||
|
"""시장별 국면(6단계) — 상승비율(폭) + 지수추세(방향) + 당일등락(강도) 조합.
|
||||||
|
상승비율 항상 최신(네이버, 실패 시 EOD). market_ok·시장탭 배지가 공용으로 사용."""
|
||||||
|
breadth = _live_breadth()
|
||||||
|
if breadth is None:
|
||||||
|
breadth = _eod_breadth()
|
||||||
|
chg = _index_daily_change()
|
||||||
|
trend = _index_trend_ok()
|
||||||
|
out = {}
|
||||||
|
for mk, v in breadth.items():
|
||||||
|
c = chg.get(mk)
|
||||||
|
up = trend.get(mk, True)
|
||||||
|
if v < config.MARKET_BREADTH_MIN: # <15%
|
||||||
|
out[mk] = 'crash' if (c is not None and c <= -2.0) else 'weak'
|
||||||
|
elif v >= config.MARKET_BREADTH_STRONG: # ≥60%
|
||||||
|
out[mk] = 'strong' if up else 'rebound'
|
||||||
|
elif v >= 0.40:
|
||||||
|
out[mk] = 'mild'
|
||||||
|
elif v >= 0.25:
|
||||||
|
out[mk] = 'mixed'
|
||||||
|
else:
|
||||||
|
out[mk] = 'weak'
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _load_jsonl_last_per_market(path) -> dict[str, dict]:
|
def _load_jsonl_last_per_market(path) -> dict[str, dict]:
|
||||||
out: dict[str, dict] = {}
|
out: dict[str, dict] = {}
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@@ -28,22 +110,52 @@ def _load_jsonl_last_per_market(path) -> dict[str, dict]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def market_ok_map() -> dict[str, bool]:
|
_LIVE_BREADTH_CACHE: dict[str, float] | None = None
|
||||||
"""시장별 신규매수 허용 여부 — 당일 상승비율 기반 소프트 필터 (EOD 데이터라 보수적)."""
|
_LIVE_BREADTH_AT: float = 0.0
|
||||||
|
_LIVE_BREADTH_TTL = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def _live_breadth() -> dict[str, float] | None:
|
||||||
|
"""장중 실시간 상승비율(rise/total) — 네이버 m.stock 1콜/시장. 60s 캐시, 실패 시 None."""
|
||||||
|
global _LIVE_BREADTH_CACHE, _LIVE_BREADTH_AT
|
||||||
|
now = time.time()
|
||||||
|
if _LIVE_BREADTH_CACHE is not None and (now - _LIVE_BREADTH_AT) < _LIVE_BREADTH_TTL:
|
||||||
|
return _LIVE_BREADTH_CACHE
|
||||||
|
try:
|
||||||
|
sys.path.insert(0, str(config.SCRIPTS))
|
||||||
|
import market_indicators_sync as mis
|
||||||
|
out: dict[str, float] = {}
|
||||||
|
for sym, lab in (('KOSPI', '코스피'), ('KOSDAQ', '코스닥')):
|
||||||
|
r = mis.fetch_market(sym, lab)
|
||||||
|
total = (r.get('rise', 0) + r.get('fall', 0) + r.get('steady', 0)) if r else 0
|
||||||
|
if not total:
|
||||||
|
return None
|
||||||
|
out[sym] = r['rise'] / total
|
||||||
|
_LIVE_BREADTH_CACHE, _LIVE_BREADTH_AT = out, now
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _eod_breadth() -> dict[str, float]:
|
||||||
|
"""장외 폴백 — 마지막 EOD 누적 상승비율."""
|
||||||
latest = _load_jsonl_last_per_market(config.MARKET_HISTORY)
|
latest = _load_jsonl_last_per_market(config.MARKET_HISTORY)
|
||||||
out: dict[str, bool] = {}
|
out: dict[str, float] = {}
|
||||||
for label, rec in latest.items():
|
for label, rec in latest.items():
|
||||||
rise = rec.get('rise', 0)
|
total = rec.get('rise', 0) + rec.get('fall', 0) + rec.get('steady', 0)
|
||||||
fall = rec.get('fall', 0)
|
breadth = rec.get('rise', 0) / total if total else 1.0
|
||||||
steady = rec.get('steady', 0)
|
|
||||||
total = rise + fall + steady
|
|
||||||
breadth = rise / total if total else 1.0
|
|
||||||
key = 'KOSPI' if '코스피' in (rec.get('market_label') or '') or label == 'KOSPI' else \
|
key = 'KOSPI' if '코스피' in (rec.get('market_label') or '') or label == 'KOSPI' else \
|
||||||
'KOSDAQ' if '코스닥' in (rec.get('market_label') or '') or label == 'KOSDAQ' else label
|
'KOSDAQ' if '코스닥' in (rec.get('market_label') or '') or label == 'KOSDAQ' else label
|
||||||
out[key] = breadth >= config.MARKET_BREADTH_MIN
|
out[key] = breadth
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def market_ok_map() -> dict[str, bool]:
|
||||||
|
"""시장별 신규매수 허용 여부 — 6단계 국면(market_regime_map) 기반.
|
||||||
|
강세·반등·완만상승 = 매수 허용 / 혼조·약세·급락 = 보류."""
|
||||||
|
return {mk: REGIME[g][2] for mk, g in market_regime_map().items()}
|
||||||
|
|
||||||
|
|
||||||
def _is_holiday(d: datetime) -> bool:
|
def _is_holiday(d: datetime) -> bool:
|
||||||
try:
|
try:
|
||||||
hol = json.loads(config.HOLIDAYS_PATH.read_text())
|
hol = json.loads(config.HOLIDAYS_PATH.read_text())
|
||||||
@@ -101,6 +213,7 @@ def _gather(extra_codes=None):
|
|||||||
def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
|
def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
|
||||||
"""주어진 포트폴리오에 대해 현재 config(파라미터) 기준 스캔·체결. 스냅샷 반환(파일 기록 X)."""
|
"""주어진 포트폴리오에 대해 현재 config(파라미터) 기준 스캔·체결. 스냅샷 반환(파일 기록 X)."""
|
||||||
mkt_ok = market_ok_map() # MARKET_BREADTH_MIN 등 파라미터 의존 → 변이마다 재계산
|
mkt_ok = market_ok_map() # MARKET_BREADTH_MIN 등 파라미터 의존 → 변이마다 재계산
|
||||||
|
idx_ret = {mk: _index_return(mk, config.REL_STRENGTH_DAYS) for mk in ('KOSPI', 'KOSDAQ')}
|
||||||
decisions: list[dict] = []
|
decisions: list[dict] = []
|
||||||
buys: list[dict] = []
|
buys: list[dict] = []
|
||||||
sells: list[dict] = []
|
sells: list[dict] = []
|
||||||
@@ -125,7 +238,8 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
|
|||||||
pf.mark(code, cur_price)
|
pf.mark(code, cur_price)
|
||||||
flow = data.flow_net(data.investor_flow(code))
|
flow = data.flow_net(data.investor_flow(code))
|
||||||
anl = data.analyst(code, cur_price)
|
anl = data.analyst(code, cur_price)
|
||||||
dec = signals.evaluate_holding(pf.positions[code], ind, flow, anl)
|
dec = signals.evaluate_holding(pf.positions[code], ind, flow, anl,
|
||||||
|
held_days=_held_days(pf.positions[code], now))
|
||||||
pf.apply_position_update(code, dec['position_update'])
|
pf.apply_position_update(code, dec['position_update'])
|
||||||
dec.update({k: pf.positions[code].get(k) for k in
|
dec.update({k: pf.positions[code].get(k) for k in
|
||||||
('entry_price', 'qty', 'stop', 'target', 'trailing_on', 'entry_at')})
|
('entry_price', 'qty', 'stop', 'target', 'trailing_on', 'entry_at')})
|
||||||
@@ -154,6 +268,13 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# ---- 미보유: 추세 스크린 → 수급·애널 → 매수 판단 ----
|
# ---- 미보유: 추세 스크린 → 수급·애널 → 매수 판단 ----
|
||||||
|
if universe.is_etf(name):
|
||||||
|
decisions.append({
|
||||||
|
'code': code, 'name': name, 'sources': sources, 'price': cur_price,
|
||||||
|
'state': 'SKIP', 'reason': 'ETF — 자동매매 제외(방향성 애널 부재)', 'action': None,
|
||||||
|
'checks': [],
|
||||||
|
})
|
||||||
|
continue
|
||||||
if not signals.trend_ok(ind):
|
if not signals.trend_ok(ind):
|
||||||
decisions.append({
|
decisions.append({
|
||||||
'code': code, 'name': name, 'sources': sources, 'price': cur_price,
|
'code': code, 'name': name, 'sources': sources, 'price': cur_price,
|
||||||
@@ -171,6 +292,12 @@ def _run_scan(pf, uni, quotes, cmkt, now) -> dict:
|
|||||||
anl = data.analyst(code, cur_price)
|
anl = data.analyst(code, cur_price)
|
||||||
market = cmkt.get(code, '')
|
market = cmkt.get(code, '')
|
||||||
m_ok = mkt_ok.get(market, True)
|
m_ok = mkt_ok.get(market, True)
|
||||||
|
# 상대강도(라이브 전용): 종목 N일 수익률 − 시장지수 N일 수익률
|
||||||
|
n = config.REL_STRENGTH_DAYS
|
||||||
|
closes_c = [c['close'] for c in series]
|
||||||
|
if len(closes_c) > n and closes_c[-(n + 1)] and idx_ret.get(market) is not None:
|
||||||
|
stock_ret = (closes_c[-1] / closes_c[-(n + 1)] - 1) * 100
|
||||||
|
ind['rel_strength'] = round(stock_ret - idx_ret[market], 2)
|
||||||
dec = signals.evaluate_candidate(code, name, sources, ind, flow, anl, m_ok)
|
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.can_open():
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from . import config
|
from . import config, signals
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
@@ -83,7 +83,7 @@ class Portfolio:
|
|||||||
"""신규 진입(1차 트랜치). 목표비중을 ENTRY_TRANCHES 로 나눈 만큼만 매수. 체결 dict 또는 None."""
|
"""신규 진입(1차 트랜치). 목표비중을 ENTRY_TRANCHES 로 나눈 만큼만 매수. 체결 dict 또는 None."""
|
||||||
if code in self.positions or not self.can_open():
|
if code in self.positions or not self.can_open():
|
||||||
return None
|
return None
|
||||||
full_target = self.target_position_value()
|
full_target = signals.target_value(self.equity_estimate(), fill_price, stop)
|
||||||
tranche_val = full_target / max(1, config.ENTRY_TRANCHES)
|
tranche_val = full_target / max(1, config.ENTRY_TRANCHES)
|
||||||
qty = self._qty_for(fill_price, tranche_val)
|
qty = self._qty_for(fill_price, tranche_val)
|
||||||
if qty < 1:
|
if qty < 1:
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ def compute_indicators(series: list[dict]) -> dict | None:
|
|||||||
atr = ind_mod.atr(series, config.ATR_PERIOD)
|
atr = ind_mod.atr(series, config.ATR_PERIOD)
|
||||||
if sma_long is None or sma_short is None or atr is None:
|
if sma_long is None or sma_short is None or atr is None:
|
||||||
return None
|
return None
|
||||||
|
# 중기선(60) — 데이터 부족 시 None
|
||||||
|
sma_mid = ind_mod.sma(closes, config.SMA_MID)
|
||||||
|
# 20일선 기울기(우상향?) — N일 전 20일선과 비교. 데이터 부족 시 None
|
||||||
|
slope_days = config.TREND_SLOPE_DAYS
|
||||||
|
sma_long_prev = (ind_mod.sma(closes[:-slope_days], config.SMA_LONG)
|
||||||
|
if len(closes) > config.SMA_LONG + slope_days else None)
|
||||||
|
sma_long_slope_up = (sma_long > sma_long_prev) if sma_long_prev is not None else None
|
||||||
|
|
||||||
# 회전율 — 오늘 봉(라이브 시세)엔 turnover_rate 없음 → 최근 봉의 거래량/회전율로 유통주식 추정해 환산
|
# 회전율 — 오늘 봉(라이브 시세)엔 turnover_rate 없음 → 최근 봉의 거래량/회전율로 유통주식 추정해 환산
|
||||||
hist = series[:-1]
|
hist = series[:-1]
|
||||||
@@ -38,7 +45,11 @@ def compute_indicators(series: list[dict]) -> dict | None:
|
|||||||
'today_volume': series[-1]['volume'],
|
'today_volume': series[-1]['volume'],
|
||||||
'sma_short': sma_short,
|
'sma_short': sma_short,
|
||||||
'sma_long': sma_long,
|
'sma_long': sma_long,
|
||||||
|
'sma_mid': sma_mid,
|
||||||
|
'sma_long_slope_up': sma_long_slope_up,
|
||||||
|
'rel_strength': None, # 라이브 engine이 주입(종목−지수 N일 수익률). backtest는 None(중립)
|
||||||
'atr': atr,
|
'atr': atr,
|
||||||
|
'rsi': ind_mod.rsi(closes, config.RSI_PERIOD),
|
||||||
'recent_high': ind_mod.recent_high(series, config.BREAKOUT_LOOKBACK),
|
'recent_high': ind_mod.recent_high(series, config.BREAKOUT_LOOKBACK),
|
||||||
'recent_low': ind_mod.recent_low(series, config.SWING_LOW_LOOKBACK),
|
'recent_low': ind_mod.recent_low(series, config.SWING_LOW_LOOKBACK),
|
||||||
'volume_avg': ind_mod.volume_avg(series, config.VOLUME_AVG_PERIOD),
|
'volume_avg': ind_mod.volume_avg(series, config.VOLUME_AVG_PERIOD),
|
||||||
@@ -47,9 +58,28 @@ def compute_indicators(series: list[dict]) -> dict | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def target_value(equity: float, fill_price: float, stop: float) -> float:
|
||||||
|
"""종목당 목표 진입금액 — 리스크 기반(자산×RISK% ÷ 손절폭)을 균등비중(자산/MAX_POSITIONS) 상한으로 캡.
|
||||||
|
RISK_PER_TRADE_PCT=0 또는 손절 정보 없으면 균등비중으로 폴백. 엔진·백테스트 공용(같은 두뇌)."""
|
||||||
|
cap = equity / max(1, config.MAX_POSITIONS)
|
||||||
|
risk_pct = getattr(config, 'RISK_PER_TRADE_PCT', 0) or 0
|
||||||
|
if risk_pct > 0 and stop and fill_price > stop:
|
||||||
|
risk_value = (equity * risk_pct) / (fill_price - stop) * fill_price
|
||||||
|
return min(cap, risk_value)
|
||||||
|
return cap
|
||||||
|
|
||||||
|
|
||||||
def trend_ok(ind: dict) -> bool:
|
def trend_ok(ind: dict) -> bool:
|
||||||
"""추세 1차 스크린 — 비싼 수급·애널 호출 전 게이트."""
|
"""추세 1차 스크린 — 비싼 수급·애널 호출 전 게이트.
|
||||||
return ind['price'] > ind['sma_long'] and ind['sma_short'] > ind['sma_long']
|
기본: 현재가>20일선 & 정배열(5>20). 보강: ①20일선 우상향 ②5>중기선(60) — 데이터 있을 때만."""
|
||||||
|
if not (ind['price'] > ind['sma_long'] and ind['sma_short'] > ind['sma_long']):
|
||||||
|
return False
|
||||||
|
if ind.get('sma_long_slope_up') is False: # ① 20일선이 꺾여 내려오면 제외
|
||||||
|
return False
|
||||||
|
sm = ind.get('sma_mid')
|
||||||
|
if sm is not None and ind['sma_short'] <= sm: # ② 단기선이 중기선 아래면(역배열) 제외
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def compute_stop_target(entry: float, atr: float, recent_low: float | None) -> tuple[int, int]:
|
def compute_stop_target(entry: float, atr: float, recent_low: float | None) -> tuple[int, int]:
|
||||||
@@ -102,6 +132,19 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di
|
|||||||
'detail': f"{price:,} vs {ind['sma_long']:,.0f}"})
|
'detail': f"{price:,} vs {ind['sma_long']:,.0f}"})
|
||||||
checks.append({'label': '정배열(5>20)', 'ok': ind['sma_short'] > ind['sma_long'],
|
checks.append({'label': '정배열(5>20)', 'ok': ind['sma_short'] > ind['sma_long'],
|
||||||
'detail': f"{ind['sma_short']:,.0f} / {ind['sma_long']:,.0f}"})
|
'detail': f"{ind['sma_short']:,.0f} / {ind['sma_long']:,.0f}"})
|
||||||
|
# 1-b. 추세 질 보강 — 기울기 / 중기선 / 상대강도 (데이터 없으면 통과 표시)
|
||||||
|
slope = ind.get('sma_long_slope_up')
|
||||||
|
checks.append({'label': '추세 기울기(20일선↑)',
|
||||||
|
'ok': None if slope is None else slope,
|
||||||
|
'detail': '데이터 없음(통과)' if slope is None else ('우상향' if slope else '하향')})
|
||||||
|
sm = ind.get('sma_mid')
|
||||||
|
checks.append({'label': '중기 정배열(5>60)',
|
||||||
|
'ok': None if sm is None else ind['sma_short'] > sm,
|
||||||
|
'detail': '데이터 없음(통과)' if sm is None else f"{ind['sma_short']:,.0f} / {sm:,.0f}"})
|
||||||
|
rel = ind.get('rel_strength')
|
||||||
|
checks.append({'label': '상대강도(시장대비)',
|
||||||
|
'ok': None if rel is None else rel > 0,
|
||||||
|
'detail': '데이터 없음(통과)' if rel is None else f'{rel:+.1f}%p'})
|
||||||
|
|
||||||
# 2. 수급
|
# 2. 수급
|
||||||
fnet = flow.get('foreign', 0) if flow else 0
|
fnet = flow.get('foreign', 0) if flow else 0
|
||||||
@@ -114,14 +157,16 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di
|
|||||||
gate_ok, acks, bonus = _analyst_checks(analyst, price)
|
gate_ok, acks, bonus = _analyst_checks(analyst, price)
|
||||||
checks.extend(acks)
|
checks.extend(acks)
|
||||||
|
|
||||||
|
# 시장 강약 — 소프트 필터(차단 X). 약세장이면 눌림목 진입만 보류, 돌파 진입은 허용
|
||||||
|
checks.append({'label': '시장 강세', 'ok': market_ok,
|
||||||
|
'detail': '강세' if market_ok else '약세 — 돌파만 허용'})
|
||||||
|
|
||||||
base = {'code': code, 'name': name, 'sources': sources, 'price': price,
|
base = {'code': code, 'name': name, 'sources': sources, 'price': price,
|
||||||
'checks': checks, 'action': None, 'buy_path': None, 'buy_price': None,
|
'checks': checks, 'action': None, 'buy_path': None, 'buy_price': None,
|
||||||
'plan_stop': None, 'plan_target': None, 'bonus': bonus}
|
'plan_stop': None, 'plan_target': None, 'bonus': bonus}
|
||||||
|
|
||||||
if not gate_ok:
|
if not gate_ok:
|
||||||
return {**base, 'state': 'SKIP', 'reason': '애널 게이트 제외'}
|
return {**base, 'state': 'SKIP', 'reason': '애널 게이트 제외'}
|
||||||
if not market_ok:
|
|
||||||
return {**base, 'state': 'WAIT', 'reason': '시장 약세 — 신규매수 보류'}
|
|
||||||
if not supply_ok:
|
if not supply_ok:
|
||||||
return {**base, 'state': 'WAIT', 'reason': '수급 미충족'}
|
return {**base, 'state': 'WAIT', 'reason': '수급 미충족'}
|
||||||
|
|
||||||
@@ -151,7 +196,21 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di
|
|||||||
else:
|
else:
|
||||||
checks.append({'label': '회전율', 'ok': None, 'detail': '데이터 없음(통과)'})
|
checks.append({'label': '회전율', 'ok': None, 'detail': '데이터 없음(통과)'})
|
||||||
|
|
||||||
|
# RSI — 돌파 과열 추격 / 눌림목 낙하 진입 방지 (데이터 없으면 통과)
|
||||||
|
rsi = ind.get('rsi')
|
||||||
|
rsi_overbought = rsi is not None and rsi >= config.RSI_OVERBOUGHT
|
||||||
|
rsi_oversold = rsi is not None and rsi < config.RSI_OVERSOLD
|
||||||
|
checks.append({'label': 'RSI',
|
||||||
|
'ok': None if rsi is None else not (rsi_overbought or rsi_oversold),
|
||||||
|
'detail': '데이터 없음(통과)' if rsi is None else f'{rsi:.0f}'})
|
||||||
|
|
||||||
if breakout:
|
if breakout:
|
||||||
|
if not market_ok and config.BEAR_ENTRY_MODE == 0:
|
||||||
|
return {**base, 'state': 'WAIT',
|
||||||
|
'reason': '약세장 — 완전 현금화(돌파도 보류)'}
|
||||||
|
if rsi_overbought:
|
||||||
|
return {**base, 'state': 'WAIT',
|
||||||
|
'reason': f'돌파했지만 RSI 과열({rsi:.0f} ≥ {config.RSI_OVERBOUGHT:g}) — 추격 보류'}
|
||||||
return {**base, 'state': 'BUY', 'action': 'buy', 'buy_path': 'breakout',
|
return {**base, 'state': 'BUY', 'action': 'buy', 'buy_path': 'breakout',
|
||||||
'buy_price': price, 'reason': '거래량·회전율 동반 전고점 돌파'}
|
'buy_price': price, 'reason': '거래량·회전율 동반 전고점 돌파'}
|
||||||
|
|
||||||
@@ -162,6 +221,15 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di
|
|||||||
checks.append({'label': '눌림목 도달', 'ok': pulled,
|
checks.append({'label': '눌림목 도달', 'ok': pulled,
|
||||||
'detail': f'지정가 {limit:,} (현재 {price:,})'})
|
'detail': f'지정가 {limit:,} (현재 {price:,})'})
|
||||||
if pulled:
|
if pulled:
|
||||||
|
if not market_ok and config.BEAR_ENTRY_MODE != 2:
|
||||||
|
# 약세장에선 바닥 잡기식 눌림목 진입 보류 (모드 2=눌림목 허용이면 통과)
|
||||||
|
mode_msg = '완전 현금화' if config.BEAR_ENTRY_MODE == 0 else '돌파만 허용'
|
||||||
|
return {**base, 'state': 'WAIT', 'watch_price': limit,
|
||||||
|
'reason': f'약세장 — 눌림목({limit:,}) 보류 ({mode_msg})'}
|
||||||
|
if rsi_oversold:
|
||||||
|
# 과매도 = 아직 떨어지는 칼 — 반등 확인 전 진입 보류
|
||||||
|
return {**base, 'state': 'WAIT', 'watch_price': limit,
|
||||||
|
'reason': f'눌림목이지만 RSI 과매도({rsi:.0f} < {config.RSI_OVERSOLD:g}) — 낙하 중 보류'}
|
||||||
# 현재가가 이미 지정가 이하 → 시장에서 현재가(체결가)로 매수
|
# 현재가가 이미 지정가 이하 → 시장에서 현재가(체결가)로 매수
|
||||||
return {**base, 'state': 'BUY', 'action': 'buy', 'buy_path': 'pullback',
|
return {**base, 'state': 'BUY', 'action': 'buy', 'buy_path': 'pullback',
|
||||||
'buy_price': price, 'watch_price': limit,
|
'buy_price': price, 'watch_price': limit,
|
||||||
@@ -171,10 +239,11 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di
|
|||||||
'reason': f'눌림목 대기 — 지정가 {limit:,}'}
|
'reason': f'눌림목 대기 — 지정가 {limit:,}'}
|
||||||
|
|
||||||
|
|
||||||
def evaluate_holding(position, ind, flow, analyst) -> dict:
|
def evaluate_holding(position, ind, flow, analyst, held_days=None) -> dict:
|
||||||
"""보유 종목 판단. 우선순위: 손절/추세이탈(전량) → 분할익절(scale_out) → 추격매수(add) → 유지.
|
"""보유 종목 판단. 우선순위: 손절/추세이탈(전량) → 시간손절 → 분할익절(scale_out) → 추격매수(add) → 유지.
|
||||||
|
|
||||||
position 의 stop/peak/trailing/scaled_out 갱신값도 position_update 로 함께 반환.
|
position 의 stop/peak/trailing/scaled_out 갱신값도 position_update 로 함께 반환.
|
||||||
|
held_days: 보유 경과 일수(달력). None이면 시간손절 미적용(엔진·백테스트가 산정해 전달).
|
||||||
"""
|
"""
|
||||||
price = ind['price']
|
price = ind['price']
|
||||||
entry = position['entry_price']
|
entry = position['entry_price']
|
||||||
@@ -225,6 +294,13 @@ def evaluate_holding(position, ind, flow, analyst) -> dict:
|
|||||||
if price < ind['sma_long'] and fnet < 0 and inet < 0:
|
if price < ind['sma_long'] and fnet < 0 and inet < 0:
|
||||||
return {**base, 'state': 'SELL', 'action': 'sell', 'sell_frac': 1.0, 'reason': '추세·수급 이탈'}
|
return {**base, 'state': 'SELL', 'action': 'sell', 'sell_frac': 1.0, 'reason': '추세·수급 이탈'}
|
||||||
|
|
||||||
|
# 1-b. 시간손절 — N일 보유했는데 트레일링 전이고 진입가도 못 넘으면(진전 없음) 청산해 자본 회전
|
||||||
|
max_hold = getattr(config, 'MAX_HOLD_DAYS', 0) or 0
|
||||||
|
if (held_days is not None and max_hold > 0 and held_days >= max_hold
|
||||||
|
and not trailing_on and not scaled_out and price <= entry):
|
||||||
|
return {**base, 'state': 'SELL', 'action': 'sell', 'sell_frac': 1.0,
|
||||||
|
'reason': f'시간손절 — {held_days}일 보유·진전 없음(자본 회전)'}
|
||||||
|
|
||||||
# 2. 분할매도 — 목표 도달 & 미익절. 일부 익절 후 잔량은 트레일링으로 전환
|
# 2. 분할매도 — 목표 도달 & 미익절. 일부 익절 후 잔량은 트레일링으로 전환
|
||||||
if target and price >= target and not scaled_out:
|
if target and price >= target and not scaled_out:
|
||||||
upd['trailing_on'] = True
|
upd['trailing_on'] = True
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import urllib.request
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
from . import config
|
from . import config, engine
|
||||||
from .portfolio import Portfolio
|
from .portfolio import Portfolio
|
||||||
|
|
||||||
BIND_HOST = ''
|
BIND_HOST = ''
|
||||||
@@ -81,6 +81,7 @@ REFRESH_JS = """
|
|||||||
if(p.get('werr')) banner('추가 실패: "'+p.get('werr')+'" 종목을 못 찾았어요','#ef4444');
|
if(p.get('werr')) banner('추가 실패: "'+p.get('werr')+'" 종목을 못 찾았어요','#ef4444');
|
||||||
if(p.get('wrem')) banner('삭제됨: '+p.get('wrem'),'#6b7280');
|
if(p.get('wrem')) banner('삭제됨: '+p.get('wrem'),'#6b7280');
|
||||||
setInterval(function(){
|
setInterval(function(){
|
||||||
|
if(document.getElementById('chk-toast')) return; /* 설명 토스트 열려 있으면 새로고침 멈춤 */
|
||||||
if(document.getElementById('tab-lab').checked&&document.getElementById('sub-tune').checked) return;
|
if(document.getElementById('tab-lab').checked&&document.getElementById('sub-tune').checked) return;
|
||||||
var cur=TABS.find(function(x){var e=document.getElementById('tab-'+x);return e&&e.checked;});
|
var cur=TABS.find(function(x){var e=document.getElementById('tab-'+x);return e&&e.checked;});
|
||||||
var sub=SUBS.find(function(x){var e=document.getElementById('sub-'+x);return e&&e.checked;});
|
var sub=SUBS.find(function(x){var e=document.getElementById('sub-'+x);return e&&e.checked;});
|
||||||
@@ -227,20 +228,128 @@ def _pkey(params) -> str:
|
|||||||
|
|
||||||
|
|
||||||
_PSHORT = {'SMA_LONG': 'SMA', 'RR_RATIO': 'RR', 'STOP_ATR_MULT': 'S', 'PULLBACK_ATR_MULT': 'P',
|
_PSHORT = {'SMA_LONG': 'SMA', 'RR_RATIO': 'RR', 'STOP_ATR_MULT': 'S', 'PULLBACK_ATR_MULT': 'P',
|
||||||
'VOLUME_BREAKOUT_MULT': 'V', 'TURNOVER_BREAKOUT_MULT': 'TB', 'TURNOVER_OVERHEAT_MULT': 'OH'}
|
'VOLUME_BREAKOUT_MULT': 'V', 'TURNOVER_BREAKOUT_MULT': 'TB', 'TURNOVER_OVERHEAT_MULT': 'OH',
|
||||||
|
'RSI_OVERBOUGHT': 'RSI↑', 'RSI_OVERSOLD': 'RSI↓', 'MAX_HOLD_DAYS': 'HOLD',
|
||||||
|
'RISK_PER_TRADE_PCT': 'RISK'}
|
||||||
|
|
||||||
|
|
||||||
def _plabel(params) -> str:
|
def _plabel(params) -> str:
|
||||||
return '·'.join(f'{_PSHORT.get(k, k)}{v:g}' for k, v in (params or {}).items())
|
return '·'.join(f'{_PSHORT.get(k, k)}{v:g}' for k, v in (params or {}).items())
|
||||||
|
|
||||||
|
|
||||||
|
# 평가 항목 설명 — 관심종목/판단근거 칩의 ⓘ 버튼 클릭 시 토스트로 표시 (쉬운 말로)
|
||||||
|
_CHK_HELP = {
|
||||||
|
'추세': {'t': '추세 (20일선 위)', 'd': '현재가가 최근 20일 평균값(20일 이동평균선)보다 위에 있는지 봐요. 위에 있으면 최근 한 달 흐름이 상승 쪽이라는 뜻이에요. 자동매매는 떨어지는 칼을 잡지 않으려고, 추세가 위로 향한 종목만 매수 후보로 봅니다.'},
|
||||||
|
'정배열': {'t': '정배열 (5일선 > 20일선)', 'd': '단기 평균(5일선)이 장기 평균(20일선)보다 위에 있는 상태예요. 최근 5일 기세가 한 달 평균보다 강하다 = 상승에 가속이 붙은 모양이에요. 정배열이면 추세가 살아있다고 봅니다.'},
|
||||||
|
'수급': {'t': '수급 (외국인·기관 순매수)', 'd': '최근 5일 동안 외국인과 기관이 이 종목을 순매수(더 많이 사들임)했는지 봐요. 돈이 큰 큰손이 사면 주가가 잘 받쳐지는 경향이 있어요. 외국인·기관 둘 중 하나라도 순매수면 통과예요. (숫자 단위는 천 주)'},
|
||||||
|
'애널': {'t': '애널리스트 의견', 'd': '증권사 애널리스트들의 종합 의견이에요. 보통 5점 만점에 높을수록 매수 추천이에요. ETF처럼 분석 대상이 아닌 종목은 데이터가 없어서 그냥 통과 처리해요.'},
|
||||||
|
'투자의견': {'t': '투자의견 (5점 만점)', 'd': '증권사 애널리스트들의 평균 투자의견 점수예요. 5점에 가까울수록 "사라"는 의견이 많다는 뜻이고, 보통 4점 이상이면 전문가들이 긍정적으로 본다고 판단해요.'},
|
||||||
|
'상승여력': {'t': '상승여력 (목표가 대비)', 'd': '애널리스트 평균 목표주가가 지금 가격보다 몇 % 높은지예요. +20%면 전문가들이 20% 더 오를 여지를 본다는 의미예요. 이미 목표가 근처라 여력이 적으면 매력이 줄어요.'},
|
||||||
|
'목표가리비전': {'t': '목표가 리비전 (상향/하향)', 'd': '최근 애널리스트들이 목표주가를 올리는 중인지 내리는 중인지 봐요. "상향"이면 회사 전망이 좋아지는 방향이라 긍정 신호예요. "하향"이면 반대고요.'},
|
||||||
|
'어닝서프라이즈': {'t': '어닝 서프라이즈', 'd': '가장 최근 실적이 시장이 예상했던 것(컨센서스)보다 좋았는지(+) 나빴는지(−)예요. 예상보다 잘 나오면(서프라이즈 +) 주가에 긍정적으로 작용하는 경우가 많아요.'},
|
||||||
|
'시장강세': {'t': '시장 강세 (장 전체 분위기)', 'd': '그날 시장 전체에서 오른 종목의 비율이에요. 오른 종목이 기준(15%) 이상이면 "강세장"으로 봐요. 시장이 무너지는 날엔 좋은 종목도 같이 빠지기 쉬워서, 약세장에선 바닥잡기 매수는 잠깐 미루고 강한 돌파만 허용해요.'},
|
||||||
|
'과열': {'t': '과열 아님 (회전율)', 'd': '그날 거래 회전율(거래량을 상장주식 수로 나눈 값)이 평소 평균보다 지나치게(3배 이상) 높지 않은지 봐요. 너무 과열되면 단기 꼭대기일 위험이 커서, 그럴 땐 신규매수를 잠깐 보류해요.'},
|
||||||
|
'돌파': {'t': '돌파 (전고점)', 'd': '현재가가 최근 고점을 뚫고 올라갔는지예요. 전고점 돌파는 새로운 상승의 출발 신호로 봐요. 거래량·회전율 급증이 함께 나오면 "진짜 돌파"로 판단해 바로 매수합니다.'},
|
||||||
|
'거래량': {'t': '거래량 급증', 'd': '그날 거래량이 평소 평균보다 크게 많은지 봐요. 거래가 확 늘었다는 건 사람들의 관심이 몰렸다는 뜻이라, 돌파에 힘이 실렸는지 확인하는 신호예요.'},
|
||||||
|
'회전율': {'t': '회전율 급증', 'd': '회전율(거래량 ÷ 상장주식 수)이 평소보다 크게 늘었는지예요. 거래량 급증과 함께 보면 돌파의 신뢰도를 높여줘요. 데이터가 없으면 그냥 통과 처리해요.'},
|
||||||
|
'눌림목': {'t': '눌림목 도달', 'd': '강한 종목이 잠깐 쉬며 적정 매수가까지 내려왔는지 봐요. 적정가는 "20일선"과 "전일 종가 − 하루 변동폭(ATR)" 중 더 높은 값이에요. 비싸게 쫓아 사지 않고, 추세는 살아있되 잠깐 눌린 자리에서 싸게 담으려는 지정가 진입 자리예요.'},
|
||||||
|
'RSI': {'t': 'RSI (과매수·과매도)', 'd': '최근 오른 폭과 내린 폭을 견줘 0~100으로 나타낸 지표예요. 70~80 위면 단기 과열(너무 많이 올랐다), 20~30 아래면 과매도(너무 많이 빠졌다)로 봐요. 돌파 매수는 과열이면 추격을 피하고, 눌림목 매수는 과매도(아직 떨어지는 중)면 반등을 기다려요.'},
|
||||||
|
'손절선': {'t': '손절선', 'd': '이 가격 아래로 내려가면 손실을 확정하고 파는 기준선이에요. 진입가에서 변동폭(ATR)이나 최근 스윙 저점을 빼서 정해요. 트레일링이 켜지면 고점을 따라 위로 올라가며 번 수익을 지켜요.'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _chk_key(label) -> str:
|
||||||
|
"""체크 항목 라벨 → 설명 키 (라벨에 숫자·괄호가 섞여 있어 접두사로 매칭)."""
|
||||||
|
l = (label or '').strip()
|
||||||
|
for pre, key in (('추세', '추세'), ('정배열', '정배열'), ('수급', '수급'), ('애널', '애널'),
|
||||||
|
('투자의견', '투자의견'), ('상승여력', '상승여력'), ('목표가', '목표가리비전'),
|
||||||
|
('어닝', '어닝서프라이즈'), ('시장', '시장강세'), ('과열', '과열'),
|
||||||
|
('돌파', '돌파'), ('거래량', '거래량'), ('회전율', '회전율'), ('눌림목', '눌림목'),
|
||||||
|
('RSI', 'RSI'), ('손절', '손절선')):
|
||||||
|
if l.startswith(pre):
|
||||||
|
return key
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def _chips(checks) -> str:
|
def _chips(checks) -> str:
|
||||||
"""신호 체크리스트 → 칩 HTML (판단근거 표시 공용)."""
|
"""신호 체크리스트 → 칩 HTML (판단근거 표시 공용). 각 항목에 ⓘ 버튼 — 클릭 시 설명 토스트."""
|
||||||
return ''.join(
|
out = []
|
||||||
f'<span class="chk" style="color:{_check_color(c.get("ok"))}">'
|
for c in (checks or []):
|
||||||
f'{_check_mark(c.get("ok"))} {_esc(c.get("label"))}<span class="cd">{_esc(c.get("detail"))}</span></span>'
|
k = _chk_key(c.get('label'))
|
||||||
for c in (checks or [])
|
info = f'<button type="button" class="chk-i" data-k="{k}" aria-label="설명">ⓘ</button>' if k else ''
|
||||||
)
|
out.append(
|
||||||
|
f'<span class="chk" style="color:{_check_color(c.get("ok"))}">'
|
||||||
|
f'{_check_mark(c.get("ok"))} {_esc(c.get("label"))}'
|
||||||
|
f'<span class="cd">{_esc(c.get("detail"))}</span>{info}</span>'
|
||||||
|
)
|
||||||
|
return ''.join(out)
|
||||||
|
|
||||||
|
|
||||||
|
# 전략 튜닝 탭 파라미터 설명 — 같은 ⓘ 토스트 핸들러 재사용 (data-k = 파라미터 key)
|
||||||
|
_TUNE_HELP = {
|
||||||
|
'SMA_SHORT': {'t': '단기 이동평균 (일)', 'd': '단기 추세를 보는 평균선 기간이에요. 5면 최근 5일 평균이에요. 짧을수록 가격 변화에 더 민감하게(빠르게) 반응해요. "정배열(단기선>장기선)" 판단에 쓰여요.'},
|
||||||
|
'SMA_LONG': {'t': '장기 이동평균 (일)', 'd': '큰 추세를 보는 평균선 기간이에요. 20이면 최근 20일(약 한 달) 평균이에요. "추세(이 선 위인지)"와 "정배열"의 기준선이에요. 길수록 큰 흐름만 봐요.'},
|
||||||
|
'ATR_PERIOD': {'t': 'ATR 기간 (일)', 'd': 'ATR은 하루에 가격이 보통 얼마나 출렁이는지(평균 변동폭)예요. 이 기간만큼의 변동폭을 평균내요. 손절·눌림목·트레일링 폭을 계산하는 데 쓰여요.'},
|
||||||
|
'BREAKOUT_LOOKBACK': {'t': '돌파 고점 구간 (일)', 'd': '"전고점 돌파"를 따질 때 며칠간의 최고가를 기준으로 할지예요. 20이면 최근 20일 고점을 넘어야 돌파로 봐요. 길수록 더 의미 있는 고점을 요구해요.'},
|
||||||
|
'SWING_LOW_LOOKBACK': {'t': '스윙 저점 구간 (일)', 'd': '손절선을 잡을 때 참고하는 최근 저점 구간이에요. 이 기간의 저점 근처를 손절 후보로 써서, 의미 있는 지지선 아래로 빠지면 정리해요.'},
|
||||||
|
'VOLUME_AVG_PERIOD': {'t': '거래량 평균 구간 (일)', 'd': '"거래량 급증"을 판단할 때 비교 기준이 되는 평소 평균 거래량을 며칠로 낼지예요. 이 평균보다 확 늘면 관심이 몰린 걸로 봐요.'},
|
||||||
|
'FLOW_DAYS': {'t': '수급 합산 (일)', 'd': '외국인·기관 순매수를 며칠치 합쳐서 볼지예요. 5면 최근 5일 합계예요. 길수록 하루 변동이 아닌 꾸준한 큰손 매수를 봐요.'},
|
||||||
|
'VOLUME_BREAKOUT_MULT': {'t': '돌파 거래량 배수', 'd': '돌파로 인정하려면 그날 거래량이 평소 평균의 몇 배 이상이어야 하는지예요. 2면 평소 2배 넘게 거래돼야 "진짜 돌파"로 봐요. 높일수록 조건이 까다로워져요.'},
|
||||||
|
'TURNOVER_BREAKOUT_MULT': {'t': '돌파 회전율 배수', 'd': '돌파 인정에 필요한 회전율(거래 활발도) 배수예요. 거래량 배수와 함께 봐서 돌파에 힘이 실렸는지 확인해요.'},
|
||||||
|
'TURNOVER_OVERHEAT_MULT': {'t': '과열 보류 회전율 배수', 'd': '회전율이 평소의 몇 배를 넘으면 "과열"로 보고 신규매수를 보류할지예요. 3이면 평소 3배 넘게 과열되면 단기 꼭대기 위험으로 잠깐 쉬어요.'},
|
||||||
|
'PULLBACK_ATR_MULT': {'t': '눌림목 ATR 배수', 'd': '눌림목 매수가를 "전일 종가 − (변동폭 ATR × 이 배수)"로 잡아요. 클수록 더 깊이 떨어진 자리에서 사려고 해요 — 더 싸게 사지만 기회는 줄어들어요.'},
|
||||||
|
'ENTRY_TRANCHES': {'t': '분할매수 횟수', 'd': '한 종목 목표 비중을 몇 번에 나눠 살지예요. 3이면 1/3씩 세 번에 걸쳐 들어가요. 한 번에 다 사지 않아 평균 단가 위험을 줄여요.'},
|
||||||
|
'ADD_ATR_MULT': {'t': '추격매수 ATR 배수', 'd': '추가 매수(추격)할 때, 직전 매수가보다 "변동폭 ATR × 이 배수"만큼 더 오르면 다음 분할을 사요. 추세가 계속될 때만 따라 올라타는 기준이에요.'},
|
||||||
|
'RSI_OVERBOUGHT': {'t': 'RSI 과열 컷 (돌파)', 'd': '돌파 매수할 때 RSI가 이 값 이상이면 단기 과열로 보고 추격을 보류해요. 78이면 RSI 78 이상일 때 안 사요. 낮출수록 더 깐깐하게(덜) 사고, 너무 높이면 과열 종목도 사게 돼요.'},
|
||||||
|
'RSI_OVERSOLD': {'t': 'RSI 과매도 컷 (눌림목)', 'd': '눌림목 매수할 때 RSI가 이 값 미만이면 아직 떨어지는 중(과매도)으로 보고 진입을 미뤄요. 25면 RSI 25 미만일 때 기다려요. "떨어지는 칼을 잡지 않으려는" 안전장치예요.'},
|
||||||
|
'STOP_ATR_MULT': {'t': '손절 ATR 배수', 'd': '손절선을 "매수가 − (변동폭 ATR × 이 배수)"로 잡아요. 2면 하루 변동폭의 2배까지 빠지면 손절이에요. 클수록 여유롭게 두지만(잘 안 잘림) 손실은 커질 수 있어요.'},
|
||||||
|
'RR_RATIO': {'t': '손익비 (목표 ÷ 손절)', 'd': '목표 수익이 손절 위험의 몇 배인지예요. 2면 1 잃을 위험에 2 벌 목표를 둬요. 높일수록 큰 수익을 노리지만 목표 도달은 어려워져요.'},
|
||||||
|
'SCALE_OUT_FRAC': {'t': '분할매도 익절 비율', 'd': '목표가에 닿으면 보유 수량 중 몇 %를 먼저 익절할지예요. 0.5면 절반을 팔아 수익을 챙기고, 나머지는 더 끌고 가며 트레일링으로 관리해요.'},
|
||||||
|
'TRAIL_ATR_MULT': {'t': '트레일링 ATR 배수', 'd': '익절 후 남은 물량의 "따라가는 손절선"을 고점에서 "변동폭 ATR × 이 배수"만큼 아래에 둬요. 주가가 오르면 같이 올라가고, 떨어지면 그 선에서 정리해요.'},
|
||||||
|
'TRAIL_ATR_MULT_TIGHT': {'t': '트레일링 강화 배수', 'd': '수익이 충분히 났을 때 트레일링 폭을 더 좁게(타이트하게) 조이는 배수예요. 번 것을 더 꼭 지키려고 고점에 더 가깝게 따라붙어요.'},
|
||||||
|
'MAX_HOLD_DAYS': {'t': '시간손절 (보유 최대 일)', 'd': '한 종목을 산 뒤 이 일수(달력 기준)가 지나도 진전이 없으면(트레일링 시작 전이고 진입가도 못 넘김) 정리해서 자본을 다른 기회로 돌려요. 20이면 약 한 달이에요. 0이면 시간손절을 끕니다.'},
|
||||||
|
'ANALYST_MIN_UPSIDE_PCT': {'t': '최소 상승여력 (%)', 'd': '매수 후보가 되려면 애널리스트 목표가까지 최소 몇 %의 여력이 있어야 하는지예요. 이보다 낮으면(이미 목표가 근처면) 매력이 부족하다고 봐요.'},
|
||||||
|
'ANALYST_SELL_OPINION_MAX': {'t': '매도의견 컷 (1~5)', 'd': '애널리스트 평균 의견 점수가 이 값 이하로 낮으면(매도에 가까우면) 매수 후보에서 빼요. 전문가가 부정적인 종목을 거르는 방향성 안전장치예요.'},
|
||||||
|
'MARKET_BREADTH_MIN': {'t': '시장 상승비율 하한', 'd': '그날 오른 종목 비율이 이 값 미만이면 "약세장"으로 봐요. 0.15면 15%예요. 약세장에선 바닥잡기 매수는 미루고 강한 돌파만 허용해요.'},
|
||||||
|
'BEAR_ENTRY_MODE': {'t': '약세장 매수 모드', 'd': '시장이 약세 국면(혼조·약세·급락)일 때 어떻게 살지예요. 0=완전 현금화(돌파도 안 사고 무조건 쉼) · 1=돌파만 허용(강하게 치고 오르는 종목만, 바닥잡기는 안 함) · 2=눌림목도 허용(약세장에도 적정가 오면 저가 매수). 숫자가 클수록 약세장에 적극적이에요. 기본 1.'},
|
||||||
|
'MAX_POSITIONS': {'t': '동시 보유 최대 종목', 'd': '한 번에 들고 갈 수 있는 최대 종목 수예요. 분산은 되지만 너무 많으면 관리가 어려워요. 이 수에 차면 신규매수를 멈춰요. "종목당 균등비중 = 자산 ÷ 이 수"의 기준이기도 해요.'},
|
||||||
|
'RISK_PER_TRADE_PCT': {'t': '종목당 리스크 비율', 'd': '한 종목에서 감수할 손실(손절까지 갔을 때)을 자산의 몇 %로 제한할지예요. 0.01이면 1%. 수량 = 자산 × 이 비율 ÷ 손절폭으로 정해서, 손절폭이 큰(변동성 큰) 종목은 자동으로 적게 사요. 종목마다 위험을 균등하게 맞춰 큰 손실 한 방을 막아요. 0이면 끄고 균등비중으로 사요.'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_CHK_HELP_JS = ("""
|
||||||
|
<script>(function(){
|
||||||
|
var H = __HELP_JSON__;
|
||||||
|
var box=null;
|
||||||
|
function close(){ if(box){box.remove();box=null;} }
|
||||||
|
function show(title, body){
|
||||||
|
close();
|
||||||
|
box=document.createElement('div'); box.id='chk-toast';
|
||||||
|
var card=document.createElement('div'); card.className='ct-card';
|
||||||
|
var t=document.createElement('div'); t.className='ct-t'; t.textContent=title;
|
||||||
|
var d=document.createElement('div'); d.className='ct-d'; d.textContent=body;
|
||||||
|
var x=document.createElement('button'); x.type='button'; x.className='ct-x'; x.textContent='닫기';
|
||||||
|
card.appendChild(t); card.appendChild(d); card.appendChild(x);
|
||||||
|
box.appendChild(card); document.body.appendChild(box);
|
||||||
|
box.addEventListener('click', function(ev){ if(ev.target===box||ev.target===x) close(); });
|
||||||
|
}
|
||||||
|
document.addEventListener('click', function(e){
|
||||||
|
var dd=e.target.closest('[data-detail]');
|
||||||
|
if(dd){
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
var det=dd.getAttribute('data-detail')||''; var ttl=dd.getAttribute('data-title');
|
||||||
|
if(ttl){ show(ttl, det); }
|
||||||
|
else { var nl=det.indexOf('\\n'); show(nl>0?det.slice(0,nl):'정보', nl>0?det.slice(nl+1):det); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var b=e.target.closest('.chk-i');
|
||||||
|
if(!b) return;
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
var h=H[b.getAttribute('data-k')]; if(!h) return;
|
||||||
|
show(h.t, h.d);
|
||||||
|
});
|
||||||
|
})();</script>
|
||||||
|
""").replace('__HELP_JSON__', json.dumps({**_CHK_HELP, **_TUNE_HELP}, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
def _kpi(label, value, sub=''):
|
def _kpi(label, value, sub=''):
|
||||||
@@ -248,6 +357,11 @@ def _kpi(label, value, sub=''):
|
|||||||
return f'<div class="kpi"><div class="kpi-label">{_esc(label)}</div><div class="kpi-val">{_esc(value)}</div>{sub_h}</div>'
|
return f'<div class="kpi"><div class="kpi-label">{_esc(label)}</div><div class="kpi-val">{_esc(value)}</div>{sub_h}</div>'
|
||||||
|
|
||||||
|
|
||||||
|
def _kvline(k, v):
|
||||||
|
"""한 줄 항목(라벨 좌 / 값 우). v는 신뢰 HTML(색상·muted 등 허용)."""
|
||||||
|
return f'<div class="kv"><span class="k">{k}</span><span>{v}</span></div>'
|
||||||
|
|
||||||
|
|
||||||
def _signed(v, suffix='억'):
|
def _signed(v, suffix='억'):
|
||||||
c = '#22c55e' if (v or 0) > 0 else '#ef4444' if (v or 0) < 0 else '#9ca3af'
|
c = '#22c55e' if (v or 0) > 0 else '#ef4444' if (v or 0) < 0 else '#9ca3af'
|
||||||
return f'<span style="color:{c}">{(v or 0):+,}{suffix}</span>'
|
return f'<span style="color:{c}">{(v or 0):+,}{suffix}</span>'
|
||||||
@@ -256,6 +370,32 @@ def _signed(v, suffix='억'):
|
|||||||
_idx_cache = {'data': {}, 'ts': 0.0}
|
_idx_cache = {'data': {}, 'ts': 0.0}
|
||||||
_IDX_TTL = 60.0
|
_IDX_TTL = 60.0
|
||||||
|
|
||||||
|
_mkt_cache = {'data': None, 'ts': 0.0}
|
||||||
|
_MKT_TTL = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def _get_live_market() -> dict | None:
|
||||||
|
"""장중 실시간 시장통계(상승하락·ADR·투자자 순매수) — 네이버 m.stock, 60s 캐시. 실패 시 None."""
|
||||||
|
now = time.time()
|
||||||
|
if _mkt_cache['data'] and now - _mkt_cache['ts'] < _MKT_TTL:
|
||||||
|
return _mkt_cache['data']
|
||||||
|
try:
|
||||||
|
sys.path.insert(0, str(config.SCRIPTS))
|
||||||
|
import market_indicators_sync as mis
|
||||||
|
out = {}
|
||||||
|
for sym, lab in (('KOSPI', '코스피'), ('KOSDAQ', '코스닥')):
|
||||||
|
rr = mis.fetch_market(sym, lab)
|
||||||
|
if rr:
|
||||||
|
rr['date'] = rr.get('bizdate') or ''
|
||||||
|
out[sym] = rr
|
||||||
|
if out:
|
||||||
|
_mkt_cache['data'] = out
|
||||||
|
_mkt_cache['ts'] = now
|
||||||
|
return out
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write(f'[sim_web] 실시간 시장통계 fetch 실패: {e}\n')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _fetch_naver_index(code: str) -> dict | None:
|
def _fetch_naver_index(code: str) -> dict | None:
|
||||||
"""네이버 지수 1개 (코스피/코스닥) closePrice·등락. 실패 시 None."""
|
"""네이버 지수 1개 (코스피/코스닥) closePrice·등락. 실패 시 None."""
|
||||||
@@ -306,12 +446,14 @@ def _market_panel() -> str:
|
|||||||
return '<div class="empty">시장 데이터 읽기 실패</div>'
|
return '<div class="empty">시장 데이터 읽기 실패</div>'
|
||||||
|
|
||||||
idx = _get_indices()
|
idx = _get_indices()
|
||||||
|
live = _get_live_market() # 세션 무관 항상 최신(네이버는 장 마감 후에도 당일 최종값 제공), 실패 시만 EOD 폴백
|
||||||
|
regimes = engine.market_regime_map() # 6단계 국면 (매수판단과 동일 소스)
|
||||||
order = ['KOSPI', 'KOSDAQ']
|
order = ['KOSPI', 'KOSDAQ']
|
||||||
keys = [k for k in order if k in by_market] + [k for k in by_market if k not in order]
|
keys = [k for k in order if k in by_market] + [k for k in by_market if k not in order]
|
||||||
out = ''
|
out = ''
|
||||||
for k in keys:
|
for k in keys:
|
||||||
recs = by_market[k]
|
recs = by_market[k]
|
||||||
r = recs[-1]
|
r = {**recs[-1], **live[k]} if live and k in live else recs[-1]
|
||||||
ix = idx.get(k)
|
ix = idx.get(k)
|
||||||
if ix:
|
if ix:
|
||||||
ic = '#22c55e' if ix['change'] > 0 else '#ef4444' if ix['change'] < 0 else '#9ca3af'
|
ic = '#22c55e' if ix['change'] > 0 else '#ef4444' if ix['change'] < 0 else '#9ca3af'
|
||||||
@@ -324,8 +466,9 @@ def _market_panel() -> str:
|
|||||||
rise, fall, steady = r.get('rise', 0), r.get('fall', 0), r.get('steady', 0)
|
rise, fall, steady = r.get('rise', 0), r.get('fall', 0), r.get('steady', 0)
|
||||||
total = rise + fall + steady
|
total = rise + fall + steady
|
||||||
breadth = (rise / total * 100) if total else 0
|
breadth = (rise / total * 100) if total else 0
|
||||||
ok = total and (rise / total) >= config.MARKET_BREADTH_MIN
|
grade = regimes.get(k, 'mixed')
|
||||||
vlabel, vcolor = ('양호', '#22c55e') if ok else ('약세', '#ef4444')
|
r_em, r_lbl, r_buy, r_desc, r_color = engine.REGIME[grade]
|
||||||
|
vlabel, vcolor = f'{r_em} {r_lbl}', r_color
|
||||||
adr = r.get('adr')
|
adr = r.get('adr')
|
||||||
adr_s = f'{adr:.1f}' if isinstance(adr, (int, float)) else '—'
|
adr_s = f'{adr:.1f}' if isinstance(adr, (int, float)) else '—'
|
||||||
recent = [rr.get('adr') for rr in recs[-5:] if isinstance(rr.get('adr'), (int, float))]
|
recent = [rr.get('adr') for rr in recs[-5:] if isinstance(rr.get('adr'), (int, float))]
|
||||||
@@ -338,15 +481,20 @@ def _market_panel() -> str:
|
|||||||
f'<div class="kv"><span class="k">ADR</span><span>{adr_s}</span></div>'
|
f'<div class="kv"><span class="k">ADR</span><span>{adr_s}</span></div>'
|
||||||
f'<div class="kv"><span class="k">상승 / 하락</span><span><span style="color:#22c55e">{rise:,}</span> / <span style="color:#ef4444">{fall:,}</span> (보합 {steady:,})</span></div>'
|
f'<div class="kv"><span class="k">상승 / 하락</span><span><span style="color:#22c55e">{rise:,}</span> / <span style="color:#ef4444">{fall:,}</span> (보합 {steady:,})</span></div>'
|
||||||
f'<div class="kv"><span class="k">상한 / 하한</span><span>{r.get("upper",0):,} / {r.get("lower",0):,}</span></div>'
|
f'<div class="kv"><span class="k">상한 / 하한</span><span>{r.get("upper",0):,} / {r.get("lower",0):,}</span></div>'
|
||||||
f'<div class="kv"><span class="k">상승비율</span><span>{breadth:.1f}% · {vlabel} (기준 {config.MARKET_BREADTH_MIN*100:.0f}%)</span></div>'
|
f'<div class="kv"><span class="k">상승비율</span><span>{breadth:.1f}%</span></div>'
|
||||||
|
f'<div class="kv"><span class="k">국면</span><span style="color:{r_color}"><b>{vlabel}</b></span></div>'
|
||||||
|
f'<div class="sub" style="margin:2px 0 4px">{r_desc}{" · 신규매수 허용" if r_buy else ""}</div>'
|
||||||
f'<div class="kv"><span class="k">개인 순매수</span>{_signed(r.get("personal"))}</div>'
|
f'<div class="kv"><span class="k">개인 순매수</span>{_signed(r.get("personal"))}</div>'
|
||||||
f'<div class="kv"><span class="k">외국인 순매수</span>{_signed(r.get("foreign"))}</div>'
|
f'<div class="kv"><span class="k">외국인 순매수</span>{_signed(r.get("foreign"))}</div>'
|
||||||
f'<div class="kv"><span class="k">기관 순매수</span>{_signed(r.get("institutional"))}</div>'
|
f'<div class="kv"><span class="k">기관 순매수</span>{_signed(r.get("institutional"))}</div>'
|
||||||
f'<div class="kv"><span class="k">최근 ADR(5일)</span><span>{recent_s}</span></div>'
|
f'<div class="kv"><span class="k">최근 ADR(5일)</span><span>{recent_s}</span></div>'
|
||||||
f'</div>'
|
f'</div>'
|
||||||
)
|
)
|
||||||
note = ('<div class="sub" style="margin-top:6px">EOD(장 마감 후 수집) 데이터 · 신규 매수 필터에만 반영 '
|
if live:
|
||||||
'· 순매수 단위 억원</div>')
|
live_lbl = '장중 실시간' if engine.is_market_session() else '직전 장 마감 기준'
|
||||||
|
note = f'<div class="sub" style="margin-top:6px">{live_lbl}(네이버 m.stock 실시간) · 순매수 단위 억원</div>'
|
||||||
|
else:
|
||||||
|
note = ('<div class="sub" style="margin-top:6px">EOD 폴백(실시간 조회 실패) · 순매수 단위 억원</div>')
|
||||||
return out + note
|
return out + note
|
||||||
|
|
||||||
|
|
||||||
@@ -364,6 +512,7 @@ def _tune_panel() -> str:
|
|||||||
body += (
|
body += (
|
||||||
f'<div class="prow"><label for="f_{k}">{_esc(spec["label"])}'
|
f'<div class="prow"><label for="f_{k}">{_esc(spec["label"])}'
|
||||||
f'<span class="muted"> · 기본 {config._DEFAULTS[k]:g}</span></label>'
|
f'<span class="muted"> · 기본 {config._DEFAULTS[k]:g}</span></label>'
|
||||||
|
f'<button type="button" class="chk-i" data-k="{k}" aria-label="설명">ⓘ</button>'
|
||||||
f'<input id="f_{k}" name="{k}" type="number" value="{eff[k]:g}" '
|
f'<input id="f_{k}" name="{k}" type="number" value="{eff[k]:g}" '
|
||||||
f'min="{spec["min"]}" max="{spec["max"]}" step="{spec.get("step",1)}" inputmode="decimal"></div>'
|
f'min="{spec["min"]}" max="{spec["max"]}" step="{spec.get("step",1)}" inputmode="decimal"></div>'
|
||||||
)
|
)
|
||||||
@@ -395,17 +544,112 @@ def _bt_alpha_row(res: dict) -> str:
|
|||||||
f'<span>{_alpha_inline(bench)}</span></div>')
|
f'<span>{_alpha_inline(bench)}</span></div>')
|
||||||
|
|
||||||
|
|
||||||
|
def _market_fit(p: dict) -> tuple[str, str]:
|
||||||
|
"""조합(설정값 세트)이 어떤 시장에서 강세/약세인지 — 파라미터 성격 기반 결정론적 설명."""
|
||||||
|
sma = p.get('SMA_LONG', 20); rr = p.get('RR_RATIO', 2.0); stop = p.get('STOP_ATR_MULT', 2.0)
|
||||||
|
vol = p.get('VOLUME_BREAKOUT_MULT', 1.5); pull = p.get('PULLBACK_ATR_MULT', 1.0)
|
||||||
|
risk = p.get('RISK_PER_TRADE_PCT', 0.01); hold = p.get('MAX_HOLD_DAYS', 0)
|
||||||
|
tags, strong, weak = [], [], []
|
||||||
|
tags.append('크게 먹으려 오래 들고가는 편' if rr >= 2.5 else
|
||||||
|
'조금씩 빨리 먹고 빠지는 편' if rr <= 1.5 else '적당히 균형 잡힌 편')
|
||||||
|
if sma <= 15: tags.append('시세에 빠르게 반응')
|
||||||
|
elif sma >= 40: tags.append('큰 흐름만 천천히')
|
||||||
|
if risk >= 0.02: tags.append('한 종목에 크게 베팅')
|
||||||
|
elif risk <= 0.005: tags.append('조심조심 적게 베팅')
|
||||||
|
|
||||||
|
if vol <= 1.3:
|
||||||
|
strong.append('거래가 활발하고 쭉쭉 오르는 장 — 치고 올라갈 때 잘 따라붙어요')
|
||||||
|
weak.append('거래도 뜸하고 옆으로 횡보하는 장 — 오르는 척하다 도로 빠질 때 속아요')
|
||||||
|
elif vol >= 2.0:
|
||||||
|
strong.append('확실하게 크게 터지며 오르는 장')
|
||||||
|
weak.append('조용한 장 — 살 만한 신호가 잘 안 나와서 기회가 적어요')
|
||||||
|
if rr >= 2.5:
|
||||||
|
strong.append('한 방향으로 길게 쭉 가는 장 — 오래 들고 크게 먹어요')
|
||||||
|
weak.append('올랐다 내렸다 왔다갔다 하는 장 — 목표에 닿기 전에 꺾여요')
|
||||||
|
elif rr <= 1.5:
|
||||||
|
strong.append('출렁출렁 자주 움직이는 장 — 조금씩 빨리 먹기 좋아요')
|
||||||
|
weak.append('한 방향으로 크게 가는 장 — 너무 일찍 팔아서 아쉬워요')
|
||||||
|
if stop <= 1.5:
|
||||||
|
strong.append('완만하게 꾸준히 오르는 장 — 손실을 작게 끊어요')
|
||||||
|
weak.append('출렁임이 심한 장 — 자꾸 손절에 걸려서 잘려요')
|
||||||
|
elif stop >= 2.5:
|
||||||
|
strong.append('흔들려도 결국 오르는 장 — 출렁임을 버텨내요')
|
||||||
|
weak.append('확 빠지는 급락장 — 한 번에 크게 손해 봐요')
|
||||||
|
if pull >= 1.5:
|
||||||
|
strong.append('올랐다 잠깐 쉬며 내려주는 장 — 싸게 살 기회가 생겨요')
|
||||||
|
weak.append('쉬지 않고 곧장 오르기만 하는 장 — 살 자리를 안 줘서 못 사요')
|
||||||
|
if risk >= 0.02:
|
||||||
|
strong.append('쭉 오르는 장 — 크게 베팅한 만큼 수익도 커요')
|
||||||
|
weak.append('내리거나 출렁이는 장 — 손실도 그만큼 커서 계좌가 휘청여요')
|
||||||
|
if sma <= 15:
|
||||||
|
weak.append('자잘하게 흔들리기만 하는 장 — 빠른 신호가 자꾸 헛다리 짚어요')
|
||||||
|
elif sma >= 40:
|
||||||
|
weak.append('빠르게 휙휙 바뀌는 장 — 굼떠서 한발 늦게 들어가요')
|
||||||
|
if hold and hold <= 10:
|
||||||
|
strong.append('짧게 끝나는 장 — 돈을 빨리 돌려 다음 기회로 넘어가요')
|
||||||
|
|
||||||
|
def _dedup(lst):
|
||||||
|
seen, out = set(), []
|
||||||
|
for x in lst:
|
||||||
|
if x not in seen:
|
||||||
|
seen.add(x); out.append(x)
|
||||||
|
return out
|
||||||
|
strong, weak = _dedup(strong), _dedup(weak)
|
||||||
|
body = ('한마디로: ' + ', '.join(tags) + ' 스타일이에요.\n\n'
|
||||||
|
'💪 이런 장에서 잘 벌어요\n' + '\n'.join('· ' + x for x in strong) +
|
||||||
|
'\n\n⚠️ 이런 장에선 약해요\n' + '\n'.join('· ' + x for x in weak) +
|
||||||
|
'\n\n※ 설정값 성격으로 따져본 거예요 — 참고만 하세요')
|
||||||
|
return '🌐 어떤 시장에 강한가', body
|
||||||
|
|
||||||
|
|
||||||
|
def _market_fit_summary(p: dict) -> str:
|
||||||
|
"""조합 성격을 한 줄로 — 행 제목용. 예: '상승장에 강한 빠른·공격 단타형'."""
|
||||||
|
rr = p.get('RR_RATIO', 2.0); risk = p.get('RISK_PER_TRADE_PCT', 0.01)
|
||||||
|
sma = p.get('SMA_LONG', 20); stop = p.get('STOP_ATR_MULT', 2.0)
|
||||||
|
vol = p.get('VOLUME_BREAKOUT_MULT', 1.5); pull = p.get('PULLBACK_ATR_MULT', 1.0)
|
||||||
|
style = '추세추종형' if rr >= 2.5 else '단타형' if rr <= 1.5 else '균형형'
|
||||||
|
adj = []
|
||||||
|
if sma <= 15: adj.append('빠른')
|
||||||
|
elif sma >= 40: adj.append('느린')
|
||||||
|
if risk >= 0.02: adj.append('공격')
|
||||||
|
elif risk <= 0.005: adj.append('보수')
|
||||||
|
style_full = ('·'.join(adj) + ' ' if adj else '') + style
|
||||||
|
if risk >= 0.02 or vol <= 1.3: market = '상승장'
|
||||||
|
elif rr >= 2.5: market = '추세장'
|
||||||
|
elif pull >= 1.5: market = '조정장'
|
||||||
|
elif rr <= 1.5 or stop <= 1.5: market = '출렁이는 장'
|
||||||
|
elif stop >= 2.5: market = '변동성 장'
|
||||||
|
else: market = '보통 장'
|
||||||
|
bear = p.get('BEAR_ENTRY_MODE', 1)
|
||||||
|
suffix = ' · 약세현금화' if bear == 0 else ' · 약세눌림허용' if bear == 2 else ''
|
||||||
|
return f'{market}에 강한 {style_full}{suffix}'
|
||||||
|
|
||||||
|
|
||||||
|
def _strategy_type(p: dict) -> str:
|
||||||
|
"""조합을 매매 스타일(손익비)로 대분류 — 추세추종형/균형형/단타형. (리스크 성향은 행 제목에 표시)"""
|
||||||
|
rr = p.get('RR_RATIO', 2.0)
|
||||||
|
if rr >= 2.5:
|
||||||
|
return 'trend'
|
||||||
|
if rr <= 1.5:
|
||||||
|
return 'scalp'
|
||||||
|
return 'swing'
|
||||||
|
|
||||||
|
|
||||||
|
_BT_TYPES = [
|
||||||
|
('trend', '📈 추세추종형', '손익비 높게(RR 2.5↑) — 큰 추세를 끝까지 먹는 형, 강한 상승장 특화'),
|
||||||
|
('swing', '⚖️ 균형형', '손익비 중간(RR 2.0) — 적당히 먹고 나오는 무난한 중도'),
|
||||||
|
('scalp', '⚡ 단타·회전형', '손익비 낮게(RR 1.5↓) — 짧게 먹고 빠르게 회전, 출렁이는 장 특화'),
|
||||||
|
]
|
||||||
|
_BT_TOPN = 8
|
||||||
|
|
||||||
|
|
||||||
def _backtest_panel() -> str:
|
def _backtest_panel() -> str:
|
||||||
"""백테스트 스윕 순위표 — 검증기간(out-of-sample) 성적순. 행마다 '이 튜닝 적용'."""
|
"""백테스트 스윕 순위표 — 검증기간(out-of-sample) 성적순. 행마다 '이 튜닝 적용'."""
|
||||||
res = _load(config.STATE_DIR / 'backtest_results.json', None)
|
res = _load(config.STATE_DIR / 'backtest_results.json', None)
|
||||||
if not res or not res.get('results'):
|
if not res or not res.get('results'):
|
||||||
return ('<div class="empty">스윕 미실행 — 터미널에서 <code>python3 -m sim sweep</code> 실행 후 표시됩니다.</div>')
|
return ('<div class="empty">스윕 미실행 — 터미널에서 <code>python3 -m sim sweep</code> 실행 후 표시됩니다.</div>')
|
||||||
sp = res.get('split', {})
|
sp = res.get('split', {})
|
||||||
head = (f'<div class="sub">학습 {sp.get("train","?")} → 검증 {sp.get("test","?")} · '
|
head = f'<div class="sub">검증 {sp.get("test","?")} · {res.get("count")}개 조합</div>'
|
||||||
f'{res.get("count")}개 조합 · 수급반영 {res.get("flow_used")} · '
|
|
||||||
f'생성 {res.get("generated_at","")[:16].replace("T"," ")}</div>'
|
|
||||||
'<div class="sub" style="color:#b45309">⚠ 절대수익보다 <b>상대순위·검증기간 일관성</b>으로 판단 '
|
|
||||||
'(수급 중립·종가체결·현 워치리스트 기준)</div>')
|
|
||||||
st = _sweep_status()
|
st = _sweep_status()
|
||||||
if st.get('state') == 'running':
|
if st.get('state') == 'running':
|
||||||
ctrl = ('<div class="sweep-bar"><span class="spin"></span>'
|
ctrl = ('<div class="sweep-bar"><span class="spin"></span>'
|
||||||
@@ -416,35 +660,26 @@ def _backtest_panel() -> str:
|
|||||||
if st.get('state') == 'error' else '')
|
if st.get('state') == 'error' else '')
|
||||||
ctrl = (err + '<form method="post" action="/run_sweep" style="margin:0 0 10px">'
|
ctrl = (err + '<form method="post" action="/run_sweep" style="margin:0 0 10px">'
|
||||||
'<button type="submit" class="btn-sweep">↻ 다시 계산 (스윕 재실행 · 약 1~2분)</button></form>')
|
'<button type="submit" class="btn-sweep">↻ 다시 계산 (스윕 재실행 · 약 1~2분)</button></form>')
|
||||||
sortbar = (
|
|
||||||
'<div class="sortbar" id="bt-sortbar">'
|
|
||||||
'<button data-sort="te">검증수익<span class="ar"></span></button>'
|
|
||||||
'<button data-sort="tr">학습수익<span class="ar"></span></button>'
|
|
||||||
'<button data-sort="mdd">MDD<span class="ar"></span></button>'
|
|
||||||
'<button data-sort="wr">승률<span class="ar"></span></button>'
|
|
||||||
'<button data-sort="pf">손익비<span class="ar"></span></button>'
|
|
||||||
'</div>'
|
|
||||||
)
|
|
||||||
from . import variants as vmod
|
from . import variants as vmod
|
||||||
registered = {_pkey(v['params']): v['id'] for v in vmod.load_variants()}
|
registered = {_pkey(v['params']): v['id'] for v in vmod.load_variants()}
|
||||||
main_p = config.load_params()
|
main_p = config.load_params()
|
||||||
main_pkey = _pkey({k: main_p[k] for k in res.get('swept_params', []) if k in main_p})
|
main_pkey = _pkey({k: main_p[k] for k in res.get('swept_params', []) if k in main_p})
|
||||||
rows = ''
|
min_tr = res.get('min_trades', 0) or 0
|
||||||
for i, r in enumerate(res['results'][:40]):
|
|
||||||
|
def _row_html(r, rank_label):
|
||||||
p, te, tr = r['params'], r['test'], r['train']
|
p, te, tr = r['params'], r['test'], r['train']
|
||||||
hidden = ''.join(f'<input type="hidden" name="{k}" value="{v:g}">' for k, v in p.items())
|
hidden = ''.join(f'<input type="hidden" name="{k}" value="{v:g}">' for k, v in p.items())
|
||||||
pk = _pkey(p)
|
pk = _pkey(p)
|
||||||
vid = registered.get(pk)
|
vid = registered.get(pk)
|
||||||
is_main = pk == main_pkey
|
is_main = pk == main_pkey
|
||||||
main_badge = ' <span class="tag">메인</span>' if is_main else ''
|
main_badge = ' <span class="badge-ic" title="메인 — 현재 적용된 튜닝">⭐</span>' if is_main else ''
|
||||||
cmp_badge = (' <span class="tag" style="background:#0e2a4d;color:#bfdbfe">비교군</span>'
|
cmp_badge = ' <span class="badge-ic" title="비교군에 등록됨">🔖</span>' if vid else ''
|
||||||
if vid else '')
|
if vid:
|
||||||
if vid: # 비교군 등록됨 → 제거 (메인 중복이라도 정리 가능)
|
|
||||||
cmp_btn = (f'<form method="post" action="/variants_remove">'
|
cmp_btn = (f'<form method="post" action="/variants_remove">'
|
||||||
f'<input type="hidden" name="id" value="{vid}">'
|
f'<input type="hidden" name="id" value="{vid}">'
|
||||||
f'<input type="hidden" name="back" value="bt">'
|
f'<input type="hidden" name="back" value="bt">'
|
||||||
f'<button type="submit" class="btn-mini btn-danger">− 비교군에서 제거</button></form>')
|
f'<button type="submit" class="btn-mini btn-danger">− 비교군에서 제거</button></form>')
|
||||||
elif is_main: # 메인은 비교 탭에 항상 포함 → 추가 불가
|
elif is_main:
|
||||||
cmp_btn = '<div class="bt-mainnote">메인 — 비교 탭에 항상 포함</div>'
|
cmp_btn = '<div class="bt-mainnote">메인 — 비교 탭에 항상 포함</div>'
|
||||||
else:
|
else:
|
||||||
cmp_btn = (f'<form method="post" action="/variants_add">{hidden}'
|
cmp_btn = (f'<form method="post" action="/variants_add">{hidden}'
|
||||||
@@ -452,20 +687,14 @@ def _backtest_panel() -> str:
|
|||||||
teret = te.get('total_return_pct', 0)
|
teret = te.get('total_return_pct', 0)
|
||||||
trret = tr.get('total_return_pct', 0)
|
trret = tr.get('total_return_pct', 0)
|
||||||
tc = '#22c55e' if teret >= 0 else '#ef4444'
|
tc = '#22c55e' if teret >= 0 else '#ef4444'
|
||||||
overfit = (trret - teret) > 40 # 학습≫검증 → 과최적 의심
|
overfit = (trret - teret) > 40
|
||||||
of_tag = ' <span class="tag" style="background:#7c2d12;color:#fed7aa">과최적?</span>' if overfit else ''
|
of_tag = ' <span class="tag" style="background:#7c2d12;color:#fed7aa">과최적?</span>' if overfit else ''
|
||||||
rank = f'<span class="muted">#{i+1}</span>'
|
|
||||||
# 정렬용 수치 (없는 값은 최하위로 — MDD는 낮을수록 좋아 큰값, 나머지는 작은값)
|
|
||||||
mdd, wr, pf = te.get('mdd_pct'), te.get('win_rate_pct'), te.get('profit_factor')
|
mdd, wr, pf = te.get('mdd_pct'), te.get('win_rate_pct'), te.get('profit_factor')
|
||||||
d_te = teret if isinstance(teret, (int, float)) else -999
|
mf_t, mf_b = _market_fit(p)
|
||||||
d_tr = trret if isinstance(trret, (int, float)) else -999
|
mf_sum = _market_fit_summary(p)
|
||||||
d_mdd = mdd if isinstance(mdd, (int, float)) else 9999
|
return (
|
||||||
d_wr = wr if isinstance(wr, (int, float)) else -1
|
f'<details class="row"><summary><span class="muted">{rank_label}</span> '
|
||||||
d_pf = 9e9 if pf == 'inf' else (pf if isinstance(pf, (int, float)) else -1)
|
f'<span class="nm">{_esc(mf_sum)}</span>{main_badge}{cmp_badge}'
|
||||||
rows += (
|
|
||||||
f'<details class="row" data-te="{d_te}" data-tr="{d_tr}" data-mdd="{d_mdd}" '
|
|
||||||
f'data-wr="{d_wr}" data-pf="{d_pf}"><summary>{rank} '
|
|
||||||
f'<span class="nm">{_esc(_plabel(p))}</span>{main_badge}{cmp_badge}'
|
|
||||||
f'<span class="rt"><b style="color:{tc}">검증 {teret:+g}%</b></span></summary>'
|
f'<span class="rt"><b style="color:{tc}">검증 {teret:+g}%</b></span></summary>'
|
||||||
f'<div class="body">'
|
f'<div class="body">'
|
||||||
f'<div class="kv"><span class="k">검증 수익 / 학습 수익</span>'
|
f'<div class="kv"><span class="k">검증 수익 / 학습 수익</span>'
|
||||||
@@ -474,13 +703,49 @@ def _backtest_panel() -> str:
|
|||||||
f'<div class="kv"><span class="k">검증 MDD</span><span>{mdd}%</span></div>'
|
f'<div class="kv"><span class="k">검증 MDD</span><span>{mdd}%</span></div>'
|
||||||
f'<div class="kv"><span class="k">검증 거래수 / 승률</span><span>{te.get("trades")}건 · {wr}%</span></div>'
|
f'<div class="kv"><span class="k">검증 거래수 / 승률</span><span>{te.get("trades")}건 · {wr}%</span></div>'
|
||||||
f'<div class="kv"><span class="k">검증 손익비(PF)</span><span>{pf}</span></div>'
|
f'<div class="kv"><span class="k">검증 손익비(PF)</span><span>{pf}</span></div>'
|
||||||
|
f'<div class="kv"><span class="k">파라미터</span><span class="muted">{_esc(_plabel(p))}</span></div>'
|
||||||
|
f'<button type="button" class="mfit" data-title="{_esc(mf_t)}" data-detail="{_esc(mf_b)}">🌐 어떤 시장에 강한가</button>'
|
||||||
f'<div class="bt-actions">'
|
f'<div class="bt-actions">'
|
||||||
f'<form method="post" action="/save_params">{hidden}'
|
f'<form method="post" action="/save_params">{hidden}'
|
||||||
f'<button type="submit" class="btn-apply">이 튜닝 적용</button></form>'
|
f'<button type="submit" class="btn-apply">이 튜닝 적용</button></form>'
|
||||||
f'{cmp_btn}'
|
f'{cmp_btn}'
|
||||||
f'</div></div></details>'
|
f'</div></div></details>'
|
||||||
)
|
)
|
||||||
return head + ctrl + sortbar + f'<div id="bt-rows">{rows}</div>' + _BT_SORT_JS
|
|
||||||
|
# 중복(동일 params) 제거 → 타입 분류 → 타입별 검증수익 상위 N
|
||||||
|
uniq = {}
|
||||||
|
for r in res['results']:
|
||||||
|
uniq.setdefault(_pkey(r['params']), r)
|
||||||
|
|
||||||
|
def _sk(r):
|
||||||
|
te = r['test']
|
||||||
|
enough = (te.get('trades', 0) or 0) >= min_tr
|
||||||
|
return (1 if enough else 0, te.get('total_return_pct', -999) or -999)
|
||||||
|
|
||||||
|
grouped = {k: [] for k, _, _ in _BT_TYPES}
|
||||||
|
for r in uniq.values():
|
||||||
|
grouped.setdefault(_strategy_type(r['params']), []).append(r)
|
||||||
|
|
||||||
|
# 비교군 등록 조합 — 성적순위와 무관하게 항상 맨 위에 표시
|
||||||
|
reg_items = sorted([r for r in uniq.values() if _pkey(r['params']) in registered],
|
||||||
|
key=_sk, reverse=True)
|
||||||
|
cmp_section = ''
|
||||||
|
if reg_items:
|
||||||
|
cmp_section = ('<h3 class="bt-type">🔖 비교군 등록 조합'
|
||||||
|
f'<span class="bt-type-d">현재 병렬 비교 중인 {len(reg_items)}개 (성적순위 무관 항상 표시)</span></h3>')
|
||||||
|
for j, r in enumerate(reg_items):
|
||||||
|
cmp_section += _row_html(r, f'#{j + 1}')
|
||||||
|
|
||||||
|
sections = (f'<div class="sub" style="margin:2px 0 10px">베팅 성향 타입별 검증수익 상위 {_BT_TOPN}개씩 · '
|
||||||
|
f'비교군 추가는 각 행에서 (총 고유 {len(uniq)}조합)</div>')
|
||||||
|
for tk, tlabel, tdesc in _BT_TYPES:
|
||||||
|
grp = sorted(grouped.get(tk, []), key=_sk, reverse=True)[:_BT_TOPN]
|
||||||
|
if not grp:
|
||||||
|
continue
|
||||||
|
sections += (f'<h3 class="bt-type">{tlabel}<span class="bt-type-d">{tdesc}</span></h3>')
|
||||||
|
for j, r in enumerate(grp):
|
||||||
|
sections += _row_html(r, f'#{j + 1}')
|
||||||
|
return head + ctrl + f'<div id="bt-rows">{cmp_section}{sections}</div>'
|
||||||
|
|
||||||
|
|
||||||
def _compare_panel() -> str:
|
def _compare_panel() -> str:
|
||||||
@@ -512,14 +777,22 @@ def _compare_panel() -> str:
|
|||||||
f'· {len(rows_data)}개 (메인 포함)</div>'
|
f'· {len(rows_data)}개 (메인 포함)</div>'
|
||||||
'<div class="sub" style="color:#b45309">⚠ 같은 라이브 시세에 서로 다른 튜닝 동시 운영. '
|
'<div class="sub" style="color:#b45309">⚠ 같은 라이브 시세에 서로 다른 튜닝 동시 운영. '
|
||||||
'기간 길수록·거래 많을수록 신뢰 · 변이는 백테스트 탭의 「+ 비교군에 추가」로 등록</div>')
|
'기간 길수록·거래 많을수록 신뢰 · 변이는 백테스트 탭의 「+ 비교군에 추가」로 등록</div>')
|
||||||
|
clear_btn = ('' if not defs else
|
||||||
|
'<form method="post" action="/variants_clear_all" style="display:inline;margin-left:8px" '
|
||||||
|
'onsubmit="return confirm(\'메인을 제외한 비교군 변이를 전부 삭제할까요?\')">'
|
||||||
|
f'<button type="submit" class="cmp-clear">🗑 전체삭제 {len(defs)}</button></form>')
|
||||||
|
title = f'<h2 class="cmp-title">병렬 페이퍼 비교{clear_btn}</h2>'
|
||||||
rows = ''
|
rows = ''
|
||||||
for i, v in enumerate(rows_data):
|
for i, v in enumerate(rows_data):
|
||||||
s = v['summary']
|
s = v['summary']
|
||||||
ret = s.get('total_return_pct', 0) or 0
|
ret = s.get('total_return_pct', 0) or 0
|
||||||
rc = '#22c55e' if ret >= 0 else '#ef4444'
|
rc = '#22c55e' if ret >= 0 else '#ef4444'
|
||||||
is_main = v['id'] == 'main'
|
is_main = v['id'] == 'main'
|
||||||
tag = ' <span class="tag">메인</span>' if is_main else ''
|
tag = ' <span class="badge-ic" title="메인 — 현재 적용된 튜닝">⭐</span>' if is_main else ''
|
||||||
plabel = '·'.join(f'{k.split("_")[0]}{val:g}' for k, val in (v.get('params') or {}).items()) or '기본값'
|
plabel = '·'.join(f'{k.split("_")[0]}{val:g}' for k, val in (v.get('params') or {}).items()) or '기본값'
|
||||||
|
mp = v.get('params') or config.load_params()
|
||||||
|
mf_t, mf_b = _market_fit(mp)
|
||||||
|
mf_sum = _market_fit_summary(mp)
|
||||||
actions = '' if is_main else (
|
actions = '' if is_main else (
|
||||||
f'<div class="cmp-row-actions">'
|
f'<div class="cmp-row-actions">'
|
||||||
f'<form method="post" action="/variants_reset_one" '
|
f'<form method="post" action="/variants_reset_one" '
|
||||||
@@ -534,7 +807,7 @@ def _compare_panel() -> str:
|
|||||||
)
|
)
|
||||||
rows += (
|
rows += (
|
||||||
f'<details class="row"{" open" if i == 0 else ""}><summary>'
|
f'<details class="row"{" open" if i == 0 else ""}><summary>'
|
||||||
f'<span class="muted">#{i+1}</span> <span class="nm">{_esc(v["name"])}</span>{tag}'
|
f'<span class="muted">#{i+1}</span> <span class="nm">{_esc(mf_sum)}</span>{tag}'
|
||||||
f'<span class="rt"><b style="color:{rc}">{ret:+g}%</b></span></summary>'
|
f'<span class="rt"><b style="color:{rc}">{ret:+g}%</b></span></summary>'
|
||||||
f'<div class="body">'
|
f'<div class="body">'
|
||||||
f'<div class="kv"><span class="k">평가자산</span><span>{s.get("equity",0):,}원</span></div>'
|
f'<div class="kv"><span class="k">평가자산</span><span>{s.get("equity",0):,}원</span></div>'
|
||||||
@@ -545,10 +818,37 @@ def _compare_panel() -> str:
|
|||||||
f'<div class="kv"><span class="k">MDD / 보유</span><span>{s.get("max_drawdown_pct",0)}% · {s.get("open_positions",0)}종목</span></div>'
|
f'<div class="kv"><span class="k">MDD / 보유</span><span>{s.get("max_drawdown_pct",0)}% · {s.get("open_positions",0)}종목</span></div>'
|
||||||
f'<div class="kv"><span class="k">지수대비(알파)</span><span>{_alpha_inline(v.get("benchmark") or {})}</span></div>'
|
f'<div class="kv"><span class="k">지수대비(알파)</span><span>{_alpha_inline(v.get("benchmark") or {})}</span></div>'
|
||||||
f'<div class="kv"><span class="k">파라미터</span><span class="muted">{_esc(plabel)}</span></div>'
|
f'<div class="kv"><span class="k">파라미터</span><span class="muted">{_esc(plabel)}</span></div>'
|
||||||
|
f'<button type="button" class="mfit" data-title="{_esc(mf_t)}" data-detail="{_esc(mf_b)}">🌐 어떤 시장에 강한가</button>'
|
||||||
f'{actions}'
|
f'{actions}'
|
||||||
f'</div></details>'
|
f'</div></details>'
|
||||||
)
|
)
|
||||||
return head + rows
|
return title + head + rows
|
||||||
|
|
||||||
|
|
||||||
|
def _engine_status(snap, summ) -> tuple[str, str]:
|
||||||
|
"""스캔 엔진 헬스 → (점 클래스 ok/idle/bad, 토스트 상세). 장중 최근스캔=초록, 장중 멈춤=빨강, 장외=대기."""
|
||||||
|
now = datetime.now(config.KST)
|
||||||
|
session = engine.is_market_session(now)
|
||||||
|
ts = snap.get('scanned_at')
|
||||||
|
age_min, last_str = None, '—'
|
||||||
|
if ts:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(ts)
|
||||||
|
age_min = (now - dt).total_seconds() / 60
|
||||||
|
last_str = dt.strftime('%m-%d %H:%M:%S')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
asset = (f"자산 {summ.get('equity', 0):,}원 · 보유 {summ.get('open_positions', 0)}종목 "
|
||||||
|
f"· 현금 {summ.get('cash', 0):,}원")
|
||||||
|
if session:
|
||||||
|
if age_min is not None and age_min <= 12:
|
||||||
|
return 'ok', (f"🟢 시뮬 가동 중\n장중 · 5분 주기 자동 스캔\n"
|
||||||
|
f"마지막 스캔 {last_str} ({age_min:.0f}분 전)\n{asset}")
|
||||||
|
agetxt = f'{age_min:.0f}분 전' if age_min is not None else '기록 없음'
|
||||||
|
return 'bad', (f"🔴 스캔 멈춤 의심\n장중인데 마지막 스캔이 {agetxt}입니다.\n"
|
||||||
|
f"sim-scan launchd 점검이 필요할 수 있어요.\n{asset}")
|
||||||
|
return 'idle', (f"🟡 대기 중 (장 마감/장외)\n다음 개장(평일 09:00)에 자동 재개돼요.\n"
|
||||||
|
f"마지막 스캔 {last_str}\n{asset}")
|
||||||
|
|
||||||
|
|
||||||
def render_html() -> str:
|
def render_html() -> str:
|
||||||
@@ -565,21 +865,21 @@ def render_html() -> str:
|
|||||||
|
|
||||||
s = pf.summary()
|
s = pf.summary()
|
||||||
scanned = snap.get('scanned_at', '')[:19].replace('T', ' ')
|
scanned = snap.get('scanned_at', '')[:19].replace('T', ' ')
|
||||||
|
hcls, hdetail = _engine_status(snap, s)
|
||||||
ret = s.get('total_return_pct', 0) or 0
|
ret = s.get('total_return_pct', 0) or 0
|
||||||
ret_color = '#22c55e' if ret >= 0 else '#ef4444'
|
ret_color = '#22c55e' if ret >= 0 else '#ef4444'
|
||||||
|
|
||||||
# ---- KPI ----
|
# ---- 계좌 요약 (한 줄씩 나열) ----
|
||||||
|
wr = s.get('win_rate_pct')
|
||||||
kpis = (
|
kpis = (
|
||||||
_kpi('평가자산', f"{s.get('equity', 0):,}원", f"초기 {s.get('initial', 0):,}") +
|
_kvline('평가자산', f"{s.get('equity', 0):,}원 <span class=\"muted\">(초기 {s.get('initial', 0):,})</span>") +
|
||||||
_kpi('총보유(주식)', f"{s.get('held_value', 0):,}원") +
|
_kvline('총보유(주식)', f"{s.get('held_value', 0):,}원") +
|
||||||
_kpi('예수금', f"{s.get('cash', 0):,}원") +
|
_kvline('예수금', f"{s.get('cash', 0):,}원") +
|
||||||
f'<div class="kpi"><div class="kpi-label">수익률</div>'
|
_kvline('수익률', f'<b style="color:{ret_color}">{ret:+.2f}%</b>') +
|
||||||
f'<div class="kpi-val" style="color:{ret_color}">{ret:+.2f}%</div></div>' +
|
_kvline('실현손익', f"{s.get('realized_pnl', 0):,}원") +
|
||||||
_kpi('실현손익', f"{s.get('realized_pnl', 0):,}") +
|
_kvline('승률', f"{wr}% <span class=\"muted\">({s.get('closed_trades', 0)}건 청산)</span>" if wr is not None else '—') +
|
||||||
_kpi('승률', f"{s.get('win_rate_pct')}%" if s.get('win_rate_pct') is not None else '—',
|
_kvline('MDD', f"{s.get('max_drawdown_pct', 0)}%") +
|
||||||
f"{s.get('closed_trades', 0)}건 청산") +
|
_kvline('보유', f"{s.get('open_positions', 0)}종목")
|
||||||
_kpi('MDD', f"{s.get('max_drawdown_pct', 0)}%") +
|
|
||||||
_kpi('보유', f"{s.get('open_positions', 0)}종목")
|
|
||||||
)
|
)
|
||||||
# 기준지수 대비(알파) — 시작일 대비 같은 기간 지수 매수후보유와 비교
|
# 기준지수 대비(알파) — 시작일 대비 같은 기간 지수 매수후보유와 비교
|
||||||
bench = snap.get('benchmark', {}) or {}
|
bench = snap.get('benchmark', {}) or {}
|
||||||
@@ -587,12 +887,11 @@ def render_html() -> str:
|
|||||||
b = (bench.get('indices') or {}).get(idx, {})
|
b = (bench.get('indices') or {}).get(idx, {})
|
||||||
a, ip = b.get('alpha'), b.get('pct')
|
a, ip = b.get('alpha'), b.get('pct')
|
||||||
if a is None:
|
if a is None:
|
||||||
kpis += _kpi(f'{lbl} 대비', '—', '지수 데이터 없음')
|
kpis += _kvline(f'{lbl} 대비(알파)', '— <span class="muted">(지수 데이터 없음)</span>')
|
||||||
else:
|
else:
|
||||||
ac = '#22c55e' if a >= 0 else '#ef4444'
|
ac = '#22c55e' if a >= 0 else '#ef4444'
|
||||||
kpis += (f'<div class="kpi"><div class="kpi-label">{lbl} 대비(알파)</div>'
|
kpis += _kvline(f'{lbl} 대비(알파)',
|
||||||
f'<div class="kpi-val" style="color:{ac}">{a:+.2f}%p</div>'
|
f'<b style="color:{ac}">{a:+.2f}%p</b> <span class="muted">(지수 {ip:+.2f}%)</span>')
|
||||||
f'<div class="kpi-sub">지수 {ip:+.2f}%</div></div>')
|
|
||||||
|
|
||||||
# ---- 보유 포지션 ----
|
# ---- 보유 포지션 ----
|
||||||
pos_rows = ''
|
pos_rows = ''
|
||||||
@@ -734,10 +1033,10 @@ h2{{font-size:15px;margin:22px 0 8px;border-bottom:1px solid #1f2937;padding-bot
|
|||||||
h3.grp{{font-size:13px;margin:16px 0 8px;color:#cbd5e1}}
|
h3.grp{{font-size:13px;margin:16px 0 8px;color:#cbd5e1}}
|
||||||
.badge{{color:#fff;border-radius:6px;padding:1px 8px;font-size:12px;font-weight:600}}
|
.badge{{color:#fff;border-radius:6px;padding:1px 8px;font-size:12px;font-weight:600}}
|
||||||
.row{{background:#141b24;border:1px solid #1f2937;border-radius:10px;margin-bottom:8px;overflow:hidden}}
|
.row{{background:#141b24;border:1px solid #1f2937;border-radius:10px;margin-bottom:8px;overflow:hidden}}
|
||||||
.row>summary{{list-style:none;cursor:pointer;padding:10px 12px;display:flex;align-items:center;gap:8px;font-size:13px}}
|
.row>summary{{list-style:none;cursor:pointer;padding:10px 12px;display:flex;flex-wrap:nowrap;align-items:center;gap:8px;font-size:13px;overflow:hidden}}
|
||||||
.row>summary::-webkit-details-marker{{display:none}}
|
.row>summary::-webkit-details-marker{{display:none}}
|
||||||
.row>summary .nm{{font-weight:600}}
|
.row>summary .nm{{font-weight:600;flex:1 1 auto;min-width:0;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}
|
||||||
.row>summary .rt{{margin-left:auto;display:flex;align-items:center;gap:8px;font-variant-numeric:tabular-nums}}
|
.row>summary .rt{{margin-left:auto;display:flex;align-items:center;gap:8px;font-variant-numeric:tabular-nums;flex:0 0 auto;white-space:nowrap;font-size:11px}}
|
||||||
.row>summary::after{{content:'›';color:#6b7280;font-size:16px;line-height:1;transition:transform .15s}}
|
.row>summary::after{{content:'›';color:#6b7280;font-size:16px;line-height:1;transition:transform .15s}}
|
||||||
.row[open]>summary::after{{transform:rotate(90deg)}}
|
.row[open]>summary::after{{transform:rotate(90deg)}}
|
||||||
.row .body{{padding:2px 12px 10px;border-top:1px solid #161e29}}
|
.row .body{{padding:2px 12px 10px;border-top:1px solid #161e29}}
|
||||||
@@ -753,6 +1052,24 @@ h3.grp{{font-size:13px;margin:16px 0 8px;color:#cbd5e1}}
|
|||||||
.chks{{display:flex;flex-wrap:wrap;gap:5px}}
|
.chks{{display:flex;flex-wrap:wrap;gap:5px}}
|
||||||
.chk{{background:#0f151c;border:1px solid #1f2937;border-radius:6px;padding:2px 7px;font-size:11px}}
|
.chk{{background:#0f151c;border:1px solid #1f2937;border-radius:6px;padding:2px 7px;font-size:11px}}
|
||||||
.chk .cd{{color:#6b7280;margin-left:5px}}
|
.chk .cd{{color:#6b7280;margin-left:5px}}
|
||||||
|
.chk-i{{background:none;border:none;color:#60a5fa;font-size:11px;cursor:pointer;padding:0 0 0 4px;line-height:1;vertical-align:baseline}}
|
||||||
|
.hdot{{display:inline-block;width:11px;height:11px;border-radius:50%;margin-left:6px;cursor:pointer;vertical-align:middle;background:#6b7280}}
|
||||||
|
.hdot.ok{{background:#22c55e;box-shadow:0 0 6px #22c55e}}
|
||||||
|
.hdot.idle{{background:#eab308}}
|
||||||
|
.hdot.bad{{background:#ef4444;box-shadow:0 0 7px #ef4444;animation:hdot-bl 1s infinite}}
|
||||||
|
@keyframes hdot-bl{{50%{{opacity:.35}}}}
|
||||||
|
.mfit{{display:block;width:100%;margin:8px 0 2px;background:#0e2236;color:#7dd3fc;border:1px solid #1e3a5f;border-radius:8px;padding:8px;font-size:12px;font-weight:600;cursor:pointer}}
|
||||||
|
.bt-type{{margin:18px 0 8px;font-size:14px;font-weight:700;border-bottom:1px solid #1f2937;padding-bottom:5px}}
|
||||||
|
.bt-type .bt-type-d{{display:block;font-size:11px;font-weight:400;color:#9ca3af;margin-top:2px}}
|
||||||
|
.cmp-title{{display:flex;align-items:center}}
|
||||||
|
.cmp-clear{{background:#3a1518;color:#fca5a5;border:1px solid #7f1d1d;border-radius:6px;padding:3px 8px;font-size:11px;font-weight:600;cursor:pointer}}
|
||||||
|
.badge-ic{{flex:0 0 auto;font-size:12px;line-height:1}}
|
||||||
|
#chk-toast{{position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center}}
|
||||||
|
#chk-toast .ct-card{{background:#141b24;border:1px solid #2952cc;border-radius:14px 14px 0 0;padding:16px 18px 22px;max-width:520px;width:100%;box-shadow:0 -4px 24px rgba(0,0,0,.5);animation:ct-up .18s ease-out}}
|
||||||
|
@keyframes ct-up{{from{{transform:translateY(100%)}}to{{transform:translateY(0)}}}}
|
||||||
|
#chk-toast .ct-t{{font-size:15px;font-weight:700;color:#fff;margin-bottom:8px}}
|
||||||
|
#chk-toast .ct-d{{font-size:13px;line-height:1.6;color:#cbd5e1;white-space:pre-line}}
|
||||||
|
#chk-toast .ct-x{{margin-top:14px;width:100%;background:#1e3a8a;color:#fff;border:1px solid #2952cc;border-radius:9px;padding:10px;font-size:13px;font-weight:600;cursor:pointer}}
|
||||||
input[name="tab"],input[name="subtab"]{{display:none}}
|
input[name="tab"],input[name="subtab"]{{display:none}}
|
||||||
.hd{{position:sticky;top:0;z-index:30;background:#0b0f14;padding:6px 0;border-bottom:1px solid #161e29}}
|
.hd{{position:sticky;top:0;z-index:30;background:#0b0f14;padding:6px 0;border-bottom:1px solid #161e29}}
|
||||||
#page.dragging .hd{{position:static}}
|
#page.dragging .hd{{position:static}}
|
||||||
@@ -827,16 +1144,16 @@ input[name="tab"],input[name="subtab"]{{display:none}}
|
|||||||
<input type="radio" name="tab" id="tab-watch">
|
<input type="radio" name="tab" id="tab-watch">
|
||||||
<input type="radio" name="tab" id="tab-market">
|
<input type="radio" name="tab" id="tab-market">
|
||||||
<input type="radio" name="tab" id="tab-lab">
|
<input type="radio" name="tab" id="tab-lab">
|
||||||
<input type="radio" name="subtab" id="sub-tune" checked>
|
<input type="radio" name="subtab" id="sub-cmp" checked>
|
||||||
<input type="radio" name="subtab" id="sub-bt">
|
<input type="radio" name="subtab" id="sub-bt">
|
||||||
<input type="radio" name="subtab" id="sub-cmp">
|
<input type="radio" name="subtab" id="sub-tune">
|
||||||
<header class="hd">
|
<header class="hd">
|
||||||
<h1> 레이 자동매매 시뮬레이션</h1>
|
<h1> 레이 자동매매 시뮬레이션 <span id="health" class="hdot {hcls}" data-detail="{_esc(hdetail)}" role="button" tabindex="0" title="시뮬 상태 보기"></span></h1>
|
||||||
<div class=sub>마지막 스캔 {scanned or '—'} · 시장 {mkt_h}</div>
|
<div class=sub>마지막 스캔 {scanned or '—'} · 시장 {mkt_h}</div>
|
||||||
<nav class="tabs"><label for="tab-acct">내 계좌</label><label for="tab-watch">관심종목 ({len(decisions)})</label><label for="tab-market">시장</label><label for="tab-lab">전략</label></nav>
|
<nav class="tabs"><label for="tab-acct">내 계좌</label><label for="tab-watch">관심종목 ({len(decisions)})</label><label for="tab-market">시장</label><label for="tab-lab">전략</label></nav>
|
||||||
</header>
|
</header>
|
||||||
<section class="panel" id="p-acct">
|
<section class="panel" id="p-acct">
|
||||||
<div class=kpis>{kpis}</div>
|
<div class="card" style="margin-bottom:18px">{kpis}</div>
|
||||||
<h2>보유 포지션</h2>{pos_table}
|
<h2>보유 포지션</h2>{pos_table}
|
||||||
<h2>최근 거래</h2>{tr_table}
|
<h2>최근 거래</h2>{tr_table}
|
||||||
</section>
|
</section>
|
||||||
@@ -853,16 +1170,17 @@ input[name="tab"],input[name="subtab"]{{display:none}}
|
|||||||
<h2>시장 동향</h2>{market_html}
|
<h2>시장 동향</h2>{market_html}
|
||||||
</section>
|
</section>
|
||||||
<section class="panel" id="p-lab">
|
<section class="panel" id="p-lab">
|
||||||
<nav class="subtabs"><label for="sub-tune">튜닝</label><label for="sub-bt">백테스트</label><label for="sub-cmp">비교</label></nav>
|
<nav class="subtabs"><label for="sub-cmp">비교</label><label for="sub-bt">백테스트</label><label for="sub-tune">튜닝</label></nav>
|
||||||
<div class="subpanel" id="ps-tune"><h2>파라미터 튜닝</h2>{tune_html}</div>
|
<div class="subpanel" id="ps-cmp">{cmp_html}</div>
|
||||||
<div class="subpanel" id="ps-bt"><h2>백테스트 순위표</h2>{bt_html}</div>
|
<div class="subpanel" id="ps-bt"><h2>백테스트 순위표</h2>{bt_html}</div>
|
||||||
<div class="subpanel" id="ps-cmp"><h2>병렬 페이퍼 비교</h2>{cmp_html}</div>
|
<div class="subpanel" id="ps-tune"><h2>파라미터 튜닝</h2>{tune_html}</div>
|
||||||
</section>
|
</section>
|
||||||
<div class=sub style="margin-top:20px">60초 자동 새로고침 · 당겨서 새로고침 · sim/ 가상 엔진 (실주문 아님)</div>
|
<div class=sub style="margin-top:20px">60초 자동 새로고침 · 당겨서 새로고침 · sim/ 가상 엔진 (실주문 아님)</div>
|
||||||
</div>
|
</div>
|
||||||
{PTR_HTML}
|
{PTR_HTML}
|
||||||
{REFRESH_JS}
|
{REFRESH_JS}
|
||||||
{_WATCH_SEARCH_JS}
|
{_WATCH_SEARCH_JS}
|
||||||
|
{_CHK_HELP_JS}
|
||||||
</body></html>"""
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
@@ -902,7 +1220,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_header('Location', '/?t=lab&s=bt')
|
self.send_header('Location', '/?t=lab&s=bt')
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
return
|
return
|
||||||
if self.path in ('/variants_add', '/variants_reset_one', '/variants_remove'):
|
if self.path in ('/variants_add', '/variants_reset_one', '/variants_remove', '/variants_clear_all'):
|
||||||
self._handle_variants()
|
self._handle_variants()
|
||||||
return
|
return
|
||||||
if self.path in ('/watch_add', '/watch_remove'):
|
if self.path in ('/watch_add', '/watch_remove'):
|
||||||
@@ -948,6 +1266,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
vmod.reset_variant(form['id'])
|
vmod.reset_variant(form['id'])
|
||||||
elif self.path == '/variants_remove' and form.get('id'):
|
elif self.path == '/variants_remove' and form.get('id'):
|
||||||
vmod.remove_variant(form['id'])
|
vmod.remove_variant(form['id'])
|
||||||
|
elif self.path == '/variants_clear_all':
|
||||||
|
vmod.clear() # 등록 변이 전부 삭제 (메인은 variants.json에 없어 보존)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
sys.stderr.write(f'[sim_web] variants {self.path} 실패: {e}\n')
|
sys.stderr.write(f'[sim_web] variants {self.path} 실패: {e}\n')
|
||||||
self.send_response(303)
|
self.send_response(303)
|
||||||
|
|||||||
@@ -15,13 +15,18 @@ from datetime import datetime
|
|||||||
from . import backtest, config, universe
|
from . import backtest, config, universe
|
||||||
|
|
||||||
# 백테스트에서 실제 영향 있는 파라미터만 스윕 (애널 게이트·시장필터는 backtest 중립이라 제외).
|
# 백테스트에서 실제 영향 있는 파라미터만 스윕 (애널 게이트·시장필터는 backtest 중립이라 제외).
|
||||||
# SMA_LONG 5단계로 추세 속도 다양화 + 돌파 강도 추가. 5×3×3×3×2 = 270 조합.
|
# 기존 5 + 신규 4(RSI·시간손절·리스크) 탐색. 조합폭발 억제 위해 기존 그리드는 핵심값으로 축소.
|
||||||
|
# base 3×3×2×2×2 = 72 × 신규 2×2×2×2 = 16 → 1152 조합.
|
||||||
GRID = {
|
GRID = {
|
||||||
'SMA_LONG': [10, 20, 40, 60, 120], # 추세 속도 — 빠른/느린 (다양성 핵심)
|
'SMA_LONG': [10, 20, 40], # 추세 속도 — 빠른/느린 (다양성 핵심)
|
||||||
'RR_RATIO': [1.5, 2.0, 3.0], # 손익비
|
'RR_RATIO': [1.5, 2.0, 3.0], # 손익비
|
||||||
'STOP_ATR_MULT': [1.5, 2.0, 3.0], # 손절 폭
|
'STOP_ATR_MULT': [1.5, 2.0], # 손절 폭
|
||||||
'PULLBACK_ATR_MULT': [0.5, 1.0, 1.5], # 눌림 깊이
|
'PULLBACK_ATR_MULT': [1.0, 1.5], # 눌림 깊이
|
||||||
'VOLUME_BREAKOUT_MULT': [1.3, 2.0], # 돌파 거래량 강도
|
'VOLUME_BREAKOUT_MULT': [1.3, 2.0], # 돌파 거래량 강도
|
||||||
|
'RSI_OVERBOUGHT': [75, 88], # 돌파 과열 컷 (엄격/느슨)
|
||||||
|
'RSI_OVERSOLD': [20, 35], # 눌림목 과매도 컷 (느슨/엄격)
|
||||||
|
'MAX_HOLD_DAYS': [0, 20], # 시간손절 끔/20일
|
||||||
|
'RISK_PER_TRADE_PCT': [0.01, 0.02], # 종목당 리스크 1%/2%
|
||||||
}
|
}
|
||||||
|
|
||||||
MIN_TRADES = 10 # 검증기간 거래 이 미만이면 표본 부족 → 하위로
|
MIN_TRADES = 10 # 검증기간 거래 이 미만이면 표본 부족 → 하위로
|
||||||
@@ -87,9 +92,12 @@ if __name__ == '__main__':
|
|||||||
print('스윕 실패:', res)
|
print('스윕 실패:', res)
|
||||||
else:
|
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} | {'학습수익%':>8} {'검증수익%':>8} {'검증MDD%':>8} {'검증거래':>6} {'승률%':>6}")
|
print(f"{'RR':>4} {'STOP':>5} {'SMA':>4} {'PULL':>5} {'RSI±':>8} {'HOLD':>5} {'RISK':>5} | "
|
||||||
|
f"{'검증수익%':>8} {'검증MDD%':>8} {'거래':>5} {'승률%':>6}")
|
||||||
for r in res['results'][:12]:
|
for r in res['results'][:12]:
|
||||||
p, te, tr = r['params'], r['test'], r['train']
|
p, te = r['params'], r['test']
|
||||||
print(f"{p['RR_RATIO']:>4} {p['STOP_ATR_MULT']:>5} {p['SMA_LONG']:>4} {p['PULLBACK_ATR_MULT']:>5} | "
|
rsi = f"{p.get('RSI_OVERBOUGHT','-')}/{p.get('RSI_OVERSOLD','-')}"
|
||||||
f"{tr['total_return_pct']:>8} {te['total_return_pct']:>8} {te['mdd_pct']:>8} "
|
print(f"{p['RR_RATIO']:>4} {p['STOP_ATR_MULT']:>5} {p['SMA_LONG']:>4} {p['PULLBACK_ATR_MULT']:>5} "
|
||||||
f"{te['trades']:>6} {str(te['win_rate_pct']):>6}")
|
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"{te['trades']:>5} {str(te['win_rate_pct']):>6}")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -18,6 +19,16 @@ sys.path.insert(0, str(config.SCRIPTS))
|
|||||||
import kiwoom_client as kc # noqa: E402
|
import kiwoom_client as kc # noqa: E402
|
||||||
|
|
||||||
WATCHLIST_PATH = config.WORKSPACE / 'state' / 'behive_watchlist.json'
|
WATCHLIST_PATH = config.WORKSPACE / 'state' / 'behive_watchlist.json'
|
||||||
|
|
||||||
|
# ETF/ETN 운용사 브랜드 프리픽스 — 자동매수 후보에서 제외 (방향성=애널 컨센서스가 ETF엔 없어 전략 부정합)
|
||||||
|
_ETF_PREFIX = re.compile(
|
||||||
|
r'^(KODEX|TIGER|KBSTAR|ARIRANG|HANARO|ACE|SOL|PLUS|RISE|KOSEF|TIMEFOLIO|히어로즈|FOCUS|TREX|BNK|WON|마이다스|에셋플러스|KIWOOM|파워|마이티)',
|
||||||
|
re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def is_etf(name: str) -> bool:
|
||||||
|
"""종목명 운용사 프리픽스로 ETF/ETN 판별 (보유분 청산은 별도라 매수 후보 필터 전용)."""
|
||||||
|
return bool(name and _ETF_PREFIX.match(name.strip()))
|
||||||
INTERESTS_PATH = config.WORKSPACE / 'state' / 'behive_interests.json'
|
INTERESTS_PATH = config.WORKSPACE / 'state' / 'behive_interests.json'
|
||||||
SIM_WATCHLIST_PATH = config.STATE_DIR / 'watchlist.json' # sim 누적 관찰목록
|
SIM_WATCHLIST_PATH = config.STATE_DIR / 'watchlist.json' # sim 누적 관찰목록
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ def variant_paths(vid: str):
|
|||||||
def _label(params: dict) -> str:
|
def _label(params: dict) -> str:
|
||||||
short = {'RR_RATIO': 'RR', 'STOP_ATR_MULT': 'S', 'SMA_LONG': 'SMA',
|
short = {'RR_RATIO': 'RR', 'STOP_ATR_MULT': 'S', 'SMA_LONG': 'SMA',
|
||||||
'PULLBACK_ATR_MULT': 'P', 'VOLUME_BREAKOUT_MULT': 'V',
|
'PULLBACK_ATR_MULT': 'P', 'VOLUME_BREAKOUT_MULT': 'V',
|
||||||
'TURNOVER_OVERHEAT_MULT': 'OH'}
|
'TURNOVER_OVERHEAT_MULT': 'OH', 'RSI_OVERBOUGHT': 'RSI↑',
|
||||||
|
'RSI_OVERSOLD': 'RSI↓', 'MAX_HOLD_DAYS': 'HOLD', 'RISK_PER_TRADE_PCT': 'RISK'}
|
||||||
return '·'.join(f'{short.get(k, k)}{v:g}' for k, v in params.items())
|
return '·'.join(f'{short.get(k, k)}{v:g}' for k, v in params.items())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,3 +22,5 @@
|
|||||||
{"date": "2026-06-05", "market": "KOSPI", "market_label": "코스피", "rise": 225, "upper": 1, "fall": 672, "lower": 0, "steady": 26, "adr": 33.63, "personal": 42240, "foreign": -27638, "institutional": -13809, "captured_at": "2026-06-05T21:00:05.007446+09:00"}
|
{"date": "2026-06-05", "market": "KOSPI", "market_label": "코스피", "rise": 225, "upper": 1, "fall": 672, "lower": 0, "steady": 26, "adr": 33.63, "personal": 42240, "foreign": -27638, "institutional": -13809, "captured_at": "2026-06-05T21:00:05.007446+09:00"}
|
||||||
{"date": "2026-06-08", "market": "KOSDAQ", "market_label": "코스닥", "rise": 75, "upper": 7, "fall": 1634, "lower": 1, "steady": 27, "adr": 5.02, "personal": -1245, "foreign": 2976, "institutional": -1466, "captured_at": "2026-06-08T21:00:04.897696+09:00"}
|
{"date": "2026-06-08", "market": "KOSDAQ", "market_label": "코스닥", "rise": 75, "upper": 7, "fall": 1634, "lower": 1, "steady": 27, "adr": 5.02, "personal": -1245, "foreign": 2976, "institutional": -1466, "captured_at": "2026-06-08T21:00:04.897696+09:00"}
|
||||||
{"date": "2026-06-08", "market": "KOSPI", "market_label": "코스피", "rise": 42, "upper": 1, "fall": 876, "lower": 0, "steady": 3, "adr": 4.91, "personal": 17628, "foreign": -2644, "institutional": -17164, "captured_at": "2026-06-08T21:00:04.897696+09:00"}
|
{"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"}
|
||||||
|
|||||||
@@ -76,3 +76,5 @@
|
|||||||
{"date": "2026-06-08", "account": "ISA", "owner": "본인", "code": "0193W0", "name": "KODEX 삼성전자단일종목레버리지", "buy_qty": 12, "buy_avg": 21100, "buy_amt": 253200, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 30, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"}
|
{"date": "2026-06-08", "account": "ISA", "owner": "본인", "code": "0193W0", "name": "KODEX 삼성전자단일종목레버리지", "buy_qty": 12, "buy_avg": 21100, "buy_amt": 253200, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 30, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"}
|
||||||
{"date": "2026-06-08", "account": "가희_ISA", "owner": "가희", "code": "000660", "name": "SK하이닉스", "buy_qty": 1, "buy_avg": 1970000, "buy_amt": 1970000, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 290, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"}
|
{"date": "2026-06-08", "account": "가희_ISA", "owner": "가희", "code": "000660", "name": "SK하이닉스", "buy_qty": 1, "buy_avg": 1970000, "buy_amt": 1970000, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 290, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"}
|
||||||
{"date": "2026-06-08", "account": "가희_ISA", "owner": "가희", "code": "005930", "name": "삼성전자", "buy_qty": 10, "buy_avg": 299750, "buy_amt": 2997500, "sell_qty": 0, "sell_avg": 0, "sell_amt": 0, "pl_amt": 0, "cmsn_tax": 440, "prft_rt": 0.0, "collected_at": "2026-06-08T23:17:34.110543+09:00"}
|
{"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"}
|
||||||
|
|||||||
Reference in New Issue
Block a user