auto: 일일 백업 2026-07-22 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-07-22 02:00:04 +09:00
parent 1d6fe0f70d
commit 2cf5b38da1
195 changed files with 11185 additions and 8580 deletions
+557 -49
View File
@@ -79,6 +79,7 @@ QUOTE_WORKERS = 8
QUOTE_CACHE_TTL = 30.0 # 첫 cold 8s 동안 분산 박힌 캐시가 다음 RENDER 만료(10s) 시점에 살아있도록. 가격 stale 최대 30s.
sys.path.insert(0, str(WORKSPACE / 'scripts'))
sys.path.insert(0, str(WORKSPACE)) # sim 패키지(from sim import signals, config) import 용
_holidays_cache: dict = {'mtime': 0.0, 'set': set()}
@@ -87,6 +88,155 @@ _holding_since_cache: dict = {'mtime': None, 'map': {}}
_quote_cache: dict[str, tuple[dict, float]] = {}
_quote_cache_lock = threading.Lock()
# 계좌별 미체결(ka10075) 캐시 — 거래 모달 max_qty 계산용. 미체결은 가격과 무관하므로
# 단가 keystroke마다 재조회하지 않도록 짧은 TTL로 묶는다. 주문 접수 후 프론트가 재조회하면
# 최대 TTL 만큼 stale할 수 있으나, propose/키움 서버가 최종 검증하므로 표시용으론 충분.
OPEN_ORDERS_CACHE_TTL = 15.0
_open_orders_cache: dict[str, tuple[list, float]] = {}
_open_orders_cache_lock = threading.Lock()
def _cached_open_orders(account_label: str) -> list:
"""계좌 미체결 주문 목록 (ka10075) — TTL 캐시. 실패 시 빈 리스트."""
now = time.time()
with _open_orders_cache_lock:
hit = _open_orders_cache.get(account_label)
if hit and (now - hit[1]) < OPEN_ORDERS_CACHE_TTL:
return hit[0]
try:
import kiwoom_client as kc
rows = kc.get_open_orders(account_label)
except Exception:
return []
with _open_orders_cache_lock:
_open_orders_cache[account_label] = (rows, now)
return rows
def _pending_buy_lock(account_label: str) -> int:
"""계좌의 미체결 매수 지정가로 묶인 예수금 합계. d2_entra는 이 금액을 아직 포함하므로
실제 매수가능액 = d2_entra . 시장가(price=0) 미체결은 금액 미상이라 제외(render 경로와 동일)."""
total = 0
for o in _cached_open_orders(account_label):
if o.get('side') == 'BUY':
px = o.get('order_price') or 0
if px > 0:
total += px * (o.get('unfilled_qty') or 0)
return total
def _pending_sell_qty(account_label: str, code: str) -> int:
"""계좌·종목의 미체결 매도 수량 합계."""
total = 0
for o in _cached_open_orders(account_label):
if o.get('side') == 'SELL' and o.get('code') == code:
total += o.get('unfilled_qty') or 0
return total
# 기술적 권장가(매수/매도 판단 보조) 캐시 — 완성된 일봉 기반이라 장중 안정적.
# 키: f'{code}:{side}:{avg_price}'. 현재가 대비 비교는 클라이언트가 실시간 시세로 처리.
ADVICE_CACHE_TTL = 120.0
_advice_cache: dict[str, tuple[dict, float]] = {}
_advice_cache_lock = threading.Lock()
def _price_advice(code: str, side: str, avg_price: int | None = None) -> dict | None:
"""일봉 기반 기술적 권장 매수가/매도가. 근거는 이평·ATR만(sim.signals 규칙 재사용).
반환: {'levels': [{label, price, hint, tap}], 'atr','sma20','rsi','price','recent_low','recent_high'}
데이터 부족(신규주)·ETF 등으로 지표 산출 불가 None (예외 raise ).
상수는 활성 sim.config 사용 params.json 튜닝이 걸려있으면 값이 반영됨(현재는 문서화된 기본값).
"""
side = (side or '').upper()
cache_key = f'{code}:{side}:{avg_price or 0}'
now = time.time()
with _advice_cache_lock:
hit = _advice_cache.get(cache_key)
if hit and (now - hit[1]) < ADVICE_CACHE_TTL:
return hit[0]
try:
from sim import signals, config
import daily_candles_cache as dcc
candles = dcc.get_candles(code, count=250)
ind = signals.compute_indicators(candles) if candles else None
except Exception:
traceback.print_exc()
ind = None
if not ind:
with _advice_cache_lock:
_advice_cache[cache_key] = (None, now)
return None
price = ind['price']
atr = ind['atr']
sma20 = ind['sma_long']
prev_close = ind['prev_close']
recent_high = ind['recent_high']
recent_low = ind['recent_low']
rsi = ind.get('rsi')
levels: list[dict] = []
if side == 'BUY':
pullback = int(round(max(sma20, prev_close - atr * config.PULLBACK_ATR_MULT)))
p_stop, p_target = signals.compute_stop_target(pullback, atr, recent_low)
levels.append({
'label': '눌림목 매수가', 'price': pullback, 'tap': True,
'hint': f'20일선({sma20:,.0f})과 전일종가−ATR({prev_close - atr * config.PULLBACK_ATR_MULT:,.0f}) 중 높은 값. '
f'여기까지 눌리면 매수 검토(지정가로 걸어둠).',
})
if recent_high:
levels.append({
'label': '돌파 기준가', 'price': int(recent_high), 'tap': True,
'hint': f'최근 {config.BREAKOUT_LOOKBACK}일 전고점. 이 위로 거래량 실려 뚫으면 돌파 매수 신호.',
})
levels.append({
'label': '(눌림목 매수 시) 예상 손절', 'price': p_stop, 'tap': False,
'hint': f'매수가−ATR×{config.STOP_ATR_MULT:g}와 최근 {config.SWING_LOW_LOOKBACK}일 저점 중 위쪽. 여기 깨지면 손절.',
})
levels.append({
'label': '(눌림목 매수 시) 목표가', 'price': p_target, 'tap': False,
'hint': f'손익비 {config.RR_RATIO:g}:1 — 손절폭의 {config.RR_RATIO:g}배 위. 1차 익절 목표.',
})
else: # SELL — 보유 청산. 평단 고정은 고수익주에서 붕괴 → 지지·저항·트레일링으로.
trail = int(round(price - atr * config.TRAIL_ATR_MULT))
levels.append({
'label': '트레일링 손절', 'price': trail, 'tap': True,
'hint': f'현재가−ATR×{config.TRAIL_ATR_MULT:g}. 고점 대비 이만큼 되돌리면 익절/이탈(수익 보호).',
})
levels.append({
'label': '20일선 지지', 'price': int(round(sma20)), 'tap': True,
'hint': '20일 이동평균선. 추세 지지선 — 종가로 깨지면 상승추세 이탈 신호.',
})
if recent_low:
levels.append({
'label': f'{config.SWING_LOW_LOOKBACK}일 스윙저점', 'price': int(recent_low), 'tap': True,
'hint': f'최근 {config.SWING_LOW_LOOKBACK}일 최저가. 이 아래로 밀리면 하락 전환 경계.',
})
if recent_high:
levels.append({
'label': '전고점(저항/목표)', 'price': int(recent_high), 'tap': True,
'hint': f'최근 {config.BREAKOUT_LOOKBACK}일 전고점. 1차 저항 — 도달 시 분할 익절 고려.',
})
# 평단 근처(±ATR)일 때만 평단 기준 손절선 추가 — 그 밖(고수익/큰손실)에선 무의미해 생략.
if avg_price and abs(price - avg_price) <= atr:
a_stop, _ = signals.compute_stop_target(int(avg_price), atr, recent_low)
levels.append({
'label': '평단 기준 손절', 'price': a_stop, 'tap': True,
'hint': f'평단({avg_price:,})ATR×{config.STOP_ATR_MULT:g}와 스윙저점 중 위쪽. 본전 근처에서 손실 방어선.',
})
result = {
'levels': levels, 'price': int(price), 'atr': int(round(atr)),
'sma20': int(round(sma20)), 'rsi': (round(rsi) if rsi is not None else None),
'recent_low': int(recent_low) if recent_low else None,
'recent_high': int(recent_high) if recent_high else None,
}
with _advice_cache_lock:
_advice_cache[cache_key] = (result, now)
return result
INDICES_CACHE_TTL = 30.0
_indices_cache: dict = {'data': None, 'expires_at': 0.0}
_indices_lock = threading.Lock()
@@ -2559,8 +2709,8 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
trade_mark = (
f'<button type="button" class="badge badge-trade btn-trades" data-trade-code="{html.escape(raw_code, quote=True)}" data-trade-stock="{stock_attr}" title="거래내역 보기">거래내역</button>'
) if has_trades and source == 'interests' else ''
# 매매 진입 버튼 — 보유 모드면 default, 그 외는 매수 default. 모달에서 토글 가능.
order_side = 'SELL' if c.get('mode') == 'held' else 'BUY'
# 매매 진입 버튼 — 항상 default (오타 방지). 모달에서 매도로 토글 가능.
order_side = 'BUY'
order_btn = (
f'<button type="button" class="btn-order" data-order-code="{html.escape(raw_code, quote=True)}" data-order-stock="{stock_attr}" data-order-side="{order_side}" title="매수/매도">💰 거래</button>'
) if raw_code else ''
@@ -3349,7 +3499,7 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
f'{note_btn}'
f'{_info_button_html(r.get("code") or "", r.get("stock") or "")}'
f'<button type="button" class="btn-trades" data-trade-code="{code}" data-trade-stock="{stock}">거래내역</button>'
f'<button type="button" class="btn-order" data-order-code="{code}" data-order-stock="{html.escape(stock, quote=True)}" data-order-side="SELL" title="매수/매도">💰 거래</button>'
f'<button type="button" class="btn-order" data-order-code="{code}" data-order-stock="{html.escape(stock, quote=True)}" data-order-side="BUY" title="매수/매도">💰 거래</button>'
f'</div>'
) if code else ''
tag_chip = _tag_chip_html(r.get('code') or '', r.get('stock') or '', interactive=True)
@@ -5793,7 +5943,7 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
}
/* 주문 모달 (order-modal) 거래내역 보기용 trade-modal과 별개. 모달 박스 자체 스크롤 X (호가창만 스크롤) */
.order-box { max-width: 560px; overflow-y: hidden; display: flex; flex-direction: column; transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; }
.order-box { max-width: 640px; max-height: calc(100dvh - env(safe-area-inset-top) - 56px); overflow-y: hidden; display: flex; flex-direction: column; transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; }
.order-box > [data-order-step="1"] { display: flex; flex-direction: column; flex: 1; min-height: 0; }
/* 매수/매도 모드 전체 색조 모달 어디를 봐도 side가 한눈에 (매수=빨강/매도=파랑) */
.order-box.side-buy { border-color: #c9303e; box-shadow: 0 0 0 1px #c9303e, 0 12px 44px rgba(201,48,62,0.34); background: linear-gradient(180deg, rgba(201,48,62,0.12), rgba(201,48,62,0.02) 130px, #0d1018 300px); }
@@ -5802,6 +5952,8 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
.order-box.side-sell .modal-head { border-bottom: 2px solid rgba(42,95,179,0.55); padding-bottom: 10px; }
.order-box.side-buy .orderbook { border-color: rgba(201,48,62,0.38); }
.order-box.side-sell .orderbook { border-color: rgba(42,95,179,0.38); }
/* 주문 방향 확인 팝업 매수·매도 버튼을 직접 골라 누르게 (위치 랜덤). 오타 방지 */
#order-confirm-modal .btn-confirm { font-size: 16px; font-weight: 700; padding: 15px 14px; }
.order-symbol-select { background: #14171f; color: #f0f0f0; border: 1px solid #1f2330; border-radius: 6px; padding: 7px 9px; font-size: 14px; font-weight: 600; font-family: inherit; width: 100%; max-width: 360px; cursor: pointer; color-scheme: dark; }
.order-symbol-select:focus { outline: none; border-color: #ff4d5e; }
.order-symbol-select option { background: #14171f; color: #f0f0f0; }
@@ -5831,8 +5983,8 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
.orderbook::-webkit-scrollbar-thumb { background: #2a2e3a; border-radius: 2px; }
.orderbook .ob-row { display: grid; grid-template-columns: 1fr 1fr; padding: 1px 5px; font-size: 7.5px; line-height: 1.35; font-variant-numeric: tabular-nums; cursor: pointer; border-bottom: 1px solid #15171e; transition: box-shadow 0.08s; color: #565c68; }
.orderbook .ob-row:last-of-type { border-bottom: 0; }
.orderbook .ob-row.ask { background: linear-gradient(to left, rgba(201,48,62,0.16), transparent 60%); color: #ff8a95; }
.orderbook .ob-row.bid { background: linear-gradient(to left, rgba(42,95,179,0.16), transparent 60%); color: #8db4ff; }
.orderbook .ob-row.ask { background: linear-gradient(to left, rgba(42,95,179,0.16), transparent 60%); color: #8db4ff; }
.orderbook .ob-row.bid { background: linear-gradient(to left, rgba(201,48,62,0.16), transparent 60%); color: #ff8a95; }
.orderbook .ob-row:hover { background: #1c1f29; }
.orderbook .ob-row.ob-current { background-color: rgba(230,230,230,0.13); color: #fff; font-weight: 700; box-shadow: inset 3px 0 0 #e6e6e6; }
.orderbook .ob-row.ob-synthetic { background: rgba(230,230,230,0.10); }
@@ -5844,7 +5996,7 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
.orderbook .ob-row.selected { box-shadow: inset 0 0 0 1.5px #ffcc33; color: #fff !important; font-weight: 700; background-color: rgba(255,204,51,0.18) !important; }
.orderbook .ob-row .price { text-align: left; }
.orderbook .ob-row .qty { text-align: right; opacity: 0.8; font-size: 7px; }
.price-tick-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin-top: 4px; }
.price-tick-buttons { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 4px; margin-top: 4px; }
.price-tick-buttons button { background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 5px; padding: 5px 4px; font-size: 11px; cursor: pointer; font-family: inherit; }
.price-tick-buttons button:hover { border-color: #ff4d5e; color: #f0f0f0; }
.price-tick-buttons button:active { transform: translateY(1px); }
@@ -5871,6 +6023,27 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
.order-info .pl-down { color: #8db4ff; white-space: nowrap; }
.order-info b.qty-over { color: #ff8a95; }
input[data-order-qty].qty-over { color: #ff8a95; }
.price-tick-buttons .advice-open-btn { padding: 5px 4px; border-radius: 5px; background: #1a1710; border: 1px solid #4a3c1a; color: #e6c86b; font-size: 11px; font-weight: 700; cursor: pointer; text-align: center; font-family: inherit; white-space: nowrap; }
.price-tick-buttons .advice-open-btn:hover { border-color: #e6c86b; color: #f0d98a; }
.price-tick-buttons .advice-open-btn:active { transform: translateY(1px); }
.advice-box { max-width: 420px; }
.advice-body { padding: 4px 2px; }
.advice-sub { font-size: 11px; color: #9aa0ad; margin-bottom: 8px; line-height: 1.4; }
.advice-rows { display: flex; flex-direction: column; gap: 6px; }
.advice-row { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 11px; border-radius: 8px; background: #1a1f2c; border: 1px solid #2a3040; text-align: left; cursor: default; }
.advice-row .advice-lbl { font-size: 12px; color: #c9ccd3; flex: 1; }
.advice-row .advice-px { font-size: 14px; font-weight: 700; color: #e8eaf0; font-variant-numeric: tabular-nums; white-space: nowrap; }
.advice-rel { font-size: 10px; }
.advice-rel.up { color: #ff8a95; }
.advice-rel.dn { color: #8ab6ff; }
.advice-tapmark { font-size: 10.5px; font-weight: 700; color: #8ab6ff; white-space: nowrap; margin-left: 2px; }
.advice-row.advice-tap { cursor: pointer; border-color: #34506e; background: #16202e; }
.advice-row.advice-tap:active { background: #1e2c3e; }
.advice-row.advice-info { opacity: 0.72; }
.advice-why { margin: 10px 0 0; background: none; border: none; color: #8ab6ff; font-size: 12px; font-weight: 600; cursor: pointer; padding: 4px 2px; }
.advice-hints { margin: 6px 0 2px; padding-left: 16px; font-size: 11.5px; color: #b7bcc7; line-height: 1.5; }
.advice-hints li { margin-bottom: 5px; }
.advice-hints b { color: #d6dae2; white-space: nowrap; }
.order-adr-banner { padding: 8px 12px; margin-bottom: 12px; border-radius: 8px; font-size: 12.5px; font-weight: 600; line-height: 1.4; }
.order-adr-banner.adr-over { background: #2a1418; border: 1px solid #5a1f28; color: #ff8a95; }
.order-adr-banner.adr-under { background: #101b2e; border: 1px solid #1f3a5a; color: #8ab6ff; }
@@ -7167,6 +7340,26 @@ def _render_info_desc_modal() -> str:
)
def _render_advice_modal() -> str:
"""기술적 권장가 팝업. order-modal 위에 겹쳐 뜨는 modal-top. [💡 권장가 보기] 클릭 시 표시.
가격 단가 자동입력(호가단위 스냅) 팝업 닫힘. 근거로 계산식 설명 토글."""
return (
'<div id="advice-modal" class="modal hidden modal-top" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="advice-modal-title">'
'<div class="modal-overlay" data-modal-close="1"></div>'
'<div class="modal-box advice-box" role="document">'
'<div class="modal-head">'
'<div class="modal-title" id="advice-modal-title"><span data-advice-title>💡 권장가</span></div>'
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
'</div>'
'<div class="advice-body" data-advice-body><div class="muted small">불러오는 중…</div></div>'
'<div class="modal-actions">'
'<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
'</div>'
'</div>'
'</div>'
)
def _render_order_modal() -> str:
"""주문 진입 모달 (매수/매도). 호가창 1초 폴링, PIN OTP 흐름.
@@ -7208,12 +7401,18 @@ def _render_order_modal() -> str:
'<label class="inline-row">주문유형<select data-order-type>'
'<option value="LIMIT">지정가</option>'
'<option value="MARKET">시장가</option>'
'<option value="STOP_LIMIT">스톱지정가 (하락 시 매도)</option>'
'</select></label>'
'<label data-order-price-row>단가'
'<label data-order-stop-row style="display:none;">'
'<span>조건단가 <span class="muted small" style="font-weight:400;">(이 가격 도달 시 매도)</span></span>'
'<input type="number" inputmode="numeric" min="0" step="1" data-order-stop-input>'
'</label>'
'<label data-order-price-row><span data-order-price-label>단가</span>'
'<input type="number" inputmode="numeric" min="0" step="1" data-order-price-input>'
'<div class="price-tick-buttons">'
'<button type="button" data-tick-dir="-1">▼ 한 호가 아래</button>'
'<button type="button" data-tick-dir="1">▲ 한 호가</button>'
'<button type="button" data-tick-dir="-1">▼ 한 호가</button>'
'<button type="button" data-tick-dir="1">▲ 한 호가</button>'
'<button type="button" class="advice-open-btn" data-advice-open>💡 권장가</button>'
'</div>'
'</label>'
'<label data-order-budget-row>'
@@ -7322,6 +7521,32 @@ def _render_sell_choice_modal() -> str:
)
def _render_order_confirm_modal() -> str:
"""매수/매도 버튼 → propose 직전 방향 확인 팝업. 매수/매도 오타 방지용.
매수·매도 버튼을 띄우고 관리자님이 직접 맞는 방향을 찾아 누르게 (위치 랜덤).
주문 방향과 일치하면 진행, 다르면 팝업 닫고 취소. order-modal 위에 겹침(modal-top).
"""
return (
'<div id="order-confirm-modal" class="modal hidden modal-top" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="order-confirm-modal-title">'
'<div class="modal-overlay" data-modal-close="1"></div>'
'<div class="modal-box pin-box" role="document">'
'<div class="modal-head">'
'<div class="modal-title" id="order-confirm-modal-title">방향 확인 — 맞는 것을 누르세요</div>'
'</div>'
'<div class="order-modal-msg info" data-order-confirm-summary>—</div>'
'<div class="order-actions" style="gap:8px;">'
'<button type="button" class="btn-confirm" data-order-confirm-side="BUY" data-side="BUY">매수</button>'
'<button type="button" class="btn-confirm" data-order-confirm-side="SELL" data-side="SELL">매도</button>'
'</div>'
'<div class="order-actions" style="margin-top:8px;">'
'<button type="button" class="btn-cancel" data-modal-close="1" style="width:100%;">취소</button>'
'</div>'
'</div>'
'</div>'
)
def _render_interests_modal() -> str:
"""shell HTML 직속에 두는 종목 추가 모달. panels API의 swap 영역(section.tab-content) 밖이라
자동 갱신 중에도 DOM·입력값이 보존된다."""
@@ -9020,9 +9245,11 @@ def render_html() -> str:
trade_modal_html = _render_trade_modal()
info_modal_html = _render_info_modal()
info_desc_modal_html = _render_info_desc_modal()
advice_modal_html = _render_advice_modal()
order_modal_html = _render_order_modal()
pin_modal_html = _render_pin_modal()
sell_choice_modal_html = _render_sell_choice_modal()
order_confirm_modal_html = _render_order_confirm_modal()
open_orders_modal_html = _render_open_orders_modal()
stock_name_modal_html = _render_stock_name_modal()
@@ -10023,7 +10250,9 @@ def render_html() -> str:
var modal = document.getElementById('order-modal');
var pinModal = document.getElementById('pin-modal');
var sellChoiceModal = document.getElementById('sell-choice-modal');
var orderConfirmModal = document.getElementById('order-confirm-modal');
var openOrdersModal = document.getElementById('open-orders-modal');
var adviceModal = document.getElementById('advice-modal');
if(!modal) return;
var ACCOUNTS = [
{label: '일반', display: '본인 일반', owner: '본인'},
@@ -10031,10 +10260,11 @@ var ACCOUNTS = [
{label: '가희_일반', display: '가희 일반', owner: '가희'},
{label: '가희_ISA', display: '가희 ISA', owner: '가희'}
];
var state = { code:'', name:'', side:'BUY', pollTimer:null, countdownTimer:null, expiryAt:0, isOpen:false, lastBook:null, bookCentered:false, lastCheck:null, marketActive:true, marketPhase:null, symbolsCache:null, pendingMaxOnSell:false, pendingMaxOnBuy:false, accStatus:null, sellChoice:null };
var state = { code:'', name:'', side:'BUY', pollTimer:null, countdownTimer:null, expiryAt:0, isOpen:false, lastBook:null, bookCentered:false, lastCheck:null, marketActive:true, marketPhase:null, symbolsCache:null, pendingMaxOnSell:false, pendingMaxOnBuy:false, accStatus:null, sellChoice:null, advice:null, adviceKey:null };
function $(sel, root){ return (root||modal).querySelector(sel); }
function $$(sel, root){ return (root||modal).querySelectorAll(sel); }
function $p(sel){ return pinModal ? pinModal.querySelector(sel) : null; }
function $a(sel){ return adviceModal ? adviceModal.querySelector(sel) : null; }
function fmt(n){ if(!isFinite(n)) return ''; return (Math.round(n)||0).toLocaleString('ko-KR'); }
function highlightSelectedTick(){
var inp = $('[data-order-price-input]');
@@ -10088,7 +10318,21 @@ function setSide(side){
// BUY max 계산엔 price 필요 비어있으면 호가창 현재가로 즉시 채움
_resolveOrderPrice();
}
refreshStopUI(); // 스톱지정가(매도 전용) 옵션·입력행 side에 맞게 갱신
updateCheck();
fetchAdvice(); // 매수/매도 방향에 따라 권장가 세트가 다름
}
// 스톱지정가는 매도 전용 매수면 옵션 비활성+선택돼 있으면 LIMIT로 되돌림. 조건단가 ·단가 라벨 갱신.
function refreshStopUI(){
var otSel = $('[data-order-type]'); if(!otSel) return;
var stopOpt = otSel.querySelector('option[value="STOP_LIMIT"]');
if(stopOpt) stopOpt.disabled = (state.side === 'BUY');
if(state.side === 'BUY' && otSel.value === 'STOP_LIMIT') otSel.value = 'LIMIT';
var isStop = (otSel.value === 'STOP_LIMIT');
var stopRow = $('[data-order-stop-row]');
if(stopRow) stopRow.style.display = isStop ? '' : 'none';
var priceLabel = $('[data-order-price-label]');
if(priceLabel) priceLabel.textContent = isStop ? '매도 단가' : '단가';
}
function _resolveOrderPrice(){
// 단가 우선, 없으면 현재가 fallback. 단가 input 비어있으면 자동 채움 (disabled 아닌 경우)
@@ -10169,20 +10413,21 @@ function renderBook(book){
var inBook = false;
for(var t=0;t<asks.length;t++){ if(asks[t].price===cur) inBook=true; }
for(var u=0;u<bids.length;u++){ if(bids[u].price===cur) inBook=true; }
var html = '';
if(book.upper_limit > 0) html += capRowHtml(book.upper_limit, 'up'); // 상한가
var specs = [];
if(book.upper_limit > 0) specs.push(capRowSpec(book.upper_limit, 'up')); // 상한가
if(cur > 0 && !inBook){
// 현재가가 20호가 어디에도 없음 현재가 중심 사다리로 재구성(잔량은 겹치는 호가에 표시).
html += buildCenteredRows(asks, bids, cur);
specs = specs.concat(buildCenteredSpecs(asks, bids, cur));
} else {
// 평소: 매도10 + 매수10 (=20). 현재가는 일치 호가에 하이라이트.
for(var i2=0;i2<asks.length;i2++){ var a=asks[i2];
html += '<div class="ob-row ask'+(a.price===cur?' ob-current':'')+'" data-ob-price="'+a.price+'"><span class="price">'+fmt(a.price)+'</span><span class="qty">'+fmt(a.qty)+'</span></div>'; }
specs.push({key:String(a.price), cls:'ob-row ask'+(a.price===cur?' ob-current':''), priceHtml:fmt(a.price), qty:fmt(a.qty)}); }
for(var j2=0;j2<bids.length;j2++){ var b=bids[j2];
html += '<div class="ob-row bid'+(b.price===cur?' ob-current':'')+'" data-ob-price="'+b.price+'"><span class="price">'+fmt(b.price)+'</span><span class="qty">'+fmt(b.qty)+'</span></div>'; }
specs.push({key:String(b.price), cls:'ob-row bid'+(b.price===cur?' ob-current':''), priceHtml:fmt(b.price), qty:fmt(b.qty)}); }
}
if(book.lower_limit > 0) html += capRowHtml(book.lower_limit, 'down'); // 아래 하한가
ob.innerHTML = html;
if(book.lower_limit > 0) specs.push(capRowSpec(book.lower_limit, 'down')); // 아래 하한가
// 호가 구조(/가격) 같으면 바뀐 셀만 제자리 갱신 노드·스크롤 유지, 깜빡임 제거
applyRows(ob, specs);
syncMarketPriceInput(book);
highlightSelectedTick(); updateMarketPhaseDisplay(); decorateAccounts();
if(!state.bookCentered){
@@ -10207,8 +10452,68 @@ function tickSize(p){
if(p < 500000) return 500;
return 1000;
}
function snapToTick(p){ if(!(p>0)) return p; var t = tickSize(p); return Math.round(p/t)*t; }
// 기술적 권장가(매수/매도 판단 보조). code·side(·평단) 바뀔 때만 fetch, keystroke엔 캐시 재사용.
function fetchAdvice(){
var code = state.code; if(!code){ renderAdvice(null); return; }
var side = state.side;
var avg = (side === 'SELL' && state.lastCheck && state.lastCheck.avg_price) ? state.lastCheck.avg_price : 0;
var key = code + ':' + side + ':' + avg;
if(state.adviceKey === key){ if(state.advice) renderAdvice(state.advice); return; }
state.adviceKey = key;
var url = '/api/order/advice?code=' + encodeURIComponent(code) + '&side=' + side + (avg ? '&avg_price=' + avg : '');
fetch(url).then(function(r){ return r.json(); }).then(function(d){
if(state.code !== code || state.side !== side) return; // 그새 종목·방향 바뀌면 무시
state.advice = d.advice || null;
renderAdvice(state.advice);
}).catch(function(){ renderAdvice(null); });
}
function renderAdvice(a){
var body = $a('[data-advice-body]');
var titleEl = $a('[data-advice-title]');
if(titleEl) titleEl.textContent = '💡 권장 ' + (state.side==='BUY'?'매수가':'매도가');
if(!body) return;
if(!a || !a.levels || !a.levels.length){
body.innerHTML = '<div class="muted small">권장가 데이터 없음 (신규·ETF 등)</div>';
return;
}
var cur = (state.lastBook && state.lastBook.price) || a.price || 0;
var rsiTag = '';
if(a.rsi != null){ var rz = a.rsi>=70?' 과열':(a.rsi<=30?' 과매도':''); rsiTag = 'RSI ' + a.rsi + rz + ' · '; }
var rows = a.levels.map(function(l){
var rel = cur>0 ? (l.price>cur?' <span class="advice-rel up">▲</span>':(l.price<cur?' <span class="advice-rel dn">▼</span>':'')) : '';
var cls = 'advice-row' + (l.tap?' advice-tap':' advice-info');
var attr = l.tap ? ' data-adv-price="' + l.price + '"' : '';
var tapMark = l.tap ? '<span class="advice-tapmark">▶ 탭</span>' : '';
return '<button type="button" class="' + cls + '"' + attr + '>' +
'<span class="advice-lbl">' + l.label + '</span>' +
'<span class="advice-px">' + fmt(l.price) + rel + '</span>' + tapMark + '</button>';
}).join('');
var hints = a.levels.map(function(l){
return '<li><b>' + l.label + ' ' + fmt(l.price) + '</b> — ' + l.hint + '</li>';
}).join('');
body.innerHTML =
'<div class="advice-sub">' + rsiTag + '현재가 ' + fmt(cur) + ' · 가격을 탭하면 단가에 채워져요</div>' +
'<div class="advice-rows">' + rows + '</div>' +
'<button type="button" class="advice-why" data-adv-why>▸ 근거 설명</button>' +
'<ul class="advice-hints hidden" data-adv-hints>' + hints + '</ul>';
}
function openAdviceModal(){
if(!adviceModal) return;
renderAdvice(state.advice); // 이미 로드된 즉시 렌더
fetchAdvice(); // 미로드/오래됐으면 갱신 (key 동일하면 no-op)
adviceModal.classList.remove('hidden');
adviceModal.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
}
function closeAdviceModal(){
if(!adviceModal) return;
adviceModal.classList.add('hidden');
adviceModal.setAttribute('aria-hidden', 'true');
if(modal.classList.contains('hidden')) document.body.classList.remove('modal-open');
}
// 현재가가 호가에 없을 : 현재가 중심 ±10 사다리(20). 잔량은 겹치는 호가에만, 현재가 하이라이트.
function buildCenteredRows(asks, bids, cur){
function buildCenteredSpecs(asks, bids, cur){
var qmap = {};
for(var i=0;i<asks.length;i++) qmap[asks[i].price] = {q:asks[i].qty, side:'ask'};
for(var j=0;j<bids.length;j++) qmap[bids[j].price] = {q:bids[j].qty, side:'bid'};
@@ -10217,19 +10522,40 @@ function buildCenteredRows(asks, bids, cur){
var dn = [], q = cur;
for(var b=0;b<10;b++){ q -= tickSize(q>1?q-1:q); if(q<1) break; dn.push(q); } // 현재가 아래 10
var rows = up.reverse().concat([cur]).concat(dn); // 높은가격 낮은가격 아래
var html = '';
var specs = [];
for(var r=0;r<rows.length;r++){
var pr = rows[r], m = qmap[pr];
var cls = pr===cur ? 'bid ob-current' : (m ? m.side : '');
var qty = m ? fmt(m.q) : '';
html += '<div class="ob-row '+cls+'" data-ob-price="'+pr+'"><span class="price">'+fmt(pr)+'</span><span class="qty">'+qty+'</span></div>';
specs.push({ key:String(pr), cls:'ob-row '+cls, priceHtml:fmt(pr), qty: m ? fmt(m.q) : '' });
}
return html;
return specs;
}
function capRowHtml(price, dir){
function capRowSpec(price, dir){
var mk = dir==='up' ? '<b class="ob-cap-mark up">상</b> ' : '<b class="ob-cap-mark down">하</b> ';
var lab = dir==='up' ? '상한' : '하한';
return '<div class="ob-row ob-cap ob-cap-'+dir+'" data-ob-price="'+price+'"><span class="price">'+mk+fmt(price)+'</span><span class="qty">'+lab+'</span></div>';
return { key:String(price), cls:'ob-row ob-cap ob-cap-'+dir, priceHtml: mk+fmt(price), qty: lab };
}
// 호가 반영. 구조(행수·가격열) 직전과 같으면 바뀐 셀만 제자리 갱신(노드 유지 깜빡임·스크롤 없음), 다르면 1 전체 재생성.
function applyRows(ob, specs){
var kids = ob.children;
var same = kids.length === specs.length;
if(same){
for(var i=0;i<specs.length;i++){ if(kids[i].getAttribute('data-ob-price') !== specs[i].key){ same=false; break; } }
}
if(!same){
var h='';
for(var k=0;k<specs.length;k++){ var s=specs[k];
h += '<div class="'+s.cls+'" data-ob-price="'+s.key+'"><span class="price">'+s.priceHtml+'</span><span class="qty">'+s.qty+'</span></div>'; }
ob.innerHTML = h;
return;
}
for(var j=0;j<specs.length;j++){
var row=kids[j], sp=specs[j];
if(row.className !== sp.cls) row.className = sp.cls;
var pe=row.children[0], qe=row.children[1];
if(pe && pe.innerHTML !== sp.priceHtml) pe.innerHTML = sp.priceHtml;
if(qe && qe.textContent !== sp.qty) qe.textContent = sp.qty;
}
}
function pollOnce(){
if(document.hidden || !state.isOpen || !state.code) return;
@@ -10262,6 +10588,7 @@ function updateCheck(){
if(qtyInpB){ qtyInpB.value = d.max_qty; state.pendingMaxOnBuy = false; recalcBudgetFromQty(); }
}
renderOrderInfo();
fetchAdvice(); // SELL 평단(avg_price) 확보 권장가 갱신 key 동일하면 재fetch
}).catch(function(){});
}
function renderOrderInfo(){
@@ -10272,12 +10599,16 @@ function renderOrderInfo(){
var amount = price * qty;
var orderType = (($('[data-order-type]')||{}).value || 'LIMIT');
if(state.side === 'BUY'){
var avail = (typeof d.avail_cash === 'number') ? d.avail_cash : (d.d2_entra||0);
var lines = [
'매수가능액 (D+2 예수금): <b>'+fmt(d.d2_entra||0)+'</b>원'
'매수가능액: <b>'+fmt(avail)+'</b>원'
];
if((d.pending_buy_locked||0) > 0){
lines.push('<span class="muted small">D+2 예수금 '+fmt(d.d2_entra||0)+' 매수대기 '+fmt(d.pending_buy_locked)+'원</span>');
}
if(orderType === 'MARKET' && d.upper_limit){
var needed = (d.upper_limit||0) * qty;
var overCls = (needed > (d.d2_entra||0)) ? ' class="qty-over"' : '';
var overCls = (needed > avail) ? ' class="qty-over"' : '';
lines.push('필요증거금<span class="muted small"> (상한가 '+fmt(d.upper_limit)+'원 기준)</span>: <b'+overCls+'>'+fmt(needed)+'</b>원 ('+fmt(qty)+'주)');
} else {
lines.push('주문예상액: <b>'+fmt(amount)+'</b>원 ('+fmt(qty)+'주)');
@@ -10524,6 +10855,7 @@ function onSymbolChange(){
state.lastBook = null;
state.lastCheck = null;
state.bookCentered = false;
state.advice = null; state.adviceKey = null; renderAdvice(null);
// 모달 표시 갱신
var ne = $('[data-order-name]'); if(ne) ne.textContent = name;
var cd = $('[data-order-code-display]'); if(cd) cd.textContent = '('+code+')';
@@ -10532,6 +10864,9 @@ function onSymbolChange(){
if(ob) ob.innerHTML = '<div class="loading" style="padding:30px 0;"><div class="loading-spin"></div>호가 로딩중…</div>';
$('[data-order-price-input]').value = '';
$('[data-order-qty]').value = '';
var stopInp = $('[data-order-stop-input]'); if(stopInp) stopInp.value = '';
// 종목 변경 스톱지정가는 해제 이전 종목 트리거가 종목에 잘못 적용 방지
var otSelR = $('[data-order-type]'); if(otSelR && otSelR.value === 'STOP_LIMIT'){ otSelR.value = 'LIMIT'; refreshStopUI(); }
var budgetInp = $('[data-order-budget]');
if(budgetInp) budgetInp.value = '';
// 종목 변경 max로 자동 채움 (SELL: 보유 100%, BUY: 매수가능 max)
@@ -10542,6 +10877,7 @@ function onSymbolChange(){
fetchAccountStatus();
pollOnce();
updateCheck();
fetchAdvice();
}
function fillAccounts(allowed){
var sel = $('[data-order-account]'); if(!sel) return;
@@ -10566,7 +10902,8 @@ function decorateAccounts(){
opt.textContent = base + (q > 0 ? ' · ' + fmt(q) + '' : ' · 미보유');
opt.style.color = q > 0 ? '#f0f0f0' : '#ff6b75';
} else {
var ok = price > 0 ? (a.d2_entra >= price) : (a.d2_entra > 0);
var avail = (typeof a.avail_cash === 'number') ? a.avail_cash : (a.d2_entra || 0);
var ok = price > 0 ? (avail >= price) : (avail > 0);
opt.textContent = base + (ok ? '' : ' · ⚠ 부족');
opt.style.color = ok ? '#f0f0f0' : '#ff6b75';
}
@@ -10647,6 +10984,7 @@ function openModal(opts){
}
function closeModal(){
state.isOpen = false; stopPolling();
if(adviceModal && !adviceModal.classList.contains('hidden')) closeAdviceModal();
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
// pin-modal이 있을 때만 body unlock
@@ -10708,9 +11046,17 @@ function doPropose(){
var orderType = $('[data-order-type]').value;
var qty = parseInt($('[data-order-qty]').value || '0', 10);
var price = parseInt($('[data-order-price-input]').value || '0', 10);
var stopPrice = parseInt(($('[data-order-stop-input]')||{}).value || '0', 10);
if(!account){ setMsg('계좌를 선택하세요', 'error'); return; }
if(qty <= 0){ setMsg('수량을 입력하세요', 'error'); return; }
if(orderType === 'LIMIT' && price <= 0){ setMsg('지정가는 단가가 필요합니다', 'error'); return; }
if(orderType === 'STOP_LIMIT'){
if(state.side !== 'SELL'){ setMsg('스톱지정가는 매도만 가능합니다', 'error'); return; }
if(price <= 0){ setMsg('스톱지정가는 매도 단가가 필요합니다', 'error'); return; }
if(stopPrice <= 0){ setMsg('조건단가(트리거 가격)를 입력하세요', 'error'); return; }
var curP = (state.lastCheck && state.lastCheck.cur_price) || (state.lastBook && state.lastBook.price) || 0;
if(curP && stopPrice >= curP){ setMsg('조건단가는 현재가(' + fmt(curP) + '원)보다 낮아야 합니다', 'error'); return; }
}
// 클라이언트 사전 차단 서버 왕복 없이 즉시 에러 (PIN 발급 토스트 깜빡임 방지)
var lc = state.lastCheck;
if(lc && typeof lc.max_qty === 'number' && lc.max_qty >= 0 && qty > lc.max_qty){
@@ -10718,15 +11064,25 @@ function doPropose(){
var basis = (orderType === 'MARKET' && lc.upper_limit) ? '상한가' : '단가';
var ref = (orderType === 'MARKET' && lc.upper_limit) ? lc.upper_limit : price;
var needed = ref * qty;
setMsg('예수금 부족 — 필요 ' + needed.toLocaleString() + '원 (' + basis + ' ' + (ref||0).toLocaleString() + '× ' + qty + '주) / 가용 ' + (lc.d2_entra||0).toLocaleString() + '\n최대 ' + lc.max_qty + '주까지 매수 가능', 'error');
var availC = (typeof lc.avail_cash === 'number') ? lc.avail_cash : (lc.d2_entra||0);
setMsg('예수금 부족 — 필요 ' + needed.toLocaleString() + '원 (' + basis + ' ' + (ref||0).toLocaleString() + '× ' + qty + '주) / 가용 ' + availC.toLocaleString() + '\n최대 ' + lc.max_qty + '주까지 매수 가능', 'error');
} else {
setMsg('보유 부족 — 매도 ' + qty + '주 / 매도가능 ' + lc.max_qty + '', 'error');
}
return;
}
// 검증 통과 매수/매도 방향 최종 확인 팝업 (오타 방지). 확인 시에만 실제 매매로 진행.
state.pendingOrder = {account: account, orderType: orderType, qty: qty, price: price, stopPrice: stopPrice};
openOrderConfirm();
}
// 방향 확인 실제 매매 진행 전량매도 sell-choice 분기 포함.
function doProposeConfirmed(){
var po = state.pendingOrder; if(!po) return;
var account = po.account, orderType = po.orderType, qty = po.qty, price = po.price;
var lc = state.lastCheck;
// 전량매도(입력 수량 = 매도가능 전량)이고 같은 소유자 그룹의 다른 계좌에도 같은 종목 보유
// 선택 팝업 ( 계좌 모두 / 선택 계좌 / 취소). 일부매도 팝업 없이 단일 진행.
if(state.side === 'SELL'){
// 선택 팝업. 스톱지정가는 단일 계좌 예약이라 2계좌 동시 매도 대상 아님.
if(state.side === 'SELL' && orderType !== 'STOP_LIMIT'){
var fullSell = lc && typeof lc.max_qty === 'number' && lc.max_qty > 0 && qty === lc.max_qty;
var sib = siblingAccount(account);
var sibInfo = (sib && state.accStatus && state.accStatus.byLabel) ? state.accStatus.byLabel[sib] : null;
@@ -10736,7 +11092,58 @@ function doPropose(){
return;
}
}
proposeSingle(account, orderType, qty, price);
proposeSingle(account, orderType, qty, price, po.stopPrice);
}
// 매수/매도 방향 확인 모달
// 매수·매도 버튼을 띄우고 관리자님이 직접 맞는 방향을 누르게 (위치 랜덤).
// 주문 방향(state.side) 일치하면 진행, 다르면 팝업 닫고 취소.
function $oc(sel){ return orderConfirmModal ? orderConfirmModal.querySelector(sel) : null; }
function openOrderConfirm(){
if(!orderConfirmModal){ doProposeConfirmed(); return; }
var po = state.pendingOrder; if(!po) return;
var disp = {}; ACCOUNTS.forEach(function(a){ disp[a.label] = a.display; });
var isStop = (po.orderType === 'STOP_LIMIT');
var typeStr = (po.orderType === 'MARKET') ? '시장가' : (isStop ? '스톱지정가(예약)' : '지정가');
var priceStr = (po.orderType === 'MARKET') ? '시장가' : (fmt(po.price) + '');
var sum = $oc('[data-order-confirm-summary]');
if(sum){
// 매도 예상 수수료(수수료+증권거래세) 표시용 근사. 요율은 sim/config.py와 동일.
var feeLine = '';
if(state.side === 'SELL'){
var refPrice = (po.orderType === 'MARKET') ? ((state.lastCheck && state.lastCheck.cur_price) || 0) : po.price;
var amount = refPrice * po.qty;
var fee = Math.round(amount * (0.00015 + 0.0018)); // 수수료 0.015% + 거래세 0.18%
if(amount > 0){
feeLine = '· 예상 수수료: <b>' + fmt(fee) + '원</b> <span class="muted small">(체결 시 확정)</span><br>';
}
}
var stopLine = isStop ? ('· 조건단가: <b>' + fmt(po.stopPrice) + '원 도달 시</b> <span class="muted small">(예약)</span><br>') : '';
// 방향은 일부러 숨김 관리자님이 직접 고르게 해야 오타가 걸러짐.
sum.innerHTML = '<b>' + (state.name || state.code) + '</b><br>' +
'· 계좌: <b>' + (disp[po.account]||po.account) + '</b><br>' +
'· 유형: <b>' + typeStr + '</b><br>' +
stopLine +
'· ' + (isStop ? '매도 단가' : '단가') + ': <b>' + priceStr + '</b><br>' +
'· 수량: <b>' + fmt(po.qty) + '주</b><br>' +
feeLine + '<br>' +
'<b>매수 / 매도</b> 중 하려던 것을 눌러주세요.';
}
// 매수·매도 버튼 위치를 랜덤으로 섞어 습관적 오탭 방지 (CSS order)
var buyBtn = $oc('[data-order-confirm-side="BUY"]');
var sellBtn = $oc('[data-order-confirm-side="SELL"]');
if(buyBtn && sellBtn){
var buyFirst = Math.random() < 0.5;
buyBtn.style.order = buyFirst ? '1' : '2';
sellBtn.style.order = buyFirst ? '2' : '1';
}
orderConfirmModal.classList.remove('hidden');
orderConfirmModal.setAttribute('aria-hidden', 'false');
}
function closeOrderConfirm(){
if(!orderConfirmModal) return;
orderConfirmModal.classList.add('hidden');
orderConfirmModal.setAttribute('aria-hidden', 'true');
if(modal.classList.contains('hidden') && (!pinModal || pinModal.classList.contains('hidden'))) document.body.classList.remove('modal-open');
}
function siblingAccount(label){
var me = ACCOUNTS.find(function(a){ return a.label === label; });
@@ -10744,7 +11151,7 @@ function siblingAccount(label){
var sib = ACCOUNTS.find(function(a){ return a.owner === me.owner && a.label !== label; });
return sib ? sib.label : null;
}
function proposeSingle(account, orderType, qty, price){
function proposeSingle(account, orderType, qty, price, stopPrice){
var body = new URLSearchParams();
body.set('account', account);
body.set('side', state.side);
@@ -10752,7 +11159,8 @@ function proposeSingle(account, orderType, qty, price){
body.set('symbol_name', state.name);
body.set('qty', String(qty));
body.set('order_type', orderType);
if(orderType === 'LIMIT') body.set('price', String(price));
if(orderType === 'LIMIT' || orderType === 'STOP_LIMIT') body.set('price', String(price));
if(orderType === 'STOP_LIMIT') body.set('stop_price', String(stopPrice || 0));
proposeAndOpenPin('/api/order/propose', body);
}
function doProposeMulti(opts){
@@ -10874,6 +11282,8 @@ modal.addEventListener('click', function(e){
}
var qtyBtn = t.closest && t.closest('[data-qty-step]');
if(qtyBtn){ gotoQty(qtyBtn.getAttribute('data-qty-step')); return; }
var advOpen = t.closest && t.closest('[data-advice-open]');
if(advOpen){ openAdviceModal(); return; }
var obRow = t.closest && t.closest('.ob-row');
if(obRow){
var p = parseInt(obRow.getAttribute('data-ob-price') || '0', 10);
@@ -10902,6 +11312,31 @@ if(pinModal) pinModal.addEventListener('click', function(e){
if(t.classList && t.classList.contains('modal-close')){ closePinModal(); return; }
if(t.matches && t.matches('[data-pin-verify]')){ doVerify(); return; }
});
// 권장가 팝업 click 핸들러 가격 단가 채우고 팝업 닫기, 근거 토글
if(adviceModal) adviceModal.addEventListener('click', function(e){
var t = e.target;
if(t.getAttribute && t.getAttribute('data-modal-close')==='1'){ closeAdviceModal(); return; }
if(t.classList && t.classList.contains('modal-close')){ closeAdviceModal(); return; }
var advWhy = t.closest && t.closest('[data-adv-why]');
if(advWhy){
var hints = $a('[data-adv-hints]');
if(hints){ hints.classList.toggle('hidden'); advWhy.textContent = hints.classList.contains('hidden') ? '▸ 근거 설명' : '▾ 근거 설명'; }
return;
}
var advTap = t.closest && t.closest('.advice-tap');
if(advTap){
var ap = parseInt(advTap.getAttribute('data-adv-price') || '0', 10);
if(ap > 0){
// 권장가는 특정 단가 지정가로 채움 (시장가면 LIMIT로 전환해 입력칸 활성화)
var typeSel = $('[data-order-type]');
if(typeSel && typeSel.value !== 'LIMIT'){ typeSel.value = 'LIMIT'; typeSel.dispatchEvent(new Event('change')); }
var inp = $('[data-order-price-input]');
if(inp){ inp.disabled = false; inp.value = snapToTick(ap); updateCheck(); highlightSelectedTick(); recalcQtyFromBudget(); }
}
closeAdviceModal();
return;
}
});
// 매도 계좌 선택 모달 click 핸들러
if(sellChoiceModal) sellChoiceModal.addEventListener('click', function(e){
var t = e.target;
@@ -10918,14 +11353,34 @@ if(sellChoiceModal) sellChoiceModal.addEventListener('click', function(e){
return;
}
});
// 매수/매도 방향 확인 모달 click 핸들러
if(orderConfirmModal) orderConfirmModal.addEventListener('click', function(e){
var t = e.target;
if(t.getAttribute && t.getAttribute('data-modal-close')==='1'){ closeOrderConfirm(); return; }
var sideBtn = t.closest && t.closest('[data-order-confirm-side]');
if(sideBtn){
var picked = sideBtn.getAttribute('data-order-confirm-side');
closeOrderConfirm();
if(picked === state.side){
doProposeConfirmed();
} else {
// 방향 불일치 오타로 판단, 매매 취소
showToast('⚠️ 방향이 주문과 달라 취소했어요. 다시 확인해 주세요.', 'error', 4500);
}
return;
}
});
var accSel = $('[data-order-account]'); if(accSel) accSel.addEventListener('change', updateCheck);
var symSel = $('[data-order-symbol-select]'); if(symSel) symSel.addEventListener('change', onSymbolChange);
var otSel = $('[data-order-type]');
if(otSel) otSel.addEventListener('change', function(){
// 스톱지정가는 매도 전용 선택 매도로 전환 (setSide가 refreshStopUI+updateCheck 수행)
if(otSel.value === 'STOP_LIMIT' && state.side !== 'SELL'){ setSide('SELL'); return; }
var isMarket = (otSel.value === 'MARKET');
var inp = $('[data-order-price-input]');
if(inp) inp.disabled = isMarket;
$$('[data-tick-dir]').forEach(function(b){ b.disabled = isMarket; });
refreshStopUI();
// 주문유형 토글 max_qty 기준이 바뀜 (MARKET=상한가, LIMIT=단가) 재조회
updateCheck();
});
@@ -10946,8 +11401,8 @@ document.addEventListener('click', function(e){
if(subOrderBtn){
function openWithFirst(items){
var first = (items || []).find(function(it){ return it.source === '보유' && it.owner === '본인'; });
// 보유종목을 자동선택하므로 거래버튼과 동일 규칙으로 매도 진입. 보유분 없으면 모달(매수 기본).
if(first){ openModal({code: first.code, name: first.name, side: 'SELL'}); }
// 보유종목을 자동선택하 항상 매수 기본(오타 방지). 매도 모달에서 토글. 보유분 없으면 모달.
if(first){ openModal({code: first.code, name: first.name, side: 'BUY'}); }
else { openModal({code: '', name: ''}); }
}
if(state.symbolsCache){ openWithFirst(state.symbolsCache); }
@@ -11256,9 +11711,11 @@ window.openPinModal = openPinModal;
{trade_modal_html}
{info_modal_html}
{info_desc_modal_html}
{advice_modal_html}
{order_modal_html}
{pin_modal_html}
{sell_choice_modal_html}
{order_confirm_modal_html}
{open_orders_modal_html}
{stock_name_modal_html}
{ptr_script}
@@ -11826,6 +12283,26 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e:
self._send_json(200, {'results': [], 'error': str(e)})
return
if parsed.path == '/api/order/advice':
# 거래 모달용: 종목·side 기술적 권장 매수가/매도가 + 근거(이평·ATR). 매도는 avg_price(평단) 선택.
qs = parse_qs(parsed.query)
code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
side = (qs.get('side') or [''])[0].strip().upper()
try:
avg_price = int((qs.get('avg_price') or ['0'])[0].strip() or '0') or None
except ValueError:
avg_price = None
if not code or side not in ('BUY', 'SELL'):
self._send_json(400, {'error': 'code required, side=BUY|SELL'})
return
try:
advice = _price_advice(code, side, avg_price)
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'order/advice failed: {e}'})
return
self._send_json(200, {'code': code, 'side': side, 'advice': advice})
return
if parsed.path == '/api/order/check':
# 거래 모달용: 계좌·종목·side(BUY/SELL)·order_type·단가(선택) 받아서
# 최대 거래 가능 주수, 잔액·보유주수·평단가, 매도 시 손익 미리보기 반환.
@@ -11835,7 +12312,7 @@ class Handler(BaseHTTPRequestHandler):
account = (qs.get('account') or [''])[0].strip()
side = (qs.get('side') or [''])[0].strip().upper()
order_type = (qs.get('order_type') or ['LIMIT'])[0].strip().upper() or 'LIMIT'
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT'):
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_LIMIT'):
order_type = 'LIMIT'
try:
price = int((qs.get('price') or ['0'])[0].strip() or '0')
@@ -11854,8 +12331,13 @@ class Handler(BaseHTTPRequestHandler):
# 동일. kt00001 ord_alow_amt는 오늘 즉시현금(entr)만 잡혀 매도결제분(D+2)을 빠뜨려
# max_qty가 과소 계산되고 "예수금 부족" 오탐을 냈다. ord_alow_amt는 참고 표시용만 유지.
d2 = int(bal.get('d2_entra') or 0)
# d2_entra는 미체결 매수 지정가로 묶인 예수금을 아직 포함 → 실제 가용액에서 차감.
locked = _pending_buy_lock(account)
avail = max(0, d2 - locked)
result['ord_alow_amt'] = int(bal.get('ord_alow_amt') or 0)
result['d2_entra'] = d2
result['pending_buy_locked'] = locked
result['avail_cash'] = avail
if order_type == 'MARKET':
# 키움 시장가 매수 증거금 = 상한가 × qty. 같은 기준으로 max_qty 표시.
try:
@@ -11865,24 +12347,29 @@ class Handler(BaseHTTPRequestHandler):
except Exception:
upper = 0
result['upper_limit'] = upper
result['max_qty'] = (d2 // upper) if upper > 0 else 0
result['max_qty'] = (avail // upper) if upper > 0 else 0
else:
result['max_qty'] = (d2 // price) if price > 0 else 0
result['max_qty'] = (avail // price) if price > 0 else 0
else: # SELL
positions = kc.get_positions(account)
pos = next((p for p in positions if p['code'] == code), None)
if pos:
# trde_able_qty가 미체결 매도를 이미 제외하는지 불확실 → min으로 이중차감 방지.
# (제외됨: trde_able ≤ 보유−미체결, min=trde_able 유지 / 미제외: min=보유−미체결)
pend_sell = _pending_sell_qty(account, code)
sellable = max(0, min(pos['trde_able_qty'], pos['qty'] - pend_sell))
result['hold_qty'] = pos['qty']
result['trde_able_qty'] = pos['trde_able_qty']
result['trde_able_qty'] = sellable
result['pending_sell_qty'] = pend_sell
result['avg_price'] = pos['avg_price']
result['cur_price'] = pos['cur_price']
result['max_qty'] = pos['trde_able_qty']
result['max_qty'] = sellable
# 선택 단가 기준 손익 미리보기 — 단가 0이면 현재가 fallback
ref_price = price or pos['cur_price']
result['pl_preview'] = (ref_price - pos['avg_price']) * result['max_qty']
else:
result.update({'hold_qty': 0, 'trde_able_qty': 0, 'avg_price': 0,
'cur_price': 0, 'max_qty': 0, 'pl_preview': 0})
result.update({'hold_qty': 0, 'trde_able_qty': 0, 'pending_sell_qty': 0,
'avg_price': 0, 'cur_price': 0, 'max_qty': 0, 'pl_preview': 0})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'order/check failed: {e}'})
@@ -11910,11 +12397,19 @@ class Handler(BaseHTTPRequestHandler):
p = next((x for x in (pos_by.get(lb, []) or []) if x['code'] == code), None)
if p and p.get('cur_price') and not cur_price:
cur_price = p['cur_price']
d2 = int(bals[lb].get('d2_entra') or 0)
locked = _pending_buy_lock(lb) # 미체결 매수 묶임 차감
hold = p['qty'] if p else 0
# 매도가능 = min(trde_able, 보유−미체결매도) — check 핸들러와 동일 로직
pend_sell = _pending_sell_qty(lb, code) if p else 0
sellable = max(0, min(p['trde_able_qty'], hold - pend_sell)) if p else 0
accounts.append({
'label': lb,
'd2_entra': int(bals[lb].get('d2_entra') or 0),
'hold_qty': p['qty'] if p else 0,
'trde_able_qty': p['trde_able_qty'] if p else 0,
'd2_entra': d2,
'avail_cash': max(0, d2 - locked),
'pending_buy_locked': locked,
'hold_qty': hold,
'trde_able_qty': sellable,
})
self._send_json(200, {'code': code, 'cur_price': cur_price, 'accounts': accounts})
except Exception as e:
@@ -12318,15 +12813,27 @@ class Handler(BaseHTTPRequestHandler):
price = int(price_raw) if price_raw else None
except ValueError:
price = None
stop_raw = (params.get('stop_price') or [''])[0].strip()
try:
stop_price = int(stop_raw) if stop_raw else None
except ValueError:
stop_price = None
if not account or side not in ('BUY', 'SELL') or not symbol or qty <= 0:
self._send_json(400, {'ok': False, 'error': 'account/side/symbol/qty required'})
return
if order_type not in ('LIMIT', 'MARKET'):
self._send_json(400, {'ok': False, 'error': 'order_type must be LIMIT|MARKET'})
if order_type not in ('LIMIT', 'MARKET', 'STOP_LIMIT'):
self._send_json(400, {'ok': False, 'error': 'order_type must be LIMIT|MARKET|STOP_LIMIT'})
return
if order_type == 'LIMIT' and (price is None or price <= 0):
self._send_json(400, {'ok': False, 'error': 'LIMIT requires positive price'})
return
if order_type == 'STOP_LIMIT':
if side != 'SELL':
self._send_json(400, {'ok': False, 'error': '스톱지정가는 매도만 지원'})
return
if price is None or price <= 0 or stop_price is None or stop_price <= 0:
self._send_json(400, {'ok': False, 'error': '스톱지정가는 단가와 조건단가가 모두 필요'})
return
try:
if not symbol_name:
import kiwoom_client as kc
@@ -12341,7 +12848,8 @@ class Handler(BaseHTTPRequestHandler):
res = handler.propose_trade(
account=account, side=side, symbol=symbol, symbol_name=symbol_name,
qty=qty, order_type=order_type,
price=(price if order_type == 'LIMIT' else None),
price=(price if order_type in ('LIMIT', 'STOP_LIMIT') else None),
stop_price=(stop_price if order_type == 'STOP_LIMIT' else None),
)
if res.get('ok'):
# 매수/매도 미리보기 카드 메시지·PIN 메시지 모두 텔레그램 발송 X.