Compare commits
2 Commits
feature/v3
...
8b5fcfb7c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b5fcfb7c1 | ||
|
|
a16a1e3e05 |
@@ -60,6 +60,7 @@ class Settings(BaseSettings):
|
|||||||
# This value is used as a fallback when the balance API returns 0 in paper mode.
|
# 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)
|
PAPER_OVERSEAS_CASH: float = Field(default=50000.0, ge=0.0)
|
||||||
USD_BUFFER_MIN: float = Field(default=1000.0, ge=0.0)
|
USD_BUFFER_MIN: float = Field(default=1000.0, ge=0.0)
|
||||||
|
STOPLOSS_REENTRY_COOLDOWN_MINUTES: int = Field(default=120, ge=1, le=1440)
|
||||||
OVERNIGHT_EXCEPTION_ENABLED: bool = True
|
OVERNIGHT_EXCEPTION_ENABLED: bool = True
|
||||||
|
|
||||||
# Trading frequency mode (daily = batch API calls, realtime = per-stock calls)
|
# Trading frequency mode (daily = batch API calls, realtime = per-stock calls)
|
||||||
|
|||||||
69
src/main.py
69
src/main.py
@@ -70,6 +70,7 @@ BLACKOUT_ORDER_MANAGER = BlackoutOrderManager(
|
|||||||
_SESSION_CLOSE_WINDOWS = {"NXT_AFTER", "US_AFTER"}
|
_SESSION_CLOSE_WINDOWS = {"NXT_AFTER", "US_AFTER"}
|
||||||
_RUNTIME_EXIT_STATES: dict[str, PositionState] = {}
|
_RUNTIME_EXIT_STATES: dict[str, PositionState] = {}
|
||||||
_RUNTIME_EXIT_PEAKS: dict[str, float] = {}
|
_RUNTIME_EXIT_PEAKS: dict[str, float] = {}
|
||||||
|
_STOPLOSS_REENTRY_COOLDOWN_UNTIL: dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
def safe_float(value: str | float | None, default: float = 0.0) -> float:
|
def safe_float(value: str | float | None, default: float = 0.0) -> float:
|
||||||
@@ -110,6 +111,16 @@ DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day
|
|||||||
TRADE_SESSION_INTERVAL_HOURS = 6 # Hours between sessions
|
TRADE_SESSION_INTERVAL_HOURS = 6 # Hours between sessions
|
||||||
|
|
||||||
|
|
||||||
|
def _stoploss_cooldown_key(*, market: MarketInfo, stock_code: str) -> str:
|
||||||
|
return f"{market.code}:{stock_code}"
|
||||||
|
|
||||||
|
|
||||||
|
def _stoploss_cooldown_minutes(settings: Settings | None) -> int:
|
||||||
|
if settings is None:
|
||||||
|
return 120
|
||||||
|
return max(1, int(getattr(settings, "STOPLOSS_REENTRY_COOLDOWN_MINUTES", 120)))
|
||||||
|
|
||||||
|
|
||||||
async def _retry_connection(coro_factory: Any, *args: Any, label: str = "", **kwargs: Any) -> Any:
|
async def _retry_connection(coro_factory: Any, *args: Any, label: str = "", **kwargs: Any) -> Any:
|
||||||
"""Call an async function retrying on ConnectionError with exponential backoff.
|
"""Call an async function retrying on ConnectionError with exponential backoff.
|
||||||
|
|
||||||
@@ -1291,6 +1302,23 @@ async def trading_cycle(
|
|||||||
stock_code,
|
stock_code,
|
||||||
market.name,
|
market.name,
|
||||||
)
|
)
|
||||||
|
if decision.action == "BUY":
|
||||||
|
cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code)
|
||||||
|
now_epoch = datetime.now(UTC).timestamp()
|
||||||
|
cooldown_until = _STOPLOSS_REENTRY_COOLDOWN_UNTIL.get(cooldown_key, 0.0)
|
||||||
|
if now_epoch < cooldown_until:
|
||||||
|
remaining = int(cooldown_until - now_epoch)
|
||||||
|
decision = TradeDecision(
|
||||||
|
action="HOLD",
|
||||||
|
confidence=decision.confidence,
|
||||||
|
rationale=f"Stop-loss reentry cooldown active ({remaining}s remaining)",
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"BUY suppressed for %s (%s): stop-loss cooldown active (%ds remaining)",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
remaining,
|
||||||
|
)
|
||||||
|
|
||||||
if decision.action == "HOLD":
|
if decision.action == "HOLD":
|
||||||
open_position = get_open_position(db_conn, stock_code, market.code)
|
open_position = get_open_position(db_conn, stock_code, market.code)
|
||||||
@@ -1665,6 +1693,18 @@ async def trading_cycle(
|
|||||||
pnl=trade_pnl,
|
pnl=trade_pnl,
|
||||||
accuracy=1 if trade_pnl > 0 else 0,
|
accuracy=1 if trade_pnl > 0 else 0,
|
||||||
)
|
)
|
||||||
|
if trade_pnl < 0:
|
||||||
|
cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code)
|
||||||
|
cooldown_minutes = _stoploss_cooldown_minutes(settings)
|
||||||
|
_STOPLOSS_REENTRY_COOLDOWN_UNTIL[cooldown_key] = (
|
||||||
|
datetime.now(UTC).timestamp() + cooldown_minutes * 60
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Stop-loss cooldown set for %s (%s): %d minutes",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
cooldown_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
# 6. Log trade with selection context (skip if order was rejected)
|
# 6. Log trade with selection context (skip if order was rejected)
|
||||||
if decision.action in ("BUY", "SELL") and not order_succeeded:
|
if decision.action in ("BUY", "SELL") and not order_succeeded:
|
||||||
@@ -2442,6 +2482,23 @@ async def run_daily_session(
|
|||||||
stock_code,
|
stock_code,
|
||||||
market.name,
|
market.name,
|
||||||
)
|
)
|
||||||
|
if decision.action == "BUY":
|
||||||
|
cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code)
|
||||||
|
now_epoch = datetime.now(UTC).timestamp()
|
||||||
|
cooldown_until = _STOPLOSS_REENTRY_COOLDOWN_UNTIL.get(cooldown_key, 0.0)
|
||||||
|
if now_epoch < cooldown_until:
|
||||||
|
remaining = int(cooldown_until - now_epoch)
|
||||||
|
decision = TradeDecision(
|
||||||
|
action="HOLD",
|
||||||
|
confidence=decision.confidence,
|
||||||
|
rationale=f"Stop-loss reentry cooldown active ({remaining}s remaining)",
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"BUY suppressed for %s (%s): stop-loss cooldown active (%ds remaining)",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
remaining,
|
||||||
|
)
|
||||||
if decision.action == "HOLD":
|
if decision.action == "HOLD":
|
||||||
daily_open = get_open_position(db_conn, stock_code, market.code)
|
daily_open = get_open_position(db_conn, stock_code, market.code)
|
||||||
if not daily_open:
|
if not daily_open:
|
||||||
@@ -2762,6 +2819,18 @@ async def run_daily_session(
|
|||||||
pnl=trade_pnl,
|
pnl=trade_pnl,
|
||||||
accuracy=1 if trade_pnl > 0 else 0,
|
accuracy=1 if trade_pnl > 0 else 0,
|
||||||
)
|
)
|
||||||
|
if trade_pnl < 0:
|
||||||
|
cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code)
|
||||||
|
cooldown_minutes = _stoploss_cooldown_minutes(settings)
|
||||||
|
_STOPLOSS_REENTRY_COOLDOWN_UNTIL[cooldown_key] = (
|
||||||
|
datetime.now(UTC).timestamp() + cooldown_minutes * 60
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Stop-loss cooldown set for %s (%s): %d minutes",
|
||||||
|
stock_code,
|
||||||
|
market.name,
|
||||||
|
cooldown_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
# Log trade (skip if order was rejected by API)
|
# Log trade (skip if order was rejected by API)
|
||||||
if decision.action in ("BUY", "SELL") and not order_succeeded:
|
if decision.action in ("BUY", "SELL") and not order_succeeded:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from src.evolution.scorecard import DailyScorecard
|
|||||||
from src.logging.decision_logger import DecisionLogger
|
from src.logging.decision_logger import DecisionLogger
|
||||||
from src.main import (
|
from src.main import (
|
||||||
KILL_SWITCH,
|
KILL_SWITCH,
|
||||||
|
_STOPLOSS_REENTRY_COOLDOWN_UNTIL,
|
||||||
_RUNTIME_EXIT_PEAKS,
|
_RUNTIME_EXIT_PEAKS,
|
||||||
_RUNTIME_EXIT_STATES,
|
_RUNTIME_EXIT_STATES,
|
||||||
_should_force_exit_for_overnight,
|
_should_force_exit_for_overnight,
|
||||||
@@ -92,10 +93,12 @@ def _reset_kill_switch_state() -> None:
|
|||||||
KILL_SWITCH.clear_block()
|
KILL_SWITCH.clear_block()
|
||||||
_RUNTIME_EXIT_STATES.clear()
|
_RUNTIME_EXIT_STATES.clear()
|
||||||
_RUNTIME_EXIT_PEAKS.clear()
|
_RUNTIME_EXIT_PEAKS.clear()
|
||||||
|
_STOPLOSS_REENTRY_COOLDOWN_UNTIL.clear()
|
||||||
yield
|
yield
|
||||||
KILL_SWITCH.clear_block()
|
KILL_SWITCH.clear_block()
|
||||||
_RUNTIME_EXIT_STATES.clear()
|
_RUNTIME_EXIT_STATES.clear()
|
||||||
_RUNTIME_EXIT_PEAKS.clear()
|
_RUNTIME_EXIT_PEAKS.clear()
|
||||||
|
_STOPLOSS_REENTRY_COOLDOWN_UNTIL.clear()
|
||||||
|
|
||||||
|
|
||||||
class TestExtractAvgPriceFromBalance:
|
class TestExtractAvgPriceFromBalance:
|
||||||
@@ -2040,6 +2043,105 @@ async def test_sell_updates_original_buy_decision_outcome() -> None:
|
|||||||
assert updated_buy is not None
|
assert updated_buy is not None
|
||||||
assert updated_buy.outcome_pnl == 20.0
|
assert updated_buy.outcome_pnl == 20.0
|
||||||
assert updated_buy.outcome_accuracy == 1
|
assert updated_buy.outcome_accuracy == 1
|
||||||
|
assert "KR:005930" not in _STOPLOSS_REENTRY_COOLDOWN_UNTIL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stoploss_reentry_cooldown_blocks_buy_when_active() -> None:
|
||||||
|
_STOPLOSS_REENTRY_COOLDOWN_UNTIL["KR:005930"] = datetime.now(UTC).timestamp() + 300
|
||||||
|
db_conn = init_db(":memory:")
|
||||||
|
|
||||||
|
broker = MagicMock()
|
||||||
|
broker.get_current_price = AsyncMock(return_value=(100.0, 0.0, 0.0))
|
||||||
|
broker.get_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output1": [],
|
||||||
|
"output2": [{"tot_evlu_amt": "100000", "dnca_tot_amt": "50000", "pchs_amt_smtl_amt": "50000"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||||
|
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "Korea"
|
||||||
|
market.code = "KR"
|
||||||
|
market.exchange_code = "KRX"
|
||||||
|
market.is_domestic = True
|
||||||
|
|
||||||
|
await trading_cycle(
|
||||||
|
broker=broker,
|
||||||
|
overseas_broker=MagicMock(),
|
||||||
|
scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_buy_match("005930"))),
|
||||||
|
playbook=_make_playbook(),
|
||||||
|
risk=MagicMock(validate_order=MagicMock(), check_circuit_breaker=MagicMock()),
|
||||||
|
db_conn=db_conn,
|
||||||
|
decision_logger=DecisionLogger(db_conn),
|
||||||
|
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=MagicMock(
|
||||||
|
notify_trade_execution=AsyncMock(),
|
||||||
|
notify_fat_finger=AsyncMock(),
|
||||||
|
notify_circuit_breaker=AsyncMock(),
|
||||||
|
notify_scenario_matched=AsyncMock(),
|
||||||
|
),
|
||||||
|
market=market,
|
||||||
|
stock_code="005930",
|
||||||
|
scan_candidates={},
|
||||||
|
settings=MagicMock(POSITION_SIZING_ENABLED=False, CONFIDENCE_THRESHOLD=80, MODE="paper"),
|
||||||
|
)
|
||||||
|
|
||||||
|
broker.send_order.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stoploss_reentry_cooldown_allows_buy_after_expiry() -> None:
|
||||||
|
_STOPLOSS_REENTRY_COOLDOWN_UNTIL["KR:005930"] = datetime.now(UTC).timestamp() - 10
|
||||||
|
db_conn = init_db(":memory:")
|
||||||
|
|
||||||
|
broker = MagicMock()
|
||||||
|
broker.get_current_price = AsyncMock(return_value=(100.0, 0.0, 0.0))
|
||||||
|
broker.get_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output1": [],
|
||||||
|
"output2": [{"tot_evlu_amt": "100000", "dnca_tot_amt": "50000", "pchs_amt_smtl_amt": "50000"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
|
||||||
|
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "Korea"
|
||||||
|
market.code = "KR"
|
||||||
|
market.exchange_code = "KRX"
|
||||||
|
market.is_domestic = True
|
||||||
|
|
||||||
|
await trading_cycle(
|
||||||
|
broker=broker,
|
||||||
|
overseas_broker=MagicMock(),
|
||||||
|
scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_buy_match("005930"))),
|
||||||
|
playbook=_make_playbook(),
|
||||||
|
risk=MagicMock(validate_order=MagicMock(), check_circuit_breaker=MagicMock()),
|
||||||
|
db_conn=db_conn,
|
||||||
|
decision_logger=DecisionLogger(db_conn),
|
||||||
|
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=MagicMock(
|
||||||
|
notify_trade_execution=AsyncMock(),
|
||||||
|
notify_fat_finger=AsyncMock(),
|
||||||
|
notify_circuit_breaker=AsyncMock(),
|
||||||
|
notify_scenario_matched=AsyncMock(),
|
||||||
|
),
|
||||||
|
market=market,
|
||||||
|
stock_code="005930",
|
||||||
|
scan_candidates={},
|
||||||
|
settings=MagicMock(POSITION_SIZING_ENABLED=False, CONFIDENCE_THRESHOLD=80, MODE="paper"),
|
||||||
|
)
|
||||||
|
|
||||||
|
broker.send_order.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user