Compare commits
3 Commits
e6eae6c6e0
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff9c4d6082 | ||
| 25ad4776c9 | |||
|
|
9339824e22 |
152
src/main.py
152
src/main.py
@@ -40,7 +40,7 @@ from src.evolution.daily_review import DailyReviewer
|
||||
from src.evolution.optimizer import EvolutionOptimizer
|
||||
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.markets.schedule import MARKETS, MarketInfo, get_next_market_open, get_open_markets
|
||||
from src.notifications.telegram_client import NotificationFilter, TelegramClient, TelegramCommandHandler
|
||||
from src.strategy.models import DayPlaybook, MarketOutlook
|
||||
from src.strategy.playbook_store import PlaybookStore
|
||||
@@ -129,6 +129,88 @@ async def _retry_connection(coro_factory: Any, *args: Any, label: str = "", **kw
|
||||
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:
|
||||
"""Extract symbol from overseas holding payload variants."""
|
||||
for key in (
|
||||
@@ -571,11 +653,11 @@ async def trading_cycle(
|
||||
# BUY 결정 전 기존 포지션 체크 (중복 매수 방지)
|
||||
if decision.action == "BUY":
|
||||
existing_position = get_open_position(db_conn, stock_code, market.code)
|
||||
if not existing_position and not market.is_domestic:
|
||||
if not existing_position:
|
||||
# SELL 지정가 접수 후 미체결 시 DB는 종료로 기록되나 브로커는 여전히 보유 중.
|
||||
# 이중 매수 방지를 위해 라이브 브로커 잔고를 authoritative source로 사용.
|
||||
# 국내/해외 모두 라이브 브로커 잔고를 authoritative source로 사용.
|
||||
broker_qty = _extract_held_qty_from_balance(
|
||||
balance_data, stock_code, is_domestic=False
|
||||
balance_data, stock_code, is_domestic=market.is_domestic
|
||||
)
|
||||
if broker_qty > 0:
|
||||
existing_position = {"price": 0.0, "quantity": broker_qty}
|
||||
@@ -908,18 +990,30 @@ async def run_daily_session(
|
||||
telegram: TelegramClient,
|
||||
settings: Settings,
|
||||
smart_scanner: SmartVolatilityScanner | None = None,
|
||||
) -> None:
|
||||
daily_start_eval: float = 0.0,
|
||||
) -> float:
|
||||
"""Execute one daily trading session.
|
||||
|
||||
V2 proactive strategy: 1 Gemini call for playbook generation,
|
||||
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
|
||||
open_markets = get_open_markets(settings.enabled_market_list)
|
||||
|
||||
if not open_markets:
|
||||
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))
|
||||
|
||||
@@ -1121,7 +1215,22 @@ async def run_daily_session(
|
||||
):
|
||||
total_cash = settings.PAPER_OVERSEAS_CASH
|
||||
|
||||
# Calculate daily P&L %
|
||||
# Capture the day's opening portfolio value on the first market processed
|
||||
# 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 = (
|
||||
((total_eval - purchase_total) / purchase_total * 100)
|
||||
if purchase_total > 0
|
||||
@@ -1160,11 +1269,11 @@ async def run_daily_session(
|
||||
# BUY 중복 방지: 브로커 잔고 기반 (미체결 SELL 리밋 주문 보호)
|
||||
if decision.action == "BUY":
|
||||
daily_existing = get_open_position(db_conn, stock_code, market.code)
|
||||
if not daily_existing and not market.is_domestic:
|
||||
if not daily_existing:
|
||||
# SELL 지정가 접수 후 미체결 시 DB는 종료로 기록되나 브로커는 여전히 보유 중.
|
||||
# 이중 매수 방지를 위해 라이브 브로커 잔고를 authoritative source로 사용.
|
||||
# 국내/해외 모두 라이브 브로커 잔고를 authoritative source로 사용.
|
||||
broker_qty = _extract_held_qty_from_balance(
|
||||
balance_data, stock_code, is_domestic=False
|
||||
balance_data, stock_code, is_domestic=market.is_domestic
|
||||
)
|
||||
if broker_qty > 0:
|
||||
daily_existing = {"price": 0.0, "quantity": broker_qty}
|
||||
@@ -1395,6 +1504,7 @@ async def run_daily_session(
|
||||
)
|
||||
|
||||
logger.info("Daily trading session completed")
|
||||
return daily_start_eval
|
||||
|
||||
|
||||
async def _handle_market_close(
|
||||
@@ -2012,6 +2122,12 @@ async def run(settings: Settings) -> None:
|
||||
except Exception as 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
|
||||
try:
|
||||
await command_handler.start_polling()
|
||||
@@ -2030,13 +2146,26 @@ async def run(settings: Settings) -> None:
|
||||
|
||||
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():
|
||||
# Wait for trading to be unpaused
|
||||
await pause_trading.wait()
|
||||
_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:
|
||||
await run_daily_session(
|
||||
_cb_daily_start_eval = await run_daily_session(
|
||||
broker,
|
||||
overseas_broker,
|
||||
scenario_engine,
|
||||
@@ -2050,6 +2179,7 @@ async def run(settings: Settings) -> None:
|
||||
telegram,
|
||||
settings,
|
||||
smart_scanner=smart_scanner,
|
||||
daily_start_eval=_cb_daily_start_eval,
|
||||
)
|
||||
except CircuitBreakerTripped:
|
||||
logger.critical("Circuit breaker tripped — shutting down")
|
||||
|
||||
@@ -22,7 +22,9 @@ from src.main import (
|
||||
_run_context_scheduler,
|
||||
_run_evolution_loop,
|
||||
_start_dashboard_server,
|
||||
run_daily_session,
|
||||
safe_float,
|
||||
sync_positions_from_broker,
|
||||
trading_cycle,
|
||||
)
|
||||
from src.strategy.models import (
|
||||
@@ -3271,3 +3273,522 @@ class TestRetryConnection:
|
||||
await _retry_connection(bad_input, label="bad")
|
||||
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSyncPositionsFromBroker:
|
||||
"""Tests for sync_positions_from_broker() startup position sync (issue #206).
|
||||
|
||||
The function queries broker balances at startup and inserts synthetic BUY
|
||||
records for any holdings that the local DB is unaware of, preventing
|
||||
double-buy when positions were opened in a previous session or manually.
|
||||
"""
|
||||
|
||||
def _make_settings(self, enabled_markets: str = "KR") -> Settings:
|
||||
return Settings(
|
||||
KIS_APP_KEY="k",
|
||||
KIS_APP_SECRET="s",
|
||||
KIS_ACCOUNT_NO="12345678-01",
|
||||
GEMINI_API_KEY="g",
|
||||
ENABLED_MARKETS=enabled_markets,
|
||||
MODE="paper",
|
||||
)
|
||||
|
||||
def _domestic_balance(
|
||||
self,
|
||||
stock_code: str = "005930",
|
||||
qty: int = 5,
|
||||
) -> dict:
|
||||
return {
|
||||
"output1": [{"pdno": stock_code, "ord_psbl_qty": str(qty)}],
|
||||
"output2": [
|
||||
{
|
||||
"tot_evlu_amt": "1000000",
|
||||
"dnca_tot_amt": "500000",
|
||||
"pchs_amt_smtl_amt": "500000",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
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
|
||||
async def test_syncs_domestic_position_not_in_db(self) -> None:
|
||||
"""A domestic holding found in broker but absent from DB is inserted."""
|
||||
settings = self._make_settings("KR")
|
||||
db_conn = init_db(":memory:")
|
||||
|
||||
broker = MagicMock()
|
||||
broker.get_balance = AsyncMock(
|
||||
return_value=self._domestic_balance("005930", qty=7)
|
||||
)
|
||||
overseas_broker = MagicMock()
|
||||
|
||||
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, "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.name = "KR"
|
||||
market.code = "KR"
|
||||
market.exchange_code = "KRX"
|
||||
market.is_domestic = True
|
||||
|
||||
engine = MagicMock(spec=ScenarioEngine)
|
||||
engine.evaluate = MagicMock(return_value=_make_buy_match("005930"))
|
||||
|
||||
telegram = MagicMock()
|
||||
telegram.notify_trade_execution = AsyncMock()
|
||||
telegram.notify_fat_finger = AsyncMock()
|
||||
telegram.notify_circuit_breaker = AsyncMock()
|
||||
telegram.notify_scenario_matched = AsyncMock()
|
||||
|
||||
decision_logger = MagicMock()
|
||||
decision_logger.log_decision = MagicMock(return_value="d1")
|
||||
|
||||
settings = Settings(
|
||||
KIS_APP_KEY="k",
|
||||
KIS_APP_SECRET="s",
|
||||
KIS_ACCOUNT_NO="12345678-01",
|
||||
GEMINI_API_KEY="g",
|
||||
MODE="paper",
|
||||
)
|
||||
|
||||
await trading_cycle(
|
||||
broker=broker,
|
||||
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
|
||||
broker.send_order.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user