"""guards 단위테스트. 매매 가드의 모든 분기를 mock 데이터로 검증."""

from __future__ import annotations

import unittest
from datetime import datetime, timedelta, timezone
from unittest import mock

from orders import guards, ledger

KST = timezone(timedelta(hours=9))


def t(h, m, s=0, day=6):
    return datetime(2026, 5, day, h, m, s, tzinfo=KST)


# ---------- 계좌 ----------

class AccountTests(unittest.TestCase):
    def test_owner_accounts_allowed(self):
        self.assertTrue(guards.validate_account('일반').ok)
        self.assertTrue(guards.validate_account('ISA').ok)

    def test_spouse_accounts_allowed(self):
        self.assertTrue(guards.validate_account('가희_일반').ok)
        self.assertTrue(guards.validate_account('가희_ISA').ok)

    def test_unknown_account_rejected(self):
        r = guards.validate_account('해외')
        self.assertFalse(r.ok)
        self.assertEqual(r.code, 'ACCOUNT_NOT_WHITELISTED')

    def test_is_spouse_account(self):
        self.assertTrue(guards.is_spouse_account('가희_일반'))
        self.assertTrue(guards.is_spouse_account('가희_ISA'))
        self.assertFalse(guards.is_spouse_account('일반'))
        self.assertFalse(guards.is_spouse_account('ISA'))


# ---------- 시간대 ----------

class SessionTests(unittest.TestCase):
    def test_closed_before_pre(self):
        self.assertEqual(guards.session_at(t(7, 30)), 'CLOSED')

    def test_nxt_pre_boundaries(self):
        self.assertEqual(guards.session_at(t(8, 0)), 'NXT_PRE')
        self.assertEqual(guards.session_at(t(8, 30)), 'NXT_PRE')
        self.assertEqual(guards.session_at(t(8, 59, 59)), 'NXT_PRE')

    def test_krx_nxt_concurrent(self):
        self.assertEqual(guards.session_at(t(9, 0, 30)), 'KRX_NXT')
        self.assertEqual(guards.session_at(t(12, 0)), 'KRX_NXT')
        self.assertEqual(guards.session_at(t(15, 19, 59)), 'KRX_NXT')

    def test_krx_close(self):
        self.assertEqual(guards.session_at(t(15, 20)), 'KRX_CLOSE')
        self.assertEqual(guards.session_at(t(15, 25)), 'KRX_CLOSE')
        self.assertEqual(guards.session_at(t(15, 29, 59)), 'KRX_CLOSE')

    def test_nxt_after(self):
        self.assertEqual(guards.session_at(t(15, 30)), 'NXT_AFTER')
        self.assertEqual(guards.session_at(t(18, 0)), 'NXT_AFTER')
        self.assertEqual(guards.session_at(t(19, 59, 59)), 'NXT_AFTER')

    def test_closed_after_after(self):
        self.assertEqual(guards.session_at(t(20, 0)), 'CLOSED')
        self.assertEqual(guards.session_at(t(21, 0)), 'CLOSED')

    def test_gap_between_pre_and_regular_is_closed(self):
        self.assertEqual(guards.session_at(t(9, 0, 0)), 'CLOSED')


# ---------- 거래시간 + NXT 매트릭스 ----------

class TradingHoursTests(unittest.TestCase):
    def test_holiday(self):
        r = guards.validate_trading_hours(t(10, 0), True, True)
        self.assertFalse(r.ok)
        self.assertEqual(r.code, 'HOLIDAY')

    def test_closed(self):
        r = guards.validate_trading_hours(t(21, 0), False, True)
        self.assertFalse(r.ok)
        self.assertEqual(r.code, 'OUTSIDE_HOURS')

    def test_nxt_pre_eligible(self):
        self.assertTrue(guards.validate_trading_hours(t(8, 30), False, True).ok)

    def test_nxt_pre_not_eligible(self):
        r = guards.validate_trading_hours(t(8, 30), False, False)
        self.assertFalse(r.ok)
        self.assertEqual(r.code, 'NXT_NOT_ELIGIBLE')

    def test_nxt_after_not_eligible(self):
        r = guards.validate_trading_hours(t(18, 0), False, False)
        self.assertEqual(r.code, 'NXT_NOT_ELIGIBLE')

    def test_krx_close_no_nxt_required(self):
        self.assertTrue(guards.validate_trading_hours(t(15, 25), False, False).ok)


# ---------- 라우팅 ----------

class RoutingTests(unittest.TestCase):
    def test_krx_nxt_with_eligible(self):
        self.assertEqual(guards.determine_routing(t(10, 0), True, None), '_AL')

    def test_krx_nxt_without_eligible(self):
        self.assertEqual(guards.determine_routing(t(10, 0), False, None), '')

    def test_nxt_pre(self):
        self.assertEqual(guards.determine_routing(t(8, 30), True, None), '_NX')

    def test_nxt_after(self):
        self.assertEqual(guards.determine_routing(t(18, 0), True, None), '_NX')

    def test_krx_close(self):
        self.assertEqual(guards.determine_routing(t(15, 25), False, None), '')

    def test_force_options(self):
        self.assertEqual(guards.determine_routing(t(10, 0), True, 'AL'), '_AL')
        self.assertEqual(guards.determine_routing(t(10, 0), True, 'NX'), '_NX')
        self.assertEqual(guards.determine_routing(t(10, 0), True, 'KRX'), '')

    def test_invalid_force(self):
        with self.assertRaises(ValueError):
            guards.determine_routing(t(10, 0), True, 'XYZ')

    def test_closed_session_raises(self):
        with self.assertRaises(ValueError):
            guards.determine_routing(t(21, 0), True, None)


# ---------- 가격 가드 ----------

class PriceBandTests(unittest.TestCase):
    def test_within_band(self):
        self.assertTrue(guards.validate_price_band('BUY', 75000, 97500, 52500).ok)
        self.assertTrue(guards.validate_price_band('SELL', 75000, 97500, 52500).ok)

    def test_at_upper_inclusive(self):
        self.assertTrue(guards.validate_price_band('BUY', 97500, 97500, 52500).ok)

    def test_at_lower_inclusive(self):
        self.assertTrue(guards.validate_price_band('SELL', 52500, 97500, 52500).ok)

    def test_above_upper(self):
        r = guards.validate_price_band('BUY', 100000, 97500, 52500)
        self.assertEqual(r.code, 'PRICE_ABOVE_UPPER')

    def test_below_lower(self):
        r = guards.validate_price_band('SELL', 50000, 97500, 52500)
        self.assertEqual(r.code, 'PRICE_BELOW_LOWER')


# ---------- 잔고 / 보유 / 정지 / VI ----------

class BalancePositionTests(unittest.TestCase):
    def test_balance_sufficient(self):
        self.assertTrue(guards.validate_balance_for_buy(5, 75000, 1_000_000).ok)

    def test_balance_exact(self):
        self.assertTrue(guards.validate_balance_for_buy(5, 75000, 375_000).ok)

    def test_balance_insufficient(self):
        r = guards.validate_balance_for_buy(5, 75000, 100)
        self.assertEqual(r.code, 'INSUFFICIENT_BALANCE')

    def test_position_sufficient(self):
        self.assertTrue(guards.validate_position_for_sell(5, 100).ok)

    def test_position_exact(self):
        self.assertTrue(guards.validate_position_for_sell(5, 5).ok)

    def test_position_insufficient(self):
        r = guards.validate_position_for_sell(5, 3)
        self.assertEqual(r.code, 'INSUFFICIENT_POSITION')


class MarketOrderSessionTests(unittest.TestCase):
    def test_market_in_krx_regular_ok(self):
        self.assertTrue(guards.validate_market_order_session(t(10, 0), 'MARKET').ok)

    def test_market_in_nxt_pre_blocked(self):
        r = guards.validate_market_order_session(t(8, 30), 'MARKET')
        self.assertFalse(r.ok)
        self.assertEqual(r.code, 'MARKET_NOT_ALLOWED_IN_NXT')

    def test_market_in_nxt_after_blocked(self):
        r = guards.validate_market_order_session(t(18, 0), 'MARKET')
        self.assertEqual(r.code, 'MARKET_NOT_ALLOWED_IN_NXT')

    def test_market_in_auction_blocked(self):
        r = guards.validate_market_order_session(t(15, 25), 'MARKET')
        self.assertEqual(r.code, 'MARKET_NOT_ALLOWED_IN_AUCTION')

    def test_limit_unaffected(self):
        for hour, minute in [(8, 30), (10, 0), (15, 25), (18, 0)]:
            with self.subTest(time=(hour, minute)):
                self.assertTrue(guards.validate_market_order_session(t(hour, minute), 'LIMIT').ok)

    def test_aggressive_limit_unaffected(self):
        for hour, minute in [(8, 30), (15, 25), (18, 0)]:
            self.assertTrue(guards.validate_market_order_session(t(hour, minute), 'AGGRESSIVE_LIMIT').ok)


class HaltViTests(unittest.TestCase):
    def test_normal(self):
        self.assertTrue(guards.validate_halt_vi(False, False).ok)

    def test_halt(self):
        r = guards.validate_halt_vi(True, False)
        self.assertEqual(r.code, 'TRADING_HALT')

    def test_vi(self):
        r = guards.validate_halt_vi(False, True)
        self.assertEqual(r.code, 'VI')


# ---------- 딜레이 ----------

class DelayTests(unittest.TestCase):
    def setUp(self):
        self.now = t(10, 0)

    def test_global_no_history(self):
        with mock.patch.object(ledger, 'last_terminal_event', return_value=None):
            self.assertTrue(guards.validate_delay_between_orders(self.now).ok)

    def test_global_within_cooldown(self):
        recent = (self.now - timedelta(seconds=30)).isoformat()
        with mock.patch.object(ledger, 'last_terminal_event',
                               return_value={'ts': recent, 'event': 'filled'}):
            r = guards.validate_delay_between_orders(self.now)
            self.assertEqual(r.code, 'COOLDOWN_GLOBAL')

    def test_global_after_cooldown(self):
        old = (self.now - timedelta(seconds=120)).isoformat()
        with mock.patch.object(ledger, 'last_terminal_event',
                               return_value={'ts': old, 'event': 'filled'}):
            self.assertTrue(guards.validate_delay_between_orders(self.now).ok)

    def test_same_symbol_within_3min(self):
        recent = (self.now - timedelta(seconds=60)).isoformat()
        with mock.patch.object(ledger, 'last_event_for_symbol',
                               return_value={'ts': recent, 'event': 'filled',
                                             'payload': {'account': '일반', 'symbol': '005930'}}):
            r = guards.validate_delay_same_symbol(self.now, '일반', '005930')
            self.assertEqual(r.code, 'COOLDOWN_SAME_SYMBOL')

    def test_same_symbol_after_3min(self):
        old = (self.now - timedelta(seconds=200)).isoformat()
        with mock.patch.object(ledger, 'last_event_for_symbol',
                               return_value={'ts': old, 'event': 'filled',
                                             'payload': {'account': '일반', 'symbol': '005930'}}):
            self.assertTrue(guards.validate_delay_same_symbol(self.now, '일반', '005930').ok)

    def test_same_symbol_no_history(self):
        with mock.patch.object(ledger, 'last_event_for_symbol', return_value=None):
            self.assertTrue(guards.validate_delay_same_symbol(self.now, '일반', '005930').ok)

    def test_same_symbol_blocked_by_submitted(self):
        """체결 폴링 미구현 상태에서도 submitted 만으로 동일 종목 가드 작동."""
        recent = (self.now - timedelta(seconds=30)).isoformat()
        with mock.patch.object(ledger, 'last_event_for_symbol',
                               return_value={'ts': recent, 'event': 'submitted',
                                             'payload': {'account': '일반', 'symbol': '005930'}}):
            r = guards.validate_delay_same_symbol(self.now, '일반', '005930')
            self.assertEqual(r.code, 'COOLDOWN_SAME_SYMBOL')

    def test_same_symbol_only_counts_post_submit_events(self):
        """rejected/canceled/expired/failed 는 키움 접수 전이라 동일 종목 가드 대상 아님."""
        captured = {}

        def fake_last(account, symbol, events=()):
            captured['events'] = events
            return None

        with mock.patch.object(ledger, 'last_event_for_symbol', side_effect=fake_last):
            self.assertTrue(guards.validate_delay_same_symbol(self.now, '일반', '005930').ok)
        self.assertEqual(captured['events'], ('submitted', 'filled', 'partial'))


# ---------- 키움 진실 소스 가드 (kt00007) ----------

class BrokerDelayTests(unittest.TestCase):
    """validate_delay_same_symbol_via_broker — NETWORK 사각·키움앱 직접 매매까지 잡는 가드."""

    def setUp(self):
        self.now = t(10, 0)

    def _exec(self, code: str, seconds_ago: int, comm_src: str = 'REST API') -> dict:
        ord_tm = (self.now - timedelta(seconds=seconds_ago)).strftime('%H:%M:%S')
        return {'code': code, 'ord_tm': ord_tm, 'comm_src': comm_src}

    def test_no_executions_passes(self):
        r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', [], None)
        self.assertTrue(r.ok)

    def test_query_failed_blocks_conservatively(self):
        """키움 조회 자체 실패 시 보수적 차단 — 정확도 우선."""
        r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', None, 'TimeoutError(...)')
        self.assertEqual(r.code, 'BROKER_QUERY_FAILED')

    def test_recent_execution_blocks(self):
        executions = [self._exec('005930', seconds_ago=60)]
        r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', executions, None)
        self.assertEqual(r.code, 'COOLDOWN_SAME_SYMBOL_BROKER')

    def test_old_execution_passes(self):
        executions = [self._exec('005930', seconds_ago=200)]
        r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', executions, None)
        self.assertTrue(r.ok)

    def test_different_symbol_ignored(self):
        executions = [self._exec('000660', seconds_ago=30)]
        r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', executions, None)
        self.assertTrue(r.ok)

    def test_external_app_execution_also_blocks(self):
        """사용자가 키움앱(영웅문)으로 직접 매매한 것도 가드 대상."""
        executions = [self._exec('005930', seconds_ago=30, comm_src='영웅문S#')]
        r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', executions, None)
        self.assertEqual(r.code, 'COOLDOWN_SAME_SYMBOL_BROKER')
        self.assertIn('영웅문', r.message)

    def test_picks_latest_among_multiple(self):
        executions = [
            self._exec('005930', seconds_ago=300, comm_src='영웅문S#'),
            self._exec('005930', seconds_ago=60, comm_src='REST API'),
        ]
        r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', executions, None)
        self.assertEqual(r.code, 'COOLDOWN_SAME_SYMBOL_BROKER')

    def test_malformed_ord_tm_skipped(self):
        """ord_tm 파싱 실패 행은 무시. 다른 정상 행이 없으면 통과."""
        executions = [{'code': '005930', 'ord_tm': '', 'comm_src': 'REST API'}]
        r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', executions, None)
        self.assertTrue(r.ok)

    def test_global_cooldown_only_counts_post_submit_events(self):
        """rejected/canceled/expired/failed 는 키움 접수 전이라 쿨다운 대상 아님."""
        captured = {}

        def fake_last(events=()):
            captured['events'] = events
            return None

        with mock.patch.object(ledger, 'last_terminal_event', side_effect=fake_last):
            self.assertTrue(guards.validate_delay_between_orders(self.now).ok)
        self.assertEqual(captured['events'], ('submitted', 'filled', 'partial'))

    def test_global_cooldown_triggered_by_submitted(self):
        """submitted (실주문 접수) 도 쿨다운 트리거."""
        recent = (self.now - timedelta(seconds=30)).isoformat()
        with mock.patch.object(ledger, 'last_terminal_event',
                               return_value={'ts': recent, 'event': 'submitted'}):
            r = guards.validate_delay_between_orders(self.now)
            self.assertEqual(r.code, 'COOLDOWN_GLOBAL')


# ---------- 자연어 시장가 분류 ----------

class IntentTests(unittest.TestCase):
    def test_market_keyword(self):
        self.assertEqual(guards.classify_order_intent('삼성 5주 시장가'), 'MARKET')

    def test_aggressive_keywords(self):
        for kw in ('지금 바로 사줘', '즉시 매수', '빨리 사', '당장 사줘'):
            self.assertEqual(guards.classify_order_intent(kw), 'AGGRESSIVE_LIMIT')

    def test_default_limit(self):
        self.assertEqual(guards.classify_order_intent('삼성 5주 75000원'), 'LIMIT')
        self.assertEqual(guards.classify_order_intent(''), 'LIMIT')

    def test_market_takes_priority_over_aggressive(self):
        # 시장가 키워드가 우선 — "지금 바로 시장가" 같은 경우
        self.assertEqual(guards.classify_order_intent('지금 바로 시장가로'), 'MARKET')


# ---------- 호가단위 + 공격적 지정가 ----------

class TickSizeTests(unittest.TestCase):
    def test_ranges(self):
        cases = [(1500, 1), (3000, 5), (15000, 10), (45000, 50),
                 (100000, 100), (300000, 500), (700000, 1000)]
        for price, expected in cases:
            with self.subTest(price=price):
                self.assertEqual(guards.tick_size(price), expected)


class AggressivePriceTests(unittest.TestCase):
    def test_buy_one_tick_above(self):
        ob = {'asks': [{'price': 75100, 'qty': 1000}], 'bids': [{'price': 75000, 'qty': 1000}]}
        r = guards.aggressive_limit_price('BUY', ob)
        self.assertTrue(r['ok'])
        self.assertEqual(r['price'], 75200)
        self.assertEqual(r['source'], 'orderbook')

    def test_sell_one_tick_below(self):
        ob = {'asks': [{'price': 75100, 'qty': 1000}], 'bids': [{'price': 75000, 'qty': 1000}]}
        r = guards.aggressive_limit_price('SELL', ob)
        self.assertTrue(r['ok'])
        self.assertEqual(r['price'], 74900)

    def test_buy_at_low_price_band(self):
        # 1500원대 → tick=1
        ob = {'asks': [{'price': 1500, 'qty': 1000}], 'bids': [{'price': 1499, 'qty': 1000}]}
        r = guards.aggressive_limit_price('BUY', ob)
        self.assertEqual(r['price'], 1501)

    def test_no_orderbook_uses_fallback_price(self):
        r = guards.aggressive_limit_price('BUY', None, fallback_price=75050)
        self.assertTrue(r['ok'])
        self.assertEqual(r['price'], 75150)  # 75050 + 100tick
        self.assertEqual(r['source'], 'fallback')

    def test_empty_orderbook_uses_fallback_price(self):
        r = guards.aggressive_limit_price('SELL', {'asks': [], 'bids': []}, fallback_price=75050)
        self.assertTrue(r['ok'])
        self.assertEqual(r['price'], 74950)
        self.assertEqual(r['source'], 'fallback')

    def test_no_orderbook_no_fallback_rejects(self):
        r = guards.aggressive_limit_price('BUY', None)
        self.assertFalse(r['ok'])
        self.assertEqual(r['code'], 'NO_ORDERBOOK')

    def test_orderbook_takes_priority_over_fallback(self):
        ob = {'asks': [{'price': 75100, 'qty': 1000}], 'bids': [{'price': 75000, 'qty': 1000}]}
        r = guards.aggressive_limit_price('BUY', ob, fallback_price=80000)
        self.assertEqual(r['source'], 'orderbook')
        self.assertEqual(r['price'], 75200)


# ---------- 시장가 슬리피지 추정 ----------

class MarketEstimateTests(unittest.TestCase):
    def setUp(self):
        self.ob = {
            'asks': [{'price': 75100, 'qty': 1000}, {'price': 75200, 'qty': 500}],
            'bids': [{'price': 75000, 'qty': 800}, {'price': 74900, 'qty': 1200}],
        }

    def test_buy_within_first_level(self):
        est = guards.estimate_market_fill('BUY', 5, self.ob)
        self.assertEqual(est['avg_fill'], 75100)
        self.assertEqual(est['slippage_pct'], 0.0)

    def test_buy_across_levels(self):
        est = guards.estimate_market_fill('BUY', 1500, self.ob)
        # 1000 × 75100 + 500 × 75200 = 112,700,000 → avg 75133
        self.assertEqual(est['total_won'], 112_700_000)
        self.assertEqual(est['avg_fill'], 75133)
        self.assertGreater(est['slippage_pct'], 0)

    def test_sell_first_level(self):
        est = guards.estimate_market_fill('SELL', 5, self.ob)
        self.assertEqual(est['avg_fill'], 75000)

    def test_buy_exceeds_orderbook_depth(self):
        # depth=3 default 지만 ob asks 2단계뿐 → 5000주 매수면 마지막 호가가 fallback
        est = guards.estimate_market_fill('BUY', 5000, self.ob)
        self.assertGreater(est['total_won'], 0)
        self.assertGreater(est['avg_fill'], 0)


# ---------- 통합 검증 ----------

class IntegrationTests(unittest.TestCase):
    def setUp(self):
        self.now = t(10, 0)
        self.ob = {
            'asks': [{'price': 75100, 'qty': 1250}, {'price': 75200, 'qty': 890}],
            'bids': [{'price': 75000, 'qty': 800}, {'price': 74900, 'qty': 1200}],
        }
        self.md_buy = {
            'now': self.now, 'is_holiday': False, 'nxt_eligible': True,
            'current_price': 75000, 'prev_close': 74800,
            'upper_limit': 97500, 'lower_limit': 52500,
            'halt': False, 'vi': False, 'orderbook': self.ob,
            'balance_d2': 1_000_000,
            'broker_executions': [],  # broker 가드 통과용 (정상=빈 리스트)
            'broker_query_error': None,
        }
        self.md_sell = dict(self.md_buy, position_qty=100)
        self.req_buy = {'account': '일반', 'side': 'BUY', 'symbol': '005930',
                        'qty': 5, 'order_type': 'LIMIT', 'price': 75000}
        self.req_sell = {'account': 'ISA', 'side': 'SELL', 'symbol': '005930',
                         'qty': 5, 'order_type': 'LIMIT', 'price': 75000}

    def _no_history(self):
        return mock.patch.multiple(
            ledger,
            last_terminal_event=mock.MagicMock(return_value=None),
            last_event_for_symbol=mock.MagicMock(return_value=None),
        )

    def test_normal_buy(self):
        with self._no_history():
            self.assertTrue(guards.validate_request(self.req_buy, self.md_buy).ok)

    def test_normal_sell(self):
        with self._no_history():
            self.assertTrue(guards.validate_request(self.req_sell, self.md_sell).ok)

    def test_account_rejected(self):
        with self._no_history():
            req = dict(self.req_buy, account='해외')
            r = guards.validate_request(req, self.md_buy)
            self.assertEqual(r.code, 'ACCOUNT_NOT_WHITELISTED')

    def test_invalid_side(self):
        with self._no_history():
            req = dict(self.req_buy, side='HOLD')
            r = guards.validate_request(req, self.md_buy)
            self.assertEqual(r.code, 'INVALID_SIDE')

    def test_invalid_order_type(self):
        with self._no_history():
            req = dict(self.req_buy, order_type='UNKNOWN')
            r = guards.validate_request(req, self.md_buy)
            self.assertEqual(r.code, 'INVALID_ORDER_TYPE')

    def test_holiday_blocks(self):
        with self._no_history():
            md = dict(self.md_buy, is_holiday=True)
            self.assertEqual(guards.validate_request(self.req_buy, md).code, 'HOLIDAY')

    def test_outside_hours(self):
        with self._no_history():
            md = dict(self.md_buy, now=t(21, 0))
            self.assertEqual(guards.validate_request(self.req_buy, md).code, 'OUTSIDE_HOURS')

    def test_halt(self):
        with self._no_history():
            md = dict(self.md_buy, halt=True)
            self.assertEqual(guards.validate_request(self.req_buy, md).code, 'TRADING_HALT')

    def test_vi(self):
        with self._no_history():
            md = dict(self.md_buy, vi=True)
            self.assertEqual(guards.validate_request(self.req_buy, md).code, 'VI')

    def test_price_above_upper(self):
        with self._no_history():
            req = dict(self.req_buy, price=100_000)
            self.assertEqual(guards.validate_request(req, self.md_buy).code, 'PRICE_ABOVE_UPPER')

    def test_insufficient_balance(self):
        with self._no_history():
            md = dict(self.md_buy, balance_d2=100)
            self.assertEqual(guards.validate_request(self.req_buy, md).code, 'INSUFFICIENT_BALANCE')

    def test_insufficient_position(self):
        with self._no_history():
            md = dict(self.md_sell, position_qty=2)
            self.assertEqual(guards.validate_request(self.req_sell, md).code, 'INSUFFICIENT_POSITION')

    def test_market_buy_uses_orderbook(self):
        with self._no_history():
            req = dict(self.req_buy, order_type='MARKET')
            req.pop('price', None)
            self.assertTrue(guards.validate_request(req, self.md_buy).ok)

    def test_market_buy_in_nxt_blocked(self):
        with self._no_history():
            req = dict(self.req_buy, order_type='MARKET'); req.pop('price', None)
            md = dict(self.md_buy, now=t(8, 30))
            r = guards.validate_request(req, md)
            self.assertEqual(r.code, 'MARKET_NOT_ALLOWED_IN_NXT')

    def test_market_buy_in_auction_blocked(self):
        with self._no_history():
            req = dict(self.req_buy, order_type='MARKET'); req.pop('price', None)
            md = dict(self.md_buy, now=t(15, 25))
            r = guards.validate_request(req, md)
            self.assertEqual(r.code, 'MARKET_NOT_ALLOWED_IN_AUCTION')

    def test_nxt_time_not_eligible(self):
        with self._no_history():
            md = dict(self.md_buy, now=t(8, 30), nxt_eligible=False)
            self.assertEqual(guards.validate_request(self.req_buy, md).code, 'NXT_NOT_ELIGIBLE')

    def test_global_cooldown_blocks_new_order(self):
        recent = (self.now - timedelta(seconds=30)).isoformat()
        with mock.patch.object(ledger, 'last_terminal_event',
                               return_value={'ts': recent, 'event': 'filled'}), \
             mock.patch.object(ledger, 'last_event_for_symbol', return_value=None):
            self.assertEqual(guards.validate_request(self.req_buy, self.md_buy).code, 'COOLDOWN_GLOBAL')

    def test_aggressive_limit_buy_uses_orderbook(self):
        with self._no_history():
            req = dict(self.req_buy, order_type='AGGRESSIVE_LIMIT'); req.pop('price', None)
            self.assertTrue(guards.validate_request(req, self.md_buy).ok)

    def test_aggressive_limit_buy_fallback_when_orderbook_missing(self):
        """호가창 비어도 current_price fallback 으로 잔고 가드까지 통과."""
        with self._no_history():
            req = dict(self.req_buy, order_type='AGGRESSIVE_LIMIT'); req.pop('price', None)
            md = dict(self.md_buy, orderbook=None, current_price=75000)
            self.assertTrue(guards.validate_request(req, md).ok)

    def test_aggressive_limit_buy_no_orderbook_no_quote_rejects(self):
        with self._no_history():
            req = dict(self.req_buy, order_type='AGGRESSIVE_LIMIT'); req.pop('price', None)
            md = dict(self.md_buy, orderbook=None, current_price=0)
            r = guards.validate_request(req, md)
            self.assertEqual(r.code, 'NO_ORDERBOOK')

    def test_same_symbol_cooldown_blocks(self):
        recent = (self.now - timedelta(seconds=60)).isoformat()
        with mock.patch.object(ledger, 'last_terminal_event', return_value=None), \
             mock.patch.object(ledger, 'last_event_for_symbol',
                               return_value={'ts': recent, 'event': 'filled',
                                             'payload': {'account': '일반', 'symbol': '005930'}}):
            self.assertEqual(guards.validate_request(self.req_buy, self.md_buy).code,
                             'COOLDOWN_SAME_SYMBOL')


# ---------- 예산 → 수량 환산 ----------

class BudgetConversionTests(unittest.TestCase):
    def setUp(self):
        self.orderbook = {
            'asks': [{'price': 75100, 'qty': 200}, {'price': 75200, 'qty': 150}],
            'bids': [{'price': 75000, 'qty': 180}, {'price': 74900, 'qty': 220}],
        }

    def test_buy_uses_ask1_floor_plus_one_under_threshold(self):
        # 1,000,000 / 75,100 = 13.31... → floor 13 + bump → 14주, 초과액 51,400원
        # 75,100원 ≤ 30만원 → BUY +1 정책 적용
        r = guards.convert_budget_to_qty('BUY', 1_000_000, self.orderbook)
        self.assertTrue(r['ok'])
        self.assertEqual(r['qty'], 14)
        self.assertEqual(r['ref_price'], 75100)
        self.assertTrue(r['bumped'])
        self.assertEqual(r['remainder'], 1_000_000 - 14 * 75100)  # 음수
        self.assertLess(r['remainder'], 0)

    def test_sell_uses_bid1_floor(self):
        # SELL 은 ref_price 가 30만원 이하여도 bump 안 함
        # 500,000 / 75,000 = 6.66... → 6주
        r = guards.convert_budget_to_qty('SELL', 500_000, self.orderbook)
        self.assertTrue(r['ok'])
        self.assertEqual(r['qty'], 6)
        self.assertEqual(r['ref_price'], 75000)
        self.assertFalse(r['bumped'])
        self.assertEqual(r['remainder'], 500_000 - 6 * 75000)

    def test_budget_too_small_rejects(self):
        r = guards.convert_budget_to_qty('BUY', 50_000, self.orderbook)
        self.assertFalse(r['ok'])
        self.assertEqual(r['code'], 'BUDGET_TOO_SMALL')

    def test_budget_zero_rejects(self):
        r = guards.convert_budget_to_qty('BUY', 0, self.orderbook)
        self.assertFalse(r['ok'])
        self.assertEqual(r['code'], 'BUDGET_INVALID')

    def test_budget_negative_rejects(self):
        r = guards.convert_budget_to_qty('BUY', -1000, self.orderbook)
        self.assertFalse(r['ok'])
        self.assertEqual(r['code'], 'BUDGET_INVALID')

    def test_no_orderbook_no_fallback_rejects(self):
        r = guards.convert_budget_to_qty('BUY', 1_000_000, None)
        self.assertFalse(r['ok'])
        self.assertEqual(r['code'], 'NO_ORDERBOOK')

    def test_empty_asks_no_fallback_rejects(self):
        ob = {'asks': [], 'bids': self.orderbook['bids']}
        r = guards.convert_budget_to_qty('BUY', 1_000_000, ob)
        self.assertFalse(r['ok'])
        self.assertEqual(r['code'], 'NO_ORDERBOOK')

    def test_no_orderbook_uses_fallback_price(self):
        # fallback 도 ≤ 30만원 이면 bump 적용
        r = guards.convert_budget_to_qty('BUY', 1_000_000, None, fallback_price=75050)
        self.assertTrue(r['ok'])
        self.assertEqual(r['qty'], 14)  # floor 13 + bump
        self.assertEqual(r['ref_price'], 75050)
        self.assertEqual(r['source'], 'fallback')
        self.assertTrue(r['bumped'])

    def test_empty_asks_uses_fallback_price(self):
        ob = {'asks': [], 'bids': self.orderbook['bids']}
        r = guards.convert_budget_to_qty('BUY', 1_000_000, ob, fallback_price=75050)
        self.assertTrue(r['ok'])
        self.assertEqual(r['source'], 'fallback')
        self.assertEqual(r['ref_price'], 75050)

    def test_orderbook_takes_priority_over_fallback(self):
        r = guards.convert_budget_to_qty('BUY', 1_000_000, self.orderbook, fallback_price=80000)
        self.assertTrue(r['ok'])
        self.assertEqual(r['source'], 'orderbook')
        self.assertEqual(r['ref_price'], 75100)

    def test_fallback_budget_too_small(self):
        r = guards.convert_budget_to_qty('BUY', 50_000, None, fallback_price=75050)
        self.assertFalse(r['ok'])
        self.assertEqual(r['code'], 'BUDGET_TOO_SMALL')
        self.assertIn('현재가', r['message'])

    def test_exact_multiple_no_remainder(self):
        # 75,100 × 10 = 751,000 — remainder=0 이면 bump 안 함
        r = guards.convert_budget_to_qty('BUY', 751_000, self.orderbook)
        self.assertTrue(r['ok'])
        self.assertEqual(r['qty'], 10)
        self.assertEqual(r['remainder'], 0)
        self.assertFalse(r['bumped'])

    def test_just_below_one_share(self):
        r = guards.convert_budget_to_qty('BUY', 75_099, self.orderbook)
        self.assertFalse(r['ok'])
        self.assertEqual(r['code'], 'BUDGET_TOO_SMALL')

    def test_exactly_one_share(self):
        # remainder=0 이면 bump 안 함
        r = guards.convert_budget_to_qty('BUY', 75_100, self.orderbook)
        self.assertTrue(r['ok'])
        self.assertEqual(r['qty'], 1)
        self.assertEqual(r['remainder'], 0)
        self.assertFalse(r['bumped'])

    def test_buy_no_bump_when_ref_price_exactly_at_threshold(self):
        # ref_price 정확히 30만원 → bump 적용 (≤ 경계 inclusive)
        ob = {'asks': [{'price': 300_000, 'qty': 50}],
              'bids': [{'price': 299_500, 'qty': 50}]}
        r = guards.convert_budget_to_qty('BUY', 1_000_000, ob)
        self.assertTrue(r['ok'])
        self.assertEqual(r['qty'], 4)  # floor 3 + bump
        self.assertTrue(r['bumped'])

    def test_buy_no_bump_when_ref_price_above_threshold(self):
        # ref_price 30만원 초과 → bump 미적용
        ob = {'asks': [{'price': 300_001, 'qty': 50}],
              'bids': [{'price': 299_500, 'qty': 50}]}
        r = guards.convert_budget_to_qty('BUY', 1_000_000, ob)
        self.assertTrue(r['ok'])
        self.assertEqual(r['qty'], 3)  # floor 그대로
        self.assertFalse(r['bumped'])
        self.assertGreater(r['remainder'], 0)


if __name__ == '__main__':
    unittest.main(verbosity=2)
