Compare commits

...

4 Commits

Author SHA1 Message Date
hyowons e95e2b3f3a feat: 매도 시 같은 그룹 2계좌 동시 매도 (카드 1장+PIN 1개, 선택 팝업)
- orders/handler.py: propose_trade_multi (레그별 독립 검증, 같은 소유자 그룹만,
  SELL+LIMIT/MARKET 한정) + submit_with_pin multi 분기 _submit_multi_legs
  (레그 독립 제출·독립 fill_watcher, 한 레그 실패해도 나머지 시도)
- orders/card.py: format_card_multi (레그별 계좌·수량·예상회수 + 합계)
- behive_web.py: POST /api/order/propose_multi (PIN은 iMessage '매도(2계좌)'),
  sell-choice-modal (두 계좌 모두 / 선택 계좌만 / 취소), doPropose SELL 분기
  (accStatus로 sibling 보유 감지 → 팝업, sibling 수량은 보유 전량)
- fix: /api/order/cancel 응답이 dict를 PendingCard로 취급해 매번 500 나던
  기존 버그 (취소 자체는 동작, 응답만 깨짐)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:05:35 +09:00
hyowons 72b98409f2 feat: 종목차트 탭 재배열 + 3개월 추가 — 1분봉/5분봉/1개월/3개월/6개월/1년
- CHART_RANGE_DAYS에 '3M': 63 추가 (stock_analysis)
- /api/chart_svg 탭·패널 순서를 분봉 우선으로 재배열, 3M 패널 신설
- 기본 활성 탭은 1개월 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:28:08 +09:00
hyowons ff3652c8b1 feat: 자산 차트 단위 경계 세로선 — 일별=주 경계, 월별=연 경계
- 일별 보기: ISO 주차가 바뀌는 첫 점에 세로 점선 (주 단위 구간 인지)
- 월별 보기: 연도가 바뀌는 첫 점에 세로 점선
- per_period 모드는 기존 period-sep과 중복이라 생략, week/year 단위 미표시
- 스타일 .grid-v — 가로 grid와 동일 톤 점선

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:03:11 +09:00
hyowons b5e3fc0f4e fix: VI 1h 매핑 실증 반영 — active=해제시각(1224) 유무로 판정
2026-06-12 사이드카 날 VI 폭주(~1,000건) 실증 결과:
- 9068은 발동/해제가 아니라 VI 종류(1=정적, 2=동적, 3=동적+정적)
  → 구 매핑은 동적 VI를 통째로 무시하고, 해제 이벤트도 active로 덮어썼음
- 발동 이벤트는 1224(해제시각)='000000', 해제 이벤트는 시각이 참
  → active = release_time in ('','000000') 으로 교체, 1223 발동시각 추가 저장
- 1h는 구독 무관 시장 전역 broadcast임을 확인 (주석 기록)
- 실증용 raw 로그 제거 (폭주 날 ~2,000줄 — 역할 완료)
- 토스트: 발동시각 표시 추가, '000000'은 해제 표시 생략
- TTL 백스톱(_vi_expired 180s)은 해제 메시지 유실 대비로 유지

라이브 검증: 002780 동적 VI 발동(09:09:08) → active=True 정상 포착

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:09:58 +09:00
5 changed files with 424 additions and 26 deletions
+210 -17
View File
@@ -3537,6 +3537,31 @@ def _render_net_worth_chart(series: list[tuple[str, int]], current_net: int | No
label = f'{gv / 1_000_000:,.{dec_m}f}M' if abs(gv) >= 1_000_000 else f'{gv / 1000:,.{dec_k}f}K' label = f'{gv / 1_000_000:,.{dec_m}f}M' if abs(gv) >= 1_000_000 else f'{gv / 1000:,.{dec_k}f}K'
grid_lines.append(f'<text class="axis" x="{L - 6}" y="{gy + 3.5:.2f}" text-anchor="end">{html.escape(label)}</text>') grid_lines.append(f'<text class="axis" x="{L - 6}" y="{gy + 3.5:.2f}" text-anchor="end">{html.escape(label)}</text>')
# 단위 경계 세로선 — 일별=주 경계(ISO 주가 바뀌는 첫 점), 월별=연 경계(연도가 바뀌는 첫 점).
# 그 외 단위는 미표시. per_period 모드는 period-sep(점마다 구분선)이 이미 있어 중복이라 생략.
boundary_lines: list[str] = []
if not per_period and selected_unit in ('day', 'month'):
from datetime import date as _bd_date
def _bkey(ds: str):
if selected_unit == 'day':
try:
iso = _bd_date.fromisoformat(ds).isocalendar()
return (iso[0], iso[1]) # (ISO연, 주차)
except Exception:
return None
return ds[:4] # month → 연도
_prev_bk = _bkey(full[0][0])
for _bi in range(1, n):
_bk = _bkey(full[_bi][0])
if _bk is not None and _prev_bk is not None and _bk != _prev_bk:
_bx = x_at(_bi)
boundary_lines.append(
f'<line class="grid-v" x1="{_bx:.2f}" x2="{_bx:.2f}" y1="{T:.2f}" y2="{T + ih:.2f}" />'
)
if _bk is not None:
_prev_bk = _bk
# 0원 기준선 — 손익 0 높이가 차트 범위 안일 때만 (어디부터 적자/흑자인지 표시). 순자산은 항상 양수라 자동 미표시. # 0원 기준선 — 손익 0 높이가 차트 범위 안일 때만 (어디부터 적자/흑자인지 표시). 순자산은 항상 양수라 자동 미표시.
# 기준선(가로선)은 유지하고 '0' 텍스트 라벨만 제거 (관리자님 요청 — M/K 라벨과 겹쳐 보기 번잡). # 기준선(가로선)은 유지하고 '0' 텍스트 라벨만 제거 (관리자님 요청 — M/K 라벨과 겹쳐 보기 번잡).
zero_line = '' zero_line = ''
@@ -3704,6 +3729,7 @@ def _render_net_worth_chart(series: list[tuple[str, int]], current_net: int | No
'</linearGradient>' '</linearGradient>'
'</defs>', '</defs>',
''.join(grid_lines), ''.join(grid_lines),
''.join(boundary_lines),
zero_line, zero_line,
''.join(period_seps), ''.join(period_seps),
] ]
@@ -4689,6 +4715,7 @@ section.tab-content { min-height: calc(100dvh - 110px); }
} }
.net-chart-svg .grid { stroke: #353b4a; stroke-width: 1; stroke-dasharray: 3 3; } .net-chart-svg .grid { stroke: #353b4a; stroke-width: 1; stroke-dasharray: 3 3; }
.net-chart-svg .period-sep { stroke: #4a5266; stroke-width: 1; stroke-dasharray: 2 4; opacity: 0.85; } .net-chart-svg .period-sep { stroke: #4a5266; stroke-width: 1; stroke-dasharray: 2 4; opacity: 0.85; }
.net-chart-svg .grid-v { stroke: #353b4a; stroke-width: 1; stroke-dasharray: 3 3; opacity: 0.8; }
.net-chart-svg .zero-line { stroke: #8b93a7; stroke-width: 1.2; stroke-dasharray: 5 3; opacity: 0.9; } .net-chart-svg .zero-line { stroke: #8b93a7; stroke-width: 1.2; stroke-dasharray: 5 3; opacity: 0.9; }
.net-chart-svg .pt-dot { stroke: #0b0d12; stroke-width: 1.2; } .net-chart-svg .pt-dot { stroke: #0b0d12; stroke-width: 1.2; }
.net-chart-svg .axis { fill: #6b7280; font-size: 14px; font-variant-numeric: tabular-nums; } .net-chart-svg .axis { fill: #6b7280; font-size: 14px; font-variant-numeric: tabular-nums; }
@@ -6263,6 +6290,29 @@ def _render_pin_modal() -> str:
) )
def _render_sell_choice_modal() -> str:
"""매도 시 같은 소유자 그룹의 다른 계좌에도 같은 종목 보유 중일 때 띄우는 선택 팝업.
[ 계좌 모두 매도] / [선택 계좌만 매도] / [취소] 3버튼. order-modal 위에 겹침(modal-top).
"""
return (
'<div id="sell-choice-modal" class="modal hidden modal-top" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="sell-choice-modal-title">'
'<div class="modal-overlay" data-modal-close="1"></div>'
'<div class="modal-box pin-box" role="document">'
'<div class="modal-head">'
'<div class="modal-title" id="sell-choice-modal-title">📌 두 계좌 모두 보유 중</div>'
'</div>'
'<div class="order-modal-msg info" data-sell-choice-summary>—</div>'
'<div class="order-actions" style="flex-direction:column;gap:8px;">'
'<button type="button" class="btn-confirm" data-sell-choice-both style="width:100%;">두 계좌 모두 매도</button>'
'<button type="button" class="btn-add" data-sell-choice-single style="width:100%;">선택 계좌만 매도</button>'
'<button type="button" class="btn-cancel" data-modal-close="1" style="width:100%;">취소</button>'
'</div>'
'</div>'
'</div>'
)
def _render_interests_modal() -> str: def _render_interests_modal() -> str:
"""shell HTML 직속에 두는 종목 추가 모달. panels API의 swap 영역(section.tab-content) 밖이라 """shell HTML 직속에 두는 종목 추가 모달. panels API의 swap 영역(section.tab-content) 밖이라
자동 갱신 중에도 DOM·입력값이 보존된다.""" 자동 갱신 중에도 DOM·입력값이 보존된다."""
@@ -7649,6 +7699,7 @@ def render_html() -> str:
info_desc_modal_html = _render_info_desc_modal() info_desc_modal_html = _render_info_desc_modal()
order_modal_html = _render_order_modal() order_modal_html = _render_order_modal()
pin_modal_html = _render_pin_modal() pin_modal_html = _render_pin_modal()
sell_choice_modal_html = _render_sell_choice_modal()
open_orders_modal_html = _render_open_orders_modal() open_orders_modal_html = _render_open_orders_modal()
stock_name_modal_html = _render_stock_name_modal() stock_name_modal_html = _render_stock_name_modal()
@@ -8458,6 +8509,7 @@ def render_html() -> str:
order_modal_script = r'''<script>(function(){ order_modal_script = r'''<script>(function(){
var modal = document.getElementById('order-modal'); var modal = document.getElementById('order-modal');
var pinModal = document.getElementById('pin-modal'); var pinModal = document.getElementById('pin-modal');
var sellChoiceModal = document.getElementById('sell-choice-modal');
var openOrdersModal = document.getElementById('open-orders-modal'); var openOrdersModal = document.getElementById('open-orders-modal');
if(!modal) return; if(!modal) return;
var ACCOUNTS = [ var ACCOUNTS = [
@@ -8466,7 +8518,7 @@ var ACCOUNTS = [
{label: '가희_일반', display: '가희 일반', owner: '가희'}, {label: '가희_일반', display: '가희 일반', owner: '가희'},
{label: '가희_ISA', display: '가희 ISA', owner: '가희'} {label: '가희_ISA', display: '가희 ISA', owner: '가희'}
]; ];
var state = { code:'', name:'', side:'BUY', pollTimer:null, countdownTimer:null, expiryAt:0, isOpen:false, lastBook:null, bookCentered:false, lastCheck:null, marketActive:true, marketPhase:null, symbolsCache:null, pendingMaxOnSell:false, pendingMaxOnBuy:false, accStatus:null }; var state = { code:'', name:'', side:'BUY', pollTimer:null, countdownTimer:null, expiryAt:0, isOpen:false, lastBook:null, bookCentered:false, lastCheck:null, marketActive:true, marketPhase:null, symbolsCache:null, pendingMaxOnSell:false, pendingMaxOnBuy:false, accStatus:null, sellChoice:null };
function $(sel, root){ return (root||modal).querySelector(sel); } function $(sel, root){ return (root||modal).querySelector(sel); }
function $$(sel, root){ return (root||modal).querySelectorAll(sel); } function $$(sel, root){ return (root||modal).querySelectorAll(sel); }
function $p(sel){ return pinModal ? pinModal.querySelector(sel) : null; } function $p(sel){ return pinModal ? pinModal.querySelector(sel) : null; }
@@ -9101,6 +9153,25 @@ function doPropose(){
} }
return; return;
} }
// 매도인데 같은 소유자 그룹의 다른 계좌에도 같은 종목 보유 선택 팝업 ( 계좌 모두 / 선택 계좌만 / 취소)
if(state.side === 'SELL'){
var sib = siblingAccount(account);
var sibInfo = (sib && state.accStatus && state.accStatus.byLabel) ? state.accStatus.byLabel[sib] : null;
var sibQty = sibInfo ? (sibInfo.trde_able_qty || 0) : 0;
if(sibQty > 0){
openSellChoice({account: account, qty: qty, sibling: sib, siblingQty: sibQty, orderType: orderType, price: price});
return;
}
}
proposeSingle(account, orderType, qty, price);
}
function siblingAccount(label){
var me = ACCOUNTS.find(function(a){ return a.label === label; });
if(!me) return null;
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){
var body = new URLSearchParams(); var body = new URLSearchParams();
body.set('account', account); body.set('account', account);
body.set('side', state.side); body.set('side', state.side);
@@ -9109,13 +9180,27 @@ function doPropose(){
body.set('qty', String(qty)); body.set('qty', String(qty));
body.set('order_type', orderType); body.set('order_type', orderType);
if(orderType === 'LIMIT') body.set('price', String(price)); if(orderType === 'LIMIT') body.set('price', String(price));
proposeAndOpenPin('/api/order/propose', body);
}
function doProposeMulti(opts){
var body = new URLSearchParams();
body.set('side', 'SELL');
body.set('symbol', state.code);
body.set('symbol_name', state.name);
body.set('accounts', opts.account + ',' + opts.sibling);
body.set('qtys', String(opts.qty) + ',' + String(opts.siblingQty));
body.set('order_type', opts.orderType);
if(opts.orderType === 'LIMIT') body.set('price', String(opts.price));
proposeAndOpenPin('/api/order/propose_multi', body);
}
function proposeAndOpenPin(url, body){
setMsg('주문 검증 중…', 'info'); setMsg('주문 검증 중…', 'info');
var btn = $('[data-order-submit]'); if(btn) btn.disabled = true; var btn = $('[data-order-submit]'); if(btn) btn.disabled = true;
// 이전 활성 카드 자동 정리 propose "이전 카드가 아직 활성" 거부 방지 // 이전 활성 카드 자동 정리 propose "이전 카드가 아직 활성" 거부 방지
fetch('/api/order/cancel', {method:'POST', headers:{'Accept':'application/json'}, body:''}) fetch('/api/order/cancel', {method:'POST', headers:{'Accept':'application/json'}, body:''})
.catch(function(){}) .catch(function(){})
.then(function(){ .then(function(){
return fetch('/api/order/propose', { return fetch(url, {
method: 'POST', method: 'POST',
headers: {'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'}, headers: {'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},
body: body.toString() body: body.toString()
@@ -9137,6 +9222,33 @@ function doPropose(){
setMsg('네트워크 오류: ' + e, 'error'); setMsg('네트워크 오류: ' + e, 'error');
}); });
} }
// 매도 계좌 선택 모달 ( 계좌 모두 / 선택 계좌만 / 취소)
function $sc(sel){ return sellChoiceModal ? sellChoiceModal.querySelector(sel) : null; }
function openSellChoice(opts){
if(!sellChoiceModal){ proposeSingle(opts.account, opts.orderType, opts.qty, opts.price); return; }
state.sellChoice = opts;
var disp = {}; ACCOUNTS.forEach(function(a){ disp[a.label] = a.display; });
var priceStr = (opts.orderType === 'MARKET') ? '시장가' : (fmt(opts.price) + '');
var sum = $sc('[data-sell-choice-summary]');
if(sum){
sum.innerHTML = '<b>' + (state.name || state.code) + '</b> 종목을 <b>' + (disp[opts.account]||opts.account) +
'</b> · <b>' + (disp[opts.sibling]||opts.sibling) + '</b> 두 계좌 모두 보유하고 있습니다.<br><br>' +
'· ' + (disp[opts.account]||opts.account) + ': <b>' + fmt(opts.qty) + '주</b> 매도 (입력 수량)<br>' +
'· ' + (disp[opts.sibling]||opts.sibling) + ': <b>' + fmt(opts.siblingQty) + '주</b> 매도 (보유 전량)<br>' +
'· 단가: ' + priceStr + ' (두 계좌 동일)';
}
var sb = $sc('[data-sell-choice-single]');
if(sb) sb.textContent = (disp[opts.account]||opts.account) + '만 매도';
sellChoiceModal.classList.remove('hidden');
sellChoiceModal.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
}
function closeSellChoice(){
if(!sellChoiceModal) return;
sellChoiceModal.classList.add('hidden');
sellChoiceModal.setAttribute('aria-hidden', 'true');
if(modal.classList.contains('hidden') && (!pinModal || pinModal.classList.contains('hidden'))) document.body.classList.remove('modal-open');
}
function doVerify(){ function doVerify(){
var pinInp = $p('[data-pin-input]'); var pinInp = $p('[data-pin-input]');
var pin = (pinInp && pinInp.value || '').trim(); var pin = (pinInp && pinInp.value || '').trim();
@@ -9217,6 +9329,22 @@ if(pinModal) pinModal.addEventListener('click', function(e){
if(t.classList && t.classList.contains('modal-close')){ closePinModal(); return; } if(t.classList && t.classList.contains('modal-close')){ closePinModal(); return; }
if(t.matches && t.matches('[data-pin-verify]')){ doVerify(); return; } if(t.matches && t.matches('[data-pin-verify]')){ doVerify(); return; }
}); });
// 매도 계좌 선택 모달 click 핸들러
if(sellChoiceModal) sellChoiceModal.addEventListener('click', function(e){
var t = e.target;
if(t.getAttribute && t.getAttribute('data-modal-close')==='1'){ closeSellChoice(); return; }
var opts = state.sellChoice;
if(t.matches && t.matches('[data-sell-choice-both]')){
closeSellChoice();
if(opts) doProposeMulti(opts);
return;
}
if(t.matches && t.matches('[data-sell-choice-single]')){
closeSellChoice();
if(opts) proposeSingle(opts.account, opts.orderType, opts.qty, opts.price);
return;
}
});
var accSel = $('[data-order-account]'); if(accSel) accSel.addEventListener('change', updateCheck); var accSel = $('[data-order-account]'); if(accSel) accSel.addEventListener('change', updateCheck);
var symSel = $('[data-order-symbol-select]'); if(symSel) symSel.addEventListener('change', onSymbolChange); var symSel = $('[data-order-symbol-select]'); if(symSel) symSel.addEventListener('change', onSymbolChange);
var otSel = $('[data-order-type]'); var otSel = $('[data-order-type]');
@@ -9464,7 +9592,8 @@ window.openPinModal = openPinModal;
if(v.trigger_price) L.push('발동가: '+Number(v.trigger_price).toLocaleString()+''); if(v.trigger_price) L.push('발동가: '+Number(v.trigger_price).toLocaleString()+'');
if(v.trigger_pct!=null&&v.trigger_pct!==0) L.push('발동 등락률: '+(v.trigger_pct>=0?'+':'')+v.trigger_pct+'%'); if(v.trigger_pct!=null&&v.trigger_pct!==0) L.push('발동 등락률: '+(v.trigger_pct>=0?'+':'')+v.trigger_pct+'%');
if(v.count) L.push('당일 발동: '+v.count+''); if(v.count) L.push('당일 발동: '+v.count+'');
if(v.release_time) L.push('해제 예정: '+_fmtViTime(v.release_time)); if(v.trigger_time&&v.trigger_time!=='000000') L.push('발동시각: '+_fmtViTime(v.trigger_time));
if(v.release_time&&v.release_time!=='000000') L.push('해제: '+_fmtViTime(v.release_time));
head=L.join('\n')+'\n\n─────\n'; head=L.join('\n')+'\n\n─────\n';
} }
var desc='⚡VI (변동성완화장치)\n\n주가가 짧은 시간에 급변하면 약 2분간\n단일가매매로 전환해 과열을 식히는 장치예요.\n\n• 동적VI: 직전 체결가 대비\n• 정적VI: 당일 기준가 대비\n(코스피200 3%·그 외 6% 등에서 발동)'; var desc='⚡VI (변동성완화장치)\n\n주가가 짧은 시간에 급변하면 약 2분간\n단일가매매로 전환해 과열을 식히는 장치예요.\n\n• 동적VI: 직전 체결가 대비\n• 정적VI: 당일 기준가 대비\n(코스피200 3%·그 외 6% 등에서 발동)';
@@ -9544,6 +9673,7 @@ window.openPinModal = openPinModal;
{info_desc_modal_html} {info_desc_modal_html}
{order_modal_html} {order_modal_html}
{pin_modal_html} {pin_modal_html}
{sell_choice_modal_html}
{open_orders_modal_html} {open_orders_modal_html}
{stock_name_modal_html} {stock_name_modal_html}
{ptr_script} {ptr_script}
@@ -10272,10 +10402,11 @@ class Handler(BaseHTTPRequestHandler):
'sma20': sa.sma(closes, 20), 'sma20': sa.sma(closes, 20),
'sma60': sa.sma(closes, 60), 'sma60': sa.sma(closes, 60),
} }
# 분석 페이지와 동일 패턴 — 1Y/6M/1M 3 개 panel + 탭. 250 봉 캐시 슬라이스만 다름. # 분석 페이지와 동일 패턴 — 1M/3M/6M/1Y 4 개 panel + 탭. 250 봉 캐시 슬라이스만 다름.
# 분봉 panel 은 빈 placeholder. 탭 클릭 시 클라이언트가 lazy fetch. # 분봉 panel 은 빈 placeholder. 탭 클릭 시 클라이언트가 lazy fetch.
svg_1y = sa.render_svg_chart(snap, range_key='1Y') svg_1y = sa.render_svg_chart(snap, range_key='1Y')
svg_6m = sa.render_svg_chart(snap, range_key='6M') svg_6m = sa.render_svg_chart(snap, range_key='6M')
svg_3m = sa.render_svg_chart(snap, range_key='3M')
svg_1m = sa.render_svg_chart(snap, range_key='1M') svg_1m = sa.render_svg_chart(snap, range_key='1M')
# 분봉 panel 안에 기간 sub-tabs (30분/1h/3h/5h/하루). default = '1h'. # 분봉 panel 안에 기간 sub-tabs (30분/1h/3h/5h/하루). default = '1h'.
# subpanel 은 클라이언트가 sub-toggle 이벤트로 lazy fetch. # subpanel 은 클라이언트가 sub-toggle 이벤트로 lazy fetch.
@@ -10294,17 +10425,19 @@ class Handler(BaseHTTPRequestHandler):
) )
html_block = ( html_block = (
'<div class="chart-tabs">' '<div class="chart-tabs">'
'<button type="button" data-range="1Y">1년</button>'
'<button type="button" data-range="6M">6개월</button>'
'<button type="button" data-range="1M" class="active">1개월</button>'
'<button type="button" data-range="5m" data-unit="5m">5분봉</button>'
'<button type="button" data-range="1m" data-unit="1m">1분봉</button>' '<button type="button" data-range="1m" data-unit="1m">1분봉</button>'
'<button type="button" data-range="5m" data-unit="5m">5분봉</button>'
'<button type="button" data-range="1M" class="active">1개월</button>'
'<button type="button" data-range="3M">3개월</button>'
'<button type="button" data-range="6M">6개월</button>'
'<button type="button" data-range="1Y">1년</button>'
'</div>' '</div>'
f'<div class="chart-panel" data-range="1Y">{svg_1y}</div>'
f'<div class="chart-panel" data-range="6M">{svg_6m}</div>'
f'<div class="chart-panel active" data-range="1M">{svg_1m}</div>'
+ _minute_panel('5m')
+ _minute_panel('1m') + _minute_panel('1m')
+ _minute_panel('5m')
+ f'<div class="chart-panel active" data-range="1M">{svg_1m}</div>'
f'<div class="chart-panel" data-range="3M">{svg_3m}</div>'
f'<div class="chart-panel" data-range="6M">{svg_6m}</div>'
f'<div class="chart-panel" data-range="1Y">{svg_1y}</div>'
) )
body = html_block.encode('utf-8') body = html_block.encode('utf-8')
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding')) body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
@@ -10564,6 +10697,69 @@ class Handler(BaseHTTPRequestHandler):
self._send_json(status, res) self._send_json(status, res)
return return
if self.path == '/api/order/propose_multi':
# 거래 모달 매도 — 같은 소유자 그룹 2계좌 동시 매도. 카드 1장 + PIN 1개가 두 레그 승인.
# accounts/qtys 는 콤마 구분 동수 리스트 (예: accounts=일반,ISA & qtys=5,12).
side = (params.get('side') or ['SELL'])[0].strip().upper()
symbol = ''.join(ch for ch in (params.get('symbol') or [''])[0].strip() if ch.isalnum())
symbol_name = (params.get('symbol_name') or [''])[0].strip()
order_type = (params.get('order_type') or ['LIMIT'])[0].strip().upper()
accounts = [a.strip() for a in (params.get('accounts') or [''])[0].split(',') if a.strip()]
try:
qtys = [int(q.strip()) for q in (params.get('qtys') or [''])[0].split(',') if q.strip()]
except ValueError:
qtys = []
price_raw = (params.get('price') or [''])[0].strip()
try:
price = int(price_raw) if price_raw else None
except ValueError:
price = None
if side != 'SELL' or not symbol or len(accounts) < 2 \
or len(accounts) != len(qtys) or any(q <= 0 for q in qtys):
self._send_json(400, {'ok': False, 'error': 'side=SELL + accounts/qtys(2개 이상, 동수) 필요'})
return
if order_type not in ('LIMIT', 'MARKET'):
self._send_json(400, {'ok': False, 'error': 'order_type must be LIMIT|MARKET'})
return
if order_type == 'LIMIT' and (price is None or price <= 0):
self._send_json(400, {'ok': False, 'error': 'LIMIT requires positive price'})
return
try:
if not symbol_name:
import kiwoom_client as kc
try:
meta = kc.resolve_stock_code(symbol)
symbol_name = meta.get('name') or symbol
except Exception:
symbol_name = symbol
from orders import handler
# 단일 propose 와 동일 정책 — 거부는 web 토스트만, PIN 은 iMessage.
res = handler.propose_trade_multi(
legs=[{'account': a, 'qty': q} for a, q in zip(accounts, qtys)],
side='SELL', symbol=symbol, symbol_name=symbol_name,
order_type=order_type,
price=(price if order_type == 'LIMIT' else None),
)
if res.get('ok'):
try:
from orders.pin import PinStore as _PinStore
_pending = _PinStore().peek()
if _pending and not _pending.consumed:
handler.send_imessage_pin(
pin=_pending.pin,
card_id=_pending.card_id,
side_label='매도(2계좌)',
symbol_name=symbol_name,
)
except Exception:
traceback.print_exc()
except Exception as e:
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': f'propose_multi failed: {e}'})
return
self._send_json(200 if res.get('ok') else 400, res)
return
if self.path == '/api/order/verify': if self.path == '/api/order/verify':
# 거래 모달 2단계: PIN 검증 → 키움 실주문 (dry_run=False). # 거래 모달 2단계: PIN 검증 → 키움 실주문 (dry_run=False).
pin = (params.get('pin') or [''])[0].strip() pin = (params.get('pin') or [''])[0].strip()
@@ -10614,12 +10810,9 @@ class Handler(BaseHTTPRequestHandler):
# 거래 모달 닫기 시 활성 카드 정리 (자동). 텔레그램 알림 X — 웹 자동 cancel은 사용자가 명시 액션 아님. # 거래 모달 닫기 시 활성 카드 정리 (자동). 텔레그램 알림 X — 웹 자동 cancel은 사용자가 명시 액션 아님.
try: try:
from orders import handler from orders import handler
# cancel_active_card 반환은 dict {'ok','message'} — 활성 카드 없어도 베스트에포트라 200.
res = handler.cancel_active_card() res = handler.cancel_active_card()
# cancel_active_card 반환은 PendingCard | None. dict로 변환. self._send_json(200, {'ok': True, 'message': res.get('message') or '카드 취소됨'})
if res is None:
self._send_json(200, {'ok': True, 'message': '활성 카드 없음'})
return
self._send_json(200, {'ok': True, 'message': '카드 취소됨', 'card_id': res.card_id})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
self._send_json(500, {'ok': False, 'error': f'cancel failed: {e}'}) self._send_json(500, {'ok': False, 'error': f'cancel failed: {e}'})
@@ -153,6 +153,47 @@ def format_card(request: dict, market_data: dict, card_id: str,
return '\n'.join(lines) return '\n'.join(lines)
def format_card_multi(legs: list, side: str, symbol: str, symbol_name: str,
order_type: str, price: Optional[int], card_id: str,
state_warning: Optional[str] = None) -> str:
"""복수 계좌 동시 주문 카드 — 레그별 계좌·수량·예상회수 나열.
legs: [{'account': str, 'qty': int, 'position_qty': int?}, ...]
"""
cfg = _limits()['card']
side_word = '매수' if side == 'BUY' else '매도'
side_emoji = cfg['buy_emoji'] if side == 'BUY' else cfg['sell_emoji']
marker = cfg['highlight_marker']
spouse_marker = ' 🔐 가희 계좌' if any(_is_spouse(l['account']) for l in legs) else ''
type_marker = f' {cfg["warning_emoji"]} 시장가' if order_type == 'MARKET' else ''
lines = [f'{side_emoji} {_md_bold(side_word + " 미리보기")} [#{card_id}]{type_marker}{spouse_marker}'
f' · 계좌 {len(legs)}개 동시', '']
lines.append(f'{marker} {_md_bold(side_word)}')
lines.append(f'{marker} 종목: {_md_bold(symbol_name)} ({symbol})')
if state_warning:
lines.append(state_warning)
if order_type == 'LIMIT':
lines.append(f'{marker} 가격: {_md_bold(_money(price))}')
else:
lines.append(f'{marker} 가격: {_md_bold("시장가")}')
lines.append('')
total = 0
for l in legs:
part = f'{marker} {_md_bold(_account_display(l["account"]))}: {l["qty"]:,}'
if l.get('position_qty'):
part += f' (보유 {l["position_qty"]:,}주)'
if order_type == 'LIMIT' and price:
part += f'{_money(price * l["qty"])}'
total += price * l['qty']
lines.append(part)
if order_type == 'LIMIT' and price:
money_label = '예상 금액 합계' if side == 'BUY' else '예상 회수 합계'
lines.append(f'{money_label}: {_money(total)}')
lines.append(f'{_limits()["pin"]["expiry_seconds"]}초 후 만료')
return '\n'.join(lines)
def format_pin_message(pin: str) -> str: def format_pin_message(pin: str) -> str:
return pin return pin
@@ -280,6 +280,117 @@ def propose_trade(
} }
def propose_trade_multi(
legs: list,
side: str,
symbol: str,
symbol_name: str,
order_type: str,
price: Optional[int] = None,
) -> dict:
"""복수 계좌 동시 주문 — 같은 종목·방향·가격을 여러 계좌에서 카드 1장 + PIN 1개로.
legs: [{'account': str, 'qty': int}, ...] (2개 이상)
웹 거래 모달 '두 계좌 모두 매도' 진입점. SELL + LIMIT/MARKET 만 지원.
레그별 검증은 propose_trade 와 동일 경로(collect_market_data + validate_request),
하나라도 거부면 전체 거부. PIN 형식 일관성을 위해 같은 소유자 그룹 계좌만 허용.
"""
sidecar.guard_or_raise()
_sweep_expired_and_notify()
if side != 'SELL':
return {'ok': False,
'message': card.format_rejected('INVALID_SIDE', '복수 계좌 동시 주문은 매도만 지원')}
if order_type not in ('LIMIT', 'MARKET'):
return {'ok': False,
'message': card.format_rejected('INVALID_ORDER_TYPE', f'잘못된 주문방식: {order_type}')}
if not legs or len(legs) < 2:
return {'ok': False,
'message': card.format_rejected('INVALID_LEGS', '계좌 2개 이상 필요')}
accounts = [str(l.get('account') or '') for l in legs]
if len(set(accounts)) != len(accounts):
return {'ok': False,
'message': card.format_rejected('INVALID_LEGS', '계좌 중복')}
limits = _limits()
owners, spouses = set(limits['owner_accounts']), set(limits['spouse_accounts'])
if not (all(a in owners for a in accounts) or all(a in spouses for a in accounts)):
return {'ok': False,
'message': card.format_rejected('INVALID_LEGS', '같은 소유자 그룹 계좌만 동시 주문 가능')}
validated = []
state_warning = None
suffix = None
for leg in legs:
account = leg['account']
try:
qty = int(leg.get('qty') or 0)
except (TypeError, ValueError):
qty = 0
if qty <= 0:
return {'ok': False,
'message': card.format_rejected('MISSING_QTY', f'[{account}] 수량 필요')}
md = datasource.collect_market_data(account, symbol, side, qty)
request = {'account': account, 'side': side, 'symbol': symbol,
'symbol_name': symbol_name, 'qty': qty, 'order_type': order_type}
if order_type == 'LIMIT':
request['price'] = price
r = guards.validate_request(request, md)
if not r.ok:
ledger.append('rejected', {'account': account, 'side': side, 'symbol': symbol,
'qty': qty, 'price': price, 'reason': r.code,
'message': r.message, 'multi': True})
return {'ok': False, 'message': card.format_rejected(r.code, f'[{account}] {r.message}')}
state_eval = guards.evaluate_stock_state(md.get('stock_meta'))
if not state_eval['result'].ok:
sr = state_eval['result']
ledger.append('rejected', {'account': account, 'side': side, 'symbol': symbol,
'qty': qty, 'price': price, 'reason': sr.code,
'message': sr.message, 'multi': True,
'stock_state': state_eval['state'],
'order_warning': state_eval['order_warning']})
return {'ok': False, 'message': card.format_rejected(sr.code, sr.message)}
if state_eval['warning']:
state_warning = state_eval['warning']
if suffix is None:
suffix = guards.determine_routing(md['now'], md['nxt_eligible'], None)
validated.append({'account': account, 'qty': qty,
'position_qty': md.get('position_qty', 0)})
payload = {
'multi': True,
'side': side, 'symbol': symbol, 'symbol_name': symbol_name,
'order_type': order_type, 'price': price, 'routing_suffix': suffix,
'legs': [{'account': v['account'], 'qty': v['qty']} for v in validated],
# 단일 payload 필드(account/qty)를 읽는 경로(format_card_locked 등) 호환용 요약
'account': '+'.join(accounts),
'qty': sum(v['qty'] for v in validated),
}
try:
pending = _pin_store.issue(accounts[0], payload)
except RuntimeError:
active = _pin_store.peek()
info = _active_card_info(active) if active else None
return {'ok': False, 'message': card.format_card_locked(info)}
card_msg = card.format_card_multi(validated, side, symbol, symbol_name, order_type,
price, pending.card_id, state_warning)
pin_msg = card.format_pin_message(pending.pin)
ledger.append('card_issued', {'card_id': pending.card_id, **payload})
ledger.append('pin_issued', {'card_id': pending.card_id, 'account': accounts[0],
'pin_length': len(pending.pin)})
return {
'ok': True,
'card_id': pending.card_id,
'card_message': card_msg,
'pin_message': pin_msg,
'expiry_seconds': pending.expiry_seconds,
'is_spouse': pending.account_label in spouses,
}
def propose_and_send(account, side, symbol, symbol_name, qty, order_type, def propose_and_send(account, side, symbol, symbol_name, qty, order_type,
price=None, routing_force=None, budget=None) -> dict: price=None, routing_force=None, budget=None) -> dict:
"""propose_trade 후 카드+PIN 을 텔레그램에 분리 발송. skill / launchd 진입점용.""" """propose_trade 후 카드+PIN 을 텔레그램에 분리 발송. skill / launchd 진입점용."""
@@ -325,6 +436,9 @@ def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
p = card_obj.payload p = card_obj.payload
ledger.append('approved', {'card_id': card_obj.card_id, **p}) ledger.append('approved', {'card_id': card_obj.card_id, **p})
if p.get('multi'):
return _submit_multi_legs(card_obj, dry_run)
submit_order_type = 'LIMIT' if p['order_type'] == 'AGGRESSIVE_LIMIT' else p['order_type'] submit_order_type = 'LIMIT' if p['order_type'] == 'AGGRESSIVE_LIMIT' else p['order_type']
res = kiwoom_order.submit( res = kiwoom_order.submit(
account_label=p['account'], account_label=p['account'],
@@ -361,6 +475,53 @@ def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
'detail': res} 'detail': res}
def _submit_multi_legs(card_obj, dry_run: bool) -> dict:
"""multi 카드(propose_trade_multi)의 레그별 키움 제출 — 레그 독립 실행·독립 보고.
한 레그가 실패해도 나머지 레그는 계속 시도. ok = 한 레그라도 접수 성공.
접수된 레그는 각각 fill_watcher 추적 (큐 기반이라 동시 다건 OK).
"""
p = card_obj.payload
submit_order_type = 'LIMIT' if p['order_type'] == 'AGGRESSIVE_LIMIT' else p['order_type']
msgs = []
details = []
any_ok = False
for leg in p.get('legs') or []:
res = kiwoom_order.submit(
account_label=leg['account'],
side=p['side'],
symbol=p['symbol'],
qty=leg['qty'],
price=p.get('price'),
order_type=submit_order_type,
routing_suffix=p['routing_suffix'],
dry_run=dry_run,
card_id=card_obj.card_id,
)
details.append(res)
acc_disp = card.ACCOUNT_DISPLAY.get(leg['account'], leg['account'])
if res['ok']:
any_ok = True
if dry_run:
msgs.append(f'[{acc_disp}] ' + card.format_dryrun(res.get('payload', {})))
continue
ord_no = res.get('ord_no', '')
msgs.append(f'[{acc_disp}] ' + card.format_submitted(
card_obj.card_id, p['side'], p.get('symbol_name', p['symbol']),
leg['qty'], p.get('price'), ord_no))
if ord_no:
fill_watcher.watch(
ord_no=ord_no, account=leg['account'], side=p['side'],
symbol=p['symbol'], symbol_name=p.get('symbol_name', p['symbol']),
order_qty=leg['qty'], price=p.get('price'),
order_type=p['order_type'], card_id=card_obj.card_id,
)
else:
msgs.append(f'[{acc_disp}] ' + card.format_rejected(
res.get('reason', 'UNKNOWN'), str(res.get('error', ''))))
return {'ok': any_ok, 'message': '\n'.join(msgs), 'detail': details}
def cancel_active_card() -> dict: def cancel_active_card() -> dict:
sidecar.guard_or_raise() sidecar.guard_or_raise()
card_obj = _pin_store.cancel() card_obj = _pin_store.cancel()
+10 -7
View File
@@ -349,19 +349,22 @@ class RealtimeHub:
'ts': datetime.now(KST).isoformat(), 'ts': datetime.now(KST).isoformat(),
} }
elif typ == '1h': # VI 발동/해제 elif typ == '1h': # VI 발동/해제
# active 판정: 9068 VI발동구분. 해제시각(1224) 유무와 함께 실증 확인 예정(A단계). # 매핑 실증 완료 (2026-06-12 사이드카 날 VI 폭주 ~1,000건 관찰):
# 실증 로그 — VI는 드물어 부담 없음. 2026-06-11 관찰: 발동 후 해제 1h가 안 오고 # - 9068 = VI 종류 (1=정적, 2=동적, 3=동적+정적) — 발동/해제 구분 아님
# 1224(해제시각)가 발동시각과 동일 → 읽기 시점 TTL 만료(_vi_expired)로 방어 중. # - 1223 = 발동시각, 1224 = 해제시각. 발동 이벤트는 1224='000000', 해제 이벤트는 시각이 참
print(f'[1h raw] {code} {vals}', file=sys.stderr, flush=True) # - 1h는 구독 무관 시장 전역 broadcast (모든 종목 VI가 들어옴)
# 해제 메시지 유실 대비 백스톱으로 읽기 시점 TTL(_vi_expired)은 유지.
kind_raw = (vals.get('9068') or '').strip() kind_raw = (vals.get('9068') or '').strip()
release_t = (vals.get('1224') or '').strip()
with self._lock: with self._lock:
self._vi[code] = { self._vi[code] = {
'active': kind_raw not in ('', '0', '2'), # ⚠️ 매핑 실증 확인 필요 'active': release_t in ('', '000000'), # 해제시각 없음 = 발동 중
'vi_gubun': kind_raw, 'vi_gubun': kind_raw, # VI 종류 (1정적/2동적/3동적+정적)
'apply_kind': (vals.get('1225') or '').strip(), # 정적/동적/동적+정적 'apply_kind': (vals.get('1225') or '').strip(), # 정적/동적/동적+정적
'trigger_price': _abs_int(vals.get('1221')), 'trigger_price': _abs_int(vals.get('1221')),
'trigger_pct': kc._to_float(vals.get('1489')), 'trigger_pct': kc._to_float(vals.get('1489')),
'release_time': (vals.get('1224') or '').strip(), 'trigger_time': (vals.get('1223') or '').strip(),
'release_time': release_t,
'direction': (vals.get('9069') or '').strip(), 'direction': (vals.get('9069') or '').strip(),
'count': kc._to_int(vals.get('1490')), # 당일 VI 발동 횟수 'count': kc._to_int(vals.get('1490')), # 당일 VI 발동 횟수
'ts': datetime.now(KST).isoformat(), 'ts': datetime.now(KST).isoformat(),
@@ -543,13 +543,13 @@ VOL_TOP = PRICE_BOT + PANEL_GAP
VOL_BOT = VOL_TOP + VOL_PANEL_H VOL_BOT = VOL_TOP + VOL_PANEL_H
CHART_RANGE_DAYS = {'1Y': 250, '6M': 120, '1M': 21} CHART_RANGE_DAYS = {'1Y': 250, '6M': 120, '3M': 63, '1M': 21}
def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None = None, intraday: bool = False) -> str: def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None = None, intraday: bool = False) -> str:
"""캔들 + SMA 3선 + 거래량 막대 인라인 SVG. """캔들 + SMA 3선 + 거래량 막대 인라인 SVG.
range_key: '1Y' (250) | '6M' (120) | '1M' (21). 같은 snapshot 데이터에서 슬라이스만. range_key: '1Y' (250) | '6M' (120) | '3M' (63) | '1M' (21). 같은 snapshot 데이터에서 슬라이스만.
ref_price: 기준가(전일종가 ) 지정 노란 점선 기준선 + 마지막 종가의 대비 등락률 표시 (분봉용). ref_price: 기준가(전일종가 ) 지정 노란 점선 기준선 + 마지막 종가의 대비 등락률 표시 (분봉용).
snap: collect_snapshot 결과. candles_asc·sma5/20/60 사용. snap: collect_snapshot 결과. candles_asc·sma5/20/60 사용.
외부 JS 의존성 0. CSS는 inline. 외부 JS 의존성 0. CSS는 inline.