auto: 일일 백업 2026-08-01 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2001,6 +2001,23 @@ def _set_stock_tag(code: str | None, name: str | None, text: str, leader: bool)
|
||||
tmp.replace(STOCK_TAGS)
|
||||
|
||||
|
||||
# ---- 트레일링 계단 프리셋 (읽기 전용 · 고정 4종) ----
|
||||
# 계단 = [{'pct': 고점대비 하락률, 'cum': 그때까지 팔려 있어야 할 누적 비중}]
|
||||
# 누적으로 적는 이유는 관리자님이 그렇게 생각하기 때문이다("30% 빠지면 전량") —
|
||||
# 주문 수량인 추가분(20/30/50)으로의 환산은 orders/trailing.normalize_steps 가 한다.
|
||||
# 순서 = 기본값이 맨 앞(단일) + 트레일 폭이 좁은 것부터. 화면에서 고른 뒤 그 자리에서
|
||||
# 고칠 수 있으므로 저장 기능은 두지 않는다(2026-07-31 관리자님 지시로 제거).
|
||||
TRAIL_PRESETS = [
|
||||
{'name': '단일', 'steps': [{'pct': 10, 'cum': 100}]},
|
||||
{'name': '보수', 'steps': [{'pct': 5, 'cum': 30}, {'pct': 10, 'cum': 70},
|
||||
{'pct': 15, 'cum': 100}]},
|
||||
{'name': '표준', 'steps': [{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50},
|
||||
{'pct': 30, 'cum': 100}]},
|
||||
{'name': '느슨', 'steps': [{'pct': 15, 'cum': 25}, {'pct': 25, 'cum': 50},
|
||||
{'pct': 30, 'cum': 100}]},
|
||||
]
|
||||
|
||||
|
||||
def _stock_notes_lock():
|
||||
"""STOCK_NOTES 파일 직렬화 — _stock_tags_lock 과 동일 패턴, 별도 lock 파일."""
|
||||
import fcntl as _fcntl
|
||||
@@ -3633,21 +3650,35 @@ def _trailing_info_for(ord_no: str):
|
||||
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()}
|
||||
# 예약 1건이 계단(레그) N개 = 미체결 주문 N건이라, 레그마다 (예약, 레그) 로 색인한다.
|
||||
by_ord = {}
|
||||
for r in _trl.list_active():
|
||||
for s in r.get('steps') or []:
|
||||
by_ord[s['ord_no']] = (r, s)
|
||||
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:
|
||||
hit = _trailing_lookup_cache['by_ord'].get(ord_no)
|
||||
if not hit:
|
||||
return None
|
||||
r, s = hit
|
||||
steps = r.get('steps') or []
|
||||
return {
|
||||
'id': r['id'], 'trail_pct': r.get('trail_pct'), 'peak': r.get('peak', 0),
|
||||
'id': r['id'], 'peak': r.get('peak', 0),
|
||||
'min_sell_price': r.get('min_sell_price'),
|
||||
'modify_count': r.get('modify_count', 0),
|
||||
# 이 주문이 몇 단계인지 + 예약 전체가 몇 단인지 — 미체결 목록엔 레그가 따로 뜨므로
|
||||
# "3단 중 2단계" 를 보여줘야 다른 행과의 관계가 읽힌다.
|
||||
'step_n': s['n'], 'step_total': len(steps),
|
||||
'trail_pct': s.get('pct'), 'cum': s.get('cum'),
|
||||
'step_qty': s.get('qty', 0),
|
||||
'modify_count': s.get('modify_count', 0),
|
||||
# 등록 시점 값 — 손절선·고점이 그 뒤로 얼마 올라왔는지 보여주는 데 쓴다.
|
||||
'entry_cond_uv': r.get('entry_cond_uv', 0),
|
||||
'entry_cond_uv': s.get('entry_cond_uv', 0),
|
||||
'entry_peak': r.get('entry_peak', 0),
|
||||
# 형제 계단 — 이 예약의 전체 그림을 한 행에서 보여준다.
|
||||
'siblings': [{'n': x['n'], 'pct': x.get('pct'), 'cond_uv': x.get('cond_uv', 0),
|
||||
'qty': x.get('qty', 0), 'cum': x.get('cum')} for x in steps],
|
||||
}
|
||||
|
||||
|
||||
@@ -3737,7 +3768,19 @@ def _pending_detail_html(r: dict) -> str:
|
||||
# (실측 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>')
|
||||
cum_v = trl.get('cum') or 0
|
||||
cum_part = '전량' if cum_v >= 100 else f'누적 {cum_v:g}%'
|
||||
mini.append(f'<dt>계단</dt><dd><b>{trl.get("step_n")}단계</b> / '
|
||||
f'{trl.get("step_total")}단 · −{trl.get("trail_pct"):g}% '
|
||||
f'<span class="muted">({cum_part})</span></dd>')
|
||||
if (trl.get('step_total') or 0) > 1:
|
||||
# 형제 계단 — 미체결 목록엔 레그가 각각 뜨므로, 이 예약 전체가 어떤 모양인지
|
||||
# 한 행에서 보이게 한다. 지금 행에 해당하는 계단은 굵게.
|
||||
sib = ' · '.join(
|
||||
(f'<b>{x["n"]}:{x["cond_uv"]:,}</b>' if x['n'] == trl.get('step_n')
|
||||
else f'{x["n"]}:{x["cond_uv"]:,}')
|
||||
for x in (trl.get('siblings') or []))
|
||||
mini.append(f'<dt>전체 계단</dt><dd>{sib}</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:
|
||||
@@ -5130,10 +5173,11 @@ def _render_owner_panel(owner: str, d: dict, balances: dict, owner_label_text: s
|
||||
_CSS = '''
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { margin: 0; padding: 0; overscroll-behavior-y: none; touch-action: manipulation; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Apple SD Gothic Neo", sans-serif; background: #0b0d12; color: #e6e6e6; -webkit-font-smoothing: antialiased; padding-bottom: calc(env(safe-area-inset-bottom) + 28px); -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Apple SD Gothic Neo", sans-serif; background: #0b0d12; color: #e6e6e6; -webkit-font-smoothing: antialiased; padding-bottom: calc(env(safe-area-inset-bottom) + 44px); -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
||||
|
||||
/* 하단 고정 시장 지수 — 코스피/코스닥. viewport 최하단에 박힘. iOS 홈 인디케이터 영역만 safe-area 패딩으로 양보. */
|
||||
/* 증권사 스타일 무한 가로 스크롤. 같은 항목 세트를 2번 렌더 후 -50% 이동 → seamless loop. */
|
||||
/* 하단 고정 시장 지수. viewport 최하단에 박힘. iOS 홈 인디케이터 영역만 safe-area 패딩으로 양보. */
|
||||
/* 지수 2개 = 한 묶음. 바를 탭할 때마다 다음 묶음으로 순환(마지막 → 처음).
|
||||
자동 스크롤·자동 회전 없음 — 흘러가는 마퀴는 2026-07-31 관리자님 지시로 제거됐다. 되살리지 말 것. */
|
||||
.market-ticker {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
|
||||
background: rgba(11,13,18,0.96);
|
||||
@@ -5142,43 +5186,45 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Apple
|
||||
padding: 5px 0;
|
||||
padding-bottom: calc(5px + env(safe-area-inset-bottom));
|
||||
font-size: 10.5px;
|
||||
/* line-height 1.2 고정: 라벨줄이 한글(닛케이225·금·은·동)이라 normal이면 ~1.45em로 늘어나 바 높이가 어긋난다. */
|
||||
line-height: 1.2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #e6e6e6;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
/* 세로 3줄: 지수명 / 가격+등락 / 묶음 점. 점을 옆이 아니라 아래 줄 가운데로 둔 배치(2026-07-31 관리자님 지시). */
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
|
||||
/* iOS Safari는 비대화형 div에 cursor:pointer가 없으면 click을 합성하지 않는다 — 탭 전환의 전제조건. 지우지 말 것. */
|
||||
cursor: pointer;
|
||||
user-select: none; -webkit-user-select: none;
|
||||
}
|
||||
.ticker-track {
|
||||
display: inline-flex;
|
||||
width: max-content;
|
||||
/* intro(5s linear): viewport 오른쪽 끝(100vw)에서 자연 위치(0)까지 일정 속도로 슬라이드 인 — 첫 로딩 시 KOSPI 잘림 방지.
|
||||
ease-out은 시작 순간이 peak 속도라 "엄청 빠름" 인상을 줘서 linear로 고정.
|
||||
scroll(20s linear infinite, 5s 지연): intro 끝난 직후 무한 가로 스크롤로 인계. translateX(0)에서 매끄럽게 연결. */
|
||||
animation: ticker-intro 5s linear, ticker-scroll 20s linear 5s infinite;
|
||||
will-change: transform;
|
||||
/* 세로 flex라 flex-grow를 주면 높이가 늘어난다 → 0 0 auto + width로 가로만 채운다.
|
||||
max-width: PC·아이패드에서 두 칸이 화면 절반씩 벌어져 텅 비어 보이는 걸 막는 상한. */
|
||||
.ticker-page { flex: 0 0 auto; width: 100%; max-width: 520px; display: flex; align-items: center; min-height: 26px; }
|
||||
/* 탭 전환 때만 페이드. 20초 주기 자동 갱신(animate=false)은 값만 조용히 교체. */
|
||||
.ticker-page.fade { animation: ticker-fade 140ms ease-out; }
|
||||
@keyframes ticker-fade {
|
||||
from { opacity: 0.35; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.ticker-set { display: inline-flex; align-items: center; gap: 20px; flex-shrink: 0; }
|
||||
.ticker-sep { display: inline-flex; align-items: center; padding: 0 22px; color: #4a5060; font-size: 14px; font-weight: 700; user-select: none; }
|
||||
@keyframes ticker-intro {
|
||||
from { transform: translateX(100vw); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
@keyframes ticker-scroll {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
/* PC 마우스 호버 시만 일시정지. 모바일 :active는 페이지 터치 후 잔류해서 ticker가 멈춰버리는 이슈로 제외. */
|
||||
@media (hover: hover) {
|
||||
.market-ticker:hover .ticker-track { animation-play-state: paused; }
|
||||
}
|
||||
.market-ticker .idx { display: inline-flex; align-items: baseline; gap: 4px; font-size: 10.5px; }
|
||||
.market-ticker .idx-label { color: #8b8f9a; font-weight: 500; font-size: 10.5px; }
|
||||
.market-ticker .idx-price { color: #f0f0f0; font-weight: 500; font-size: 10.5px; }
|
||||
.market-ticker .idx-delta { font-weight: 400; font-size: 10.5px; }
|
||||
.market-ticker .idx { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 1px; }
|
||||
/* max-width+overflow: 칸보다 긴 내용은 오른쪽만 잘린다. .idx에 걸면 가운데정렬 탓에 양끝이 깎여 앞글자까지 사라진다. */
|
||||
.market-ticker .idx-label { max-width: 100%; overflow: hidden; text-overflow: ellipsis; color: #8b8f9a; font-weight: 500; font-size: 9.5px; }
|
||||
.market-ticker .idx-val { max-width: 100%; min-width: 0; overflow: hidden; display: flex; align-items: baseline; gap: 4px; }
|
||||
/* 좁은 화면에서 칸을 넘칠 때: 가격은 절대 안 줄이고(flex:0 0 auto) 등락만 말줄임.
|
||||
둘 다 기본 shrink로 두면 flex가 양쪽을 눌러 앞글자까지 깎이고 두 칸 폭도 어긋난다(320px 실측). */
|
||||
.market-ticker .idx-price { flex: 0 0 auto; color: #f0f0f0; font-weight: 500; font-size: 10.5px; }
|
||||
.market-ticker .idx-delta { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-weight: 400; font-size: 10.5px; }
|
||||
.market-ticker .idx-delta.up { color: #ff4d5e; }
|
||||
.market-ticker .idx-delta.down { color: #4d9bff; }
|
||||
.market-ticker .idx-delta.flat { color: #8b8f9a; }
|
||||
.market-ticker .idx-empty { color: #5a5f6c; font-size: 10px; padding: 0 14px; }
|
||||
/* 묶음 점 — 3번째 줄 가운데(부모 align-items:center가 가로 정렬). 지수 칸과 가로 폭을 나눠쓰지 않으므로
|
||||
묶음 수가 줄어도 칸 폭은 영향 없다(옆에 두던 시절 필요했던 min-width 고정폭은 그래서 제거됨). */
|
||||
.ticker-dots { flex: 0 0 auto; display: flex; align-items: center; gap: 3px; }
|
||||
.ticker-dots i { width: 4px; height: 4px; border-radius: 50%; background: #333844; }
|
||||
.ticker-dots i.on { background: #c9ccd3; }
|
||||
/* min-height/line-height를 표시 상태 content 높이(26 + gap 3 + 점 4 = 33)와 맞춰 로딩↔표시 전환 때 바가 안 튀게. */
|
||||
.market-ticker .idx-empty { flex: 0 0 auto; width: 100%; text-align: center; min-height: 33px; line-height: 33px; color: #5a5f6c; font-size: 10px; }
|
||||
|
||||
/* ── pull-to-refresh ── */
|
||||
/* .page가 손가락 따라 translateY로 내려감 → topbar+컨텐츠 다 함께 내려가고 그 위로 빈공간 노출.
|
||||
@@ -5469,6 +5515,39 @@ dl.pending-detail dl.trail-mini dd b { color: #d9a84a; font-weight: 600; }
|
||||
.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; }
|
||||
/* 계단 편집기 — .order-inputs 실폭이 모바일 ~200px 라 열 하나하나가 아깝다.
|
||||
행마다 "하락/누적" 글자를 반복하는 대신 헤더 한 줄로 빼고, 화살표도 지웠다.
|
||||
그 결과 입력칸이 38px → 78px 로 넓어진다(고정폭 58 → 44, 라벨·단위 21px 회수).
|
||||
스피너를 없애는 것도 폭 확보의 일부 — 데스크톱에서 화살표가 숫자를 가리던 문제도 같이 해결. */
|
||||
.trail-steps { display: flex; flex-direction: column; gap: 3px; margin-top: 5px; min-width: 0; }
|
||||
.trail-step { display: grid; align-items: center; gap: 4px; min-width: 0;
|
||||
grid-template-columns: 12px minmax(0,1fr) minmax(0,1fr) 16px; font-size: 10px; color: #7c8290; }
|
||||
.trail-step-head { color: #5a5f6c; font-size: 9px; letter-spacing: .02em; }
|
||||
.trail-step-head span { text-align: center; }
|
||||
.trail-step .tsn { color: #6f7480; font-variant-numeric: tabular-nums; text-align: center; }
|
||||
.trail-step .tsw { display: flex; align-items: center; gap: 2px; min-width: 0; }
|
||||
.trail-step input { width: 100%; min-width: 0; padding: 4px 5px; font-size: 12px;
|
||||
background: #0f131c; border: 1px solid #262b38; border-radius: 5px; color: #cfd3dc;
|
||||
font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.trail-step input::-webkit-outer-spin-button,
|
||||
.trail-step input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.trail-step .tsu { color: #6f7480; flex: 0 0 auto; font-size: 9px; }
|
||||
.trail-step .tsx { background: none; border: 0; color: #5a5f6c; cursor: pointer; font-size: 12px;
|
||||
padding: 0; line-height: 1; }
|
||||
.trail-step .tsx:hover { color: #e05555; }
|
||||
.trail-add-btn { width: 100%; margin-top: 5px; padding: 5px 6px; font-size: 10px; cursor: pointer;
|
||||
background: #171b26; border: 1px dashed #2c3140; border-radius: 6px; color: #8b8f9a; }
|
||||
.trail-add-btn:hover { background: #1d2230; color: #cfd3dc; border-color: #3a4152; }
|
||||
/* 미리보기 계단 목록 — 한 줄에 넷을 욱여넣으면(계단·가격·주수·누적) 200px 를 넘겨 접힌다.
|
||||
1행 "N단계 −P% … 가격", 2행 "주수 · 누적" 으로 나눠 가격을 우측 정렬로 세운다. */
|
||||
.trail-pv-steps { display: flex; flex-direction: column; gap: 5px; font-size: 10px;
|
||||
border-top: 1px solid #1a1e28; padding-top: 6px; min-width: 0; }
|
||||
.trail-pv-step { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 1px 6px;
|
||||
align-items: baseline; font-variant-numeric: tabular-nums; }
|
||||
.trail-pv-step .sp { color: #7c8290; }
|
||||
.trail-pv-step .sv { color: #cfd3dc; font-weight: 600; text-align: right; }
|
||||
.trail-pv-step .sq { grid-column: 1 / -1; color: #6f7480; font-size: 9px; }
|
||||
.trail-pv-step.tied .sv { color: #d9a84a; }
|
||||
.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; }
|
||||
|
||||
@@ -5930,9 +6009,9 @@ nav.tabs a.tab-label:hover { color: #c9ccd3; }
|
||||
.tab-content { display: none; }
|
||||
/* 짧은 탭(자산정보 등)도 viewport를 채워 모바일 Safari 주소창 토글이 일관되게 동작하도록.
|
||||
ticker(fixed bottom) 위치가 다른 탭과 시각적으로 동일해진다. */
|
||||
section.tab-content { min-height: calc(100dvh - 110px); }
|
||||
section.tab-content { min-height: calc(100dvh - 126px); }
|
||||
@supports not (height: 100dvh) {
|
||||
section.tab-content { min-height: calc(100vh - 110px); }
|
||||
section.tab-content { min-height: calc(100vh - 126px); }
|
||||
}
|
||||
|
||||
/* ── 계좌현황 패널 ── */
|
||||
@@ -8080,16 +8159,14 @@ def _render_order_modal() -> str:
|
||||
'<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>'
|
||||
# 계단식(2026-07-31): 하락률이 깊어질수록 더 많이 파는 분할 매도. 프리셋으로 고르고
|
||||
# 그 자리에서 계단을 고칠 수 있다. 계단 1개짜리 프리셋이 종전의 단일 트레일링이다.
|
||||
'<span style="margin-top:8px;">매도 계단 '
|
||||
'<span class="muted small" style="font-weight:400;">고점 대비 얼마 빠지면 얼마나 팔지</span></span>'
|
||||
# 프리셋은 고정 4종 읽기 전용 — 고른 뒤 아래 표에서 그 자리에 고쳐 쓴다.
|
||||
'<select data-trail-preset></select>'
|
||||
'<div class="trail-steps" data-trail-steps></div>'
|
||||
'<button type="button" class="trail-add-btn" data-trail-add>+ 계단 추가</button>'
|
||||
'<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>'
|
||||
@@ -8418,6 +8495,9 @@ def render_html() -> str:
|
||||
first_tid = descs[0][0]
|
||||
market_active = _market_active_now()
|
||||
now = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S')
|
||||
# 계단 프리셋은 셸에 실어 보낸다 — 거래 모달을 열 때마다 fetch 하면 느리고,
|
||||
# 고정 4종이라 셸에 넣어도 부담이 없다.
|
||||
trail_presets_json = json.dumps(TRAIL_PRESETS, ensure_ascii=False)
|
||||
|
||||
nav_html = '\n'.join(
|
||||
f'<a class="tab-label" href="#{tid}">{html.escape(label)}</a>'
|
||||
@@ -8491,70 +8571,63 @@ def render_html() -> str:
|
||||
'}'
|
||||
'window.__behive_set_market=setMarket;'
|
||||
'var tickerItems=[];'
|
||||
# 현재 보고 있는 묶음 index. 자동 갱신(20초 주기)에도 유지돼 보던 묶음이 안 튄다.
|
||||
'var tickerPage=0;'
|
||||
'var TICKER_PER_PAGE=2;'
|
||||
'var arrowMap={up:"▲",down:"▼",flat:"-"};'
|
||||
'function tickerItemHtml(item){'
|
||||
'function tickerPages(){return Math.ceil(tickerItems.length/TICKER_PER_PAGE);}'
|
||||
# 2줄 칸: 윗줄 지수명 / 아랫줄 가격+등락. 1줄로는 닛케이225 같은 조합이 칸 폭을 넘는다.
|
||||
'function tickerCellHtml(item){'
|
||||
'var d=item.direction||"flat";'
|
||||
# label은 data-key로 박아 in-place 업데이트 매칭 키로 사용.
|
||||
'var key=String(item.label).replace(/"/g,""");'
|
||||
'return \'<span class="idx \'+(item.kind||"")+\'" data-key="\'+key+\'">\'+'
|
||||
'return \'<div class="idx \'+(item.kind||"")+\'">\'+'
|
||||
'\'<span class="idx-label">\'+item.label+\'</span>\'+'
|
||||
'\'<span class="idx-price">\'+item.price+\'</span>\'+'
|
||||
'\'<span class="idx-delta \'+d+\'">\'+arrowMap[d]+\' \'+item.change+\' (\'+item.pct+\'%)</span></span>\';'
|
||||
'\'<span class="idx-val"><span class="idx-price">\'+item.price+\'</span>\'+'
|
||||
'\'<span class="idx-delta \'+d+\'">\'+arrowMap[d]+\' \'+item.change+\' (\'+item.pct+\'%)</span></span></div>\';'
|
||||
'}'
|
||||
# 같은 세트를 2번 렌더 → CSS translateX(-50%)로 seamless 무한 스크롤.
|
||||
'function renderTicker(){'
|
||||
'function tickerDotsHtml(){'
|
||||
'var n=tickerPages();if(n<2)return "";'
|
||||
'var s="";'
|
||||
'for(var i=0;i<n;i++){s+=(i===tickerPage?\'<i class="on"></i>\':"<i></i>");}'
|
||||
'return \'<span class="ticker-dots">\'+s+\'</span>\';'
|
||||
'}'
|
||||
# animate=true(탭)일 때만 페이드. 자동 갱신은 값만 조용히 교체.
|
||||
# 점(.ticker-dots)은 .ticker-page 바깥 형제로 둔다 — 안에 넣으면 탭할 때 같이 깜빡인다.
|
||||
'function renderTicker(animate){'
|
||||
'var el=document.getElementById("market-ticker");if(!el)return;'
|
||||
'if(!tickerItems.length){el.innerHTML="<span class=\\"idx-empty\\">데이터 없음</span>";return;}'
|
||||
'var setHtml=tickerItems.map(tickerItemHtml).join("")+\'<span class="ticker-sep">·</span>\';'
|
||||
'el.innerHTML=\'<div class="ticker-track">\'+'
|
||||
'\'<div class="ticker-set">\'+setHtml+\'</div>\'+'
|
||||
'\'<div class="ticker-set" aria-hidden="true">\'+setHtml+\'</div>\'+'
|
||||
'\'</div>\';'
|
||||
# intro 속도 = scroll 속도가 되도록 동적 계산.
|
||||
# scroll은 20s에 트랙 절반(=한 세트 너비) 이동 → px/s = setW/20.
|
||||
# intro 거리는 viewport 너비(100vw) → duration = viewport / (setW/20) = viewport*20/setW.
|
||||
# 2~12s 캡: 너무 짧으면 pop-in, 너무 길면 사용자가 첫 등장 기다림.
|
||||
'var trk=el.querySelector(".ticker-track");'
|
||||
'if(trk){'
|
||||
'var setW=trk.scrollWidth/2,vw=window.innerWidth||document.documentElement.clientWidth||0;'
|
||||
'if(setW>0&&vw>0){'
|
||||
'var d=vw*20/setW;'
|
||||
'if(d<2)d=2;else if(d>12)d=12;'
|
||||
'trk.style.animation="ticker-intro "+d+"s linear, ticker-scroll 20s linear "+d+"s infinite";'
|
||||
'}'
|
||||
'}'
|
||||
'}'
|
||||
# 항목 키 셋이 동일하면 가격·등락만 in-place 갱신 → CSS 애니메이션 진행 상태 보존.
|
||||
# 자동 갱신 중에도 스크롤이 0초부터 리셋되지 않는다.
|
||||
'function updateTickerInPlace(arr){'
|
||||
'var el=document.getElementById("market-ticker");if(!el)return false;'
|
||||
'var nodes=el.querySelectorAll(".idx[data-key]");if(!nodes.length)return false;'
|
||||
'var byKey={};'
|
||||
'for(var i=0;i<nodes.length;i++){'
|
||||
'var k=nodes[i].getAttribute("data-key");'
|
||||
'(byKey[k]=byKey[k]||[]).push(nodes[i]);'
|
||||
'}'
|
||||
'if(Object.keys(byKey).length!==arr.length)return false;'
|
||||
'for(var j=0;j<arr.length;j++){if(!byKey[arr[j].label])return false;}'
|
||||
'arr.forEach(function(item){'
|
||||
'var d=item.direction||"flat";'
|
||||
'var deltaText=arrowMap[d]+" "+item.change+" ("+item.pct+"%)";'
|
||||
'byKey[item.label].forEach(function(n){'
|
||||
'var price=n.querySelector(".idx-price");'
|
||||
'var delta=n.querySelector(".idx-delta");'
|
||||
'if(price)price.textContent=item.price;'
|
||||
'if(delta){delta.className="idx-delta "+d;delta.textContent=deltaText;}'
|
||||
'});'
|
||||
'});'
|
||||
'return true;'
|
||||
'if(!tickerItems.length){el.innerHTML=\'<span class="idx-empty">데이터 없음</span>\';return;}'
|
||||
'var n=tickerPages();'
|
||||
# 지수 조회 실패로 개수가 줄면 clamp. 0으로 되돌리면 보던 묶음에서 튕겨나간다.
|
||||
'if(tickerPage>n-1)tickerPage=n-1;'
|
||||
'if(tickerPage<0)tickerPage=0;'
|
||||
'var start=tickerPage*TICKER_PER_PAGE;'
|
||||
'var page=tickerItems.slice(start,start+TICKER_PER_PAGE);'
|
||||
'var cells=page.map(tickerCellHtml).join("");'
|
||||
# 홀수 개(조회 하나 실패)면 마지막 묶음이 한 칸뿐 → 빈 칸을 채워 왼쪽 절반에 고정.
|
||||
'if(page.length<TICKER_PER_PAGE)cells+=\'<div class="idx" aria-hidden="true"></div>\';'
|
||||
'el.innerHTML=\'<div class="ticker-page\'+(animate?" fade":"")+\'">\'+cells+\'</div>\'+tickerDotsHtml();'
|
||||
'}'
|
||||
# 빈 배열이면 기존 표시 유지 (owner-only 응답에서 ticker_items 비어도 stale 보존).
|
||||
'function setTicker(arr){'
|
||||
'if(!arr||!arr.length){if(!tickerItems.length)renderTicker();return;}'
|
||||
'if(updateTickerInPlace(arr)){tickerItems=arr;return;}'
|
||||
'if(!arr||!arr.length){if(!tickerItems.length)renderTicker(false);return;}'
|
||||
'tickerItems=arr;'
|
||||
'renderTicker();'
|
||||
'renderTicker(false);'
|
||||
'}'
|
||||
# 바 아무 곳이나 탭 → 다음 묶음. 마지막이면 처음으로.
|
||||
# touchend가 아니라 click을 쓴다: 스크롤 드래그(터치 시작점이 바여도 touchend는 바에 꽂힘)·
|
||||
# 멀티터치 중복발화·touchcancel 처리를 브라우저에 맡길 수 있다. 직접 처리하면 셋 다 오탐.
|
||||
# 대신 nodbltap_script가 300ms 내 두번째 탭의 click을 삼키므로 거기서 이 바만 예외 처리했다.
|
||||
'(function(){'
|
||||
'var el=document.getElementById("market-ticker");if(!el)return;'
|
||||
'function nextPage(){'
|
||||
'var n=tickerPages();if(n<2)return;'
|
||||
'tickerPage=(tickerPage+1)%n;'
|
||||
'renderTicker(true);'
|
||||
'}'
|
||||
'el.addEventListener("click",nextPage);'
|
||||
'el.addEventListener("keydown",function(e){'
|
||||
'if(e.key==="Enter"||e.key===" "){e.preventDefault();nextPage();}'
|
||||
'});'
|
||||
'})();'
|
||||
'function apply(p){'
|
||||
# 서버 코드가 갱신됐는데(=build 불일치, __build 없는 옛 페이지 포함) 옛 인라인 JS/CSS로 도는 경우
|
||||
# build 당 1회만 강제 리로드(sessionStorage 가드 — 리로드로도 안 맞으면 루프 없이 포기).
|
||||
@@ -9760,12 +9833,17 @@ def render_html() -> str:
|
||||
# 더블탭 zoom 차단 — iOS Safari가 `touch-action: manipulation`을 무시할 때 폴백.
|
||||
# 300ms 안에 두 번째 touchend가 들어오면 preventDefault → 시스템 zoom 동작·동시 발생하는 ghost click 둘 다 억제.
|
||||
# 첫 탭의 click은 정상 dispatch되므로 details 토글·새로고침 버튼 등 단일 탭 UX 영향 없음.
|
||||
# 예외: 하단 지수 바(#market-ticker)는 탭할 때마다 묶음을 넘기는 UI라 빠른 연타가 정상 사용이다.
|
||||
# 여기서 걸러내면 두 번째 탭부터 click이 안 와서 한 칸씩 건너뛴다. 그 바만 억제 대상에서 뺀다
|
||||
# (더블탭 zoom은 html,body의 touch-action: manipulation이 1차로 막고, 이 바는 36px 최하단 띠라 위험이 작다).
|
||||
nodbltap_script = (
|
||||
'<script>(function(){'
|
||||
'var lastT=0;'
|
||||
'document.addEventListener("touchend",function(e){'
|
||||
'var now=Date.now();'
|
||||
'if(now-lastT<=300)e.preventDefault();'
|
||||
'var t=e.target;'
|
||||
'var onTicker=!!(t&&t.closest&&t.closest("#market-ticker"));'
|
||||
'if(now-lastT<=300&&!onTicker)e.preventDefault();'
|
||||
'lastT=now;'
|
||||
'},{passive:false});'
|
||||
'})();</script>'
|
||||
@@ -11448,62 +11526,155 @@ function refreshStopUI(){
|
||||
if(priceRow) priceRow.style.display = isTrail ? 'none' : '';
|
||||
var priceLabel = $('[data-order-price-label]');
|
||||
if(priceLabel) priceLabel.textContent = isStop ? '매도 단가' : '단가';
|
||||
if(isTrail) renderTrailPreview();
|
||||
if(isTrail){
|
||||
// 처음 트레일링을 고른 순간에만 프리셋을 채운다 — 이미 편집 중이면 덮어쓰지 않는다.
|
||||
var psel = $('[data-trail-preset]');
|
||||
if(psel && !psel.options.length){ renderTrailPresetSelect(); applyTrailPreset(); }
|
||||
else renderTrailPreview();
|
||||
}
|
||||
else refreshSubmitGate(); // 트레일링에서 빠져나오면 트레일 게이트를 풀어야 한다
|
||||
}
|
||||
// 트레일링 입력값 읽기 (트레일 폭·최저 매도가). 미리보기·propose 공용.
|
||||
// ---- 계단식 트레일링 (2026-07-31) ----
|
||||
// 계단 = {pct: 고점 대비 하락률, cum: 그때까지 팔려 있어야 할 누적 비중}.
|
||||
// 초기 고점은 항상 등록 시점 현재가 — 과거 고점 기준 옵션은 2026-07-30 제거됨.
|
||||
function trailInputs(){
|
||||
return {
|
||||
pct: parseFloat(($('[data-order-trail-input]')||{}).value || '0'),
|
||||
minPrice: parseInt(($('[data-order-trail-min]')||{}).value || '0', 10) || 0
|
||||
};
|
||||
function trailPresets(){ return window.__trailPresets || []; }
|
||||
function trailSteps(){
|
||||
// :not(.trail-step-head) — 헤더도 같은 .trail-step 이라 빼지 않으면 입력칸 없는 행을 읽는다.
|
||||
var out = [],
|
||||
rows = document.querySelectorAll('[data-trail-steps] .trail-step:not(.trail-step-head)');
|
||||
for(var i=0;i<rows.length;i++){
|
||||
out.push({
|
||||
pct: parseFloat(rows[i].querySelector('[data-ts-pct]').value || '0'),
|
||||
cum: parseFloat(rows[i].querySelector('[data-ts-cum]').value || '0')
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function trailMinPrice(){
|
||||
return parseInt(($('[data-order-trail-min]')||{}).value || '0', 10) || 0;
|
||||
}
|
||||
// 계단별 주수 — 서버 orders/trailing.allocate_step_qty(최대잔여법)와 같은 식이어야 한다.
|
||||
function allocStepQty(total, weights){
|
||||
if(!(total > 0)) return weights.map(function(){ return 0; });
|
||||
var exact = weights.map(function(w){ return total * w / 100; });
|
||||
var base = exact.map(function(x){ return Math.floor(x); });
|
||||
var sumE = 0, sumB = 0;
|
||||
exact.forEach(function(x){ sumE += x; });
|
||||
base.forEach(function(x){ sumB += x; });
|
||||
var remain = Math.round(sumE) - sumB;
|
||||
var order = exact.map(function(x,i){ return {i:i, f:x - base[i]}; })
|
||||
.sort(function(a,b){ return b.f - a.f; });
|
||||
for(var k=0; k<remain && k<order.length; k++) base[order[k].i] += 1;
|
||||
return base;
|
||||
}
|
||||
// 누적 비중 → 계단별 추가 비중 (서버 normalize_steps 와 같은 환산).
|
||||
function trailWeights(steps){
|
||||
var w = [], prev = 0;
|
||||
steps.forEach(function(s){ w.push(s.cum - prev); prev = s.cum; });
|
||||
return w;
|
||||
}
|
||||
// 손절선 계산 — 서버 `orders/trailing.compute_levels` 와 **같은 식**이어야 한다.
|
||||
// 실제 발주값은 서버 계산분이 승자이므로, 한쪽만 바꾸면 표시와 발주가 어긋난다.
|
||||
// Math.round(_*1e6)/1e6 은 서버의 round(_, 6) 과 짝 — 5500×(1−30/100) 이 3849.9999999999995
|
||||
// 로 나오는 부동소수점 잡음 때문에 1틱 낮게 잡히던 것을 막는다.
|
||||
function trailCondPrice(peak, pct, minPrice){
|
||||
var cond = floorTick(Math.floor(peak * (1 - pct/100)));
|
||||
var cond = floorTick(Math.floor(Math.round(peak * (1 - pct/100) * 1e6) / 1e6));
|
||||
var floorUv = minPrice > 0 ? floorTick(minPrice) : 0;
|
||||
return { cond: Math.max(cond, floorUv), floorApplied: floorUv > cond };
|
||||
}
|
||||
// 라벨은 헤더 한 줄로만 — 행마다 "하락/누적" 을 반복하면 좁은 폭에서 입력칸이 절반으로 줄어든다.
|
||||
// 계단이 0개면 헤더도 그리지 않는다(빈 표 머리만 떠 있으면 뭐가 빠진 것처럼 보인다).
|
||||
function renderTrailStepRows(steps){
|
||||
var box = $('[data-trail-steps]'); if(!box) return;
|
||||
if(!steps.length){ box.innerHTML = ''; return; }
|
||||
var head = '<div class="trail-step trail-step-head" aria-hidden="true">' +
|
||||
'<span></span><span>하락</span><span>누적</span><span></span></div>';
|
||||
box.innerHTML = head + steps.map(function(s, i){
|
||||
return '<div class="trail-step">' +
|
||||
'<span class="tsn">' + (i+1) + '</span>' +
|
||||
'<span class="tsw"><input type="number" inputmode="decimal" min="0.5" max="30" ' +
|
||||
'step="0.5" value="' + s.pct + '" data-ts-pct aria-label="' + (i+1) + '단계 하락률(%)">' +
|
||||
'<span class="tsu">%</span></span>' +
|
||||
'<span class="tsw"><input type="number" inputmode="numeric" min="1" max="100" ' +
|
||||
'step="5" value="' + s.cum + '" data-ts-cum aria-label="' + (i+1) + '단계 누적 비중(%)">' +
|
||||
'<span class="tsu">%</span></span>' +
|
||||
'<button type="button" class="tsx" data-ts-del="' + i + '" aria-label="계단 삭제">✕</button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
// 첫 항목(단일)이 기본값 — TRAIL_PRESETS 순서가 곧 표시 순서다.
|
||||
// 옵션은 이름만 — 계단 수치는 바로 아래 편집 표에 그대로 보이므로 중복이다.
|
||||
function renderTrailPresetSelect(){
|
||||
var sel = $('[data-trail-preset]'); if(!sel) return;
|
||||
sel.innerHTML = '';
|
||||
trailPresets().forEach(function(p, i){
|
||||
var opt = document.createElement('option');
|
||||
opt.value = String(i);
|
||||
opt.textContent = p.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.value = '0';
|
||||
}
|
||||
function applyTrailPreset(){
|
||||
var sel = $('[data-trail-preset]'); if(!sel) return;
|
||||
var p = trailPresets()[parseInt(sel.value, 10)];
|
||||
if(p) renderTrailStepRows(p.steps);
|
||||
renderTrailPreview();
|
||||
}
|
||||
function renderTrailPreview(){
|
||||
// 게이트 갱신은 조기 반환보다 먼저 — 폭이 비었거나 시세 로딩 중일 때도 버튼이 잠겨야 한다.
|
||||
// 게이트 갱신은 조기 반환보다 먼저 — 계단이 비었거나 시세 로딩 중일 때도 버튼이 잠겨야 한다.
|
||||
// trailBlockReason 이 DOM 을 직접 읽으므로 호출 순서에 의존하지 않는다.
|
||||
refreshSubmitGate();
|
||||
var el = $('[data-order-trail-preview]'); if(!el) return;
|
||||
var t = trailInputs();
|
||||
var steps = trailSteps();
|
||||
var minPrice = trailMinPrice();
|
||||
var cur = (state.lastCheck && state.lastCheck.cur_price) || (state.lastBook && state.lastBook.price) || 0;
|
||||
if(!(t.pct > 0) || !(cur > 0)){ el.textContent = ''; return; }
|
||||
if(!steps.length || !(cur > 0)){ el.textContent = ''; return; }
|
||||
var qty = parseInt(($('[data-order-qty]')||{}).value || '0', 10) || 0;
|
||||
var reason = trailBlockReason();
|
||||
var peak = cur; // 초기 고점 = 등록 시점 현재가 (서버 handler 와 동일)
|
||||
var r = trailCondPrice(peak, t.pct, t.minPrice);
|
||||
var bad = (r.cond >= cur);
|
||||
var qtys = allocStepQty(qty, trailWeights(steps));
|
||||
|
||||
// 결론(손절선)을 카드 상단에 크게, 근거는 라벨/값 표로, 안내는 하단 작게.
|
||||
var hero, note;
|
||||
if(bad){
|
||||
// 서버도 TRAIL_IMMEDIATE 로 거부한다 — PIN 발급 왕복 전에 여기서 먼저 알려준다.
|
||||
if(reason){
|
||||
// 서버도 같은 조건으로 거부한다 — PIN 발급 왕복 전에 여기서 먼저 알려준다.
|
||||
hero = '<div class="cap">등록할 수 없습니다</div>' +
|
||||
'<div class="val">손절선 ' + fmt(r.cond) + '원 ≥ 현재가 ' + fmt(cur) + '원</div>' +
|
||||
'<div class="sub">조건을 이미 넘어 등록 즉시 발동됩니다</div>';
|
||||
note = r.floorApplied
|
||||
? '최저 매도가를 현재가보다 낮게 내려주세요.'
|
||||
: '트레일 폭을 넓혀주세요.';
|
||||
'<div class="val">' + reason + '</div>';
|
||||
note = '위 입력을 고쳐주세요.';
|
||||
} 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 r0 = trailCondPrice(peak, steps[0].pct, minPrice);
|
||||
var pctFromCur = ((cur - r0.cond) / cur * 100);
|
||||
hero = '<div class="cap">지금 등록하면 1단계 손절선</div>' +
|
||||
'<div class="val">' + fmt(r0.cond) + '원' +
|
||||
(r0.floorApplied ? '<span class="trail-pv-badge">최저가 적용</span>' : '') + '</div>' +
|
||||
'<div class="sub">현재가보다 ' + pctFromCur.toFixed(1) + '% 아래 · 여기 닿으면 ' +
|
||||
(qtys[0] > 0 ? fmt(qtys[0]) + '주' : '') + ' 매도</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>');
|
||||
var rows = steps.map(function(s, i){
|
||||
var r = trailCondPrice(peak, s.pct, minPrice);
|
||||
var cum = (s.cum >= 100 ? '전량' : '누적 ' + s.cum + '%');
|
||||
var q = qtys[i] > 0 ? fmt(qtys[i]) + '주' : '0주';
|
||||
return '<div class="trail-pv-step' + (r.floorApplied ? ' tied' : '') + '">' +
|
||||
'<span class="sp">' + (i+1) + '단계 −' + s.pct + '%</span>' +
|
||||
'<span class="sv">' + fmt(r.cond) + '원</span>' +
|
||||
'<span class="sq">' + q + ' · ' + cum + '</span></div>';
|
||||
}).join('');
|
||||
var allotted = 0;
|
||||
qtys.forEach(function(q){ allotted += q; });
|
||||
if(qty > 0 && allotted < qty){
|
||||
// 이 행은 1행짜리 — 계단 행처럼 2행으로 쪼개면 "나머지" 아래 숫자가 떠 어색하다.
|
||||
rows += '<div class="trail-pv-step"><span class="sp">나머지 ' + fmt(qty - allotted) +
|
||||
'주</span><span class="sv">스톱 없음</span></div>';
|
||||
}
|
||||
|
||||
el.innerHTML = '<div class="trail-pv' + (bad ? ' bad' : '') + '">' +
|
||||
var kv = ['<dt>시작 고점</dt><dd><b>' + fmt(peak) + '원</b><br>등록 시점 현재가</dd>'];
|
||||
if(minPrice > 0) kv.push('<dt>최저 매도가</dt><dd>' + fmt(minPrice) + '원</dd>');
|
||||
|
||||
el.innerHTML = '<div class="trail-pv' + (reason ? ' bad' : '') + '">' +
|
||||
'<div class="trail-pv-hero">' + hero + '</div>' +
|
||||
'<div class="trail-pv-steps">' + rows + '</div>' +
|
||||
'<dl class="trail-pv-kv">' + kv.join('') + '</dl>' +
|
||||
'<div class="trail-pv-note">' + note + '</div>' +
|
||||
'</div>';
|
||||
@@ -11923,6 +12094,12 @@ function loadOpenOrders(){
|
||||
}
|
||||
// 고점에 기준 라벨을 붙이지 않는다 — 서버 렌더와 같은 이유(peak 는 갱신된 현재 고점).
|
||||
tb.push('고점 ' + fmt(r.trailing.peak) + '원 대비 −' + r.trailing.trail_pct + '%');
|
||||
// 계단 위치 — 미체결 목록엔 레그가 각각 뜨므로 "3단 중 2단계" 를 밝혀야 관계가 읽힌다.
|
||||
if((r.trailing.step_total || 0) > 1){
|
||||
var cum = r.trailing.cum || 0;
|
||||
tb.push(r.trailing.step_n + '단계/' + r.trailing.step_total + '단 (' +
|
||||
(cum >= 100 ? '전량' : '누적 ' + cum + '%') + ')');
|
||||
}
|
||||
if(r.trailing.min_sell_price) tb.push('최저 ' + fmt(r.trailing.min_sell_price) + '원');
|
||||
if(r.cur_price && r.stop_price){
|
||||
// 손절선 기준 여유폭 — 분모는 손절선(라벨과 계산 기준을 일치시킴).
|
||||
@@ -12036,15 +12213,36 @@ function updateMarketPhaseDisplay(){
|
||||
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% 범위로 입력하세요';
|
||||
// 계단 검증은 서버 orders/trailing.normalize_steps 와 같은 규칙 — 순서·범위가 어긋나면
|
||||
// 서버가 TRAIL_STEPS 로 거부하므로, PIN 왕복 전에 여기서 같은 문구로 막는다.
|
||||
var steps = trailSteps();
|
||||
if(!steps.length) return '계단을 하나 이상 추가하세요';
|
||||
if(steps.length > 5) return '계단은 최대 5개입니다';
|
||||
var prevP = 0, prevC = 0;
|
||||
for(var i=0;i<steps.length;i++){
|
||||
var s = steps[i], n = i + 1;
|
||||
if(!(s.pct >= 0.5 && s.pct <= 30)) return n + '단계 하락률은 0.5~30% 범위로 입력하세요';
|
||||
if(s.pct <= prevP) return n + '단계 하락률이 앞 계단(' + prevP + '%)보다 깊어야 합니다';
|
||||
if(!(s.cum > 0 && s.cum <= 100)) return n + '단계 누적 비중은 1~100% 범위로 입력하세요';
|
||||
if(s.cum <= prevC) return n + '단계 누적 비중이 앞 계단(' + prevC + '%)보다 커야 합니다';
|
||||
prevP = s.pct; prevC = s.cum;
|
||||
}
|
||||
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); // 초기 고점 = 현재가
|
||||
// 가장 얕은 계단이 제일 먼저 발동한다 — 이게 현재가 이상이면 등록 즉시 터진다.
|
||||
var minPrice = trailMinPrice();
|
||||
var r = trailCondPrice(cur, steps[0].pct, minPrice); // 초기 고점 = 현재가
|
||||
if(r.cond >= cur){
|
||||
return r.floorApplied
|
||||
? ('최저 매도가 ' + fmt(t.minPrice) + '원이 현재가 ' + fmt(cur) + '원보다 높아 즉시 발동됩니다')
|
||||
: ('손절선 ' + fmt(r.cond) + '원이 현재가 ' + fmt(cur) + '원 이상이라 즉시 발동됩니다');
|
||||
? ('최저 매도가 ' + fmt(minPrice) + '원이 현재가 ' + fmt(cur) + '원보다 높아 즉시 발동됩니다')
|
||||
: ('1단계 손절선 ' + fmt(r.cond) + '원이 현재가 ' + fmt(cur) + '원 이상이라 즉시 발동됩니다');
|
||||
}
|
||||
var qty = parseInt(($('[data-order-qty]')||{}).value || '0', 10) || 0;
|
||||
if(qty > 0){
|
||||
var qs = allocStepQty(qty, trailWeights(steps));
|
||||
var any = false;
|
||||
qs.forEach(function(q){ if(q > 0) any = true; });
|
||||
if(!any) return fmt(qty) + '주로는 계단을 나눌 수 없습니다 (수량을 늘리거나 계단을 줄이세요)';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -12298,15 +12496,15 @@ 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;
|
||||
var trailStepList = trailSteps();
|
||||
var trailMin = trailMinPrice();
|
||||
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)가 최후 방어선.
|
||||
// 계단 검증·즉시발동 검사는 입력 시점에 trailBlockReason 이 하고, 걸리면 매도 버튼이
|
||||
// 비활성이라 여기 도달하지 않는다. 서버(TRAIL_STEPS/TRAIL_IMMEDIATE)가 최후 방어선.
|
||||
}
|
||||
if(orderType === 'STOP_LIMIT'){
|
||||
if(state.side !== 'SELL'){ setMsg('스톱지정가는 매도만 가능합니다', 'error'); return; }
|
||||
@@ -12331,8 +12529,8 @@ function doPropose(){
|
||||
}
|
||||
// 검증 통과 — 매수/매도 방향 최종 확인 팝업 (오타 방지). 확인 시에만 실제 매매로 진행.
|
||||
state.pendingOrder = {account: account, orderType: orderType, qty: qty, price: price,
|
||||
stopPrice: stopPrice, trailPct: trailPct,
|
||||
trailMinPrice: trail.minPrice};
|
||||
stopPrice: stopPrice, trailSteps: trailStepList,
|
||||
trailMinPrice: trailMin};
|
||||
openOrderConfirm();
|
||||
}
|
||||
// 방향 확인 후 실제 매매 진행 — 전량매도 sell-choice 분기 포함.
|
||||
@@ -12352,7 +12550,7 @@ function doProposeConfirmed(){
|
||||
return;
|
||||
}
|
||||
}
|
||||
proposeSingle(account, orderType, qty, price, po.stopPrice, po.trailPct, po.trailMinPrice);
|
||||
proposeSingle(account, orderType, qty, price, po.stopPrice, po.trailSteps, po.trailMinPrice);
|
||||
}
|
||||
// ── 매수/매도 방향 확인 모달 ──
|
||||
// 매수·매도 버튼을 둘 다 띄우고 관리자님이 직접 맞는 방향을 누르게 함(위치 랜덤).
|
||||
@@ -12370,8 +12568,11 @@ function openOrderConfirm(){
|
||||
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 trailStepsPo = (po.trailSteps || []);
|
||||
var trailRes = (isTrail && trailStepsPo.length)
|
||||
? trailCondPrice(trailPeak, trailStepsPo[0].pct, po.trailMinPrice)
|
||||
: {cond: 0, floorApplied: false};
|
||||
var trailCond = trailRes.cond; // 1단계(가장 먼저 발동할 계단) 손절선
|
||||
var priceStr = (po.orderType === 'MARKET') ? '시장가' : (fmt(po.price) + '원');
|
||||
var sum = $oc('[data-order-confirm-summary]');
|
||||
if(sum){
|
||||
@@ -12388,12 +12589,19 @@ function openOrderConfirm(){
|
||||
var stopLine = isStop ? ('· 조건단가: <b>' + fmt(po.stopPrice) + '원 도달 시</b> <span class="muted small">(예약)</span><br>') : '';
|
||||
var priceLine;
|
||||
if(isTrail){
|
||||
var poQtys = allocStepQty(po.qty, trailWeights(trailStepsPo));
|
||||
var stepLines = trailStepsPo.map(function(s, i){
|
||||
var r = trailCondPrice(trailPeak, s.pct, po.trailMinPrice);
|
||||
return ' ' + (i+1) + '단계 −' + s.pct + '% <b>' + fmt(r.cond) + '원</b> → ' +
|
||||
fmt(poQtys[i]) + '주 ' +
|
||||
'<span class="muted small">(' + (s.cum >= 100 ? '전량' : '누적 ' + s.cum + '%') + ')</span><br>';
|
||||
}).join('');
|
||||
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>';
|
||||
'· 매도 계단 <b>' + trailStepsPo.length + '단</b> <span class="muted small">(현재가 ' + fmt(curNow) + '원)</span><br>' +
|
||||
stepLines +
|
||||
'· <span class="muted small">고점이 오르면 계단이 통째로 따라 올라갑니다</span><br>';
|
||||
} else {
|
||||
priceLine = '· ' + (isStop ? '매도 단가' : '단가') + ': <b>' + priceStr + '</b><br>';
|
||||
}
|
||||
@@ -12430,7 +12638,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, trailPct, trailMinPrice){
|
||||
function proposeSingle(account, orderType, qty, price, stopPrice, trailStepList, trailMinPrice){
|
||||
var body = new URLSearchParams();
|
||||
body.set('account', account);
|
||||
body.set('side', state.side);
|
||||
@@ -12440,9 +12648,12 @@ function proposeSingle(account, orderType, qty, price, stopPrice, trailPct, trai
|
||||
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));
|
||||
// 승인 팝업에서 확정된 계단을 그대로 보낸다 — 팝업이 떠 있는 사이 편집기를 건드려도
|
||||
// 관리자님이 확인한 내용과 발주가 어긋나지 않는다.
|
||||
body.set('trail_steps', JSON.stringify(trailStepList || []));
|
||||
if(trailMinPrice > 0) body.set('min_sell_price', String(trailMinPrice));
|
||||
}
|
||||
proposeAndOpenPin('/api/order/propose', body);
|
||||
@@ -12674,14 +12885,43 @@ if(otSel) otSel.addEventListener('change', function(){
|
||||
var priceInpEl = $('[data-order-price-input]');
|
||||
if(priceInpEl) priceInpEl.addEventListener('input', function(){ updateCheck(); highlightSelectedTick(); recalcQtyFromBudget(); });
|
||||
var qtyInpEl = $('[data-order-qty]');
|
||||
if(qtyInpEl) qtyInpEl.addEventListener('input', function(){ recalcBudgetFromQty(); renderOrderInfo(); });
|
||||
if(qtyInpEl) qtyInpEl.addEventListener('input', function(){
|
||||
recalcBudgetFromQty(); renderOrderInfo();
|
||||
// 계단별 주수는 수량에서 나온다 — 수량이 바뀌면 미리보기도 다시 그린다.
|
||||
var ot = $('[data-order-type]');
|
||||
if(ot && ot.value === 'TRAILING_STOP') renderTrailPreview();
|
||||
});
|
||||
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);
|
||||
// 프리셋은 select — change 로 잡는다(select 의 정식 이벤트).
|
||||
var trailPresetEl = $('[data-trail-preset]');
|
||||
if(trailPresetEl) trailPresetEl.addEventListener('change', applyTrailPreset);
|
||||
var trailMinEl = $('[data-order-trail-min]');
|
||||
if(trailMinEl) trailMinEl.addEventListener('input', renderTrailPreview);
|
||||
// 계단 입력·삭제는 행이 동적으로 다시 그려지므로 컨테이너에 위임한다.
|
||||
var trailStepsEl = $('[data-trail-steps]');
|
||||
if(trailStepsEl){
|
||||
trailStepsEl.addEventListener('input', function(e){
|
||||
if(e.target.matches('[data-ts-pct],[data-ts-cum]')) renderTrailPreview();
|
||||
});
|
||||
trailStepsEl.addEventListener('click', function(e){
|
||||
var b = e.target.closest('[data-ts-del]'); if(!b) return;
|
||||
var steps = trailSteps();
|
||||
steps.splice(parseInt(b.getAttribute('data-ts-del'), 10), 1);
|
||||
renderTrailStepRows(steps);
|
||||
renderTrailPreview();
|
||||
});
|
||||
}
|
||||
var trailAddEl = $('[data-trail-add]');
|
||||
if(trailAddEl) trailAddEl.addEventListener('click', function(){
|
||||
var steps = trailSteps();
|
||||
if(steps.length >= 5){ showToast('계단은 최대 5개입니다', 'error'); return; }
|
||||
// 새 계단은 마지막보다 5%p 깊게·누적 100% 로 — 대개 "마지막에 전량" 을 덧붙이는 흐름이다.
|
||||
var last = steps.length ? steps[steps.length-1] : null;
|
||||
steps.push({pct: Math.min(30, last ? last.pct + 5 : 10), cum: 100});
|
||||
renderTrailStepRows(steps);
|
||||
renderTrailPreview();
|
||||
});
|
||||
document.addEventListener('visibilitychange', function(){
|
||||
if(document.hidden) stopPolling();
|
||||
else if(state.isOpen) startPolling();
|
||||
@@ -13020,7 +13260,7 @@ window.openPinModal = openPinModal;
|
||||
<meta name="apple-mobile-web-app-title" content="자산현황">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>자산현황</title>
|
||||
<script>window.__build="{BUILD}";</script>
|
||||
<script>window.__build="{BUILD}";window.__trailPresets={trail_presets_json};</script>
|
||||
<style>{_CSS}{dynamic_css}</style>
|
||||
{head_script}
|
||||
</head>
|
||||
@@ -13053,7 +13293,7 @@ window.openPinModal = openPinModal;
|
||||
</div>
|
||||
{content_html}
|
||||
</div>
|
||||
<div class="market-ticker" id="market-ticker"><span class="idx-empty">지수 불러오는 중…</span></div>
|
||||
<div class="market-ticker" id="market-ticker" role="button" tabindex="0" aria-label="시장 지수 — 탭하면 다음 지수"><span class="idx-empty">지수 불러오는 중…</span></div>
|
||||
{idx_info_modal_html}
|
||||
{interest_modal_html}
|
||||
{interest_edit_modal_html}
|
||||
@@ -14296,11 +14536,11 @@ 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()
|
||||
steps_raw = (params.get('trail_steps') or [''])[0].strip()
|
||||
try:
|
||||
trail_pct = float(trail_raw) if trail_raw else None
|
||||
trail_steps = json.loads(steps_raw) if steps_raw else None
|
||||
except ValueError:
|
||||
trail_pct = None
|
||||
trail_steps = 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
|
||||
@@ -14324,12 +14564,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._send_json(400, {'ok': False, 'error': '스톱지정가는 단가와 조건단가가 모두 필요'})
|
||||
return
|
||||
if order_type == 'TRAILING_STOP':
|
||||
# 조건단가·지정가는 handler 가 현재가 기준으로 계산 — 여기선 트레일 폭만 받는다.
|
||||
# 계단별 조건단가·지정가·주수는 handler 가 현재가·보유수량 기준으로 계산 —
|
||||
# 여기선 계단 정의(하락률·누적 비중)만 받는다. 형식 검증은 handler 가
|
||||
# orders/trailing.normalize_steps 로 하므로 여기선 최소한만 본다.
|
||||
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': '트레일 폭(%)을 입력하세요'})
|
||||
if not isinstance(trail_steps, list) or not trail_steps:
|
||||
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보다 커야 합니다'})
|
||||
@@ -14350,7 +14592,7 @@ 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),
|
||||
trail_steps=(trail_steps if order_type == 'TRAILING_STOP' else None),
|
||||
min_sell_price=(min_sell_price if order_type == 'TRAILING_STOP' else None),
|
||||
)
|
||||
if res.get('ok'):
|
||||
|
||||
Reference in New Issue
Block a user