Compare commits

..

1 Commits

Author SHA1 Message Date
agentson
9339824e22 feat: Daily CB P&L 기준을 당일 시작 평가금액으로 변경 (#207)
Some checks failed
CI / test (pull_request) Has been cancelled
- run_daily_session에 daily_start_eval 파라미터 추가 (반환 타입: float)
  - 세션 첫 잔고 조회 시 total_eval을 baseline으로 캡처
  - 이후 세션에서 pnl_pct = (total_eval - daily_start_eval) / daily_start_eval
  - 기존 purchase_total(누적) 기반 계산 제거
- run 함수 daily 루프에서 날짜 변경 시 baseline 리셋 (_cb_last_date 추적)
- early return 시 daily_start_eval 반환하도록 버그 수정 (None 반환 방지)
- TestDailyCBBaseline 클래스 4개 테스트 추가
  - no_markets: 0.0/기존값 그대로 반환
  - first session: total_eval을 baseline으로 캡처
  - subsequent session: 기존 baseline 유지 (덮어쓰기 방지)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 16:47:09 +09:00
2 changed files with 253 additions and 339 deletions

View File

@@ -40,7 +40,7 @@ from src.evolution.daily_review import DailyReviewer
from src.evolution.optimizer import EvolutionOptimizer from src.evolution.optimizer import EvolutionOptimizer
from src.logging.decision_logger import DecisionLogger from src.logging.decision_logger import DecisionLogger
from src.logging_config import setup_logging from src.logging_config import setup_logging
from src.markets.schedule import MARKETS, MarketInfo, get_next_market_open, get_open_markets from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets
from src.notifications.telegram_client import NotificationFilter, TelegramClient, TelegramCommandHandler from src.notifications.telegram_client import NotificationFilter, TelegramClient, TelegramCommandHandler
from src.strategy.models import DayPlaybook, MarketOutlook from src.strategy.models import DayPlaybook, MarketOutlook
from src.strategy.playbook_store import PlaybookStore from src.strategy.playbook_store import PlaybookStore
@@ -129,88 +129,6 @@ async def _retry_connection(coro_factory: Any, *args: Any, label: str = "", **kw
raise raise
async def sync_positions_from_broker(
broker: Any,
overseas_broker: Any,
db_conn: Any,
settings: "Settings",
) -> int:
"""Sync open positions from the live broker into the local DB at startup.
Fetches current holdings from the broker for all configured markets and
inserts a synthetic BUY record for any position that the DB does not
already know about. This prevents double-buy when positions were opened
in a previous session or entered manually outside the system.
Returns:
Number of new positions synced.
"""
synced = 0
seen_exchange_codes: set[str] = set()
for market_code in settings.enabled_market_list:
market = MARKETS.get(market_code)
if market is None:
continue
try:
if market.is_domestic:
balance_data = await broker.get_balance()
log_market = market_code # "KR"
else:
if market.exchange_code in seen_exchange_codes:
continue
seen_exchange_codes.add(market.exchange_code)
balance_data = await overseas_broker.get_overseas_balance(
market.exchange_code
)
log_market = market_code # e.g. "US_NASDAQ"
except ConnectionError as exc:
logger.warning(
"Startup sync: balance fetch failed for %s — skipping: %s",
market_code,
exc,
)
continue
held_codes = _extract_held_codes_from_balance(
balance_data, is_domestic=market.is_domestic
)
for stock_code in held_codes:
if get_open_position(db_conn, stock_code, log_market):
continue # already tracked
qty = _extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=market.is_domestic
)
log_trade(
conn=db_conn,
stock_code=stock_code,
action="BUY",
confidence=0,
rationale="[startup-sync] Position detected from broker at startup",
quantity=qty,
price=0.0,
market=log_market,
exchange_code=market.exchange_code,
mode=settings.MODE,
)
logger.info(
"Startup sync: %s/%s recorded as open position (qty=%d)",
log_market,
stock_code,
qty,
)
synced += 1
if synced:
logger.info(
"Startup sync complete: %d position(s) synced from broker", synced
)
else:
logger.info("Startup sync: no new positions to sync from broker")
return synced
def _extract_symbol_from_holding(item: dict[str, Any]) -> str: def _extract_symbol_from_holding(item: dict[str, Any]) -> str:
"""Extract symbol from overseas holding payload variants.""" """Extract symbol from overseas holding payload variants."""
for key in ( for key in (
@@ -653,11 +571,11 @@ async def trading_cycle(
# BUY 결정 전 기존 포지션 체크 (중복 매수 방지) # BUY 결정 전 기존 포지션 체크 (중복 매수 방지)
if decision.action == "BUY": if decision.action == "BUY":
existing_position = get_open_position(db_conn, stock_code, market.code) existing_position = get_open_position(db_conn, stock_code, market.code)
if not existing_position: if not existing_position and not market.is_domestic:
# SELL 지정가 접수 후 미체결 시 DB는 종료로 기록되나 브로커는 여전히 보유 중. # SELL 지정가 접수 후 미체결 시 DB는 종료로 기록되나 브로커는 여전히 보유 중.
# 국내/해외 모두 라이브 브로커 잔고를 authoritative source로 사용. # 이중 매수 방지를 위해 라이브 브로커 잔고를 authoritative source로 사용.
broker_qty = _extract_held_qty_from_balance( broker_qty = _extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=market.is_domestic balance_data, stock_code, is_domestic=False
) )
if broker_qty > 0: if broker_qty > 0:
existing_position = {"price": 0.0, "quantity": broker_qty} existing_position = {"price": 0.0, "quantity": broker_qty}
@@ -990,18 +908,30 @@ async def run_daily_session(
telegram: TelegramClient, telegram: TelegramClient,
settings: Settings, settings: Settings,
smart_scanner: SmartVolatilityScanner | None = None, smart_scanner: SmartVolatilityScanner | None = None,
) -> None: daily_start_eval: float = 0.0,
) -> float:
"""Execute one daily trading session. """Execute one daily trading session.
V2 proactive strategy: 1 Gemini call for playbook generation, V2 proactive strategy: 1 Gemini call for playbook generation,
then local scenario evaluation per stock (0 API calls). then local scenario evaluation per stock (0 API calls).
Args:
daily_start_eval: Portfolio evaluation at the start of the trading day.
Used to compute intra-day P&L for the Circuit Breaker.
Pass 0.0 on the first session of each day; the function will set
it from the first balance query and return it for subsequent
sessions.
Returns:
The daily_start_eval value that should be forwarded to the next
session of the same trading day.
""" """
# Get currently open markets # Get currently open markets
open_markets = get_open_markets(settings.enabled_market_list) open_markets = get_open_markets(settings.enabled_market_list)
if not open_markets: if not open_markets:
logger.info("No markets open for this session") logger.info("No markets open for this session")
return return daily_start_eval
logger.info("Starting daily trading session for %d markets", len(open_markets)) logger.info("Starting daily trading session for %d markets", len(open_markets))
@@ -1203,12 +1133,27 @@ async def run_daily_session(
): ):
total_cash = settings.PAPER_OVERSEAS_CASH total_cash = settings.PAPER_OVERSEAS_CASH
# Calculate daily P&L % # Capture the day's opening portfolio value on the first market processed
pnl_pct = ( # in this session. Used to compute intra-day P&L for the CB instead of
((total_eval - purchase_total) / purchase_total * 100) # the cumulative purchase_total which spans the entire account history.
if purchase_total > 0 if daily_start_eval <= 0 and total_eval > 0:
else 0.0 daily_start_eval = total_eval
) logger.info(
"Daily CB baseline set: total_eval=%.2f (first balance of the day)",
daily_start_eval,
)
# Daily P&L: compare current eval vs start-of-day eval.
# Falls back to purchase_total if daily_start_eval is unavailable (e.g. paper
# mode where balance API returns 0 for all values).
if daily_start_eval > 0:
pnl_pct = (total_eval - daily_start_eval) / daily_start_eval * 100
else:
pnl_pct = (
((total_eval - purchase_total) / purchase_total * 100)
if purchase_total > 0
else 0.0
)
portfolio_data = { portfolio_data = {
"portfolio_pnl_pct": pnl_pct, "portfolio_pnl_pct": pnl_pct,
"total_cash": total_cash, "total_cash": total_cash,
@@ -1242,11 +1187,11 @@ async def run_daily_session(
# BUY 중복 방지: 브로커 잔고 기반 (미체결 SELL 리밋 주문 보호) # BUY 중복 방지: 브로커 잔고 기반 (미체결 SELL 리밋 주문 보호)
if decision.action == "BUY": if decision.action == "BUY":
daily_existing = get_open_position(db_conn, stock_code, market.code) daily_existing = get_open_position(db_conn, stock_code, market.code)
if not daily_existing: if not daily_existing and not market.is_domestic:
# SELL 지정가 접수 후 미체결 시 DB는 종료로 기록되나 브로커는 여전히 보유 중. # SELL 지정가 접수 후 미체결 시 DB는 종료로 기록되나 브로커는 여전히 보유 중.
# 국내/해외 모두 라이브 브로커 잔고를 authoritative source로 사용. # 이중 매수 방지를 위해 라이브 브로커 잔고를 authoritative source로 사용.
broker_qty = _extract_held_qty_from_balance( broker_qty = _extract_held_qty_from_balance(
balance_data, stock_code, is_domestic=market.is_domestic balance_data, stock_code, is_domestic=False
) )
if broker_qty > 0: if broker_qty > 0:
daily_existing = {"price": 0.0, "quantity": broker_qty} daily_existing = {"price": 0.0, "quantity": broker_qty}
@@ -1477,6 +1422,7 @@ async def run_daily_session(
) )
logger.info("Daily trading session completed") logger.info("Daily trading session completed")
return daily_start_eval
async def _handle_market_close( async def _handle_market_close(
@@ -2094,12 +2040,6 @@ async def run(settings: Settings) -> None:
except Exception as exc: except Exception as exc:
logger.warning("System startup notification failed: %s", exc) logger.warning("System startup notification failed: %s", exc)
# Sync broker positions → DB to prevent double-buy on restart
try:
await sync_positions_from_broker(broker, overseas_broker, db_conn, settings)
except Exception as exc:
logger.warning("Startup position sync failed (non-fatal): %s", exc)
# Start command handler # Start command handler
try: try:
await command_handler.start_polling() await command_handler.start_polling()
@@ -2118,13 +2058,26 @@ async def run(settings: Settings) -> None:
session_interval = settings.SESSION_INTERVAL_HOURS * 3600 # Convert to seconds session_interval = settings.SESSION_INTERVAL_HOURS * 3600 # Convert to seconds
# daily_start_eval: portfolio eval captured at the first session of each
# trading day. Reset on calendar-date change so the CB measures only
# today's drawdown, not cumulative account history.
_cb_daily_start_eval: float = 0.0
_cb_last_date: str = ""
while not shutdown.is_set(): while not shutdown.is_set():
# Wait for trading to be unpaused # Wait for trading to be unpaused
await pause_trading.wait() await pause_trading.wait()
_run_context_scheduler(context_scheduler, now=datetime.now(UTC)) _run_context_scheduler(context_scheduler, now=datetime.now(UTC))
# Reset intra-day CB baseline on a new calendar date
today_str = datetime.now(UTC).date().isoformat()
if today_str != _cb_last_date:
_cb_last_date = today_str
_cb_daily_start_eval = 0.0
logger.info("New trading day %s — daily CB baseline reset", today_str)
try: try:
await run_daily_session( _cb_daily_start_eval = await run_daily_session(
broker, broker,
overseas_broker, overseas_broker,
scenario_engine, scenario_engine,
@@ -2138,6 +2091,7 @@ async def run(settings: Settings) -> None:
telegram, telegram,
settings, settings,
smart_scanner=smart_scanner, smart_scanner=smart_scanner,
daily_start_eval=_cb_daily_start_eval,
) )
except CircuitBreakerTripped: except CircuitBreakerTripped:
logger.critical("Circuit breaker tripped — shutting down") logger.critical("Circuit breaker tripped — shutting down")

View File

@@ -22,8 +22,8 @@ from src.main import (
_run_context_scheduler, _run_context_scheduler,
_run_evolution_loop, _run_evolution_loop,
_start_dashboard_server, _start_dashboard_server,
run_daily_session,
safe_float, safe_float,
sync_positions_from_broker,
trading_cycle, trading_cycle,
) )
from src.strategy.models import ( from src.strategy.models import (
@@ -3275,280 +3275,240 @@ class TestRetryConnection:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# sync_positions_from_broker — startup DB sync tests (issue #206) # run_daily_session — daily CB baseline (daily_start_eval) tests (issue #207)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSyncPositionsFromBroker: class TestDailyCBBaseline:
"""Tests for sync_positions_from_broker() startup position sync (issue #206). """Tests for run_daily_session's daily_start_eval (CB baseline) behaviour.
The function queries broker balances at startup and inserts synthetic BUY Issue #207: CB P&L should be computed relative to the portfolio value at
records for any holdings that the local DB is unaware of, preventing the start of each trading day, not the cumulative purchase_total.
double-buy when positions were opened in a previous session or manually.
""" """
def _make_settings(self, enabled_markets: str = "KR") -> Settings: def _make_settings(self) -> Settings:
return Settings( return Settings(
KIS_APP_KEY="k", KIS_APP_KEY="test-key",
KIS_APP_SECRET="s", KIS_APP_SECRET="test-secret",
KIS_ACCOUNT_NO="12345678-01", KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="g", GEMINI_API_KEY="test-gemini",
ENABLED_MARKETS=enabled_markets,
MODE="paper", MODE="paper",
PAPER_OVERSEAS_CASH=0,
) )
def _domestic_balance( def _make_domestic_balance(
self, self, tot_evlu_amt: float = 0.0, dnca_tot_amt: float = 50000.0
stock_code: str = "005930",
qty: int = 5,
) -> dict: ) -> dict:
return { return {
"output1": [{"pdno": stock_code, "ord_psbl_qty": str(qty)}], "output1": [],
"output2": [ "output2": [
{ {
"tot_evlu_amt": "1000000", "tot_evlu_amt": str(tot_evlu_amt),
"dnca_tot_amt": "500000", "dnca_tot_amt": str(dnca_tot_amt),
"pchs_amt_smtl_amt": "500000", "pchs_amt_smtl_amt": "40000.0",
}
],
}
def _overseas_balance(
self,
stock_code: str = "AAPL",
qty: int = 10,
) -> dict:
return {
"output1": [{"ovrs_pdno": stock_code, "ovrs_cblc_qty": str(qty)}],
"output2": [
{
"frcr_evlu_tota": "50000",
"frcr_dncl_amt_2": "10000",
"frcr_buy_amt_smtl": "40000",
} }
], ],
} }
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_syncs_domestic_position_not_in_db(self) -> None: async def test_returns_daily_start_eval_when_no_markets_open(self) -> None:
"""A domestic holding found in broker but absent from DB is inserted.""" """run_daily_session returns the unchanged daily_start_eval when no markets are open."""
settings = self._make_settings("KR") with patch("src.main.get_open_markets", return_value=[]):
db_conn = init_db(":memory:") result = await run_daily_session(
broker=MagicMock(),
overseas_broker=MagicMock(),
scenario_engine=MagicMock(),
playbook_store=MagicMock(),
pre_market_planner=MagicMock(),
risk=MagicMock(),
db_conn=init_db(":memory:"),
decision_logger=MagicMock(),
context_store=MagicMock(),
criticality_assessor=MagicMock(),
telegram=MagicMock(),
settings=self._make_settings(),
smart_scanner=None,
daily_start_eval=12345.0,
)
assert result == 12345.0
@pytest.mark.asyncio
async def test_returns_zero_when_no_markets_and_no_baseline(self) -> None:
"""run_daily_session returns 0.0 when no markets are open and daily_start_eval=0."""
with patch("src.main.get_open_markets", return_value=[]):
result = await run_daily_session(
broker=MagicMock(),
overseas_broker=MagicMock(),
scenario_engine=MagicMock(),
playbook_store=MagicMock(),
pre_market_planner=MagicMock(),
risk=MagicMock(),
db_conn=init_db(":memory:"),
decision_logger=MagicMock(),
context_store=MagicMock(),
criticality_assessor=MagicMock(),
telegram=MagicMock(),
settings=self._make_settings(),
smart_scanner=None,
daily_start_eval=0.0,
)
assert result == 0.0
@pytest.mark.asyncio
async def test_captures_total_eval_as_baseline_on_first_session(self) -> None:
"""When daily_start_eval=0 and balance returns a positive total_eval, the returned
value equals total_eval (the captured baseline for the day)."""
from src.analysis.smart_scanner import ScanCandidate
settings = self._make_settings()
broker = MagicMock() broker = MagicMock()
# Domestic balance: tot_evlu_amt=55000
broker.get_balance = AsyncMock( broker.get_balance = AsyncMock(
return_value=self._domestic_balance("005930", qty=7) return_value=self._make_domestic_balance(tot_evlu_amt=55000.0)
) )
overseas_broker = MagicMock() # Price data for the stock
broker.get_current_price = AsyncMock(
synced = await sync_positions_from_broker( return_value=(100.0, 1.5, 100.0)
broker, overseas_broker, db_conn, settings
) )
assert synced == 1
from src.db import get_open_position
pos = get_open_position(db_conn, "005930", "KR")
assert pos is not None
assert pos["quantity"] == 7
@pytest.mark.asyncio
async def test_skips_position_already_in_db(self) -> None:
"""No duplicate record is created when the position already exists in DB."""
settings = self._make_settings("KR")
db_conn = init_db(":memory:")
# Pre-insert a BUY record
log_trade(
conn=db_conn,
stock_code="005930",
action="BUY",
confidence=85,
rationale="existing position",
quantity=5,
price=70000.0,
market="KR",
exchange_code="KRX",
)
broker = MagicMock()
broker.get_balance = AsyncMock(
return_value=self._domestic_balance("005930", qty=5)
)
overseas_broker = MagicMock()
synced = await sync_positions_from_broker(
broker, overseas_broker, db_conn, settings
)
assert synced == 0
@pytest.mark.asyncio
async def test_syncs_overseas_position_not_in_db(self) -> None:
"""An overseas holding found in broker but absent from DB is inserted."""
settings = self._make_settings("US_NASDAQ")
db_conn = init_db(":memory:")
broker = MagicMock()
overseas_broker = MagicMock()
overseas_broker.get_overseas_balance = AsyncMock(
return_value=self._overseas_balance("AAPL", qty=10)
)
synced = await sync_positions_from_broker(
broker, overseas_broker, db_conn, settings
)
assert synced == 1
from src.db import get_open_position
pos = get_open_position(db_conn, "AAPL", "US_NASDAQ")
assert pos is not None
assert pos["quantity"] == 10
@pytest.mark.asyncio
async def test_returns_zero_when_broker_has_no_holdings(self) -> None:
"""Returns 0 when broker reports empty holdings."""
settings = self._make_settings("KR")
db_conn = init_db(":memory:")
broker = MagicMock()
broker.get_balance = AsyncMock(
return_value={"output1": [], "output2": [{}]}
)
overseas_broker = MagicMock()
synced = await sync_positions_from_broker(
broker, overseas_broker, db_conn, settings
)
assert synced == 0
@pytest.mark.asyncio
async def test_handles_connection_error_gracefully(self) -> None:
"""ConnectionError during balance fetch is logged but does not raise."""
settings = self._make_settings("KR")
db_conn = init_db(":memory:")
broker = MagicMock()
broker.get_balance = AsyncMock(
side_effect=ConnectionError("KIS unreachable")
)
overseas_broker = MagicMock()
synced = await sync_positions_from_broker(
broker, overseas_broker, db_conn, settings
)
assert synced == 0 # Failure treated as no-op
@pytest.mark.asyncio
async def test_deduplicates_exchange_codes_for_overseas(self) -> None:
"""Each exchange code is queried at most once even if multiple market
codes share the same exchange (defensive deduplication)."""
# Both US_NASDAQ and a hypothetical duplicate would share "NASD"
# Use two DIFFERENT overseas markets (NASD vs NYSE) to verify each is
# queried separately.
settings = self._make_settings("US_NASDAQ,US_NYSE")
db_conn = init_db(":memory:")
broker = MagicMock()
overseas_broker = MagicMock()
overseas_broker.get_overseas_balance = AsyncMock(
return_value={"output1": [], "output2": [{}]}
)
await sync_positions_from_broker(
broker, overseas_broker, db_conn, settings
)
# Two distinct exchange codes (NASD, NYSE) → 2 calls
assert overseas_broker.get_overseas_balance.call_count == 2
# ---------------------------------------------------------------------------
# Domestic BUY double-prevention (issue #206) — trading_cycle integration
# ---------------------------------------------------------------------------
class TestDomesticBuyDoublePreventionTradingCycle:
"""Verify domestic BUY suppression using broker balance in trading_cycle.
Issue #206: the broker-balance check was overseas-only; domestic stocks
were not protected against double-buy caused by untracked positions.
"""
@pytest.mark.asyncio
async def test_domestic_buy_suppressed_when_broker_holds_stock(
self,
) -> None:
"""BUY for a domestic stock must be suppressed when broker holds it,
even if the DB shows no open position."""
db_conn = init_db(":memory:")
# DB: no open position for 005930
broker = MagicMock()
broker.get_current_price = AsyncMock(return_value=(70000.0, 1.0, 0.0))
# Broker balance: holds 5 shares of 005930
broker.get_balance = AsyncMock(
return_value={
"output1": [{"pdno": "005930", "ord_psbl_qty": "5"}],
"output2": [
{
"tot_evlu_amt": "1000000",
"dnca_tot_amt": "500000",
"pchs_amt_smtl_amt": "500000",
}
],
}
)
broker.send_order = AsyncMock(return_value={"msg1": "주문접수"})
market = MagicMock() market = MagicMock()
market.name = "KR" market.name = "KR"
market.code = "KR" market.code = "KR"
market.exchange_code = "KRX" market.exchange_code = "KRX"
market.is_domestic = True market.is_domestic = True
market.timezone = __import__("zoneinfo").ZoneInfo("Asia/Seoul")
engine = MagicMock(spec=ScenarioEngine) smart_scanner = MagicMock()
engine.evaluate = MagicMock(return_value=_make_buy_match("005930")) smart_scanner.scan = AsyncMock(
return_value=[
ScanCandidate(
stock_code="005930",
name="Samsung",
price=100.0,
volume=1_000_000.0,
volume_ratio=2.5,
rsi=45.0,
signal="momentum",
score=80.0,
)
]
)
playbook_store = MagicMock()
playbook_store.load = MagicMock(return_value=_make_playbook("KR"))
scenario_engine = MagicMock(spec=ScenarioEngine)
scenario_engine.evaluate = MagicMock(return_value=_make_hold_match("005930"))
risk = MagicMock()
risk.check_circuit_breaker = MagicMock()
risk.check_fat_finger = MagicMock()
telegram = MagicMock() telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock() telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock() telegram.notify_scenario_matched = AsyncMock()
decision_logger = MagicMock() decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="d1") decision_logger.log_decision = MagicMock(return_value="d1")
settings = Settings( async def _passthrough(fn, *a, label: str = "", **kw): # type: ignore[override]
KIS_APP_KEY="k", return await fn(*a, **kw)
KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01", with patch("src.main.get_open_markets", return_value=[market]), \
GEMINI_API_KEY="g", patch("src.main._retry_connection", new=_passthrough):
MODE="paper", result = await run_daily_session(
broker=broker,
overseas_broker=MagicMock(),
scenario_engine=scenario_engine,
playbook_store=playbook_store,
pre_market_planner=MagicMock(),
risk=risk,
db_conn=init_db(":memory:"),
decision_logger=decision_logger,
context_store=MagicMock(),
criticality_assessor=MagicMock(),
telegram=telegram,
settings=settings,
smart_scanner=smart_scanner,
daily_start_eval=0.0,
)
assert result == 55000.0 # captured from tot_evlu_amt
@pytest.mark.asyncio
async def test_does_not_overwrite_existing_baseline(self) -> None:
"""When daily_start_eval > 0, it must not be overwritten even if balance returns
a different value (baseline is fixed at the start of each trading day)."""
from src.analysis.smart_scanner import ScanCandidate
settings = self._make_settings()
broker = MagicMock()
# Balance reports a different eval value (market moved during the day)
broker.get_balance = AsyncMock(
return_value=self._make_domestic_balance(tot_evlu_amt=58000.0)
)
broker.get_current_price = AsyncMock(return_value=(100.0, 1.5, 100.0))
market = MagicMock()
market.name = "KR"
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
market.timezone = __import__("zoneinfo").ZoneInfo("Asia/Seoul")
smart_scanner = MagicMock()
smart_scanner.scan = AsyncMock(
return_value=[
ScanCandidate(
stock_code="005930",
name="Samsung",
price=100.0,
volume=1_000_000.0,
volume_ratio=2.5,
rsi=45.0,
signal="momentum",
score=80.0,
)
]
) )
await trading_cycle( playbook_store = MagicMock()
broker=broker, playbook_store.load = MagicMock(return_value=_make_playbook("KR"))
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=_make_playbook(market="KR"),
risk=MagicMock(),
db_conn=db_conn,
decision_logger=decision_logger,
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=telegram,
settings=settings,
market=market,
stock_code="005930",
scan_candidates={"KR": {}},
)
# BUY must NOT have been executed because broker still holds the stock scenario_engine = MagicMock(spec=ScenarioEngine)
broker.send_order.assert_not_called() scenario_engine.evaluate = MagicMock(return_value=_make_hold_match("005930"))
risk = MagicMock()
risk.check_circuit_breaker = MagicMock()
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="d1")
async def _passthrough(fn, *a, label: str = "", **kw): # type: ignore[override]
return await fn(*a, **kw)
with patch("src.main.get_open_markets", return_value=[market]), \
patch("src.main._retry_connection", new=_passthrough):
result = await run_daily_session(
broker=broker,
overseas_broker=MagicMock(),
scenario_engine=scenario_engine,
playbook_store=playbook_store,
pre_market_planner=MagicMock(),
risk=risk,
db_conn=init_db(":memory:"),
decision_logger=decision_logger,
context_store=MagicMock(),
criticality_assessor=MagicMock(),
telegram=telegram,
settings=settings,
smart_scanner=smart_scanner,
daily_start_eval=55000.0, # existing baseline
)
# Must return the original baseline, NOT the new total_eval (58000)
assert result == 55000.0