feat: enforce stop-loss reentry cooldown window (#319)
Some checks failed
Gitea CI / test (pull_request) Waiting to run
Gitea CI / test (push) Has been cancelled

This commit is contained in:
agentson
2026-02-28 14:41:14 +09:00
parent 13a6d6612a
commit a16a1e3e05
2 changed files with 70 additions and 0 deletions

View File

@@ -60,6 +60,7 @@ class Settings(BaseSettings):
# 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)
STOPLOSS_REENTRY_COOLDOWN_MINUTES: int = Field(default=120, ge=1, le=1440)
OVERNIGHT_EXCEPTION_ENABLED: bool = True
# Trading frequency mode (daily = batch API calls, realtime = per-stock calls)

View File

@@ -70,6 +70,7 @@ BLACKOUT_ORDER_MANAGER = BlackoutOrderManager(
_SESSION_CLOSE_WINDOWS = {"NXT_AFTER", "US_AFTER"}
_RUNTIME_EXIT_STATES: dict[str, PositionState] = {}
_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:
@@ -110,6 +111,16 @@ DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day
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:
"""Call an async function retrying on ConnectionError with exponential backoff.
@@ -1291,6 +1302,23 @@ async def trading_cycle(
stock_code,
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":
open_position = get_open_position(db_conn, stock_code, market.code)
@@ -1665,6 +1693,18 @@ async def trading_cycle(
pnl=trade_pnl,
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)
if decision.action in ("BUY", "SELL") and not order_succeeded:
@@ -2442,6 +2482,23 @@ async def run_daily_session(
stock_code,
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":
daily_open = get_open_position(db_conn, stock_code, market.code)
if not daily_open:
@@ -2762,6 +2819,18 @@ async def run_daily_session(
pnl=trade_pnl,
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)
if decision.action in ("BUY", "SELL") and not order_succeeded: