- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
420 lines
20 KiB
Python
420 lines
20 KiB
Python
"""DT 청산 하드닝 라운드 1 회귀 테스트 (2026-10-09, daytrading/exit_service.py).
|
||
|
||
R1-1 고아 근거를 DT 원장 순체결(BUY−SELL)>0 으로 한정 — 수동·HTS 합산분 매도 금지
|
||
R1-2 고아 매도 결과 불명이어도 후보에서 빠지지 않는다(불명 재판정 경로 재도달)
|
||
R1-3/7 KB금융(105560) 재현 — 'synced' active 행 공존·DT 원장 순보유 0 → 팔지 않고 수동 경보
|
||
R1-4 전일 포지션은 일반 청산 경로(손절 선판정·분봉·EOD·일일한도·공통 종단)에서 제외
|
||
R1-5 매도 미체결 복원 시 ActiveTrade(day_trade) 도 되살린다
|
||
R1-6 근거 없음 → error + 텔레그램(하루 1회)
|
||
R1-9 2단계 판정가 — 마지막 1분봉 이후 관측된 REST 현재가 재사용
|
||
|
||
목킹 원칙(CLAUDE.md §4.1): 주문은 공통 종단(`_execute_exit_impl`·`safe_place_order`)에 걸고,
|
||
호출되면 안 되는 경로는 BaseException 발생형. 기대값은 settings.* 에서 계산한다.
|
||
"""
|
||
import asyncio
|
||
from datetime import datetime, timedelta
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock, patch
|
||
|
||
import pytest
|
||
|
||
from app.core.config import settings
|
||
from app.services.day_trading_strategy import MinuteCandle
|
||
from app.services.daytrading import exit_service as es
|
||
from app.services.daytrading import fill_reconciler as fr
|
||
from app.services.daytrading.position_manager import day_trading_state
|
||
|
||
|
||
class MustNotBeCalled(BaseException):
|
||
"""호출되면 안 되는 경로 (except Exception 에 삼켜지지 않게 BaseException)."""
|
||
|
||
|
||
def _boom(name):
|
||
async def _f(*a, **k):
|
||
raise MustNotBeCalled(f"호출 금지 경로 도달: {name}")
|
||
return _f
|
||
|
||
|
||
class _SeqSession:
|
||
def __init__(self, results):
|
||
self._results = list(results)
|
||
|
||
async def __aenter__(self):
|
||
return self
|
||
|
||
async def __aexit__(self, *a):
|
||
return False
|
||
|
||
async def execute(self, *a, **k):
|
||
rows = self._results.pop(0) if self._results else []
|
||
return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: rows))
|
||
|
||
async def commit(self):
|
||
raise MustNotBeCalled("commit")
|
||
|
||
|
||
def _sess(*results):
|
||
return lambda: _SeqSession(results)
|
||
|
||
|
||
def _balance(rows):
|
||
return AsyncMock(return_value={
|
||
"rt_cd": "0",
|
||
"output1": [{"pdno": c, "hldg_qty": str(h), "ord_psbl_qty": str(s)} for c, h, s in rows],
|
||
"output2": [{}],
|
||
})
|
||
|
||
|
||
KB = "105560"
|
||
|
||
|
||
def _pf(code=KB, qty=3):
|
||
return SimpleNamespace(stock_code=code, stock_name="KB금융", quantity=qty,
|
||
average_price=167_881.25, strategy_type="daytrading")
|
||
|
||
|
||
def _ev(code=KB, qty=3, days_ago=3):
|
||
return SimpleNamespace(
|
||
stock_code=code, stock_name="KB금융", quantity=qty, entry_price=166_866.0,
|
||
highest_since_entry=167_000.0, status="closed", strategy_name="day_trade",
|
||
created_at=datetime.now().replace(microsecond=0) - timedelta(days=days_ago),
|
||
)
|
||
|
||
|
||
def _th_row(code, side, qty, status="COMPLETED", executed=None):
|
||
return SimpleNamespace(stock_code=code, order_type=side, status=status,
|
||
executed_quantity=qty if executed is None else executed,
|
||
order_quantity=qty)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _clean():
|
||
from app.services.telegram_notifier import telegram_notifier
|
||
saved = (dict(day_trading_state.positions), set(day_trading_state.closed_today),
|
||
set(day_trading_state.dt_exited_today), day_trading_state.daily_pnl,
|
||
day_trading_state.last_total_assets)
|
||
day_trading_state.reset_if_new_day()
|
||
day_trading_state.positions.clear()
|
||
day_trading_state.closed_today.clear()
|
||
day_trading_state.dt_exited_today.clear()
|
||
day_trading_state.daily_pnl = 0.0
|
||
for coll in (es._stale_sell_attempted, es._stale_sell_failures, es._last_exit_order_outcome,
|
||
es._unresolved_exit_unknown, es._stale_cleaned, es._exit_tokens,
|
||
es._recent_dt_sell_finalized, es._manual_alerted, es._rest_price_seen):
|
||
coll.clear()
|
||
day_trading_state.exits_in_progress.clear()
|
||
tg = AsyncMock()
|
||
with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \
|
||
patch.object(telegram_notifier, "notify_system_warning", tg):
|
||
yield tg
|
||
day_trading_state.positions.clear()
|
||
day_trading_state.positions.update(saved[0])
|
||
day_trading_state.closed_today.clear()
|
||
day_trading_state.closed_today.update(saved[1])
|
||
day_trading_state.dt_exited_today.clear()
|
||
day_trading_state.dt_exited_today.update(saved[2])
|
||
day_trading_state.daily_pnl = saved[3]
|
||
day_trading_state.last_total_assets = saved[4]
|
||
for coll in (es._unresolved_exit_unknown, es._manual_alerted, es._rest_price_seen,
|
||
es._recent_dt_sell_finalized):
|
||
coll.clear()
|
||
|
||
|
||
async def _run(session_factory, balance, exec_mock):
|
||
with patch.object(es, "is_market_open", return_value=True), \
|
||
patch("app.core.database.AsyncSessionLocal", session_factory), \
|
||
patch.object(es.korea_investment_api, "get_balance", balance), \
|
||
patch.object(es.korea_investment_api, "get_current_price",
|
||
AsyncMock(return_value={"output": {"stck_prpr": "163400"}})), \
|
||
patch.object(es, "_execute_exit_impl", exec_mock):
|
||
out = await es.close_stale_positions()
|
||
await asyncio.sleep(0) # 경보 태스크 실행
|
||
await asyncio.sleep(0)
|
||
return out
|
||
|
||
|
||
# 쿼리 순서: ①DT active ③Portfolio DT ④DT 근거 행 ④b DT 원장 ⑤추가 비-DT
|
||
|
||
# ═══ R1-1 ════════════════════════════════════════════════════════════════════
|
||
|
||
def test_net_qty_from_history_is_conservative():
|
||
rows = [
|
||
_th_row("A", "BUY", 3), _th_row("A", "SELL", 3), # 정상 왕복 → 0
|
||
_th_row("B", "BUY", 3), _th_row("B", "SELL", 3, "CANCELLED", executed=0), # 0체결 → 3
|
||
_th_row("C", "BUY", 3), _th_row("C", "SELL", 3, "PENDING", executed=None), # 불명 = 팔림
|
||
_th_row("D", "BUY", 3, "PENDING"), # 미확인 매수 미산정
|
||
_th_row("E", "BUY", 3), _th_row("E", "SELL", 3, "FAILED"), # 실패 매도 미산정
|
||
]
|
||
net = es._dt_net_qty_from_history(rows)
|
||
assert net.get("A", 0) == 0
|
||
assert net["B"] == 3
|
||
assert net.get("C", 0) == 0
|
||
assert net.get("D", 0) == 0
|
||
assert net["E"] == 3
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_manual_buy_after_normal_dt_exit_is_not_sold(_clean):
|
||
"""DT 3주 정상 청산 뒤 수동 10주 — 'daytrading' 태그·closed DT 행이 남아도 팔지 않는다."""
|
||
th = [_th_row(KB, "BUY", 3), _th_row(KB, "SELL", 3)]
|
||
out = await _run(_sess([], [_pf(qty=10)], [_ev()], th),
|
||
_balance([(KB, 10, 10)]), _boom("_execute_exit_impl"))
|
||
assert out == []
|
||
assert _clean.await_count == 1 # 수동 확인 경보
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_zero_fill_cancelled_sell_is_orphan_capped_by_net(_clean):
|
||
"""근거 행 5주·Portfolio 10주라도 DT 원장 순보유(2주)까지만 판다."""
|
||
th = [_th_row(KB, "BUY", 5), _th_row(KB, "SELL", 3),
|
||
_th_row(KB, "SELL", 2, "CANCELLED", executed=0)]
|
||
exec_mock = AsyncMock(return_value={"stock_code": KB})
|
||
await _run(_sess([], [_pf(qty=10)], [_ev(qty=5)], th, []),
|
||
_balance([(KB, 10, 10)]), exec_mock)
|
||
assert exec_mock.await_count == 1
|
||
assert exec_mock.await_args.args[1]["quantity"] == 2
|
||
assert exec_mock.await_args.kwargs.get("is_stale") is True
|
||
|
||
|
||
# ═══ R1-2 ════════════════════════════════════════════════════════════════════
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_orphan_with_unresolved_unknown_stays_candidate():
|
||
"""결과 불명이 남아 있어도 후보에 남아 stale 경로(불명 재판정)에 도달한다."""
|
||
es._unresolved_exit_unknown[(es._today_key(), KB)] = {
|
||
"qty": 3, "since": es._kst_now(), "total_qty": 3,
|
||
}
|
||
th = [_th_row(KB, "BUY", 3), _th_row(KB, "SELL", 3, "CANCELLED", executed=0)]
|
||
exec_mock = AsyncMock(return_value={"stock_code": KB})
|
||
await _run(_sess([], [_pf()], [_ev()], th, []), _balance([(KB, 3, 3)]), exec_mock)
|
||
assert exec_mock.await_count == 1
|
||
|
||
|
||
def test_restore_path_still_blocks_unresolved_unknown():
|
||
es._unresolved_exit_unknown[(es._today_key(), KB)] = {"qty": 1, "since": es._kst_now()}
|
||
assert es._orphan_blocked_reason(KB, es._today_key()) == "결과 불명 매도 미해결"
|
||
assert es._orphan_blocked_reason_v2(KB, es._today_key(), block_unresolved_unknown=False) is None
|
||
|
||
|
||
# ═══ R1-3 / R1-7 (KB금융 운영 상태 재현) ═════════════════════════════════════
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_kb_synced_row_and_net_zero_history_alerts_instead_of_selling(_clean):
|
||
"""운영 DB(10/09): Portfolio 'daytrading' 3, DT 행 전부 closed, 'synced' active 3,
|
||
DT 원장 9/30·10/01·10/06 모두 3/3 왕복 → DT 몫 없음. 팔지 않고 수동 경보."""
|
||
th = [_th_row(KB, s, 3) for s in ("BUY", "SELL", "BUY", "SELL", "BUY", "SELL")]
|
||
out = await _run(_sess([], [_pf()], [_ev()], th), _balance([(KB, 3, 3)]),
|
||
_boom("_execute_exit_impl"))
|
||
assert out == []
|
||
assert _clean.await_count == 1
|
||
assert "105560" in _clean.await_args.args[1]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_orphan_with_synced_row_and_no_dt_available_alerts_error(_clean):
|
||
"""DT 원장상 3주 남았지만 실보유 3주 전부 'synced' 행 몫 → 팔지 않고 error+텔레그램."""
|
||
synced = SimpleNamespace(stock_code=KB, quantity=3)
|
||
th = [_th_row(KB, "BUY", 3), _th_row(KB, "SELL", 3, "CANCELLED", executed=0)]
|
||
await _run(_sess([], [_pf()], [_ev()], th, [synced]), _balance([(KB, 3, 3)]),
|
||
_boom("_execute_exit_impl"))
|
||
assert _clean.await_count == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_orphan_with_synced_row_sells_only_dt_share():
|
||
synced = SimpleNamespace(stock_code=KB, quantity=3)
|
||
th = [_th_row(KB, "BUY", 3), _th_row(KB, "SELL", 3, "CANCELLED", executed=0)]
|
||
exec_mock = AsyncMock(return_value={"stock_code": KB})
|
||
await _run(_sess([], [_pf(qty=6)], [_ev()], th, [synced]), _balance([(KB, 6, 6)]), exec_mock)
|
||
assert exec_mock.await_args.args[1]["quantity"] == 3
|
||
|
||
|
||
# ═══ R1-6 ════════════════════════════════════════════════════════════════════
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_no_evidence_alerts_once_per_day(_clean):
|
||
for _ in range(2):
|
||
await _run(_sess([], [_pf()], [], []), _balance([(KB, 3, 3)]), _boom("_execute_exit_impl"))
|
||
assert _clean.await_count == 1
|
||
|
||
|
||
# ═══ R1-4 전일 포지션은 일반 경로 제외 ═══════════════════════════════════════
|
||
|
||
def _stale_pos(qty=10, entry=10_000.0):
|
||
return {"stock_name": "전일", "entry_price": entry, "quantity": qty,
|
||
"stop_loss": entry * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT),
|
||
"target_price": entry * 1.05, "highest_price": entry,
|
||
"entry_time": datetime.now() - timedelta(days=1), "order_id": "1"}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fast_stop_and_minute_check_skip_stale_position():
|
||
day_trading_state.positions["111111"] = _stale_pos()
|
||
with patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")), \
|
||
patch.object(es.korea_investment_api, "get_minute_chart_full_day", _boom("chart")), \
|
||
patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")):
|
||
assert await es._fast_stop_check_impl("111111") == (True, None)
|
||
assert await es._check_single_position("111111") is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_common_terminus_refuses_stale_position_in_general_path():
|
||
pos = _stale_pos()
|
||
day_trading_state.positions["111111"] = pos
|
||
import app.services.trade_executor as te
|
||
with patch.object(te, "safe_place_order", _boom("safe_place_order")):
|
||
assert await es._execute_exit("111111", pos, 9_000.0, "손절") is None
|
||
assert "111111" not in day_trading_state.exits_in_progress
|
||
assert day_trading_state.daily_pnl == 0.0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_force_close_all_routes_stale_to_close_stale_positions():
|
||
day_trading_state.positions["111111"] = _stale_pos()
|
||
stale_mock = AsyncMock(return_value=[{"stock_code": "111111"}])
|
||
|
||
async def _exec(code, pos, price, reason, sell_pct, is_stale):
|
||
if not is_stale:
|
||
raise MustNotBeCalled("전일 포지션이 일반 경로로 매도됨")
|
||
return None
|
||
|
||
with patch.object(es, "_execute_exit_impl", _exec), \
|
||
patch.object(es, "close_stale_positions", stale_mock), \
|
||
patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")):
|
||
out = await es.force_close_all(reason="EOD 강제 청산")
|
||
assert stale_mock.await_count == 1
|
||
assert out == [{"stock_code": "111111"}]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_stale_overnight_gap_not_counted_in_daily_loss_limit():
|
||
"""전일 포지션 -20% 갭 미실현이 있어도 당일 -한도 강제청산이 발동하지 않는다."""
|
||
pos = _stale_pos(qty=100, entry=10_000.0)
|
||
pos["current_price"] = 8_000.0
|
||
day_trading_state.positions["111111"] = pos
|
||
day_trading_state.last_total_assets = 1_000_000.0
|
||
with patch.object(es, "_ensure_total_assets_for_exit_impl", AsyncMock(return_value=1_000_000.0)), \
|
||
patch.object(es, "force_close_all", _boom("force_close_all")), \
|
||
patch.object(es, "is_eod_flat_time", return_value=False), \
|
||
patch.object(es, "close_stale_positions", AsyncMock(return_value=[])), \
|
||
patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")), \
|
||
patch.object(es.korea_investment_api, "get_minute_chart_full_day", _boom("chart")), \
|
||
patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")):
|
||
assert await es.check_and_exit_positions() == []
|
||
|
||
|
||
# ═══ R1-5 미체결 복원 → ActiveTrade 되살림 ═══════════════════════════════════
|
||
|
||
@pytest.fixture
|
||
def exit_env():
|
||
import app.services.trade_executor as te
|
||
import app.services.portfolio_cache_bridge as pcb
|
||
cap = {"upserts": []}
|
||
|
||
async def _ups(payload):
|
||
cap["upserts"].append(dict(payload))
|
||
|
||
with patch.object(te, "safe_place_order",
|
||
AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "0000123"}})), \
|
||
patch.object(es, "enqueue_trade_history", AsyncMock()), \
|
||
patch.object(es, "enqueue_close_active_trade", AsyncMock()), \
|
||
patch("app.services.db_worker.enqueue_active_trade_upsert", _ups), \
|
||
patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", AsyncMock()), \
|
||
patch.object(pcb, "reflect_sell", AsyncMock()), \
|
||
patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \
|
||
patch.object(es.loss_tracker, "save_to_db", AsyncMock()), \
|
||
patch.object(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True), \
|
||
patch.object(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0), \
|
||
patch.object(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0):
|
||
yield cap
|
||
|
||
|
||
def _today_pos(qty=10):
|
||
return {"stock_name": "테스트", "entry_price": 10_000.0, "quantity": qty,
|
||
"stop_loss": 9_700.0, "target_price": 10_450.0, "highest_price": 10_050.0,
|
||
"order_id": "0000999", "entry_time": datetime.now(), "reason": "ORB"}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_full_exit_zero_fill_reopens_active_trade(exit_env):
|
||
day_trading_state.positions["666666"] = _today_pos(10)
|
||
rows = [{"odno": "123", "pdno": "666666", "ord_qty": "10", "tot_ccld_qty": "0",
|
||
"avg_prvs": "0", "rmn_qty": "0", "cncl_yn": "Y"}]
|
||
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)):
|
||
await es._execute_exit("666666", day_trading_state.positions["666666"], 9_900.0, "EOD 강제 청산")
|
||
await asyncio.gather(*list(fr._pending_tasks))
|
||
assert day_trading_state.positions["666666"]["quantity"] == 10
|
||
last = exit_env["upserts"][-1]
|
||
assert last["strategy_name"] == "day_trade" and last["status"] == "active"
|
||
assert last["quantity"] == 10 and last["entry_price"] == 10_000.0
|
||
assert last["stop_loss"] == 9_700.0 and last["take_profit"] == 10_450.0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_partial_exit_zero_fill_restores_active_trade_quantity(exit_env):
|
||
day_trading_state.positions["666666"] = _today_pos(10)
|
||
rows = [{"odno": "123", "pdno": "666666", "ord_qty": "5", "tot_ccld_qty": "0",
|
||
"avg_prvs": "0", "rmn_qty": "0", "cncl_yn": "Y"}]
|
||
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)):
|
||
await es._execute_exit("666666", day_trading_state.positions["666666"], 10_500.0,
|
||
"목표가 도달", sell_pct=0.5)
|
||
await asyncio.gather(*list(fr._pending_tasks))
|
||
assert day_trading_state.positions["666666"]["quantity"] == 10
|
||
# finalize 가 잔량 5로 줄인 뒤, 미체결 복원이 10 으로 되돌린다
|
||
assert [u["quantity"] for u in exit_env["upserts"]] == [5, 10]
|
||
last = exit_env["upserts"][-1]
|
||
# [R4-1 2026-10-09] 체결 0 = 분할이 없었던 것 → 목표가·partial_sold 를 분할 전으로 되돌리고
|
||
# remaining_pct=1.0 으로 영속화한다(예전 기대값 inf/<1.0 은 분할 익절 영구 소실 결함이었다).
|
||
restored = day_trading_state.positions["666666"]
|
||
assert restored["target_price"] == pytest.approx(10_450.0)
|
||
assert restored["partial_sold"] is False
|
||
assert last["take_profit"] == pytest.approx(10_450.0)
|
||
assert last["remaining_pct"] == 1.0
|
||
|
||
|
||
# ═══ R1-9 REST 현재가 재사용 ═════════════════════════════════════════════════
|
||
|
||
def _candle(dt: datetime, close: float) -> MinuteCandle:
|
||
return MinuteCandle(date=dt.strftime("%Y%m%d"), time=dt.strftime("%H%M%S"),
|
||
open=close, high=close, low=close, close=close, volume=100,
|
||
cum_volume=100, cum_amount=close * 100)
|
||
|
||
|
||
def test_fresher_rest_price_rules():
|
||
now = es._kst_now()
|
||
candles = [_candle(now - timedelta(seconds=150), 100.0)]
|
||
es._rest_price_seen["A"] = (101.0, now) # 마지막 봉 +60초 이후
|
||
assert es._fresher_rest_price_impl("A", candles) == 101.0
|
||
fresh_candles = [_candle(now - timedelta(seconds=10), 100.0)] # 봉이 더 신선
|
||
assert es._fresher_rest_price_impl("A", fresh_candles) is None
|
||
_old = float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC) + float(settings.DAY_TRADE_EXIT_CHECK_TIMEOUT_SEC) + 1
|
||
es._rest_price_seen["A"] = (101.0, now - timedelta(seconds=_old)) # 같은 사이클 아님
|
||
assert es._fresher_rest_price_impl("A", candles) is None
|
||
assert es._fresher_rest_price_impl("B", candles) is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_minute_check_uses_fresher_rest_price_not_stale_candle_close():
|
||
now = es._kst_now()
|
||
pos = _today_pos(10)
|
||
pos["entry_time"] = now - timedelta(minutes=5)
|
||
day_trading_state.positions["555555"] = pos
|
||
es._rest_price_seen["555555"] = (10_020.0, now)
|
||
candles = [_candle(now - timedelta(minutes=10) + timedelta(minutes=i), 10_000.0) for i in range(8)]
|
||
c5 = [SimpleNamespace(close=9_990.0, high=9_990.0, low=9_990.0)] * 6
|
||
seen = {}
|
||
|
||
def _exit(**kw):
|
||
seen.update(kw)
|
||
return None
|
||
|
||
with patch.object(es.korea_investment_api, "get_minute_chart_full_day", AsyncMock(return_value={})), \
|
||
patch.object(es.korea_investment_api, "get_minute_chart_full_day_v2",
|
||
AsyncMock(return_value=[])), \
|
||
patch.object(es, "parse_minute_candles", return_value=candles), \
|
||
patch.object(es, "aggregate_to_5min", return_value=c5), \
|
||
patch.object(es, "check_day_trade_exit", _exit):
|
||
assert await es._check_single_position("555555") is None
|
||
assert seen["current_price"] == 10_020.0
|
||
assert day_trading_state.positions["555555"]["current_price"] == 10_020.0
|