1 Commits

Author SHA1 Message Date
agentson
d01f4d93a3 feat: 시작 시 브로커 포지션 → DB 동기화 및 국내주식 이중 매수 방지 (#206)
Some checks failed
CI / test (pull_request) Has been cancelled
- sync_positions_from_broker() 함수 추가
  - 시스템 시작 시 브로커 잔고를 조회해 DB에 없는 포지션을 BUY 레코드로 삽입
  - 국내: get_balance(), 해외: get_overseas_balance(exchange_code) 순회
  - ConnectionError는 경고 로그만 남기고 계속 진행 (non-fatal)
  - 동일 exchange_code 중복 조회 방지 (seen_exchange_codes 집합)
  - run() 초기화 후 최초 한 번 자동 호출

- 국내주식 BUY 이중 방지 로직 확장
  - trading_cycle 및 run_daily_session에서 기존에 해외 전용(not market.is_domestic)
    으로만 적용하던 broker balance 체크를 국내/해외 공통으로 변경
  - _extract_held_qty_from_balance(is_domestic=market.is_domestic)

- 테스트 (827 passed)
  - TestSyncPositionsFromBroker (6개): 국내/해외 동기화, 중복 skip, 공란, ConnectionError, dedup
  - TestDomesticBuyDoublePreventionTradingCycle (1개): 국내 보유 주식 BUY 억제

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

View File

@@ -990,30 +990,18 @@ async def run_daily_session(
telegram: TelegramClient, telegram: TelegramClient,
settings: Settings, settings: Settings,
smart_scanner: SmartVolatilityScanner | None = None, smart_scanner: SmartVolatilityScanner | None = None,
daily_start_eval: float = 0.0, ) -> None:
) -> 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 daily_start_eval return
logger.info("Starting daily trading session for %d markets", len(open_markets)) logger.info("Starting daily trading session for %d markets", len(open_markets))
@@ -1215,22 +1203,7 @@ async def run_daily_session(
): ):
total_cash = settings.PAPER_OVERSEAS_CASH total_cash = settings.PAPER_OVERSEAS_CASH
# Capture the day's opening portfolio value on the first market processed # Calculate daily P&L %
# in this session. Used to compute intra-day P&L for the CB instead of
# the cumulative purchase_total which spans the entire account history.
if daily_start_eval <= 0 and total_eval > 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 = ( pnl_pct = (
((total_eval - purchase_total) / purchase_total * 100) ((total_eval - purchase_total) / purchase_total * 100)
if purchase_total > 0 if purchase_total > 0
@@ -1504,7 +1477,6 @@ 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(
@@ -2146,26 +2118,13 @@ 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:
_cb_daily_start_eval = await run_daily_session( await run_daily_session(
broker, broker,
overseas_broker, overseas_broker,
scenario_engine, scenario_engine,
@@ -2179,7 +2138,6 @@ 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,7 +22,6 @@ 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, sync_positions_from_broker,
trading_cycle, trading_cycle,
@@ -3275,245 +3274,6 @@ class TestRetryConnection:
assert call_count == 1 # No retry for non-ConnectionError assert call_count == 1 # No retry for non-ConnectionError
# run_daily_session — daily CB baseline (daily_start_eval) tests (issue #207)
# ---------------------------------------------------------------------------
class TestDailyCBBaseline:
"""Tests for run_daily_session's daily_start_eval (CB baseline) behaviour.
Issue #207: CB P&L should be computed relative to the portfolio value at
the start of each trading day, not the cumulative purchase_total.
"""
def _make_settings(self) -> Settings:
return Settings(
KIS_APP_KEY="test-key",
KIS_APP_SECRET="test-secret",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="test-gemini",
MODE="paper",
PAPER_OVERSEAS_CASH=0,
)
def _make_domestic_balance(
self, tot_evlu_amt: float = 0.0, dnca_tot_amt: float = 50000.0
) -> dict:
return {
"output1": [],
"output2": [
{
"tot_evlu_amt": str(tot_evlu_amt),
"dnca_tot_amt": str(dnca_tot_amt),
"pchs_amt_smtl_amt": "40000.0",
}
],
}
@pytest.mark.asyncio
async def test_returns_daily_start_eval_when_no_markets_open(self) -> None:
"""run_daily_session returns the unchanged daily_start_eval when no markets are open."""
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=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()
# Domestic balance: tot_evlu_amt=55000
broker.get_balance = AsyncMock(
return_value=self._make_domestic_balance(tot_evlu_amt=55000.0)
)
# Price data for the stock
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,
)
]
)
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.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=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,
)
]
)
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()
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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# sync_positions_from_broker — startup DB sync tests (issue #206) # sync_positions_from_broker — startup DB sync tests (issue #206)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------