Compare commits
9 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4da22b10eb | ||
| c920b257b6 | |||
| 9927bfa13e | |||
|
|
aceba86186 | ||
|
|
b961c53a92 | ||
| 76a7ee7cdb | |||
|
|
77577f3f4d | ||
| 17112b864a | |||
|
|
39b9f179f4 |
@@ -175,7 +175,7 @@ class SmartVolatilityScanner:
|
|||||||
liquidity_score = volume_rank_bonus.get(stock_code, 0.0)
|
liquidity_score = volume_rank_bonus.get(stock_code, 0.0)
|
||||||
score = min(100.0, volatility_score + liquidity_score)
|
score = min(100.0, volatility_score + liquidity_score)
|
||||||
signal = "momentum" if change_rate >= 0 else "oversold"
|
signal = "momentum" if change_rate >= 0 else "oversold"
|
||||||
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 4.0)))
|
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 2.0)))
|
||||||
|
|
||||||
candidates.append(
|
candidates.append(
|
||||||
ScanCandidate(
|
ScanCandidate(
|
||||||
@@ -282,7 +282,7 @@ class SmartVolatilityScanner:
|
|||||||
liquidity_score = volume_rank_bonus.get(stock_code, 0.0)
|
liquidity_score = volume_rank_bonus.get(stock_code, 0.0)
|
||||||
score = min(100.0, volatility_score + liquidity_score)
|
score = min(100.0, volatility_score + liquidity_score)
|
||||||
signal = "momentum" if change_rate >= 0 else "oversold"
|
signal = "momentum" if change_rate >= 0 else "oversold"
|
||||||
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 4.0)))
|
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 2.0)))
|
||||||
candidates.append(
|
candidates.append(
|
||||||
ScanCandidate(
|
ScanCandidate(
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
@@ -338,7 +338,7 @@ class SmartVolatilityScanner:
|
|||||||
|
|
||||||
score = min(volatility_pct / 10.0, 1.0) * 100.0
|
score = min(volatility_pct / 10.0, 1.0) * 100.0
|
||||||
signal = "momentum" if change_rate >= 0 else "oversold"
|
signal = "momentum" if change_rate >= 0 else "oversold"
|
||||||
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 4.0)))
|
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 2.0)))
|
||||||
candidates.append(
|
candidates.append(
|
||||||
ScanCandidate(
|
ScanCandidate(
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
|
|||||||
78
src/main.py
78
src/main.py
@@ -81,6 +81,7 @@ def safe_float(value: str | float | None, default: float = 0.0) -> float:
|
|||||||
TRADE_INTERVAL_SECONDS = 60
|
TRADE_INTERVAL_SECONDS = 60
|
||||||
SCAN_INTERVAL_SECONDS = 60 # Scan markets every 60 seconds
|
SCAN_INTERVAL_SECONDS = 60 # Scan markets every 60 seconds
|
||||||
MAX_CONNECTION_RETRIES = 3
|
MAX_CONNECTION_RETRIES = 3
|
||||||
|
_BUY_COOLDOWN_SECONDS = 600 # 10-minute cooldown after insufficient-balance rejection
|
||||||
|
|
||||||
# Daily trading mode constants (for Free tier API efficiency)
|
# Daily trading mode constants (for Free tier API efficiency)
|
||||||
DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day
|
DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day
|
||||||
@@ -298,6 +299,7 @@ async def trading_cycle(
|
|||||||
stock_code: str,
|
stock_code: str,
|
||||||
scan_candidates: dict[str, dict[str, ScanCandidate]],
|
scan_candidates: dict[str, dict[str, ScanCandidate]],
|
||||||
settings: Settings | None = None,
|
settings: Settings | None = None,
|
||||||
|
buy_cooldown: dict[str, float] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Execute one trading cycle for a single stock."""
|
"""Execute one trading cycle for a single stock."""
|
||||||
cycle_start_time = asyncio.get_event_loop().time()
|
cycle_start_time = asyncio.get_event_loop().time()
|
||||||
@@ -642,8 +644,28 @@ async def trading_cycle(
|
|||||||
return
|
return
|
||||||
order_amount = current_price * quantity
|
order_amount = current_price * quantity
|
||||||
|
|
||||||
# 4. Risk check BEFORE order
|
# 4. Check BUY cooldown (set when a prior BUY failed due to insufficient balance)
|
||||||
|
if decision.action == "BUY" and buy_cooldown is not None:
|
||||||
|
cooldown_key = f"{market.code}:{stock_code}"
|
||||||
|
cooldown_until = buy_cooldown.get(cooldown_key, 0.0)
|
||||||
|
now = asyncio.get_event_loop().time()
|
||||||
|
if now < cooldown_until:
|
||||||
|
remaining = int(cooldown_until - now)
|
||||||
|
logger.info(
|
||||||
|
"Skip BUY %s (%s): insufficient-balance cooldown active (%ds remaining)",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
remaining,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 5a. Risk check BEFORE order
|
||||||
|
# SELL orders do not consume cash (they receive it), so fat-finger check
|
||||||
|
# is skipped for SELLs — only circuit breaker applies.
|
||||||
try:
|
try:
|
||||||
|
if decision.action == "SELL":
|
||||||
|
risk.check_circuit_breaker(pnl_pct)
|
||||||
|
else:
|
||||||
risk.validate_order(
|
risk.validate_order(
|
||||||
current_pnl_pct=pnl_pct,
|
current_pnl_pct=pnl_pct,
|
||||||
order_amount=order_amount,
|
order_amount=order_amount,
|
||||||
@@ -690,11 +712,23 @@ async def trading_cycle(
|
|||||||
# Check if KIS rejected the order (rt_cd != "0")
|
# Check if KIS rejected the order (rt_cd != "0")
|
||||||
if result.get("rt_cd", "") != "0":
|
if result.get("rt_cd", "") != "0":
|
||||||
order_succeeded = False
|
order_succeeded = False
|
||||||
|
msg1 = result.get("msg1") or ""
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Overseas order not accepted for %s: rt_cd=%s msg=%s",
|
"Overseas order not accepted for %s: rt_cd=%s msg=%s",
|
||||||
stock_code,
|
stock_code,
|
||||||
result.get("rt_cd"),
|
result.get("rt_cd"),
|
||||||
result.get("msg1"),
|
msg1,
|
||||||
|
)
|
||||||
|
# Set BUY cooldown when the rejection is due to insufficient balance
|
||||||
|
if decision.action == "BUY" and buy_cooldown is not None and "주문가능금액" in msg1:
|
||||||
|
cooldown_key = f"{market.code}:{stock_code}"
|
||||||
|
buy_cooldown[cooldown_key] = (
|
||||||
|
asyncio.get_event_loop().time() + _BUY_COOLDOWN_SECONDS
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"BUY cooldown set for %s: %.0fs (insufficient balance)",
|
||||||
|
stock_code,
|
||||||
|
_BUY_COOLDOWN_SECONDS,
|
||||||
)
|
)
|
||||||
logger.info("Order result: %s", result.get("msg1", "OK"))
|
logger.info("Order result: %s", result.get("msg1", "OK"))
|
||||||
|
|
||||||
@@ -803,6 +837,9 @@ async def run_daily_session(
|
|||||||
|
|
||||||
logger.info("Starting daily trading session for %d markets", len(open_markets))
|
logger.info("Starting daily trading session for %d markets", len(open_markets))
|
||||||
|
|
||||||
|
# BUY cooldown: prevents retrying stocks rejected for insufficient balance
|
||||||
|
daily_buy_cooldown: dict[str, float] = {} # "{market_code}:{stock_code}" -> expiry timestamp
|
||||||
|
|
||||||
# Process each open market
|
# Process each open market
|
||||||
for market in open_markets:
|
for market in open_markets:
|
||||||
# Use market-local date for playbook keying
|
# Use market-local date for playbook keying
|
||||||
@@ -1075,8 +1112,28 @@ async def run_daily_session(
|
|||||||
continue
|
continue
|
||||||
order_amount = stock_data["current_price"] * quantity
|
order_amount = stock_data["current_price"] * quantity
|
||||||
|
|
||||||
|
# Check BUY cooldown (insufficient balance)
|
||||||
|
if decision.action == "BUY":
|
||||||
|
daily_cooldown_key = f"{market.code}:{stock_code}"
|
||||||
|
daily_cooldown_until = daily_buy_cooldown.get(daily_cooldown_key, 0.0)
|
||||||
|
now = asyncio.get_event_loop().time()
|
||||||
|
if now < daily_cooldown_until:
|
||||||
|
remaining = int(daily_cooldown_until - now)
|
||||||
|
logger.info(
|
||||||
|
"Skip BUY %s (%s): insufficient-balance cooldown active (%ds remaining)",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
remaining,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
# Risk check
|
# Risk check
|
||||||
|
# SELL orders do not consume cash (they receive it), so fat-finger
|
||||||
|
# check is skipped for SELLs — only circuit breaker applies.
|
||||||
try:
|
try:
|
||||||
|
if decision.action == "SELL":
|
||||||
|
risk.check_circuit_breaker(pnl_pct)
|
||||||
|
else:
|
||||||
risk.validate_order(
|
risk.validate_order(
|
||||||
current_pnl_pct=pnl_pct,
|
current_pnl_pct=pnl_pct,
|
||||||
order_amount=order_amount,
|
order_amount=order_amount,
|
||||||
@@ -1131,11 +1188,22 @@ async def run_daily_session(
|
|||||||
)
|
)
|
||||||
if result.get("rt_cd", "") != "0":
|
if result.get("rt_cd", "") != "0":
|
||||||
order_succeeded = False
|
order_succeeded = False
|
||||||
|
daily_msg1 = result.get("msg1") or ""
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Overseas order not accepted for %s: rt_cd=%s msg=%s",
|
"Overseas order not accepted for %s: rt_cd=%s msg=%s",
|
||||||
stock_code,
|
stock_code,
|
||||||
result.get("rt_cd"),
|
result.get("rt_cd"),
|
||||||
result.get("msg1"),
|
daily_msg1,
|
||||||
|
)
|
||||||
|
if decision.action == "BUY" and "주문가능금액" in daily_msg1:
|
||||||
|
daily_cooldown_key = f"{market.code}:{stock_code}"
|
||||||
|
daily_buy_cooldown[daily_cooldown_key] = (
|
||||||
|
asyncio.get_event_loop().time() + _BUY_COOLDOWN_SECONDS
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"BUY cooldown set for %s: %.0fs (insufficient balance)",
|
||||||
|
stock_code,
|
||||||
|
_BUY_COOLDOWN_SECONDS,
|
||||||
)
|
)
|
||||||
logger.info("Order result: %s", result.get("msg1", "OK"))
|
logger.info("Order result: %s", result.get("msg1", "OK"))
|
||||||
|
|
||||||
@@ -1763,6 +1831,9 @@ async def run(settings: Settings) -> None:
|
|||||||
# Active stocks per market (dynamically discovered by scanner)
|
# Active stocks per market (dynamically discovered by scanner)
|
||||||
active_stocks: dict[str, list[str]] = {} # market_code -> [stock_codes]
|
active_stocks: dict[str, list[str]] = {} # market_code -> [stock_codes]
|
||||||
|
|
||||||
|
# BUY cooldown: prevents retrying a stock rejected for insufficient balance
|
||||||
|
buy_cooldown: dict[str, float] = {} # "{market_code}:{stock_code}" -> expiry timestamp
|
||||||
|
|
||||||
# Initialize latency control system
|
# Initialize latency control system
|
||||||
criticality_assessor = CriticalityAssessor(
|
criticality_assessor = CriticalityAssessor(
|
||||||
critical_pnl_threshold=-2.5, # Near circuit breaker at -3.0%
|
critical_pnl_threshold=-2.5, # Near circuit breaker at -3.0%
|
||||||
@@ -2105,6 +2176,7 @@ async def run(settings: Settings) -> None:
|
|||||||
stock_code,
|
stock_code,
|
||||||
scan_candidates,
|
scan_candidates,
|
||||||
settings,
|
settings,
|
||||||
|
buy_cooldown,
|
||||||
)
|
)
|
||||||
break # Success — exit retry loop
|
break # Success — exit retry loop
|
||||||
except CircuitBreakerTripped as exc:
|
except CircuitBreakerTripped as exc:
|
||||||
|
|||||||
@@ -604,6 +604,16 @@ class TelegramCommandHandler:
|
|||||||
async with session.post(url, json=payload) as resp:
|
async with session.post(url, json=payload) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
error_text = await resp.text()
|
error_text = await resp.text()
|
||||||
|
if resp.status == 409:
|
||||||
|
# Another bot instance is already polling — stop this poller entirely.
|
||||||
|
# Retrying would keep conflicting with the other instance.
|
||||||
|
self._running = False
|
||||||
|
logger.warning(
|
||||||
|
"Telegram conflict (409): another instance is already polling. "
|
||||||
|
"Disabling Telegram commands for this process. "
|
||||||
|
"Ensure only one instance of The Ouroboros is running at a time.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
logger.error(
|
logger.error(
|
||||||
"getUpdates API error (status=%d): %s", resp.status, error_text
|
"getUpdates API error (status=%d): %s", resp.status, error_text
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -631,6 +631,119 @@ class TestTradingCycleTelegramIntegration:
|
|||||||
# Verify no trade notification sent
|
# Verify no trade notification sent
|
||||||
mock_telegram.notify_trade_execution.assert_not_called()
|
mock_telegram.notify_trade_execution.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sell_skips_fat_finger_check(
|
||||||
|
self,
|
||||||
|
mock_broker: MagicMock,
|
||||||
|
mock_overseas_broker: MagicMock,
|
||||||
|
mock_scenario_engine: MagicMock,
|
||||||
|
mock_playbook: DayPlaybook,
|
||||||
|
mock_risk: MagicMock,
|
||||||
|
mock_db: MagicMock,
|
||||||
|
mock_decision_logger: MagicMock,
|
||||||
|
mock_context_store: MagicMock,
|
||||||
|
mock_criticality_assessor: MagicMock,
|
||||||
|
mock_telegram: MagicMock,
|
||||||
|
mock_market: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""SELL orders must not be blocked by fat-finger check.
|
||||||
|
|
||||||
|
Even if position value > 30% of cash (e.g. stop-loss on a large holding
|
||||||
|
with low remaining cash), the SELL should proceed — only circuit breaker
|
||||||
|
applies to SELLs.
|
||||||
|
"""
|
||||||
|
# SELL decision with held qty=100 shares @ 50,000 = 5,000,000
|
||||||
|
# cash = 5,000,000 → ratio = 100% which would normally trigger fat finger
|
||||||
|
mock_scenario_engine.evaluate = MagicMock(return_value=_make_sell_match())
|
||||||
|
mock_broker.get_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output1": [{"pdno": "005930", "ord_psbl_qty": "100"}],
|
||||||
|
"output2": [
|
||||||
|
{
|
||||||
|
"tot_evlu_amt": "10000000",
|
||||||
|
"dnca_tot_amt": "5000000",
|
||||||
|
"pchs_amt_smtl_amt": "5000000",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=mock_overseas_broker,
|
||||||
|
scenario_engine=mock_scenario_engine,
|
||||||
|
playbook=mock_playbook,
|
||||||
|
risk=mock_risk,
|
||||||
|
db_conn=mock_db,
|
||||||
|
decision_logger=mock_decision_logger,
|
||||||
|
context_store=mock_context_store,
|
||||||
|
criticality_assessor=mock_criticality_assessor,
|
||||||
|
telegram=mock_telegram,
|
||||||
|
market=mock_market,
|
||||||
|
stock_code="005930",
|
||||||
|
scan_candidates={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# validate_order (which includes fat finger) must NOT be called for SELL
|
||||||
|
mock_risk.validate_order.assert_not_called()
|
||||||
|
# check_circuit_breaker MUST be called for SELL
|
||||||
|
mock_risk.check_circuit_breaker.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sell_circuit_breaker_still_applies(
|
||||||
|
self,
|
||||||
|
mock_broker: MagicMock,
|
||||||
|
mock_overseas_broker: MagicMock,
|
||||||
|
mock_scenario_engine: MagicMock,
|
||||||
|
mock_playbook: DayPlaybook,
|
||||||
|
mock_risk: MagicMock,
|
||||||
|
mock_db: MagicMock,
|
||||||
|
mock_decision_logger: MagicMock,
|
||||||
|
mock_context_store: MagicMock,
|
||||||
|
mock_criticality_assessor: MagicMock,
|
||||||
|
mock_telegram: MagicMock,
|
||||||
|
mock_market: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""SELL orders must still respect the circuit breaker."""
|
||||||
|
mock_scenario_engine.evaluate = MagicMock(return_value=_make_sell_match())
|
||||||
|
mock_broker.get_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output1": [{"pdno": "005930", "ord_psbl_qty": "100"}],
|
||||||
|
"output2": [
|
||||||
|
{
|
||||||
|
"tot_evlu_amt": "10000000",
|
||||||
|
"dnca_tot_amt": "5000000",
|
||||||
|
"pchs_amt_smtl_amt": "5000000",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mock_risk.check_circuit_breaker.side_effect = CircuitBreakerTripped(
|
||||||
|
pnl_pct=-4.0, threshold=-3.0
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
with pytest.raises(CircuitBreakerTripped):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=mock_overseas_broker,
|
||||||
|
scenario_engine=mock_scenario_engine,
|
||||||
|
playbook=mock_playbook,
|
||||||
|
risk=mock_risk,
|
||||||
|
db_conn=mock_db,
|
||||||
|
decision_logger=mock_decision_logger,
|
||||||
|
context_store=mock_context_store,
|
||||||
|
criticality_assessor=mock_criticality_assessor,
|
||||||
|
telegram=mock_telegram,
|
||||||
|
market=mock_market,
|
||||||
|
stock_code="005930",
|
||||||
|
scan_candidates={},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_risk.check_circuit_breaker.assert_called_once()
|
||||||
|
mock_risk.validate_order.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestRunFunctionTelegramIntegration:
|
class TestRunFunctionTelegramIntegration:
|
||||||
"""Test telegram notifications in run function."""
|
"""Test telegram notifications in run function."""
|
||||||
@@ -2217,6 +2330,245 @@ def test_start_dashboard_server_returns_none_when_uvicorn_missing() -> None:
|
|||||||
assert thread is None
|
assert thread is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# BUY cooldown tests (#179)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuyCooldown:
|
||||||
|
"""Tests for BUY cooldown after insufficient-balance rejection."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_broker(self) -> MagicMock:
|
||||||
|
broker = MagicMock()
|
||||||
|
broker.get_current_price = AsyncMock(return_value=(100.0, 1.0, 0.0))
|
||||||
|
broker.get_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output2": [{"tot_evlu_amt": "1000000", "dnca_tot_amt": "500000",
|
||||||
|
"pchs_amt_smtl_amt": "500000"}]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||||
|
return broker
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_market(self) -> MagicMock:
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "Korea"
|
||||||
|
market.code = "KR"
|
||||||
|
market.exchange_code = "KRX"
|
||||||
|
market.is_domestic = True
|
||||||
|
return market
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_overseas_market(self) -> MagicMock:
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "NASDAQ"
|
||||||
|
market.code = "US_NASDAQ"
|
||||||
|
market.exchange_code = "NAS"
|
||||||
|
market.is_domestic = False
|
||||||
|
return market
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_overseas_broker(self) -> MagicMock:
|
||||||
|
broker = MagicMock()
|
||||||
|
broker.get_overseas_price = AsyncMock(
|
||||||
|
return_value={"output": {"last": "1.0", "rate": "0.0",
|
||||||
|
"high": "1.05", "low": "0.95", "tvol": "1000000"}}
|
||||||
|
)
|
||||||
|
broker.get_overseas_balance = AsyncMock(return_value={
|
||||||
|
"output1": [],
|
||||||
|
"output2": [{"frcr_dncl_amt_2": "50000", "frcr_evlu_tota": "50000",
|
||||||
|
"frcr_buy_amt_smtl": "0"}],
|
||||||
|
})
|
||||||
|
broker.send_overseas_order = AsyncMock(
|
||||||
|
return_value={"rt_cd": "1", "msg1": "모의투자 주문가능금액이 부족합니다."}
|
||||||
|
)
|
||||||
|
return broker
|
||||||
|
|
||||||
|
def _make_buy_match_overseas(self, stock_code: str = "MLECW") -> ScenarioMatch:
|
||||||
|
return ScenarioMatch(
|
||||||
|
stock_code=stock_code,
|
||||||
|
matched_scenario=None,
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=85,
|
||||||
|
rationale="Test buy",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cooldown_set_on_insufficient_balance(
|
||||||
|
self, mock_broker: MagicMock, mock_overseas_broker: MagicMock,
|
||||||
|
mock_overseas_market: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""BUY cooldown entry is created after 주문가능금액 rejection."""
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=self._make_buy_match_overseas("MLECW"))
|
||||||
|
buy_cooldown: dict[str, float] = {}
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=mock_overseas_broker,
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=_make_playbook("US_NASDAQ"),
|
||||||
|
risk=MagicMock(),
|
||||||
|
db_conn=MagicMock(),
|
||||||
|
decision_logger=MagicMock(),
|
||||||
|
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=MagicMock(
|
||||||
|
notify_trade_execution=AsyncMock(),
|
||||||
|
notify_fat_finger=AsyncMock(),
|
||||||
|
notify_circuit_breaker=AsyncMock(),
|
||||||
|
notify_scenario_matched=AsyncMock(),
|
||||||
|
),
|
||||||
|
market=mock_overseas_market,
|
||||||
|
stock_code="MLECW",
|
||||||
|
scan_candidates={},
|
||||||
|
buy_cooldown=buy_cooldown,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "US_NASDAQ:MLECW" in buy_cooldown
|
||||||
|
assert buy_cooldown["US_NASDAQ:MLECW"] > 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cooldown_skips_buy(
|
||||||
|
self, mock_broker: MagicMock, mock_overseas_broker: MagicMock,
|
||||||
|
mock_overseas_market: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""BUY is skipped when cooldown is active for the stock."""
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=self._make_buy_match_overseas("MLECW"))
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
# Set an active cooldown (expires far in the future)
|
||||||
|
buy_cooldown: dict[str, float] = {
|
||||||
|
"US_NASDAQ:MLECW": asyncio.get_event_loop().time() + 600
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=mock_overseas_broker,
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=_make_playbook("US_NASDAQ"),
|
||||||
|
risk=MagicMock(),
|
||||||
|
db_conn=MagicMock(),
|
||||||
|
decision_logger=MagicMock(),
|
||||||
|
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=MagicMock(
|
||||||
|
notify_trade_execution=AsyncMock(),
|
||||||
|
notify_fat_finger=AsyncMock(),
|
||||||
|
notify_circuit_breaker=AsyncMock(),
|
||||||
|
notify_scenario_matched=AsyncMock(),
|
||||||
|
),
|
||||||
|
market=mock_overseas_market,
|
||||||
|
stock_code="MLECW",
|
||||||
|
scan_candidates={},
|
||||||
|
buy_cooldown=buy_cooldown,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Order should NOT have been sent
|
||||||
|
mock_overseas_broker.send_overseas_order.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cooldown_not_set_on_other_errors(
|
||||||
|
self, mock_broker: MagicMock, mock_overseas_market: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Cooldown is NOT set for non-balance-related rejections."""
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=self._make_buy_match_overseas("MLECW"))
|
||||||
|
# Different rejection reason
|
||||||
|
overseas_broker = MagicMock()
|
||||||
|
overseas_broker.get_overseas_price = AsyncMock(
|
||||||
|
return_value={"output": {"last": "1.0", "rate": "0.0",
|
||||||
|
"high": "1.05", "low": "0.95", "tvol": "1000000"}}
|
||||||
|
)
|
||||||
|
overseas_broker.get_overseas_balance = AsyncMock(return_value={
|
||||||
|
"output1": [],
|
||||||
|
"output2": [{"frcr_dncl_amt_2": "50000", "frcr_evlu_tota": "50000",
|
||||||
|
"frcr_buy_amt_smtl": "0"}],
|
||||||
|
})
|
||||||
|
overseas_broker.send_overseas_order = AsyncMock(
|
||||||
|
return_value={"rt_cd": "1", "msg1": "기타 오류 메시지"}
|
||||||
|
)
|
||||||
|
buy_cooldown: dict[str, float] = {}
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=overseas_broker,
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=_make_playbook("US_NASDAQ"),
|
||||||
|
risk=MagicMock(),
|
||||||
|
db_conn=MagicMock(),
|
||||||
|
decision_logger=MagicMock(),
|
||||||
|
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=MagicMock(
|
||||||
|
notify_trade_execution=AsyncMock(),
|
||||||
|
notify_fat_finger=AsyncMock(),
|
||||||
|
notify_circuit_breaker=AsyncMock(),
|
||||||
|
notify_scenario_matched=AsyncMock(),
|
||||||
|
),
|
||||||
|
market=mock_overseas_market,
|
||||||
|
stock_code="MLECW",
|
||||||
|
scan_candidates={},
|
||||||
|
buy_cooldown=buy_cooldown,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cooldown should NOT be set for non-balance errors
|
||||||
|
assert "US_NASDAQ:MLECW" not in buy_cooldown
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_cooldown_param_still_works(
|
||||||
|
self, mock_broker: MagicMock, mock_overseas_broker: MagicMock,
|
||||||
|
mock_overseas_market: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""trading_cycle works normally when buy_cooldown is None (default)."""
|
||||||
|
engine = MagicMock(spec=ScenarioEngine)
|
||||||
|
engine.evaluate = MagicMock(return_value=self._make_buy_match_overseas("MLECW"))
|
||||||
|
|
||||||
|
with patch("src.main.log_trade"):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=mock_overseas_broker,
|
||||||
|
scenario_engine=engine,
|
||||||
|
playbook=_make_playbook("US_NASDAQ"),
|
||||||
|
risk=MagicMock(),
|
||||||
|
db_conn=MagicMock(),
|
||||||
|
decision_logger=MagicMock(),
|
||||||
|
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=MagicMock(
|
||||||
|
notify_trade_execution=AsyncMock(),
|
||||||
|
notify_fat_finger=AsyncMock(),
|
||||||
|
notify_circuit_breaker=AsyncMock(),
|
||||||
|
notify_scenario_matched=AsyncMock(),
|
||||||
|
),
|
||||||
|
market=mock_overseas_market,
|
||||||
|
stock_code="MLECW",
|
||||||
|
scan_candidates={},
|
||||||
|
# buy_cooldown not passed → defaults to None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should attempt the order (and fail), but not crash
|
||||||
|
mock_overseas_broker.send_overseas_order.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# market_outlook BUY confidence threshold tests (#173)
|
# market_outlook BUY confidence threshold tests (#173)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -350,6 +350,42 @@ class TestSmartVolatilityScanner:
|
|||||||
assert [c.stock_code for c in candidates] == ["ABCD"]
|
assert [c.stock_code for c in candidates] == ["ABCD"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestImpliedRSIFormula:
|
||||||
|
"""Test the implied_rsi formula in SmartVolatilityScanner (issue #181)."""
|
||||||
|
|
||||||
|
def test_neutral_change_gives_neutral_rsi(self) -> None:
|
||||||
|
"""0% change → implied_rsi = 50 (neutral)."""
|
||||||
|
# formula: 50 + (change_rate * 2.0)
|
||||||
|
rsi = max(0.0, min(100.0, 50.0 + (0.0 * 2.0)))
|
||||||
|
assert rsi == 50.0
|
||||||
|
|
||||||
|
def test_10pct_change_gives_rsi_70(self) -> None:
|
||||||
|
"""10% upward change → implied_rsi = 70 (momentum signal)."""
|
||||||
|
rsi = max(0.0, min(100.0, 50.0 + (10.0 * 2.0)))
|
||||||
|
assert rsi == 70.0
|
||||||
|
|
||||||
|
def test_minus_10pct_gives_rsi_30(self) -> None:
|
||||||
|
"""-10% change → implied_rsi = 30 (oversold signal)."""
|
||||||
|
rsi = max(0.0, min(100.0, 50.0 + (-10.0 * 2.0)))
|
||||||
|
assert rsi == 30.0
|
||||||
|
|
||||||
|
def test_saturation_at_25pct(self) -> None:
|
||||||
|
"""Saturation occurs at >=25% change (not 12.5% as with old coefficient 4.0)."""
|
||||||
|
rsi_12pct = max(0.0, min(100.0, 50.0 + (12.5 * 2.0)))
|
||||||
|
rsi_25pct = max(0.0, min(100.0, 50.0 + (25.0 * 2.0)))
|
||||||
|
rsi_30pct = max(0.0, min(100.0, 50.0 + (30.0 * 2.0)))
|
||||||
|
# At 12.5% change: RSI = 75 (not 100, unlike old formula)
|
||||||
|
assert rsi_12pct == 75.0
|
||||||
|
# At 25%+ saturation
|
||||||
|
assert rsi_25pct == 100.0
|
||||||
|
assert rsi_30pct == 100.0 # Capped
|
||||||
|
|
||||||
|
def test_negative_saturation(self) -> None:
|
||||||
|
"""Saturation at -25% gives RSI = 0."""
|
||||||
|
rsi = max(0.0, min(100.0, 50.0 + (-25.0 * 2.0)))
|
||||||
|
assert rsi == 0.0
|
||||||
|
|
||||||
|
|
||||||
class TestRSICalculation:
|
class TestRSICalculation:
|
||||||
"""Test RSI calculation in VolatilityAnalyzer."""
|
"""Test RSI calculation in VolatilityAnalyzer."""
|
||||||
|
|
||||||
|
|||||||
@@ -876,6 +876,54 @@ class TestGetUpdates:
|
|||||||
|
|
||||||
assert updates == []
|
assert updates == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_updates_409_stops_polling(self) -> None:
|
||||||
|
"""409 Conflict response stops the poller (_running = False) and returns empty list."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
handler._running = True # simulate active poller
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 409
|
||||||
|
mock_resp.text = AsyncMock(
|
||||||
|
return_value='{"ok":false,"error_code":409,"description":"Conflict"}'
|
||||||
|
)
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
|
||||||
|
updates = await handler._get_updates()
|
||||||
|
|
||||||
|
assert updates == []
|
||||||
|
assert handler._running is False # poller stopped
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_loop_exits_after_409(self) -> None:
|
||||||
|
"""_poll_loop exits naturally after _running is set to False by a 409 response."""
|
||||||
|
import asyncio as _asyncio
|
||||||
|
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def mock_get_updates_409() -> list[dict]:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
# Simulate 409 stopping the poller
|
||||||
|
handler._running = False
|
||||||
|
return []
|
||||||
|
|
||||||
|
handler._get_updates = mock_get_updates_409 # type: ignore[method-assign]
|
||||||
|
|
||||||
|
handler._running = True
|
||||||
|
task = _asyncio.create_task(handler._poll_loop())
|
||||||
|
await _asyncio.wait_for(task, timeout=2.0)
|
||||||
|
|
||||||
|
# _get_updates called exactly once, then loop exited
|
||||||
|
assert call_count == 1
|
||||||
|
assert handler._running is False
|
||||||
|
|
||||||
|
|
||||||
class TestCommandWithArgs:
|
class TestCommandWithArgs:
|
||||||
"""Test register_command_with_args and argument dispatch."""
|
"""Test register_command_with_args and argument dispatch."""
|
||||||
|
|||||||
Reference in New Issue
Block a user