Compare commits
5 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4da22b10eb | ||
| c920b257b6 | |||
| 9927bfa13e | |||
|
|
aceba86186 | ||
|
|
77577f3f4d |
30
src/main.py
30
src/main.py
@@ -660,12 +660,17 @@ 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:
|
||||||
risk.validate_order(
|
if decision.action == "SELL":
|
||||||
current_pnl_pct=pnl_pct,
|
risk.check_circuit_breaker(pnl_pct)
|
||||||
order_amount=order_amount,
|
else:
|
||||||
total_cash=total_cash,
|
risk.validate_order(
|
||||||
)
|
current_pnl_pct=pnl_pct,
|
||||||
|
order_amount=order_amount,
|
||||||
|
total_cash=total_cash,
|
||||||
|
)
|
||||||
except FatFingerRejected as exc:
|
except FatFingerRejected as exc:
|
||||||
try:
|
try:
|
||||||
await telegram.notify_fat_finger(
|
await telegram.notify_fat_finger(
|
||||||
@@ -1123,12 +1128,17 @@ 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:
|
||||||
risk.validate_order(
|
if decision.action == "SELL":
|
||||||
current_pnl_pct=pnl_pct,
|
risk.check_circuit_breaker(pnl_pct)
|
||||||
order_amount=order_amount,
|
else:
|
||||||
total_cash=total_cash,
|
risk.validate_order(
|
||||||
)
|
current_pnl_pct=pnl_pct,
|
||||||
|
order_amount=order_amount,
|
||||||
|
total_cash=total_cash,
|
||||||
|
)
|
||||||
except FatFingerRejected as exc:
|
except FatFingerRejected as exc:
|
||||||
try:
|
try:
|
||||||
await telegram.notify_fat_finger(
|
await telegram.notify_fat_finger(
|
||||||
|
|||||||
@@ -604,9 +604,19 @@ 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()
|
||||||
logger.error(
|
if resp.status == 409:
|
||||||
"getUpdates API error (status=%d): %s", resp.status, error_text
|
# 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(
|
||||||
|
"getUpdates API error (status=%d): %s", resp.status, error_text
|
||||||
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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