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
@@ -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)