Compare commits

...

5 Commits

Author SHA1 Message Date
agentson
4da22b10eb fix: SELL 주문에서 Fat Finger 오탐 수정 — 손절/익절 차단 버그 (#187)
Some checks failed
CI / test (pull_request) Has been cancelled
SELL 주문은 현금을 소비하지 않고 받는 것이므로 Fat Finger 체크 대상이
아님. 포지션 가치가 잔여 현금의 30%를 초과해도 SELL은 정상 실행돼야 함.

- realtime/daily 사이클 두 곳 모두 수정
- SELL: check_circuit_breaker만 호출 (Fat Finger 스킵)
- BUY: 기존대로 validate_order 호출 (Fat Finger + Circuit Breaker)
- 테스트 2개 추가: SELL Fat Finger 스킵, SELL 서킷브레이커 적용 확인

재현 사례 (2026-02-21):
  JELD stop-loss -6.20% → FAT FINGER: 49,548 is 99.1% of cash 50,000
  RXT take-profit +46.13% → FAT FINGER: 88,676 is 177.4% of cash 50,000

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 00:32:11 +09:00
c920b257b6 Merge pull request 'improve: implied_rsi 포화 임계점 개선 12.5%→25% (#181)' (#186) from feature/issue-181-implied-rsi-saturation into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #186
2026-02-20 10:35:10 +09:00
9927bfa13e Merge pull request 'fix: Telegram 409 다중 인스턴스 충돌 시 WARNING + 30초 백오프 (#180)' (#185) from feature/issue-180-telegram-instance-lock into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #185
2026-02-20 09:52:15 +09:00
agentson
aceba86186 fix: Telegram 409 감지 시 백오프 대신 polling 즉시 종료 (#180)
Some checks failed
CI / test (pull_request) Has been cancelled
409 충돌 감지 시 30초 백오프 후 재시도하는 방식에서
_running = False로 polling을 즉시 중단하는 방식으로 변경.

다중 인스턴스가 실행 중인 경우 재시도는 의미 없고 충돌만 반복됨.
이제 409 발생 시 이 프로세스의 Telegram 명령어 polling을 완전히 비활성화.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 09:35:33 +09:00
agentson
77577f3f4d fix: Telegram 409 충돌 시 WARNING 로그 + 30초 백오프 적용 (#180)
Some checks failed
CI / test (pull_request) Has been cancelled
다중 인스턴스 실행 시 Telegram getUpdates 409 응답을 ERROR가 아닌 WARNING으로
처리하고, 30초 동안 polling을 일시 중단하여 충돌을 완화.

- _conflict_backoff_until 속성 추가
- 409 감지 시 명확한 "another instance is polling" 메시지 출력
- poll_loop에서 백오프 활성 중 polling 스킵
- TestGetUpdates에 409 관련 테스트 2개 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 09:31:04 +09:00
4 changed files with 194 additions and 13 deletions

View File

@@ -660,7 +660,12 @@ async def trading_cycle(
return return
# 5a. Risk check BEFORE order # 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,
@@ -1123,7 +1128,12 @@ async def run_daily_session(
continue 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,

View File

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

View File

@@ -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."""

View File

@@ -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."""