Merge pull request '[FX-ACCOUNTING] TKT-P1-001 USD/KRW 버퍼 진입 제한' (#288) from feature/issue-tkt-p1-001-fx-buffer-guard into feature/v3-session-policy-stream
This commit was merged in pull request #288.
This commit is contained in:
@@ -59,6 +59,7 @@ class Settings(BaseSettings):
|
||||
# KIS VTS overseas balance API returns errors for most accounts.
|
||||
# This value is used as a fallback when the balance API returns 0 in paper mode.
|
||||
PAPER_OVERSEAS_CASH: float = Field(default=50000.0, ge=0.0)
|
||||
USD_BUFFER_MIN: float = Field(default=1000.0, ge=0.0)
|
||||
|
||||
# Trading frequency mode (daily = batch API calls, realtime = per-stock calls)
|
||||
TRADE_MODE: str = Field(default="daily", pattern="^(daily|realtime)$")
|
||||
|
||||
56
src/main.py
56
src/main.py
@@ -429,6 +429,26 @@ def _determine_order_quantity(
|
||||
return quantity
|
||||
|
||||
|
||||
def _should_block_overseas_buy_for_fx_buffer(
|
||||
*,
|
||||
market: MarketInfo,
|
||||
action: str,
|
||||
total_cash: float,
|
||||
order_amount: float,
|
||||
settings: Settings | None,
|
||||
) -> tuple[bool, float, float]:
|
||||
if (
|
||||
market.is_domestic
|
||||
or not market.code.startswith("US")
|
||||
or action != "BUY"
|
||||
or settings is None
|
||||
):
|
||||
return False, total_cash - order_amount, 0.0
|
||||
remaining = total_cash - order_amount
|
||||
required = settings.USD_BUFFER_MIN
|
||||
return remaining < required, remaining, required
|
||||
|
||||
|
||||
async def build_overseas_symbol_universe(
|
||||
db_conn: Any,
|
||||
overseas_broker: OverseasBroker,
|
||||
@@ -1292,6 +1312,24 @@ async def trading_cycle(
|
||||
)
|
||||
return
|
||||
order_amount = current_price * quantity
|
||||
fx_blocked, remaining_cash, required_buffer = _should_block_overseas_buy_for_fx_buffer(
|
||||
market=market,
|
||||
action=decision.action,
|
||||
total_cash=total_cash,
|
||||
order_amount=order_amount,
|
||||
settings=settings,
|
||||
)
|
||||
if fx_blocked:
|
||||
logger.warning(
|
||||
"Skip BUY %s (%s): FX buffer guard (remaining=%.2f, required=%.2f, cash=%.2f, order=%.2f)",
|
||||
stock_code,
|
||||
market.name,
|
||||
remaining_cash,
|
||||
required_buffer,
|
||||
total_cash,
|
||||
order_amount,
|
||||
)
|
||||
return
|
||||
|
||||
# 4. Check BUY cooldown (set when a prior BUY failed due to insufficient balance)
|
||||
if decision.action == "BUY" and buy_cooldown is not None:
|
||||
@@ -2360,6 +2398,24 @@ async def run_daily_session(
|
||||
)
|
||||
continue
|
||||
order_amount = stock_data["current_price"] * quantity
|
||||
fx_blocked, remaining_cash, required_buffer = _should_block_overseas_buy_for_fx_buffer(
|
||||
market=market,
|
||||
action=decision.action,
|
||||
total_cash=total_cash,
|
||||
order_amount=order_amount,
|
||||
settings=settings,
|
||||
)
|
||||
if fx_blocked:
|
||||
logger.warning(
|
||||
"Skip BUY %s (%s): FX buffer guard (remaining=%.2f, required=%.2f, cash=%.2f, order=%.2f)",
|
||||
stock_code,
|
||||
market.name,
|
||||
remaining_cash,
|
||||
required_buffer,
|
||||
total_cash,
|
||||
order_amount,
|
||||
)
|
||||
continue
|
||||
|
||||
# Check BUY cooldown (insufficient balance)
|
||||
if decision.action == "BUY":
|
||||
|
||||
@@ -15,6 +15,7 @@ from src.evolution.scorecard import DailyScorecard
|
||||
from src.logging.decision_logger import DecisionLogger
|
||||
from src.main import (
|
||||
KILL_SWITCH,
|
||||
_should_block_overseas_buy_for_fx_buffer,
|
||||
_trigger_emergency_kill_switch,
|
||||
_apply_dashboard_flag,
|
||||
_determine_order_quantity,
|
||||
@@ -3690,6 +3691,81 @@ class TestOverseasBrokerIntegration:
|
||||
# DB도 브로커도 보유 없음 → BUY 주문이 실행되어야 함 (회귀 테스트)
|
||||
overseas_broker.send_overseas_order.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overseas_buy_blocked_by_usd_buffer_guard(self) -> None:
|
||||
"""Overseas BUY must be blocked when USD buffer would be breached."""
|
||||
db_conn = init_db(":memory:")
|
||||
|
||||
overseas_broker = MagicMock()
|
||||
overseas_broker.get_overseas_price = AsyncMock(
|
||||
return_value={"output": {"last": "182.50"}}
|
||||
)
|
||||
overseas_broker.get_overseas_balance = AsyncMock(
|
||||
return_value={
|
||||
"output1": [],
|
||||
"output2": [
|
||||
{
|
||||
"frcr_evlu_tota": "50000.00",
|
||||
"frcr_buy_amt_smtl": "0.00",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
overseas_broker.get_overseas_buying_power = AsyncMock(
|
||||
return_value={"output": {"ovrs_ord_psbl_amt": "50000.00"}}
|
||||
)
|
||||
overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "주문접수"})
|
||||
|
||||
engine = MagicMock(spec=ScenarioEngine)
|
||||
engine.evaluate = MagicMock(return_value=_make_buy_match("AAPL"))
|
||||
|
||||
market = MagicMock()
|
||||
market.name = "NASDAQ"
|
||||
market.code = "US_NASDAQ"
|
||||
market.exchange_code = "NASD"
|
||||
market.is_domestic = False
|
||||
|
||||
telegram = MagicMock()
|
||||
telegram.notify_trade_execution = AsyncMock()
|
||||
telegram.notify_fat_finger = AsyncMock()
|
||||
telegram.notify_circuit_breaker = AsyncMock()
|
||||
telegram.notify_scenario_matched = AsyncMock()
|
||||
|
||||
decision_logger = MagicMock()
|
||||
decision_logger.log_decision = MagicMock(return_value="decision-id")
|
||||
|
||||
settings = MagicMock()
|
||||
settings.POSITION_SIZING_ENABLED = False
|
||||
settings.CONFIDENCE_THRESHOLD = 80
|
||||
settings.USD_BUFFER_MIN = 49900.0
|
||||
settings.MODE = "paper"
|
||||
settings.PAPER_OVERSEAS_CASH = 50000.0
|
||||
|
||||
await trading_cycle(
|
||||
broker=MagicMock(),
|
||||
overseas_broker=overseas_broker,
|
||||
scenario_engine=engine,
|
||||
playbook=_make_playbook(market="US"),
|
||||
risk=MagicMock(),
|
||||
db_conn=db_conn,
|
||||
decision_logger=decision_logger,
|
||||
context_store=MagicMock(
|
||||
get_latest_timeframe=MagicMock(return_value=None),
|
||||
set_context=MagicMock(),
|
||||
),
|
||||
criticality_assessor=MagicMock(
|
||||
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||
get_timeout=MagicMock(return_value=5.0),
|
||||
),
|
||||
telegram=telegram,
|
||||
market=market,
|
||||
stock_code="AAPL",
|
||||
scan_candidates={},
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
overseas_broker.send_overseas_order.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _retry_connection — unit tests (issue #209)
|
||||
@@ -3723,7 +3799,6 @@ class TestRetryConnection:
|
||||
with patch("src.main.asyncio.sleep") as mock_sleep:
|
||||
mock_sleep.return_value = None
|
||||
result = await _retry_connection(flaky, label="flaky")
|
||||
|
||||
assert result == "ok"
|
||||
assert call_count == 2
|
||||
mock_sleep.assert_called_once()
|
||||
@@ -3778,6 +3853,48 @@ class TestRetryConnection:
|
||||
assert call_count == 1 # No retry for non-ConnectionError
|
||||
|
||||
|
||||
def test_fx_buffer_guard_applies_only_to_us_and_respects_boundary() -> None:
|
||||
settings = MagicMock()
|
||||
settings.USD_BUFFER_MIN = 1000.0
|
||||
|
||||
us_market = MagicMock()
|
||||
us_market.is_domestic = False
|
||||
us_market.code = "US_NASDAQ"
|
||||
|
||||
blocked, remaining, required = _should_block_overseas_buy_for_fx_buffer(
|
||||
market=us_market,
|
||||
action="BUY",
|
||||
total_cash=5000.0,
|
||||
order_amount=4001.0,
|
||||
settings=settings,
|
||||
)
|
||||
assert blocked
|
||||
assert remaining == 999.0
|
||||
assert required == 1000.0
|
||||
|
||||
blocked_eq, _, _ = _should_block_overseas_buy_for_fx_buffer(
|
||||
market=us_market,
|
||||
action="BUY",
|
||||
total_cash=5000.0,
|
||||
order_amount=4000.0,
|
||||
settings=settings,
|
||||
)
|
||||
assert not blocked_eq
|
||||
|
||||
jp_market = MagicMock()
|
||||
jp_market.is_domestic = False
|
||||
jp_market.code = "JP"
|
||||
blocked_jp, _, required_jp = _should_block_overseas_buy_for_fx_buffer(
|
||||
market=jp_market,
|
||||
action="BUY",
|
||||
total_cash=5000.0,
|
||||
order_amount=4500.0,
|
||||
settings=settings,
|
||||
)
|
||||
assert not blocked_jp
|
||||
assert required_jp == 0.0
|
||||
|
||||
|
||||
# run_daily_session — daily CB baseline (daily_start_eval) tests (issue #207)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user