Merge branch 'dev' into main — 리스크 결함 일괄 수정 + 체결조회 경로 + 일지 보정 0013
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
21
CLAUDE.md
21
CLAUDE.md
@@ -46,7 +46,8 @@ adaptive_strategy(_MODE_PARAMS) > strategy_cfg(DB: strategy_configs) > setti
|
||||
- `services/daytrading/`·`services/swing/` — entry_service, exit_service, position_manager, scheduler
|
||||
- `services/` 순수 계산 모듈 (settings·DB·네트워크 참조 없음 — 이 원칙을 깨지 말 것):
|
||||
- `indicators_core.py` — RSI/BB/ATR/ADX 단일 출처. `rsi_wilder`/`rsi_sma`/`atr_sma`/`bollinger_sma`/`adx_wilder`/`true_ranges`
|
||||
- `sizing_core.py` — 포지션 사이징 원시 연산. `budget_by_ratios`/`clamp_by_ratios`/`apply_confidence_scale`/`amount_to_quantity`/`half_kelly_fraction`/`effective_min_buy_amount`. **한도 숫자는 이 모듈에 없다**(호출부의 settings 소유)
|
||||
- `sizing_core.py` — 포지션 사이징 원시 연산. `budget_by_ratios`/`clamp_by_ratios`/`apply_confidence_scale`/`amount_to_quantity`/`half_kelly_fraction`/`effective_min_buy_amount`/`remaining_position_room`. **한도 숫자는 이 모듈에 없다**(호출부의 settings 소유)
|
||||
- `trade_cost_core.py` — 왕복 비용(수수료×2 + 매도세)·순손익. `trade_helpers.calculate_trade_costs` 와 동일 정의(테스트로 고정)
|
||||
- `services/sector_mapper.py` — 섹터 조회 단일화. **조회 경로는 파일 캐시만 읽고 네트워크를 쓰지 않는다**(§3.1). 수집은 `backend/collect_sector_map.py` 배치 전용
|
||||
- `services/` 핵심: trade_executor(매매엔진), db_worker(비동기 DB워커), mdd_manager·loss_tracker·correlation_filter(리스크), korea_investment_api·kis_realtime_stream·naver_finance(데이터), feature_engineering·ml_ensemble·technical_indicators(분석), analysis_db_service(DB 기반 스캐너), market_analyzer(국면 게이트), market_playbook(이벤트 차단), ai_strategy_service(AI 필터 계층)
|
||||
- `mcp-server/` — stdio_server, auto_trade_claude, claude_client, gemini_client, trade_monitor, risk_validator, mcp_tools
|
||||
@@ -97,6 +98,7 @@ adaptive_strategy(_MODE_PARAMS) > strategy_cfg(DB: strategy_configs) > setti
|
||||
## 5. 리스크 규칙
|
||||
### 5.1 사이징·필터
|
||||
- 종목당 **4%** (`MAX_POSITION_SIZE`) — **절대 상한(hard ceiling)**. 적응형 모드가 6~7%를 지시해도 소비 지점에서 `min(_adp.position_size, MAX_POSITION_SIZE)` 로 클램프되고, 패턴메모리 조정 후에도 `try` 밖에서 재클램프된다.
|
||||
- **누적 기준 (2026-10-01)**: 상한은 주문 1건이 아니라 **기존 보유분 포함 누적**이다(`trade_executor._apply_position_cap_room_v2` → `sizing_core.remaining_position_room`). 기존 보유 = `max(Portfolio 행 수량, DT 인메모리 수량)` × 현재가. 남은 한도 < 실효 최소주문금액이면 skip, 보유가 있는데 총자산 조회 실패면 추가매수 skip. 신규 진입 수치는 이전과 동일.
|
||||
- 최대 **20종목**(`MAX_HOLDINGS_COUNT`), 레버리지/인버스 ETF 최대 **20%**(`LEVERAGE_ETF_MAX_RATIO`), 전 전략 합산 현금 사용 상한 **95%**(`MAX_TOTAL_CAPITAL_USAGE`).
|
||||
- 최소 주문금액 **15만원**(`MIN_BUY_AMOUNT_KRW`) — **단 실효값은 `min(150,000, 총자산 × 적응형포지션상한 × 0.9)`** 다. 소액계좌에서 주문이 전부 탈락하는 것을 막기 위한 의도적 완화(2026-09-23 하루 141건 차단 사례).
|
||||
- 가격 필터: 국내 >5,000원, 미국 >$4 (`MIN_PRICE_KRW`, `MIN_PRICE_USD`). **ETF 예외는 `trade_executor` 경로에만 있고** `market_analyzer`·`portfolio_v2`·`manual_buy` 경로에는 없어 경로별로 동작이 다르다.
|
||||
@@ -107,6 +109,8 @@ adaptive_strategy(_MODE_PARAMS) > strategy_cfg(DB: strategy_configs) > setti
|
||||
- **배포 후 `collect_sector_map.py` 를 한 번 돌려야 캐시가 생긴다**(§1-4). 없으면 44종목 표로만 폴백.
|
||||
- **미매핑 종목의 섹터캡**: `SECTOR_CAP_UNMAPPED_ENABLED`(기본 **False**). 미매핑을 "UNKNOWN" 단일 버킷으로 묶어 3종목 한도를 적용하는 스위치다. 켜기 전 조건 — 운영 환경 커버리지 ≥95% & 캐시 7일 이내, 미매핑 잔여가 ETF/ETN 만(일반 주식이 남아 있으면 UNKNOWN 3종목 상한에 걸려 거래가 죽는다), 최대 버킷 <20%, 그리고 켠 상태 백테스트에서 체결 건수가 유의미하게 줄지 않고 `MAX_HOLDINGS_COUNT=20` 도달이 가능함을 확인.
|
||||
- 연속 손실: 동일 종목 **3연패 → `CONSECUTIVE_LOSS_BAN_DAYS`(42캘린더일 ≈ 30거래일) 진입 금지** (`loss_tracker`). 값은 config 연동이며 하드코딩이 아니다. `record_trade_result()` 후 반드시 `save_to_db()`(db_worker `upsert_stock_ban` 경유). 재시작 시 `load_from_db()` 복원.
|
||||
- **의도적 예외 (2026-10-01)**: DT **시간손절**(`day_trading_strategy.TIME_STOP_REASON_PREFIX`)은 loss_tracker 에 기록하지 않는다(중립 — 연패를 늘리지도 끊지도 않음). 이 밴이 스윙 진입까지 막아 -0.3% 시간손절이 42일 금지를 유발했기 때문. 하드/폴백 손절·일일한도·EOD·트레일링은 기존대로 기록. DT 당일 3연패 쿨다운(`loss_streak_today`)은 시간손절도 계속 센다.
|
||||
- 손익 판정은 **비용 차감 순손익** 기준이라 왕복 비용(≈0.21%) 이내의 소폭 이익 청산도 손실로 집계된다.
|
||||
|
||||
### 5.2 SL/TP (일봉/스윙)
|
||||
- 기본 SL: **-7%**(안정) / **-10%**(변동성, ATR/종가 > 3%). ATR SL: `진입가 − 2.5×ATR` (`ATR_STOP_MULTIPLIER`).
|
||||
@@ -171,6 +175,9 @@ adaptive_strategy(_MODE_PARAMS) > strategy_cfg(DB: strategy_configs) > setti
|
||||
- **거시/국면 position_multiplier** (`market_analyzer`): 장외 0.5, CRASH 0.0~0.1, BEAR ratio
|
||||
- **적응형 Crisis 모드**: 진입 전면 차단 (§8.4)
|
||||
- **추격매수 방지·추세 필터·이격도 과열·일일 매매한도·재매수 쿨다운** (`trade_executor`)
|
||||
- 일일 매매한도(`MAX_DAILY_TRADES`)는 **스윙 소유 매수만** 집계한다(`strategy_type IS NULL OR = 'swing'`, `_swing_owned_clause_v2`). 집계 조회가 실패하면 **fail-closed** — 그 사이클 신규 진입 0건 + `logger.error`.
|
||||
- 재매수 쿨다운은 DT 매도 포함 전체 매도 기준, 부분매도 반복 차단(`_sold_today`)만 비-DT 매도 기준.
|
||||
- **추천 경로(`execute_auto_trade`)는 스윙 소유 행만 매수·매도한다**(`_sell_ownership_block_reason_v2`): external/manual 은 수동 판단, daytrading 은 DT 엔진 소유, us_swing 등은 타 전략 — 매도도 추가매수도 skip(대칭). MDD 축소·`swing/exit_service`·DT 청산은 별도 주문 경로라 무관. 고아 daytrading 행(DT 인메모리 0)은 warning 으로 수동 확인을 요청한다.
|
||||
- **AI 전략 계층** (`ai_strategy_service`): 08:30 장전 분석 / 08:50 DT 후보 선정 / 뉴스 감시 / 15:40 장후 리뷰. 적합성 필터링과 뉴스 악재 배율로 실제 매매에 영향을 준다.
|
||||
- **듀얼 전략 후보 소스 의존**: `StockAnalysis.analysis_date >= today` 에만 의존하므로 **일일 배치가 실패하면 국면이 BULL이어도 후보 0건**으로 조용히 아무 일도 일어나지 않는다.
|
||||
|
||||
@@ -199,7 +206,9 @@ adaptive_strategy(_MODE_PARAMS) > strategy_cfg(DB: strategy_configs) > setti
|
||||
- **전일 포지션 청산 손익은 당일 실현손익에 넣지 않는다**(`_finalize_exit_impl(count_daily_pnl=False)`). 넣으면 당일 매매 0건인데 -3% 손실한도가 발동해 정상 포지션까지 전량 청산된다. 전일 손익은 `get_stale_realized_pnl()` + warning 로그 + TradeHistory로 추적한다. stale 청산 종목은 `closed_today` 에 추가해 당일 재진입을 막는다(주문 실패 시에도).
|
||||
- **트레일링 고점(`highest_price`)은 진입 이후에 시작한 1분봉 고가 + 현재가로만 갱신한다**(`daytrading/exit_service._post_entry_candle_high`, 2026-10-01). 진입이 속한 봉은 진입 전 체결이 섞여 제외하고, 라벨 ≥ 진입+60초 봉만 인정하며 날짜까지 비교한다. entry_time 이 없거나 정규장 밖(UTC 저장 추정)이면 현재가만 쓴다. 이전의 `max(candles_5m[-3:].high)` 는 진입 전 고가로 +1% 트리거를 오발동시켰다.
|
||||
- **분봉 조회 실패 시 폴백 손절**이 있다(`_check_fallback_stop_loss`). 현재가만으로 손절선 이탈을 판정해 전량 청산한다. 폴백 손절가는 `min()` 으로 고른 가장 타이트한 하드캡(-2%) 기준이며, 현재가조차 못 구하면 `logger.error` 로 수동 확인을 요청한다.
|
||||
- 제한: **15:00 이후 신규 마감**(`DAY_TRADE_ENTRY_CUTOFF_*`) / 일일 **-3%** 손실 시 당일 중단(`DAY_TRADE_DAILY_LOSS_LIMIT`) / 당일 3연패 쿨다운(position_manager) / 전량 매도 시 loss_tracker 기록 + `save_to_db()`
|
||||
- 제한: **15:00 이후 신규 마감**(`DAY_TRADE_ENTRY_CUTOFF_*`) / 일일 **-3%** 손실 시 당일 중단(`DAY_TRADE_DAILY_LOSS_LIMIT`) / 당일 3연패 쿨다운(position_manager) / 전량 매도 시 loss_tracker 기록 + `save_to_db()`(시간손절 제외 — §5.1)
|
||||
- **실현손익은 비용 차감 순손익**(2026-10-01, `exit_service._finalize_exit_impl`): `daily_pnl`·연패·loss_tracker·TradeHistory(`net_profit_loss`, `profit_loss_rate`, `commission`) 모두 순손익 기준이다. 따라서 일일 -3% 한도가 비용만큼 일찍 발동한다(의도). 미실현 손익은 여전히 비용 전. DT 포지션에 시장 구분이 없어 거래세는 `DAY_TRADE_DEFAULT_TAX_MARKET`(KOSPI) 세율을 쓴다.
|
||||
- **체결가 보정**(`daytrading/fill_reconciler`, `DAY_TRADE_FILL_CHECK_*`): 주문 후 백그라운드로 `get_todays_orders` 를 ODNO 매칭해 실제 체결가로 매수 진입가(인메모리·ActiveTrade)와 매도 손익·`daily_pnl` 을 보정한다(손절가·목표가는 절대가 유지). 보정 결과 일일한도를 넘으면 다음 사이클에 전량 청산된다. 실패·시간초과면 신호가 유지. 문제 시 `DAY_TRADE_FILL_CHECK_ENABLED=false`.
|
||||
- 감시종목: 09:00에 당일 분석이 없으면 최근 분석일 결과로 선채움(ORB 수집 보장), 자동 목록은 30분마다 갱신(수동 설정 목록은 유지)
|
||||
- **1분봉 데이터 주의**: `get_minute_chart_full_day` 는 과거 데이터 포함 요청(`FID_PW_DATA_INCU_YN="Y"`)이라 전일 캔들이 섞인다. dedupe·정렬 키에 `stck_bsop_date` 를 포함하고 당일 외 캔들을 제외해야 ORB 거래량(`candles_1min[-1]`)·VWAP이 오염되지 않는다.
|
||||
|
||||
@@ -340,7 +349,13 @@ DX(0~100)에 합산형 평활을 적용한 버그. 측정: `up 1361 vs 97 / flat
|
||||
18. `naver_finance` 등 **KIS 외 외부 HTTP 경로는 conftest 가 전역 차단하지 않는다**. 현재 소켓 차단 하 전체 통과로 실호출은 없음을 확인했으나, 추가 방어가 필요하면 별도 작업.
|
||||
|
||||
**멀티테넌트**
|
||||
19. MT 2단계 잔여 — 스케줄러 사용자별 순회, legacy 라우터 격리, `user_id` 를 실제로 payload 에 싣는 호출부 전환, alembic **0013**(NOT NULL + `uq_portfolio_user_stock`). `db_worker` 의 `MultipleResultsFound` 전제조건 1건은 해소됐다. **`MULTI_TENANT_ENABLED=True` 는 계속 금지.**
|
||||
19. MT 2단계 잔여 — 스케줄러 사용자별 순회, legacy 라우터 격리, `user_id` 를 실제로 payload 에 싣는 호출부 전환, alembic **0014**(NOT NULL + `uq_portfolio_user_stock`). `db_worker` 의 `MultipleResultsFound` 전제조건 1건은 해소됐다. **`MULTI_TENANT_ENABLED=True` 는 계속 금지.**
|
||||
|
||||
**체결 확인 (2026-10-01 — `get_order_execution` 이 존재하지 않는 경로 `/trading/inquire-ccnl` 을 호출해 스윙 체결 확인이 한 번도 성공하지 못했던 결함 수정)**
|
||||
27. 체결 조회는 `/trading/inquire-daily-ccld`(TR `TTTC0081R`/`VTTC0081R` 우선 → `rt_cd≠0` 이면 구 TR `*8001R` 폴백, 인스턴스에 기억) + `EXCG_ID_DVSN_CD=KRX`. 배포 후 로그 `[daily-ccld] TR legacy 로 전환` / `대체 TR 재시도` 빈도를 확인할 것 — "결과 없음"을 `rt_cd≠0` 으로 주면 매 조회가 2배가 된다.
|
||||
28. **부분체결 잔량 자동 재주문은 하지 않는다.** `_reorder_unfilled` 는 MDD/밴/누적 4%/현금/주문 락/TradeHistory 를 전부 우회하고, 잔량 소멸은 사실상 HTS 취소·KIS 거부·만료일 때만 일어난다. 잔량 소멸 시 warning("수동 확인 필요")만 남긴다. 되살리지 말 것.
|
||||
29. 후속: (a) 체결 0주로 취소·거부된 주문이 `COMPLETED`·전량으로 남는다 → CANCELLED 강등 필요, (b) 스윙 부분체결 매도의 `net_profit_loss`/`commission` 이 주문 전량 기준이라 성과 과대, (c) 폴링 상수(`max_polls=5`, `poll_interval=3`, `ORDER_EXEC_MAX_PAGES=5`) config 화, (d) 모의 서버 초당 2건 제한 대비 주문당 최대 5~10회 조회.
|
||||
30. `TradeHistory.order_id` 갱신(`db_worker._handle_update_trade_history_by_order_id`)은 **당일(KST) + 최신 1행**만 대상이다. KIS 주문번호가 일별 순번이라 과거 행을 덮어쓰던 위험을 막는다. 0건이면 재시도 없이 warning.
|
||||
|
||||
**ML 앙상블 (2026-09-28 — 코드는 고쳤으나 모델 재학습 전까지 신뢰 금지)**
|
||||
20. **운영 LSTM 체크포인트가 무의미하다.** `seq_mean=[0,0,0,0,0]`, `seq_std=[1e-8]×5` — `X_seq` 가 전부 0 인 상태로 학습된 흔적이다(구 `prepare_training_data` 가 시퀀스에 NaN 을 넣어 `X_seq.mean` 이 NaN 이 된 결과). std≈0 이면 정규화가 `x/1e-8` 로 폭주해 **가중치 35% 를 가진 LSTM 출력이 사실상 난수**다. 시계열 방향 수정 + 시퀀스 NaN 수정이 들어갔으므로 **재학습이 필요**하다. 재학습 후 새 `metrics.json` 의 `lstm_seq_std_min` 과 `lstm_per_class` 로 판정할 것 — 정확도만 보면 "전부 HOLD" 모델을 통과시킨다.
|
||||
|
||||
146
backend/alembic/versions/0013_fix_unknown_journal_strategy.py
Normal file
146
backend/alembic/versions/0013_fix_unknown_journal_strategy.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""backfill trade_journals.strategy_name='unknown' from trade_history.strategy_type (data-only)
|
||||
|
||||
Revision ID: 0013
|
||||
Revises: 0012
|
||||
Create Date: 2026-10-01
|
||||
|
||||
[배경]
|
||||
매매일지 생성 경로가 전략명을 판정하지 못해 `trade_journals.strategy_name` 이
|
||||
'unknown' 으로 저장된 행(약 187건)이 있다. 코드는 이미 수정됐으나 기존 행은 그대로다.
|
||||
`trade_history.strategy_type` 이 신뢰 가능한 원본이므로 그 값으로 보정한다.
|
||||
|
||||
[매핑] trade_analyzer._parse_strategy(strategy_type 분기)와 동일하다.
|
||||
swing -> mean_reversion / daytrading -> day_trade / kosdaq_scan -> kosdaq_scanner
|
||||
short_term -> short_term / 그 외 -> lower(trim(strategy_type)) 그대로
|
||||
tags 는 [strategy, exit_type, ...] 형태(db_worker/trade_analyzer)이므로 첫 요소가
|
||||
'unknown' 일 때만 같은 전략명으로 치환한다. 배열이 아니거나 비어 있으면 그대로 둔다.
|
||||
|
||||
[안전]
|
||||
- 백업 테이블 `trade_journals_bak_strategy_20261001` 에 대상 행의 (id, strategy_name, tags)
|
||||
를 복사한다(IF NOT EXISTS + 이미 있는 id 는 재삽입 안 함 -> 재실행 안전).
|
||||
- 테이블/컬럼이 없으면(신규 볼륨, 앱 기동 전) 조용히 no-op (0012 와 동일한 가드).
|
||||
- downgrade 는 백업에서 strategy_name/tags 복원. 백업 테이블은 남긴다.
|
||||
스키마 변경은 백업 테이블 생성뿐이다.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0013"
|
||||
down_revision = "0012"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_BAK = "trade_journals_bak_strategy_20261001"
|
||||
|
||||
# trade_analyzer._parse_strategy 의 strategy_type 매핑과 동일해야 한다.
|
||||
_MAPPED = """
|
||||
CASE lower(btrim(th.strategy_type))
|
||||
WHEN 'swing' THEN 'mean_reversion'
|
||||
WHEN 'daytrading' THEN 'day_trade'
|
||||
WHEN 'kosdaq_scan' THEN 'kosdaq_scanner'
|
||||
WHEN 'short_term' THEN 'short_term'
|
||||
ELSE lower(btrim(th.strategy_type))
|
||||
END
|
||||
"""
|
||||
|
||||
# 대상 조건 (백업/갱신 공통)
|
||||
_TARGET_FROM = """
|
||||
FROM trade_journals j
|
||||
JOIN trade_history th ON th.id = j.trade_id
|
||||
WHERE j.strategy_name = 'unknown'
|
||||
AND j.trade_id > 0
|
||||
AND th.strategy_type IS NOT NULL
|
||||
AND btrim(th.strategy_type) <> ''
|
||||
"""
|
||||
|
||||
_CREATE_BAK = sa.text(
|
||||
f"CREATE TABLE IF NOT EXISTS {_BAK} (id integer PRIMARY KEY, strategy_name varchar(100), tags jsonb)"
|
||||
)
|
||||
|
||||
_INSERT_BAK = sa.text(
|
||||
f"""
|
||||
INSERT INTO {_BAK} (id, strategy_name, tags)
|
||||
SELECT j.id, j.strategy_name, j.tags
|
||||
{_TARGET_FROM}
|
||||
AND NOT EXISTS (SELECT 1 FROM {_BAK} b WHERE b.id = j.id)
|
||||
"""
|
||||
)
|
||||
|
||||
_UPDATE = sa.text(
|
||||
f"""
|
||||
UPDATE trade_journals j
|
||||
SET strategy_name = {_MAPPED},
|
||||
tags = CASE
|
||||
WHEN jsonb_typeof(j.tags) = 'array' THEN
|
||||
CASE
|
||||
WHEN jsonb_array_length(j.tags) > 0
|
||||
AND j.tags -> 0 = to_jsonb('unknown'::text)
|
||||
THEN jsonb_set(j.tags, '{{0}}', to_jsonb({_MAPPED}))
|
||||
ELSE j.tags
|
||||
END
|
||||
ELSE j.tags
|
||||
END,
|
||||
updated_at = now() AT TIME ZONE 'Asia/Seoul'
|
||||
FROM trade_history th
|
||||
WHERE th.id = j.trade_id
|
||||
AND j.strategy_name = 'unknown'
|
||||
AND j.trade_id > 0
|
||||
AND th.strategy_type IS NOT NULL
|
||||
AND btrim(th.strategy_type) <> ''
|
||||
"""
|
||||
)
|
||||
|
||||
_RESTORE = sa.text(
|
||||
f"""
|
||||
UPDATE trade_journals j
|
||||
SET strategy_name = b.strategy_name,
|
||||
tags = b.tags,
|
||||
updated_at = now() AT TIME ZONE 'Asia/Seoul'
|
||||
FROM {_BAK} b
|
||||
WHERE b.id = j.id
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _has_table(conn, table: str) -> bool:
|
||||
return conn.execute(sa.text(
|
||||
"SELECT 1 FROM information_schema.tables WHERE table_name=:t"
|
||||
), {"t": table}).fetchone() is not None
|
||||
|
||||
|
||||
def _has_columns(conn, table: str, cols) -> bool:
|
||||
rows = conn.execute(sa.text(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name=:t"
|
||||
), {"t": table}).fetchall()
|
||||
have = {r[0] for r in rows}
|
||||
return set(cols) <= have
|
||||
|
||||
|
||||
def _schema_ready(conn) -> bool:
|
||||
return (
|
||||
_has_table(conn, "trade_journals")
|
||||
and _has_table(conn, "trade_history")
|
||||
and _has_columns(conn, "trade_journals", ["id", "trade_id", "strategy_name", "tags", "updated_at"])
|
||||
and _has_columns(conn, "trade_history", ["id", "strategy_type"])
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if not _schema_ready(conn):
|
||||
print("[0013] trade_journals/trade_history 없음 — 보정 스킵")
|
||||
return
|
||||
conn.execute(_CREATE_BAK)
|
||||
bak = conn.execute(_INSERT_BAK).rowcount
|
||||
upd = conn.execute(_UPDATE).rowcount
|
||||
print(f"[0013] unknown 일지 보정: 백업 {bak}건 추가, 갱신 {upd}건")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if not _has_table(conn, _BAK) or not _has_table(conn, "trade_journals"):
|
||||
print("[0013] 백업 테이블 없음 — 복원 스킵")
|
||||
return
|
||||
n = conn.execute(_RESTORE).rowcount
|
||||
print(f"[0013] 일지 strategy_name/tags 복원: {n}건 (백업 테이블은 유지)")
|
||||
@@ -33,7 +33,7 @@ from app.services.day_trading_strategy import (
|
||||
parse_minute_candles, aggregate_to_5min,
|
||||
calculate_vwap, calculate_opening_range,
|
||||
check_orb_signal, check_vwap_bounce_signal, check_rsi_bb_signal,
|
||||
check_day_trade_exit,
|
||||
check_day_trade_exit, is_time_stop_exit_reason,
|
||||
calculate_day_trade_position_size, is_daily_loss_exceeded,
|
||||
ORB_END, ENTRY_CUTOFF, EOD_FLAT, MAX_POSITIONS,
|
||||
)
|
||||
@@ -271,6 +271,8 @@ async def _record_trade(
|
||||
"status": "COMPLETED",
|
||||
"entry_reason": reason if order_type == "BUY" else None,
|
||||
"exit_reason": reason if order_type == "SELL" else None,
|
||||
# [2026-10-01] 명시 — 빠지면 모델 기본값 'swing' 으로 기록돼 DT 거래가 스윙 통계에 섞인다.
|
||||
"strategy_type": "daytrading",
|
||||
"executed_at": _kst_now(),
|
||||
})
|
||||
except Exception as e:
|
||||
@@ -480,9 +482,11 @@ async def _check_exits(
|
||||
await enqueue_close_active_trade(code, strategy_name="day_trade")
|
||||
|
||||
# loss_tracker 기록 — CLAUDE.md 7.3 규칙 준수
|
||||
_profit_rate = (current_price - pos["entry_price"]) / pos["entry_price"] if pos["entry_price"] > 0 else 0
|
||||
await loss_tracker.record_trade_result_safe(code, _profit_rate < 0)
|
||||
await loss_tracker.save_to_db(db=None, stock_code=code)
|
||||
# [2026-10-01] 시간손절은 연속손실 집계 제외(중립) — 엔진 경로(exit_service)와 동일
|
||||
if not is_time_stop_exit_reason(exit_signal.reason):
|
||||
_profit_rate = (current_price - pos["entry_price"]) / pos["entry_price"] if pos["entry_price"] > 0 else 0
|
||||
await loss_tracker.record_trade_result_safe(code, _profit_rate < 0)
|
||||
await loss_tracker.save_to_db(db=None, stock_code=code)
|
||||
|
||||
exits.append({
|
||||
"stock_code": code,
|
||||
|
||||
@@ -312,6 +312,15 @@ class Settings(BaseSettings):
|
||||
DAY_TRADE_TRAILING_ATR_MULT: float = 1.5 # 트레일링 ATR 배수 (1.0→1.5 whipsaw 방지)
|
||||
DAY_TRADE_ORB_BREAKOUT_MARGIN: float = 0.002 # ORB 돌파 최소 여유 +0.2%
|
||||
DAY_TRADE_COST_PCT: float = 0.005 # 수수료+슬리피지 합산 (매수+매도)
|
||||
# [2026-10-01] DT 실제 체결가 확인 (주문 후 백그라운드, 실시간 루프 비차단).
|
||||
# 실패/시간초과 시 신호가 기록 유지 + warning. 판정 임계값이 아니라 기록 정확도용.
|
||||
DAY_TRADE_FILL_CHECK_ENABLED: bool = True
|
||||
DAY_TRADE_FILL_CHECK_DELAY_SEC: float = 2.0 # 주문 직후 첫 조회까지 대기
|
||||
DAY_TRADE_FILL_CHECK_ATTEMPTS: int = 3 # 체결 조회 최대 횟수
|
||||
DAY_TRADE_FILL_CHECK_INTERVAL_SEC: float = 2.0 # 조회 간격
|
||||
DAY_TRADE_FILL_CHECK_TIMEOUT_SEC: float = 15.0 # 전체 상한 (토큰 rate-limit sleep 차단)
|
||||
# DT 종목의 시장 구분을 모를 때 거래세 적용 시장 (스윙 trade_executor 기본값과 동일)
|
||||
DAY_TRADE_DEFAULT_TAX_MARKET: str = "KOSPI"
|
||||
DAY_TRADE_ENTRY_CUTOFF_HOUR: int = 15 # 신규 진입 마감 시간 (14:30→15:00, 2026-09-22 시간대 제한 완화)
|
||||
DAY_TRADE_ENTRY_CUTOFF_MIN: int = 0 # 신규 진입 마감 분
|
||||
DAY_TRADE_EOD_HOUR: int = 15 # EOD 강제 청산 시작 시간
|
||||
|
||||
@@ -164,6 +164,19 @@ class DayTradeExit:
|
||||
sell_pct: float = 1.0
|
||||
|
||||
|
||||
# [2026-10-01] 시간손절 사유 접두어 — 사유 문자열 생성과 판별이 이 상수 하나를 공유한다.
|
||||
# 시간손절은 "진입 후 N분간 목표 방향 진행 미미"를 정리하는 **기회비용 청산**이지
|
||||
# 손절선 이탈이 아니다. 이것까지 loss_tracker(3연패 → CONSECUTIVE_LOSS_BAN_DAYS 진입금지)에
|
||||
# 집계하면 소폭 손실 시간손절 3회만으로 그 종목의 스윙 진입까지 42일간 막힌다.
|
||||
# 판별은 exit_service 가 청산 사유로 한다(_execute_exit 시그니처 불변 유지).
|
||||
TIME_STOP_REASON_PREFIX = "시간 손절"
|
||||
|
||||
|
||||
def is_time_stop_exit_reason(reason: Optional[str]) -> bool:
|
||||
"""청산 사유가 DT 시간손절인지 판별 (check_day_trade_exit 가 만든 사유 기준)."""
|
||||
return bool(reason) and str(reason).startswith(TIME_STOP_REASON_PREFIX)
|
||||
|
||||
|
||||
# ── 분봉 파싱 헬퍼 ────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_minute_candles(raw_output2: list) -> List[MinuteCandle]:
|
||||
@@ -771,7 +784,7 @@ def check_day_trade_exit(
|
||||
if gain_pct < settings.DAY_TRADE_TIME_STOP_MIN_GAIN: # 최소 수익 미달 (수수료 커버 못함)
|
||||
return DayTradeExit(
|
||||
action="SELL_ALL",
|
||||
reason=f"시간 손절: {elapsed_min:.0f}분 경과, 수익={gain_pct*100:.1f}% < {settings.DAY_TRADE_TIME_STOP_MIN_GAIN*100:.1f}%",
|
||||
reason=f"{TIME_STOP_REASON_PREFIX}: {elapsed_min:.0f}분 경과, 수익={gain_pct*100:.1f}% < {settings.DAY_TRADE_TIME_STOP_MIN_GAIN*100:.1f}%",
|
||||
)
|
||||
|
||||
# 4. 목표가 달성 → 50% 분할 매도 (CLAUDE.md 5.2: +2R 시 50% 분할 매도)
|
||||
|
||||
@@ -1035,6 +1035,11 @@ async def _try_enter(
|
||||
"take_profit": signal.target_price,
|
||||
})
|
||||
|
||||
# [2026-10-01] 실제 체결가 확인 — 백그라운드(루프 비차단). 위 기록의 executed_price 는
|
||||
# 신호 시점 1분봉 종가다. 확인되면 원장·인메모리 진입가·ActiveTrade 를 체결가로 보정하고,
|
||||
# 실패하면 신호가 기록이 그대로 남는다(warning).
|
||||
_schedule_buy_fill_reconcile(stock_code, order_id, current_price, quantity)
|
||||
|
||||
logger.info(
|
||||
f"[DayEntry] ✅ 진입: {stock_code} {stock_name} "
|
||||
f"수량={quantity} 가격={current_price:,.0f} 신호={signal.strategy}"
|
||||
@@ -1052,3 +1057,59 @@ async def _try_enter(
|
||||
except Exception as e:
|
||||
logger.error(f"[DayEntry] _try_enter 오류 ({stock_code}): {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _schedule_buy_fill_reconcile(
|
||||
stock_code: str, order_id: str, signal_price: float, quantity: int,
|
||||
):
|
||||
"""[2026-10-01] 매수 실제 체결가 확인을 백그라운드로 예약 (실시간 루프 비차단).
|
||||
|
||||
확인되면:
|
||||
- TradeHistory(order_id) executed_price·total_amount·slippage(%) 갱신 (db_worker 큐)
|
||||
- 인메모리 포지션 entry_price 를 체결가로 교체(같은 주문번호의 포지션이 남아 있을 때만).
|
||||
손절가·목표가는 신호 시점 절대가격 그대로 둔다(전략 파라미터 불변).
|
||||
- ActiveTrade(day_trade) entry_price 갱신 — 재기동 복원도 체결가 기준이 된다.
|
||||
이미 일부/전량 청산된 뒤에 확인되면 그 청산 손익은 신호가 기준으로 남는다(보고된 잔여 위험).
|
||||
"""
|
||||
from app.services.daytrading import fill_reconciler
|
||||
|
||||
from app.core.timeutil import now_kst as _now_kst
|
||||
_order_date = _now_kst().date().isoformat() # 주문일(KST) — 확인이 늦어져도 이 날짜로 조회
|
||||
|
||||
async def _on_fill(filled_qty: int, avg_price: float) -> None:
|
||||
if filled_qty < quantity:
|
||||
logger.warning(
|
||||
f"[DayEntry] 매수 부분체결 확인: {stock_code} {filled_qty}/{quantity}주 "
|
||||
f"order_id={order_id} — 인메모리 수량은 주문 수량 기준(수동 확인 필요)"
|
||||
)
|
||||
slippage_pct = ((avg_price - signal_price) / signal_price * 100) if signal_price > 0 else 0.0
|
||||
from decimal import Decimal
|
||||
from app.services.db_worker import enqueue_update_trade_history_by_order_id
|
||||
await enqueue_update_trade_history_by_order_id(order_id, {
|
||||
# 식별 키(db_worker 가 setattr 하지 않음): KIS ODNO 는 일별 순번이라 날짜·구분으로 좁힌다
|
||||
"order_date": _order_date,
|
||||
"order_type": "BUY",
|
||||
"strategy_type": "daytrading",
|
||||
"executed_price": Decimal(str(round(avg_price, 2))),
|
||||
"total_amount": Decimal(str(round(avg_price * quantity, 2))),
|
||||
"slippage": Decimal(str(round(slippage_pct, 4))),
|
||||
})
|
||||
updated = False
|
||||
async with day_trading_state.trade_lock:
|
||||
pos = day_trading_state.positions.get(stock_code)
|
||||
if pos is not None and str(pos.get("order_id", "")) == str(order_id):
|
||||
pos["entry_price"] = float(avg_price)
|
||||
updated = True
|
||||
if updated:
|
||||
await enqueue_active_trade_upsert({
|
||||
"stock_code": stock_code,
|
||||
"strategy_name": "day_trade",
|
||||
"entry_price": float(avg_price),
|
||||
})
|
||||
logger.info(
|
||||
f"[DayEntry] 매수 체결가 확인: {stock_code} 신호가={signal_price:,.0f} "
|
||||
f"체결가={avg_price:,.2f} (슬리피지 {slippage_pct:+.3f}%)"
|
||||
+ ("" if updated else " — 포지션 이미 청산/교체됨, 원장만 보정")
|
||||
)
|
||||
|
||||
return fill_reconciler.schedule(stock_code, order_id, "BUY", _on_fill)
|
||||
|
||||
@@ -31,10 +31,13 @@ from app.services.korea_investment_api import korea_investment_api
|
||||
from app.services.day_trading_strategy import (
|
||||
parse_minute_candles, aggregate_to_5min,
|
||||
check_day_trade_exit, is_daily_loss_exceeded,
|
||||
is_time_stop_exit_reason,
|
||||
)
|
||||
from app.services.daytrading.position_manager import day_trading_state
|
||||
from app.services.daytrading import fill_reconciler
|
||||
from app.services.db_worker import enqueue_trade_history, enqueue_close_active_trade
|
||||
from app.services.loss_tracker import loss_tracker
|
||||
from app.services.trade_cost_core import net_realized_pnl
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,6 +54,26 @@ def get_stale_realized_pnl() -> float:
|
||||
return _stale_realized_pnl
|
||||
|
||||
|
||||
def _dt_sell_tax_rate(pos: dict) -> float:
|
||||
"""DT 매도 거래세율 — 포지션에 시장 구분이 없으면 DAY_TRADE_DEFAULT_TAX_MARKET(스윙 기본과 동일)."""
|
||||
market = str((pos or {}).get("market") or settings.DAY_TRADE_DEFAULT_TAX_MARKET or "KOSPI").upper()
|
||||
if market == "KOSDAQ":
|
||||
return float(settings.KR_SELL_TAX_RATE_KOSDAQ)
|
||||
return float(settings.KR_SELL_TAX_RATE_KOSPI)
|
||||
|
||||
|
||||
def dt_net_pnl(entry_price: float, exit_price: float, quantity: int, pos: dict) -> dict:
|
||||
"""[2026-10-01] DT 실현손익(비용 차감). 스윙 집행 경로와 같은 비용 정의(trade_cost_core).
|
||||
|
||||
매수·매도 수수료(KR_COMMISSION_RATE) + 매도 거래세(KR_SELL_TAX_RATE_*)를 뺀다.
|
||||
반환 키: gross_profit_loss / total_cost / net_profit_loss / net_profit_loss_rate
|
||||
"""
|
||||
return net_realized_pnl(
|
||||
entry_price, exit_price, quantity,
|
||||
float(settings.KR_COMMISSION_RATE), _dt_sell_tax_rate(pos),
|
||||
)
|
||||
|
||||
|
||||
def _fallback_stop_pct() -> float:
|
||||
"""[M7] stop_loss 가 없을 때 사용할 폴백 손절 비율.
|
||||
|
||||
@@ -528,8 +551,15 @@ async def _finalize_exit_impl(
|
||||
entry_price = pos.get("entry_price", 0)
|
||||
stock_name = pos.get("stock_name", stock_code)
|
||||
order_id = order_resp.get("output", {}).get("ODNO", "")
|
||||
profit_loss = (price - entry_price) * quantity
|
||||
profit_loss_rate = ((price - entry_price) / entry_price * 100) if entry_price > 0 else 0.0
|
||||
# [2026-10-01] 순손익(수수료·거래세 차감)으로 기록·집계한다.
|
||||
# 일일 손실한도(DAY_TRADE_DAILY_LOSS_LIMIT)·당일 연패·loss_tracker 가 모두 이 값을 본다
|
||||
# → 비용만큼 한도가 **약간 더 일찍** 발동한다(보수적 방향, 의도). 손익분기(gross≈0)
|
||||
# 청산도 순손익으로는 손실이다.
|
||||
_pnl = dt_net_pnl(entry_price, price, quantity, pos)
|
||||
gross_profit_loss = _pnl["gross_profit_loss"]
|
||||
trade_cost = _pnl["total_cost"]
|
||||
profit_loss = _pnl["net_profit_loss"]
|
||||
profit_loss_rate = _pnl["net_profit_loss_rate"] if entry_price > 0 else 0.0
|
||||
|
||||
# 인메모리 상태 업데이트 (DB 직접 접근 금지)
|
||||
async with day_trading_state.trade_lock:
|
||||
@@ -561,10 +591,22 @@ async def _finalize_exit_impl(
|
||||
await day_trading_state.save_daily_state()
|
||||
|
||||
# [P0] loss_tracker 연동 — 전량 매도 시 연속 손실 추적
|
||||
# [2026-10-01] 시간손절은 **중립**(손실로도 이익으로도 세지 않음 → 기록 자체를 생략).
|
||||
# 기회비용 청산까지 3연패 → CONSECUTIVE_LOSS_BAN_DAYS 진입금지에 넣으면 그 밴이
|
||||
# 스윙 진입(swing/entry_service)까지 막는다. 하드/폴백 손절·일일한도·EOD·트레일링 등
|
||||
# 나머지 청산은 기존대로 기록한다. (당일 연패 쿨다운 loss_streak_today 는 위 update_pnl 이
|
||||
# 그대로 센다 — DT 전용·당일 한정 스로틀이라 스윙에 번지지 않는다.)
|
||||
if is_full_exit:
|
||||
is_loss = profit_loss < 0
|
||||
await loss_tracker.record_trade_result_safe(stock_code, is_loss)
|
||||
await loss_tracker.save_to_db(stock_code=stock_code)
|
||||
if is_time_stop_exit_reason(reason):
|
||||
logger.info(
|
||||
f"[DayExit] 시간손절은 연속손실 집계 제외(중립): {stock_code} "
|
||||
f"순손익={profit_loss:+.0f}원 — {settings.CONSECUTIVE_LOSS_BAN_COUNT}연패/"
|
||||
f"{settings.CONSECUTIVE_LOSS_BAN_DAYS}일 밴 카운트 변화 없음"
|
||||
)
|
||||
else:
|
||||
is_loss = profit_loss < 0
|
||||
await loss_tracker.record_trade_result_safe(stock_code, is_loss)
|
||||
await loss_tracker.save_to_db(stock_code=stock_code)
|
||||
|
||||
# DB 기록 (큐 경유)
|
||||
await enqueue_trade_history({
|
||||
@@ -579,10 +621,17 @@ async def _finalize_exit_impl(
|
||||
"status": "COMPLETED",
|
||||
"order_id": order_id,
|
||||
"exit_reason": reason,
|
||||
"commission": trade_cost,
|
||||
"net_profit_loss": profit_loss,
|
||||
"profit_loss_rate": profit_loss_rate,
|
||||
"strategy_type": "daytrading",
|
||||
})
|
||||
# [2026-10-01] 실제 체결가 확인 — 백그라운드(루프 비차단). 확인되면 원장·당일 손익 보정.
|
||||
_schedule_sell_fill_reconcile(
|
||||
stock_code=stock_code, pos=dict(pos), order_id=order_id, signal_price=price,
|
||||
quantity=quantity, entry_price=entry_price, recorded_net=profit_loss,
|
||||
count_daily_pnl=count_daily_pnl,
|
||||
)
|
||||
if is_full_exit:
|
||||
await enqueue_close_active_trade(stock_code, strategy_name="day_trade")
|
||||
else:
|
||||
@@ -601,7 +650,8 @@ async def _finalize_exit_impl(
|
||||
sell_label = f"{int(sell_pct * 100)}%" if sell_pct < 1.0 else "전량"
|
||||
logger.info(
|
||||
f"[DayExit] ✅ 청산({sell_label}): {stock_code} {stock_name} "
|
||||
f"수량={quantity}/{total_qty} 수익률={profit_loss_rate:.1f}% 이유={reason}"
|
||||
f"수량={quantity}/{total_qty} 순수익률={profit_loss_rate:.2f}% "
|
||||
f"(총손익={gross_profit_loss:+.0f} 비용={trade_cost:.0f} 순손익={profit_loss:+.0f}원) 이유={reason}"
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -609,13 +659,72 @@ async def _finalize_exit_impl(
|
||||
"stock_name": stock_name,
|
||||
"price": price,
|
||||
"quantity": quantity,
|
||||
"profit_loss": profit_loss,
|
||||
"profit_loss_rate": profit_loss_rate,
|
||||
"profit_loss": profit_loss, # 순손익(비용 차감)
|
||||
"profit_loss_rate": profit_loss_rate, # 순수익률(진입 원금 대비 %)
|
||||
"gross_profit_loss": gross_profit_loss,
|
||||
"trade_cost": trade_cost,
|
||||
"reason": reason,
|
||||
"sell_pct": sell_pct,
|
||||
"order_id": order_id,
|
||||
}
|
||||
|
||||
|
||||
def _schedule_sell_fill_reconcile(
|
||||
stock_code: str, pos: dict, order_id: str, signal_price: float, quantity: int,
|
||||
entry_price: float, recorded_net: float, count_daily_pnl: bool,
|
||||
):
|
||||
"""매도 실제 체결가 확인을 백그라운드로 예약 (실시간 루프 비차단).
|
||||
|
||||
확인되면:
|
||||
- TradeHistory(order_id) 의 체결가·비용·순손익·수익률·슬리피지(%)를 db_worker 로 갱신
|
||||
- 기록했던 순손익과의 차이를 당일 daily_pnl 에 보정(전일 stale 청산은 _stale_realized_pnl —
|
||||
§7 당일 미반영 규칙 유지). 당일 연패·loss_tracker 판정은 청산 시점 값 그대로 둔다.
|
||||
확인 실패면 신호가 기록이 그대로 남는다(fill_reconciler 가 warning).
|
||||
"""
|
||||
from app.core.timeutil import now_kst as _now_kst
|
||||
_order_date = _now_kst().date().isoformat() # 주문일(KST) — 확인이 늦어져도 이 날짜로 조회
|
||||
|
||||
async def _on_fill(filled_qty: int, avg_price: float) -> None:
|
||||
global _stale_realized_pnl
|
||||
if filled_qty < quantity:
|
||||
logger.warning(
|
||||
f"[DayExit] 매도 부분체결 확인: {stock_code} {filled_qty}/{quantity}주 "
|
||||
f"order_id={order_id} — 인메모리 수량은 주문 수량 기준으로 반영돼 있다(수동 확인 필요)"
|
||||
)
|
||||
new = dt_net_pnl(entry_price, avg_price, quantity, pos)
|
||||
delta = new["net_profit_loss"] - recorded_net
|
||||
slippage_pct = ((avg_price - signal_price) / signal_price * 100) if signal_price > 0 else 0.0
|
||||
from decimal import Decimal
|
||||
from app.services.db_worker import enqueue_update_trade_history_by_order_id
|
||||
await enqueue_update_trade_history_by_order_id(order_id, {
|
||||
# 식별 키(db_worker 가 setattr 하지 않음): KIS ODNO 는 일별 순번이라 날짜·구분으로 좁힌다
|
||||
"order_date": _order_date,
|
||||
"order_type": "SELL",
|
||||
"strategy_type": "daytrading",
|
||||
"executed_price": Decimal(str(round(avg_price, 2))),
|
||||
"total_amount": Decimal(str(round(avg_price * quantity, 2))),
|
||||
"commission": Decimal(str(new["total_cost"])),
|
||||
"net_profit_loss": Decimal(str(round(new["net_profit_loss"], 2))),
|
||||
"profit_loss_rate": Decimal(str(round(new["net_profit_loss_rate"], 4))) if entry_price > 0 else None,
|
||||
"slippage": Decimal(str(round(slippage_pct, 4))),
|
||||
})
|
||||
if delta:
|
||||
if count_daily_pnl:
|
||||
async with day_trading_state.trade_lock:
|
||||
day_trading_state.adjust_realized_pnl(delta)
|
||||
await day_trading_state.save_daily_state()
|
||||
else:
|
||||
_stale_realized_pnl += delta
|
||||
logger.info(
|
||||
f"[DayExit] 매도 체결가 확인: {stock_code} 신호가={signal_price:,.0f} "
|
||||
f"체결가={avg_price:,.2f} (슬리피지 {slippage_pct:+.3f}%) "
|
||||
f"순손익 {recorded_net:+.0f}→{new['net_profit_loss']:+.0f}원 "
|
||||
f"({'당일' if count_daily_pnl else '전일'} 손익 보정 {delta:+.0f}원)"
|
||||
)
|
||||
|
||||
return fill_reconciler.schedule(stock_code, order_id, "SELL", _on_fill)
|
||||
|
||||
|
||||
async def close_stale_positions() -> list[dict]:
|
||||
"""진입일이 오늘이 아닌(전일 미청산) 데이트레이딩 포지션을 시장가 전량 청산.
|
||||
|
||||
|
||||
143
backend/app/services/daytrading/fill_reconciler.py
Normal file
143
backend/app/services/daytrading/fill_reconciler.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""데이트레이딩 실제 체결가 확인 (백그라운드).
|
||||
|
||||
[2026-10-01] DT 는 시장가 주문 후 신호 시점 가격(1분봉 종가)을 체결가로 기록했다.
|
||||
실제 체결가와의 차이(슬리피지)가 원장·일일 손익·성과 통계에서 사라진다.
|
||||
|
||||
설계:
|
||||
- 주문 응답(KIS order-cash)에는 체결가가 없다 → 당일 주문체결 조회
|
||||
(`korea_investment_api.get_todays_orders`, inquire-daily-ccld)에서 주문번호(ODNO)로 찾는다.
|
||||
`get_order_execution()` 은 쓰지 않는다 — 국내 경로가 같은 TR(TTTC8001R)을
|
||||
`/trading/inquire-ccnl` 로 호출해(같은 TR 의 다른 메서드 `get_todays_orders` 는
|
||||
`/trading/inquire-daily-ccld`) 경로가 어긋나 있을 가능성이 높고, 실패를 debug 로 삼켜
|
||||
체결 0 과 구분되지 않는다(운영 확인 필요 — 보고 사항).
|
||||
- **실시간 루프를 막지 않는다**: `schedule()` 이 asyncio 태스크로 띄우고 즉시 반환.
|
||||
전체를 `DAY_TRADE_FILL_CHECK_TIMEOUT_SEC` 로 감싼다 — KIS 토큰 rate-limit 의
|
||||
`asyncio.sleep(≈70초)` 에 걸려도 상한에서 끊긴다.
|
||||
- DB I/O 없음. 반영은 호출부 콜백이 인메모리 + db_worker 큐로 한다(§3).
|
||||
- 확인 실패 시 None → 호출부는 신호가 기록을 유지하고 warning 을 남긴다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# create_task 결과의 강한 참조 — 참조가 없으면 GC 로 태스크가 중간에 사라질 수 있다.
|
||||
_pending_tasks: "set[asyncio.Task]" = set()
|
||||
|
||||
|
||||
def _norm_odno(value) -> str:
|
||||
"""KIS 주문번호 정규화 — 응답(ODNO)은 0 패딩, 조회(odno)는 경우에 따라 다르다."""
|
||||
s = str(value or "").strip()
|
||||
return s.lstrip("0") or ("0" if s else "")
|
||||
|
||||
|
||||
def parse_fill_row(rows: list, order_id: str) -> Optional[tuple[int, float]]:
|
||||
"""inquire-daily-ccld output1 에서 주문번호가 일치하는 행의 (체결수량, 평균체결가).
|
||||
|
||||
체결수량 0 이거나 평균가를 구할 수 없으면 None.
|
||||
"""
|
||||
target = _norm_odno(order_id)
|
||||
if not target:
|
||||
return None
|
||||
for row in rows or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if _norm_odno(row.get("odno")) != target:
|
||||
continue
|
||||
try:
|
||||
qty = int(float(row.get("tot_ccld_qty", 0) or 0))
|
||||
avg = float(row.get("avg_prvs", 0) or 0)
|
||||
if avg <= 0 and qty > 0:
|
||||
amt = float(row.get("tot_ccld_amt", 0) or 0)
|
||||
avg = amt / qty if amt > 0 else 0.0
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if qty > 0 and avg > 0:
|
||||
return qty, avg
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def _poll_fill(stock_code: str, order_id: str) -> Optional[tuple[int, float]]:
|
||||
"""체결 조회 폴링 (타임아웃은 호출부 wait_for 가 건다)."""
|
||||
attempts = max(1, int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS))
|
||||
interval = max(0.0, float(settings.DAY_TRADE_FILL_CHECK_INTERVAL_SEC))
|
||||
await asyncio.sleep(max(0.0, float(settings.DAY_TRADE_FILL_CHECK_DELAY_SEC)))
|
||||
for i in range(attempts):
|
||||
rows = await korea_investment_api.get_todays_orders(stock_code)
|
||||
fill = parse_fill_row(rows, order_id)
|
||||
if fill is not None:
|
||||
return fill
|
||||
if i < attempts - 1:
|
||||
await asyncio.sleep(interval)
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_fill(stock_code: str, order_id: str, side: str) -> Optional[tuple[int, float]]:
|
||||
"""주문번호의 실제 (체결수량, 평균체결가). 실패·시간초과·미체결이면 None + warning."""
|
||||
if not order_id:
|
||||
logger.warning(f"[DTFill] {stock_code} {side}: 주문번호 없음 — 신호가 기록 유지")
|
||||
return None
|
||||
timeout = float(settings.DAY_TRADE_FILL_CHECK_TIMEOUT_SEC)
|
||||
try:
|
||||
fill = await asyncio.wait_for(_poll_fill(stock_code, order_id), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"[DTFill] {stock_code} {side} 체결 조회 시간초과({timeout:.0f}초) "
|
||||
f"order_id={order_id} — 신호가 기록 유지"
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[DTFill] {stock_code} {side} 체결 조회 실패 order_id={order_id}: "
|
||||
f"{type(e).__name__}: {e} — 신호가 기록 유지"
|
||||
)
|
||||
return None
|
||||
if fill is None:
|
||||
logger.warning(
|
||||
f"[DTFill] {stock_code} {side} 체결 미확인 "
|
||||
f"({int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS)}회 조회) order_id={order_id} — 신호가 기록 유지"
|
||||
)
|
||||
return fill
|
||||
|
||||
|
||||
def schedule(
|
||||
stock_code: str,
|
||||
order_id: str,
|
||||
side: str,
|
||||
on_fill: Callable[[int, float], Awaitable[None]],
|
||||
) -> Optional[asyncio.Task]:
|
||||
"""체결 확인을 백그라운드로 띄우고 즉시 반환. 확인되면 `on_fill(qty, avg_price)` 호출.
|
||||
|
||||
`DAY_TRADE_FILL_CHECK_ENABLED=False` 면 아무것도 하지 않는다(None).
|
||||
"""
|
||||
if not settings.DAY_TRADE_FILL_CHECK_ENABLED:
|
||||
return None
|
||||
|
||||
async def _run() -> None:
|
||||
fill = await fetch_fill(stock_code, order_id, side)
|
||||
if fill is None:
|
||||
return
|
||||
try:
|
||||
await on_fill(*fill)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[DTFill] {stock_code} {side} 체결가 반영 실패 order_id={order_id}: "
|
||||
f"{type(e).__name__}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
task = asyncio.get_running_loop().create_task(_run())
|
||||
except RuntimeError as e:
|
||||
logger.warning(f"[DTFill] {stock_code} {side} 체결 확인 예약 실패: {e}")
|
||||
return None
|
||||
_pending_tasks.add(task)
|
||||
task.add_done_callback(_pending_tasks.discard)
|
||||
return task
|
||||
@@ -200,6 +200,15 @@ class DayTradingState:
|
||||
# 이익 발생 시 연속 손실 카운트 리셋 (손익분기 realized==0은 중립 — 리셋하지 않음)
|
||||
self.loss_streak_today = 0
|
||||
|
||||
def adjust_realized_pnl(self, delta: float) -> None:
|
||||
"""[2026-10-01] 실현손익 사후 보정 (체결가 확인으로 기록 손익이 바뀐 경우).
|
||||
|
||||
반드시 trade_lock 획득 후 호출해야 함.
|
||||
daily_pnl 만 보정하고 loss_streak_today 는 건드리지 않는다 — 연패 판정은 청산
|
||||
시점에 한 번만 한다(같은 청산을 두 번 세지 않기 위함).
|
||||
"""
|
||||
self.daily_pnl += float(delta)
|
||||
|
||||
def get_position(self, stock_code: str) -> dict | None:
|
||||
"""특정 종목의 활성 포지션 정보 반환.
|
||||
|
||||
|
||||
@@ -883,6 +883,117 @@ async def _handle_upsert_portfolio(session, data: dict) -> None:
|
||||
)
|
||||
|
||||
|
||||
# [2026-10-01] update_trade_history_by_order_id 의 식별·필터 전용 키 — setattr 대상이 아니다.
|
||||
# strategy_type/order_type 은 중복 행을 좁히는 데만 쓴다(좁히기에 실패해 다른 전략 행이
|
||||
# 선택됐을 때 그 행의 전략 구분을 덮어쓰지 않도록).
|
||||
_ORDER_ID_UPDATE_META_KEYS: frozenset = frozenset(
|
||||
{"order_id", "order_date", "strategy_type", "order_type"}
|
||||
)
|
||||
|
||||
|
||||
def _norm_kis_order_id(value) -> str:
|
||||
"""KIS 주문번호 정규화(앞자리 0 제거). 저장 형식은 응답 ODNO 원문(0 패딩)이다."""
|
||||
s = str(value or "").strip()
|
||||
return s.lstrip("0")
|
||||
|
||||
|
||||
def _order_id_ref_date(data: dict) -> date:
|
||||
"""조회 기준일(KST). 우선순위: order_date(메타) > created_at > executed_at > 오늘.
|
||||
|
||||
executed_at 은 체결 확인 시각(주문 후 수 초)이라 주문일과 같다. DLQ 재처리로 다음 날
|
||||
실행돼도 payload 의 원래 날짜를 쓰므로 당일 필터가 행을 놓치지 않는다.
|
||||
"""
|
||||
for key in ("order_date", "created_at", "executed_at"):
|
||||
v = data.get(key)
|
||||
if v is None or v == "":
|
||||
continue
|
||||
try:
|
||||
if isinstance(v, datetime):
|
||||
return v.date()
|
||||
if isinstance(v, date):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
return datetime.fromisoformat(v).date() if "T" in v or " " in v else date.fromisoformat(v)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("⚠️ [Worker] order_id 갱신 기준일 파싱 실패 %s=%r — 다음 후보 사용", key, v)
|
||||
return now_kst().date()
|
||||
|
||||
|
||||
async def _handle_update_trade_history_by_order_id(session, data: dict) -> None:
|
||||
"""TradeHistory UPDATE by KIS 주문번호 — 정상 경로 단일 구현.
|
||||
|
||||
[2026-10-01] KIS ODNO 는 **일별 순번**이라 날짜가 바뀌면 같은 번호가 반복된다. 예전 구현
|
||||
(`order_id == X` + `scalar_one_or_none()`)은 과거 행과 겹치면 MultipleResultsFound →
|
||||
단일 워커가 1+2+4초 재시도로 큐 전체를 멈추고 DLQ 로 보냈고, 당일 INSERT 가 유실된
|
||||
상태면 **과거 행을 덮어써** 원장을 오염시켰다.
|
||||
- 기준일(KST 00:00~다음날 00:00) created_at 범위로 한정한다(_order_id_ref_date).
|
||||
- order_id 는 원문 일치 OR 앞자리 0 제거 일치.
|
||||
- 그래도 여러 행이면 payload 의 strategy_type·order_type 으로 좁히고 created_at desc
|
||||
첫 행만 갱신 + warning (예외 없음 → 큐 정체 없음).
|
||||
- 0건이면 warning 후 종료(재시도·DLQ 없음 — 기존 동작 유지).
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import func
|
||||
|
||||
order_id = data.get("order_id")
|
||||
if not order_id:
|
||||
return
|
||||
user_id = data.get("user_id")
|
||||
ref = _order_id_ref_date(data)
|
||||
start = datetime.combine(ref, datetime.min.time())
|
||||
end = start + timedelta(days=1)
|
||||
|
||||
raw = str(order_id).strip()
|
||||
norm = _norm_kis_order_id(raw)
|
||||
id_cond = TradeHistory.order_id == raw
|
||||
if norm:
|
||||
id_cond = or_(id_cond, func.ltrim(TradeHistory.order_id, "0") == norm)
|
||||
|
||||
query = select(TradeHistory).where(
|
||||
id_cond,
|
||||
TradeHistory.created_at >= start,
|
||||
TradeHistory.created_at < end,
|
||||
)
|
||||
if user_id is not None:
|
||||
query = query.where(TradeHistory.user_id == user_id)
|
||||
query = query.order_by(TradeHistory.created_at.desc(), TradeHistory.id.desc())
|
||||
rows = list((await session.execute(query)).scalars().all())
|
||||
|
||||
if not rows:
|
||||
logger.warning(
|
||||
"⚠️ [Worker] TradeHistory not found for order_id=%s (기준일 %s KST) — 갱신 생략",
|
||||
order_id, ref.isoformat(),
|
||||
)
|
||||
return
|
||||
|
||||
if len(rows) > 1:
|
||||
narrowed = rows
|
||||
for key in ("strategy_type", "order_type"):
|
||||
want = data.get(key)
|
||||
if want is None:
|
||||
continue
|
||||
cand = [r for r in narrowed if getattr(r, key, None) == want]
|
||||
if cand:
|
||||
narrowed = cand
|
||||
logger.warning(
|
||||
"⚠️ [Worker] order_id=%s 기준일 %s 에 TradeHistory %d건 중복 — "
|
||||
"strategy_type/order_type 으로 좁힌 %d건 중 최신 1건(id=%s)만 갱신",
|
||||
order_id, ref.isoformat(), len(rows), len(narrowed), narrowed[0].id,
|
||||
)
|
||||
rows = narrowed
|
||||
|
||||
trade = rows[0]
|
||||
for key, val in data.items():
|
||||
if key in _ORDER_ID_UPDATE_META_KEYS or val is None or not hasattr(trade, key):
|
||||
continue
|
||||
setattr(trade, key, val)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"✅ [Worker] TradeHistory updated by order_id=%s (id=%s) status=%s",
|
||||
order_id, trade.id, data.get("status", "?"),
|
||||
)
|
||||
|
||||
|
||||
async def _handle_upsert_active_trade(session, data: dict) -> None:
|
||||
"""ActiveTrade UPSERT — 정상 경로와 종료 drain 이 공유하는 단일 구현."""
|
||||
from app.models.models import ActiveTrade
|
||||
@@ -1054,29 +1165,7 @@ async def db_writer_worker() -> None:
|
||||
|
||||
# ── 2b. TradeHistory UPDATE by order_id (KIS 주문번호 기준) ──
|
||||
elif task_type == "update_trade_history_by_order_id":
|
||||
order_id = data.get("order_id")
|
||||
user_id = data.get("user_id")
|
||||
if order_id:
|
||||
query = select(TradeHistory).where(TradeHistory.order_id == order_id)
|
||||
if user_id is not None:
|
||||
query = query.where(TradeHistory.user_id == user_id)
|
||||
row = await session.execute(query)
|
||||
trade = row.scalar_one_or_none()
|
||||
if trade:
|
||||
for key, val in data.items():
|
||||
if key != "order_id" and val is not None and hasattr(trade, key):
|
||||
setattr(trade, key, val)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"✅ [Worker] TradeHistory updated by order_id=%s status=%s",
|
||||
order_id,
|
||||
data.get("status", "?"),
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ [Worker] TradeHistory not found for order_id=%s",
|
||||
order_id,
|
||||
)
|
||||
await _handle_update_trade_history_by_order_id(session, data)
|
||||
|
||||
# ── 3. 단건 StockAnalysis UPSERT ──────────────────────────
|
||||
elif task_type == "upsert_stock_analysis":
|
||||
|
||||
@@ -165,6 +165,79 @@ async def _cache_set(key: str, ttl: int, data: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ── 국내 주문체결 조회 (inquire-daily-ccld) ────────────────────────────────
|
||||
# KIS 공식 "주식일별주문체결조회"(3개월 이내): GET /uapi/domestic-stock/v1/trading/inquire-daily-ccld
|
||||
# TR — 현행 공식 샘플(koreainvestment/open-trading-api examples_llm/domestic_stock/inquire_daily_ccld):
|
||||
# 실전 TTTC0081R / 모의 VTTC0081R. 구 TR(legacy/ 샘플·Postman v2.6): 실전 TTTC8001R / 모의 VTTC8001R.
|
||||
# 구 TR 의 현재 지원 여부는 레포 근거로 확정 불가 → 현행 TR 우선, rt_cd != "0" 이면 구 TR 1회 폴백
|
||||
# (성공한 쪽을 인스턴스에 기억). 응답 output1[] = 주문 단위 행
|
||||
# (odno, pdno, sll_buy_dvsn_cd, ord_qty, tot_ccld_qty, avg_prvs, tot_ccld_amt, rmn_qty, cncl_yn ...).
|
||||
# 연속조회: 응답 헤더 tr_cont F/M = 다음 있음 → 요청 헤더 tr_cont=N + CTX_AREA_FK100/NK100.
|
||||
DAILY_CCLD_ENDPOINT = "/uapi/domestic-stock/v1/trading/inquire-daily-ccld"
|
||||
DAILY_CCLD_TR_IDS = { # (실전, 모의)
|
||||
"current": ("TTTC0081R", "VTTC0081R"),
|
||||
"legacy": ("TTTC8001R", "VTTC8001R"),
|
||||
}
|
||||
ORDER_EXEC_MAX_PAGES = 5 # 단건 체결조회 연속조회 상한 (ODNO 필터가 무시돼도 무한루프 방지)
|
||||
|
||||
|
||||
def _norm_kis_odno(value) -> str:
|
||||
"""KIS 주문번호 정규화 — 주문 응답(ODNO)과 조회 응답(odno)의 0 패딩 차이를 흡수."""
|
||||
s = str(value or "").strip()
|
||||
return s.lstrip("0") or ("0" if s else "")
|
||||
|
||||
|
||||
def _to_int_or_none(value) -> Optional[int]:
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
return None
|
||||
try:
|
||||
return int(float(str(value).replace(",", "")))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_order_execution_rows(rows, order_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""inquire-daily-ccld output1 에서 주문번호가 일치하는 행을 체결 dict 로 변환 (순수 함수).
|
||||
|
||||
반환 (일치 행 없으면 None):
|
||||
filled_qty : 총체결수량(tot_ccld_qty)
|
||||
filled_price : 평균체결가(avg_prvs). 0 이면 tot_ccld_amt / tot_ccld_qty 로 보정
|
||||
ordered_qty : 주문수량(ord_qty) — 모르면 None
|
||||
remaining_qty : 잔여수량(rmn_qty) — **필드가 없으면 None**(0 과 구분: 0 은 '잔량 소멸')
|
||||
cancelled : cncl_yn == "Y" (취소는 별도 행(orgn_odno)이라 원주문은 N 일 수 있음 — 로그용)
|
||||
cancel_confirmed_qty : 취소확인수량(cnc_cfrm_qty) — 없으면 None (로그용)
|
||||
rejected_qty : 거부수량(rjct_qty) — 없으면 None (로그용)
|
||||
"""
|
||||
target = _norm_kis_odno(order_id)
|
||||
if not target:
|
||||
return None
|
||||
if isinstance(rows, dict):
|
||||
rows = [rows]
|
||||
for row in rows or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if _norm_kis_odno(row.get("odno")) != target:
|
||||
continue
|
||||
filled_qty = _to_int_or_none(row.get("tot_ccld_qty")) or 0
|
||||
try:
|
||||
filled_price = float(str(row.get("avg_prvs") or 0).replace(",", ""))
|
||||
except (TypeError, ValueError):
|
||||
filled_price = 0.0
|
||||
if filled_price <= 0 and filled_qty > 0:
|
||||
amt = _to_int_or_none(row.get("tot_ccld_amt")) or 0
|
||||
filled_price = (amt / filled_qty) if amt > 0 else 0.0
|
||||
return {
|
||||
"filled_qty": max(0, filled_qty),
|
||||
"filled_price": float(filled_price) if filled_qty > 0 else 0.0,
|
||||
"ordered_qty": _to_int_or_none(row.get("ord_qty")),
|
||||
"remaining_qty": _to_int_or_none(row.get("rmn_qty")),
|
||||
"cancelled": str(row.get("cncl_yn") or "").upper() == "Y",
|
||||
"cancel_confirmed_qty": _to_int_or_none(row.get("cnc_cfrm_qty")),
|
||||
"rejected_qty": _to_int_or_none(row.get("rjct_qty")),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _round_to_krx_tick(price: int) -> int:
|
||||
"""KRX 호가단위에 맞춰 가격을 올림(ceil) 처리. 모의투자 호가단위 오류 방지."""
|
||||
if price < 1_000:
|
||||
@@ -1278,45 +1351,105 @@ class KoreaInvestmentAPI:
|
||||
raise
|
||||
|
||||
async def get_todays_orders(self, stock_code: str = "") -> list[dict]:
|
||||
"""당일 주문체결 내역 조회 (inquire-daily-ccld).
|
||||
"""당일 주문체결 내역 조회 (inquire-daily-ccld) — 첫 페이지만(실전 100건 / 모의 15건, 역순).
|
||||
|
||||
주문 전송 후 응답을 못 받았을 때 '실제로 접수됐는지' 확인해 중복 주문을 막는 용도.
|
||||
실전: TTTC8001R / 모의: VTTC8001R. 실패 시 빈 리스트.
|
||||
TR: 현행 TTTC0081R/VTTC0081R 우선, 실패 시 구 TTTC8001R/VTTC8001R. 실패 시 빈 리스트.
|
||||
"""
|
||||
try:
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
|
||||
"VTTC8001R" if is_virtual else "TTTC8001R",
|
||||
params={
|
||||
"CANO": account_number_prefix,
|
||||
"ACNT_PRDT_CD": account_number_suffix,
|
||||
"INQR_STRT_DT": today,
|
||||
"INQR_END_DT": today,
|
||||
"SLL_BUY_DVSN_CD": "00",
|
||||
"INQR_DVSN": "00",
|
||||
"PDNO": stock_code or "",
|
||||
"CCLD_DVSN": "00",
|
||||
"ORD_GNO_BRNO": "",
|
||||
"ODNO": "",
|
||||
"INQR_DVSN_3": "00",
|
||||
"INQR_DVSN_1": "",
|
||||
"CTX_AREA_FK100": "",
|
||||
"CTX_AREA_NK100": "",
|
||||
},
|
||||
)
|
||||
result, _ = await self._daily_ccld_fetch_v2(stock_code=stock_code)
|
||||
if result.get("rt_cd") != "0":
|
||||
# [2026-10-01] 이전에는 조용히 [] — 조회 실패와 '주문 없음' 이 구분되지 않았다.
|
||||
logger.warning(
|
||||
f"[get_todays_orders] {stock_code or '*'} 조회 실패 "
|
||||
f"rt_cd={result.get('rt_cd')} msg_cd={result.get('msg_cd')} msg1={result.get('msg1')}"
|
||||
)
|
||||
return []
|
||||
out = result.get("output1") or []
|
||||
return out if isinstance(out, list) else [out]
|
||||
except Exception as e:
|
||||
logger.debug(f"[get_todays_orders] 조회 실패: {e}")
|
||||
# CLAUDE.md §4: 예외 로그 debug 금지 (중복주문 방지·DT 체결가 확인의 근거 조회)
|
||||
logger.warning(f"[get_todays_orders] {stock_code or '*'} 조회 예외: {type(e).__name__}: {e}")
|
||||
return []
|
||||
|
||||
def _daily_ccld_request_v2(
|
||||
self,
|
||||
stock_code: str = "",
|
||||
order_id: str = "",
|
||||
ctx_fk100: str = "",
|
||||
ctx_nk100: str = "",
|
||||
tr_generation: str = "current",
|
||||
) -> tuple:
|
||||
"""inquire-daily-ccld 요청 (tr_id, params) 구성 — 당일 조회. 모의/실전 TR 분기 포함.
|
||||
|
||||
`get_todays_orders` 와 `get_order_execution` 이 같은 요청 구성을 공유한다
|
||||
(2026-10-01 이전에는 후자가 존재하지 않는 `/trading/inquire-ccnl` 경로로 따로 조립했다).
|
||||
tr_generation: "current"(TTTC0081R/VTTC0081R) | "legacy"(TTTC8001R/VTTC8001R)
|
||||
"""
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
real_tr, virtual_tr = DAILY_CCLD_TR_IDS[tr_generation]
|
||||
tr_id = virtual_tr if is_virtual else real_tr
|
||||
params = {
|
||||
"CANO": self.account_number[:8],
|
||||
"ACNT_PRDT_CD": self.account_number[8:],
|
||||
"INQR_STRT_DT": today,
|
||||
"INQR_END_DT": today,
|
||||
"SLL_BUY_DVSN_CD": "00", # 00 전체 / 01 매도 / 02 매수
|
||||
"INQR_DVSN": "00", # 00 역순(최신 먼저)
|
||||
"PDNO": stock_code or "",
|
||||
"CCLD_DVSN": "00", # 00 전체 / 01 체결 / 02 미체결
|
||||
"ORD_GNO_BRNO": "",
|
||||
"ODNO": order_id or "",
|
||||
"INQR_DVSN_3": "00",
|
||||
"INQR_DVSN_1": "",
|
||||
"CTX_AREA_FK100": ctx_fk100 or "",
|
||||
"CTX_AREA_NK100": ctx_nk100 or "",
|
||||
"EXCG_ID_DVSN_CD": "KRX", # 거래소ID구분 (공식 샘플 기본값 KRX / NXT / SOR / ALL)
|
||||
}
|
||||
return tr_id, params
|
||||
|
||||
async def _daily_ccld_fetch_v2(
|
||||
self,
|
||||
stock_code: str = "",
|
||||
order_id: str = "",
|
||||
ctx_fk100: str = "",
|
||||
ctx_nk100: str = "",
|
||||
next_page: bool = False,
|
||||
) -> tuple:
|
||||
"""inquire-daily-ccld 1페이지 호출 → (result, headers). 예외는 그대로 올린다.
|
||||
|
||||
현행 TR 이 rt_cd != "0" 이면 구 TR 로 1회 재시도하고, 구 TR 이 성공하면 이후 구 TR 을
|
||||
우선 쓴다(인스턴스 상태). 둘 다 실패하면 마지막 결과를 그대로 반환(호출부가 warning).
|
||||
연속조회는 요청 헤더 tr_cont=N (공식 샘플 규약).
|
||||
"""
|
||||
preferred = getattr(self, "_daily_ccld_tr_generation", "current")
|
||||
order = [preferred] + [g for g in ("current", "legacy") if g != preferred]
|
||||
extra = {"tr_cont": "N"} if next_page else None
|
||||
result, headers = {}, None
|
||||
for i, gen in enumerate(order):
|
||||
tr_id, params = self._daily_ccld_request_v2(
|
||||
stock_code=stock_code, order_id=order_id,
|
||||
ctx_fk100=ctx_fk100, ctx_nk100=ctx_nk100, tr_generation=gen,
|
||||
)
|
||||
result, headers = await self._make_request(
|
||||
"GET", DAILY_CCLD_ENDPOINT, tr_id, extra_headers=extra, params=params,
|
||||
)
|
||||
if isinstance(result, dict) and result.get("rt_cd") == "0":
|
||||
if gen != preferred:
|
||||
logger.warning(
|
||||
f"[daily-ccld] TR {gen} 로 전환 (선호 TR {preferred} 실패) — tr_id={tr_id}"
|
||||
)
|
||||
self._daily_ccld_tr_generation = gen
|
||||
return result, headers
|
||||
if i == 0:
|
||||
_r = result if isinstance(result, dict) else {}
|
||||
logger.warning(
|
||||
f"[daily-ccld] tr_id={tr_id} 실패 rt_cd={_r.get('rt_cd')} "
|
||||
f"msg_cd={_r.get('msg_cd')} msg1={_r.get('msg1')} — 대체 TR 재시도"
|
||||
)
|
||||
return result, headers
|
||||
|
||||
async def get_buyable_cash(self, stock_code: str = "005930") -> float:
|
||||
"""주문가능현금(ord_psbl_cash) 조회 — 매수가능조회 API.
|
||||
|
||||
@@ -1448,51 +1581,63 @@ class KoreaInvestmentAPI:
|
||||
async def get_order_execution(self, order_id: str, is_domestic: bool = True) -> Dict[str, Any]:
|
||||
"""주문 체결 내역 단건 조회.
|
||||
|
||||
국내: TTTC8001R (실전) / VTTC8001R (모의)
|
||||
국내: GET /uapi/domestic-stock/v1/trading/inquire-daily-ccld,
|
||||
TTTC0081R/VTTC0081R (현행) → 실패 시 TTTC8001R/VTTC8001R (구)
|
||||
Returns:
|
||||
{"filled_qty": int, "filled_price": float}
|
||||
체결 미확인 시 filled_qty=0 반환.
|
||||
"""
|
||||
try:
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
{"filled_qty": int, "filled_price": float} ← 기존 계약(불변)
|
||||
+ 추가 키(국내, 하위호환): "found"(주문행 발견), "ok"(조회 성공),
|
||||
"ordered_qty", "remaining_qty"(None=모름), "cancelled",
|
||||
"cancel_confirmed_qty", "rejected_qty"
|
||||
체결 미확인·조회 실패 시 filled_qty=0 반환.
|
||||
|
||||
if is_domestic:
|
||||
tr_id = "VTTC8001R" if is_virtual else "TTTC8001R"
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/trading/inquire-ccnl",
|
||||
tr_id,
|
||||
params={
|
||||
"CANO": account_number_prefix,
|
||||
"ACNT_PRDT_CD": account_number_suffix,
|
||||
"INQR_STRT_DT": today,
|
||||
"INQR_END_DT": today,
|
||||
"SLL_BUY_DVSN_CD": "00",
|
||||
"INQR_DVSN": "00",
|
||||
"PDNO": "",
|
||||
"CCLD_DVSN": "00",
|
||||
"ORD_GNO_BRNO": "",
|
||||
"ODNO": order_id,
|
||||
"INQR_DVSN_3": "00",
|
||||
"INQR_DVSN_1": "",
|
||||
"CTX_AREA_FK100": "",
|
||||
"CTX_AREA_NK100": "",
|
||||
},
|
||||
[2026-10-01] 이전 구현은 `/domestic-stock/v1/trading/inquire-ccnl` 을 호출했다 — KIS 에
|
||||
그런 경로는 없다(국내 `inquire-ccnl` 은 `/quotations/` 아래 시세 체결 FHKST01010300,
|
||||
`/trading/inquire-ccnl` 은 해외 `/overseas-stock/` 의 체결조회 TTTS3035R). 그리고 실패를 debug 로
|
||||
삼키거나(예외) 아예 로그 없이(4xx 응답) filled_qty=0 을 돌려줬다 → 체결 확인이 항상 실패.
|
||||
"""
|
||||
if not is_domestic:
|
||||
# 해외 체결 조회(TTTS3035R)는 미구현 — 포트폴리오 동기화로 대체
|
||||
return {"filled_qty": 0, "filled_price": 0.0}
|
||||
return await self._get_order_execution_domestic_impl(order_id)
|
||||
|
||||
async def _get_order_execution_domestic_impl(self, order_id: str) -> Dict[str, Any]:
|
||||
"""국내 단건 체결조회 본체 — inquire-daily-ccld 를 ODNO 로 조회 + 클라이언트측 ODNO 매칭.
|
||||
|
||||
서버측 ODNO 필터가 기대대로 동작하지 않아도(전체 반환) 응답에서 주문번호로 다시 고르고,
|
||||
연속조회(tr_cont F/M → N)로 최대 ORDER_EXEC_MAX_PAGES 페이지까지 찾는다.
|
||||
"""
|
||||
empty = {"filled_qty": 0, "filled_price": 0.0, "found": False, "ok": False,
|
||||
"ordered_qty": None, "remaining_qty": None, "cancelled": False,
|
||||
"cancel_confirmed_qty": None, "rejected_qty": None}
|
||||
if not order_id:
|
||||
return empty
|
||||
try:
|
||||
ctx_fk, ctx_nk = "", ""
|
||||
for page in range(ORDER_EXEC_MAX_PAGES):
|
||||
result, headers = await self._daily_ccld_fetch_v2(
|
||||
order_id=order_id, ctx_fk100=ctx_fk, ctx_nk100=ctx_nk, next_page=page > 0,
|
||||
)
|
||||
output = result.get("output1", [])
|
||||
if output:
|
||||
row = output[0] if isinstance(output, list) else output
|
||||
filled_qty = int(row.get("tot_ccld_qty", 0) or 0)
|
||||
filled_price = float(row.get("avg_prvs", 0) or 0)
|
||||
return {"filled_qty": filled_qty, "filled_price": filled_price}
|
||||
# 해외 체결 조회는 별도 TR_ID 필요 — 현재 미지원 (포트폴리오 동기화로 대체)
|
||||
return {"filled_qty": 0, "filled_price": 0.0}
|
||||
if not isinstance(result, dict) or result.get("rt_cd") != "0":
|
||||
_r = result if isinstance(result, dict) else {}
|
||||
logger.warning(
|
||||
f"[get_order_execution] order_id={order_id} 체결조회 실패 "
|
||||
f"rt_cd={_r.get('rt_cd')} msg_cd={_r.get('msg_cd')} msg1={_r.get('msg1')}"
|
||||
)
|
||||
return empty
|
||||
parsed = parse_order_execution_rows(result.get("output1") or [], order_id)
|
||||
if parsed is not None:
|
||||
return {**parsed, "found": True, "ok": True}
|
||||
tr_cont = (headers.get("tr_cont") if headers is not None else None) or ""
|
||||
ctx_fk = str(result.get("ctx_area_fk100") or "").strip()
|
||||
ctx_nk = str(result.get("ctx_area_nk100") or "").strip()
|
||||
if tr_cont not in ("F", "M") or not ctx_nk:
|
||||
break
|
||||
return {**empty, "ok": True} # 조회는 성공했으나 주문행 없음(접수 전/다른 날)
|
||||
except Exception as e:
|
||||
logger.debug(f"[get_order_execution] order_id={order_id} 조회 오류: {e}")
|
||||
return {"filled_qty": 0, "filled_price": 0.0}
|
||||
logger.warning(
|
||||
f"[get_order_execution] order_id={order_id} 체결조회 예외: {type(e).__name__}: {e}"
|
||||
)
|
||||
return empty
|
||||
|
||||
async def cancel_order(self, order_id: str, quantity: int) -> Dict[str, Any]:
|
||||
"""주문 취소"""
|
||||
|
||||
@@ -43,6 +43,7 @@ __all__ = [
|
||||
"amount_to_quantity",
|
||||
"half_kelly_fraction",
|
||||
"effective_min_buy_amount",
|
||||
"remaining_position_room",
|
||||
]
|
||||
|
||||
|
||||
@@ -198,3 +199,27 @@ def effective_min_buy_amount(
|
||||
if small_account_cap <= 0:
|
||||
return config_min_amount
|
||||
return min(config_min_amount, small_account_cap)
|
||||
|
||||
|
||||
# ── 종목당 누적 한도 ─────────────────────────────────────────────────────────
|
||||
|
||||
def remaining_position_room(
|
||||
total_assets: float,
|
||||
position_cap_ratio: float,
|
||||
existing_position_value: float,
|
||||
) -> float:
|
||||
"""종목당 **누적** 잔여 한도 = `max(0, 총자산 × 종목당상한 − 해당 종목 기존 보유 평가액)`.
|
||||
|
||||
`clamp_by_ratios(..., [MAX_POSITION_SIZE], ...)` 는 **주문 1건**을 상한으로 묶을 뿐이라
|
||||
같은 종목이 매일 추천되면 추가매수가 쌓여 종목 비중이 상한을 넘을 수 있다
|
||||
(CLAUDE.md §5.1 "종목당 4% 절대 상한"). 이 함수는 기존 보유분을 차감한 잔여 한도를
|
||||
돌려준다.
|
||||
|
||||
**floor 있음** — 이미 한도를 초과했으면 0. `total_assets <= 0` 또는
|
||||
`position_cap_ratio <= 0` 이면 한도를 계산할 수 없으므로 0 을 반환한다
|
||||
(호출부가 "검증 불가"를 별도로 판단한다). 음수 보유 평가액은 0 으로 본다.
|
||||
"""
|
||||
if total_assets <= 0 or position_cap_ratio <= 0:
|
||||
return 0.0
|
||||
existing = max(0.0, float(existing_position_value or 0.0))
|
||||
return max(0.0, total_assets * position_cap_ratio - existing)
|
||||
|
||||
72
backend/app/services/trade_cost_core.py
Normal file
72
backend/app/services/trade_cost_core.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""매매 비용·순손익 순수 계산 모듈.
|
||||
|
||||
settings·DB·네트워크를 참조하지 않는다(indicators_core / sizing_core 와 같은 원칙).
|
||||
**비용률 숫자는 이 모듈에 없다** — 호출부가 settings(KR_COMMISSION_RATE,
|
||||
KR_SELL_TAX_RATE_KOSPI/KOSDAQ)에서 꺼내 넘긴다.
|
||||
|
||||
비용 정의는 스윙 집행 경로(`trade_helpers.calculate_trade_costs` →
|
||||
`trade_executor` 매도 기록)와 동일하다:
|
||||
- 매수 수수료 = 진입가 × 수량 × 수수료율
|
||||
- 매도 수수료 = 청산가 × 수량 × 수수료율
|
||||
- 매도 거래세 = 청산가 × 수량 × 거래세율
|
||||
- 각 항목을 원 단위로 반올림한 뒤 합산
|
||||
슬리피지는 비용률에 넣지 않는다 — 실제 체결가를 쓰면 이미 손익에 반영되어 있고,
|
||||
`DAY_TRADE_COST_PCT`(수수료+슬리피지 추정치)는 진입/청산 **판정 게이트**용이라 기록에 쓰지 않는다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def round_trip_costs(
|
||||
entry_price: float,
|
||||
exit_price: float,
|
||||
quantity: int,
|
||||
commission_rate: float,
|
||||
sell_tax_rate: float,
|
||||
) -> dict:
|
||||
"""왕복 매매 비용(원). `trade_helpers.calculate_trade_costs` 와 같은 반올림 규칙.
|
||||
|
||||
Returns:
|
||||
{"buy_commission", "sell_commission", "sell_tax", "total_cost"}
|
||||
"""
|
||||
qty = max(0, int(quantity))
|
||||
buy_commission = float(entry_price) * qty * float(commission_rate)
|
||||
sell_commission = float(exit_price) * qty * float(commission_rate)
|
||||
sell_tax = float(exit_price) * qty * float(sell_tax_rate)
|
||||
total_cost = buy_commission + sell_commission + sell_tax
|
||||
return {
|
||||
"buy_commission": round(buy_commission, 0),
|
||||
"sell_commission": round(sell_commission, 0),
|
||||
"sell_tax": round(sell_tax, 0),
|
||||
"total_cost": round(total_cost, 0),
|
||||
}
|
||||
|
||||
|
||||
def net_realized_pnl(
|
||||
entry_price: float,
|
||||
exit_price: float,
|
||||
quantity: int,
|
||||
commission_rate: float,
|
||||
sell_tax_rate: float,
|
||||
) -> dict:
|
||||
"""비용 차감 실현손익.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"gross_profit_loss": (청산가 − 진입가) × 수량,
|
||||
"total_cost": 왕복 비용(원),
|
||||
"net_profit_loss": gross − total_cost,
|
||||
"net_profit_loss_rate": net / (진입가 × 수량) × 100 (진입 원금 대비 %, 진입가<=0 이면 0.0),
|
||||
}
|
||||
"""
|
||||
qty = max(0, int(quantity))
|
||||
gross = (float(exit_price) - float(entry_price)) * qty
|
||||
costs = round_trip_costs(entry_price, exit_price, qty, commission_rate, sell_tax_rate)
|
||||
net = gross - costs["total_cost"]
|
||||
invested = float(entry_price) * qty
|
||||
rate = (net / invested * 100.0) if invested > 0 else 0.0
|
||||
return {
|
||||
"gross_profit_loss": gross,
|
||||
"total_cost": costs["total_cost"],
|
||||
"net_profit_loss": net,
|
||||
"net_profit_loss_rate": rate,
|
||||
}
|
||||
@@ -32,6 +32,7 @@ from app.services.technical_indicators import (
|
||||
# §8.5-2 포지션 사이징 단일 출처 (순수 계산 모듈 — settings/DB/네트워크 없음)
|
||||
from app.services.sizing_core import (
|
||||
amount_to_quantity, clamp_by_ratios, effective_min_buy_amount, half_kelly_fraction,
|
||||
remaining_position_room,
|
||||
)
|
||||
from app.services.signal_types import BUY_KEYWORDS, SELL_KEYWORDS, STRONG_BUY_KEYWORDS, BLOCK_KEYWORDS
|
||||
from app.services.correlation_filter import validate_correlation_filter
|
||||
@@ -531,16 +532,33 @@ async def _verify_and_sync_execution(
|
||||
db_trade_id: int,
|
||||
delay: int = 3,
|
||||
) -> None:
|
||||
"""주문 후 KIS 체결 내역 조회 → DB 업데이트 + 슬리피지 기록 + 부분 체결 재주문.
|
||||
"""주문 후 KIS 체결 내역 조회 → DB 업데이트 + 슬리피지 기록 (부분 체결 자동 재주문 없음).
|
||||
|
||||
[동작 흐름]
|
||||
1. delay초 대기 (체결 확인 전 KIS 서버 처리 시간 확보)
|
||||
2. KIS 체결 조회 API 호출 → filled_qty, filled_price 추출
|
||||
3. 슬리피지 계산: (체결가 - 주문가) / 주문가 × 100
|
||||
4. db_worker 큐를 통해 TradeHistory UPDATE (CLAUDE.md 3.2 준수)
|
||||
- 완전 체결(filled_qty == ordered_quantity) → status = "COMPLETED"
|
||||
- 부분 체결(filled_qty < ordered_quantity) → status = "PARTIAL" + 잔여 재주문
|
||||
- 완전 체결(filled_qty >= ordered_quantity) → status = "COMPLETED"
|
||||
- 부분 체결 + KIS 잔량 소멸(rmn_qty == 0: 취소·거부·만료) → 체결분만 기록(COMPLETED)
|
||||
+ warning("수동 확인"). **자동 재주문하지 않는다** (아래 ③)
|
||||
- 부분 체결 + 잔량이 KIS 에 살아 있음/모름 → DB 미갱신 + warning (재주문 금지)
|
||||
5. 포트폴리오 동기화 트리거
|
||||
|
||||
[2026-10-01] `get_order_execution` 이 존재하지 않는 경로를 호출해 이 함수의 체결 반영
|
||||
분기는 **한 번도 실행된 적이 없다**. 경로를 고치면서 처음 살아나는 분기의 결함을 함께 고쳤다:
|
||||
① 첫 부분체결 관측에서 곧바로 break → 잔량이 아직 KIS 에 지정가 주문으로 살아 있는데
|
||||
같은 수량을 재주문 = **중복 주문(과다 매수 / 과다 매도 시도)**. 이제 전량 체결 또는
|
||||
잔량 소멸까지 폴링을 계속한다.
|
||||
② status "PARTIAL" 은 매수 재진입 가드(당일 중복매수·일일횟수·쿨다운: EXECUTED/COMPLETED
|
||||
(/PENDING) 만 셈)와 손익 집계(COMPLETED/FILLED 만 셈)에서 빠진다 → 부분체결 BUY 가
|
||||
당일 재매수를 허용하고 부분체결 SELL 이 성과에서 사라진다. 체결분만 수량에 반영하고
|
||||
status 는 COMPLETED 를 유지한다.
|
||||
③ 잔량 소멸 시 재주문도 제거했다. 잔량이 사라지는 경우는 사실상 사용자 HTS 취소·KIS 거부·
|
||||
만료뿐이고(취소는 별도 행(orgn_odno)으로 기록돼 원주문 cncl_yn 이 N 일 수 있다),
|
||||
`_reorder_unfilled` 는 MDD fail-safe·밴·종목당 4%·현금·주문 세마포어/Redis 락·
|
||||
TradeHistory·체결 검증을 전부 우회한다. 취소 의도를 덮어쓰거나 가드 없는 주문을 내느니
|
||||
warning 으로 수동 확인을 요청한다. `_reorder_unfilled` 는 시그니처 보존용으로만 남긴다.
|
||||
"""
|
||||
await asyncio.sleep(delay) # KIS 서버 체결 처리 대기
|
||||
|
||||
@@ -550,25 +568,29 @@ async def _verify_and_sync_execution(
|
||||
return
|
||||
|
||||
try:
|
||||
# KIS 체결 내역 조회 (TR: TTTC8001R / VTTC8001R)
|
||||
# KIS 체결 내역 조회 (inquire-daily-ccld, TR: TTTC0081R→폴백 TTTC8001R / 모의 V*)
|
||||
# [실전 안정화] 최대 5회 폴링 (3초 간격, 최대 15초) — 체결 지연 대응
|
||||
filled_qty = 0
|
||||
filled_price = 0.0
|
||||
remaining_qty = None
|
||||
exec_result = {}
|
||||
max_polls = 5
|
||||
poll_interval = 3
|
||||
|
||||
for poll in range(max_polls):
|
||||
exec_result = await korea_investment_api.get_order_execution(order_id, is_domestic)
|
||||
filled_qty = int(exec_result.get("filled_qty", 0))
|
||||
filled_qty = int(exec_result.get("filled_qty", 0) or 0)
|
||||
filled_price = float(exec_result.get("filled_price", order_price) or order_price)
|
||||
remaining_qty = exec_result.get("remaining_qty")
|
||||
|
||||
if filled_qty > 0:
|
||||
break # 체결 확인됨
|
||||
if filled_qty >= ordered_quantity:
|
||||
break # 전량 체결
|
||||
if filled_qty > 0 and remaining_qty == 0:
|
||||
break # 부분 체결 후 잔량 소멸(취소·거부) — 더 기다려도 변하지 않음
|
||||
|
||||
if poll < max_polls - 1:
|
||||
logger.debug(
|
||||
f"[VERIFY_EXEC] {stock_code} {order_type} 체결 미확인 "
|
||||
f"[VERIFY_EXEC] {stock_code} {order_type} 체결 {filled_qty}/{ordered_quantity} "
|
||||
f"({poll + 1}/{max_polls}), {poll_interval}초 후 재시도"
|
||||
)
|
||||
await asyncio.sleep(poll_interval)
|
||||
@@ -576,12 +598,21 @@ async def _verify_and_sync_execution(
|
||||
if filled_qty == 0:
|
||||
logger.warning(
|
||||
f"[VERIFY_EXEC] {stock_code} {order_type} "
|
||||
f"{max_polls}회 폴링 후에도 체결 미확인 — order_id={order_id}"
|
||||
f"{max_polls}회 폴링 후에도 체결 미확인 — order_id={order_id} "
|
||||
f"(조회성공={exec_result.get('ok')}, 주문행발견={exec_result.get('found')})"
|
||||
)
|
||||
|
||||
if filled_qty > ordered_quantity:
|
||||
# 다른 주문 행과 혼동된 경우 등 — 주문 수량을 넘겨 기록하지 않는다
|
||||
logger.warning(
|
||||
f"[VERIFY_EXEC] {stock_code} {order_type} 체결수량 {filled_qty} > 주문수량 "
|
||||
f"{ordered_quantity} — 주문수량으로 클램프 (order_id={order_id})"
|
||||
)
|
||||
filled_qty = ordered_quantity
|
||||
|
||||
# ── 슬리피지 계산: (체결가 - 주문가) / 주문가 × 100 ─────────────────
|
||||
slippage_pct = 0.0
|
||||
if order_price > 0 and filled_price > 0:
|
||||
if filled_qty > 0 and order_price > 0 and filled_price > 0:
|
||||
slippage_pct = ((filled_price - order_price) / order_price) * 100
|
||||
logger.info(
|
||||
f"[SLIPPAGE] {stock_code} {order_type} "
|
||||
@@ -589,13 +620,24 @@ async def _verify_and_sync_execution(
|
||||
f"슬리피지={slippage_pct:+.3f}%"
|
||||
)
|
||||
|
||||
partial = 0 < filled_qty < ordered_quantity
|
||||
remainder_dead = partial and remaining_qty == 0
|
||||
if partial and not remainder_dead:
|
||||
# 잔량이 KIS 에 미체결 지정가로 살아 있다(또는 모른다) → 재주문하면 중복 주문.
|
||||
# 최종 체결 수량이 아직 정해지지 않았으므로 원장도 건드리지 않는다(포트폴리오 sync 가 진실).
|
||||
logger.warning(
|
||||
f"[PARTIAL_FILL] {stock_code} {order_type} {filled_qty}/{ordered_quantity}주 체결, "
|
||||
f"잔량 {remaining_qty if remaining_qty is not None else '미상'}주 KIS 미체결 주문 유지 "
|
||||
f"— 재주문·원장 갱신 안 함 (order_id={order_id})"
|
||||
)
|
||||
|
||||
# ── DB 업데이트 (CLAUDE.md 3.2: db_worker 큐 경유, 직접 세션 금지) ───
|
||||
if filled_qty > 0:
|
||||
partial = filled_qty < ordered_quantity # 부분 체결 여부
|
||||
if filled_qty > 0 and (not partial or remainder_dead):
|
||||
update_fields = {
|
||||
"executed_quantity": filled_qty,
|
||||
"executed_price": Decimal(str(filled_price)),
|
||||
"status": "PARTIAL" if partial else "COMPLETED",
|
||||
# PARTIAL 금지 — 위 docstring ② (가드·집계 누락)
|
||||
"status": "COMPLETED",
|
||||
"executed_at": _get_kst_now(),
|
||||
"slippage": Decimal(str(round(slippage_pct, 4))),
|
||||
}
|
||||
@@ -608,24 +650,14 @@ async def _verify_and_sync_execution(
|
||||
from app.services.db_worker import enqueue_update_trade_history_by_order_id
|
||||
await enqueue_update_trade_history_by_order_id(order_id, update_fields)
|
||||
|
||||
# ── 부분 체결 → 미체결 수량 재주문 ──────────────────────
|
||||
if partial:
|
||||
# ── 부분 체결 + 잔량 소멸 → 자동 재주문 금지, 수동 확인 요청 (docstring ③) ──
|
||||
if remainder_dead:
|
||||
remain = ordered_quantity - filled_qty
|
||||
logger.warning(
|
||||
f"[PARTIAL_FILL] {stock_code} {order_type} "
|
||||
f"{filled_qty}/{ordered_quantity}주 체결 → 잔여 {remain}주 재주문"
|
||||
)
|
||||
# 재주문은 백그라운드 태스크로 분리 (현재 흐름 블록 방지)
|
||||
asyncio.create_task(
|
||||
_reorder_unfilled(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
order_type=order_type,
|
||||
quantity=remain,
|
||||
price=order_price,
|
||||
is_domestic=is_domestic,
|
||||
exchange_code=exchange_code,
|
||||
)
|
||||
f"[PARTIAL_FILL] {stock_code}/{stock_name} {order_type} order_id={order_id} "
|
||||
f"체결 {filled_qty}/{ordered_quantity}주, 잔량 {remaining_qty}주(미체결 {remain}주 소멸) "
|
||||
f"취소확인={exec_result.get('cancel_confirmed_qty')} 거부={exec_result.get('rejected_qty')} "
|
||||
f"cncl_yn={'Y' if exec_result.get('cancelled') else 'N'} — 자동 재주문 안 함, 수동 확인 필요"
|
||||
)
|
||||
|
||||
await _delayed_sync_portfolio(0) # KIS 잔고 기준으로 포트폴리오 재동기화
|
||||
@@ -667,6 +699,83 @@ async def _reorder_unfilled(
|
||||
logger.error(f"[REORDER_ERROR] {stock_code} {order_type}: {type(e).__name__}: {e}")
|
||||
|
||||
|
||||
def _existing_position_qty_v2(holding, stock_code: str) -> int:
|
||||
"""해당 종목의 기존 보유 수량 (종목당 누적 상한 산정용).
|
||||
|
||||
Portfolio 행은 stock_code UNIQUE 라 스윙+DT 수량이 합산돼 있다(CLAUDE.md §3.5).
|
||||
§5.1 의 종목당 상한은 '계좌 내 그 종목 비중'이므로 **전략 구분 없이 전체**를 센다
|
||||
(보수적). 단 DT 가 sync 이전에 산 수량은 행에 아직 없을 수 있으므로
|
||||
`max(행 수량, DT 인메모리 수량)` 으로 잡는다 — 더하면 sync 이후 이중 계상된다.
|
||||
"""
|
||||
_row_qty = int(getattr(holding, "quantity", 0) or 0) if holding is not None else 0
|
||||
try:
|
||||
from app.services.daytrading.position_manager import day_trading_state as _dts
|
||||
_dt_qty = int((_dts.positions.get(stock_code) or {}).get("quantity", 0) or 0)
|
||||
except Exception as _e:
|
||||
logger.warning(
|
||||
f"[POSITION_CAP] {stock_code}: DT 인메모리 수량 조회 실패 — Portfolio 행 수량만 반영: {_e}"
|
||||
)
|
||||
_dt_qty = 0
|
||||
return max(_row_qty, _dt_qty)
|
||||
|
||||
|
||||
def _apply_position_cap_room_v2(
|
||||
invest_amount: float,
|
||||
holding,
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
current_price: float,
|
||||
total_assets: float,
|
||||
position_cap_ratio: float,
|
||||
) -> tuple[float, str | None]:
|
||||
"""종목당 누적 상한 적용. 반환 = (클램프된 금액, SKIP 사유 또는 None).
|
||||
|
||||
매수 예산 = min(기존 예산, 총자산 × position_cap_ratio − 기존 보유 평가액).
|
||||
잔여 한도가 실효 최소주문금액(effective_min_buy_amount) 미만이면
|
||||
'position_cap_reached' 로 건너뛴다. 보유가 있는데 총자산을 모르면(잔고 조회 실패)
|
||||
한도를 검증할 수 없으므로 'position_cap_unverifiable' 로 추가매수를 막는다.
|
||||
|
||||
리스크 가드이므로 이 함수 자체에 try 로 감싼 판정이 없다(§4, §8.1-10).
|
||||
신규 진입(보유 0)은 잔여 한도 = 총자산 × cap 이라 기존 1건 클램프와 동일하게 동작한다.
|
||||
"""
|
||||
_existing_qty = _existing_position_qty_v2(holding, stock_code)
|
||||
if _existing_qty <= 0:
|
||||
return invest_amount, None
|
||||
|
||||
_existing_value = _existing_qty * float(current_price)
|
||||
if total_assets <= 0:
|
||||
logger.warning(
|
||||
f"[POSITION_CAP] {stock_code}/{stock_name}: 기존 보유 {_existing_qty}주 "
|
||||
f"(평가 {_existing_value:,.0f}원)인데 총자산 미확인 — 종목당 상한 "
|
||||
f"{position_cap_ratio:.1%} 검증 불가로 추가매수 차단"
|
||||
)
|
||||
return 0.0, "position_cap_unverifiable"
|
||||
|
||||
_room = remaining_position_room(total_assets, position_cap_ratio, _existing_value)
|
||||
_min_buy = effective_min_buy_amount(
|
||||
config_min_amount=settings.MIN_BUY_AMOUNT_KRW,
|
||||
total_assets=total_assets,
|
||||
position_cap_ratio=position_cap_ratio,
|
||||
factor=0.9,
|
||||
)
|
||||
if _room < _min_buy:
|
||||
logger.info(
|
||||
f"[POSITION_CAP] {stock_code}/{stock_name}: 종목당 상한 도달 — 기존 "
|
||||
f"{_existing_qty}주 평가 {_existing_value:,.0f}원 = 총자산의 "
|
||||
f"{_existing_value / total_assets:.2%} (상한 {position_cap_ratio:.1%}), "
|
||||
f"잔여 {_room:,.0f}원 < 최소주문 {_min_buy:,.0f}원 — 추가매수 건너뜀"
|
||||
)
|
||||
return 0.0, "position_cap_reached"
|
||||
|
||||
if invest_amount > _room:
|
||||
logger.info(
|
||||
f"[POSITION_CAP] {stock_code}/{stock_name}: 기존 보유 {_existing_value:,.0f}원 반영 — "
|
||||
f"매수 예산 {invest_amount:,.0f} → {_room:,.0f}원 (종목당 상한 {position_cap_ratio:.1%})"
|
||||
)
|
||||
return _room, None
|
||||
return invest_amount, None
|
||||
|
||||
|
||||
async def _execute_buy(
|
||||
stock: dict,
|
||||
db: AsyncSession,
|
||||
@@ -985,6 +1094,23 @@ async def _execute_buy(
|
||||
else:
|
||||
invest_amount = min(invest_amount, available_cash)
|
||||
|
||||
# ── 7.6. 종목당 **누적** 상한 (CLAUDE.md §5.1) ─────────────────────────
|
||||
# [2026-10-01] 위 클램프는 '주문 1건' 기준이라, 같은 종목이 매일 추천되면
|
||||
# 추가매수가 쌓여 종목 비중이 MAX_POSITION_SIZE 를 넘었다. 기존 보유 평가액을
|
||||
# 차감한 잔여 한도로 다시 묶는다. 리스크 가드이므로 try 밖에 둔다(§4, §8.1-10).
|
||||
_pos_cap_result = _apply_position_cap_room_v2(
|
||||
invest_amount=invest_amount,
|
||||
holding=holding,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
current_price=current_price,
|
||||
total_assets=total_assets,
|
||||
position_cap_ratio=_adaptive_position_cap,
|
||||
)
|
||||
if _pos_cap_result[1] is not None:
|
||||
return {"action": "SKIP", "reason": _pos_cap_result[1], "stock": stock_name}
|
||||
invest_amount = _pos_cap_result[0]
|
||||
|
||||
# [BUG FIX] 국면별 배율(_position_multiplier)과 뉴스 배율(_news_position_scale)을
|
||||
# invest_amount에 반영 — 기존에는 stock dict에 주입만 되고 실제로 사용되지 않았음.
|
||||
try:
|
||||
@@ -1241,6 +1367,64 @@ async def _execute_buy(
|
||||
await _release_order_lock(stock_code, "BUY")
|
||||
|
||||
|
||||
def _dt_inmemory_qty_v2(stock_code: str) -> int | None:
|
||||
"""DT 인메모리 포지션 수량. 조회 실패 시 None(호출부가 '확인 불가'로 취급)."""
|
||||
try:
|
||||
from app.services.daytrading.position_manager import day_trading_state as _dts
|
||||
return int((_dts.positions.get(stock_code) or {}).get("quantity", 0) or 0)
|
||||
except Exception as e:
|
||||
logger.warning(f"[SELL] {stock_code}: DT 인메모리 수량 조회 실패: {type(e).__name__}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# 자동 매도 대상이 아닌 Portfolio.strategy_type — 사용자 수동 판단 원칙.
|
||||
# external : sync 가 시스템 매수 근거 없이 발견한 유입 잔고(portfolio.EXTERNAL_STRATEGY_TYPE)
|
||||
# manual : manual_buy / portfolio_v2 수동 매수
|
||||
_MANUAL_JUDGMENT_STRATEGY_TYPES = ("external", "manual")
|
||||
|
||||
# 스윙 엔진이 '자기 것'으로 간주하는 strategy_type (NULL 레거시 행은 별도로 포함).
|
||||
# 매도 소유권(_sell_ownership_block_reason_v2)과 일일 매수 한도 집계
|
||||
# (_count_swing_buys_today_v2)가 같은 기준을 쓴다 — 한쪽만 바꾸지 말 것.
|
||||
# swing/position_manager.STRATEGY_TYPE("swing") 과 동일.
|
||||
_SWING_OWNER_STRATEGY_TYPES = ("swing",)
|
||||
|
||||
|
||||
def _swing_owned_clause_v2(column):
|
||||
"""스윙 소유 행 SQL 필터 = `column IS NULL OR column IN (_SWING_OWNER_STRATEGY_TYPES)`.
|
||||
|
||||
NULL 을 명시 포함한다(SQL 3값 논리 — `IN` 만 쓰면 legacy NULL 행이 빠진다).
|
||||
"""
|
||||
return or_(column.is_(None), column.in_(_SWING_OWNER_STRATEGY_TYPES))
|
||||
|
||||
|
||||
def _sell_ownership_block_reason_v2(holding, stock: dict) -> str | None:
|
||||
"""추천 경로 매도(_execute_sell)가 이 보유 행을 팔아도 되는지 판정.
|
||||
|
||||
swing/exit_service 가 `get_swing_holdings()`(strategy_type=='swing') 만 청산하는 것과
|
||||
같은 기준이다. 반환이 None 이면 허용, 문자열이면 SKIP 사유.
|
||||
|
||||
- external / manual → 'manual_judgment_holding' (warning)
|
||||
- DT 계열(DT_STRATEGY_NAMES) → 'owned_by_daytrading' — DT 엔진 소유
|
||||
(단 호출자 태그도 DT 계열이면 자기 보유분이므로 허용)
|
||||
- 그 외 비-swing(us_swing 등) → 'owned_by_other_strategy' — 호출자 태그와 같을 때만 허용
|
||||
|
||||
strategy_type 이 비어 있으면 모델 기본값과 같이 'swing' 으로 본다.
|
||||
MDD 강제축소(mdd_manager)·DT 청산·swing/exit_service 는 이 함수를 거치지 않는다
|
||||
(각자 주문 경로를 가진다) — 리스크 청산은 영향받지 않는다(§8.1-11).
|
||||
"""
|
||||
from app.services.db_worker import DT_STRATEGY_NAMES
|
||||
|
||||
_holding_type = (getattr(holding, "strategy_type", None) or "swing")
|
||||
_caller_tag = stock.get("strategy_type") or "swing"
|
||||
if _holding_type in _SWING_OWNER_STRATEGY_TYPES:
|
||||
return None
|
||||
if _holding_type in _MANUAL_JUDGMENT_STRATEGY_TYPES:
|
||||
return "manual_judgment_holding"
|
||||
if _holding_type in DT_STRATEGY_NAMES:
|
||||
return None if _caller_tag in DT_STRATEGY_NAMES else "owned_by_daytrading"
|
||||
return None if _caller_tag == _holding_type else "owned_by_other_strategy"
|
||||
|
||||
|
||||
async def _execute_sell(
|
||||
stock: dict,
|
||||
db: AsyncSession,
|
||||
@@ -1269,6 +1453,39 @@ async def _execute_sell(
|
||||
- 기본 손절 -6% (CLAUDE.md 5.2), 익절 2R (ATR 기반)
|
||||
"""
|
||||
|
||||
# ── 보유 행 소유권 가드 (2026-10-01) ─────────────────────────────────────
|
||||
# 추천 경로는 strategy_type 검사 없이 보유 행을 스윙 이름으로 팔았다
|
||||
# (external·manual 유입 잔고, strategy_type='daytrading' 행 포함).
|
||||
# 이 함수의 호출자는 execute_auto_trade 하나뿐이며 MDD 강제축소·DT 청산·
|
||||
# swing/exit_service 는 별도 주문 경로라 영향이 없다. try 밖 가드(§4).
|
||||
_ownership_block = _sell_ownership_block_reason_v2(holding, stock)
|
||||
if _ownership_block is not None:
|
||||
_h_type = getattr(holding, "strategy_type", None)
|
||||
_msg = (
|
||||
f"[SELL] {stock_code}/{stock_name}: 보유 {getattr(holding, 'quantity', '?')}주 "
|
||||
f"strategy_type='{_h_type}' — 추천 경로 자동매도 스킵({_ownership_block}, "
|
||||
f"요청 sell_pct={sell_pct*100:.0f}%, 호출 태그='{stock.get('strategy_type', 'swing')}')"
|
||||
)
|
||||
if _ownership_block == "owned_by_daytrading":
|
||||
# [2026-10-01] DT 인메모리에 포지션이 없는데 daytrading 행이 남아 있으면
|
||||
# 고아 행일 수 있다(재시작·stale 청산 실패 등) — 아무 엔진도 팔지 않으므로
|
||||
# info 에 묻히지 않게 warning 으로 올린다.
|
||||
_dt_mem_qty = _dt_inmemory_qty_v2(stock_code)
|
||||
if _dt_mem_qty is None or _dt_mem_qty <= 0:
|
||||
logger.warning(
|
||||
_msg + f" — DT 인메모리 포지션 "
|
||||
f"{'조회 실패' if _dt_mem_qty is None else '0주'}인데 daytrading 행 "
|
||||
f"{getattr(holding, 'quantity', '?')}주가 남아 있음: 고아 행 가능성, "
|
||||
f"수동 확인 필요 ({stock_name}/{stock_code})"
|
||||
)
|
||||
else:
|
||||
logger.info(_msg + f" — DT 엔진 소유(인메모리 {_dt_mem_qty}주)")
|
||||
elif _ownership_block == "manual_judgment_holding":
|
||||
logger.warning(_msg + " — 유입/수동 보유분은 수동 판단 대상")
|
||||
else:
|
||||
logger.warning(_msg + " — 다른 전략 엔진 소유")
|
||||
return {"action": "SKIP", "reason": _ownership_block, "stock": stock_name}
|
||||
|
||||
# [CLAUDE.md 5.2] 분할매도: sell_pct에 따라 매도 수량 결정
|
||||
# [2026-09-23] Portfolio 행은 stock_code UNIQUE 라 같은 종목의 스윙+데이트레이딩
|
||||
# 수량이 합산돼 있다. 행 전체를 대상으로 잡으면 다른 전략 보유분까지 매도된다
|
||||
@@ -1632,6 +1849,29 @@ async def _execute_sell(
|
||||
_auto_trade_bg_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _count_swing_buys_today_v2(db: AsyncSession) -> int | None:
|
||||
"""당일 스윙 소유 BUY 종목 수 (distinct stock_code). 조회 실패 시 None.
|
||||
|
||||
None 은 '모름'이다 — 호출부가 fail-closed(신규 매수 차단)로 처리한다.
|
||||
여기서 0 으로 폴백하면 한도가 조용히 풀린다(이전 동작).
|
||||
"""
|
||||
try:
|
||||
_res = await db.execute(
|
||||
select(TradeHistory.stock_code)
|
||||
.where(
|
||||
TradeHistory.order_type == "BUY",
|
||||
TradeHistory.status.in_(["COMPLETED", "PENDING"]),
|
||||
TradeHistory.created_at >= get_daily_cutoff_utc(),
|
||||
_swing_owned_clause_v2(TradeHistory.strategy_type),
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
return len(_res.scalars().all())
|
||||
except Exception as e:
|
||||
logger.error(f"[AUTO_TRADE_BG] 당일 스윙 매수 건수 조회 예외: {type(e).__name__}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession):
|
||||
"""recommendations_response dict 에서 추천 종목을 꺼내 자동매매 실행.
|
||||
|
||||
@@ -1670,24 +1910,23 @@ async def _execute_auto_trade_background_locked(recommendations_response: dict,
|
||||
|
||||
# 일일 한도는 "오늘 이미 나간 BUY 종목 수"를 차감한 전역 기준으로 적용
|
||||
# (호출 단위 슬라이스만으로는 여러 사이클에 걸쳐 한도 초과됨)
|
||||
try:
|
||||
_today_buys = await db.execute(
|
||||
select(TradeHistory.stock_code)
|
||||
.where(
|
||||
TradeHistory.order_type == "BUY",
|
||||
TradeHistory.status.in_(["COMPLETED", "PENDING"]),
|
||||
TradeHistory.created_at >= get_daily_cutoff_utc(),
|
||||
)
|
||||
.distinct()
|
||||
# [2026-10-01] MAX_DAILY_TRADES 는 스윙 한도다 → 스윙 소유 매수(swing + NULL
|
||||
# 레거시)만 센다. DT·manual·external·us_swing 매수는 제외(매도 소유권과 같은 기준).
|
||||
# [2026-10-01] 조회 실패 시 fail-closed — 한도를 검증할 수 없으면 신규 매수를
|
||||
# 막는다. 판정은 try 밖(§4). 스윙 청산은 swing/scheduler step 2
|
||||
# (check_and_execute_exit_v2)가 이 함수와 독립적으로 수행하므로 영향이 없다.
|
||||
_bought_today = await _count_swing_buys_today_v2(db)
|
||||
if _bought_today is None:
|
||||
logger.error(
|
||||
f"[AUTO_TRADE_BG] 🚨 당일 스윙 매수 건수 조회 실패 — 일일 한도 "
|
||||
f"{min(settings.MAX_DAILY_TRADES, regime_max_trades)}종목 검증 불가로 "
|
||||
f"이번 사이클 신규 진입 차단(fail-closed). 스윙 청산 경로는 영향 없음"
|
||||
)
|
||||
_bought_today = len(_today_buys.scalars().all())
|
||||
except Exception as _e:
|
||||
logger.warning(f"[AUTO_TRADE_BG] 당일 매수 건수 조회 실패 (한도 미차감): {_e}")
|
||||
_bought_today = 0
|
||||
return []
|
||||
max_trades = max(0, max_trades - _bought_today)
|
||||
if max_trades == 0:
|
||||
logger.info(
|
||||
f"[AUTO_TRADE_BG] 일일 매수 한도 도달 (오늘 {_bought_today}종목 / 한도 "
|
||||
f"[AUTO_TRADE_BG] 일일 매수 한도 도달 (오늘 스윙 {_bought_today}종목 / 한도 "
|
||||
f"{min(settings.MAX_DAILY_TRADES, regime_max_trades)}) — 신규 진입 스킵"
|
||||
)
|
||||
return []
|
||||
@@ -1831,6 +2070,47 @@ def _ml_gate_score_v2(rule_score: float, ml_score: float) -> float:
|
||||
return (rule_score / rule_max) * 100.0
|
||||
|
||||
|
||||
def _today_sell_conditions_v2(stock_code: str, cutoff_time) -> list:
|
||||
"""당일 SELL 판정 조건 (COMPLETED/PARTIAL 은 executed_at, PENDING 은 created_at 기준)."""
|
||||
return [
|
||||
TradeHistory.stock_code == stock_code,
|
||||
TradeHistory.order_type == "SELL",
|
||||
or_(
|
||||
and_(
|
||||
TradeHistory.status.in_(["COMPLETED", "PARTIAL"]),
|
||||
TradeHistory.executed_at >= cutoff_time,
|
||||
),
|
||||
and_(
|
||||
TradeHistory.status == "PENDING",
|
||||
TradeHistory.created_at >= cutoff_time,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def _non_dt_sold_today_v2(db: AsyncSession, stock_code: str, cutoff_time) -> bool:
|
||||
"""당일 비-DT SELL 존재 여부 — 스윙 보유분 '부분매도 반복 차단' 판정 전용.
|
||||
|
||||
DT 판정은 db_worker.not_dt_strategy_clause 정본 표현(CLAUDE.md §3.3).
|
||||
조회 실패 시 True(보수적: 부분매도만 막고 전량 청산은 허용 — 기존 동작과 동일).
|
||||
"""
|
||||
from app.services.db_worker import not_dt_strategy_clause
|
||||
try:
|
||||
_res = await db.execute(
|
||||
select(TradeHistory.id).where(
|
||||
*_today_sell_conditions_v2(stock_code, cutoff_time),
|
||||
not_dt_strategy_clause(TradeHistory.strategy_type),
|
||||
).limit(1)
|
||||
)
|
||||
return _res.first() is not None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[TRADE] {stock_code}: 당일 비-DT 매도 조회 실패 — 부분매도 차단으로 간주 "
|
||||
f"(전량 청산은 허용): {type(e).__name__}: {e}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
"""종목 단건 매매 판단 및 실행.
|
||||
|
||||
@@ -1944,6 +2224,22 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
_r = _guard("external_inflow_holding")
|
||||
if _r is not None:
|
||||
return _r
|
||||
elif holding is not None:
|
||||
# [2026-10-01] "팔 수 없는 행에는 사지 않는다" — 매도 소유권과 대칭.
|
||||
# manual(수동매수)·daytrading(EOD 미청산 등)·us_swing 행에 스윙이 추가매수하면
|
||||
# 그 수량은 swing/exit_service(=='swing' 만)도 _execute_sell 도 팔지 않아
|
||||
# SL/TP 없는 보유가 된다. `_guard` 경유라 청산 판정은 그대로 통과(§8.1-11).
|
||||
_own_block = _sell_ownership_block_reason_v2(holding, stock)
|
||||
if _own_block is not None:
|
||||
logger.warning(
|
||||
f"🚧 비-스윙 보유 행 추가매수 차단: {stock_name}({stock_code}) "
|
||||
f"보유 {holding.quantity}주 strategy_type='{holding.strategy_type}' "
|
||||
f"({_own_block}) — 스윙 청산 경로가 이 행을 팔지 않으므로 "
|
||||
f"추가매수분이 SL/TP 없이 남는다"
|
||||
)
|
||||
_r = _guard(f"non_swing_holding:{_own_block}")
|
||||
if _r is not None:
|
||||
return _r
|
||||
|
||||
held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
|
||||
# 적응형 전략: max_holdings 오버라이드 (실패 시 settings 기본값 fallback)
|
||||
@@ -1974,26 +2270,16 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
# 쿨다운을 영구히 걸지 않는다.
|
||||
cutoff_time = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
recent_sell = await db.execute(
|
||||
select(TradeHistory).where(
|
||||
TradeHistory.stock_code == stock_code,
|
||||
TradeHistory.order_type == "SELL",
|
||||
or_(
|
||||
and_(
|
||||
TradeHistory.status.in_(["COMPLETED", "PARTIAL"]),
|
||||
TradeHistory.executed_at >= cutoff_time,
|
||||
),
|
||||
and_(
|
||||
TradeHistory.status == "PENDING",
|
||||
TradeHistory.created_at >= cutoff_time,
|
||||
),
|
||||
),
|
||||
)
|
||||
select(TradeHistory).where(*_today_sell_conditions_v2(stock_code, cutoff_time))
|
||||
)
|
||||
if recent_sell.first():
|
||||
_sold_today = True
|
||||
# 당일 재매수 차단은 DT 매도까지 포함한다(같은 종목 왕복 방지 — 기존 동작).
|
||||
_r = _guard("cooldown_sell_today")
|
||||
if _r is not None:
|
||||
return _r
|
||||
# [2026-10-01] 단 '부분매도 반복 차단'(_sold_today) 은 스윙 자신의 매도만 본다.
|
||||
# DT 가 같은 종목을 팔았다고 스윙 보유분의 부분익절이 당일 막히면 안 된다.
|
||||
_sold_today = await _non_dt_sold_today_v2(db, stock_code, cutoff_time)
|
||||
|
||||
# [BUG FIX] ActiveTrade.remaining_pct 조회 — 미주입 시 1차 익절 후에도
|
||||
# remaining_pct가 항상 1.0으로 간주되어 SELL_PARTIAL이 반복 발생하는 문제 방지.
|
||||
|
||||
@@ -108,6 +108,9 @@ _KIS_CLASS_METHODS = (
|
||||
"get_balance",
|
||||
"get_buyable_cash",
|
||||
"get_overseas_balance",
|
||||
# ── 체결 조회 계열 (2026-10-01: DT 체결가 확인이 백그라운드로 호출) ──
|
||||
"get_todays_orders",
|
||||
"get_order_execution",
|
||||
)
|
||||
|
||||
_TRADE_EXECUTOR_FUNCS = (
|
||||
@@ -202,3 +205,9 @@ def _block_live_kis(request, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
te_mod, name, _blocked(f"trade_executor.{name}"), raising=True
|
||||
)
|
||||
|
||||
# [2026-10-01] DT 체결가 확인(daytrading/fill_reconciler)은 주문 직후 백그라운드 태스크를
|
||||
# 띄운다. 유닛 테스트에서는 기본 OFF — 테스트 종료 뒤 남은 태스크가 차단된 KIS 를 치거나
|
||||
# "Task was destroyed but it is pending" 노이즈를 내지 않게 한다. 검증하는 테스트만 켠다.
|
||||
from app.core.config import settings as _settings
|
||||
monkeypatch.setattr(_settings, "DAY_TRADE_FILL_CHECK_ENABLED", False, raising=False)
|
||||
|
||||
176
backend/tests/test_db_worker_order_id_update_20261001.py
Normal file
176
backend/tests/test_db_worker_order_id_update_20261001.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""update_trade_history_by_order_id 의 주문번호 중복 회귀 테스트 (2026-10-01).
|
||||
|
||||
KIS ODNO 는 일별 순번 → 과거일 행과 번호가 겹친다. 예전 구현(`order_id == X` +
|
||||
scalar_one_or_none)은 MultipleResultsFound 로 단일 워커가 1+2+4초 재시도하며 큐를 막고
|
||||
DLQ 로 보냈으며, 당일 행이 없으면 과거 행을 덮어썼다.
|
||||
|
||||
실제 워커 루프(db_writer_worker)로 큐를 처리하고 SQLite(in-memory)에 결과를 확인한다.
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
|
||||
from app.core.timeutil import now_kst
|
||||
from app.models.models import Base, TradeHistory
|
||||
from app.services import db_worker
|
||||
|
||||
|
||||
@compiles(JSONB, "sqlite")
|
||||
def _jsonb_sqlite(element, compiler, **kw): # 테스트 전용 — SQLite 에 JSONB 타입이 없다
|
||||
return "JSON"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def session_factory():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(lambda c: Base.metadata.create_all(c, tables=[TradeHistory.__table__]))
|
||||
yield async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _seed(factory, *rows):
|
||||
async with factory() as s:
|
||||
for r in rows:
|
||||
s.add(TradeHistory(**r))
|
||||
await s.commit()
|
||||
|
||||
|
||||
def _row(order_id, created_at, order_type="SELL", strategy_type="daytrading", price=10_000):
|
||||
return dict(stock_code="005930", order_type=order_type, order_quantity=1,
|
||||
order_price=Decimal(price), executed_price=Decimal(price), status="COMPLETED",
|
||||
order_id=order_id, created_at=created_at, strategy_type=strategy_type)
|
||||
|
||||
|
||||
async def _drain(queue, factory):
|
||||
"""실제 워커 경로로 큐 처리. 재시도(1+2+4초)가 일어나면 timeout 으로 실패한다."""
|
||||
sleeps = []
|
||||
real_sleep = asyncio.sleep
|
||||
|
||||
async def _spy_sleep(d, *a, **k):
|
||||
sleeps.append(d)
|
||||
return await real_sleep(0)
|
||||
|
||||
with patch.object(db_worker, "AsyncSessionLocal", factory), \
|
||||
patch.object(db_worker, "get_db_task_queue", return_value=queue), \
|
||||
patch.object(db_worker, "_save_to_dlq", side_effect=AssertionError("DLQ 적재 금지")), \
|
||||
patch.object(db_worker.asyncio, "sleep", _spy_sleep):
|
||||
worker = asyncio.create_task(db_worker.db_writer_worker())
|
||||
await asyncio.wait_for(queue.join(), timeout=5)
|
||||
worker.cancel()
|
||||
try:
|
||||
await worker
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
return sleeps
|
||||
|
||||
|
||||
async def _all(factory):
|
||||
async with factory() as s:
|
||||
return {r.id: r for r in (await s.execute(select(TradeHistory))).scalars().all()}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_today_row_updated_when_past_day_shares_order_id(session_factory):
|
||||
today = now_kst().replace(hour=10, minute=0, second=0, microsecond=0)
|
||||
await _seed(session_factory,
|
||||
_row("0000123", today - timedelta(days=3), price=5_000),
|
||||
_row("0000123", today, price=10_000))
|
||||
q = asyncio.Queue()
|
||||
await q.put({"type": "update_trade_history_by_order_id",
|
||||
"data": {"order_id": "0000123", "executed_price": Decimal("10050")}})
|
||||
sleeps = await _drain(q, session_factory)
|
||||
rows = sorted((await _all(session_factory)).values(), key=lambda r: r.created_at)
|
||||
assert float(rows[0].executed_price) == 5_000 # 과거 행 불변
|
||||
assert float(rows[1].executed_price) == 10_050 # 당일 행 갱신
|
||||
assert not [d for d in sleeps if d >= 1] # 재시도 백오프 없음
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_payload_date_takes_priority_over_today(session_factory):
|
||||
"""DLQ 재처리 등으로 다음 날 실행돼도 payload 기준일(order_date/executed_at)의 행을 고른다."""
|
||||
today = now_kst().replace(hour=10, minute=0, second=0, microsecond=0)
|
||||
yday = today - timedelta(days=1)
|
||||
await _seed(session_factory, _row("777", yday, price=5_000), _row("777", today, price=6_000))
|
||||
q = asyncio.Queue()
|
||||
await q.put({"type": "update_trade_history_by_order_id",
|
||||
"data": {"order_id": "777", "order_date": yday.date().isoformat(),
|
||||
"executed_price": Decimal("5100")}})
|
||||
await q.put({"type": "update_trade_history_by_order_id",
|
||||
"data": {"order_id": "777", "executed_at": today, "status": "PARTIAL"}})
|
||||
await _drain(q, session_factory)
|
||||
rows = sorted((await _all(session_factory)).values(), key=lambda r: r.created_at)
|
||||
assert float(rows[0].executed_price) == 5_100 and rows[0].status == "COMPLETED"
|
||||
assert float(rows[1].executed_price) == 6_000 and rows[1].status == "PARTIAL"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_day_duplicates_update_exactly_one_row(session_factory, caplog):
|
||||
today = now_kst().replace(hour=10, minute=0, second=0, microsecond=0)
|
||||
await _seed(session_factory,
|
||||
_row("0000555", today, order_type="BUY", strategy_type="daytrading"),
|
||||
_row("0000555", today + timedelta(minutes=1), order_type="SELL", strategy_type="swing"),
|
||||
_row("0000555", today + timedelta(minutes=2), order_type="SELL", strategy_type="daytrading"))
|
||||
q = asyncio.Queue()
|
||||
await q.put({"type": "update_trade_history_by_order_id",
|
||||
"data": {"order_id": "0000555", "order_type": "SELL", "strategy_type": "daytrading",
|
||||
"executed_price": Decimal("12345")}})
|
||||
await _drain(q, session_factory)
|
||||
rows = sorted((await _all(session_factory)).values(), key=lambda r: r.created_at)
|
||||
changed = [r for r in rows if float(r.executed_price) == 12_345]
|
||||
assert len(changed) == 1
|
||||
assert changed[0].order_type == "SELL" and changed[0].strategy_type == "daytrading"
|
||||
assert any("중복" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicates_without_filters_pick_latest_and_keep_strategy(session_factory):
|
||||
"""필터 키가 없으면 최신 1행. 필터 키(strategy_type/order_type)는 setattr 되지 않는다."""
|
||||
today = now_kst().replace(hour=10, minute=0, second=0, microsecond=0)
|
||||
await _seed(session_factory,
|
||||
_row("42", today, strategy_type="swing"),
|
||||
_row("42", today + timedelta(minutes=5), strategy_type="swing"))
|
||||
q = asyncio.Queue()
|
||||
await q.put({"type": "update_trade_history_by_order_id",
|
||||
"data": {"order_id": "42", "strategy_type": "daytrading", "executed_price": Decimal("1")}})
|
||||
await _drain(q, session_factory)
|
||||
rows = sorted((await _all(session_factory)).values(), key=lambda r: r.created_at)
|
||||
assert float(rows[1].executed_price) == 1 and float(rows[0].executed_price) == 10_000
|
||||
assert all(r.strategy_type == "swing" for r in rows)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_padding_normalized(session_factory):
|
||||
today = now_kst().replace(hour=10, minute=0, second=0, microsecond=0)
|
||||
await _seed(session_factory, _row("0000000088", today))
|
||||
q = asyncio.Queue()
|
||||
await q.put({"type": "update_trade_history_by_order_id",
|
||||
"data": {"order_id": "88", "executed_price": Decimal("9999")}})
|
||||
await _drain(q, session_factory)
|
||||
(row,) = (await _all(session_factory)).values()
|
||||
assert float(row.executed_price) == 9_999 and row.order_id == "0000000088"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_match_does_not_stall_queue(session_factory, caplog):
|
||||
"""0건(당일 행 없음, 과거 행만 존재) → warning 후 종료. 과거 행 불변, 재시도·DLQ 없음, 다음 태스크 진행."""
|
||||
today = now_kst().replace(hour=10, minute=0, second=0, microsecond=0)
|
||||
await _seed(session_factory, _row("999", today - timedelta(days=2), price=5_000))
|
||||
q = asyncio.Queue()
|
||||
await q.put({"type": "update_trade_history_by_order_id",
|
||||
"data": {"order_id": "999", "executed_price": Decimal("1")}})
|
||||
await q.put({"type": "insert_trade_history", "data": _row("1000", today)})
|
||||
sleeps = await _drain(q, session_factory)
|
||||
rows = await _all(session_factory)
|
||||
assert len(rows) == 2
|
||||
assert all(float(r.executed_price) != 1 for r in rows.values())
|
||||
assert not [d for d in sleeps if d >= 1]
|
||||
assert any("not found" in r.message for r in caplog.records)
|
||||
338
backend/tests/test_dt_cost_fill_timestop_20261001.py
Normal file
338
backend/tests/test_dt_cost_fill_timestop_20261001.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""DT 결함 3건 회귀 테스트 (2026-10-01).
|
||||
|
||||
1. 시간손절은 loss_tracker(3연패 → CONSECUTIVE_LOSS_BAN_DAYS 진입금지) 집계에서 제외(중립).
|
||||
하드 손절·폴백 손절·일일한도 청산 등은 기존대로 기록 + save_to_db.
|
||||
2. 실현손익은 수수료·거래세 차감 순손익. 실제 체결가는 백그라운드로 확인해 원장·당일 손익 보정.
|
||||
→ 일일 손실한도가 비용만큼 **더 일찍** 발동(보수적, 의도).
|
||||
3. 레거시 `_record_trade` 가 strategy_type="daytrading" 을 명시.
|
||||
|
||||
목킹 원칙: 주문 경로는 예외 발생형(BaseException) — 목 누락이 거짓 통과가 되지 않게.
|
||||
기대값은 settings.* 에서 계산(하드코딩 금지).
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime, time, timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services import day_trading_strategy as dts
|
||||
from app.services.daytrading import exit_service as es
|
||||
from app.services.daytrading import entry_service as ens
|
||||
from app.services.daytrading import fill_reconciler as fr
|
||||
from app.services.daytrading.position_manager import day_trading_state
|
||||
from app.services.trade_cost_core import net_realized_pnl, round_trip_costs
|
||||
|
||||
|
||||
class MustNotBeCalled(BaseException):
|
||||
"""호출되면 안 되는 경로 도달 (except Exception 에 삼켜지지 않게 BaseException)."""
|
||||
|
||||
|
||||
def _boom(name):
|
||||
async def _f(*a, **k):
|
||||
raise MustNotBeCalled(f"호출 금지 경로 도달: {name}")
|
||||
return _f
|
||||
|
||||
|
||||
def _expected_net(entry, exit_, qty, tax_rate=None):
|
||||
"""settings 비용률로 독립 계산한 기대 순손익 (trade_helpers.calculate_trade_costs 와 같은 반올림)."""
|
||||
tax = settings.KR_SELL_TAX_RATE_KOSPI if tax_rate is None else tax_rate
|
||||
c = settings.KR_COMMISSION_RATE
|
||||
cost = round(entry * qty * c + exit_ * qty * c + exit_ * qty * tax, 0)
|
||||
return (exit_ - entry) * qty - cost, cost
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dt_env():
|
||||
"""_execute_exit → _execute_exit_impl → _finalize_exit_impl 을 실제로 타게 하되
|
||||
외부 경계(주문·DB 큐·캐시·Redis)만 목으로 덮는다."""
|
||||
import app.services.trade_executor as te
|
||||
import app.services.portfolio_cache_bridge as pcb
|
||||
|
||||
captured = {"history": [], "updates": [], "upserts": []}
|
||||
|
||||
async def _hist(payload):
|
||||
captured["history"].append(payload)
|
||||
|
||||
async def _upd(order_id, fields):
|
||||
captured["updates"].append((order_id, fields))
|
||||
|
||||
async def _ups(payload):
|
||||
captured["upserts"].append(payload)
|
||||
|
||||
saved_positions = dict(day_trading_state.positions)
|
||||
saved_pnl = day_trading_state.daily_pnl
|
||||
saved_streak = day_trading_state.loss_streak_today
|
||||
saved_closed = set(day_trading_state.closed_today)
|
||||
saved_stale = es._stale_realized_pnl
|
||||
day_trading_state.positions.clear()
|
||||
day_trading_state.daily_pnl = 0.0
|
||||
day_trading_state.loss_streak_today = 0
|
||||
|
||||
with patch.object(te, "safe_place_order",
|
||||
AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "0000123"}})), \
|
||||
patch.object(es, "enqueue_trade_history", _hist), \
|
||||
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", _upd), \
|
||||
patch.object(ens, "enqueue_active_trade_upsert", _ups), \
|
||||
patch.object(pcb, "reflect_sell", AsyncMock()), \
|
||||
patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)):
|
||||
yield captured
|
||||
|
||||
day_trading_state.positions.clear()
|
||||
day_trading_state.positions.update(saved_positions)
|
||||
day_trading_state.daily_pnl = saved_pnl
|
||||
day_trading_state.loss_streak_today = saved_streak
|
||||
day_trading_state.closed_today.clear()
|
||||
day_trading_state.closed_today.update(saved_closed)
|
||||
es._stale_realized_pnl = saved_stale
|
||||
|
||||
|
||||
def _pos(entry=10_000.0, qty=10, order_id="0000999"):
|
||||
return {"stock_name": "테스트", "entry_price": entry, "quantity": qty,
|
||||
"highest_price": entry, "order_id": order_id}
|
||||
|
||||
|
||||
# ── 1. 시간손절 중립 처리 ───────────────────────────────────────────────────
|
||||
|
||||
def test_time_stop_reason_from_strategy_is_classified():
|
||||
"""check_day_trade_exit 가 실제로 만드는 시간손절 사유가 판별 함수와 일치해야 한다."""
|
||||
from app.core.timeutil import now_kst
|
||||
entry_time = now_kst() - timedelta(minutes=settings.DAY_TRADE_TIME_STOP_MINUTES + 1)
|
||||
entry = 10_000.0
|
||||
price = entry * (1 + settings.DAY_TRADE_TIME_STOP_MIN_GAIN * 2) # 임계 미달
|
||||
sig = dts.check_day_trade_exit(
|
||||
entry_price=entry, current_price=price, stop_loss=entry * 0.5,
|
||||
target_price=entry * 2, highest_since_entry=entry, candles_5min=[],
|
||||
current_time=time(10, 0), entry_time=entry_time,
|
||||
)
|
||||
assert sig is not None and dts.is_time_stop_exit_reason(sig.reason), sig
|
||||
# 손절·EOD·폴백·일일한도 사유는 시간손절이 아니다
|
||||
for r in ("손절 -3.0% (현재=9700 ≤ 손절가=9700)", "EOD 강제 청산 (15:20 ≥ 15:20)",
|
||||
"폴백 손절(분봉 미확보) -2.1%", "일일 손실 한도 초과", "", None):
|
||||
assert not dts.is_time_stop_exit_reason(r)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_time_stop_exit_not_recorded_in_loss_tracker(dt_env):
|
||||
day_trading_state.positions["111111"] = _pos()
|
||||
with patch.object(es.loss_tracker, "record_trade_result_safe", _boom("record_trade_result_safe")), \
|
||||
patch.object(es.loss_tracker, "save_to_db", _boom("save_to_db")):
|
||||
res = await es._execute_exit(
|
||||
"111111", day_trading_state.positions["111111"], 9_960.0,
|
||||
f"{dts.TIME_STOP_REASON_PREFIX}: 31분 경과, 수익=-0.4% < -0.3%",
|
||||
)
|
||||
assert res is not None and res["profit_loss"] < 0
|
||||
# 당일 연패 쿨다운(DT 전용·당일 한정)은 그대로 센다
|
||||
assert day_trading_state.loss_streak_today == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("reason", [
|
||||
"손절 -3.0% (현재=9700 ≤ 손절가=9700)",
|
||||
"폴백 손절(분봉 미확보) -2.1%",
|
||||
"일일 손실 한도 초과",
|
||||
])
|
||||
async def test_real_stop_exits_still_recorded_and_saved(dt_env, reason):
|
||||
day_trading_state.positions["222222"] = _pos()
|
||||
rec, save = AsyncMock(), AsyncMock()
|
||||
with patch.object(es.loss_tracker, "record_trade_result_safe", rec), \
|
||||
patch.object(es.loss_tracker, "save_to_db", save):
|
||||
await es._execute_exit("222222", day_trading_state.positions["222222"], 9_700.0, reason)
|
||||
rec.assert_awaited_once_with("222222", True)
|
||||
save.assert_awaited_once_with(stock_code="222222")
|
||||
|
||||
|
||||
# ── 2. 순손익 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_cost_core_matches_swing_definition():
|
||||
"""trade_cost_core 는 스윙 집행 경로(trade_helpers.calculate_trade_costs)와 같은 정의."""
|
||||
from app.services.trade_helpers import calculate_trade_costs
|
||||
for market, tax in (("KOSPI", settings.KR_SELL_TAX_RATE_KOSPI),
|
||||
("KOSDAQ", settings.KR_SELL_TAX_RATE_KOSDAQ)):
|
||||
for e, x, q in ((10_000, 10_100, 10), (53_400, 52_100, 7), (1_234, 1_250, 333)):
|
||||
assert round_trip_costs(e, x, q, settings.KR_COMMISSION_RATE, tax) == \
|
||||
calculate_trade_costs(e, x, q, True, market)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exit_records_net_pnl_and_feeds_daily_pnl(dt_env):
|
||||
entry, exit_, qty = 10_000.0, 10_100.0, 10
|
||||
day_trading_state.positions["333333"] = _pos(entry, qty)
|
||||
with patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \
|
||||
patch.object(es.loss_tracker, "save_to_db", AsyncMock()):
|
||||
res = await es._execute_exit("333333", day_trading_state.positions["333333"], exit_, "트레일링 스탑")
|
||||
net, cost = _expected_net(entry, exit_, qty)
|
||||
rec = dt_env["history"][-1]
|
||||
assert rec["net_profit_loss"] == pytest.approx(net)
|
||||
assert rec["commission"] == pytest.approx(cost)
|
||||
assert rec["profit_loss_rate"] == pytest.approx(net / (entry * qty) * 100)
|
||||
assert rec["strategy_type"] == "daytrading"
|
||||
assert day_trading_state.daily_pnl == pytest.approx(net)
|
||||
assert res["gross_profit_loss"] == pytest.approx((exit_ - entry) * qty)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_breakeven_exit_counts_as_loss_after_costs(dt_env):
|
||||
"""총손익 0 이어도 순손익은 비용만큼 음수 → loss_tracker 손실, 당일 연패 +1 (의도된 보수화)."""
|
||||
day_trading_state.positions["444444"] = _pos()
|
||||
rec = AsyncMock()
|
||||
with patch.object(es.loss_tracker, "record_trade_result_safe", rec), \
|
||||
patch.object(es.loss_tracker, "save_to_db", AsyncMock()):
|
||||
await es._execute_exit("444444", day_trading_state.positions["444444"], 10_000.0, "EOD 강제 청산")
|
||||
rec.assert_awaited_once_with("444444", True)
|
||||
assert day_trading_state.daily_pnl < 0
|
||||
|
||||
|
||||
def test_daily_loss_limit_fires_earlier_with_net_pnl():
|
||||
"""[의도 고정] 총손익으로는 한도 직전이던 손실이 순손익으로는 한도를 넘는다."""
|
||||
total_assets = 10_000_000.0
|
||||
limit_amt = abs(dts._get_daily_loss_limit()) * total_assets
|
||||
entry, qty = 10_000.0, 100
|
||||
# 총손실이 한도보다 1원 작게: (exit-entry)*qty = -(limit_amt-1)
|
||||
exit_ = entry - (limit_amt - 1) / qty
|
||||
gross = (exit_ - entry) * qty
|
||||
net = net_realized_pnl(entry, exit_, qty, settings.KR_COMMISSION_RATE,
|
||||
settings.KR_SELL_TAX_RATE_KOSPI)["net_profit_loss"]
|
||||
assert not dts.is_daily_loss_exceeded(total_assets, gross)
|
||||
assert dts.is_daily_loss_exceeded(total_assets, net)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_exit_net_pnl_excluded_from_daily(dt_env):
|
||||
entry, exit_, qty = 10_000.0, 9_500.0, 10
|
||||
pos = _pos(entry, qty)
|
||||
before = es.get_stale_realized_pnl()
|
||||
with patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \
|
||||
patch.object(es.loss_tracker, "save_to_db", AsyncMock()):
|
||||
await es._execute_exit_stale("555555", pos, exit_, "전일 미청산 포지션 강제 청산")
|
||||
net, _ = _expected_net(entry, exit_, qty)
|
||||
assert day_trading_state.daily_pnl == 0.0
|
||||
assert es.get_stale_realized_pnl() - before == pytest.approx(net)
|
||||
|
||||
|
||||
# ── 2b. 체결가 확인 ────────────────────────────────────────────────────────
|
||||
|
||||
def test_parse_fill_row_matches_zero_padded_odno():
|
||||
rows = [{"odno": "0000000777", "tot_ccld_qty": "5", "avg_prvs": "10050"},
|
||||
{"odno": "0000123", "tot_ccld_qty": "10", "avg_prvs": "0", "tot_ccld_amt": "100700"}]
|
||||
assert fr.parse_fill_row(rows, "0000123") == (10, 10070.0)
|
||||
assert fr.parse_fill_row(rows, "777") == (5, 10050.0)
|
||||
assert fr.parse_fill_row(rows, "888") is None
|
||||
assert fr.parse_fill_row([{"odno": "1", "tot_ccld_qty": "0", "avg_prvs": "0"}], "1") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_fill_timeout_returns_none(monkeypatch, caplog):
|
||||
monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0)
|
||||
monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_TIMEOUT_SEC", 0.05)
|
||||
|
||||
async def _slow(*a, **k):
|
||||
await asyncio.sleep(5) # rate-limit sleep 모사
|
||||
return []
|
||||
|
||||
with patch.object(fr.korea_investment_api, "get_todays_orders", _slow):
|
||||
assert await fr.fetch_fill("000000", "1", "SELL") is None
|
||||
assert any("시간초과" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_disabled_is_noop():
|
||||
# conftest 가 유닛 테스트 기본값으로 OFF
|
||||
assert settings.DAY_TRADE_FILL_CHECK_ENABLED is False
|
||||
assert fr.schedule("000000", "1", "BUY", _boom("on_fill")) is None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fill_on(monkeypatch):
|
||||
monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True)
|
||||
monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0)
|
||||
monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sell_fill_reconcile_adjusts_daily_pnl_and_history(dt_env, fill_on):
|
||||
entry, signal, fill, qty = 10_000.0, 10_100.0, 10_080.0, 10
|
||||
day_trading_state.positions["666666"] = _pos(entry, qty)
|
||||
rows = [{"odno": "123", "tot_ccld_qty": str(qty), "avg_prvs": str(fill)}]
|
||||
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)), \
|
||||
patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \
|
||||
patch.object(es.loss_tracker, "save_to_db", AsyncMock()):
|
||||
await es._execute_exit("666666", day_trading_state.positions["666666"], signal, "트레일링 스탑")
|
||||
await asyncio.gather(*list(fr._pending_tasks))
|
||||
net_fill, cost_fill = _expected_net(entry, fill, qty)
|
||||
assert day_trading_state.daily_pnl == pytest.approx(net_fill)
|
||||
oid, upd = dt_env["updates"][-1]
|
||||
assert oid == "0000123"
|
||||
assert float(upd["executed_price"]) == pytest.approx(fill)
|
||||
assert float(upd["net_profit_loss"]) == pytest.approx(net_fill)
|
||||
assert float(upd["commission"]) == pytest.approx(cost_fill)
|
||||
assert float(upd["slippage"]) == pytest.approx(round((fill - signal) / signal * 100, 4))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sell_fill_reconcile_stale_goes_to_stale_bucket(dt_env, fill_on):
|
||||
entry, signal, fill, qty = 10_000.0, 9_500.0, 9_450.0, 10
|
||||
rows = [{"odno": "123", "tot_ccld_qty": str(qty), "avg_prvs": str(fill)}]
|
||||
before = es.get_stale_realized_pnl()
|
||||
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)), \
|
||||
patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \
|
||||
patch.object(es.loss_tracker, "save_to_db", AsyncMock()):
|
||||
await es._execute_exit_stale("777777", _pos(entry, qty), signal, "전일 미청산 포지션 강제 청산")
|
||||
await asyncio.gather(*list(fr._pending_tasks))
|
||||
net_fill, _ = _expected_net(entry, fill, qty)
|
||||
assert day_trading_state.daily_pnl == 0.0
|
||||
assert es.get_stale_realized_pnl() - before == pytest.approx(net_fill)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_buy_fill_reconcile_updates_entry_price(dt_env, fill_on):
|
||||
day_trading_state.positions["888888"] = _pos(10_000.0, 10, order_id="0000456")
|
||||
rows = [{"odno": "456", "tot_ccld_qty": "10", "avg_prvs": "10030"}]
|
||||
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)):
|
||||
ens._schedule_buy_fill_reconcile("888888", "0000456", 10_000.0, 10)
|
||||
await asyncio.gather(*list(fr._pending_tasks))
|
||||
assert day_trading_state.positions["888888"]["entry_price"] == pytest.approx(10_030.0)
|
||||
assert dt_env["upserts"][-1] == {"stock_code": "888888", "strategy_name": "day_trade",
|
||||
"entry_price": 10_030.0}
|
||||
oid, upd = dt_env["updates"][-1]
|
||||
assert oid == "0000456" and float(upd["executed_price"]) == pytest.approx(10_030.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_buy_fill_reconcile_skips_replaced_position(dt_env, fill_on):
|
||||
"""다른 주문으로 교체된 포지션의 진입가는 건드리지 않는다(원장만 보정)."""
|
||||
day_trading_state.positions["999999"] = _pos(10_000.0, 10, order_id="OTHER")
|
||||
rows = [{"odno": "456", "tot_ccld_qty": "10", "avg_prvs": "10030"}]
|
||||
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)):
|
||||
ens._schedule_buy_fill_reconcile("999999", "0000456", 10_000.0, 10)
|
||||
await asyncio.gather(*list(fr._pending_tasks))
|
||||
assert day_trading_state.positions["999999"]["entry_price"] == 10_000.0
|
||||
assert dt_env["upserts"] == []
|
||||
assert len(dt_env["updates"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fill_check_failure_keeps_signal_price(dt_env, fill_on):
|
||||
"""체결 미확인이면 원장·당일 손익을 건드리지 않는다(신호가 유지)."""
|
||||
day_trading_state.positions["121212"] = _pos()
|
||||
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[])), \
|
||||
patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \
|
||||
patch.object(es.loss_tracker, "save_to_db", AsyncMock()):
|
||||
await es._execute_exit("121212", day_trading_state.positions["121212"], 10_100.0, "트레일링 스탑")
|
||||
pnl_after_exit = day_trading_state.daily_pnl
|
||||
await asyncio.gather(*list(fr._pending_tasks))
|
||||
assert day_trading_state.daily_pnl == pnl_after_exit
|
||||
assert dt_env["updates"] == []
|
||||
|
||||
|
||||
# ── 3. strategy_type ───────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_record_trade_sets_daytrading_strategy_type():
|
||||
from app.api.endpoints import day_trading as dt_api
|
||||
cap = AsyncMock()
|
||||
with patch("app.services.db_worker.enqueue_trade_history", cap):
|
||||
await dt_api._record_trade(None, "005930", "삼성전자", "SELL", 70_000.0, 1, "EOD_flat", -100.0)
|
||||
assert cap.await_args.args[0]["strategy_type"] == "daytrading"
|
||||
411
backend/tests/test_order_execution_daily_ccld_20261001.py
Normal file
411
backend/tests/test_order_execution_daily_ccld_20261001.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""[2026-10-01] get_order_execution 경로 결함 수정 + 처음 살아나는 _verify_and_sync_execution 분기.
|
||||
|
||||
결함: 국내 체결조회가 존재하지 않는 `/domestic-stock/v1/trading/inquire-ccnl` 을 호출해
|
||||
체결 확인이 한 번도 성공하지 못했다(실패는 debug 로 삼키거나 4xx 는 로그조차 없음).
|
||||
|
||||
검증 방식 — KIS 는 conftest 가 클래스 메서드 단위로 차단한다. 여기서는 그보다 아래인
|
||||
**HTTP 계층**(`_get_http_client().request`)을 인스턴스 패치로 가로챈다. 요청 구성 검사는
|
||||
예외 발생형 목(인자 캡처 후 raise)으로, 응답 파싱은 가짜 응답으로 한다.
|
||||
토큰(`_get_access_token`)은 conftest 차단을 인스턴스 패치로 덮어 고정 토큰을 준다.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import korea_investment_api as kis_mod
|
||||
from app.services import trade_executor as te
|
||||
from app.services.korea_investment_api import (
|
||||
CircuitBreaker,
|
||||
DAILY_CCLD_ENDPOINT,
|
||||
parse_order_execution_rows,
|
||||
)
|
||||
|
||||
# 모듈 싱글톤(`kis_mod.korea_investment_api`)은 쓰지 않는다 — test_api_endpoints.py 가 수집 시점에
|
||||
# MagicMock 으로 바꿔 두고 되돌리지 않는다(세션 전체 누수). 테스트마다 새 인스턴스를 만든다.
|
||||
|
||||
# conftest 는 테스트 실행 시점에 클래스 메서드를 차단 스텁으로 바꾼다. 수집(import) 시점의
|
||||
# 원본을 잡아 두고, HTTP 계층만 목킹한 상태에서 **실제 공개 메서드**를 검증한다.
|
||||
_ORIG_GET_TODAYS = kis_mod.KoreaInvestmentAPI.get_todays_orders
|
||||
_ORIG_GET_EXEC = kis_mod.KoreaInvestmentAPI.get_order_execution
|
||||
|
||||
|
||||
class _HttpCaptured(Exception):
|
||||
"""요청 인자를 캡처한 뒤 던지는 예외 — 실제 전송이 일어나지 않았음을 보장."""
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, body: dict, headers: dict | None = None, status: int = 200):
|
||||
self._body = body
|
||||
self.headers = headers or {}
|
||||
self.status_code = status
|
||||
self.text = ""
|
||||
|
||||
def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""httpx.AsyncClient.request 대체. responses 가 비면 캡처 예외를 던진다."""
|
||||
|
||||
def __init__(self, responses=None):
|
||||
self.calls = []
|
||||
self.responses = list(responses or [])
|
||||
self.is_closed = False
|
||||
|
||||
async def request(self, method, url, headers=None, timeout=None, **kwargs):
|
||||
self.calls.append({"method": method, "url": url, "headers": dict(headers or {}), **kwargs})
|
||||
if not self.responses:
|
||||
raise _HttpCaptured(url)
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
class _Api:
|
||||
"""HTTP 계층만 가로챈 새 KoreaInvestmentAPI 인스턴스 (토큰 캐시 파일·네트워크 미사용)."""
|
||||
|
||||
def __init__(self, client: _FakeClient, base_url: str):
|
||||
api = object.__new__(kis_mod.KoreaInvestmentAPI)
|
||||
api.base_url = base_url
|
||||
api.app_key = "TEST_APP_KEY"
|
||||
api.app_secret = "TEST_APP_SECRET"
|
||||
api.account_number = "1234567801"
|
||||
api.access_token = None
|
||||
api.token_expires_at = None
|
||||
api.last_request_time = None
|
||||
api._throttle_lock = asyncio.Lock()
|
||||
api.min_request_interval = 0.0
|
||||
api.max_retries = 0
|
||||
api.retry_delay = 0.0
|
||||
api.retry_backoff = 1.0
|
||||
api.timeout = 5.0
|
||||
api.semaphore = asyncio.Semaphore(5)
|
||||
api.monitor = kis_mod.APICallMonitor(window_size=10)
|
||||
api.circuit_breaker = CircuitBreaker(failure_threshold=99, recovery_timeout=1)
|
||||
# conftest 의 클래스 차단(_get_access_token)을 인스턴스 속성으로 덮는다
|
||||
api._get_access_token = AsyncMock(return_value="TEST_TOKEN")
|
||||
api._get_http_client = AsyncMock(return_value=client)
|
||||
self.api = api
|
||||
|
||||
def __enter__(self):
|
||||
return self.api
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
REAL = "https://openapi.koreainvestment.com:9443"
|
||||
VIRTUAL = "https://openapivts.koreainvestment.com:29443"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 1. 요청 구성 (URL·TR_ID·파라미터) — 예외 발생형 HTTP 목으로 인자만 검사
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("base_url,expected_tr", [(REAL, "TTTC0081R"), (VIRTUAL, "VTTC0081R")])
|
||||
async def test_order_execution_request_uses_daily_ccld(base_url, expected_tr, caplog):
|
||||
client = _FakeClient()
|
||||
with _Api(client, base_url) as API, caplog.at_level(logging.WARNING):
|
||||
out = await API._get_order_execution_domestic_impl("0000117057")
|
||||
|
||||
assert len(client.calls) == 1, "예외 발생 시 대체 TR 재시도·내부 재시도 없이 1회만"
|
||||
call = client.calls[0]
|
||||
assert call["method"] == "GET"
|
||||
assert call["url"] == f"{base_url}{DAILY_CCLD_ENDPOINT}"
|
||||
assert call["url"].endswith("/uapi/domestic-stock/v1/trading/inquire-daily-ccld")
|
||||
assert "inquire-ccnl" not in call["url"]
|
||||
assert call["headers"]["tr_id"] == expected_tr
|
||||
assert "tr_cont" not in call["headers"], "첫 페이지는 tr_cont 헤더 없음"
|
||||
p = call["params"]
|
||||
assert p["CANO"] == "12345678" and p["ACNT_PRDT_CD"] == "01"
|
||||
assert p["INQR_STRT_DT"] == p["INQR_END_DT"] and len(p["INQR_STRT_DT"]) == 8
|
||||
assert p["ODNO"] == "0000117057"
|
||||
assert p["SLL_BUY_DVSN_CD"] == "00" and p["CCLD_DVSN"] == "00"
|
||||
assert p["INQR_DVSN"] == "00" and p["INQR_DVSN_3"] == "00"
|
||||
assert p["CTX_AREA_FK100"] == "" and p["CTX_AREA_NK100"] == ""
|
||||
assert p["EXCG_ID_DVSN_CD"] == "KRX"
|
||||
# 실패 → 기존 계약대로 filled_qty=0 + warning(debug 금지)
|
||||
assert out["filled_qty"] == 0 and out["filled_price"] == 0.0 and out["ok"] is False
|
||||
assert any("[get_order_execution]" in r.message and r.levelno >= logging.WARNING
|
||||
for r in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_get_order_execution_hits_daily_ccld():
|
||||
"""공개 메서드(원본) 경유로도 inquire-daily-ccld 를 부르고 기존 2키 계약을 지킨다."""
|
||||
body = {"rt_cd": "0", "output1": [{"odno": "0000000042", "tot_ccld_qty": "3",
|
||||
"avg_prvs": "5000", "ord_qty": "3", "rmn_qty": "0"}]}
|
||||
client = _FakeClient([_Resp(body)])
|
||||
with _Api(client, REAL) as API:
|
||||
out = await _ORIG_GET_EXEC(API, "0000000042", True)
|
||||
assert client.calls[0]["url"].endswith("/trading/inquire-daily-ccld")
|
||||
assert out["filled_qty"] == 3 and out["filled_price"] == 5000.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_todays_orders_request_and_warning(caplog):
|
||||
"""get_todays_orders 도 같은 요청 구성(ODNO 공란, PDNO=종목)이며 실패는 warning."""
|
||||
client = _FakeClient()
|
||||
with _Api(client, VIRTUAL) as API, caplog.at_level(logging.WARNING):
|
||||
rows = await _ORIG_GET_TODAYS(API, "005930")
|
||||
assert rows == []
|
||||
call = client.calls[0]
|
||||
assert call["url"] == f"{VIRTUAL}{DAILY_CCLD_ENDPOINT}"
|
||||
assert call["headers"]["tr_id"] == "VTTC0081R"
|
||||
assert call["params"]["PDNO"] == "005930" and call["params"]["ODNO"] == ""
|
||||
assert any("[get_todays_orders]" in r.message and r.levelno >= logging.WARNING
|
||||
for r in caplog.records)
|
||||
_, p_exec = API._daily_ccld_request_v2(order_id="1")
|
||||
assert set(call["params"]) == set(p_exec), "두 메서드의 파라미터 키 집합이 같아야 한다"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_todays_orders_success_returns_rows():
|
||||
rows = [{"odno": "1", "pdno": "005930"}]
|
||||
client = _FakeClient([_Resp({"rt_cd": "0", "output1": rows})])
|
||||
with _Api(client, REAL) as API:
|
||||
assert await _ORIG_GET_TODAYS(API, "005930") == rows
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_tr_fallback_on_error_rt_cd():
|
||||
"""현행 TR 이 rt_cd!=0 이면 구 TR(TTTC8001R) 1회 재시도, 성공하면 이후 구 TR 우선."""
|
||||
ok = {"rt_cd": "0", "output1": [{"odno": "0000000777", "tot_ccld_qty": "5",
|
||||
"avg_prvs": "10100", "ord_qty": "5", "rmn_qty": "0"}]}
|
||||
client = _FakeClient([
|
||||
_Resp({"rt_cd": "1", "msg_cd": "OPSQ0002", "msg1": "없는 서비스 코드"}),
|
||||
_Resp(ok),
|
||||
])
|
||||
with _Api(client, REAL) as API:
|
||||
out = await API._get_order_execution_domestic_impl("777")
|
||||
assert [c["headers"]["tr_id"] for c in client.calls] == ["TTTC0081R", "TTTC8001R"]
|
||||
assert out["filled_qty"] == 5 and out["filled_price"] == 10100.0 and out["found"]
|
||||
# 다음 호출은 구 TR 부터
|
||||
client.responses.append(_Resp(ok))
|
||||
await API._get_order_execution_domestic_impl("777")
|
||||
assert client.calls[-1]["headers"]["tr_id"] == "TTTC8001R"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pagination_uses_tr_cont_n_and_ctx_keys():
|
||||
page1 = _Resp({"rt_cd": "0", "output1": [{"odno": "0000000001", "tot_ccld_qty": "1"}],
|
||||
"ctx_area_fk100": "FK1", "ctx_area_nk100": "NK1"}, headers={"tr_cont": "M"})
|
||||
page2 = _Resp({"rt_cd": "0", "output1": [{"odno": "0000000042", "tot_ccld_qty": "3",
|
||||
"avg_prvs": "5000", "ord_qty": "3", "rmn_qty": "0"}]},
|
||||
headers={"tr_cont": "D"})
|
||||
client = _FakeClient([page1, page2])
|
||||
with _Api(client, VIRTUAL) as API:
|
||||
out = await API._get_order_execution_domestic_impl("0000000042")
|
||||
assert len(client.calls) == 2
|
||||
assert client.calls[1]["headers"]["tr_cont"] == "N"
|
||||
assert client.calls[1]["params"]["CTX_AREA_FK100"] == "FK1"
|
||||
assert client.calls[1]["params"]["CTX_AREA_NK100"] == "NK1"
|
||||
assert out["filled_qty"] == 3 and out["filled_price"] == 5000.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_like_response_logs_warning(caplog):
|
||||
"""옛 경로처럼 4xx 가 돌아와도 로그 없이 0 을 돌려주던 동작 → warning."""
|
||||
client = _FakeClient([_Resp({"rt_cd": "404", "msg1": "Not Found"}, status=404),
|
||||
_Resp({"rt_cd": "404", "msg1": "Not Found"}, status=404)])
|
||||
with _Api(client, REAL) as API, caplog.at_level(logging.WARNING):
|
||||
out = await API._get_order_execution_domestic_impl("123")
|
||||
assert out["filled_qty"] == 0 and out["ok"] is False
|
||||
assert any("체결조회 실패" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 2. 응답 파싱 (순수 함수)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_parse_full_fill_with_zero_padding():
|
||||
rows = [{"odno": "0000117057", "tot_ccld_qty": "10", "avg_prvs": "71200",
|
||||
"ord_qty": "10", "rmn_qty": "0", "cncl_yn": "N"}]
|
||||
out = parse_order_execution_rows(rows, "117057")
|
||||
assert out == {"filled_qty": 10, "filled_price": 71200.0, "ordered_qty": 10,
|
||||
"remaining_qty": 0, "cancelled": False,
|
||||
"cancel_confirmed_qty": None, "rejected_qty": None}
|
||||
|
||||
|
||||
def test_parse_cancel_and_reject_quantities():
|
||||
rows = [{"odno": "3", "tot_ccld_qty": "4", "avg_prvs": "100", "ord_qty": "10",
|
||||
"rmn_qty": "0", "cnc_cfrm_qty": "5", "rjct_qty": "1", "cncl_yn": "N"}]
|
||||
out = parse_order_execution_rows(rows, "3")
|
||||
assert out["cancel_confirmed_qty"] == 5 and out["rejected_qty"] == 1
|
||||
assert out["cancelled"] is False
|
||||
|
||||
|
||||
def test_parse_partial_and_avg_fallback():
|
||||
rows = [{"odno": "0000000099", "tot_ccld_qty": "4", "avg_prvs": "0", "tot_ccld_amt": "40400",
|
||||
"ord_qty": "10", "rmn_qty": "6"}]
|
||||
out = parse_order_execution_rows(rows, "0000000099")
|
||||
assert out["filled_qty"] == 4 and out["filled_price"] == 10100.0
|
||||
assert out["remaining_qty"] == 6
|
||||
|
||||
|
||||
def test_parse_unfilled_and_missing_remaining_field():
|
||||
out = parse_order_execution_rows([{"odno": "5", "tot_ccld_qty": "0", "avg_prvs": "0",
|
||||
"ord_qty": "3"}], "5")
|
||||
assert out["filled_qty"] == 0 and out["filled_price"] == 0.0
|
||||
assert out["remaining_qty"] is None, "필드 없음은 0(소멸)이 아니라 None(모름)"
|
||||
|
||||
|
||||
def test_parse_empty_or_no_match():
|
||||
assert parse_order_execution_rows([], "1") is None
|
||||
assert parse_order_execution_rows(None, "1") is None
|
||||
assert parse_order_execution_rows([{"odno": "2", "tot_ccld_qty": "1"}], "1") is None
|
||||
assert parse_order_execution_rows([{"odno": "1"}], "") is None
|
||||
# 첫 행이 아니라 주문번호가 일치하는 행을 고른다 (옛 구현은 output[0] 고정)
|
||||
rows = [{"odno": "8", "tot_ccld_qty": "9", "avg_prvs": "1"},
|
||||
{"odno": "7", "tot_ccld_qty": "2", "avg_prvs": "300", "ord_qty": "2", "rmn_qty": "0"}]
|
||||
assert parse_order_execution_rows(rows, "7")["filled_qty"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overseas_returns_legacy_contract_without_http():
|
||||
client = _FakeClient()
|
||||
with _Api(client, REAL) as API:
|
||||
out = await _ORIG_GET_EXEC(API, "123", False)
|
||||
assert out == {"filled_qty": 0, "filled_price": 0.0} and client.calls == []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 3. _verify_and_sync_execution — 처음 살아나는 분기의 동작
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
class _ReorderCalled(BaseException):
|
||||
"""자동 재주문 경로가 호출됨 — BaseException 이라 `except Exception` 에 삼켜지지 않는다."""
|
||||
|
||||
|
||||
def _reorder_forbidden(*args, **kwargs):
|
||||
raise _ReorderCalled(f"_reorder_unfilled 호출됨: {kwargs}")
|
||||
|
||||
|
||||
class _VerifyHarness:
|
||||
def __init__(self, exec_results):
|
||||
self.exec_mock = AsyncMock(side_effect=list(exec_results))
|
||||
self.upd_by_order = AsyncMock()
|
||||
self.upd_by_id = AsyncMock()
|
||||
self.sync = AsyncMock()
|
||||
self.reorder = _reorder_forbidden
|
||||
self._real_sleep = asyncio.sleep
|
||||
|
||||
async def _fast_sleep(self, *_a, **_k):
|
||||
await self._real_sleep(0)
|
||||
|
||||
async def run(self, ordered=10, order_type="BUY", order_price=10000.0):
|
||||
with patch.object(te.korea_investment_api, "get_order_execution", self.exec_mock), \
|
||||
patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", self.upd_by_order), \
|
||||
patch("app.services.db_worker.enqueue_update_trade_history", self.upd_by_id), \
|
||||
patch.object(te, "_delayed_sync_portfolio", self.sync), \
|
||||
patch.object(te, "_reorder_unfilled", self.reorder), \
|
||||
patch.object(te.asyncio, "sleep", self._fast_sleep):
|
||||
await te._verify_and_sync_execution(
|
||||
order_id="0000000042", stock_code="005930", stock_name="삼성전자",
|
||||
ordered_quantity=ordered, order_price=order_price, order_type=order_type,
|
||||
is_domestic=True, exchange_code="KRX", db_trade_id=0, delay=0,
|
||||
)
|
||||
await self._real_sleep(0) # create_task 로 띄운 재주문 태스크 실행 기회
|
||||
|
||||
|
||||
def _ex(filled, price=10050.0, remaining=None, cancelled=False, ordered=10):
|
||||
return {"filled_qty": filled, "filled_price": price if filled else 0.0, "found": True, "ok": True,
|
||||
"ordered_qty": ordered, "remaining_qty": remaining, "cancelled": cancelled}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_full_fill_updates_completed_once():
|
||||
h = _VerifyHarness([_ex(10, remaining=0)])
|
||||
await h.run()
|
||||
assert h.exec_mock.await_count == 1
|
||||
h.upd_by_order.assert_awaited_once()
|
||||
order_id, fields = h.upd_by_order.await_args.args
|
||||
assert order_id == "0000000042"
|
||||
assert fields["status"] == "COMPLETED" and fields["executed_quantity"] == 10
|
||||
assert float(fields["executed_price"]) == 10050.0
|
||||
assert float(fields["slippage"]) == pytest.approx(0.5)
|
||||
h.sync.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_partial_with_live_remainder_never_reorders():
|
||||
"""잔량이 KIS 에 살아 있으면 재주문 = 중복 주문. 옛 코드는 첫 부분체결에서 바로 재주문했다."""
|
||||
h = _VerifyHarness([_ex(4, remaining=6)] * 5)
|
||||
await h.run()
|
||||
assert h.exec_mock.await_count == 5, "부분체결 첫 관측에서 멈추지 않고 계속 폴링"
|
||||
h.upd_by_order.assert_not_called()
|
||||
h.sync.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_partial_then_full_within_polls():
|
||||
h = _VerifyHarness([_ex(4, remaining=6), _ex(10, remaining=0)])
|
||||
await h.run()
|
||||
assert h.exec_mock.await_count == 2
|
||||
fields = h.upd_by_order.await_args.args[1]
|
||||
assert fields["executed_quantity"] == 10 and fields["status"] == "COMPLETED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_partial_unknown_remainder_is_treated_as_live():
|
||||
h = _VerifyHarness([_ex(4, remaining=None)] * 5)
|
||||
await h.run()
|
||||
h.upd_by_order.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("order_type", ["BUY", "SELL"])
|
||||
async def test_verify_partial_dead_remainder_records_and_never_reorders(order_type, caplog):
|
||||
"""잔량 소멸(취소·거부·만료) — 체결분만 기록, 자동 재주문 금지(가드 전부 우회하므로), 수동 확인 warning."""
|
||||
res = _ex(4, remaining=0)
|
||||
res.update({"cancel_confirmed_qty": 6, "rejected_qty": 0})
|
||||
h = _VerifyHarness([res])
|
||||
with caplog.at_level(logging.WARNING):
|
||||
await h.run(order_type=order_type) # 재주문 호출 시 _ReorderCalled 로 테스트 실패
|
||||
fields = h.upd_by_order.await_args.args[1]
|
||||
assert fields["executed_quantity"] == 4
|
||||
assert fields["status"] == "COMPLETED", "PARTIAL 은 재진입 가드·손익 집계에서 빠진다"
|
||||
msgs = [r.message for r in caplog.records if "[PARTIAL_FILL]" in r.message]
|
||||
assert msgs, "잔량 소멸 warning 누락"
|
||||
m = msgs[-1]
|
||||
for frag in ("005930", "0000000042", order_type, "4/10", "취소확인=6", "거부=0", "수동 확인"):
|
||||
assert frag in m, (frag, m)
|
||||
h.sync.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_partial_cancelled_does_not_reorder():
|
||||
h = _VerifyHarness([_ex(4, remaining=0, cancelled=True)])
|
||||
await h.run()
|
||||
h.upd_by_order.assert_awaited_once()
|
||||
|
||||
|
||||
def test_verify_source_has_no_reorder_call():
|
||||
"""정적 고정 — _verify_and_sync_execution 본문에서 _reorder_unfilled 호출 금지."""
|
||||
import inspect
|
||||
body = inspect.getsource(te._verify_and_sync_execution).split('"""', 2)[2]
|
||||
assert "_reorder_unfilled(" not in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_unfilled_no_db_write_and_warns(caplog):
|
||||
h = _VerifyHarness([_ex(0, remaining=10)] * 5)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
await h.run()
|
||||
h.upd_by_order.assert_not_called()
|
||||
h.sync.assert_awaited_once()
|
||||
assert any("체결 미확인" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_overfill_is_clamped():
|
||||
h = _VerifyHarness([_ex(15, remaining=0)])
|
||||
await h.run()
|
||||
assert h.upd_by_order.await_args.args[1]["executed_quantity"] == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_legacy_two_key_result_still_works():
|
||||
"""get_order_execution 의 기존 2키 계약만 주는 목(해외/옛 호출부)과도 동작."""
|
||||
h = _VerifyHarness([{"filled_qty": 10, "filled_price": 9950.0}])
|
||||
await h.run(order_type="SELL")
|
||||
fields = h.upd_by_order.await_args.args[1]
|
||||
assert fields["executed_quantity"] == 10 and fields["status"] == "COMPLETED"
|
||||
647
backend/tests/test_trade_executor_risk_fixes_20261001.py
Normal file
647
backend/tests/test_trade_executor_risk_fixes_20261001.py
Normal file
@@ -0,0 +1,647 @@
|
||||
"""trade_executor 리스크 결함 3건 회귀 테스트 (2026-10-01).
|
||||
|
||||
1. 일일 매수 한도(MAX_DAILY_TRADES) 집계에서 데이트레이딩 매수 제외
|
||||
2. 종목당 상한(MAX_POSITION_SIZE)에 기존 보유분 반영 (CLAUDE.md §5.1)
|
||||
3. 추천 경로 매도(_execute_sell)에서 external/manual/DT 보유 행 제외
|
||||
|
||||
규약(CLAUDE.md §4.1):
|
||||
- 주문 경로 목은 **예외 발생형**이다(_Reached). 가드가 빗나가면 테스트가 터진다.
|
||||
- 목은 공통 종단(_acquire_order_lock — 매수·매도 주문 직전의 공통 관문)에 건다.
|
||||
- 기대값은 settings.* 참조.
|
||||
"""
|
||||
import ast
|
||||
import inspect
|
||||
import textwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.models import TradeHistory
|
||||
from app.services import trade_executor
|
||||
from app.services.db_worker import DT_STRATEGY_NAMES
|
||||
from app.services.sizing_core import effective_min_buy_amount, remaining_position_room
|
||||
|
||||
|
||||
class _Reached(BaseException):
|
||||
"""가드를 통과해 주문 관문에 도달했다는 신호.
|
||||
|
||||
BaseException 파생 — 프로덕션의 `except Exception:` 폴백에 삼켜지지 않게(§4.1).
|
||||
"""
|
||||
|
||||
|
||||
class _Result:
|
||||
def __init__(self, rows=None):
|
||||
self._rows = list(rows or [])
|
||||
|
||||
def scalars(self):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def all(self):
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""execute 문장을 기록하고 빈 결과(또는 지정 결과)를 돌려준다. commit 은 호출되면 실패."""
|
||||
|
||||
def __init__(self, rows_for_first_call=None):
|
||||
self.statements = []
|
||||
self._first_rows = rows_for_first_call
|
||||
|
||||
async def execute(self, stmt):
|
||||
self.statements.append(stmt)
|
||||
if len(self.statements) == 1 and self._first_rows is not None:
|
||||
return _Result(self._first_rows)
|
||||
return _Result([])
|
||||
|
||||
async def commit(self):
|
||||
raise AssertionError("trade_executor 가 db.commit() 을 직접 호출했다 (§3.2 위반)")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _block_order_gate(monkeypatch):
|
||||
gate = AsyncMock(side_effect=_Reached("order gate reached"))
|
||||
monkeypatch.setattr(trade_executor, "_acquire_order_lock", gate)
|
||||
return gate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _empty_dt_state(monkeypatch):
|
||||
from app.services.daytrading.position_manager import day_trading_state
|
||||
monkeypatch.setattr(day_trading_state, "positions", {})
|
||||
return day_trading_state
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 1. 일일 매수 한도에서 DT 제외
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _compile(stmt) -> str:
|
||||
return str(stmt.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_daily_buy_limit_counts_only_swing_owned_buys(monkeypatch):
|
||||
"""스윙 한도 → swing + NULL 레거시만 집계. DT·manual·external·us_swing 제외."""
|
||||
monkeypatch.setattr(settings, "BEAR_MARKET_FILTER_ENABLED", False)
|
||||
# 오늘 스윙 매수 = 한도 → 조기 반환(추천 처리 없음)
|
||||
db = _FakeDB(rows_for_first_call=[f"{i:06d}" for i in range(settings.MAX_DAILY_TRADES)])
|
||||
result = await trade_executor._execute_auto_trade_background_locked(
|
||||
{"recommendations_by_rule": []}, db
|
||||
)
|
||||
assert result == []
|
||||
assert db.statements, "당일 매수 집계 쿼리가 실행되지 않았다"
|
||||
|
||||
sql = _compile(db.statements[0])
|
||||
assert "trade_history.strategy_type IS NULL" in sql
|
||||
assert "trade_history.strategy_type IN ('swing')" in sql
|
||||
for excluded in (*DT_STRATEGY_NAMES, "manual", "external", "us_swing"):
|
||||
assert f"'{excluded}'" not in sql, f"{excluded} 가 집계 조건에 들어 있다"
|
||||
|
||||
|
||||
def test_daily_limit_and_sell_ownership_share_owner_set():
|
||||
"""일일 한도 집계와 매도 소유권이 같은 스윙 소유 기준을 쓴다."""
|
||||
owners = trade_executor._SWING_OWNER_STRATEGY_TYPES
|
||||
f = trade_executor._sell_ownership_block_reason_v2
|
||||
for st in owners:
|
||||
assert f(SimpleNamespace(strategy_type=st), {"strategy_type": "swing"}) is None
|
||||
sql = _compile(select(TradeHistory.id).where(
|
||||
trade_executor._swing_owned_clause_v2(TradeHistory.strategy_type)))
|
||||
for st in owners:
|
||||
assert f"'{st}'" in sql
|
||||
|
||||
|
||||
class _RaisingDB(_FakeDB):
|
||||
async def execute(self, stmt):
|
||||
self.statements.append(stmt)
|
||||
raise RuntimeError("db down")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_daily_buy_limit_fails_closed_on_query_error(monkeypatch, caplog):
|
||||
"""집계 조회 실패 → 신규 진입 0건(fail-closed) + error 로그. execute_auto_trade 미호출."""
|
||||
monkeypatch.setattr(settings, "BEAR_MARKET_FILTER_ENABLED", False)
|
||||
called = AsyncMock(side_effect=_Reached("execute_auto_trade reached"))
|
||||
monkeypatch.setattr(trade_executor, "execute_auto_trade", called)
|
||||
db = _RaisingDB()
|
||||
recs = {"recommendations_by_rule": [{"rule_name": "r", "stocks": [
|
||||
{"stock_code": "005930", "stock_name": "t", "current_price": 70000, "score": 5},
|
||||
]}]}
|
||||
import logging
|
||||
with caplog.at_level(logging.ERROR, logger=trade_executor.logger.name):
|
||||
result = await trade_executor._execute_auto_trade_background_locked(recs, db)
|
||||
assert result == []
|
||||
called.assert_not_called()
|
||||
assert any(r.levelno >= logging.ERROR and "fail-closed" in r.getMessage() for r in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_swing_buys_returns_none_on_error():
|
||||
assert await trade_executor._count_swing_buys_today_v2(_RaisingDB()) is None
|
||||
|
||||
|
||||
def test_daily_limit_fail_closed_decision_outside_inner_try():
|
||||
"""fail-closed 판정(`_bought_today is None`)이 내부 try 안에 있지 않다(§4).
|
||||
|
||||
함수 전체를 감싼 최외곽 try 는 예외 시 빈/부분 결과를 반환하므로 fail-closed 방향이다.
|
||||
"""
|
||||
tree = ast.parse(textwrap.dedent(inspect.getsource(
|
||||
trade_executor._execute_auto_trade_background_locked)))
|
||||
fn = tree.body[0]
|
||||
outer = next(s for s in fn.body if isinstance(s, ast.Try))
|
||||
|
||||
def _is_none_check(n):
|
||||
return (isinstance(n, ast.Compare) and isinstance(n.left, ast.Name)
|
||||
and n.left.id == "_bought_today" and isinstance(n.ops[0], ast.Is))
|
||||
|
||||
assert any(_is_none_check(n) for n in ast.walk(outer)), "fail-closed 판정이 없다"
|
||||
for n in ast.walk(outer):
|
||||
if isinstance(n, ast.Try) and n is not outer:
|
||||
assert not any(_is_none_check(m) for m in ast.walk(n)), "판정이 내부 try 안에 있다"
|
||||
|
||||
|
||||
# ── 1-b. 부분매도 반복 차단(_sold_today)에서 DT 매도 제외 ─────────────────
|
||||
|
||||
def test_non_dt_sold_today_query_uses_not_dt_clause():
|
||||
import datetime as _dt
|
||||
captured = []
|
||||
|
||||
class _DB(_FakeDB):
|
||||
async def execute(self, stmt):
|
||||
captured.append(stmt)
|
||||
return _Result([])
|
||||
|
||||
import asyncio
|
||||
found = asyncio.run(trade_executor._non_dt_sold_today_v2(
|
||||
_DB(), "005930", _dt.datetime(2026, 10, 1)))
|
||||
assert found is False
|
||||
sql = _compile(captured[0])
|
||||
assert "trade_history.strategy_type IS NULL" in sql
|
||||
assert "NOT IN" in sql
|
||||
for name in DT_STRATEGY_NAMES:
|
||||
assert f"'{name}'" in sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_dt_sold_today_is_conservative_on_error():
|
||||
import datetime as _dt
|
||||
assert await trade_executor._non_dt_sold_today_v2(
|
||||
_RaisingDB(), "005930", _dt.datetime(2026, 10, 1)) is True
|
||||
|
||||
|
||||
class _SellFlowDB:
|
||||
"""execute_auto_trade 의 쿼리 순서를 흉내 낸다.
|
||||
|
||||
1) 보유 조회 → holding 2) 보유 수 → [holding] 3) 당일 SELL(전체) → dt_sell_row
|
||||
4) 당일 비-DT SELL → non_dt_rows 그 외 → 빈 결과
|
||||
"""
|
||||
|
||||
def __init__(self, holding, any_sell_rows, non_dt_rows):
|
||||
self._seq = [[holding], [holding], any_sell_rows, non_dt_rows]
|
||||
self.n = 0
|
||||
|
||||
async def execute(self, stmt):
|
||||
rows = self._seq[self.n] if self.n < len(self._seq) else []
|
||||
self.n += 1
|
||||
return _Result(rows)
|
||||
|
||||
async def commit(self):
|
||||
raise AssertionError("commit 금지")
|
||||
|
||||
|
||||
def _partial_exit_stock():
|
||||
# check_exit_signal 이 부분매도를 내도록 모킹하고, 잔량 추적은 된 상태(remaining_pct=1.0)
|
||||
return {
|
||||
"stock_code": "005930", "stock_name": "테스트", "current_price": 11_000.0,
|
||||
"signals": [], "strategy_type": "swing", "remaining_pct": 1.0,
|
||||
"rsi": 56.0, "bb": (12_000.0, 10_500.0, 9_000.0), "atr": 200.0,
|
||||
}
|
||||
|
||||
|
||||
def _patch_exit_flow(monkeypatch, sell_pct):
|
||||
monkeypatch.setattr(trade_executor.mdd_manager, "can_open_new_position", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(trade_executor.loss_tracker, "is_banned_safe", AsyncMock(return_value=False))
|
||||
monkeypatch.setattr(settings, "ML_ENSEMBLE_ENABLED", False)
|
||||
monkeypatch.setattr(settings, "NEWS_ENABLED", False)
|
||||
monkeypatch.setattr(
|
||||
trade_executor, "check_exit_signal",
|
||||
lambda **kw: SimpleNamespace(action="SELL_PARTIAL", sell_pct=sell_pct, reason="RSI test"),
|
||||
)
|
||||
sell = AsyncMock(side_effect=_Reached("sell reached"))
|
||||
monkeypatch.setattr(trade_executor, "_execute_sell", sell)
|
||||
return sell
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dt_only_sell_today_does_not_block_swing_partial_exit(monkeypatch):
|
||||
"""오늘 매도가 DT 것뿐이면 스윙 부분매도는 진행된다."""
|
||||
sell = _patch_exit_flow(monkeypatch, 0.5)
|
||||
holding = SimpleNamespace(quantity=10, strategy_type="swing", average_price=10_000.0,
|
||||
profit_loss_rate=10.0, stock_name="테스트")
|
||||
db = _SellFlowDB(holding, any_sell_rows=["dt-sell"], non_dt_rows=[])
|
||||
with pytest.raises(_Reached):
|
||||
await trade_executor.execute_auto_trade(_partial_exit_stock(), db)
|
||||
assert sell.await_args.kwargs.get("sell_pct") == 0.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swing_sell_today_still_blocks_partial_exit(monkeypatch):
|
||||
"""오늘 스윙 매도가 있으면 부분매도는 기존대로 차단된다."""
|
||||
sell = _patch_exit_flow(monkeypatch, 0.5)
|
||||
holding = SimpleNamespace(quantity=10, strategy_type="swing", average_price=10_000.0,
|
||||
profit_loss_rate=10.0, stock_name="테스트")
|
||||
db = _SellFlowDB(holding, any_sell_rows=["swing-sell"], non_dt_rows=["swing-sell"])
|
||||
res = await trade_executor.execute_auto_trade(_partial_exit_stock(), db)
|
||||
assert res == {"action": "SKIP", "reason": "cooldown_sell_today", "stock": "테스트"}
|
||||
sell.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dt_sell_today_still_blocks_rebuy_without_holding(monkeypatch):
|
||||
"""당일 재매수 차단은 DT 매도도 포함(무보유 종목 → 즉시 SKIP)."""
|
||||
monkeypatch.setattr(trade_executor.mdd_manager, "can_open_new_position", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(trade_executor.loss_tracker, "is_banned_safe", AsyncMock(return_value=False))
|
||||
buy = AsyncMock(side_effect=_Reached("buy reached"))
|
||||
monkeypatch.setattr(trade_executor, "_execute_buy", buy)
|
||||
db = _SellFlowDB(None, any_sell_rows=["dt-sell"], non_dt_rows=[])
|
||||
db._seq = [[], [], ["dt-sell"], []]
|
||||
res = await trade_executor.execute_auto_trade(
|
||||
{"stock_code": "005930", "stock_name": "테스트", "current_price": 11_000.0,
|
||||
"signals": ["x"], "rule_id": 1}, db)
|
||||
assert res["reason"] == "cooldown_sell_today"
|
||||
buy.assert_not_called()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 2. 종목당 누적 상한
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TOTAL = 100_000_000.0
|
||||
|
||||
|
||||
def test_remaining_position_room_pure():
|
||||
cap = settings.MAX_POSITION_SIZE
|
||||
assert remaining_position_room(TOTAL, cap, 0) == pytest.approx(TOTAL * cap)
|
||||
assert remaining_position_room(TOTAL, cap, TOTAL * cap * 0.5) == pytest.approx(TOTAL * cap * 0.5)
|
||||
assert remaining_position_room(TOTAL, cap, TOTAL * cap * 2) == 0.0 # 초과 → floor 0
|
||||
assert remaining_position_room(0, cap, 1) == 0.0
|
||||
assert remaining_position_room(TOTAL, 0, 0) == 0.0
|
||||
assert remaining_position_room(TOTAL, cap, -5) == pytest.approx(TOTAL * cap)
|
||||
|
||||
|
||||
def test_position_cap_no_holding_is_unchanged(_empty_dt_state):
|
||||
amt, reason = trade_executor._apply_position_cap_room_v2(
|
||||
invest_amount=1_234_567.0, holding=None, stock_code="005930", stock_name="t",
|
||||
current_price=10_000.0, total_assets=TOTAL, position_cap_ratio=settings.MAX_POSITION_SIZE,
|
||||
)
|
||||
assert reason is None and amt == 1_234_567.0
|
||||
|
||||
|
||||
def test_position_cap_clamps_to_remaining_room(_empty_dt_state):
|
||||
cap = settings.MAX_POSITION_SIZE
|
||||
price = 10_000.0
|
||||
held_qty = int(TOTAL * cap * 0.5 / price) # 상한의 절반 보유
|
||||
holding = SimpleNamespace(quantity=held_qty, strategy_type="swing")
|
||||
amt, reason = trade_executor._apply_position_cap_room_v2(
|
||||
invest_amount=TOTAL * cap, holding=holding, stock_code="005930", stock_name="t",
|
||||
current_price=price, total_assets=TOTAL, position_cap_ratio=cap,
|
||||
)
|
||||
assert reason is None
|
||||
assert amt == pytest.approx(TOTAL * cap - held_qty * price)
|
||||
assert (held_qty * price + amt) / TOTAL <= cap + 1e-9
|
||||
|
||||
|
||||
def test_position_cap_reached_skips(_empty_dt_state):
|
||||
cap = settings.MAX_POSITION_SIZE
|
||||
price = 10_000.0
|
||||
min_buy = effective_min_buy_amount(settings.MIN_BUY_AMOUNT_KRW, TOTAL, cap, 0.9)
|
||||
# 잔여 한도가 최소주문금액보다 1주만큼 작게 남도록 보유
|
||||
held_value = TOTAL * cap - (min_buy - price)
|
||||
holding = SimpleNamespace(quantity=int(held_value // price) + 1, strategy_type="swing")
|
||||
amt, reason = trade_executor._apply_position_cap_room_v2(
|
||||
invest_amount=TOTAL * cap, holding=holding, stock_code="005930", stock_name="t",
|
||||
current_price=price, total_assets=TOTAL, position_cap_ratio=cap,
|
||||
)
|
||||
assert reason == "position_cap_reached" and amt == 0.0
|
||||
|
||||
|
||||
def test_position_cap_counts_dt_inmemory_qty(_empty_dt_state):
|
||||
"""sync 전 DT 매수분(행에 미반영)도 센다 — max(행, DT)."""
|
||||
cap = settings.MAX_POSITION_SIZE
|
||||
price = 10_000.0
|
||||
dt_qty = int(TOTAL * cap / price) # DT 만으로 상한 도달
|
||||
_empty_dt_state.positions["005930"] = {"quantity": dt_qty}
|
||||
amt, reason = trade_executor._apply_position_cap_room_v2(
|
||||
invest_amount=TOTAL * cap, holding=None, stock_code="005930", stock_name="t",
|
||||
current_price=price, total_assets=TOTAL, position_cap_ratio=cap,
|
||||
)
|
||||
assert reason == "position_cap_reached"
|
||||
|
||||
|
||||
def test_position_cap_unverifiable_without_total_assets(_empty_dt_state):
|
||||
holding = SimpleNamespace(quantity=10, strategy_type="swing")
|
||||
amt, reason = trade_executor._apply_position_cap_room_v2(
|
||||
invest_amount=1_000_000.0, holding=holding, stock_code="005930", stock_name="t",
|
||||
current_price=10_000.0, total_assets=0.0, position_cap_ratio=settings.MAX_POSITION_SIZE,
|
||||
)
|
||||
assert reason == "position_cap_unverifiable" and amt == 0.0
|
||||
|
||||
|
||||
def test_position_cap_guard_is_outside_try():
|
||||
"""_execute_buy 에서 누적 상한 가드 호출이 try 블록 밖(함수 최상위 문장)에 있다."""
|
||||
tree = ast.parse(textwrap.dedent(inspect.getsource(trade_executor._execute_buy)))
|
||||
fn = tree.body[0]
|
||||
|
||||
def _calls_guard(node):
|
||||
return any(
|
||||
isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
||||
and n.func.id == "_apply_position_cap_room_v2"
|
||||
for n in ast.walk(node)
|
||||
)
|
||||
|
||||
for n in ast.walk(fn):
|
||||
if isinstance(n, ast.Try):
|
||||
assert not _calls_guard(n), "누적 상한 가드가 try 안에 있다 (§4 위반)"
|
||||
assert any(_calls_guard(s) for s in fn.body), "누적 상한 가드 호출이 최상위에 없다"
|
||||
|
||||
# 1건 절대 상한 클램프보다 뒤에 있어야 한다(패턴메모리 상향분까지 묶음)
|
||||
idx_guard = next(i for i, s in enumerate(fn.body) if _calls_guard(s))
|
||||
idx_clamp = next(
|
||||
i for i, s in enumerate(fn.body)
|
||||
if any(isinstance(n, ast.Name) and n.id == "clamp_by_ratios" for n in ast.walk(s))
|
||||
and any(isinstance(n, ast.Attribute) and n.attr == "MAX_POSITION_SIZE" for n in ast.walk(s))
|
||||
and not isinstance(s, ast.Try)
|
||||
)
|
||||
assert idx_clamp < idx_guard
|
||||
|
||||
|
||||
def _patch_buy_pipeline(monkeypatch, total, cash, recorded):
|
||||
monkeypatch.setattr(trade_executor, "check_opening_range_volatility", AsyncMock(return_value=False))
|
||||
monkeypatch.setattr(trade_executor, "check_surge_prevention", AsyncMock(return_value={}))
|
||||
monkeypatch.setattr(trade_executor, "check_trend_filter", AsyncMock(return_value={}))
|
||||
monkeypatch.setattr(trade_executor, "_get_cached_account", lambda: (cash, total))
|
||||
monkeypatch.setattr(trade_executor, "validate_correlation_filter", AsyncMock(return_value=(True, "ok")))
|
||||
monkeypatch.setattr(trade_executor, "calculate_cooldown_minutes", lambda *a, **k: 0)
|
||||
# Kelly 가 상한까지 꽉 채운 예산을 낸다고 가정
|
||||
monkeypatch.setattr(
|
||||
trade_executor, "_calculate_kelly_position",
|
||||
AsyncMock(return_value=total * settings.MAX_POSITION_SIZE),
|
||||
)
|
||||
import app.services.adaptive_strategy as _ad
|
||||
monkeypatch.setattr(_ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True))
|
||||
import app.services.stock_pattern_memory as _pm
|
||||
|
||||
async def _identity(stock_code, base_invest_amount, **kw):
|
||||
return base_invest_amount
|
||||
monkeypatch.setattr(_pm, "get_pattern_adjusted_invest_amount", _identity)
|
||||
|
||||
_orig_a2q = trade_executor.amount_to_quantity
|
||||
|
||||
def _rec(amount, price):
|
||||
recorded.append(amount)
|
||||
return _orig_a2q(amount, price)
|
||||
monkeypatch.setattr(trade_executor, "amount_to_quantity", _rec)
|
||||
|
||||
|
||||
async def _call_buy(holding, price):
|
||||
return await trade_executor._execute_buy(
|
||||
stock={"_regime_str": "bull"}, db=_FakeDB(), holding=holding,
|
||||
current_price=price, signals=["x"], score_val=5.0, is_domestic=True,
|
||||
exchange_code="KRX", stock_code="005930", stock_name="테스트",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_buy_add_on_is_capped_by_existing_holding(
|
||||
monkeypatch, _block_order_gate, _empty_dt_state
|
||||
):
|
||||
recorded = []
|
||||
_patch_buy_pipeline(monkeypatch, TOTAL, TOTAL, recorded)
|
||||
cap = settings.MAX_POSITION_SIZE
|
||||
price = 10_000.0
|
||||
held_qty = int(TOTAL * cap * 0.5 / price)
|
||||
holding = SimpleNamespace(quantity=held_qty, strategy_type="swing", stock_name="테스트",
|
||||
current_price=price, average_price=price)
|
||||
with pytest.raises(_Reached):
|
||||
await _call_buy(holding, price)
|
||||
assert recorded, "주수 환산까지 도달하지 않았다"
|
||||
assert recorded[-1] <= TOTAL * cap - held_qty * price + 1e-6
|
||||
assert (held_qty * price + recorded[-1]) / TOTAL <= cap + 1e-9
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_buy_skips_when_holding_at_cap(
|
||||
monkeypatch, _block_order_gate, _empty_dt_state
|
||||
):
|
||||
recorded = []
|
||||
_patch_buy_pipeline(monkeypatch, TOTAL, TOTAL, recorded)
|
||||
price = 10_000.0
|
||||
held_qty = int(TOTAL * settings.MAX_POSITION_SIZE / price)
|
||||
holding = SimpleNamespace(quantity=held_qty, strategy_type="swing", stock_name="테스트",
|
||||
current_price=price, average_price=price)
|
||||
res = await _call_buy(holding, price)
|
||||
assert res["action"] == "SKIP" and res["reason"] == "position_cap_reached"
|
||||
_block_order_gate.assert_not_called()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 3. 추천 경로 매도에서 external / manual / DT 보유 행 제외
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _holding(st):
|
||||
return SimpleNamespace(quantity=10, strategy_type=st, average_price=10_000.0,
|
||||
profit_loss_rate=0.0, stock_name="테스트")
|
||||
|
||||
|
||||
async def _call_sell(holding, stock=None, sell_pct=1.0):
|
||||
return await trade_executor._execute_sell(
|
||||
stock=stock if stock is not None else {"strategy_type": "swing"},
|
||||
db=_FakeDB(), holding=holding, current_price=9_000.0, signals=["ATR 손절"],
|
||||
is_domestic=True, exchange_code="KRX", stock_code="005930", stock_name="테스트",
|
||||
sell_pct=sell_pct,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("st,reason", [
|
||||
("external", "manual_judgment_holding"),
|
||||
("manual", "manual_judgment_holding"),
|
||||
("daytrading", "owned_by_daytrading"),
|
||||
("day_trade", "owned_by_daytrading"),
|
||||
("us_swing", "owned_by_other_strategy"),
|
||||
])
|
||||
async def test_sell_skips_non_swing_holdings(st, reason, _block_order_gate, _empty_dt_state):
|
||||
res = await _call_sell(_holding(st))
|
||||
assert res == {"action": "SKIP", "reason": reason, "stock": "테스트"}
|
||||
_block_order_gate.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("st", ["swing", None])
|
||||
async def test_sell_allows_swing_holdings(st, _block_order_gate, _empty_dt_state):
|
||||
"""스윙(또는 legacy NULL) 행은 가드를 통과해 주문 관문까지 간다."""
|
||||
with pytest.raises(_Reached):
|
||||
await _call_sell(_holding(st))
|
||||
|
||||
|
||||
def test_ownership_rule_owner_caller_allowed():
|
||||
"""호출자 태그가 소유 엔진과 같으면 허용(현재 그런 호출자는 없지만 계약 고정)."""
|
||||
f = trade_executor._sell_ownership_block_reason_v2
|
||||
assert f(_holding("daytrading"), {"strategy_type": "daytrading"}) is None
|
||||
assert f(_holding("us_swing"), {"strategy_type": "us_swing"}) is None
|
||||
# external/manual 은 호출자 태그와 무관하게 항상 차단
|
||||
assert f(_holding("manual"), {"strategy_type": "manual"}) == "manual_judgment_holding"
|
||||
assert f(_holding("external"), {"strategy_type": "external"}) == "manual_judgment_holding"
|
||||
|
||||
|
||||
def test_ownership_guard_is_outside_try_and_first():
|
||||
"""_execute_sell 의 소유권 가드는 try 밖, 주문 관문보다 앞에 있다."""
|
||||
tree = ast.parse(textwrap.dedent(inspect.getsource(trade_executor._execute_sell)))
|
||||
fn = tree.body[0]
|
||||
|
||||
def _calls(node, name):
|
||||
return any(
|
||||
isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == name
|
||||
for n in ast.walk(node)
|
||||
)
|
||||
|
||||
for n in ast.walk(fn):
|
||||
if isinstance(n, ast.Try):
|
||||
assert not _calls(n, "_sell_ownership_block_reason_v2")
|
||||
idx_guard = next(i for i, s in enumerate(fn.body) if _calls(s, "_sell_ownership_block_reason_v2"))
|
||||
idx_lock = next(i for i, s in enumerate(fn.body) if _calls(s, "_acquire_order_lock"))
|
||||
assert idx_guard < idx_lock
|
||||
|
||||
|
||||
def test_mdd_reduction_does_not_route_through_execute_sell():
|
||||
"""MDD 강제축소는 _execute_sell / execute_auto_trade 를 쓰지 않는다 (§8.1-11)."""
|
||||
from app.services import mdd_manager as _mdd
|
||||
src = inspect.getsource(_mdd)
|
||||
assert "_execute_sell" not in src
|
||||
assert "execute_auto_trade" not in src
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# H-1. 팔 수 없는 행에는 사지 않는다 (매수·매도 소유권 대칭)
|
||||
# M-1. 고아 daytrading 행 skip 은 warning
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _patch_buy_side_flow(monkeypatch, exit_signal=None):
|
||||
monkeypatch.setattr(trade_executor.mdd_manager, "can_open_new_position", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(trade_executor.loss_tracker, "is_banned_safe", AsyncMock(return_value=False))
|
||||
monkeypatch.setattr(settings, "ML_ENSEMBLE_ENABLED", False)
|
||||
monkeypatch.setattr(settings, "NEWS_ENABLED", False)
|
||||
monkeypatch.setattr(trade_executor, "check_exit_signal", lambda **kw: exit_signal)
|
||||
buy = AsyncMock(side_effect=_Reached("buy reached"))
|
||||
sell = AsyncMock(side_effect=_Reached("sell reached"))
|
||||
monkeypatch.setattr(trade_executor, "_execute_buy", buy)
|
||||
monkeypatch.setattr(trade_executor, "_execute_sell", sell)
|
||||
return buy, sell
|
||||
|
||||
|
||||
def _rule_buy_stock():
|
||||
return {
|
||||
"stock_code": "005930", "stock_name": "테스트", "current_price": 10_000.0,
|
||||
"signals": ["x"], "rule_id": 1, "strategy_type": "swing", "remaining_pct": 1.0,
|
||||
"rsi": 40.0, "bb": (12_000.0, 10_500.0, 9_000.0), "atr": 200.0,
|
||||
}
|
||||
|
||||
|
||||
def _flow_db(holding):
|
||||
db = _SellFlowDB(holding, any_sell_rows=[], non_dt_rows=[])
|
||||
db._seq = [[holding], [holding], []]
|
||||
return db
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("st,reason", [
|
||||
("manual", "manual_judgment_holding"),
|
||||
("daytrading", "owned_by_daytrading"),
|
||||
("day_trade", "owned_by_daytrading"),
|
||||
("us_swing", "owned_by_other_strategy"),
|
||||
])
|
||||
async def test_buy_blocked_on_unsellable_holding(monkeypatch, st, reason):
|
||||
buy, _ = _patch_buy_side_flow(monkeypatch)
|
||||
holding = SimpleNamespace(quantity=10, strategy_type=st, average_price=10_000.0,
|
||||
profit_loss_rate=0.0, stock_name="테스트")
|
||||
res = await trade_executor.execute_auto_trade(_rule_buy_stock(), _flow_db(holding))
|
||||
assert res == {"action": "SKIP", "reason": f"non_swing_holding:{reason}", "stock": "테스트"}
|
||||
buy.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("st", ["swing", None])
|
||||
async def test_buy_allowed_on_swing_holding(monkeypatch, st):
|
||||
buy, _ = _patch_buy_side_flow(monkeypatch)
|
||||
holding = SimpleNamespace(quantity=10, strategy_type=st, average_price=10_000.0,
|
||||
profit_loss_rate=0.0, stock_name="테스트")
|
||||
with pytest.raises(_Reached):
|
||||
await trade_executor.execute_auto_trade(_rule_buy_stock(), _flow_db(holding))
|
||||
buy.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_buy_block_does_not_stop_exit_judgment(monkeypatch):
|
||||
"""_guard 경유 — 매수만 막고 청산 판정은 _execute_sell 까지 도달한다(§8.1-11)."""
|
||||
_, sell = _patch_buy_side_flow(
|
||||
monkeypatch,
|
||||
exit_signal=SimpleNamespace(action="SELL_ALL", sell_pct=1.0, reason="test full exit"),
|
||||
)
|
||||
holding = SimpleNamespace(quantity=10, strategy_type="manual", average_price=10_000.0,
|
||||
profit_loss_rate=0.0, stock_name="테스트")
|
||||
with pytest.raises(_Reached):
|
||||
await trade_executor.execute_auto_trade(_rule_buy_stock(), _flow_db(holding))
|
||||
sell.assert_awaited()
|
||||
|
||||
|
||||
def test_buy_side_block_mirrors_sell_ownership_and_is_not_in_inner_try():
|
||||
tree = ast.parse(textwrap.dedent(inspect.getsource(trade_executor.execute_auto_trade)))
|
||||
fn = tree.body[0]
|
||||
outer = next(s for s in fn.body if isinstance(s, ast.Try))
|
||||
|
||||
def _calls(node):
|
||||
return any(
|
||||
isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
||||
and n.func.id == "_sell_ownership_block_reason_v2"
|
||||
for n in ast.walk(node)
|
||||
)
|
||||
|
||||
assert _calls(outer), "매수 측이 매도 소유권 판정을 쓰지 않는다"
|
||||
for n in ast.walk(outer):
|
||||
if isinstance(n, ast.Try) and n is not outer:
|
||||
assert not _calls(n), "소유권 매수 차단이 내부 try 안에 있다"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orphan_daytrading_row_sell_skip_logs_warning(
|
||||
_block_order_gate, _empty_dt_state, caplog
|
||||
):
|
||||
import logging
|
||||
with caplog.at_level(logging.INFO, logger=trade_executor.logger.name):
|
||||
res = await _call_sell(_holding("daytrading"))
|
||||
assert res["reason"] == "owned_by_daytrading"
|
||||
warns = [r for r in caplog.records if r.levelno == logging.WARNING and "수동 확인 필요" in r.getMessage()]
|
||||
assert warns, "고아 daytrading 행이 warning 으로 남지 않았다"
|
||||
assert "005930" in warns[0].getMessage() and "10주" in warns[0].getMessage()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_daytrading_row_sell_skip_stays_info(
|
||||
_block_order_gate, _empty_dt_state, caplog
|
||||
):
|
||||
import logging
|
||||
_empty_dt_state.positions["005930"] = {"quantity": 10}
|
||||
with caplog.at_level(logging.INFO, logger=trade_executor.logger.name):
|
||||
res = await _call_sell(_holding("daytrading"))
|
||||
assert res["reason"] == "owned_by_daytrading"
|
||||
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
|
||||
Reference in New Issue
Block a user