auto: 일일 백업 2026-08-28 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12570,7 +12570,8 @@ function showProgressTab(){
|
||||
});
|
||||
return;
|
||||
}
|
||||
if((d.open_orders_count || 0) > 0){
|
||||
// 최근 종료분만 있어도 연다 — 재등록 목록에 닿을 유일한 경로다.
|
||||
if((d.open_orders_count || 0) > 0 || (d.recent_count || 0) > 0){
|
||||
openOpenOrdersModal();
|
||||
return;
|
||||
}
|
||||
@@ -12640,10 +12641,16 @@ function loadOpenOrders(){
|
||||
list.innerHTML = '<div class="loading" style="padding:24px 0;"><div class="loading-spin"></div>불러오는 중…</div>';
|
||||
fetch('/api/orders/open').then(function(r){ return r.json(); }).then(function(d){
|
||||
var rows = d.rows || [];
|
||||
if(!rows.length){
|
||||
// 최근 종료분은 캐시해 둔다 — 재등록 버튼이 인덱스로 이 배열을 되짚는다.
|
||||
state.recentEnded = d.recent || [];
|
||||
if(!rows.length && !state.recentEnded.length){
|
||||
list.innerHTML = '<div class="empty">미체결 주문 없음</div>';
|
||||
return;
|
||||
}
|
||||
if(!rows.length){
|
||||
list.innerHTML = '<div class="empty">미체결 주문 없음</div>' + recentSectionHtml();
|
||||
return;
|
||||
}
|
||||
// 대기 예약을 맨 위로 — 아직 주문이 아니니 미체결과 섞이면 성격이 흐려진다.
|
||||
rows.sort(function(a, b){ return (b.kind === 'arm') - (a.kind === 'arm'); });
|
||||
list.innerHTML = rows.map(function(r){
|
||||
@@ -12723,11 +12730,62 @@ function loadOpenOrders(){
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}).join('');
|
||||
}).join('') + recentSectionHtml();
|
||||
}).catch(function(e){
|
||||
list.innerHTML = '<div class="empty">조회 실패: ' + e + '</div>';
|
||||
});
|
||||
}
|
||||
// 최근 종료(재등록 후보) 섹션 — 비어 있으면 아예 그리지 않는다.
|
||||
function recentSectionHtml(){
|
||||
var list = state.recentEnded || [];
|
||||
if(!list.length) return '';
|
||||
var rows = list.map(function(e, i){
|
||||
var isArm = (e.kind === 'arm');
|
||||
var badge = isArm ? '예약 만료' : '장마감 소멸';
|
||||
var bits = [];
|
||||
if(isArm && e.trigger_price) bits.push('발동가 ' + fmt(e.trigger_price) + '원');
|
||||
(e.steps || []).forEach(function(s){
|
||||
bits.push('−' + s.pct + '% ' + (s.cum >= 100 ? '전량' : '누적 ' + s.cum + '%'));
|
||||
});
|
||||
if(e.min_sell_price) bits.push('최저 ' + fmt(e.min_sell_price) + '원');
|
||||
var when = String(e.ended_at || '').slice(5, 16).replace('T', ' ');
|
||||
return (
|
||||
'<div class="open-order-row" data-recent-idx="' + i + '">' +
|
||||
'<div class="row-info">' +
|
||||
'<div class="row-line1">' +
|
||||
'<span class="badge-side sell">' + badge + '</span>' +
|
||||
'<span>' + (e.symbol_name || e.symbol) + '</span>' +
|
||||
'<span style="color:#8b8f9a; font-weight:400; font-size:11px;">(' + e.symbol + ')</span>' +
|
||||
'</div>' +
|
||||
'<div class="row-line2">[' + e.account + '] ' + fmt(e.qty) + '주 · ' + when + ' 종료</div>' +
|
||||
'<div class="row-line2" style="color:#d9a84a; overflow-wrap:anywhere;">' +
|
||||
bits.join(' · ') + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="row-action">' +
|
||||
'<button type="button" data-rearm>↺ 재등록</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}).join('');
|
||||
return '<div class="row-line2" style="margin:14px 0 6px; color:#8b8f9a;">' +
|
||||
'── 최근 종료 (재등록 가능) ──</div>' + rows;
|
||||
}
|
||||
// 재등록 — 이전 설정을 채운 거래 모달을 연다. 발주는 기존 매도 → 확인 → PIN 경로 그대로다.
|
||||
function rearmFromRecent(idx){
|
||||
var e = (state.recentEnded || [])[idx];
|
||||
if(!e){ showToast('재등록 정보를 찾을 수 없습니다', 'error'); return; }
|
||||
// open-orders(z 1010) 가 order-modal(z 1000) 보다 위라, 먼저 닫지 않으면 뒤에 깔린다.
|
||||
closeOpenOrdersModal();
|
||||
openModal({
|
||||
code: e.symbol, name: e.symbol_name || e.symbol, side: 'SELL', account: e.account,
|
||||
orderType: (e.kind === 'arm') ? 'TRAILING_ARM' : 'TRAILING_STOP',
|
||||
qty: e.qty,
|
||||
armPrice: (e.kind === 'arm') ? e.trigger_price : 0,
|
||||
trailMinPrice: e.min_sell_price || 0,
|
||||
trailSteps: e.steps || []
|
||||
});
|
||||
showToast('이전 설정을 불러왔습니다 — 확인 후 매도를 눌러주세요', 'info', 4000);
|
||||
}
|
||||
function cancelOpenOrder(ordNo, account, btn){
|
||||
if(!confirm('주문 #' + ordNo + ' 취소하시겠습니까?')) return;
|
||||
if(btn){ btn.disabled = true; btn.textContent = '취소중…'; }
|
||||
@@ -13050,6 +13108,33 @@ function openModal(opts){
|
||||
fetchAccountStatus();
|
||||
setSide(opts.side === 'SELL' ? 'SELL' : 'BUY');
|
||||
if(opts.price){ $('[data-order-price-input]').value = opts.price; }
|
||||
// ── 재등록 프리필 (2026-08-27) ──
|
||||
// ⚠️ 순서가 전부다. setSide 뒤라야 하고, 아래 세 줄의 차례를 바꾸면 조용히 망가진다:
|
||||
// · orderType 은 side=SELL 확정 뒤에 — refreshStopUI 가 BUY 면 TRAILING_* 를 LIMIT 로 되돌린다
|
||||
// · 계단은 refreshStopUI 뒤에 — 프리셋 select 가 비어 있으면 계단을 프리셋 0번으로 덮는다
|
||||
// · 수량은 pendingMax 플래그를 꺼야 한다 — 안 그러면 async /api/order/check 가 max_qty 로 덮는다
|
||||
if(opts.orderType){
|
||||
var otPre = $('[data-order-type]');
|
||||
if(otPre){ otPre.value = opts.orderType; refreshStopUI(); }
|
||||
}
|
||||
if(opts.orderType){
|
||||
// ⚠️ 항상 대입한다(0이면 빈 문자열). reset() 이 이 두 칸을 안 지워서, 건너뛰면 이전
|
||||
// 종목·이전 세션의 절대가격이 그대로 남는다.
|
||||
var apEl = $('[data-order-arm-price]');
|
||||
if(apEl) apEl.value = (opts.armPrice > 0) ? opts.armPrice : '';
|
||||
var mnEl = $('[data-order-trail-min]');
|
||||
if(mnEl) mnEl.value = (opts.trailMinPrice > 0) ? opts.trailMinPrice : '';
|
||||
}
|
||||
if(opts.qty){
|
||||
var qEl = $('[data-order-qty]');
|
||||
if(qEl) qEl.value = opts.qty;
|
||||
state.pendingMaxOnSell = false;
|
||||
state.pendingMaxOnBuy = false;
|
||||
}
|
||||
if(opts.trailSteps && opts.trailSteps.length){
|
||||
renderTrailStepRows(opts.trailSteps);
|
||||
renderTrailPreview(); // 내부 첫 줄이 refreshSubmitGate — 별도 호출 불필요
|
||||
}
|
||||
modal.classList.remove('hidden');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('modal-open');
|
||||
@@ -13465,6 +13550,13 @@ if(openOrdersModal) openOrdersModal.addEventListener('click', function(e){
|
||||
cancelArm(arow.getAttribute('data-arm-id'), armBtn);
|
||||
return;
|
||||
}
|
||||
var reBtn = t.closest && t.closest('[data-rearm]');
|
||||
if(reBtn){
|
||||
var rrow = reBtn.closest('.open-order-row');
|
||||
if(!rrow) return;
|
||||
rearmFromRecent(parseInt(rrow.getAttribute('data-recent-idx'), 10));
|
||||
return;
|
||||
}
|
||||
});
|
||||
// PIN 모달 click 핸들러
|
||||
if(pinModal) pinModal.addEventListener('click', function(e){
|
||||
@@ -14465,7 +14557,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# 대기 예약은 키움에 주문이 없어 ka10075 에 안 잡힌다 → 합성 행으로 합류시킨다.
|
||||
# 안 보이는 자동 발주 예약은 그 자체로 사고 요인이고, 이 팝업이 유일한 창구다.
|
||||
rows.extend(_arm_rows())
|
||||
self._send_json(200, {'rows': rows, 'count': len(rows)})
|
||||
# 최근 종료분(재등록 후보). 상태파일 읽기라 키움 콜 0.
|
||||
# ⚠️ rows 에 섞지 않는다 — '진행중'이 아니라 '지난 일'이고, 배지 카운트에도
|
||||
# 들어가면 안 된다(처리할 게 남은 것처럼 보인다).
|
||||
try:
|
||||
from orders import trailing as _trl
|
||||
recent = _trl.list_recent()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
recent = []
|
||||
self._send_json(200, {'rows': rows, 'count': len(rows), 'recent': recent})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self._send_json(500, {'error': f'open orders failed: {e}'})
|
||||
@@ -14508,11 +14609,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
traceback.print_exc()
|
||||
# 대기 예약도 진행중 항목으로 센다 — 그래야 대기 예약만 있을 때도 배지가 뜨고
|
||||
# 버튼이 팝업을 연다(키움 미체결이 0건이면 '진행 중 거래 없음' 토스트로 끝난다).
|
||||
recent_count = 0
|
||||
try:
|
||||
from orders import trailing as _trl
|
||||
open_orders_count += len(_trl.list_arms())
|
||||
recent_count = len(_trl.list_recent())
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
# 최근 종료분은 **배지 숫자에 넣지 않는다**(처리할 게 남은 것처럼 보인다).
|
||||
# 다만 이것만 있어도 팝업은 열려야 한다 — 아니면 재등록 목록에 닿을 길이 없다.
|
||||
result['recent_count'] = recent_count
|
||||
result['open_orders_count'] = open_orders_count
|
||||
# 진행중 아이템 수 = 미체결 주문 건수(팝업 행과 동일) + 활성 PIN 카드(있으면 1).
|
||||
# 종목코드 중복제거 X — 같은 종목 2계좌 매도도 2건으로 표시(팝업과 일치).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""@비하이브투자자문 신규 '종목분석' 영상 감지 + 자막 수집 + watchlist 저장 + 이메일/텔레그램 발송 + 조회.
|
||||
"""@비하이브투자자문 신규 '종목분석'/'관심테마' 영상 감지 + 자막 수집 + watchlist 저장 + 이메일/텔레그램 발송 + 조회.
|
||||
|
||||
Subcommands:
|
||||
fetch — 신규 매칭 영상 메타데이터 JSON 출력(자막 제외) + 자막은 fetch 캐시에만 저장
|
||||
@@ -42,7 +42,7 @@ CHANNEL_ID = 'UCHTRF5r154igU2gXjudUMzg'
|
||||
CHANNEL_NAME = '비하이브 투자자문'
|
||||
FEED_URL = f'https://www.youtube.com/feeds/videos.xml?channel_id={CHANNEL_ID}'
|
||||
SEARCH_URL = 'https://www.youtube.com/results?search_query={query}&sp=CAISAhAB'
|
||||
TITLE_FILTER = '종목분석'
|
||||
TITLE_FILTERS = ('종목분석', '관심테마')
|
||||
TRANSCRIPT_LANGS = ['ko', 'ko-KR', 'en']
|
||||
TRANSCRIPT_CHAR_LIMIT = 8000
|
||||
FETCH_LIMIT = 10
|
||||
@@ -139,8 +139,19 @@ def _is_recent_published_text(text: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _matches_title_filter(title: str) -> bool:
|
||||
return any(keyword in title for keyword in TITLE_FILTERS)
|
||||
|
||||
|
||||
def _video_kind(title: str) -> str:
|
||||
for keyword in TITLE_FILTERS:
|
||||
if keyword in title:
|
||||
return keyword
|
||||
return '비하이브'
|
||||
|
||||
|
||||
def _search_results_fallback() -> list[dict]:
|
||||
query = urllib.parse.quote(f'{CHANNEL_NAME} {TITLE_FILTER}')
|
||||
query = urllib.parse.quote(f'{CHANNEL_NAME} {" OR ".join(TITLE_FILTERS)}')
|
||||
url = SEARCH_URL.format(query=query)
|
||||
req = urllib.request.Request(url, headers={'User-Agent': FEED_USER_AGENT})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
@@ -178,7 +189,7 @@ def _search_results_fallback() -> list[dict]:
|
||||
title = ''.join(run.get('text', '') for run in vr.get('title', {}).get('runs', []))
|
||||
vid = vr.get('videoId', '')
|
||||
published = vr.get('publishedTimeText', {}).get('simpleText', '')
|
||||
if owner != CHANNEL_NAME or TITLE_FILTER not in title or not vid or vid in seen_ids:
|
||||
if owner != CHANNEL_NAME or not _matches_title_filter(title) or not vid or vid in seen_ids:
|
||||
continue
|
||||
if not _is_recent_published_text(published):
|
||||
continue
|
||||
@@ -186,6 +197,7 @@ def _search_results_fallback() -> list[dict]:
|
||||
entries.append({
|
||||
'video_id': vid,
|
||||
'title': title.strip(),
|
||||
'kind': _video_kind(title),
|
||||
'published': published,
|
||||
'url': f'https://www.youtube.com/watch?v={vid}',
|
||||
})
|
||||
@@ -231,6 +243,7 @@ def fetch_feed() -> list[dict]:
|
||||
entries.append({
|
||||
'video_id': vid,
|
||||
'title': title.strip(),
|
||||
'kind': _video_kind(title),
|
||||
'published': published,
|
||||
'url': link or f'https://www.youtube.com/watch?v={vid}',
|
||||
})
|
||||
@@ -267,7 +280,7 @@ def cmd_fetch() -> int:
|
||||
entries = fetch_feed()
|
||||
matched = [
|
||||
e for e in entries
|
||||
if TITLE_FILTER in e['title'] and e['video_id'] not in seen
|
||||
if _matches_title_filter(e['title']) and e['video_id'] not in seen
|
||||
]
|
||||
matched = list(reversed(matched))[:FETCH_LIMIT]
|
||||
for item in matched:
|
||||
@@ -304,6 +317,7 @@ def load_cached_video(video_id: str) -> dict:
|
||||
return {
|
||||
'id': item.get('video_id'),
|
||||
'title': item.get('title'),
|
||||
'kind': item.get('kind') or _video_kind(item.get('title') or ''),
|
||||
'url': item.get('url'),
|
||||
'published': item.get('published'),
|
||||
}
|
||||
@@ -435,7 +449,8 @@ def _buy_primary(entry: dict) -> float | None:
|
||||
|
||||
def format_entry_block(entry: dict) -> str:
|
||||
stock = entry.get('stock', '')
|
||||
lines = [f'[종목분석] #{stock}', '━━━━━━━━━━']
|
||||
kind = (entry.get('video') or {}).get('kind') or '종목분석'
|
||||
lines = [f'[{kind}] #{stock}', '━━━━━━━━━━']
|
||||
target_line = format_price_line('목표가', entry.get('target'))
|
||||
upside = entry.get('upside_pct')
|
||||
if upside is not None and '언급 없음' not in target_line:
|
||||
@@ -480,10 +495,11 @@ def _format_current_html(current: str | None) -> str:
|
||||
def format_entry_html_block(entry: dict) -> str:
|
||||
S = HTML_STYLES
|
||||
stock = entry.get('stock', '')
|
||||
kind = (entry.get('video') or {}).get('kind') or '종목분석'
|
||||
current = fetch_current_price(entry.get('code') or stock, _buy_primary(entry))
|
||||
parts = [f'<div style="{S["card"]}">']
|
||||
parts.append(
|
||||
f'<div style="{S["card_title"]}">[종목분석] #{html_escape(stock)} '
|
||||
f'<div style="{S["card_title"]}">[{html_escape(kind)}] #{html_escape(stock)} '
|
||||
f'<span style="{S["card_meta"]}">현재가: {_format_current_html(current)}</span></div>'
|
||||
)
|
||||
|
||||
@@ -541,7 +557,7 @@ def build_email_html(entries: list[dict]) -> str:
|
||||
S = HTML_STYLES
|
||||
parts = [
|
||||
f'<div style="{S["wrap"]}">',
|
||||
f'<div style="{S["greet"]}">관리자님, 비하이브투자자문 신규 종목분석 {len(entries)}건을 전달드립니다.</div>',
|
||||
f'<div style="{S["greet"]}">관리자님, 비하이브투자자문 신규 추천 영상 {len(entries)}건을 전달드립니다.</div>',
|
||||
]
|
||||
for entry in entries:
|
||||
parts.append(format_entry_html_block(entry))
|
||||
@@ -585,7 +601,7 @@ def cmd_email(video_ids: list[str]) -> int:
|
||||
return 1
|
||||
date_str = datetime.now(KST).strftime('%Y-%m-%d %H:%M')
|
||||
stock_names = ', '.join(e.get('stock', '') for e in entries)
|
||||
subject = f'[비하이브 종목분석] {len(entries)}개 보고서 — {date_str} ({stock_names})'
|
||||
subject = f'[비하이브 추천영상] {len(entries)}개 보고서 — {date_str} ({stock_names})'
|
||||
html_body = build_email_html(entries)
|
||||
cmd = ['gog', 'gmail', 'send', '--to', EMAIL_RECIPIENT, '--subject', subject, '--body-html', html_body]
|
||||
p = subprocess.run(cmd, text=True, capture_output=True)
|
||||
@@ -652,7 +668,7 @@ def cmd_notify(video_ids: list[str]) -> int:
|
||||
if not entries:
|
||||
print('no matching entries in watchlist', file=sys.stderr)
|
||||
return 1
|
||||
header = f'비하이브 종목분석 {len(entries)}건 제출'
|
||||
header = f'비하이브 추천영상 {len(entries)}건 제출'
|
||||
blocks = [format_telegram_block(e) for e in entries]
|
||||
text = header + '\n\n' + '\n\n'.join(blocks)
|
||||
if send_telegram(text):
|
||||
|
||||
@@ -279,6 +279,86 @@ class MonitorFlowTests(unittest.TestCase):
|
||||
self.assertIn('미체결 소멸', self.sent[0])
|
||||
self.assertIn('다시 등록', self.sent[0])
|
||||
|
||||
def test_full_expiry_lands_in_recent(self):
|
||||
"""장 마감에 계단이 통째로 소멸 → 재등록 목록에 예약 단위로 1건."""
|
||||
self.state['quote'] = 5500
|
||||
self.state['open']['일반'] = []
|
||||
self.tm.check(force=True)
|
||||
recent = t.list_recent()
|
||||
self.assertEqual(len(recent), 1, '예약 1건인데 레그 수만큼 쌓였다')
|
||||
self.assertEqual((recent[0]['kind'], recent[0]['symbol']), ('trailing', '381620'))
|
||||
self.assertEqual(recent[0]['qty'], 1943) # 등록 시점 총 수량
|
||||
self.assertEqual(len(recent[0]['steps']), 3) # entry_steps 로 복원됨
|
||||
self.assertIsNone(recent[0]['trigger_price']) # 일반 트레일링엔 발동가 없음
|
||||
# 목록에 실제로 올라간 건에만 재등록 버튼을 안내한다
|
||||
self.assertIn('재등록', self.sent[0])
|
||||
|
||||
def test_filled_notice_does_not_promise_rearm_button(self):
|
||||
"""체결분은 목록에 안 올라간다 — 없는 목록으로 안내하면 안 된다."""
|
||||
self.state['quote'] = 5500
|
||||
self.state['execs'] = [{'ord_no': 'A0001', 'cntr_qty': 389, 'cntr_uv': 4948}]
|
||||
self.state['open']['일반'] = []
|
||||
self.tm.check(force=True)
|
||||
self.assertEqual(t.list_recent(), [])
|
||||
self.assertNotIn('최근 종료', self.sent[0])
|
||||
|
||||
def test_filled_reservation_not_offered_for_reregister(self):
|
||||
"""한 계단이라도 체결됐으면 재등록 목록에 넣지 않는다 — 이미 판 걸 또 파는 셈이다."""
|
||||
self.state['quote'] = 5500
|
||||
self.state['execs'] = [{'ord_no': 'A0001', 'cntr_qty': 389, 'cntr_uv': 4948}]
|
||||
self.state['open']['일반'] = []
|
||||
self.tm.check(force=True)
|
||||
self.assertEqual(t.list_recent(), [])
|
||||
|
||||
def test_unknown_outcome_not_offered_for_reregister(self):
|
||||
"""체결 여부 판정 실패 — 체결됐을 수도 있으므로 재등록 후보로 올리지 않는다."""
|
||||
self.state['quote'] = 5500
|
||||
self.state['open']['일반'] = []
|
||||
self.tm.kc.get_order_executions = lambda acct: (_ for _ in ()).throw(
|
||||
RuntimeError('조회 실패'))
|
||||
self.tm.check(force=True)
|
||||
self.assertIsNone(t.get(self.res['id'])) # 예약은 정리되지만
|
||||
self.assertEqual(t.list_recent(), []) # 재등록 후보는 아니다
|
||||
|
||||
def test_partial_leg_loss_not_recorded(self):
|
||||
"""레그 하나만 사라지고 예약은 살아있으면 아직 종료가 아니다."""
|
||||
self.state['quote'] = 5500
|
||||
self.state['open']['일반'] = [r for r in self.state['open']['일반']
|
||||
if r['ord_no'] != 'A0001']
|
||||
self.tm.check(force=True)
|
||||
self.assertIsNotNone(t.get(self.res['id']))
|
||||
self.assertEqual(t.list_recent(), [])
|
||||
|
||||
def test_earlier_cycle_fill_blocks_recording(self):
|
||||
"""어제 1단 체결 → 오늘 2·3단 소멸. 이 배치엔 체결이 없지만 이미 일부 판 설정이다."""
|
||||
self.state['quote'] = 5500
|
||||
# 사이클 1 — 1단만 사라지고 체결로 판정 (예약은 생존)
|
||||
self.state['execs'] = [{'ord_no': 'A0001', 'cntr_qty': 389, 'cntr_uv': 4948}]
|
||||
self.state['open']['일반'] = [r for r in self.state['open']['일반']
|
||||
if r['ord_no'] != 'A0001']
|
||||
self.tm.check(force=True)
|
||||
self.assertIsNotNone(t.get(self.res['id']))
|
||||
self.assertEqual(t.list_recent(), [])
|
||||
# 사이클 2 — 남은 2·3단이 장 마감으로 소멸 (이 배치엔 체결 없음)
|
||||
self.state['execs'] = []
|
||||
self.state['open']['일반'] = []
|
||||
self.tm.check(force=True)
|
||||
self.assertIsNone(t.get(self.res['id']))
|
||||
self.assertEqual(t.list_recent(), [], '일부 체결된 설정을 재등록 후보로 올렸다')
|
||||
|
||||
def test_legacy_reservation_without_entry_steps_skipped(self):
|
||||
"""구 스키마(entry_steps 없음) 예약은 계단을 지어내지 않고 조용히 건너뛴다."""
|
||||
with t._FileLock(t._LOCK_FILE):
|
||||
rs = t._read()
|
||||
rs[0].pop('entry_steps', None)
|
||||
t._write(rs)
|
||||
self.state['quote'] = 5500
|
||||
self.state['open']['일반'] = []
|
||||
self.tm.check(force=True)
|
||||
self.assertIsNone(t.get(self.res['id']))
|
||||
self.assertEqual(t.list_recent(), [])
|
||||
self.assertEqual(len(self.sent), 1) # 알림은 정상적으로 나간다
|
||||
|
||||
def test_open_order_query_failure_preserves_reservation(self):
|
||||
"""조회 실패를 '사라짐'으로 단정하면 살아있는 예약을 지운다."""
|
||||
def boom(acct, side=None):
|
||||
@@ -533,6 +613,89 @@ class ArmExpiryTests(unittest.TestCase):
|
||||
datetime(2026, 8, 26, 9, 0, tzinfo=KST)))
|
||||
|
||||
|
||||
class RecentStoreTests(unittest.TestCase):
|
||||
"""최근 종료 목록 — 재등록 버튼의 데이터원. 예약·대기예약과 한 파일을 공유한다."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp()) / 'trailing_stops.json'
|
||||
self._orig = (t.STATE_FILE, t._LOCK_FILE)
|
||||
t.STATE_FILE = self.tmp
|
||||
t._LOCK_FILE = self.tmp.with_suffix('.json.lock')
|
||||
self.steps = t.normalize_steps([{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50},
|
||||
{'pct': 30, 'cum': 100}])
|
||||
|
||||
def tearDown(self):
|
||||
t.STATE_FILE, t._LOCK_FILE = self._orig
|
||||
shutil.rmtree(self.tmp.parent, ignore_errors=True)
|
||||
|
||||
def _entry(self, symbol='005930'):
|
||||
src = {'account': '일반', 'symbol': symbol, 'symbol_name': '삼성전자', 'qty': 100,
|
||||
'min_sell_price': 70000, 'trigger_price': 80000}
|
||||
return t.make_recent_entry('arm', 'expired_end_of_day', src, self.steps)
|
||||
|
||||
def _legs(self, peak=5500, qty=300):
|
||||
lv = t.compute_step_levels(peak, self.steps, None)
|
||||
qtys = t.allocate_step_qty(qty, [s['weight'] for s in self.steps])
|
||||
return [dict(s, qty=q, ord_no=f'A{s["n"]:04d}') for s, q in zip(lv, qtys)]
|
||||
|
||||
def test_entry_keeps_only_definition(self):
|
||||
e = self._entry()
|
||||
self.assertEqual([(s['pct'], s['cum']) for s in e['steps']],
|
||||
[(10.0, 20.0), (20.0, 50.0), (30.0, 100.0)])
|
||||
self.assertNotIn('cond_uv', e['steps'][0]) # 가격은 재등록 시점에 다시 계산된다
|
||||
self.assertNotIn('ord_no', e['steps'][0])
|
||||
|
||||
def test_newest_first_and_capped(self):
|
||||
for i in range(t.RECENT_MAX + 5):
|
||||
t.push_recent(self._entry(symbol=f'{i:06d}'))
|
||||
recent = t.list_recent()
|
||||
self.assertEqual(len(recent), t.RECENT_MAX)
|
||||
self.assertEqual(recent[0]['symbol'], f'{t.RECENT_MAX + 4:06d}') # 최신이 앞
|
||||
|
||||
def test_old_entries_dropped(self):
|
||||
stale = self._entry(symbol='000001')
|
||||
stale['ended_at'] = (datetime.now(KST) - timedelta(days=t.RECENT_DAYS + 1)) \
|
||||
.isoformat(timespec='seconds')
|
||||
with t._FileLock(t._LOCK_FILE):
|
||||
t._write(recent=[stale])
|
||||
t.push_recent(self._entry(symbol='000002'))
|
||||
self.assertEqual([e['symbol'] for e in t.list_recent()], ['000002'])
|
||||
|
||||
def test_get_recent_by_key(self):
|
||||
e = t.push_recent(self._entry())
|
||||
self.assertEqual(t.get_recent(e['key'])['symbol'], '005930')
|
||||
self.assertIsNone(t.get_recent('RC-NOPE'))
|
||||
|
||||
def test_other_writes_do_not_wipe_recent(self):
|
||||
"""예약·대기예약 쪽 쓰기가 recent 를 지우면 안 된다 (arms 때와 같은 함정)."""
|
||||
t.push_recent(self._entry())
|
||||
arm = t.register_arm(account='일반', symbol='000660', symbol_name='SK하이닉스',
|
||||
qty=10, trigger_price=500000, steps=self.steps)
|
||||
res = t.register_steps(account='ISA', symbol='035720', symbol_name='카카오',
|
||||
total_qty=300, peak=5500, steps=self._legs())
|
||||
for fn in (lambda: t.commit_peak(res['id'], 6000),
|
||||
lambda: t.commit_step_modify(res['id'], 1, 'X1', 6000, 5400, 5300),
|
||||
lambda: t.should_notify_failure(res['id'], 9e9, 1800),
|
||||
lambda: t.remove_step(res['id'], 3, 'test'),
|
||||
lambda: t.mark_arm_firing(arm['id']),
|
||||
lambda: t.record_arm_leg(arm['id'], self._legs()[0]),
|
||||
lambda: t.remove_arm(arm['id'], 'test')):
|
||||
fn()
|
||||
self.assertEqual(len(t.list_recent()), 1, '최근 종료 목록이 사라졌다')
|
||||
|
||||
def test_entry_steps_survives_last_leg_removal(self):
|
||||
"""마지막 레그까지 빠져도 계단 정의는 예약에 남아 있어야 한다 (재등록의 전제)."""
|
||||
res = t.register_steps(account='일반', symbol='381620', symbol_name='제닉스로보틱스',
|
||||
total_qty=300, peak=5500, steps=self._legs())
|
||||
self.assertEqual(len(t.get(res['id'])['entry_steps']), 3)
|
||||
gone = None
|
||||
for n in (1, 2, 3):
|
||||
gone = t.remove_step(res['id'], n, 'not_in_open_orders')
|
||||
self.assertTrue(gone['reservation_removed'])
|
||||
self.assertEqual(gone['reservation']['steps'], []) # 레그는 비었지만
|
||||
self.assertEqual(len(gone['reservation']['entry_steps']), 3) # 정의는 남는다
|
||||
|
||||
|
||||
class ArmMonitorTests(unittest.TestCase):
|
||||
"""감시 루프의 발동 단계 — 실주문 경로라 stub 으로 전부 막고 호출만 관찰한다."""
|
||||
|
||||
@@ -627,6 +790,38 @@ class ArmMonitorTests(unittest.TestCase):
|
||||
with t._FileLock(t._LOCK_FILE):
|
||||
t._write(arms=arms)
|
||||
|
||||
def test_expired_arm_lands_in_recent(self):
|
||||
self._age_arm()
|
||||
self.tm.check(force=True)
|
||||
recent = t.list_recent()
|
||||
self.assertEqual(len(recent), 1)
|
||||
self.assertEqual((recent[0]['kind'], recent[0]['reason']),
|
||||
('arm', 'expired_end_of_day'))
|
||||
self.assertEqual((recent[0]['symbol'], recent[0]['qty'], recent[0]['trigger_price']),
|
||||
('005930', 100, 80000))
|
||||
self.assertEqual(len(recent[0]['steps']), 3)
|
||||
|
||||
def test_stuck_arm_not_offered_for_reregister(self):
|
||||
"""발동 처리가 중단된 예약은 주문이 나갔을 수 있다 — 재등록을 권하면 이중 매도 유도."""
|
||||
t.mark_arm_firing(self.arm['id'])
|
||||
self._age_arm()
|
||||
self.tm.check(force=True)
|
||||
self.assertEqual(t.list_arms(), []) # 만료 정리는 되지만
|
||||
self.assertEqual(t.list_recent(), []) # 재등록 후보는 아니다
|
||||
|
||||
def test_dropped_arm_not_offered_for_reregister(self):
|
||||
"""보유가 없어 발동 못 한 건 — 다시 걸어도 같은 이유로 죽는다."""
|
||||
self.state['quote'] = 81000
|
||||
self.state['able'] = 0
|
||||
self.tm.check(force=True)
|
||||
self.assertEqual(t.list_arms(), [])
|
||||
self.assertEqual(t.list_recent(), [])
|
||||
|
||||
def test_dry_run_does_not_record_recent(self):
|
||||
self._age_arm()
|
||||
self.tm.check(force=True, dry_run=True)
|
||||
self.assertEqual(t.list_recent(), [])
|
||||
|
||||
def test_expired_arm_is_purged_not_fired(self):
|
||||
"""어제 예약이 오늘 시세로 발동하면 안 된다 — 지우고 알린다."""
|
||||
self._age_arm()
|
||||
@@ -732,6 +927,72 @@ class ArmMonitorTests(unittest.TestCase):
|
||||
self.assertEqual(t.get(res['id'])['peak'], 81000)
|
||||
|
||||
|
||||
def _grab_fn(html: str, name: str, must_contain: str = None) -> str:
|
||||
"""렌더된 페이지에서 JS 함수 본문 추출.
|
||||
|
||||
⚠️ 같은 이름의 함수가 여러 스크립트에 있다(openModal 은 거래 모달 말고도 있음) —
|
||||
must_contain 으로 골라야 엉뚱한 걸 잡지 않는다.
|
||||
"""
|
||||
pos = 0
|
||||
while True:
|
||||
i = html.find(f'function {name}(', pos)
|
||||
if i < 0:
|
||||
raise AssertionError(f'{name} 추출 실패 (must_contain={must_contain!r})')
|
||||
depth, j, started = 0, i, False
|
||||
while j < len(html):
|
||||
if html[j] == '{':
|
||||
depth += 1
|
||||
started = True
|
||||
elif html[j] == '}':
|
||||
depth -= 1
|
||||
if started and depth == 0:
|
||||
break
|
||||
j += 1
|
||||
body = html[i:j + 1]
|
||||
if must_contain is None or must_contain in body:
|
||||
return body
|
||||
pos = i + 1
|
||||
|
||||
|
||||
class RearmPrefillOrderTests(unittest.TestCase):
|
||||
"""재등록 프리필은 **호출 순서가 전부**다 — 순서가 바뀌면 조용히 망가지므로 소스로 고정한다.
|
||||
|
||||
실행 검증은 별개로 하고(node + 가짜 DOM), 여기서는 리팩터가 순서를 뒤집는 걸 막는다.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
import behive_web
|
||||
cls.html = behive_web.render_html()
|
||||
cls.open_modal = _grab_fn(cls.html, 'openModal', must_contain='재등록 프리필')
|
||||
cls.rearm = _grab_fn(cls.html, 'rearmFromRecent')
|
||||
cls.row = _grab_fn(cls.html, 'recentSectionHtml')
|
||||
|
||||
def test_order_type_set_after_side(self):
|
||||
"""setSide 앞에 두면 refreshStopUI 가 TRAILING_* 를 LIMIT 로 되돌린다."""
|
||||
self.assertLess(self.open_modal.index('setSide('),
|
||||
self.open_modal.index("$('[data-order-type]')"))
|
||||
|
||||
def test_steps_rendered_after_refresh_stop_ui(self):
|
||||
"""refreshStopUI 앞에 두면 프리셋 초기화가 계단을 프리셋 0번으로 덮는다."""
|
||||
self.assertLess(self.open_modal.index('refreshStopUI()'),
|
||||
self.open_modal.index('renderTrailStepRows('))
|
||||
|
||||
def test_pending_max_flag_cleared(self):
|
||||
"""안 끄면 async /api/order/check 응답이 프리필 수량을 max_qty 로 덮는다."""
|
||||
self.assertIn('pendingMaxOnSell = false', self.open_modal)
|
||||
|
||||
def test_open_orders_modal_closed_before_opening_order_modal(self):
|
||||
"""open-orders(z 1010) > order-modal(z 1000) — 안 닫으면 뒤에 깔려 안 보인다."""
|
||||
self.assertLess(self.rearm.index('closeOpenOrdersModal()'),
|
||||
self.rearm.index('openModal('))
|
||||
|
||||
def test_recent_rows_have_no_step_table_markup(self):
|
||||
"""trailSteps() 가 전역 querySelectorAll 이라 계단 테이블이 복제되면 수량이 어긋난다."""
|
||||
self.assertNotIn('data-trail-steps', self.row)
|
||||
self.assertNotIn('class="trail-step', self.row)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -35,12 +35,18 @@
|
||||
바뀌었다. 거래소가 지워주는 스톱주문과 달리 arm 은 우리 파일에만 있으니 우리가 지운다:
|
||||
`is_arm_expired` 판정 + 감시 루프의 다음 거래일 첫 사이클 정리.
|
||||
|
||||
여기에 세 번째 키 `recent` 가 더 있다 — 장 마감 소멸·만료된 예약의 설정을 짧게 보관해
|
||||
자산웹이 "같은 설정으로 다시 등록" 버튼을 띄울 수 있게 하는 **UI 편의용 링버퍼**다.
|
||||
감사 기록이 아니다(그쪽은 state/order_log.jsonl, 무한 append).
|
||||
|
||||
⚠️ **두 갈래를 한 파일에 둔 이유는 발동이 "arm 제거 + 예약 등록"이라는 하나의 원자적
|
||||
전이여야 해서다.** promote_arm 이 락 하나 안에서 os.replace 1회로 끝낸다. 파일을 쪼개면
|
||||
락을 중첩해야 하고(데드락), 중간에 죽으면 둘 다 존재하거나 둘 다 사라진다.
|
||||
|
||||
⚠️ 그 대가로 **_write 는 None 인 쪽을 디스크에서 다시 읽어 보존**한다. 이 계약이 깨지면
|
||||
reservations 쪽 쓰기 6곳이 arms 를 조용히 전멸시킨다 (테스트로 고정해 둠).
|
||||
한쪽 쓰기가 나머지 두 갈래를 조용히 전멸시킨다 — reservations 쪽 쓰기만 6곳이다.
|
||||
`_read_doc` 이 세 키를 하드 정규화하므로 **키를 늘릴 때 _read_doc 과 _write 를 반드시
|
||||
같이** 고쳐야 한다 (테스트로 고정해 둠).
|
||||
|
||||
⚠️ arm 의 steps 에는 **가격·주수를 저장하지 않는다**(정의 n/pct/cum/weight 만). routing_suffix
|
||||
도 저장하지 않는다. 갭 상승으로 발동가를 훌쩍 넘겨 열릴 수 있고, 무기한 대기라 등록 시점
|
||||
@@ -253,16 +259,18 @@ def _read_doc() -> dict:
|
||||
때문이다. 파일이 둘이면 락을 중첩해야 하고, 중간에 죽으면 둘 다 존재하거나 둘 다
|
||||
사라진다.
|
||||
"""
|
||||
empty = {'reservations': [], 'arms': [], 'recent': []}
|
||||
if not STATE_FILE.exists():
|
||||
return {'reservations': [], 'arms': []}
|
||||
return empty
|
||||
try:
|
||||
data = json.loads(STATE_FILE.read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError):
|
||||
return {'reservations': [], 'arms': []}
|
||||
return empty
|
||||
if not isinstance(data, dict):
|
||||
return {'reservations': [], 'arms': []}
|
||||
return empty
|
||||
return {'reservations': data.get('reservations') or [],
|
||||
'arms': data.get('arms') or []}
|
||||
'arms': data.get('arms') or [],
|
||||
'recent': data.get('recent') or []}
|
||||
|
||||
|
||||
def _read() -> list:
|
||||
@@ -273,10 +281,15 @@ def _read_arms() -> list:
|
||||
return _read_doc()['arms']
|
||||
|
||||
|
||||
def _write(reservations: Optional[list] = None, arms: Optional[list] = None) -> None:
|
||||
def _read_recent() -> list:
|
||||
return _read_doc()['recent']
|
||||
|
||||
|
||||
def _write(reservations: Optional[list] = None, arms: Optional[list] = None,
|
||||
recent: Optional[list] = None) -> None:
|
||||
"""None 인 쪽은 디스크의 현재 값을 유지한다.
|
||||
|
||||
⚠️ 두 갈래가 한 파일에 있으므로 한쪽만 쓰는 호출이 다른 쪽을 지우면 안 된다.
|
||||
⚠️ 세 갈래가 한 파일에 있으므로 한쪽만 쓰는 호출이 다른 쪽을 지우면 안 된다.
|
||||
반드시 _FileLock 안에서 부를 것 — 여기서 다시 읽어 병합한다.
|
||||
"""
|
||||
doc = _read_doc()
|
||||
@@ -284,6 +297,8 @@ def _write(reservations: Optional[list] = None, arms: Optional[list] = None) ->
|
||||
doc['reservations'] = reservations
|
||||
if arms is not None:
|
||||
doc['arms'] = arms
|
||||
if recent is not None:
|
||||
doc['recent'] = recent
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = STATE_FILE.with_suffix(STATE_FILE.suffix + '.tmp')
|
||||
tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
@@ -334,6 +349,10 @@ def _build_reservation(account: str, symbol: str, symbol_name: str, total_qty: i
|
||||
'qty': s['qty'], 'ord_no': s['ord_no'],
|
||||
'cond_uv': s['cond_uv'], 'ord_uv': s['ord_uv'],
|
||||
'entry_cond_uv': s['cond_uv'], 'modify_count': 0} for s in steps],
|
||||
# 등록 시점 계단 **정의**(가격·주수 없음). steps 는 레그가 체결·소멸할 때마다 줄어들고
|
||||
# 마지막 레그가 빠지면 비어버려서, 재등록용 원본이 여기 없으면 계단을 복원할 수 없다.
|
||||
'entry_steps': [{'n': s['n'], 'pct': s['pct'], 'cum': s['cum'], 'weight': s['weight']}
|
||||
for s in steps],
|
||||
'routing_suffix': routing_suffix,
|
||||
'card_id': card_id,
|
||||
'created_at': _now_iso(),
|
||||
@@ -582,6 +601,73 @@ def mark_arm_firing(arm_id: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 최근 종료 (recent) — 재등록 버튼용 단기 캐시
|
||||
#
|
||||
# 트레일링 스톱은 장 마감에 거래소가 지우고, 대기 예약은 등록일 당일로 우리가 지운다. 둘 다
|
||||
# 사라지는 순간 계좌·수량·계단이 함께 없어져서, 다시 걸려면 처음부터 입력해야 했다.
|
||||
# 종료 시점의 설정을 짧게 보관해 자산웹이 "이전 설정 그대로 채워진 거래 모달" 을 열 수 있게 한다.
|
||||
#
|
||||
# ⚠️ 이건 **UI 편의용 단기 캐시이지 감사 기록이 아니다.** 감사 원장은 state/order_log.jsonl 이고
|
||||
# 그쪽은 무한 append 로 그대로 둔다. 여기는 링버퍼라 오래된 건 지워진다.
|
||||
# ⚠️ 재등록은 **사람이 버튼을 누르고 PIN 을 다시 거친다** — 감시 루프가 스스로 되살리는 경로는
|
||||
# 없다. "자동 재등록 안 함"(2026-07-30 결정)은 그대로 유효하다.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RECENT_MAX = 20
|
||||
RECENT_DAYS = 7
|
||||
|
||||
|
||||
def list_recent() -> list:
|
||||
"""최근 종료 목록 (최신이 앞). 락 없이 읽기 — 웹 렌더용."""
|
||||
return _read_recent()
|
||||
|
||||
|
||||
def get_recent(key: str) -> Optional[dict]:
|
||||
for e in _read_recent():
|
||||
if e.get('key') == key:
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
def push_recent(entry: dict) -> dict:
|
||||
"""종료된 예약 1건을 최근 목록 맨 앞에 넣는다 (링버퍼).
|
||||
|
||||
⚠️ 제거(remove_step/remove_arm) 와 **같은 락에 묶지 않는다.** 레그 소멸은 레그마다 제거가
|
||||
끝난 **뒤에야** "예약이 죽었다" 를 알 수 있어 애초에 한 락에 못 넣기 때문이다. 그 사이에
|
||||
죽으면 항목 하나를 잃는데, 순서를 뒤집으면(기록 먼저) 같은 자리에서 중복이 남는다 —
|
||||
편의 기능이라 유실이 중복보다 낫다.
|
||||
"""
|
||||
entry = dict(entry)
|
||||
entry.setdefault('ended_at', _now_iso())
|
||||
entry['key'] = _new_id('RC-')
|
||||
cutoff = (datetime.now(KST) - timedelta(days=RECENT_DAYS)).isoformat(timespec='seconds')
|
||||
with _FileLock(_LOCK_FILE):
|
||||
recent = [e for e in _read_recent() if (e.get('ended_at') or '') >= cutoff]
|
||||
recent.insert(0, entry)
|
||||
_write(recent=recent[:RECENT_MAX])
|
||||
return entry
|
||||
|
||||
|
||||
def make_recent_entry(kind: str, reason: str, src: dict, steps: list) -> dict:
|
||||
"""예약/대기예약 스냅샷 → 재등록에 필요한 것만 뽑은 항목.
|
||||
|
||||
steps 는 정의만(n/pct/cum/weight) — 가격·주수는 재등록 시점에 다시 계산된다.
|
||||
"""
|
||||
return {
|
||||
'kind': kind, # 'trailing' | 'arm' → 재등록 order_type 을 가른다
|
||||
'reason': reason,
|
||||
'account': src['account'],
|
||||
'symbol': src['symbol'],
|
||||
'symbol_name': src.get('symbol_name') or src['symbol'],
|
||||
'qty': src.get('qty') or 0,
|
||||
'steps': [{'n': s['n'], 'pct': s['pct'], 'cum': s['cum'], 'weight': s.get('weight')}
|
||||
for s in steps],
|
||||
'min_sell_price': src.get('min_sell_price'),
|
||||
'trigger_price': src.get('trigger_price'), # arm 만. trailing 은 None
|
||||
}
|
||||
|
||||
|
||||
def is_arm_expired(arm: dict, now: Optional[datetime] = None) -> bool:
|
||||
"""등록일의 거래 세션이 끝났으면 만료 (2026-08-25 관리자님 결정으로 무기한 → 당일).
|
||||
|
||||
|
||||
@@ -151,6 +151,20 @@ def _notify_gone_batch(events: list) -> None:
|
||||
remaining = min(e['remaining'] for e in evs)
|
||||
any_filled = any(e['filled'] for e in evs)
|
||||
unknown = any(not e['ok'] for e in evs)
|
||||
# 재등록 버튼용 스냅샷 — 예약이 통째로 죽었고, 체결도 판정불가도 없을 때만.
|
||||
# 체결분을 다시 걸면 이미 판 걸 또 파는 셈이고, 판정불가는 체결일 수도 있어 같은 위험이다.
|
||||
# 판정 로직을 새로 쓰지 않고 여기서 같이 쓰는 이유 — 두 벌이 되면 한쪽만 고쳐진다.
|
||||
if remaining == 0 and not any_filled and not unknown:
|
||||
entry_steps = res.get('entry_steps')
|
||||
# ⚠️ 이번 배치 이벤트 수 < 원래 계단 수 = **앞선 사이클에 이미 빠진 레그가 있다**
|
||||
# (거의 항상 장중 체결). 이 배치엔 체결이 없어 위 조건을 통과하므로, 여기서
|
||||
# 막지 않으면 "일부는 이미 판" 설정을 재등록 후보로 올리게 된다.
|
||||
if entry_steps and len(evs) == len(entry_steps):
|
||||
try:
|
||||
trailing.push_recent(trailing.make_recent_entry(
|
||||
'trailing', 'not_in_open_orders', res, entry_steps))
|
||||
except Exception as e:
|
||||
_log(f'재등록 기록 실패 {res["id"]}: {e!r}')
|
||||
if any_filled:
|
||||
icon = '✅'
|
||||
word = '체결'
|
||||
@@ -177,9 +191,14 @@ def _notify_gone_batch(events: list) -> None:
|
||||
lines.append(f' 🎯 {head} → 체결 여부 판정 불가 (체결내역 조회 실패)')
|
||||
if remaining > 0:
|
||||
lines.append(f'남은 계단 {remaining}개 — 감시 계속합니다.')
|
||||
else:
|
||||
elif any_filled or unknown:
|
||||
# 체결·판정불가는 재등록 목록에 올리지 않는다(_archive 조건과 동일) —
|
||||
# 없는 목록으로 안내하면 안 되므로 여기서도 재등록 버튼을 언급하지 않는다.
|
||||
lines.append('남은 계단 없음 — 계속 쓰시려면 자산웹에서 다시 등록해주세요 '
|
||||
'(자동 재등록 안 함).')
|
||||
else:
|
||||
lines.append('남은 계단 없음 — 자산웹 [📋 진행중] → 최근 종료 에서 ↺ 재등록 '
|
||||
'(같은 설정이 채워집니다 · 자동 재등록은 안 합니다).')
|
||||
send_telegram('\n'.join(lines), parse_mode=None)
|
||||
|
||||
|
||||
@@ -318,6 +337,15 @@ def _expire_arms(arms: list, dry_run: bool) -> list:
|
||||
f'(등록 {a.get("created_at", "?")[:16]} · 발동가 {a["trigger_price"]:,})')
|
||||
if not dry_run:
|
||||
trailing.remove_arm(a['id'], 'expired_end_of_day')
|
||||
# 재등록 버튼용 스냅샷. 삭제 뒤에 남긴다 — 앞에 남기고 죽으면 중복이 생긴다.
|
||||
# ⚠️ firing_at 이 걸린 예약은 제외 — 주문이 나갔는지 모르는 상태라, 재등록을
|
||||
# 권하면 이중 매도로 유도하는 셈이다(사람이 미체결부터 확인해야 한다).
|
||||
if not a.get('firing_at'):
|
||||
try:
|
||||
trailing.push_recent(trailing.make_recent_entry(
|
||||
'arm', 'expired_end_of_day', a, a.get('steps') or []))
|
||||
except Exception as e:
|
||||
_log(f'재등록 기록 실패 {a["id"]}: {e!r}')
|
||||
if dry_run:
|
||||
return alive
|
||||
lines = [f'🗓 예약 트레일링 만료 취소 — {len(dead)}건',
|
||||
@@ -325,7 +353,8 @@ def _expire_arms(arms: list, dry_run: bool) -> list:
|
||||
for a in dead:
|
||||
lines.append(f'· {a["symbol_name"]} ({a["symbol"]}) [{a["account"]}] '
|
||||
f'{a["qty"]:,}주 · 발동가 {a["trigger_price"]:,}원 — 미발동')
|
||||
lines.append('계속 노리시려면 자산웹에서 다시 등록해 주세요.')
|
||||
lines.append('계속 노리시려면 자산웹 [📋 진행중] → 최근 종료 에서 ↺ 재등록 을 누르세요 '
|
||||
'(같은 설정이 채워집니다 · PIN 은 평소대로).')
|
||||
try:
|
||||
send_telegram('\n'.join(lines), parse_mode=None)
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user