auto: 일일 백업 2026-08-26 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -808,6 +808,26 @@ def _market_phase_state() -> dict:
|
||||
return {'phase': 'closed', 'label': '장외 시간', 'active': False, 'time': time_str}
|
||||
|
||||
|
||||
# phase 경계 시각(분) — _market_phase_state 의 분기 경계와 같은 값.
|
||||
_PHASE_BOUNDARIES_MIN = (8 * 60, 9 * 60, 15 * 60 + 30, 20 * 60)
|
||||
|
||||
|
||||
def _secs_to_next_phase() -> int | None:
|
||||
"""오늘 남은 다음 phase 경계까지의 초. 주말·휴장일·20:00 이후엔 None
|
||||
(브라우저는 기본 60초 주기로 폴링).
|
||||
|
||||
⚠️ now.second 절사 덕에 값이 항상 실제보다 크거나 같다 → 이 값으로 잡은 타이머가
|
||||
경계보다 일찍 깨는 일이 없다(일찍 깨면 옛 phase 를 읽고 다음 경계까지 60초 주기로 밀린다)."""
|
||||
now = datetime.now(KST)
|
||||
if now.weekday() >= 5 or now.strftime('%Y-%m-%d') in _load_holidays():
|
||||
return None
|
||||
cur = now.hour * 3600 + now.minute * 60 + now.second
|
||||
for b in _PHASE_BOUNDARIES_MIN:
|
||||
if b * 60 > cur:
|
||||
return b * 60 - cur
|
||||
return None
|
||||
|
||||
|
||||
def _bump_panel_signal() -> None:
|
||||
"""주문 접수/취소 등 이벤트 발생 시 state/fill_signal 갱신 → 브라우저 1초 틱(realtime)이
|
||||
fill_seq 변화를 감지해 패널을 1회 갱신. 3초 주기 전면 swap 폐지(2026-07-22, 이벤트 기반 전환)
|
||||
@@ -3954,6 +3974,45 @@ _TRAILING_LOOKUP_TTL = 5.0
|
||||
_trailing_lookup_cache: dict = {'ts': 0.0, 'by_ord': {}}
|
||||
|
||||
|
||||
def _arm_rows() -> list:
|
||||
"""대기 예약 → 미체결 목록에 섞을 합성 행. 상태파일 읽기라 키움 콜 0.
|
||||
|
||||
JS 렌더러가 읽는 키를 그대로 채우고 `kind='arm'` 으로 표시한다. 취소 경로를 물리적으로
|
||||
분리하려고 `ord_no` 는 비워 둔다 — 미체결 취소(kt10003)가 이 행을 삼키면 안 된다.
|
||||
현재가는 실시간 허브에서만 읽는다(없으면 0) — 팝업 열 때 키움 조회가 나가면 안 된다.
|
||||
"""
|
||||
out = []
|
||||
try:
|
||||
from orders import trailing as _trl
|
||||
arms = _trl.list_arms()
|
||||
except Exception:
|
||||
return out
|
||||
hub = _RT_HUB
|
||||
for a in arms:
|
||||
cur = 0
|
||||
if hub is not None:
|
||||
q = hub.get_quote(a['symbol']) or {}
|
||||
cur = q.get('price') or 0
|
||||
out.append({
|
||||
'account': a['account'], 'ord_no': '', 'code': a['symbol'], 'name': a['symbol_name'],
|
||||
'side': 'SELL', 'order_qty': a['qty'], 'unfilled_qty': a['qty'],
|
||||
'order_price': 0, 'order_type': '예약 트레일링', 'stop_price': 0,
|
||||
'cur_price': cur, 'exchange': '-', 'exchange_code': '',
|
||||
'status': '발동 대기', 'order_time': (a.get('created_at') or '')[5:16].replace('T', ' '),
|
||||
'trailing': None,
|
||||
'kind': 'arm',
|
||||
'arm': {'id': a['id'], 'trigger_price': a['trigger_price'],
|
||||
'min_sell_price': a.get('min_sell_price'),
|
||||
'steps': a.get('steps') or [], 'firing_at': a.get('firing_at'),
|
||||
'fired_legs': a.get('fired_legs') or [],
|
||||
'created_at': a.get('created_at'),
|
||||
# 감시 루프는 08:00~19:59 에만 돌아 20:00 직후엔 아직 파일에 남아 있다.
|
||||
# 숨기지 않고 '만료' 로 표시한다 — 화면이 실제 상태와 달라선 안 된다.
|
||||
'expired': _trl.is_arm_expired(a)},
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _trailing_info_for(ord_no: str):
|
||||
"""미체결 주문번호 → 트레일링 예약 요약 (없으면 None).
|
||||
|
||||
@@ -8495,6 +8554,7 @@ def _render_order_modal() -> str:
|
||||
'<option value="MARKET">시장가</option>'
|
||||
'<option value="STOP_LIMIT">스톱지정가 (하락 시 매도)</option>'
|
||||
'<option value="TRAILING_STOP">트레일링 스톱 (고점 따라 손절선 상승)</option>'
|
||||
'<option value="TRAILING_ARM">예약 트레일링 (발동가 도달 시 시작)</option>'
|
||||
'</select></label>'
|
||||
'<label data-order-stop-row style="display:none;">'
|
||||
'<span>조건단가 <span class="muted small" style="font-weight:400;">(이 가격 도달 시 매도)</span></span>'
|
||||
@@ -8504,6 +8564,14 @@ def _render_order_modal() -> str:
|
||||
# 미리보기는 클라이언트에서 같은 식으로 계산해 보여주되 실제 값은 서버 계산분이 승자.
|
||||
# 고점 기준 select 제거(2026-07-30) — 과거 고점(52주·매수후) 기준은 손절선이 현재가
|
||||
# 위로 올라가 등록 즉시 발동하는 구조라 쓸 수 없었다. 초기 고점은 등록 시점 현재가 고정.
|
||||
# 예약 트레일링: 발동가 하나만 더 받는다. 계단·최저 매도가 입력은 트레일링과 공유하고,
|
||||
# 미리보기의 기준(peak)만 현재가 → 발동가로 바뀐다. 트레일 행 **위**에 두는 이유는
|
||||
# 읽는 순서가 "언제 시작 → 어떻게 판다" 여야 해서다.
|
||||
'<label data-order-arm-row style="display:none;">'
|
||||
'<span>발동가 <b style="color:#d9a84a;">(원)</b> '
|
||||
'<span class="muted small" style="font-weight:400;">이 가격까지 오르면 트레일링 시작</span></span>'
|
||||
'<input type="number" inputmode="numeric" min="0" step="1" data-order-arm-price>'
|
||||
'</label>'
|
||||
'<label data-order-trail-row style="display:none;">'
|
||||
# 단위(%·원)를 라벨에 박는다 — 입력칸 옆에 붙이면 가로로 벌어져 스크롤이 생기고,
|
||||
# number input 안쪽 absolute 접미사는 데스크톱 스피너와 겹친다.
|
||||
@@ -8842,6 +8910,8 @@ def render_html() -> str:
|
||||
valid_tids = [tid for tid, _ in descs]
|
||||
first_tid = descs[0][0]
|
||||
market_active = _market_active_now()
|
||||
market_phase = _market_phase_state()['phase']
|
||||
market_next_in = _secs_to_next_phase()
|
||||
now = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S')
|
||||
# 계단 프리셋은 셸에 실어 보낸다 — 거래 모달을 열 때마다 fetch 하면 느리고,
|
||||
# 고정 4종이라 셸에 넣어도 부담이 없다.
|
||||
@@ -10127,32 +10197,42 @@ def render_html() -> str:
|
||||
# 여기 스크립트는 상단 상태 점(🟢장중/⚪장마감)만 유지 + 개장/마감 전환 시 1회 재렌더
|
||||
# (day-change 게이트·시간대 표시 반영). 정상 상태에선 주기적 full swap 없음.
|
||||
market_active_js = 'true' if market_active else 'false'
|
||||
market_next_in_js = 'null' if market_next_in is None else str(market_next_in)
|
||||
auto_reload_script = (
|
||||
'<script>(function(){'
|
||||
f'var MARKET={market_active_js};'
|
||||
'var SLOW_MS=60000,poll=null,cur=MARKET;'
|
||||
f'var MARKET={market_active_js},PHASE="{market_phase}",NEXT_IN={market_next_in_js};'
|
||||
'var SLOW_MS=60000,poll=null,cur=MARKET,curPhase=PHASE;'
|
||||
'var HSEQ_MS=20000,hpoll=null,hseq=null;' # 외부 매매 감지 폴링(장중만)
|
||||
'var badge=document.getElementById("auto-badge");'
|
||||
# 배지 점: 장중 초록(.live) / 장마감 어두운 회색. 공유 플래그(apply·폴링이 함께 갱신) 우선, 없으면 cur.
|
||||
'function dot(){var m=(typeof window.__behive_market_active!=="undefined")?!!window.__behive_market_active:cur;if(badge)badge.classList.toggle("live",m);}'
|
||||
'function stopPoll(){if(poll){clearInterval(poll);poll=null;}}'
|
||||
# 60초마다 /api/market_state(키움 미경유·시각+휴장일만) 확인 → 배지 갱신 + 개장/마감 전환 시에만 1회 재렌더.
|
||||
'function startPoll(){'
|
||||
'stopPoll();'
|
||||
'poll=setInterval(function(){'
|
||||
'if(document.visibilityState!=="visible")return;'
|
||||
'fetch("/api/market_state",{cache:"no-store",credentials:"same-origin"})'
|
||||
'.then(function(r){return r.json();})'
|
||||
'.then(function(s){'
|
||||
'var act=!!(s&&s.active);'
|
||||
'if(act===cur)return;'
|
||||
'cur=act;'
|
||||
'function stopPoll(){if(poll){clearTimeout(poll);poll=null;}}'
|
||||
'function schedule(ms){stopPoll();poll=setTimeout(tick,ms);}'
|
||||
# 다음 phase 경계(09:00·15:30·08:00·20:00)가 기본 주기보다 가까우면 그 직후(+2s)로 당겨 잡는다.
|
||||
# setInterval 고정 60초로는 경계와 최대 60초 어긋나 초록점·OHLC 강조가 늦게 바뀐다.
|
||||
'function nextWait(n){'
|
||||
'if(n===null||n===undefined||n*1000>SLOW_MS)return SLOW_MS;'
|
||||
'return Math.max(1000,(n+2)*1000);'
|
||||
'}'
|
||||
# /api/market_state(키움 미경유·시각+휴장일만) 확인 → 배지 갱신 + phase 전환 시 1회 재렌더.
|
||||
'function tick(){'
|
||||
'if(document.visibilityState!=="visible"){schedule(SLOW_MS);return;}'
|
||||
'fetch("/api/market_state",{cache:"no-store",credentials:"same-origin"})'
|
||||
'.then(function(r){return r.json();})'
|
||||
'.then(function(s){'
|
||||
'var act=!!(s&&s.active),ph=(s&&s.phase)||curPhase,changed=false;'
|
||||
'if(act!==cur){'
|
||||
'cur=act;changed=true;'
|
||||
# set_market 이 공유 플래그 갱신 + arm(=dot) 호출. 없을 때만 직접 dot.
|
||||
'if(window.__behive_set_market)window.__behive_set_market(act);else dot();'
|
||||
# 전환(장 시작/마감) 시에만 1회 전체 재렌더 — day-change 게이트·시간대 라벨 반영.
|
||||
'if(window.__behive_load)window.__behive_load();'
|
||||
'}).catch(function(){});'
|
||||
'},SLOW_MS);'
|
||||
'}'
|
||||
# regular↔nxt 는 active 가 둘 다 true 라 phase 로만 감지된다(09:00·15:30 전환).
|
||||
'if(ph!==curPhase){curPhase=ph;changed=true;}'
|
||||
# 전환 시 1회 전체 재렌더 — 거래가능 초록점·OHLC 활성 컬럼·day-change 게이트 반영.
|
||||
# fresh 필수: 패널 캐시(10s)가 전환 직전 HTML을 물고 있고, 주기적 재렌더가 없어 stale 이 굳는다.
|
||||
'if(changed&&window.__behive_load)window.__behive_load(null,true);'
|
||||
'schedule(nextWait(s&&s.next_in));'
|
||||
'}).catch(function(){schedule(SLOW_MS);});'
|
||||
'}'
|
||||
# 외부 매매(키움 앱 등) 감지 — 장중 20s 간격 /api/holdings_seq 폴링. 보유 시그니처가 바뀌면
|
||||
# (앱 직거래·타 기기 체결 등 fill_signal 안 오는 변화 포함) 패널 1회 fresh 갱신. 첫 관측은 기준만.
|
||||
@@ -10177,9 +10257,10 @@ def render_html() -> str:
|
||||
# __behive_auto_arm — 수동 새로고침·PTR 완료 후 호출됨. 이제 배지만 재확인(주기 재무장 없음).
|
||||
'function arm(){dot();}'
|
||||
'window.__behive_auto_arm=arm;'
|
||||
'document.addEventListener("visibilitychange",dot);'
|
||||
# 숨겨진 동안엔 폴링을 건너뛰므로, 돌아오는 즉시 한 번 확인한다(폰에서 15:30을 백그라운드로 넘긴 경우).
|
||||
'document.addEventListener("visibilitychange",function(){dot();if(document.visibilityState==="visible")schedule(300);});'
|
||||
'if(window.__behive_set_market)window.__behive_set_market(MARKET);'
|
||||
'dot();startPoll();startHPoll();'
|
||||
'dot();schedule(nextWait(NEXT_IN));startHPoll();'
|
||||
'})();</script>'
|
||||
)
|
||||
|
||||
@@ -11923,11 +12004,18 @@ function refreshStopUI(){
|
||||
if(stopOpt) stopOpt.disabled = (state.side === 'BUY');
|
||||
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 armOpt = otSel.querySelector('option[value="TRAILING_ARM"]');
|
||||
if(armOpt) armOpt.disabled = (state.side === 'BUY');
|
||||
if(state.side === 'BUY' && (otSel.value === 'STOP_LIMIT' || otSel.value === 'TRAILING_STOP'
|
||||
|| otSel.value === 'TRAILING_ARM')) otSel.value = 'LIMIT';
|
||||
var isStop = (otSel.value === 'STOP_LIMIT');
|
||||
var isTrail = (otSel.value === 'TRAILING_STOP');
|
||||
var isArm = (otSel.value === 'TRAILING_ARM');
|
||||
// 계단·최저 매도가·미리보기 UI 는 트레일링과 예약 트레일링이 공유한다 (기준 peak 만 다름).
|
||||
var isTrail = (otSel.value === 'TRAILING_STOP') || isArm;
|
||||
var stopRow = $('[data-order-stop-row]');
|
||||
if(stopRow) stopRow.style.display = isStop ? '' : 'none';
|
||||
var armRow = $('[data-order-arm-row]');
|
||||
if(armRow) armRow.style.display = isArm ? '' : 'none';
|
||||
var trailRow = $('[data-order-trail-row]');
|
||||
if(trailRow) trailRow.style.display = isTrail ? '' : 'none';
|
||||
var priceRow = $('[data-order-price-row]');
|
||||
@@ -11961,6 +12049,20 @@ function trailSteps(){
|
||||
function trailMinPrice(){
|
||||
return parseInt(($('[data-order-trail-min]')||{}).value || '0', 10) || 0;
|
||||
}
|
||||
function isArmMode(){
|
||||
var otSel = $('[data-order-type]');
|
||||
return !!(otSel && otSel.value === 'TRAILING_ARM');
|
||||
}
|
||||
function armPrice(){
|
||||
return parseInt(($('[data-order-arm-price]')||{}).value || '0', 10) || 0;
|
||||
}
|
||||
// 계단 계산의 기준가. 예약 트레일링은 발동가, 일반 트레일링은 현재가.
|
||||
// 서버 handler.propose_trade 의 basis 와 같은 규칙이어야 한다.
|
||||
function trailBasePeak(){
|
||||
if(isArmMode()) return armPrice();
|
||||
return (state.lastCheck && state.lastCheck.cur_price)
|
||||
|| (state.lastBook && state.lastBook.price) || 0;
|
||||
}
|
||||
// 계단별 주수 — 서버 orders/trailing.allocate_step_qty(최대잔여법)와 같은 식이어야 한다.
|
||||
function allocStepQty(total, weights){
|
||||
if(!(total > 0)) return weights.map(function(){ return 0; });
|
||||
@@ -12037,10 +12139,12 @@ function renderTrailPreview(){
|
||||
var steps = trailSteps();
|
||||
var minPrice = trailMinPrice();
|
||||
var cur = (state.lastCheck && state.lastCheck.cur_price) || (state.lastBook && state.lastBook.price) || 0;
|
||||
if(!steps.length || !(cur > 0)){ el.textContent = ''; return; }
|
||||
var isArm = isArmMode();
|
||||
// 계단 기준가 = 예약이면 발동가, 아니면 현재가 (서버 handler 의 basis 와 동일).
|
||||
var peak = trailBasePeak();
|
||||
if(!steps.length || !(cur > 0) || !(peak > 0)){ el.textContent = ''; return; }
|
||||
var qty = parseInt(($('[data-order-qty]')||{}).value || '0', 10) || 0;
|
||||
var reason = trailBlockReason();
|
||||
var peak = cur; // 초기 고점 = 등록 시점 현재가 (서버 handler 와 동일)
|
||||
var qtys = allocStepQty(qty, trailWeights(steps));
|
||||
|
||||
var hero, note;
|
||||
@@ -12051,13 +12155,30 @@ function renderTrailPreview(){
|
||||
note = '위 입력을 고쳐주세요.';
|
||||
} else {
|
||||
var r0 = trailCondPrice(peak, steps[0].pct, minPrice);
|
||||
var pctFromCur = ((cur - r0.cond) / cur * 100);
|
||||
hero = '<div class="cap">지금 등록하면 1단계 손절선</div>' +
|
||||
var pctFromPeak = ((peak - r0.cond) / peak * 100);
|
||||
hero = '<div class="cap">' +
|
||||
(isArm ? fmt(peak) + '원에 닿으면 1단계 손절선' : '지금 등록하면 1단계 손절선') +
|
||||
'</div>' +
|
||||
'<div class="val">' + fmt(r0.cond) + '원' +
|
||||
(r0.floorApplied ? '<span class="trail-pv-badge">최저가 적용</span>' : '') + '</div>' +
|
||||
'<div class="sub">현재가보다 ' + pctFromCur.toFixed(1) + '% 아래 · 여기 닿으면 ' +
|
||||
'<div class="sub">' + (isArm ? '발동가' : '현재가') + '보다 ' +
|
||||
pctFromPeak.toFixed(1) + '% 아래 · 여기 닿으면 ' +
|
||||
(qtys[0] > 0 ? fmt(qtys[0]) + '주' : '') + ' 매도</div>';
|
||||
note = '고점이 오르면 계단이 <b>통째로 따라 올라갑니다</b>. 내려가지는 않습니다.';
|
||||
if(isArm){
|
||||
// 이 기능의 유일한 위험을 화면에서 고지하는 자리다 — 빼지 말 것.
|
||||
note = '<b>지금은 주문이 나가지 않습니다.</b> 발동가에 닿는 순간 위 계단이 ' +
|
||||
'자동으로 나갑니다 (PIN 재확인 없음).<br>' +
|
||||
'<b>오늘 장 마감까지만 유효</b> — 미발동이면 자동 취소됩니다.<br>' +
|
||||
'실제 손절선은 발동 시점 현재가 기준이라 위 값 이상입니다. ' + note;
|
||||
// 당일 유효로 바뀐 뒤(2026-08-25)로 상한가 초과 발동가는 '오늘 도달 불가'가 확정이다.
|
||||
// 막지는 않는다(관리자님 지시) — 무의미하다는 사실만 알린다.
|
||||
var upLim = (state.lastBook && state.lastBook.upper_limit) || 0;
|
||||
if(upLim > 0 && peak > upLim){
|
||||
note = '<b style="color:#e06c62;">⚠️ 오늘 상한가 ' + fmt(upLim) + '원을 넘는 발동가입니다 — ' +
|
||||
'오늘은 도달할 수 없어 장 마감에 그대로 만료됩니다.</b><br>' + note;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var rows = steps.map(function(s, i){
|
||||
@@ -12077,7 +12198,14 @@ function renderTrailPreview(){
|
||||
'주</span><span class="sv">스톱 없음</span></div>';
|
||||
}
|
||||
|
||||
var kv = ['<dt>시작 고점</dt><dd><b>' + fmt(peak) + '원</b><br>등록 시점 현재가</dd>'];
|
||||
var kv;
|
||||
if(isArm){
|
||||
kv = ['<dt>발동가</dt><dd><b>' + fmt(peak) + '원</b><br>도달 시 시작</dd>',
|
||||
'<dt>현재가</dt><dd>' + fmt(cur) + '원<br>발동까지 +' +
|
||||
((peak - cur) / cur * 100).toFixed(1) + '%</dd>'];
|
||||
} else {
|
||||
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' : '') + '">' +
|
||||
@@ -12139,7 +12267,7 @@ function renderBook(book){
|
||||
state.lastBook = book;
|
||||
// 트레일링 미리보기는 현재가·52주 전고점을 이 응답에서 읽는다 — 호가 갱신마다 다시 그린다.
|
||||
var otNow = $('[data-order-type]');
|
||||
if(otNow && otNow.value === 'TRAILING_STOP') renderTrailPreview();
|
||||
if(otNow && (otNow.value === 'TRAILING_STOP' || otNow.value === 'TRAILING_ARM')) 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]');
|
||||
@@ -12463,6 +12591,49 @@ function closeOpenOrdersModal(){
|
||||
if(modal.classList.contains('hidden') && (!pinModal || pinModal.classList.contains('hidden'))) document.body.classList.remove('modal-open');
|
||||
refreshActiveCount();
|
||||
}
|
||||
// 대기 예약 행 — 키움 주문이 아니라서 취소 버튼도 다른 경로를 쓴다(data-cancel-arm).
|
||||
function armRowHtml(r){
|
||||
var a = r.arm || {};
|
||||
var cur = r.cur_price || 0;
|
||||
var gap = (cur > 0)
|
||||
? (' · 현재가 ' + fmt(cur) + '원 (+' + ((a.trigger_price - cur) / cur * 100).toFixed(1) + '% 필요)')
|
||||
: '';
|
||||
var steps = (a.steps || []).map(function(s){
|
||||
return '−' + s.pct + '% ' + (s.cum >= 100 ? '전량' : '누적 ' + s.cum + '%');
|
||||
}).join(' · ');
|
||||
if(a.min_sell_price) steps += ' · 최저 ' + fmt(a.min_sell_price) + '원';
|
||||
var warn = '';
|
||||
if(a.firing_at){
|
||||
var nos = (a.fired_legs || []).map(function(s){ return s.ord_no; })
|
||||
.filter(function(n){ return !!n; }).join(', ') || '없음';
|
||||
warn = '<div class="row-line2" style="color:#e06c62;">⚠️ 발동 처리 중단됨 — 접수된 주문: ' +
|
||||
nos + ' · 키움 미체결 확인 필요</div>';
|
||||
}
|
||||
var safeArm = String(a.id || '').replace(/"/g,'"');
|
||||
return (
|
||||
'<div class="open-order-row" data-arm-id="' + safeArm + '">' +
|
||||
'<div class="row-info">' +
|
||||
'<div class="row-line1">' +
|
||||
'<span class="badge-side sell">예약 대기</span>' +
|
||||
'<span>' + (r.name || r.code) + '</span>' +
|
||||
'<span style="color:#8b8f9a; font-weight:400; font-size:11px;">(' + r.code + ')</span>' +
|
||||
'</div>' +
|
||||
'<div class="row-line2">' +
|
||||
'[' + r.account + '] ' + fmt(r.order_qty) + '주 · 발동가 ' +
|
||||
fmt(a.trigger_price) + '원 도달 시 시작' + gap +
|
||||
'</div>' +
|
||||
'<div class="row-line2" style="color:#d9a84a; overflow-wrap:anywhere;">' + steps + '</div>' +
|
||||
warn +
|
||||
'<div class="row-line2">등록 ' + (r.order_time || '') + ' · ' +
|
||||
(a.expired ? '<b style="color:#e06c62;">만료 — 정리 대기</b>' : '오늘 장 마감까지 유효') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="row-action">' +
|
||||
'<button type="button" data-cancel-arm>취소</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
function loadOpenOrders(){
|
||||
var list = openOrdersModal.querySelector('[data-open-orders-list]');
|
||||
if(!list) return;
|
||||
@@ -12473,7 +12644,10 @@ function loadOpenOrders(){
|
||||
list.innerHTML = '<div class="empty">미체결 주문 없음</div>';
|
||||
return;
|
||||
}
|
||||
// 대기 예약을 맨 위로 — 아직 주문이 아니니 미체결과 섞이면 성격이 흐려진다.
|
||||
rows.sort(function(a, b){ return (b.kind === 'arm') - (a.kind === 'arm'); });
|
||||
list.innerHTML = rows.map(function(r){
|
||||
if(r.kind === 'arm') return armRowHtml(r);
|
||||
var sideCls = r.side === 'BUY' ? 'buy' : 'sell';
|
||||
var sideLabel = r.side === 'BUY' ? '매수' : '매도';
|
||||
// 주문유형을 항상 붙인다 — 가격만 쓰면 스톱지정가·트레일링이 지정가처럼 보인다.
|
||||
@@ -12578,6 +12752,31 @@ function cancelOpenOrder(ordNo, account, btn){
|
||||
if(btn){ btn.disabled = false; btn.textContent = '취소'; }
|
||||
});
|
||||
}
|
||||
// 대기 예약 취소 — 키움 주문이 아니므로 kt10003 이 아니라 상태파일에서 지운다.
|
||||
function cancelArm(armId, btn){
|
||||
if(!confirm('예약 ' + armId + ' 을 취소하시겠습니까?')) return;
|
||||
if(btn){ btn.disabled = true; btn.textContent = '취소중…'; }
|
||||
var body = new URLSearchParams();
|
||||
body.set('arm_id', armId);
|
||||
fetch('/api/orders/arm_cancel', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},
|
||||
body: body.toString()
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
if(d.ok){
|
||||
showToast(d.message || '✅ 예약 취소됨', 'success', 5000);
|
||||
loadOpenOrders();
|
||||
refreshActiveCount();
|
||||
} else {
|
||||
showToast(d.message || d.error || '취소 실패', 'error');
|
||||
if(btn){ btn.disabled = false; btn.textContent = '취소'; }
|
||||
}
|
||||
}).catch(function(e){
|
||||
showToast('취소 오류: ' + e, 'error');
|
||||
if(btn){ btn.disabled = false; btn.textContent = '취소'; }
|
||||
});
|
||||
}
|
||||
function fetchMarketPhase(){
|
||||
fetch('/api/market_state').then(function(r){ return r.json(); }).then(function(d){
|
||||
state.marketActive = !!d.active;
|
||||
@@ -12620,9 +12819,11 @@ function updateMarketPhaseDisplay(){
|
||||
// 아니라 입력하는 시점에 막는다.
|
||||
function trailBlockReason(){
|
||||
var otSel = $('[data-order-type]');
|
||||
if(!otSel || otSel.value !== 'TRAILING_STOP') return '';
|
||||
if(!otSel || (otSel.value !== 'TRAILING_STOP' && otSel.value !== 'TRAILING_ARM')) return '';
|
||||
var isArm = (otSel.value === 'TRAILING_ARM');
|
||||
// 계단 검증은 서버 orders/trailing.normalize_steps 와 같은 규칙 — 순서·범위가 어긋나면
|
||||
// 서버가 TRAIL_STEPS 로 거부하므로, PIN 왕복 전에 여기서 같은 문구로 막는다.
|
||||
// 예약 트레일링도 같은 계단 규칙을 쓴다 (기준가만 발동가로 바뀐다).
|
||||
var steps = trailSteps();
|
||||
if(!steps.length) return '계단을 하나 이상 추가하세요';
|
||||
if(steps.length > 5) return '계단은 최대 5개입니다';
|
||||
@@ -12637,13 +12838,24 @@ function trailBlockReason(){
|
||||
}
|
||||
var cur = (state.lastCheck && state.lastCheck.cur_price) || (state.lastBook && state.lastBook.price) || 0;
|
||||
if(!(cur > 0)) return '현재가를 받는 중입니다';
|
||||
// 가장 얕은 계단이 제일 먼저 발동한다 — 이게 현재가 이상이면 등록 즉시 터진다.
|
||||
// 예약 트레일링: 발동가가 현재가 위여야 한다. 이미 닿아 있으면 등록 직후 발동하므로
|
||||
// 서버가 ARM_ALREADY_REACHED 로 거부한다 — 같은 조건을 여기서 먼저 막는다.
|
||||
var basis = cur;
|
||||
if(isArm){
|
||||
var ap = armPrice();
|
||||
if(!(ap > 0)) return '발동가를 입력하세요';
|
||||
if(ap <= cur) return '발동가 ' + fmt(ap) + '원이 현재가 ' + fmt(cur) +
|
||||
'원 이하입니다 (이미 도달 — 일반 트레일링으로 등록하세요)';
|
||||
basis = ap;
|
||||
}
|
||||
// 가장 얕은 계단이 제일 먼저 발동한다 — 이게 기준가 이상이면 시작하는 순간 터진다.
|
||||
var minPrice = trailMinPrice();
|
||||
var r = trailCondPrice(cur, steps[0].pct, minPrice); // 초기 고점 = 현재가
|
||||
if(r.cond >= cur){
|
||||
var r = trailCondPrice(basis, steps[0].pct, minPrice);
|
||||
if(r.cond >= basis){
|
||||
var refW = isArm ? '발동가' : '현재가';
|
||||
return r.floorApplied
|
||||
? ('최저 매도가 ' + fmt(minPrice) + '원이 현재가 ' + fmt(cur) + '원보다 높아 즉시 발동됩니다')
|
||||
: ('1단계 손절선 ' + fmt(r.cond) + '원이 현재가 ' + fmt(cur) + '원 이상이라 즉시 발동됩니다');
|
||||
? ('최저 매도가 ' + fmt(minPrice) + '원이 ' + refW + ' ' + fmt(basis) + '원보다 높아 즉시 발동됩니다')
|
||||
: ('1단계 손절선 ' + fmt(r.cond) + '원이 ' + refW + ' ' + fmt(basis) + '원 이상이라 즉시 발동됩니다');
|
||||
}
|
||||
var qty = parseInt(($('[data-order-qty]')||{}).value || '0', 10) || 0;
|
||||
if(qty > 0){
|
||||
@@ -12722,8 +12934,15 @@ function onSymbolChange(){
|
||||
$('[data-order-price-input]').value = '';
|
||||
$('[data-order-qty]').value = '';
|
||||
var stopInp = $('[data-order-stop-input]'); if(stopInp) stopInp.value = '';
|
||||
// 종목 변경 시 스톱지정가는 해제 — 이전 종목 트리거가 새 종목에 잘못 적용 방지
|
||||
var otSelR = $('[data-order-type]'); if(otSelR && otSelR.value === 'STOP_LIMIT'){ otSelR.value = 'LIMIT'; refreshStopUI(); }
|
||||
// 발동가·최저 매도가는 절대가격이라 종목이 바뀌면 반드시 비운다 — 남으면 엉뚱한 가격으로
|
||||
// 예약이 나간다(계단은 %라 종목이 바뀌어도 뜻이 유지되므로 그대로 둔다).
|
||||
var armInp = $('[data-order-arm-price]'); if(armInp) armInp.value = '';
|
||||
var minInp = $('[data-order-trail-min]'); if(minInp) minInp.value = '';
|
||||
// 종목 변경 시 스톱지정가·예약 트레일링은 해제 — 이전 종목 트리거가 새 종목에 잘못 적용 방지
|
||||
var otSelR = $('[data-order-type]');
|
||||
if(otSelR && (otSelR.value === 'STOP_LIMIT' || otSelR.value === 'TRAILING_ARM')){
|
||||
otSelR.value = 'LIMIT'; refreshStopUI();
|
||||
}
|
||||
var budgetInp = $('[data-order-budget]');
|
||||
if(budgetInp) budgetInp.value = '';
|
||||
// 종목 변경 시 새 max로 자동 채움 (SELL: 보유 100%, BUY: 매수가능 max)
|
||||
@@ -12914,6 +13133,12 @@ function doPropose(){
|
||||
// 계단 검증·즉시발동 검사는 입력 시점에 trailBlockReason 이 하고, 걸리면 매도 버튼이
|
||||
// 비활성이라 여기 도달하지 않는다. 서버(TRAIL_STEPS/TRAIL_IMMEDIATE)가 최후 방어선.
|
||||
}
|
||||
if(orderType === 'TRAILING_ARM'){
|
||||
if(state.side !== 'SELL'){ setMsg('예약 트레일링은 매도만 가능합니다', 'error'); return; }
|
||||
if(armPrice() <= 0){ setMsg('발동가를 입력하세요', 'error'); return; }
|
||||
// 발동가 ≤ 현재가·계단 오류도 trailBlockReason 이 입력 시점에 버튼을 잠근다.
|
||||
// 서버(ARM_ALREADY_REACHED/ARM_IMMEDIATE/TRAIL_STEPS)가 최후 방어선.
|
||||
}
|
||||
if(orderType === 'STOP_LIMIT'){
|
||||
if(state.side !== 'SELL'){ setMsg('스톱지정가는 매도만 가능합니다', 'error'); return; }
|
||||
if(price <= 0){ setMsg('스톱지정가는 매도 단가가 필요합니다', 'error'); return; }
|
||||
@@ -12938,7 +13163,7 @@ function doPropose(){
|
||||
// 검증 통과 — 매수/매도 방향 최종 확인 팝업 (오타 방지). 확인 시에만 실제 매매로 진행.
|
||||
state.pendingOrder = {account: account, orderType: orderType, qty: qty, price: price,
|
||||
stopPrice: stopPrice, trailSteps: trailStepList,
|
||||
trailMinPrice: trailMin};
|
||||
trailMinPrice: trailMin, armPrice: armPrice()};
|
||||
openOrderConfirm();
|
||||
}
|
||||
// 방향 확인 후 실제 매매 진행 — 전량매도 sell-choice 분기 포함.
|
||||
@@ -12947,8 +13172,9 @@ 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' && orderType !== 'TRAILING_STOP'){
|
||||
// → 선택 팝업. 스톱지정가·트레일링·예약 트레일링은 단일 계좌 예약이라 대상 아님.
|
||||
if(state.side === 'SELL' && orderType !== 'STOP_LIMIT' && orderType !== 'TRAILING_STOP'
|
||||
&& orderType !== 'TRAILING_ARM'){
|
||||
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;
|
||||
@@ -12958,7 +13184,8 @@ function doProposeConfirmed(){
|
||||
return;
|
||||
}
|
||||
}
|
||||
proposeSingle(account, orderType, qty, price, po.stopPrice, po.trailSteps, po.trailMinPrice);
|
||||
proposeSingle(account, orderType, qty, price, po.stopPrice, po.trailSteps, po.trailMinPrice,
|
||||
po.armPrice);
|
||||
}
|
||||
// ── 매수/매도 방향 확인 모달 ──
|
||||
// 매수·매도 버튼을 둘 다 띄우고 관리자님이 직접 맞는 방향을 누르게 함(위치 랜덤).
|
||||
@@ -12969,13 +13196,16 @@ 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 isTrail = (po.orderType === 'TRAILING_STOP');
|
||||
var isArm = (po.orderType === 'TRAILING_ARM');
|
||||
var isTrail = (po.orderType === 'TRAILING_STOP') || isArm;
|
||||
var typeStr = (po.orderType === 'MARKET') ? '시장가'
|
||||
: isTrail ? '트레일링 스톱(예약)'
|
||||
: isArm ? '예약 트레일링(발동 대기)'
|
||||
: (po.orderType === 'TRAILING_STOP') ? '트레일링 스톱(예약)'
|
||||
: isStop ? '스톱지정가(예약)' : '지정가';
|
||||
var curNow = (state.lastCheck && state.lastCheck.cur_price) || (state.lastBook && state.lastBook.price) || 0;
|
||||
// 트레일링은 단가를 서버가 계산 — 확인 팝업엔 예상 손절선을 보여준다.
|
||||
var trailPeak = isTrail ? curNow : 0; // 초기 고점 = 등록 시점 현재가
|
||||
// 기준 고점 = 예약이면 발동가, 일반 트레일링이면 등록 시점 현재가.
|
||||
var trailPeak = isArm ? (po.armPrice || 0) : (isTrail ? curNow : 0);
|
||||
var trailStepsPo = (po.trailSteps || []);
|
||||
var trailRes = (isTrail && trailStepsPo.length)
|
||||
? trailCondPrice(trailPeak, trailStepsPo[0].pct, po.trailMinPrice)
|
||||
@@ -13004,12 +13234,27 @@ function openOrderConfirm(){
|
||||
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>' +
|
||||
var upLimC = (state.lastBook && state.lastBook.upper_limit) || 0;
|
||||
var armWarn = (isArm && upLimC > 0 && trailPeak > upLimC)
|
||||
? ('· <b style="color:#e06c62;">⚠️ 오늘 상한가 ' + fmt(upLimC) + '원 초과 — 오늘은 도달 불가, ' +
|
||||
'장 마감에 만료됩니다</b><br>')
|
||||
: '';
|
||||
priceLine = armWarn + (isArm
|
||||
? ('· 발동가: <b>' + fmt(trailPeak) + '원 도달 시 시작</b> <span class="muted small">(현재가 ' +
|
||||
fmt(curNow) + '원 · +' + (curNow ? ((trailPeak - curNow) / curNow * 100).toFixed(1) : '0') +
|
||||
'%)</span><br>')
|
||||
: ('· 시작 고점: <b>' + fmt(trailPeak) + '원</b> <span class="muted small">(등록 시점 현재가)</span><br>')) +
|
||||
(po.trailMinPrice > 0 ? ('· 최저 매도가: <b>' + fmt(po.trailMinPrice) + '원</b> <span class="muted small">(' +
|
||||
(trailRes.floorApplied ? '최저가 적용' : '트레일 적용') + ')</span><br>') : '') +
|
||||
'· 매도 계단 <b>' + trailStepsPo.length + '단</b> <span class="muted small">(현재가 ' + fmt(curNow) + '원)</span><br>' +
|
||||
'· 매도 계단 <b>' + trailStepsPo.length + '단</b>' +
|
||||
(isArm ? ' <span class="muted small">(발동가 기준 예상)</span>'
|
||||
: ' <span class="muted small">(현재가 ' + fmt(curNow) + '원)</span>') + '<br>' +
|
||||
stepLines +
|
||||
'· <span class="muted small">고점이 오르면 계단이 통째로 따라 올라갑니다</span><br>';
|
||||
(isArm
|
||||
? '· <b>지금은 주문이 나가지 않습니다</b> <span class="muted small">— 발동가에 닿는 순간 ' +
|
||||
'PIN 재확인 없이 자동 발주됩니다. 실제 손절선은 발동 시점 현재가 기준이라 위 값 이상입니다.</span><br>' +
|
||||
'· <b>오늘 장 마감까지만 유효</b> <span class="muted small">(미발동 시 자동 취소)</span><br>'
|
||||
: '· <span class="muted small">고점이 오르면 계단이 통째로 따라 올라갑니다</span><br>');
|
||||
} else {
|
||||
priceLine = '· ' + (isStop ? '매도 단가' : '단가') + ': <b>' + priceStr + '</b><br>';
|
||||
}
|
||||
@@ -13046,7 +13291,8 @@ 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, trailStepList, trailMinPrice){
|
||||
function proposeSingle(account, orderType, qty, price, stopPrice, trailStepList, trailMinPrice,
|
||||
armPriceV){
|
||||
var body = new URLSearchParams();
|
||||
body.set('account', account);
|
||||
body.set('side', state.side);
|
||||
@@ -13058,12 +13304,14 @@ function proposeSingle(account, orderType, qty, price, stopPrice, trailStepList,
|
||||
if(orderType === 'STOP_LIMIT') body.set('stop_price', String(stopPrice || 0));
|
||||
// 트레일링은 계단 정의(하락률·누적 비중)와 최저 매도가만 보낸다 —
|
||||
// 계단별 조건단가·지정가·주수는 서버가 현재가·보유수량 기준으로 계산한다.
|
||||
if(orderType === 'TRAILING_STOP'){
|
||||
if(orderType === 'TRAILING_STOP' || orderType === 'TRAILING_ARM'){
|
||||
// 승인 팝업에서 확정된 계단을 그대로 보낸다 — 팝업이 떠 있는 사이 편집기를 건드려도
|
||||
// 관리자님이 확인한 내용과 발주가 어긋나지 않는다.
|
||||
body.set('trail_steps', JSON.stringify(trailStepList || []));
|
||||
if(trailMinPrice > 0) body.set('min_sell_price', String(trailMinPrice));
|
||||
}
|
||||
// 예약 트레일링은 발동가가 승인 대상의 일부 — 계단과 같은 스냅샷에서 온다.
|
||||
if(orderType === 'TRAILING_ARM') body.set('trigger_price', String(armPriceV || 0));
|
||||
proposeAndOpenPin('/api/order/propose', body);
|
||||
}
|
||||
function doProposeMulti(opts){
|
||||
@@ -13210,6 +13458,13 @@ if(openOrdersModal) openOrdersModal.addEventListener('click', function(e){
|
||||
cancelOpenOrder(row.getAttribute('data-ord-no'), row.getAttribute('data-account'), cancelBtn);
|
||||
return;
|
||||
}
|
||||
var armBtn = t.closest && t.closest('[data-cancel-arm]');
|
||||
if(armBtn){
|
||||
var arow = armBtn.closest('.open-order-row');
|
||||
if(!arow) return;
|
||||
cancelArm(arow.getAttribute('data-arm-id'), armBtn);
|
||||
return;
|
||||
}
|
||||
});
|
||||
// PIN 모달 click 핸들러
|
||||
if(pinModal) pinModal.addEventListener('click', function(e){
|
||||
@@ -13297,7 +13552,7 @@ if(qtyInpEl) qtyInpEl.addEventListener('input', function(){
|
||||
recalcBudgetFromQty(); renderOrderInfo();
|
||||
// 계단별 주수는 수량에서 나온다 — 수량이 바뀌면 미리보기도 다시 그린다.
|
||||
var ot = $('[data-order-type]');
|
||||
if(ot && ot.value === 'TRAILING_STOP') renderTrailPreview();
|
||||
if(ot && (ot.value === 'TRAILING_STOP' || ot.value === 'TRAILING_ARM')) renderTrailPreview();
|
||||
});
|
||||
var budgetInpEl = $('[data-order-budget]');
|
||||
if(budgetInpEl) budgetInpEl.addEventListener('input', recalcQtyFromBudget);
|
||||
@@ -13306,6 +13561,8 @@ var trailPresetEl = $('[data-trail-preset]');
|
||||
if(trailPresetEl) trailPresetEl.addEventListener('change', applyTrailPreset);
|
||||
var trailMinEl = $('[data-order-trail-min]');
|
||||
if(trailMinEl) trailMinEl.addEventListener('input', renderTrailPreview);
|
||||
var armPriceEl = $('[data-order-arm-price]');
|
||||
if(armPriceEl) armPriceEl.addEventListener('input', renderTrailPreview);
|
||||
// 계단 입력·삭제는 행이 동적으로 다시 그려지므로 컨테이너에 위임한다.
|
||||
var trailStepsEl = $('[data-trail-steps]');
|
||||
if(trailStepsEl){
|
||||
@@ -14205,6 +14462,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# 트레일링 예약이면 그 상태를 실어 보낸다 (예약 파일 읽기, 키움 콜 0).
|
||||
for _r in rows:
|
||||
_r['trailing'] = _trailing_info_for(_r.get('ord_no', ''))
|
||||
# 대기 예약은 키움에 주문이 없어 ka10075 에 안 잡힌다 → 합성 행으로 합류시킨다.
|
||||
# 안 보이는 자동 발주 예약은 그 자체로 사고 요인이고, 이 팝업이 유일한 창구다.
|
||||
rows.extend(_arm_rows())
|
||||
self._send_json(200, {'rows': rows, 'count': len(rows)})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
@@ -14246,6 +14506,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
open_orders_count = len(rows)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
# 대기 예약도 진행중 항목으로 센다 — 그래야 대기 예약만 있을 때도 배지가 뜨고
|
||||
# 버튼이 팝업을 연다(키움 미체결이 0건이면 '진행 중 거래 없음' 토스트로 끝난다).
|
||||
try:
|
||||
from orders import trailing as _trl
|
||||
open_orders_count += len(_trl.list_arms())
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
result['open_orders_count'] = open_orders_count
|
||||
# 진행중 아이템 수 = 미체결 주문 건수(팝업 행과 동일) + 활성 PIN 카드(있으면 1).
|
||||
# 종목코드 중복제거 X — 같은 종목 2계좌 매도도 2건으로 표시(팝업과 일치).
|
||||
@@ -14257,7 +14524,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
if parsed.path == '/api/market_state':
|
||||
try:
|
||||
self._send_json(200, _market_phase_state())
|
||||
st = _market_phase_state()
|
||||
# next_in — 다음 phase 경계까지 남은 초. 브라우저가 폴링 시각을 경계에 맞춘다.
|
||||
st['next_in'] = _secs_to_next_phase()
|
||||
self._send_json(200, st)
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self._send_json(500, {'error': f'market_state failed: {e}'})
|
||||
@@ -14473,7 +14743,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
account = (qs.get('account') or [''])[0].strip()
|
||||
side = (qs.get('side') or [''])[0].strip().upper()
|
||||
order_type = (qs.get('order_type') or ['LIMIT'])[0].strip().upper() or 'LIMIT'
|
||||
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_LIMIT'):
|
||||
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_LIMIT',
|
||||
'TRAILING_STOP', 'TRAILING_ARM'):
|
||||
order_type = 'LIMIT'
|
||||
try:
|
||||
price = int((qs.get('price') or ['0'])[0].strip() or '0')
|
||||
@@ -14989,12 +15260,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||
min_sell_price = int(min_sell_raw) if min_sell_raw else None
|
||||
except ValueError:
|
||||
min_sell_price = None
|
||||
trigger_raw = (params.get('trigger_price') or [''])[0].strip()
|
||||
try:
|
||||
trigger_price = int(trigger_raw) if trigger_raw else None
|
||||
except ValueError:
|
||||
trigger_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', 'TRAILING_STOP'):
|
||||
if order_type not in ('LIMIT', 'MARKET', 'STOP_LIMIT', 'TRAILING_STOP', 'TRAILING_ARM'):
|
||||
self._send_json(400, {'ok': False,
|
||||
'error': 'order_type must be LIMIT|MARKET|STOP_LIMIT|TRAILING_STOP'})
|
||||
'error': 'order_type must be '
|
||||
'LIMIT|MARKET|STOP_LIMIT|TRAILING_STOP|TRAILING_ARM'})
|
||||
return
|
||||
if order_type == 'LIMIT' and (price is None or price <= 0):
|
||||
self._send_json(400, {'ok': False, 'error': 'LIMIT requires positive price'})
|
||||
@@ -15006,12 +15283,13 @@ 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 가 현재가·보유수량 기준으로 계산 —
|
||||
# 여기선 계단 정의(하락률·누적 비중)만 받는다. 형식 검증은 handler 가
|
||||
# orders/trailing.normalize_steps 로 하므로 여기선 최소한만 본다.
|
||||
if order_type in ('TRAILING_STOP', 'TRAILING_ARM'):
|
||||
# 계단별 조건단가·지정가·주수는 handler 가 계산 — 여기선 계단 정의(하락률·누적
|
||||
# 비중)만 받는다. 형식 검증은 handler 가 orders/trailing.normalize_steps 로
|
||||
# 하므로 여기선 최소한만 본다. 예약 트레일링은 발동가가 하나 더 붙는다.
|
||||
kind_label = '예약 트레일링' if order_type == 'TRAILING_ARM' else '트레일링 스톱'
|
||||
if side != 'SELL':
|
||||
self._send_json(400, {'ok': False, 'error': '트레일링 스톱은 매도만 지원'})
|
||||
self._send_json(400, {'ok': False, 'error': f'{kind_label}은 매도만 지원'})
|
||||
return
|
||||
if not isinstance(trail_steps, list) or not trail_steps:
|
||||
self._send_json(400, {'ok': False, 'error': '매도 계단을 입력하세요'})
|
||||
@@ -15019,6 +15297,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if min_sell_price is not None and min_sell_price <= 0:
|
||||
self._send_json(400, {'ok': False, 'error': '최저 매도가는 0보다 커야 합니다'})
|
||||
return
|
||||
if order_type == 'TRAILING_ARM' and (trigger_price is None or trigger_price <= 0):
|
||||
self._send_json(400, {'ok': False, 'error': '발동가를 입력하세요'})
|
||||
return
|
||||
try:
|
||||
if not symbol_name:
|
||||
import kiwoom_client as kc
|
||||
@@ -15035,8 +15316,11 @@ 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_steps=(trail_steps if order_type == 'TRAILING_STOP' else None),
|
||||
min_sell_price=(min_sell_price if order_type == 'TRAILING_STOP' else None),
|
||||
trail_steps=(trail_steps
|
||||
if order_type in ('TRAILING_STOP', 'TRAILING_ARM') else None),
|
||||
min_sell_price=(min_sell_price
|
||||
if order_type in ('TRAILING_STOP', 'TRAILING_ARM') else None),
|
||||
trigger_price=(trigger_price if order_type == 'TRAILING_ARM' else None),
|
||||
)
|
||||
if res.get('ok'):
|
||||
# 매수/매도 미리보기 카드 메시지·PIN 메시지 모두 텔레그램 발송 X.
|
||||
@@ -15180,6 +15464,24 @@ class Handler(BaseHTTPRequestHandler):
|
||||
_bump_panel_signal() # 미체결 취소 → 배지 제거 반영
|
||||
self._send_json(200 if res.get('ok') else 400, res)
|
||||
return
|
||||
if self.path == '/api/orders/arm_cancel':
|
||||
# 대기 예약(예약 트레일링) 취소. 키움 주문이 아니라 상태파일에서 지운다.
|
||||
# 텔레그램 발송 없음 — 실주문 취소가 아니므로 토스트로 충분하다.
|
||||
arm_id = (params.get('arm_id') or [''])[0].strip()
|
||||
if not arm_id:
|
||||
self._send_json(400, {'ok': False, 'error': 'arm_id required'})
|
||||
return
|
||||
try:
|
||||
from orders import handler
|
||||
res = handler.cancel_trailing_arm(arm_id)
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self._send_json(500, {'ok': False, 'error': f'arm cancel failed: {e}'})
|
||||
return
|
||||
if res.get('ok'):
|
||||
_bump_panel_signal() # 대기 예약 취소 → 배지 반영
|
||||
self._send_json(200 if res.get('ok') else 400, res)
|
||||
return
|
||||
if self.path == '/api/order/cancel':
|
||||
# 거래 모달 닫기 시 활성 카드 정리 (자동). 텔레그램 알림 X — 웹 자동 cancel은 사용자가 명시 액션 아님.
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user