Merge branch 'dev' into main — 리스크 결함 일괄 수정 + 체결조회 경로 + 일지 보정 0013

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 08:55:49 +09:00
19 changed files with 2873 additions and 166 deletions

View File

@@ -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" 모델을 통과시킨다.

View 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}건 (백업 테이블은 유지)")

View File

@@ -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,

View File

@@ -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 강제 청산 시작 시간

View File

@@ -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% 분할 매도)

View File

@@ -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)

View File

@@ -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]:
"""진입일이 오늘이 아닌(전일 미청산) 데이트레이딩 포지션을 시장가 전량 청산.

View 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

View File

@@ -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:
"""특정 종목의 활성 포지션 정보 반환.

View File

@@ -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":

View File

@@ -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]:
"""주문 취소"""

View File

@@ -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)

View 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,
}

View File

@@ -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이 반복 발생하는 문제 방지.

View File

@@ -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)

View 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)

View 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"

View 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"

View 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]