Compare commits
19 Commits
feature/is
...
9927bfa13e
| Author | SHA1 | Date | |
|---|---|---|---|
| 9927bfa13e | |||
|
|
aceba86186 | ||
| 76a7ee7cdb | |||
|
|
77577f3f4d | ||
| 17112b864a | |||
|
|
28bcc7acd7 | ||
|
|
39b9f179f4 | ||
| bd2b3241b2 | |||
| 561faaaafa | |||
| a33d6a145f | |||
| 7e6c912214 | |||
|
|
d6edbc0fa2 | ||
|
|
c7640a30d7 | ||
|
|
60a22d6cd4 | ||
|
|
b1f48d859e | ||
| 03f8d220a4 | |||
|
|
305120f599 | ||
| faa23b3f1b | |||
|
|
5844ec5ad3 |
287
src/main.py
287
src/main.py
@@ -42,7 +42,7 @@ from src.logging.decision_logger import DecisionLogger
|
||||
from src.logging_config import setup_logging
|
||||
from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets
|
||||
from src.notifications.telegram_client import NotificationFilter, TelegramClient, TelegramCommandHandler
|
||||
from src.strategy.models import DayPlaybook
|
||||
from src.strategy.models import DayPlaybook, MarketOutlook
|
||||
from src.strategy.playbook_store import PlaybookStore
|
||||
from src.strategy.pre_market_planner import PreMarketPlanner
|
||||
from src.strategy.scenario_engine import ScenarioEngine
|
||||
@@ -81,6 +81,7 @@ def safe_float(value: str | float | None, default: float = 0.0) -> float:
|
||||
TRADE_INTERVAL_SECONDS = 60
|
||||
SCAN_INTERVAL_SECONDS = 60 # Scan markets every 60 seconds
|
||||
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_TRADE_SESSIONS = 4 # Number of trading sessions per day
|
||||
@@ -106,6 +107,82 @@ def _extract_symbol_from_holding(item: dict[str, Any]) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_held_codes_from_balance(
|
||||
balance_data: dict[str, Any],
|
||||
*,
|
||||
is_domestic: bool,
|
||||
) -> list[str]:
|
||||
"""Return stock codes with a positive orderable quantity from a balance response.
|
||||
|
||||
Uses the broker's live output1 as the source of truth so that partial fills
|
||||
and manual external trades are always reflected correctly.
|
||||
"""
|
||||
output1 = balance_data.get("output1", [])
|
||||
if isinstance(output1, dict):
|
||||
output1 = [output1]
|
||||
if not isinstance(output1, list):
|
||||
return []
|
||||
|
||||
codes: list[str] = []
|
||||
for holding in output1:
|
||||
if not isinstance(holding, dict):
|
||||
continue
|
||||
code_key = "pdno" if is_domestic else "ovrs_pdno"
|
||||
code = str(holding.get(code_key, "")).strip().upper()
|
||||
if not code:
|
||||
continue
|
||||
if is_domestic:
|
||||
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
|
||||
else:
|
||||
qty = int(holding.get("ovrs_cblc_qty") or holding.get("hldg_qty") or 0)
|
||||
if qty > 0:
|
||||
codes.append(code)
|
||||
return codes
|
||||
|
||||
|
||||
def _extract_held_qty_from_balance(
|
||||
balance_data: dict[str, Any],
|
||||
stock_code: str,
|
||||
*,
|
||||
is_domestic: bool,
|
||||
) -> int:
|
||||
"""Extract the broker-confirmed orderable quantity for a stock.
|
||||
|
||||
Uses the broker's live balance response (output1) as the source of truth
|
||||
rather than the local DB, because DB records reflect order quantity which
|
||||
may differ from actual fill quantity due to partial fills.
|
||||
|
||||
Domestic fields (VTTC8434R output1):
|
||||
pdno — 종목코드
|
||||
ord_psbl_qty — 주문가능수량 (preferred: excludes unsettled)
|
||||
hldg_qty — 보유수량 (fallback)
|
||||
|
||||
Overseas fields (output1):
|
||||
ovrs_pdno — 종목코드
|
||||
ovrs_cblc_qty — 해외잔고수량 (preferred)
|
||||
hldg_qty — 보유수량 (fallback)
|
||||
"""
|
||||
output1 = balance_data.get("output1", [])
|
||||
if isinstance(output1, dict):
|
||||
output1 = [output1]
|
||||
if not isinstance(output1, list):
|
||||
return 0
|
||||
|
||||
for holding in output1:
|
||||
if not isinstance(holding, dict):
|
||||
continue
|
||||
code_key = "pdno" if is_domestic else "ovrs_pdno"
|
||||
held_code = str(holding.get(code_key, "")).strip().upper()
|
||||
if held_code != stock_code.strip().upper():
|
||||
continue
|
||||
if is_domestic:
|
||||
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
|
||||
else:
|
||||
qty = int(holding.get("ovrs_cblc_qty") or holding.get("hldg_qty") or 0)
|
||||
return qty
|
||||
return 0
|
||||
|
||||
|
||||
def _determine_order_quantity(
|
||||
*,
|
||||
action: str,
|
||||
@@ -113,16 +190,40 @@ def _determine_order_quantity(
|
||||
total_cash: float,
|
||||
candidate: ScanCandidate | None,
|
||||
settings: Settings | None,
|
||||
broker_held_qty: int = 0,
|
||||
playbook_allocation_pct: float | None = None,
|
||||
scenario_confidence: int = 80,
|
||||
) -> int:
|
||||
"""Determine order quantity using volatility-aware position sizing."""
|
||||
if action != "BUY":
|
||||
return 1
|
||||
"""Determine order quantity using volatility-aware position sizing.
|
||||
|
||||
Priority:
|
||||
1. playbook_allocation_pct (AI-specified) scaled by scenario_confidence
|
||||
2. Fallback: volatility-score-based allocation from scanner candidate
|
||||
"""
|
||||
if action == "SELL":
|
||||
return broker_held_qty
|
||||
if current_price <= 0 or total_cash <= 0:
|
||||
return 0
|
||||
|
||||
if settings is None or not settings.POSITION_SIZING_ENABLED:
|
||||
return 1
|
||||
|
||||
# Use AI-specified allocation_pct if available
|
||||
if playbook_allocation_pct is not None:
|
||||
# Confidence scaling: confidence 80 → 1.0x, confidence 95 → 1.19x
|
||||
confidence_scale = scenario_confidence / 80.0
|
||||
effective_pct = min(
|
||||
settings.POSITION_MAX_ALLOCATION_PCT,
|
||||
max(
|
||||
settings.POSITION_MIN_ALLOCATION_PCT,
|
||||
playbook_allocation_pct * confidence_scale,
|
||||
),
|
||||
)
|
||||
budget = total_cash * (effective_pct / 100.0)
|
||||
quantity = int(budget // current_price)
|
||||
return max(0, quantity)
|
||||
|
||||
# Fallback: volatility-score-based allocation
|
||||
target_score = max(1.0, settings.POSITION_VOLATILITY_TARGET_SCORE)
|
||||
observed_score = candidate.score if candidate else target_score
|
||||
observed_score = max(1.0, min(100.0, observed_score))
|
||||
@@ -198,6 +299,7 @@ async def trading_cycle(
|
||||
stock_code: str,
|
||||
scan_candidates: dict[str, dict[str, ScanCandidate]],
|
||||
settings: Settings | None = None,
|
||||
buy_cooldown: dict[str, float] | None = None,
|
||||
) -> None:
|
||||
"""Execute one trading cycle for a single stock."""
|
||||
cycle_start_time = asyncio.get_event_loop().time()
|
||||
@@ -380,6 +482,34 @@ async def trading_cycle(
|
||||
)
|
||||
stock_playbook = playbook.get_stock_playbook(stock_code)
|
||||
|
||||
# 2.1. Apply market_outlook-based BUY confidence threshold
|
||||
if decision.action == "BUY":
|
||||
base_threshold = (settings.CONFIDENCE_THRESHOLD if settings else 80)
|
||||
outlook = playbook.market_outlook
|
||||
if outlook == MarketOutlook.BEARISH:
|
||||
min_confidence = 90
|
||||
elif outlook == MarketOutlook.BULLISH:
|
||||
min_confidence = 75
|
||||
else:
|
||||
min_confidence = base_threshold
|
||||
if match.confidence < min_confidence:
|
||||
logger.info(
|
||||
"BUY suppressed for %s (%s): confidence %d < %d (market_outlook=%s)",
|
||||
stock_code,
|
||||
market.name,
|
||||
match.confidence,
|
||||
min_confidence,
|
||||
outlook.value,
|
||||
)
|
||||
decision = TradeDecision(
|
||||
action="HOLD",
|
||||
confidence=match.confidence,
|
||||
rationale=(
|
||||
f"BUY confidence {match.confidence} < {min_confidence} "
|
||||
f"(market_outlook={outlook.value})"
|
||||
),
|
||||
)
|
||||
|
||||
if decision.action == "HOLD":
|
||||
open_position = get_open_position(db_conn, stock_code, market.code)
|
||||
if open_position:
|
||||
@@ -387,8 +517,10 @@ async def trading_cycle(
|
||||
if entry_price > 0:
|
||||
loss_pct = (current_price - entry_price) / entry_price * 100
|
||||
stop_loss_threshold = -2.0
|
||||
take_profit_threshold = 3.0
|
||||
if stock_playbook and stock_playbook.scenarios:
|
||||
stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct
|
||||
take_profit_threshold = stock_playbook.scenarios[0].take_profit_pct
|
||||
|
||||
if loss_pct <= stop_loss_threshold:
|
||||
decision = TradeDecision(
|
||||
@@ -406,6 +538,22 @@ async def trading_cycle(
|
||||
loss_pct,
|
||||
stop_loss_threshold,
|
||||
)
|
||||
elif loss_pct >= take_profit_threshold:
|
||||
decision = TradeDecision(
|
||||
action="SELL",
|
||||
confidence=90,
|
||||
rationale=(
|
||||
f"Take-profit triggered ({loss_pct:.2f}% >= "
|
||||
f"{take_profit_threshold:.2f}%)"
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
"Take-profit override for %s (%s): %.2f%% >= %.2f%%",
|
||||
stock_code,
|
||||
market.name,
|
||||
loss_pct,
|
||||
take_profit_threshold,
|
||||
)
|
||||
logger.info(
|
||||
"Decision for %s (%s): %s (confidence=%d)",
|
||||
stock_code,
|
||||
@@ -466,12 +614,23 @@ async def trading_cycle(
|
||||
trade_price = current_price
|
||||
trade_pnl = 0.0
|
||||
if decision.action in ("BUY", "SELL"):
|
||||
broker_held_qty = (
|
||||
_extract_held_qty_from_balance(
|
||||
balance_data, stock_code, is_domestic=market.is_domestic
|
||||
)
|
||||
if decision.action == "SELL"
|
||||
else 0
|
||||
)
|
||||
matched_scenario = match.matched_scenario
|
||||
quantity = _determine_order_quantity(
|
||||
action=decision.action,
|
||||
current_price=current_price,
|
||||
total_cash=total_cash,
|
||||
candidate=candidate,
|
||||
settings=settings,
|
||||
broker_held_qty=broker_held_qty,
|
||||
playbook_allocation_pct=matched_scenario.allocation_pct if matched_scenario else None,
|
||||
scenario_confidence=match.confidence,
|
||||
)
|
||||
if quantity <= 0:
|
||||
logger.info(
|
||||
@@ -485,7 +644,22 @@ async def trading_cycle(
|
||||
return
|
||||
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
|
||||
try:
|
||||
risk.validate_order(
|
||||
current_pnl_pct=pnl_pct,
|
||||
@@ -533,12 +707,24 @@ async def trading_cycle(
|
||||
# Check if KIS rejected the order (rt_cd != "0")
|
||||
if result.get("rt_cd", "") != "0":
|
||||
order_succeeded = False
|
||||
msg1 = result.get("msg1") or ""
|
||||
logger.warning(
|
||||
"Overseas order not accepted for %s: rt_cd=%s msg=%s",
|
||||
stock_code,
|
||||
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"))
|
||||
|
||||
# 5.5. Notify trade execution (only on success)
|
||||
@@ -646,6 +832,9 @@ async def run_daily_session(
|
||||
|
||||
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
|
||||
for market in open_markets:
|
||||
# Use market-local date for playbook keying
|
||||
@@ -891,12 +1080,20 @@ async def run_daily_session(
|
||||
trade_pnl = 0.0
|
||||
order_succeeded = True
|
||||
if decision.action in ("BUY", "SELL"):
|
||||
daily_broker_held_qty = (
|
||||
_extract_held_qty_from_balance(
|
||||
balance_data, stock_code, is_domestic=market.is_domestic
|
||||
)
|
||||
if decision.action == "SELL"
|
||||
else 0
|
||||
)
|
||||
quantity = _determine_order_quantity(
|
||||
action=decision.action,
|
||||
current_price=stock_data["current_price"],
|
||||
total_cash=total_cash,
|
||||
candidate=candidate_map.get(stock_code),
|
||||
settings=settings,
|
||||
broker_held_qty=daily_broker_held_qty,
|
||||
)
|
||||
if quantity <= 0:
|
||||
logger.info(
|
||||
@@ -910,6 +1107,21 @@ async def run_daily_session(
|
||||
continue
|
||||
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
|
||||
try:
|
||||
risk.validate_order(
|
||||
@@ -966,12 +1178,23 @@ async def run_daily_session(
|
||||
)
|
||||
if result.get("rt_cd", "") != "0":
|
||||
order_succeeded = False
|
||||
daily_msg1 = result.get("msg1") or ""
|
||||
logger.warning(
|
||||
"Overseas order not accepted for %s: rt_cd=%s msg=%s",
|
||||
stock_code,
|
||||
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"))
|
||||
|
||||
# Notify trade execution (only on success)
|
||||
@@ -1135,10 +1358,18 @@ def _start_dashboard_server(settings: Settings) -> threading.Thread | None:
|
||||
if not settings.DASHBOARD_ENABLED:
|
||||
return None
|
||||
|
||||
# Validate dependencies before spawning the thread so startup failures are
|
||||
# reported synchronously (avoids the misleading "started" → "failed" log pair).
|
||||
try:
|
||||
import uvicorn # noqa: F401
|
||||
from src.dashboard import create_dashboard_app # noqa: F401
|
||||
except ImportError as exc:
|
||||
logger.warning("Dashboard server unavailable (missing dependency): %s", exc)
|
||||
return None
|
||||
|
||||
def _serve() -> None:
|
||||
try:
|
||||
import uvicorn
|
||||
|
||||
from src.dashboard import create_dashboard_app
|
||||
|
||||
app = create_dashboard_app(settings.DB_PATH)
|
||||
@@ -1149,7 +1380,7 @@ def _start_dashboard_server(settings: Settings) -> threading.Thread | None:
|
||||
log_level="info",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Dashboard server failed to start: %s", exc)
|
||||
logger.warning("Dashboard server stopped unexpectedly: %s", exc)
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_serve,
|
||||
@@ -1590,6 +1821,9 @@ async def run(settings: Settings) -> None:
|
||||
# Active stocks per market (dynamically discovered by scanner)
|
||||
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
|
||||
criticality_assessor = CriticalityAssessor(
|
||||
critical_pnl_threshold=-2.5, # Near circuit breaker at -3.0%
|
||||
@@ -1863,8 +2097,38 @@ async def run(settings: Settings) -> None:
|
||||
except Exception as exc:
|
||||
logger.error("Smart Scanner failed for %s: %s", market.name, exc)
|
||||
|
||||
# Get active stocks from scanner (dynamic, no static fallback)
|
||||
stock_codes = active_stocks.get(market.code, [])
|
||||
# Get active stocks from scanner (dynamic, no static fallback).
|
||||
# Also include currently-held positions so stop-loss /
|
||||
# take-profit can fire even when a holding drops off the
|
||||
# scanner. Broker balance is the source of truth here —
|
||||
# unlike the local DB it reflects actual fills and any
|
||||
# manual trades done outside the bot.
|
||||
scanner_codes = active_stocks.get(market.code, [])
|
||||
try:
|
||||
if market.is_domestic:
|
||||
held_balance = await broker.get_balance()
|
||||
else:
|
||||
held_balance = await overseas_broker.get_overseas_balance(
|
||||
market.exchange_code
|
||||
)
|
||||
held_codes = _extract_held_codes_from_balance(
|
||||
held_balance, is_domestic=market.is_domestic
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to fetch holdings for %s: %s — skipping holdings merge",
|
||||
market.name, exc,
|
||||
)
|
||||
held_codes = []
|
||||
|
||||
stock_codes = list(dict.fromkeys(scanner_codes + held_codes))
|
||||
extra_held = [c for c in held_codes if c not in set(scanner_codes)]
|
||||
if extra_held:
|
||||
logger.info(
|
||||
"Holdings added to loop for %s (not in scanner): %s",
|
||||
market.name, extra_held,
|
||||
)
|
||||
|
||||
if not stock_codes:
|
||||
logger.debug("No active stocks for market %s", market.code)
|
||||
continue
|
||||
@@ -1902,6 +2166,7 @@ async def run(settings: Settings) -> None:
|
||||
stock_code,
|
||||
scan_candidates,
|
||||
settings,
|
||||
buy_cooldown,
|
||||
)
|
||||
break # Success — exit retry loop
|
||||
except CircuitBreakerTripped as exc:
|
||||
|
||||
@@ -604,9 +604,19 @@ class TelegramCommandHandler:
|
||||
async with session.post(url, json=payload) as resp:
|
||||
if resp.status != 200:
|
||||
error_text = await resp.text()
|
||||
logger.error(
|
||||
"getUpdates API error (status=%d): %s", resp.status, error_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(
|
||||
"getUpdates API error (status=%d): %s", resp.status, error_text
|
||||
)
|
||||
return []
|
||||
|
||||
data = await resp.json()
|
||||
|
||||
@@ -46,6 +46,18 @@ class StockCondition(BaseModel):
|
||||
|
||||
The ScenarioEngine evaluates all non-None fields as AND conditions.
|
||||
A condition matches only if ALL specified fields are satisfied.
|
||||
|
||||
Technical indicator fields:
|
||||
rsi_below / rsi_above — RSI threshold
|
||||
volume_ratio_above / volume_ratio_below — volume vs previous day
|
||||
price_above / price_below — absolute price level
|
||||
price_change_pct_above / price_change_pct_below — intraday % change
|
||||
|
||||
Position-aware fields (require market_data enrichment from open position):
|
||||
unrealized_pnl_pct_above — matches if unrealized P&L > threshold (e.g. 3.0 → +3%)
|
||||
unrealized_pnl_pct_below — matches if unrealized P&L < threshold (e.g. -2.0 → -2%)
|
||||
holding_days_above — matches if position held for more than N days
|
||||
holding_days_below — matches if position held for fewer than N days
|
||||
"""
|
||||
|
||||
rsi_below: float | None = None
|
||||
@@ -56,6 +68,10 @@ class StockCondition(BaseModel):
|
||||
price_below: float | None = None
|
||||
price_change_pct_above: float | None = None
|
||||
price_change_pct_below: float | None = None
|
||||
unrealized_pnl_pct_above: float | None = None
|
||||
unrealized_pnl_pct_below: float | None = None
|
||||
holding_days_above: int | None = None
|
||||
holding_days_below: int | None = None
|
||||
|
||||
def has_any_condition(self) -> bool:
|
||||
"""Check if at least one condition field is set."""
|
||||
@@ -70,6 +86,10 @@ class StockCondition(BaseModel):
|
||||
self.price_below,
|
||||
self.price_change_pct_above,
|
||||
self.price_change_pct_below,
|
||||
self.unrealized_pnl_pct_above,
|
||||
self.unrealized_pnl_pct_below,
|
||||
self.holding_days_above,
|
||||
self.holding_days_below,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ class PreMarketPlanner:
|
||||
market: str,
|
||||
candidates: list[ScanCandidate],
|
||||
today: date | None = None,
|
||||
current_holdings: list[dict] | None = None,
|
||||
) -> DayPlaybook:
|
||||
"""Generate a DayPlaybook for a market using Gemini.
|
||||
|
||||
@@ -82,6 +83,10 @@ class PreMarketPlanner:
|
||||
market: Market code ("KR" or "US")
|
||||
candidates: Stock candidates from SmartVolatilityScanner
|
||||
today: Override date (defaults to date.today()). Use market-local date.
|
||||
current_holdings: Currently held positions with entry_price and unrealized_pnl_pct.
|
||||
Each dict: {"stock_code": str, "name": str, "qty": int,
|
||||
"entry_price": float, "unrealized_pnl_pct": float,
|
||||
"holding_days": int}
|
||||
|
||||
Returns:
|
||||
DayPlaybook with scenarios. Empty/defensive if no candidates or failure.
|
||||
@@ -106,6 +111,7 @@ class PreMarketPlanner:
|
||||
context_data,
|
||||
self_market_scorecard,
|
||||
cross_market,
|
||||
current_holdings=current_holdings,
|
||||
)
|
||||
|
||||
# 3. Call Gemini
|
||||
@@ -118,7 +124,8 @@ class PreMarketPlanner:
|
||||
|
||||
# 4. Parse response
|
||||
playbook = self._parse_response(
|
||||
decision.rationale, today, market, candidates, cross_market
|
||||
decision.rationale, today, market, candidates, cross_market,
|
||||
current_holdings=current_holdings,
|
||||
)
|
||||
playbook_with_tokens = playbook.model_copy(
|
||||
update={"token_count": decision.token_count}
|
||||
@@ -230,6 +237,7 @@ class PreMarketPlanner:
|
||||
context_data: dict[str, Any],
|
||||
self_market_scorecard: dict[str, Any] | None,
|
||||
cross_market: CrossMarketContext | None,
|
||||
current_holdings: list[dict] | None = None,
|
||||
) -> str:
|
||||
"""Build a structured prompt for Gemini to generate scenario JSON."""
|
||||
max_scenarios = self._settings.MAX_SCENARIOS_PER_STOCK
|
||||
@@ -241,6 +249,26 @@ class PreMarketPlanner:
|
||||
for c in candidates
|
||||
)
|
||||
|
||||
holdings_text = ""
|
||||
if current_holdings:
|
||||
lines = []
|
||||
for h in current_holdings:
|
||||
code = h.get("stock_code", "")
|
||||
name = h.get("name", "")
|
||||
qty = h.get("qty", 0)
|
||||
entry_price = h.get("entry_price", 0.0)
|
||||
pnl_pct = h.get("unrealized_pnl_pct", 0.0)
|
||||
holding_days = h.get("holding_days", 0)
|
||||
lines.append(
|
||||
f" - {code} ({name}): {qty}주 @ {entry_price:,.0f}, "
|
||||
f"미실현손익 {pnl_pct:+.2f}%, 보유 {holding_days}일"
|
||||
)
|
||||
holdings_text = (
|
||||
"\n## Current Holdings (보유 중 — SELL/HOLD 전략 고려 필요)\n"
|
||||
+ "\n".join(lines)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
cross_market_text = ""
|
||||
if cross_market:
|
||||
cross_market_text = (
|
||||
@@ -273,10 +301,20 @@ class PreMarketPlanner:
|
||||
for key, value in list(layer_data.items())[:5]:
|
||||
context_text += f" - {key}: {value}\n"
|
||||
|
||||
holdings_instruction = ""
|
||||
if current_holdings:
|
||||
holding_codes = [h.get("stock_code", "") for h in current_holdings]
|
||||
holdings_instruction = (
|
||||
f"- Also include SELL/HOLD scenarios for held stocks: "
|
||||
f"{', '.join(holding_codes)} "
|
||||
f"(even if not in candidates list)\n"
|
||||
)
|
||||
|
||||
return (
|
||||
f"You are a pre-market trading strategist for the {market} market.\n"
|
||||
f"Generate structured trading scenarios for today.\n\n"
|
||||
f"## Candidates (from volatility scanner)\n{candidates_text}\n"
|
||||
f"{holdings_text}"
|
||||
f"{self_market_text}"
|
||||
f"{cross_market_text}"
|
||||
f"{context_text}\n"
|
||||
@@ -294,7 +332,8 @@ class PreMarketPlanner:
|
||||
f' "stock_code": "...",\n'
|
||||
f' "scenarios": [\n'
|
||||
f' {{\n'
|
||||
f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0}},\n'
|
||||
f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0,'
|
||||
f' "unrealized_pnl_pct_above": 3.0, "holding_days_above": 5}},\n'
|
||||
f' "action": "BUY|SELL|HOLD",\n'
|
||||
f' "confidence": 85,\n'
|
||||
f' "allocation_pct": 10.0,\n'
|
||||
@@ -308,7 +347,8 @@ class PreMarketPlanner:
|
||||
f'}}\n\n'
|
||||
f"Rules:\n"
|
||||
f"- Max {max_scenarios} scenarios per stock\n"
|
||||
f"- Only use stocks from the candidates list\n"
|
||||
f"- Candidates list is the primary source for BUY candidates\n"
|
||||
f"{holdings_instruction}"
|
||||
f"- Confidence 0-100 (80+ for actionable trades)\n"
|
||||
f"- stop_loss_pct must be <= 0, take_profit_pct must be >= 0\n"
|
||||
f"- Return ONLY the JSON, no markdown fences or explanation\n"
|
||||
@@ -321,12 +361,19 @@ class PreMarketPlanner:
|
||||
market: str,
|
||||
candidates: list[ScanCandidate],
|
||||
cross_market: CrossMarketContext | None,
|
||||
current_holdings: list[dict] | None = None,
|
||||
) -> DayPlaybook:
|
||||
"""Parse Gemini's JSON response into a validated DayPlaybook."""
|
||||
cleaned = self._extract_json(response_text)
|
||||
data = json.loads(cleaned)
|
||||
|
||||
valid_codes = {c.stock_code for c in candidates}
|
||||
# Holdings are also valid — AI may generate SELL/HOLD scenarios for them
|
||||
if current_holdings:
|
||||
for h in current_holdings:
|
||||
code = h.get("stock_code", "")
|
||||
if code:
|
||||
valid_codes.add(code)
|
||||
|
||||
# Parse market outlook
|
||||
outlook_str = data.get("market_outlook", "neutral")
|
||||
@@ -390,6 +437,10 @@ class PreMarketPlanner:
|
||||
price_below=cond_data.get("price_below"),
|
||||
price_change_pct_above=cond_data.get("price_change_pct_above"),
|
||||
price_change_pct_below=cond_data.get("price_change_pct_below"),
|
||||
unrealized_pnl_pct_above=cond_data.get("unrealized_pnl_pct_above"),
|
||||
unrealized_pnl_pct_below=cond_data.get("unrealized_pnl_pct_below"),
|
||||
holding_days_above=cond_data.get("holding_days_above"),
|
||||
holding_days_below=cond_data.get("holding_days_below"),
|
||||
)
|
||||
|
||||
if not condition.has_any_condition():
|
||||
|
||||
@@ -206,6 +206,37 @@ class ScenarioEngine:
|
||||
if condition.price_change_pct_below is not None:
|
||||
checks.append(price_change_pct is not None and price_change_pct < condition.price_change_pct_below)
|
||||
|
||||
# Position-aware conditions
|
||||
unrealized_pnl_pct = self._safe_float(market_data.get("unrealized_pnl_pct"))
|
||||
if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None:
|
||||
if "unrealized_pnl_pct" not in market_data:
|
||||
self._warn_missing_key("unrealized_pnl_pct")
|
||||
if condition.unrealized_pnl_pct_above is not None:
|
||||
checks.append(
|
||||
unrealized_pnl_pct is not None
|
||||
and unrealized_pnl_pct > condition.unrealized_pnl_pct_above
|
||||
)
|
||||
if condition.unrealized_pnl_pct_below is not None:
|
||||
checks.append(
|
||||
unrealized_pnl_pct is not None
|
||||
and unrealized_pnl_pct < condition.unrealized_pnl_pct_below
|
||||
)
|
||||
|
||||
holding_days = self._safe_float(market_data.get("holding_days"))
|
||||
if condition.holding_days_above is not None or condition.holding_days_below is not None:
|
||||
if "holding_days" not in market_data:
|
||||
self._warn_missing_key("holding_days")
|
||||
if condition.holding_days_above is not None:
|
||||
checks.append(
|
||||
holding_days is not None
|
||||
and holding_days > condition.holding_days_above
|
||||
)
|
||||
if condition.holding_days_below is not None:
|
||||
checks.append(
|
||||
holding_days is not None
|
||||
and holding_days < condition.holding_days_below
|
||||
)
|
||||
|
||||
return len(checks) > 0 and all(checks)
|
||||
|
||||
def _evaluate_global_condition(
|
||||
@@ -266,5 +297,9 @@ class ScenarioEngine:
|
||||
details["current_price"] = self._safe_float(market_data.get("current_price"))
|
||||
if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None:
|
||||
details["price_change_pct"] = self._safe_float(market_data.get("price_change_pct"))
|
||||
if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None:
|
||||
details["unrealized_pnl_pct"] = self._safe_float(market_data.get("unrealized_pnl_pct"))
|
||||
if condition.holding_days_above is not None or condition.holding_days_below is not None:
|
||||
details["holding_days"] = self._safe_float(market_data.get("holding_days"))
|
||||
|
||||
return details
|
||||
|
||||
1077
tests/test_main.py
1077
tests/test_main.py
File diff suppressed because it is too large
Load Diff
@@ -830,3 +830,171 @@ class TestSmartFallbackPlaybook:
|
||||
]
|
||||
assert len(buy_scenarios) == 1
|
||||
assert buy_scenarios[0].condition.volume_ratio_above == 2.0 # VOL_MULTIPLIER default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Holdings in prompt (#170)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHoldingsInPrompt:
|
||||
"""Tests for current_holdings parameter in generate_playbook / _build_prompt."""
|
||||
|
||||
def _make_holdings(self) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"stock_code": "005930",
|
||||
"name": "Samsung",
|
||||
"qty": 10,
|
||||
"entry_price": 71000.0,
|
||||
"unrealized_pnl_pct": 2.3,
|
||||
"holding_days": 3,
|
||||
}
|
||||
]
|
||||
|
||||
def test_build_prompt_includes_holdings_section(self) -> None:
|
||||
"""Prompt should contain a Current Holdings section when holdings are given."""
|
||||
planner = _make_planner()
|
||||
candidates = [_candidate()]
|
||||
holdings = self._make_holdings()
|
||||
|
||||
prompt = planner._build_prompt(
|
||||
"KR",
|
||||
candidates,
|
||||
context_data={},
|
||||
self_market_scorecard=None,
|
||||
cross_market=None,
|
||||
current_holdings=holdings,
|
||||
)
|
||||
|
||||
assert "## Current Holdings" in prompt
|
||||
assert "005930" in prompt
|
||||
assert "+2.30%" in prompt
|
||||
assert "보유 3일" in prompt
|
||||
|
||||
def test_build_prompt_no_holdings_omits_section(self) -> None:
|
||||
"""Prompt should NOT contain a Current Holdings section when holdings=None."""
|
||||
planner = _make_planner()
|
||||
candidates = [_candidate()]
|
||||
|
||||
prompt = planner._build_prompt(
|
||||
"KR",
|
||||
candidates,
|
||||
context_data={},
|
||||
self_market_scorecard=None,
|
||||
cross_market=None,
|
||||
current_holdings=None,
|
||||
)
|
||||
|
||||
assert "## Current Holdings" not in prompt
|
||||
|
||||
def test_build_prompt_empty_holdings_omits_section(self) -> None:
|
||||
"""Empty list should also omit the holdings section."""
|
||||
planner = _make_planner()
|
||||
candidates = [_candidate()]
|
||||
|
||||
prompt = planner._build_prompt(
|
||||
"KR",
|
||||
candidates,
|
||||
context_data={},
|
||||
self_market_scorecard=None,
|
||||
cross_market=None,
|
||||
current_holdings=[],
|
||||
)
|
||||
|
||||
assert "## Current Holdings" not in prompt
|
||||
|
||||
def test_build_prompt_holdings_instruction_included(self) -> None:
|
||||
"""Prompt should include instruction to generate scenarios for held stocks."""
|
||||
planner = _make_planner()
|
||||
candidates = [_candidate()]
|
||||
holdings = self._make_holdings()
|
||||
|
||||
prompt = planner._build_prompt(
|
||||
"KR",
|
||||
candidates,
|
||||
context_data={},
|
||||
self_market_scorecard=None,
|
||||
cross_market=None,
|
||||
current_holdings=holdings,
|
||||
)
|
||||
|
||||
assert "005930" in prompt
|
||||
assert "SELL/HOLD" in prompt
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_playbook_passes_holdings_to_prompt(self) -> None:
|
||||
"""generate_playbook should pass current_holdings through to the prompt."""
|
||||
planner = _make_planner()
|
||||
candidates = [_candidate()]
|
||||
holdings = self._make_holdings()
|
||||
|
||||
# Capture the actual prompt sent to Gemini
|
||||
captured_prompts: list[str] = []
|
||||
original_decide = planner._gemini.decide
|
||||
|
||||
async def capture_and_call(data: dict) -> TradeDecision:
|
||||
captured_prompts.append(data.get("prompt_override", ""))
|
||||
return await original_decide(data)
|
||||
|
||||
planner._gemini.decide = capture_and_call # type: ignore[method-assign]
|
||||
|
||||
await planner.generate_playbook(
|
||||
"KR", candidates, today=date(2026, 2, 8), current_holdings=holdings
|
||||
)
|
||||
|
||||
assert len(captured_prompts) == 1
|
||||
assert "## Current Holdings" in captured_prompts[0]
|
||||
assert "005930" in captured_prompts[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_holdings_stock_allowed_in_parse_response(self) -> None:
|
||||
"""Holdings stocks not in candidates list should be accepted in the response."""
|
||||
holding_code = "000660" # Not in candidates
|
||||
stocks = [
|
||||
{
|
||||
"stock_code": "005930", # candidate
|
||||
"scenarios": [
|
||||
{
|
||||
"condition": {"rsi_below": 30},
|
||||
"action": "BUY",
|
||||
"confidence": 85,
|
||||
"rationale": "oversold",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"stock_code": holding_code, # holding only
|
||||
"scenarios": [
|
||||
{
|
||||
"condition": {"price_change_pct_below": -2.0},
|
||||
"action": "SELL",
|
||||
"confidence": 90,
|
||||
"rationale": "stop-loss",
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
planner = _make_planner(gemini_response=_gemini_response_json(stocks=stocks))
|
||||
candidates = [_candidate()] # only 005930
|
||||
holdings = [
|
||||
{
|
||||
"stock_code": holding_code,
|
||||
"name": "SK Hynix",
|
||||
"qty": 5,
|
||||
"entry_price": 180000.0,
|
||||
"unrealized_pnl_pct": -1.5,
|
||||
"holding_days": 7,
|
||||
}
|
||||
]
|
||||
|
||||
pb = await planner.generate_playbook(
|
||||
"KR",
|
||||
candidates,
|
||||
today=date(2026, 2, 8),
|
||||
current_holdings=holdings,
|
||||
)
|
||||
|
||||
codes = [sp.stock_code for sp in pb.stock_playbooks]
|
||||
assert "005930" in codes
|
||||
assert holding_code in codes
|
||||
|
||||
@@ -440,3 +440,135 @@ class TestEvaluate:
|
||||
assert result.action == ScenarioAction.BUY
|
||||
assert result.match_details["rsi"] == 25.0
|
||||
assert isinstance(result.match_details["rsi"], float)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Position-aware condition tests (#171)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPositionAwareConditions:
|
||||
"""Tests for unrealized_pnl_pct and holding_days condition fields."""
|
||||
|
||||
def test_evaluate_condition_unrealized_pnl_above_matches(
|
||||
self, engine: ScenarioEngine
|
||||
) -> None:
|
||||
"""unrealized_pnl_pct_above should match when P&L exceeds threshold."""
|
||||
condition = StockCondition(unrealized_pnl_pct_above=3.0)
|
||||
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": 5.0}) is True
|
||||
|
||||
def test_evaluate_condition_unrealized_pnl_above_no_match(
|
||||
self, engine: ScenarioEngine
|
||||
) -> None:
|
||||
"""unrealized_pnl_pct_above should NOT match when P&L is below threshold."""
|
||||
condition = StockCondition(unrealized_pnl_pct_above=3.0)
|
||||
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": 2.0}) is False
|
||||
|
||||
def test_evaluate_condition_unrealized_pnl_below_matches(
|
||||
self, engine: ScenarioEngine
|
||||
) -> None:
|
||||
"""unrealized_pnl_pct_below should match when P&L is under threshold."""
|
||||
condition = StockCondition(unrealized_pnl_pct_below=-2.0)
|
||||
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": -3.5}) is True
|
||||
|
||||
def test_evaluate_condition_unrealized_pnl_below_no_match(
|
||||
self, engine: ScenarioEngine
|
||||
) -> None:
|
||||
"""unrealized_pnl_pct_below should NOT match when P&L is above threshold."""
|
||||
condition = StockCondition(unrealized_pnl_pct_below=-2.0)
|
||||
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": -1.0}) is False
|
||||
|
||||
def test_evaluate_condition_holding_days_above_matches(
|
||||
self, engine: ScenarioEngine
|
||||
) -> None:
|
||||
"""holding_days_above should match when position held longer than threshold."""
|
||||
condition = StockCondition(holding_days_above=5)
|
||||
assert engine.evaluate_condition(condition, {"holding_days": 7}) is True
|
||||
|
||||
def test_evaluate_condition_holding_days_above_no_match(
|
||||
self, engine: ScenarioEngine
|
||||
) -> None:
|
||||
"""holding_days_above should NOT match when position held shorter."""
|
||||
condition = StockCondition(holding_days_above=5)
|
||||
assert engine.evaluate_condition(condition, {"holding_days": 3}) is False
|
||||
|
||||
def test_evaluate_condition_holding_days_below_matches(
|
||||
self, engine: ScenarioEngine
|
||||
) -> None:
|
||||
"""holding_days_below should match when position held fewer days."""
|
||||
condition = StockCondition(holding_days_below=3)
|
||||
assert engine.evaluate_condition(condition, {"holding_days": 1}) is True
|
||||
|
||||
def test_evaluate_condition_holding_days_below_no_match(
|
||||
self, engine: ScenarioEngine
|
||||
) -> None:
|
||||
"""holding_days_below should NOT match when held more days."""
|
||||
condition = StockCondition(holding_days_below=3)
|
||||
assert engine.evaluate_condition(condition, {"holding_days": 5}) is False
|
||||
|
||||
def test_combined_pnl_and_holding_days(self, engine: ScenarioEngine) -> None:
|
||||
"""Combined position-aware conditions should AND-evaluate correctly."""
|
||||
condition = StockCondition(
|
||||
unrealized_pnl_pct_above=3.0,
|
||||
holding_days_above=5,
|
||||
)
|
||||
# Both met → match
|
||||
assert engine.evaluate_condition(
|
||||
condition,
|
||||
{"unrealized_pnl_pct": 4.5, "holding_days": 7},
|
||||
) is True
|
||||
# Only pnl met → no match
|
||||
assert engine.evaluate_condition(
|
||||
condition,
|
||||
{"unrealized_pnl_pct": 4.5, "holding_days": 3},
|
||||
) is False
|
||||
|
||||
def test_missing_unrealized_pnl_does_not_match(
|
||||
self, engine: ScenarioEngine
|
||||
) -> None:
|
||||
"""Missing unrealized_pnl_pct key should not match the condition."""
|
||||
condition = StockCondition(unrealized_pnl_pct_above=3.0)
|
||||
assert engine.evaluate_condition(condition, {}) is False
|
||||
|
||||
def test_missing_holding_days_does_not_match(
|
||||
self, engine: ScenarioEngine
|
||||
) -> None:
|
||||
"""Missing holding_days key should not match the condition."""
|
||||
condition = StockCondition(holding_days_above=5)
|
||||
assert engine.evaluate_condition(condition, {}) is False
|
||||
|
||||
def test_match_details_includes_position_fields(
|
||||
self, engine: ScenarioEngine
|
||||
) -> None:
|
||||
"""match_details should include position fields when condition specifies them."""
|
||||
pb = _playbook(
|
||||
scenarios=[
|
||||
StockScenario(
|
||||
condition=StockCondition(unrealized_pnl_pct_above=3.0),
|
||||
action=ScenarioAction.SELL,
|
||||
confidence=90,
|
||||
rationale="Take profit",
|
||||
)
|
||||
]
|
||||
)
|
||||
result = engine.evaluate(
|
||||
pb,
|
||||
"005930",
|
||||
{"unrealized_pnl_pct": 5.0},
|
||||
{},
|
||||
)
|
||||
assert result.action == ScenarioAction.SELL
|
||||
assert "unrealized_pnl_pct" in result.match_details
|
||||
assert result.match_details["unrealized_pnl_pct"] == 5.0
|
||||
|
||||
def test_position_conditions_parse_from_planner(self) -> None:
|
||||
"""StockCondition should accept and store new fields from JSON parsing."""
|
||||
condition = StockCondition(
|
||||
unrealized_pnl_pct_above=3.0,
|
||||
unrealized_pnl_pct_below=None,
|
||||
holding_days_above=5,
|
||||
holding_days_below=None,
|
||||
)
|
||||
assert condition.unrealized_pnl_pct_above == 3.0
|
||||
assert condition.holding_days_above == 5
|
||||
assert condition.has_any_condition() is True
|
||||
|
||||
@@ -876,6 +876,54 @@ class TestGetUpdates:
|
||||
|
||||
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:
|
||||
"""Test register_command_with_args and argument dispatch."""
|
||||
|
||||
Reference in New Issue
Block a user