auto: 일일 백업 2026-08-28 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-08-28 02:00:06 +09:00
parent 5eb280201d
commit 9e5b1c211e
119 changed files with 2074 additions and 1331 deletions
+110 -4
View File
@@ -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: