auto: 일일 백업 2026-07-31 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1509,6 +1509,10 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
|
||||
'qty': qty,
|
||||
'price': o.get('order_price', 0),
|
||||
'order_type': o.get('order_type', ''),
|
||||
# 스톱지정가 조건단가 (ka10075 stop_pric) + 트레일링 예약이면 그 상태.
|
||||
# 상세 표시가 "지정가"로 뭉개지지 않게 주문 성격을 그대로 실어 보낸다.
|
||||
'stop_price': o.get('stop_price', 0),
|
||||
'trailing': _trailing_info_for(o.get('ord_no', '')),
|
||||
'time': o.get('order_time', ''),
|
||||
'account': o.get('account', ''),
|
||||
'exchange': o.get('exchange', ''),
|
||||
@@ -3612,10 +3616,65 @@ def _fmt_hhmmss(raw: str) -> str:
|
||||
return s
|
||||
|
||||
|
||||
_TRAILING_LOOKUP_TTL = 5.0
|
||||
_trailing_lookup_cache: dict = {'ts': 0.0, 'by_ord': {}}
|
||||
|
||||
|
||||
def _trailing_info_for(ord_no: str):
|
||||
"""미체결 주문번호 → 트레일링 예약 요약 (없으면 None).
|
||||
|
||||
`state/trailing_stops.json` 읽기라 키움 콜 0. 렌더 경로에서 주문마다 불리므로 5초 캐시.
|
||||
⚠️ 예약의 ord_no 는 정정할 때마다 바뀌므로(kt10002 가 새 번호 발급) 항상 예약 파일의
|
||||
현재 ord_no 와 매칭한다 — 등록 시점 번호로 매칭하면 첫 상향 후 연결이 끊긴다.
|
||||
"""
|
||||
if not ord_no:
|
||||
return None
|
||||
now = time.time()
|
||||
if now - _trailing_lookup_cache['ts'] > _TRAILING_LOOKUP_TTL:
|
||||
try:
|
||||
from orders import trailing as _trl
|
||||
by_ord = {r['ord_no']: r for r in _trl.list_active()}
|
||||
except Exception:
|
||||
by_ord = {}
|
||||
_trailing_lookup_cache['by_ord'] = by_ord
|
||||
_trailing_lookup_cache['ts'] = now
|
||||
r = _trailing_lookup_cache['by_ord'].get(ord_no)
|
||||
if not r:
|
||||
return None
|
||||
return {
|
||||
'id': r['id'], 'trail_pct': r.get('trail_pct'), 'peak': r.get('peak', 0),
|
||||
'min_sell_price': r.get('min_sell_price'),
|
||||
'modify_count': r.get('modify_count', 0),
|
||||
# 등록 시점 값 — 손절선·고점이 그 뒤로 얼마 올라왔는지 보여주는 데 쓴다.
|
||||
'entry_cond_uv': r.get('entry_cond_uv', 0),
|
||||
'entry_peak': r.get('entry_peak', 0),
|
||||
}
|
||||
|
||||
|
||||
def _stop_gap_html(stop_price: int, limit_price: int) -> str:
|
||||
"""스톱 주문의 조건단가−지정가 갭 표기. 호가단위로 나눠 틱수로 보여준다.
|
||||
|
||||
`orders/trailing.ORD_UV_GAP_TICKS` 값을 화면에 그대로 박지 않는 이유는 실제 주문에
|
||||
걸린 값과 어긋날 수 있기 때문(정정 중 값 변화·키움앱 수동 개입). 실측값으로 역산한다.
|
||||
틱으로 딱 나눠지지 않으면 원으로 폴백.
|
||||
"""
|
||||
gap = (stop_price or 0) - (limit_price or 0)
|
||||
if gap <= 0:
|
||||
return ''
|
||||
try:
|
||||
from orders.trailing import tick_size
|
||||
t = tick_size(stop_price)
|
||||
except Exception:
|
||||
t = 0
|
||||
if t > 0 and gap % t == 0:
|
||||
return f' <span class="muted">−{gap // t}틱</span>'
|
||||
return f' <span class="muted">−{gap:,}원</span>'
|
||||
|
||||
|
||||
def _pending_detail_html(r: dict) -> str:
|
||||
"""자세히보기용 미체결 ord 단위 상세. 매수/매도 분리 표시.
|
||||
`r['pending_orders']` 가 ord별 dict 리스트.
|
||||
레이아웃: dt(라벨, 컬러) / dd 2줄(상단=수량@가격, 하단=계좌·시각·주문번호 muted).
|
||||
레이아웃: dt(라벨, 컬러) / dd 2~3줄(상단=수량@가격, 조건줄, 하단=계좌·시각·주문번호 muted).
|
||||
"""
|
||||
orders = r.get('pending_orders') or []
|
||||
if not orders:
|
||||
@@ -3623,11 +3682,14 @@ def _pending_detail_html(r: dict) -> str:
|
||||
buys = [o for o in orders if o.get('side') == 'BUY']
|
||||
sells = [o for o in orders if o.get('side') == 'SELL']
|
||||
|
||||
cur_price = r.get('price', 0) or 0 # 조건단가까지 남은 거리 계산용
|
||||
|
||||
def _row(o: dict, kind: str) -> str:
|
||||
qty = o.get('qty', 0)
|
||||
price = o.get('price', 0)
|
||||
otype = o.get('order_type') or ''
|
||||
price_str = f'{price:,}원 지정가' if price else (otype or '시장가')
|
||||
stop_p = o.get('stop_price', 0) or 0
|
||||
trl = o.get('trailing')
|
||||
acc = html.escape(o.get('account', ''))
|
||||
ord_no = html.escape(o.get('ord_no', ''))
|
||||
tm = _fmt_hhmmss(html.escape(o.get('time', '')))
|
||||
@@ -3636,11 +3698,72 @@ def _pending_detail_html(r: dict) -> str:
|
||||
meta_parts.append(tm)
|
||||
if ord_no:
|
||||
meta_parts.append(f'주문번호 {ord_no}')
|
||||
|
||||
label = '미체결 매수' if kind == 'buy' else '미체결 매도'
|
||||
if kind == 'sell' and trl:
|
||||
label = '트레일링 매도'
|
||||
elif kind == 'sell' and stop_p:
|
||||
label = '스톱 매도'
|
||||
|
||||
# 스톱 계열은 가격이 두 개다 — 조건단가(방아쇠)와 지정가(실제 주문가).
|
||||
# 첫 줄에 지정가만 '@ N원' 으로 박으면 그게 방아쇠처럼 오해되고 둘의 관계도 안 보인다.
|
||||
# 그래서 트레일링은 첫 줄에 수량만 남기고 두 가격을 미니표 맨 위 단독 항으로 올린다.
|
||||
if trl or stop_p:
|
||||
main = f'{qty:,}주 매도 예약'
|
||||
elif price:
|
||||
main = f'{qty:,}주 @ {price:,}원 {otype}' if otype else f'{qty:,}주 @ {price:,}원 지정가'
|
||||
else:
|
||||
main = f'{qty:,}주 @ {otype or "시장가"}'
|
||||
|
||||
cond_line = ''
|
||||
if stop_p and not trl:
|
||||
# 순수 스톱지정가 — 조건단가 고정. 표 없이 한 줄이지만 순서는 트레일링과 같게
|
||||
# (조건가 닿으면 → 지정가로 매도). 지정가가 먼저 오면 그게 방아쇠로 오해된다.
|
||||
tail = f' {price:,}원에 매도' if price else ' 매도'
|
||||
cond_line = (f'<div class="small pending-line-cond">'
|
||||
f'{stop_p:,}원 닿으면{tail}{_stop_gap_html(stop_p, price)} '
|
||||
f'<span class="muted">(고정)</span></div>')
|
||||
if trl:
|
||||
mini = []
|
||||
mini.append(f'<dt>손절선</dt><dd><b>{stop_p:,}원</b> 도달 시</dd>')
|
||||
if price:
|
||||
# 갭은 두 값의 차를 호가단위로 나눠 틱수로 표시 — ORD_UV_GAP_TICKS 를 그대로
|
||||
# 박지 않는 이유는 실제 주문 값과 어긋날 수 있어서다(정정·수동 개입).
|
||||
# 틱으로 딱 안 나눠지면 원으로 폴백.
|
||||
gap_part = _stop_gap_html(stop_p, price)
|
||||
mini.append(f'<dt>매도 지정가</dt><dd>{price:,}원{gap_part}</dd>')
|
||||
# 고점에 '등록 시점' 라벨을 붙이지 않는다 — peak 는 감시가 갱신한 현재 고점이라
|
||||
# 붙여 놓으면 지금 고점이 등록 시점 값인 것처럼 읽힌다
|
||||
# (실측 entry_peak 4,860 vs peak 4,950). 등록 후 얼마나 올랐는지는
|
||||
# 아래 '고점 상승' 행이 실제 값으로 보여준다.
|
||||
mini.append(f'<dt>고점</dt><dd><b>{trl.get("peak", 0):,}원</b></dd>')
|
||||
mini.append(f'<dt>트레일 폭</dt><dd>−{trl.get("trail_pct")}%</dd>')
|
||||
if trl.get('min_sell_price'):
|
||||
mini.append(f'<dt>최저 매도가</dt><dd>{trl["min_sell_price"]:,}원</dd>')
|
||||
if cur_price > 0 and stop_p:
|
||||
# 손절선을 기준으로 현재가가 몇 % 위에 있는지 = 팔리기까지 남은 여유.
|
||||
# 라벨이 '손절선 대비'이므로 분모도 손절선이어야 라벨과 계산이 어긋나지 않는다.
|
||||
room = (cur_price - stop_p) / stop_p * 100
|
||||
mini.append(f'<dt>손절선 대비</dt><dd>{room:+.1f}% '
|
||||
f'<span class="muted">(현재 {cur_price:,}원)</span></dd>')
|
||||
# 상향 횟수는 표시하지 않는다(관리자님 지시). mc 는 상승 행들의 표시 조건으로만 쓴다.
|
||||
mc = trl.get('modify_count', 0)
|
||||
entry_peak = trl.get('entry_peak') or 0
|
||||
peak_now = trl.get('peak', 0)
|
||||
if entry_peak and peak_now > entry_peak:
|
||||
mini.append(f'<dt>고점 상승</dt><dd>{entry_peak:,} → {peak_now:,}원 '
|
||||
f'<span class="up">+{peak_now - entry_peak:,}</span></dd>')
|
||||
entry_cond = trl.get('entry_cond_uv') or 0
|
||||
if mc and entry_cond and stop_p > entry_cond:
|
||||
mini.append(f'<dt>손절선 상승</dt><dd>{entry_cond:,} → {stop_p:,}원 '
|
||||
f'<span class="up">+{stop_p - entry_cond:,}</span></dd>')
|
||||
cond_line += f'<dl class="trail-mini">{"".join(mini)}</dl>'
|
||||
|
||||
return (
|
||||
f'<dt class="pending-{kind}-label">{label}</dt>'
|
||||
f'<dd>'
|
||||
f'<div class="num pending-line-main">{qty:,}주 @ {price_str}</div>'
|
||||
f'<div class="num pending-line-main">{main}</div>'
|
||||
f'{cond_line}'
|
||||
f'<div class="muted small pending-line-meta">{" · ".join(meta_parts)}</div>'
|
||||
f'</dd>'
|
||||
)
|
||||
@@ -3994,151 +4117,6 @@ def _render_phantom_row(r: dict) -> str:
|
||||
</details>'''
|
||||
|
||||
|
||||
def _render_pending_buy_held_row(r: dict) -> str:
|
||||
"""보유 종목 중 매수 미체결 걸린 행. 매수등록 섹션 (보유분) 카드.
|
||||
summary는 매수 대기 합계·평균 주문가, 보유 수량·평단도 함께. detail은 _pending_detail_html이 주문 단위 상세 출력."""
|
||||
stock = html.escape(r['stock'])
|
||||
code = html.escape(r.get('code', '') or '')
|
||||
accounts = html.escape('+'.join(r.get('accounts', [])))
|
||||
qty = r.get('qty', 0)
|
||||
avg = r.get('avg', 0)
|
||||
price = r.get('price', 0)
|
||||
buy_qty = r.get('pending_buy_qty', 0)
|
||||
|
||||
orders = [o for o in (r.get('pending_orders') or []) if o.get('side') == 'BUY']
|
||||
priced = [o for o in orders if o.get('price', 0) > 0]
|
||||
if priced:
|
||||
total = sum(o['qty'] for o in priced)
|
||||
avg_buy = sum(o['qty'] * o['price'] for o in priced) // total if total else 0
|
||||
else:
|
||||
avg_buy = 0
|
||||
|
||||
# 매수 주문 평균가 vs 현재가 → 추가매수 거리
|
||||
if avg_buy and price:
|
||||
gap = price - avg_buy
|
||||
gap_pct = (gap / avg_buy * 100) if avg_buy else 0.0
|
||||
gcls = 'up' if gap > 0 else ('down' if gap < 0 else 'neutral')
|
||||
gsign = '+' if gap >= 0 else ''
|
||||
diff_html = f'<span class="{gcls}">{gsign}{gap:,.0f}<span class="pct">{gsign}{gap_pct:.2f}%</span></span>'
|
||||
else:
|
||||
diff_html = ''
|
||||
|
||||
price_html = f'<span class="rt-px">{price:,}</span>' if price else '<span class="muted">-</span>'
|
||||
|
||||
summary_line2 = f'<span class="muted small">매수 대기 {buy_qty:,}주'
|
||||
if avg_buy:
|
||||
summary_line2 += f' · 평균 주문가 {avg_buy:,}원'
|
||||
if qty and avg:
|
||||
summary_line2 += f' · 보유 {qty:,}주 @ {avg:,}'
|
||||
summary_line2 += '</span>'
|
||||
|
||||
chart_block = ''
|
||||
if code:
|
||||
chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'
|
||||
|
||||
detail_pairs: list[str] = [f'<dt>종목코드</dt><dd class="muted">{code}</dd>']
|
||||
if price:
|
||||
detail_pairs.append(f'<dt>현재가</dt><dd class="num">{price:,}원</dd>')
|
||||
if avg:
|
||||
detail_pairs.append(f'<dt>평단가</dt><dd class="num">{avg:,}원</dd>')
|
||||
if avg_buy:
|
||||
detail_pairs.append(f'<dt>주문 평균가</dt><dd class="num">{avg_buy:,}원</dd>')
|
||||
if qty:
|
||||
detail_pairs.append(f'<dt>보유 수량</dt><dd class="num">{qty:,}주</dd>')
|
||||
detail_pairs.append(f'<dt>매수 대기</dt><dd class="num">{buy_qty:,}주</dd>')
|
||||
|
||||
row_key = (code or stock) + ':pending-buy'
|
||||
return f'''<details class="row neutral mode-pending-buy" data-row-key="{row_key}">
|
||||
<summary>
|
||||
<div class="left">
|
||||
<div class="line1"><span class="stock">{stock}</span><span class="badge pending-buy" title="키움 미체결 매수">매수등록</span><span class="code">{accounts}</span></div>
|
||||
<div class="line2">{summary_line2}</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="price">{price_html}<span class="caret" aria-hidden="true">▾</span></div>
|
||||
{f'<div class="diff">{diff_html}</div>' if diff_html else ''}
|
||||
</div>
|
||||
</summary>
|
||||
<div class="detail">
|
||||
<dl>{''.join(detail_pairs)}</dl>
|
||||
{_pending_detail_html(r)}
|
||||
{chart_block}
|
||||
</div>
|
||||
</details>'''
|
||||
|
||||
|
||||
def _render_pending_sell_held_row(r: dict) -> str:
|
||||
"""보유 종목 중 매도 미체결 걸린 행. 매도등록 섹션 전용 카드.
|
||||
summary는 매도 대기 합계·평균 주문가, 보유 수량·평단도 함께. detail은 _pending_detail_html이 주문 단위 상세 출력."""
|
||||
stock = html.escape(r['stock'])
|
||||
code = html.escape(r.get('code', '') or '')
|
||||
accounts = html.escape('+'.join(r.get('accounts', [])))
|
||||
qty = r.get('qty', 0)
|
||||
avg = r.get('avg', 0)
|
||||
price = r.get('price', 0)
|
||||
sell_qty = r.get('pending_sell_qty', 0)
|
||||
|
||||
orders = [o for o in (r.get('pending_orders') or []) if o.get('side') == 'SELL']
|
||||
priced = [o for o in orders if o.get('price', 0) > 0]
|
||||
if priced:
|
||||
total = sum(o['qty'] for o in priced)
|
||||
avg_sell = sum(o['qty'] * o['price'] for o in priced) // total if total else 0
|
||||
else:
|
||||
avg_sell = 0
|
||||
|
||||
if avg_sell and avg:
|
||||
gap = avg_sell - avg
|
||||
gap_pct = (gap / avg * 100) if avg else 0.0
|
||||
gcls = 'up' if gap > 0 else ('down' if gap < 0 else 'neutral')
|
||||
gsign = '+' if gap >= 0 else ''
|
||||
diff_html = f'<span class="{gcls}">{gsign}{gap:,.0f}<span class="pct">{gsign}{gap_pct:.2f}%</span></span>'
|
||||
else:
|
||||
diff_html = ''
|
||||
|
||||
price_html = f'<span class="rt-px">{price:,}</span>' if price else '<span class="muted">-</span>'
|
||||
|
||||
summary_line2 = f'<span class="muted small">매도 대기 {sell_qty:,}주'
|
||||
if avg_sell:
|
||||
summary_line2 += f' · 평균 주문가 {avg_sell:,}원'
|
||||
if qty and avg:
|
||||
summary_line2 += f' · 보유 {qty:,}주 @ {avg:,}'
|
||||
summary_line2 += '</span>'
|
||||
|
||||
chart_block = ''
|
||||
if code:
|
||||
chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'
|
||||
|
||||
detail_pairs: list[str] = [f'<dt>종목코드</dt><dd class="muted">{code}</dd>']
|
||||
if price:
|
||||
detail_pairs.append(f'<dt>현재가</dt><dd class="num">{price:,}원</dd>')
|
||||
if avg:
|
||||
detail_pairs.append(f'<dt>평단가</dt><dd class="num">{avg:,}원</dd>')
|
||||
if avg_sell:
|
||||
detail_pairs.append(f'<dt>주문 평균가</dt><dd class="num">{avg_sell:,}원</dd>')
|
||||
if qty:
|
||||
detail_pairs.append(f'<dt>보유 수량</dt><dd class="num">{qty:,}주</dd>')
|
||||
detail_pairs.append(f'<dt>매도 대기</dt><dd class="num">{sell_qty:,}주</dd>')
|
||||
|
||||
row_key = (code or stock) + ':pending-sell'
|
||||
return f'''<details class="row neutral mode-pending-sell" data-row-key="{row_key}">
|
||||
<summary>
|
||||
<div class="left">
|
||||
<div class="line1"><span class="stock">{stock}</span><span class="badge pending-sell" title="키움 미체결 매도">매도등록</span><span class="code">{accounts}</span></div>
|
||||
<div class="line2">{summary_line2}</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="price">{price_html}<span class="caret" aria-hidden="true">▾</span></div>
|
||||
{f'<div class="diff">{diff_html}</div>' if diff_html else ''}
|
||||
</div>
|
||||
</summary>
|
||||
<div class="detail">
|
||||
<dl>{''.join(detail_pairs)}</dl>
|
||||
{_pending_detail_html(r)}
|
||||
{chart_block}
|
||||
</div>
|
||||
</details>'''
|
||||
|
||||
|
||||
def _render_pending_unheld_row(r: dict) -> str:
|
||||
"""미보유 + 미체결 매수 주문 종목. 보유·당일정산 아닌 신규 매수 대기 행.
|
||||
summary는 종목명·미체결 합계, detail은 _pending_detail_html이 주문 단위로 출력."""
|
||||
@@ -5058,8 +5036,13 @@ def _render_owner_panel(owner: str, d: dict, balances: dict, owner_label_text: s
|
||||
parts.append(
|
||||
f'<div class="section-label">매수등록<span class="count">{buy_total}</span></div>'
|
||||
)
|
||||
# 보유 종목의 매수등록 카드도 보유종목 카드를 그대로 쓴다 (2026-07-30 관리자님 지시,
|
||||
# 매도등록과 동일한 이유·방식). pending_buy_held 원소가 consolidated 행 객체 자체다.
|
||||
# ⚠️ pending_unheld(미보유 + 매수 미체결)는 보유 수량·평단·손익이 없어 종목카드로
|
||||
# 대체할 수 없다 — 전용 카드 유지.
|
||||
for r in sorted(pending_buy_held, key=lambda x: -x.get('pending_buy_qty', 0)):
|
||||
parts.append(_render_pending_buy_held_row(r))
|
||||
parts.append(_render_holding_row(r, d['total_value'], show_day_change,
|
||||
key_suffix=':pending-buy'))
|
||||
for r in pending_unheld:
|
||||
parts.append(_render_pending_unheld_row(r))
|
||||
|
||||
@@ -5069,8 +5052,14 @@ def _render_owner_panel(owner: str, d: dict, balances: dict, owner_label_text: s
|
||||
parts.append(
|
||||
f'<div class="section-label">매도등록<span class="count">{len(pending_sell_held)}</span></div>'
|
||||
)
|
||||
# 매도등록 카드는 보유종목 카드를 그대로 쓴다 (2026-07-30 관리자님 지시).
|
||||
# pending_sell_held 원소가 consolidated(보유종목) 행 객체 자체라 필요한 필드가 다 있고,
|
||||
# 전용 카드는 summary 값을 detail 에서 되풀이하면서 손익·버튼·태그가 없어 축소판이었다.
|
||||
# 같은 종목이 자산정보 탭 보유종목과 겹치므로 key_suffix 로 row_key 를 분리한다
|
||||
# (바로 아래 ':held' 와 같은 방식 — mutex/open 복원 충돌 방지).
|
||||
for r in sorted(pending_sell_held, key=lambda x: -x.get('pending_sell_qty', 0)):
|
||||
parts.append(_render_pending_sell_held_row(r))
|
||||
parts.append(_render_holding_row(r, d['total_value'], show_day_change,
|
||||
key_suffix=':pending-sell'))
|
||||
|
||||
# 당일매매 발생한 종목도 보유 종목 섹션에 그대로 표시 — 양쪽 다 보이게 함.
|
||||
# 같은 종목이 양쪽에 들어가니 row_key에 ':held' suffix를 붙여 mutex/open 복원 충돌 방지.
|
||||
@@ -5446,6 +5435,42 @@ dl.pending-detail dt.pending-sell-label { color: #7fb8ff; }
|
||||
dl.pending-detail dd { line-height: 1.45; }
|
||||
dl.pending-detail .pending-line-main { font-variant-numeric: tabular-nums; }
|
||||
dl.pending-detail .pending-line-meta { margin-top: 2px; }
|
||||
/* 스톱·트레일링 조건단가 줄 — 좁은 폭에서 줄바꿈되게(가로 스크롤 방지) */
|
||||
dl.pending-detail .pending-line-cond { margin-top: 2px; color: #8b8f9a; overflow-wrap: anywhere; }
|
||||
dl.pending-detail .pending-line-cond.trailing { color: #d9a84a; }
|
||||
/* 트레일링 근거 미니표 — dd 안에 중첩되는 dl. 라벨 좌측 / 값 우측정렬로 자리수를 맞춘다.
|
||||
두 열 모두 minmax(0,..) 라 좁은 폭에서도 안 넘친다. */
|
||||
dl.pending-detail dl.trail-mini { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr);
|
||||
gap: 1px 8px; margin: 4px 0 0; padding: 5px 7px; font-size: 10.5px;
|
||||
border: 1px solid rgba(217,168,74,0.22); border-radius: 6px; background: rgba(217,168,74,0.05); }
|
||||
dl.pending-detail dl.trail-mini dt { color: #8b8f9a; font-weight: 400; white-space: nowrap; }
|
||||
dl.pending-detail dl.trail-mini dd { margin: 0; text-align: right; color: #a8adb8; line-height: 1.45;
|
||||
font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||
dl.pending-detail dl.trail-mini dd b { color: #d9a84a; font-weight: 600; }
|
||||
/* ⚠️ 보유종목 행(.row.mode-held)의 `.detail dl` 은 4열 grid(80px 1fr 80px 1fr)이고 명시도가
|
||||
(0,3,1) 로 위 `dl.pending-detail dl.trail-mini` (0,2,2) 보다 높다. 되돌리지 않으면 미니표가
|
||||
4열로 펼쳐져 고정 80px 두 개 때문에 폭을 넘겨 잘린다. pending-detail 도 같은 이유로
|
||||
바로 위에서 2열로 되돌려 놓았다. 이 규칙은 (0,4,1) 이라 640px 미디어쿼리 안의
|
||||
4열 규칙(0,3,1)까지 함께 이긴다 — 미디어쿼리용 사본은 필요 없다. */
|
||||
.row.mode-held .detail dl.trail-mini { grid-template-columns: minmax(0, auto) minmax(0, 1fr); }
|
||||
/* 트레일링 미리보기 카드 — .order-inputs 실폭이 좁아(모바일 ~200px) 가로로 벌어지는 요소를
|
||||
두면 가로 스크롤이 생긴다. 전부 세로 쌓기 + minmax(0,..) + anywhere 로 묶는다. */
|
||||
.trail-pv { border: 1px solid #1f2330; border-radius: 8px; padding: 8px 9px; background: #0f131c;
|
||||
display: flex; flex-direction: column; gap: 7px; min-width: 0; overflow-wrap: anywhere; }
|
||||
.trail-pv-hero { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.trail-pv-hero .cap { font-size: 10px; color: #7c8290; letter-spacing: .02em; }
|
||||
.trail-pv-hero .val { font-size: 17px; font-weight: 700; color: #cfd3dc; font-variant-numeric: tabular-nums; line-height: 1.15; }
|
||||
.trail-pv-hero .sub { font-size: 10px; color: #7c8290; }
|
||||
.trail-pv.bad { border-color: rgba(224,85,85,0.45); background: rgba(224,85,85,0.07); }
|
||||
.trail-pv.bad .val { color: #e05555; font-size: 13px; }
|
||||
.trail-pv-kv { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); gap: 2px 8px;
|
||||
font-size: 11px; border-top: 1px solid #1a1e28; padding-top: 6px; }
|
||||
.trail-pv-kv dt { color: #7c8290; }
|
||||
.trail-pv-kv dd { margin: 0; text-align: right; color: #a8adb8; font-variant-numeric: tabular-nums; }
|
||||
.trail-pv-kv dd b { color: #cfd3dc; font-weight: 600; }
|
||||
.trail-pv-note { font-size: 10px; color: #6f7480; line-height: 1.45; border-top: 1px solid #1a1e28; padding-top: 6px; }
|
||||
.trail-pv-badge { display: inline-block; font-size: 9px; padding: 1px 5px; border-radius: 999px;
|
||||
background: #2a2313; color: #d9a84a; margin-left: 4px; vertical-align: middle; }
|
||||
|
||||
.caret { color: #4a4f5a; font-size: 10px; transition: transform 0.2s; }
|
||||
.row[open] .caret { transform: rotate(180deg); color: #8b8f9a; }
|
||||
@@ -6578,7 +6603,11 @@ table.adr-info-table td.adr-breakdown { font-size: 11px; color: #8b8f9a; white-s
|
||||
.qty-step-buttons button[data-qty-step="max"] { background: #2a1f24; color: #ffcc77; }
|
||||
.qty-step-buttons button[data-qty-step="clear"] { background: #1d1418; color: #ff8a95; }
|
||||
.order-inputs input:disabled, .order-inputs select:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.order-inputs { display: flex; flex-direction: column; gap: 8px; min-height: 0; overflow-y: auto; }
|
||||
/* overflow-x: hidden 필수 — overflow-y:auto 만 주면 브라우저가 가로도 auto 로 만들어,
|
||||
내부가 조금만 넘쳐도 가로 스크롤이 생긴다(.orderbook 도 같은 이유로 명시). */
|
||||
.order-inputs { display: flex; flex-direction: column; gap: 8px; min-height: 0; overflow-y: auto; overflow-x: hidden; }
|
||||
/* 폼 컨트롤이 부모보다 넓어지는 것 자체를 막는다 — select 는 내재 최소폭을 갖는다 */
|
||||
.order-inputs input, .order-inputs select { max-width: 100%; box-sizing: border-box; }
|
||||
/* 입력이 모달 높이를 넘쳐도 매수/취소 버튼은 항상 하단 고정 — 잘림 방지 */
|
||||
.order-inputs .order-actions { margin-top: auto; position: sticky; bottom: 0; background: #0d1018; padding-top: 8px; }
|
||||
.order-inputs label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: #8b8f9a; }
|
||||
@@ -8038,11 +8067,33 @@ def _render_order_modal() -> str:
|
||||
'<option value="LIMIT">지정가</option>'
|
||||
'<option value="MARKET">시장가</option>'
|
||||
'<option value="STOP_LIMIT">스톱지정가 (하락 시 매도)</option>'
|
||||
'<option value="TRAILING_STOP">트레일링 스톱 (고점 따라 손절선 상승)</option>'
|
||||
'</select></label>'
|
||||
'<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>'
|
||||
# 트레일링: 폭(%)만 입력받고 조건단가·지정가는 서버가 현재가 기준으로 계산.
|
||||
# 미리보기는 클라이언트에서 같은 식으로 계산해 보여주되 실제 값은 서버 계산분이 승자.
|
||||
# 고점 기준 select 제거(2026-07-30) — 과거 고점(52주·매수후) 기준은 손절선이 현재가
|
||||
# 위로 올라가 등록 즉시 발동하는 구조라 쓸 수 없었다. 초기 고점은 등록 시점 현재가 고정.
|
||||
'<label data-order-trail-row style="display:none;">'
|
||||
# 단위(%·원)를 라벨에 박는다 — 입력칸 옆에 붙이면 가로로 벌어져 스크롤이 생기고,
|
||||
# number input 안쪽 absolute 접미사는 데스크톱 스피너와 겹친다.
|
||||
# 트레일 폭은 드롭다운(2026-07-30 관리자님 요청). 옵션 텍스트에 % 가 들어가므로
|
||||
# 라벨의 (%) 표기는 뺀다. select 는 label(flex column) 안에서 full-width 로 늘어나
|
||||
# 폭 문제가 없다 — 값이 '10%' 처럼 짧아 더욱 안전.
|
||||
'<span style="margin-top:8px;">트레일 폭 <span class="muted small" style="font-weight:400;">고점 대비 이만큼 떨어지면 매도</span></span>'
|
||||
# 1~30% 1%단위. 상한 30 은 orders/trailing.MAX_TRAIL_PCT 와 맞춰야 한다
|
||||
# (그쪽 주석에 세 곳 동시 수정 규칙 있음).
|
||||
'<select data-order-trail-input>'
|
||||
+ ''.join(f'<option value="{v}"{" selected" if v == 10 else ""}>{v}%</option>'
|
||||
for v in range(1, 31))
|
||||
+ '</select>'
|
||||
'<span style="margin-top:8px;">최저 매도가 <b style="color:#d9a84a;">(원)</b> <span class="muted small" style="font-weight:400;">비우면 제한 없음 · 손절선 하한</span></span>'
|
||||
'<input type="number" inputmode="numeric" min="0" step="1" placeholder="예: 4500" data-order-trail-min>'
|
||||
'<div data-order-trail-preview style="margin-top:6px; min-width:0;"></div>'
|
||||
'</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">'
|
||||
@@ -11378,17 +11429,84 @@ function setSide(side){
|
||||
updateCheck();
|
||||
fetchAdvice(); // 매수/매도 방향에 따라 권장가 세트가 다름
|
||||
}
|
||||
// 스톱지정가는 매도 전용 — 매수면 옵션 비활성+선택돼 있으면 LIMIT로 되돌림. 조건단가 행·단가 라벨 갱신.
|
||||
// 스톱지정가·트레일링은 매도 전용 — 매수면 옵션 비활성+선택돼 있으면 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 trailOpt = otSel.querySelector('option[value="TRAILING_STOP"]');
|
||||
if(trailOpt) trailOpt.disabled = (state.side === 'BUY');
|
||||
if(state.side === 'BUY' && (otSel.value === 'STOP_LIMIT' || otSel.value === 'TRAILING_STOP')) otSel.value = 'LIMIT';
|
||||
var isStop = (otSel.value === 'STOP_LIMIT');
|
||||
var isTrail = (otSel.value === 'TRAILING_STOP');
|
||||
var stopRow = $('[data-order-stop-row]');
|
||||
if(stopRow) stopRow.style.display = isStop ? '' : 'none';
|
||||
var trailRow = $('[data-order-trail-row]');
|
||||
if(trailRow) trailRow.style.display = isTrail ? '' : 'none';
|
||||
var priceRow = $('[data-order-price-row]');
|
||||
if(priceRow) priceRow.style.display = isTrail ? 'none' : '';
|
||||
var priceLabel = $('[data-order-price-label]');
|
||||
if(priceLabel) priceLabel.textContent = isStop ? '매도 단가' : '단가';
|
||||
if(isTrail) renderTrailPreview();
|
||||
else refreshSubmitGate(); // 트레일링에서 빠져나오면 트레일 게이트를 풀어야 한다
|
||||
}
|
||||
// 트레일링 입력값 읽기 (트레일 폭·최저 매도가). 미리보기·propose 공용.
|
||||
// 초기 고점은 항상 등록 시점 현재가 — 과거 고점 기준 옵션은 2026-07-30 제거됨.
|
||||
function trailInputs(){
|
||||
return {
|
||||
pct: parseFloat(($('[data-order-trail-input]')||{}).value || '0'),
|
||||
minPrice: parseInt(($('[data-order-trail-min]')||{}).value || '0', 10) || 0
|
||||
};
|
||||
}
|
||||
// 손절선 계산 — 서버 `orders/trailing.compute_levels` 와 **같은 식**이어야 한다.
|
||||
// 실제 발주값은 서버 계산분이 승자이므로, 한쪽만 바꾸면 표시와 발주가 어긋난다.
|
||||
function trailCondPrice(peak, pct, minPrice){
|
||||
var cond = floorTick(Math.floor(peak * (1 - pct/100)));
|
||||
var floorUv = minPrice > 0 ? floorTick(minPrice) : 0;
|
||||
return { cond: Math.max(cond, floorUv), floorApplied: floorUv > cond };
|
||||
}
|
||||
function renderTrailPreview(){
|
||||
// 게이트 갱신은 조기 반환보다 먼저 — 폭이 비었거나 시세 로딩 중일 때도 버튼이 잠겨야 한다.
|
||||
// trailBlockReason 이 DOM 을 직접 읽으므로 호출 순서에 의존하지 않는다.
|
||||
refreshSubmitGate();
|
||||
var el = $('[data-order-trail-preview]'); if(!el) return;
|
||||
var t = trailInputs();
|
||||
var cur = (state.lastCheck && state.lastCheck.cur_price) || (state.lastBook && state.lastBook.price) || 0;
|
||||
if(!(t.pct > 0) || !(cur > 0)){ el.textContent = ''; return; }
|
||||
var peak = cur; // 초기 고점 = 등록 시점 현재가 (서버 handler 와 동일)
|
||||
var r = trailCondPrice(peak, t.pct, t.minPrice);
|
||||
var bad = (r.cond >= cur);
|
||||
|
||||
// 결론(손절선)을 카드 상단에 크게, 근거는 라벨/값 표로, 안내는 하단 작게.
|
||||
var hero, note;
|
||||
if(bad){
|
||||
// 서버도 TRAIL_IMMEDIATE 로 거부한다 — PIN 발급 왕복 전에 여기서 먼저 알려준다.
|
||||
hero = '<div class="cap">등록할 수 없습니다</div>' +
|
||||
'<div class="val">손절선 ' + fmt(r.cond) + '원 ≥ 현재가 ' + fmt(cur) + '원</div>' +
|
||||
'<div class="sub">조건을 이미 넘어 등록 즉시 발동됩니다</div>';
|
||||
note = r.floorApplied
|
||||
? '최저 매도가를 현재가보다 낮게 내려주세요.'
|
||||
: '트레일 폭을 넓혀주세요.';
|
||||
} else {
|
||||
var pctFromCur = cur > 0 ? ((cur - r.cond) / cur * 100) : 0;
|
||||
hero = '<div class="cap">지금 등록하면 손절선</div>' +
|
||||
'<div class="val">' + fmt(r.cond) + '원' +
|
||||
(r.floorApplied ? '<span class="trail-pv-badge">최저가 적용</span>' : '') + '</div>' +
|
||||
'<div class="sub">현재가보다 ' + pctFromCur.toFixed(1) + '% 아래 · 여기 닿으면 매도</div>';
|
||||
note = '고점이 오르면 손절선도 <b>따라 올라갑니다</b>. 내려가지는 않습니다.';
|
||||
}
|
||||
|
||||
var kv = [];
|
||||
kv.push('<dt>시작 고점</dt><dd><b>' + fmt(peak) + '원</b><br>등록 시점 현재가</dd>');
|
||||
kv.push('<dt>트레일 폭</dt><dd>−' + t.pct + '%</dd>');
|
||||
if(t.minPrice > 0) kv.push('<dt>최저 매도가</dt><dd>' + fmt(t.minPrice) + '원</dd>');
|
||||
|
||||
el.innerHTML = '<div class="trail-pv' + (bad ? ' bad' : '') + '">' +
|
||||
'<div class="trail-pv-hero">' + hero + '</div>' +
|
||||
'<dl class="trail-pv-kv">' + kv.join('') + '</dl>' +
|
||||
'<div class="trail-pv-note">' + note + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
function _resolveOrderPrice(){
|
||||
// 단가 우선, 없으면 현재가 fallback. 단가 input 비어있으면 자동 채움 (disabled 아닌 경우)
|
||||
@@ -11440,6 +11558,9 @@ function renderQtyButtons(){
|
||||
}
|
||||
function renderBook(book){
|
||||
state.lastBook = book;
|
||||
// 트레일링 미리보기는 현재가·52주 전고점을 이 응답에서 읽는다 — 호가 갱신마다 다시 그린다.
|
||||
var otNow = $('[data-order-type]');
|
||||
if(otNow && otNow.value === 'TRAILING_STOP') renderTrailPreview();
|
||||
if(book.name){ var ne = $('[data-order-name]'); if(ne) ne.textContent = book.name; state.name = book.name; }
|
||||
var ce = $('[data-order-cur-price]'); if(ce) ce.textContent = fmt(book.price)+'원';
|
||||
var che = $('[data-order-change]');
|
||||
@@ -11509,6 +11630,8 @@ function tickSize(p){
|
||||
return 1000;
|
||||
}
|
||||
function snapToTick(p){ if(!(p>0)) return p; var t = tickSize(p); return Math.round(p/t)*t; }
|
||||
// 호가단위 내림 — 트레일링 손절선은 내림이 안전(더 빨리 발동). orders/trailing.floor_to_tick 과 동일.
|
||||
function floorTick(p){ if(!(p>0)) return p; var t = tickSize(p); return p - (p % t); }
|
||||
// 기술적 권장가(매수/매도 판단 보조). code·side(·평단) 바뀔 때만 fetch, keystroke엔 캐시 재사용.
|
||||
function fetchAdvice(){
|
||||
var code = state.code; if(!code){ renderAdvice(null); return; }
|
||||
@@ -11774,7 +11897,51 @@ function loadOpenOrders(){
|
||||
list.innerHTML = rows.map(function(r){
|
||||
var sideCls = r.side === 'BUY' ? 'buy' : 'sell';
|
||||
var sideLabel = r.side === 'BUY' ? '매수' : '매도';
|
||||
var priceLabel = r.order_price ? (r.order_price.toLocaleString('ko-KR') + '원') : (r.order_type || '시장가');
|
||||
// 주문유형을 항상 붙인다 — 가격만 쓰면 스톱지정가·트레일링이 지정가처럼 보인다.
|
||||
// 트레일링은 지정가가 방아쇠로 오해되지 않게 첫 줄에서 빼고 아래 줄에 조건가와 함께 쓴다.
|
||||
var priceLabel = r.trailing
|
||||
? '매도 예약'
|
||||
: (r.order_price
|
||||
? (r.order_price.toLocaleString('ko-KR') + '원' + (r.order_type ? ' ' + r.order_type : ''))
|
||||
: (r.order_type || '시장가'));
|
||||
var condLine = '';
|
||||
if(r.trailing){
|
||||
// 서버 렌더(_pending_detail_html)와 같은 정보·같은 순서. 모달은 폭이 넓어 한 줄 요약.
|
||||
var tb = [];
|
||||
if(r.stop_price){
|
||||
var head = fmt(r.stop_price) + '원 닿으면';
|
||||
if(r.order_price){
|
||||
// 갭은 틱수로 — 서버 _stop_gap_html 과 같은 규칙(안 나눠지면 원으로 폴백).
|
||||
var gap = r.stop_price - r.order_price;
|
||||
var tk = tickSize(r.stop_price);
|
||||
var gapTxt = '';
|
||||
if(gap > 0) gapTxt = (tk > 0 && gap % tk === 0)
|
||||
? ' (−' + (gap / tk) + '틱)' : ' (−' + fmt(gap) + '원)';
|
||||
head += ' ' + fmt(r.order_price) + '원에 매도' + gapTxt;
|
||||
} else { head += ' 매도'; }
|
||||
tb.push(head);
|
||||
}
|
||||
// 고점에 기준 라벨을 붙이지 않는다 — 서버 렌더와 같은 이유(peak 는 갱신된 현재 고점).
|
||||
tb.push('고점 ' + fmt(r.trailing.peak) + '원 대비 −' + r.trailing.trail_pct + '%');
|
||||
if(r.trailing.min_sell_price) tb.push('최저 ' + fmt(r.trailing.min_sell_price) + '원');
|
||||
if(r.cur_price && r.stop_price){
|
||||
// 손절선 기준 여유폭 — 분모는 손절선(라벨과 계산 기준을 일치시킴).
|
||||
tb.push('손절선 대비 ' + ((r.cur_price - r.stop_price) / r.stop_price * 100).toFixed(1) + '%');
|
||||
}
|
||||
// 상향 횟수는 표시하지 않는다(관리자님 지시). 얼마나 올랐는지만 보여준다.
|
||||
var mc = r.trailing.modify_count || 0;
|
||||
if(r.trailing.entry_peak && r.trailing.peak > r.trailing.entry_peak){
|
||||
tb.push('고점 상승 ' + fmt(r.trailing.entry_peak) + ' → ' + fmt(r.trailing.peak) +
|
||||
'원 (+' + fmt(r.trailing.peak - r.trailing.entry_peak) + ')');
|
||||
}
|
||||
if(mc && r.trailing.entry_cond_uv && r.stop_price > r.trailing.entry_cond_uv){
|
||||
tb.push('손절선 상승 ' + fmt(r.trailing.entry_cond_uv) + ' → ' + fmt(r.stop_price) +
|
||||
'원 (+' + fmt(r.stop_price - r.trailing.entry_cond_uv) + ')');
|
||||
}
|
||||
condLine = '<div class="row-line2" style="color:#d9a84a; overflow-wrap:anywhere;">' + tb.join(' · ') + '</div>';
|
||||
} else if(r.stop_price){
|
||||
condLine = '<div class="row-line2">' + fmt(r.stop_price) + '원 도달 시 매도 (고정)</div>';
|
||||
}
|
||||
var safeOrd = String(r.ord_no || '').replace(/"/g,'"');
|
||||
var safeAcc = String(r.account || '').replace(/"/g,'"');
|
||||
return (
|
||||
@@ -11790,6 +11957,7 @@ function loadOpenOrders(){
|
||||
' · 미체결 ' + r.unfilled_qty + '주 · ' + r.status +
|
||||
' · ' + r.exchange + ' · ' + (r.order_time || '') +
|
||||
'</div>' +
|
||||
condLine +
|
||||
'</div>' +
|
||||
'<div class="row-action">' +
|
||||
'<button type="button" data-cancel-open-order>취소</button>' +
|
||||
@@ -11856,11 +12024,38 @@ function updateMarketPhaseDisplay(){
|
||||
el.textContent = label + ' ' + (phase.time || '');
|
||||
el.className = 'order-market-phase ' + cls;
|
||||
}
|
||||
var sb = $('[data-order-submit]');
|
||||
if(sb){
|
||||
sb.disabled = !canTrade;
|
||||
sb.title = canTrade ? '' : ('매매 비활성: ' + label);
|
||||
// 버튼 disabled 는 refreshSubmitGate 한 곳에서만 결정한다 — 여기서 직접 건드리면
|
||||
// 트레일링 게이트(입력값 기반)를 주기 호출이 덮어써 즉시발동 입력이 통과된다.
|
||||
state.phaseCanTrade = canTrade;
|
||||
state.phaseLabel = label;
|
||||
refreshSubmitGate();
|
||||
}
|
||||
// 트레일링 입력이 등록 불가 상태면 그 이유를 반환, 문제 없으면 빈 문자열.
|
||||
// 즉시발동(손절선 ≥ 현재가)은 서버도 TRAIL_IMMEDIATE 로 거부하지만, 매도 버튼을 누른 뒤가
|
||||
// 아니라 입력하는 시점에 막는다.
|
||||
function trailBlockReason(){
|
||||
var otSel = $('[data-order-type]');
|
||||
if(!otSel || otSel.value !== 'TRAILING_STOP') return '';
|
||||
var t = trailInputs();
|
||||
if(!(t.pct >= 0.5 && t.pct <= 30)) return '트레일 폭은 0.5~30% 범위로 입력하세요';
|
||||
var cur = (state.lastCheck && state.lastCheck.cur_price) || (state.lastBook && state.lastBook.price) || 0;
|
||||
if(!(cur > 0)) return '현재가를 받는 중입니다';
|
||||
var r = trailCondPrice(cur, t.pct, t.minPrice); // 초기 고점 = 현재가
|
||||
if(r.cond >= cur){
|
||||
return r.floorApplied
|
||||
? ('최저 매도가 ' + fmt(t.minPrice) + '원이 현재가 ' + fmt(cur) + '원보다 높아 즉시 발동됩니다')
|
||||
: ('손절선 ' + fmt(r.cond) + '원이 현재가 ' + fmt(cur) + '원 이상이라 즉시 발동됩니다');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
// 매도/매수 버튼 활성 여부의 단일 결정 지점. 시장 phase·트레일링 입력 게이트를 합친다.
|
||||
function refreshSubmitGate(){
|
||||
var sb = $('[data-order-submit]'); if(!sb) return;
|
||||
if(sb.dataset.busy === '1') return; // propose 진행 중 — 끝나고 다시 계산한다
|
||||
var phaseBlocked = (state.phaseCanTrade === false);
|
||||
var trailMsg = trailBlockReason();
|
||||
sb.disabled = phaseBlocked || !!trailMsg;
|
||||
sb.title = phaseBlocked ? ('매매 비활성: ' + (state.phaseLabel || '')) : (trailMsg || '');
|
||||
}
|
||||
function populateSymbolSelect(currentCode, currentName){
|
||||
var sel = $('[data-order-symbol-select]'); if(!sel) return;
|
||||
@@ -12103,9 +12298,16 @@ function doPropose(){
|
||||
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);
|
||||
var trail = trailInputs();
|
||||
var trailPct = trail.pct;
|
||||
if(!account){ setMsg('계좌를 선택하세요', 'error'); return; }
|
||||
if(qty <= 0){ setMsg('수량을 입력하세요', 'error'); return; }
|
||||
if(orderType === 'LIMIT' && price <= 0){ setMsg('지정가는 단가가 필요합니다', 'error'); return; }
|
||||
if(orderType === 'TRAILING_STOP'){
|
||||
if(state.side !== 'SELL'){ setMsg('트레일링 스톱은 매도만 가능합니다', 'error'); return; }
|
||||
// 트레일 폭·즉시발동 검사는 입력 시점에 trailBlockReason 이 하고, 걸리면 매도 버튼이
|
||||
// 비활성이라 여기 도달하지 않는다. 서버(TRAIL_IMMEDIATE)가 최후 방어선.
|
||||
}
|
||||
if(orderType === 'STOP_LIMIT'){
|
||||
if(state.side !== 'SELL'){ setMsg('스톱지정가는 매도만 가능합니다', 'error'); return; }
|
||||
if(price <= 0){ setMsg('스톱지정가는 매도 단가가 필요합니다', 'error'); return; }
|
||||
@@ -12128,7 +12330,9 @@ function doPropose(){
|
||||
return;
|
||||
}
|
||||
// 검증 통과 — 매수/매도 방향 최종 확인 팝업 (오타 방지). 확인 시에만 실제 매매로 진행.
|
||||
state.pendingOrder = {account: account, orderType: orderType, qty: qty, price: price, stopPrice: stopPrice};
|
||||
state.pendingOrder = {account: account, orderType: orderType, qty: qty, price: price,
|
||||
stopPrice: stopPrice, trailPct: trailPct,
|
||||
trailMinPrice: trail.minPrice};
|
||||
openOrderConfirm();
|
||||
}
|
||||
// 방향 확인 후 실제 매매 진행 — 전량매도 sell-choice 분기 포함.
|
||||
@@ -12137,8 +12341,8 @@ function doProposeConfirmed(){
|
||||
var account = po.account, orderType = po.orderType, qty = po.qty, price = po.price;
|
||||
var lc = state.lastCheck;
|
||||
// 전량매도(입력 수량 = 매도가능 전량)이고 같은 소유자 그룹의 다른 계좌에도 같은 종목 보유
|
||||
// → 선택 팝업. 스톱지정가는 단일 계좌 예약이라 2계좌 동시 매도 대상 아님.
|
||||
if(state.side === 'SELL' && orderType !== 'STOP_LIMIT'){
|
||||
// → 선택 팝업. 스톱지정가·트레일링은 단일 계좌 예약이라 2계좌 동시 매도 대상 아님.
|
||||
if(state.side === 'SELL' && orderType !== 'STOP_LIMIT' && orderType !== 'TRAILING_STOP'){
|
||||
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;
|
||||
@@ -12148,7 +12352,7 @@ function doProposeConfirmed(){
|
||||
return;
|
||||
}
|
||||
}
|
||||
proposeSingle(account, orderType, qty, price, po.stopPrice);
|
||||
proposeSingle(account, orderType, qty, price, po.stopPrice, po.trailPct, po.trailMinPrice);
|
||||
}
|
||||
// ── 매수/매도 방향 확인 모달 ──
|
||||
// 매수·매도 버튼을 둘 다 띄우고 관리자님이 직접 맞는 방향을 누르게 함(위치 랜덤).
|
||||
@@ -12159,14 +12363,22 @@ function openOrderConfirm(){
|
||||
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 isTrail = (po.orderType === 'TRAILING_STOP');
|
||||
var typeStr = (po.orderType === 'MARKET') ? '시장가'
|
||||
: isTrail ? '트레일링 스톱(예약)'
|
||||
: isStop ? '스톱지정가(예약)' : '지정가';
|
||||
var curNow = (state.lastCheck && state.lastCheck.cur_price) || (state.lastBook && state.lastBook.price) || 0;
|
||||
// 트레일링은 단가를 서버가 계산 — 확인 팝업엔 예상 손절선을 보여준다.
|
||||
var trailPeak = isTrail ? curNow : 0; // 초기 고점 = 등록 시점 현재가
|
||||
var trailRes = isTrail ? trailCondPrice(trailPeak, po.trailPct, po.trailMinPrice) : {cond: 0, floorApplied: false};
|
||||
var trailCond = trailRes.cond;
|
||||
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 refPrice = (po.orderType === 'MARKET') ? curNow : (isTrail ? trailCond : po.price);
|
||||
var amount = refPrice * po.qty;
|
||||
var fee = Math.round(amount * (0.00015 + 0.0018)); // 수수료 0.015% + 거래세 0.18%
|
||||
if(amount > 0){
|
||||
@@ -12174,12 +12386,23 @@ function openOrderConfirm(){
|
||||
}
|
||||
}
|
||||
var stopLine = isStop ? ('· 조건단가: <b>' + fmt(po.stopPrice) + '원 도달 시</b> <span class="muted small">(예약)</span><br>') : '';
|
||||
var priceLine;
|
||||
if(isTrail){
|
||||
priceLine = '· 시작 고점: <b>' + fmt(trailPeak) + '원</b> <span class="muted small">(등록 시점 현재가)</span><br>' +
|
||||
'· 트레일 폭: <b>고점 대비 −' + po.trailPct + '%</b><br>' +
|
||||
(po.trailMinPrice > 0 ? ('· 최저 매도가: <b>' + fmt(po.trailMinPrice) + '원</b> <span class="muted small">(' +
|
||||
(trailRes.floorApplied ? '최저가 적용' : '트레일 적용') + ')</span><br>') : '') +
|
||||
'· 지금 손절선: <b>' + fmt(trailCond) + '원</b> <span class="muted small">(현재가 ' + fmt(curNow) + '원)</span><br>' +
|
||||
'· <span class="muted small">고점이 오르면 손절선도 따라 올라갑니다</span><br>';
|
||||
} else {
|
||||
priceLine = '· ' + (isStop ? '매도 단가' : '단가') + ': <b>' + priceStr + '</b><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>' +
|
||||
priceLine +
|
||||
'· 수량: <b>' + fmt(po.qty) + '주</b><br>' +
|
||||
feeLine + '<br>' +
|
||||
'<b>매수 / 매도</b> 중 하려던 것을 눌러주세요.';
|
||||
@@ -12207,7 +12430,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, stopPrice){
|
||||
function proposeSingle(account, orderType, qty, price, stopPrice, trailPct, trailMinPrice){
|
||||
var body = new URLSearchParams();
|
||||
body.set('account', account);
|
||||
body.set('side', state.side);
|
||||
@@ -12217,6 +12440,11 @@ function proposeSingle(account, orderType, qty, price, stopPrice){
|
||||
body.set('order_type', orderType);
|
||||
if(orderType === 'LIMIT' || orderType === 'STOP_LIMIT') body.set('price', String(price));
|
||||
if(orderType === 'STOP_LIMIT') body.set('stop_price', String(stopPrice || 0));
|
||||
// 트레일링은 폭(%)·고점 기준·최저 매도가만 보낸다 — 조건단가·지정가는 서버가 계산.
|
||||
if(orderType === 'TRAILING_STOP'){
|
||||
body.set('trail_pct', String(trailPct || 0));
|
||||
if(trailMinPrice > 0) body.set('min_sell_price', String(trailMinPrice));
|
||||
}
|
||||
proposeAndOpenPin('/api/order/propose', body);
|
||||
}
|
||||
function doProposeMulti(opts){
|
||||
@@ -12232,7 +12460,8 @@ function doProposeMulti(opts){
|
||||
}
|
||||
function proposeAndOpenPin(url, body){
|
||||
setMsg('주문 검증 중…', 'info');
|
||||
var btn = $('[data-order-submit]'); if(btn) btn.disabled = true;
|
||||
// busy 플래그로 잠근다 — 주기 호출(updateMarketPhaseDisplay)이 진행 중에 버튼을 되살리지 못하게.
|
||||
var btn = $('[data-order-submit]'); if(btn){ btn.dataset.busy = '1'; btn.disabled = true; }
|
||||
// 이전 활성 카드 자동 정리 후 새 propose — "이전 카드가 아직 활성" 거부 방지
|
||||
fetch('/api/order/cancel', {method:'POST', headers:{'Accept':'application/json'}, body:''})
|
||||
.catch(function(){})
|
||||
@@ -12245,7 +12474,8 @@ function proposeAndOpenPin(url, body){
|
||||
})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
if(btn) btn.disabled = false;
|
||||
if(btn) btn.dataset.busy = '0';
|
||||
refreshSubmitGate();
|
||||
if(!d.ok){ setMsg(d.message || d.error || 'PIN 발급 실패', 'error'); return; }
|
||||
setMsg('');
|
||||
// 별도 PIN 모달 띄움 (order-modal은 그대로 뒤에 남음)
|
||||
@@ -12255,7 +12485,8 @@ function proposeAndOpenPin(url, body){
|
||||
symbol_name: state.name
|
||||
});
|
||||
}).catch(function(e){
|
||||
if(btn) btn.disabled = false;
|
||||
if(btn) btn.dataset.busy = '0';
|
||||
refreshSubmitGate();
|
||||
setMsg('네트워크 오류: ' + e, 'error');
|
||||
});
|
||||
}
|
||||
@@ -12446,6 +12677,11 @@ var qtyInpEl = $('[data-order-qty]');
|
||||
if(qtyInpEl) qtyInpEl.addEventListener('input', function(){ recalcBudgetFromQty(); renderOrderInfo(); });
|
||||
var budgetInpEl = $('[data-order-budget]');
|
||||
if(budgetInpEl) budgetInpEl.addEventListener('input', recalcQtyFromBudget);
|
||||
// 트레일 폭은 select — change 로 잡는다(input 도 발생하지만 select 의 정식 이벤트는 change).
|
||||
var trailInpEl = $('[data-order-trail-input]');
|
||||
if(trailInpEl) trailInpEl.addEventListener('change', renderTrailPreview);
|
||||
var trailMinEl = $('[data-order-trail-min]');
|
||||
if(trailMinEl) trailMinEl.addEventListener('input', renderTrailPreview);
|
||||
document.addEventListener('visibilitychange', function(){
|
||||
if(document.hidden) stopPolling();
|
||||
else if(state.isOpen) startPolling();
|
||||
@@ -13283,6 +13519,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
import kiwoom_client as kc
|
||||
rows = kc.get_open_orders_all()
|
||||
# 트레일링 예약이면 그 상태를 실어 보낸다 (예약 파일 읽기, 키움 콜 0).
|
||||
for _r in rows:
|
||||
_r['trailing'] = _trailing_info_for(_r.get('ord_no', ''))
|
||||
self._send_json(200, {'rows': rows, 'count': len(rows)})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
@@ -13415,6 +13654,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except (ValueError, TypeError):
|
||||
book['upper_limit'] = 0
|
||||
book['lower_limit'] = 0
|
||||
# ⚠️ 52주 최고가(250hgst)는 여기 싣지 않는다 — 트레일링 '전고점 기준'
|
||||
# 미리보기용으로 추가했다가 옵션 제거와 함께 제거(2026-07-30).
|
||||
# 기업정보 모달의 '52주 최고'는 /api/stock_info(get_stock_basic) 경로다.
|
||||
book['nxt_enable'] = nxt_enable # 위에서 판정한 값 재사용
|
||||
# 종목이 속한 시장(KOSPI/KOSDAQ)의 20일 ADR 과매수/과매도 — 극단일 때만 실림.
|
||||
_mkt = (meta or {}).get('market') or ''
|
||||
@@ -14054,11 +14296,22 @@ class Handler(BaseHTTPRequestHandler):
|
||||
stop_price = int(stop_raw) if stop_raw else None
|
||||
except ValueError:
|
||||
stop_price = None
|
||||
trail_raw = (params.get('trail_pct') or [''])[0].strip()
|
||||
try:
|
||||
trail_pct = float(trail_raw) if trail_raw else None
|
||||
except ValueError:
|
||||
trail_pct = None
|
||||
min_sell_raw = (params.get('min_sell_price') or [''])[0].strip()
|
||||
try:
|
||||
min_sell_price = int(min_sell_raw) if min_sell_raw else None
|
||||
except ValueError:
|
||||
min_sell_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', 'STOP_LIMIT'):
|
||||
self._send_json(400, {'ok': False, 'error': 'order_type must be LIMIT|MARKET|STOP_LIMIT'})
|
||||
if order_type not in ('LIMIT', 'MARKET', 'STOP_LIMIT', 'TRAILING_STOP'):
|
||||
self._send_json(400, {'ok': False,
|
||||
'error': 'order_type must be LIMIT|MARKET|STOP_LIMIT|TRAILING_STOP'})
|
||||
return
|
||||
if order_type == 'LIMIT' and (price is None or price <= 0):
|
||||
self._send_json(400, {'ok': False, 'error': 'LIMIT requires positive price'})
|
||||
@@ -14070,6 +14323,17 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if price is None or price <= 0 or stop_price is None or stop_price <= 0:
|
||||
self._send_json(400, {'ok': False, 'error': '스톱지정가는 단가와 조건단가가 모두 필요'})
|
||||
return
|
||||
if order_type == 'TRAILING_STOP':
|
||||
# 조건단가·지정가는 handler 가 현재가 기준으로 계산 — 여기선 트레일 폭만 받는다.
|
||||
if side != 'SELL':
|
||||
self._send_json(400, {'ok': False, 'error': '트레일링 스톱은 매도만 지원'})
|
||||
return
|
||||
if trail_pct is None or trail_pct <= 0:
|
||||
self._send_json(400, {'ok': False, 'error': '트레일 폭(%)을 입력하세요'})
|
||||
return
|
||||
if min_sell_price is not None and min_sell_price <= 0:
|
||||
self._send_json(400, {'ok': False, 'error': '최저 매도가는 0보다 커야 합니다'})
|
||||
return
|
||||
try:
|
||||
if not symbol_name:
|
||||
import kiwoom_client as kc
|
||||
@@ -14086,6 +14350,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
qty=qty, order_type=order_type,
|
||||
price=(price if order_type in ('LIMIT', 'STOP_LIMIT') else None),
|
||||
stop_price=(stop_price if order_type == 'STOP_LIMIT' else None),
|
||||
trail_pct=(trail_pct if order_type == 'TRAILING_STOP' else None),
|
||||
min_sell_price=(min_sell_price if order_type == 'TRAILING_STOP' else None),
|
||||
)
|
||||
if res.get('ok'):
|
||||
# 매수/매도 미리보기 카드 메시지·PIN 메시지 모두 텔레그램 발송 X.
|
||||
|
||||
Reference in New Issue
Block a user