feat(slot): dcy-021 포지션 사이징 — 보유 슬롯 증액·부분 매도 (BAT-031)
자산 영구 슬롯(dcy-020)의 수량 가변 전제 위에 보유 포지션 수량 조정 구현. 명령형 계약(전략이 cash+quantity 로 qty+price 계산), core 는 실행 substrate, 수량 정본 = 브로커 잔고 재동기화로 "주문 수량 vs 보유 수량" 충돌 해소(별도 필드 없음). - state.py: holding→pending_buy 전이 추가(증액) → 13 transitions (REQ-057) - service.py: PENDING_SELL 진입 qty=caller as-is(None=전량 sentinel, 값=부분 주문) (REQ-062) - intents.py: 증액 admission(holding 슬롯 매수=increase, M 캡 미적용), SellIntent.qty 부분 매도 (REQ-061) - execution.py: _balance_map; 매도 dispatch=브로커잔고(전량)/min(주문,잔고)(부분); 매수 체결 avg_price 가중평균+브로커 정본 resync; 매도 체결 잔고>0→holding/0→inactive; 증액 롤백 holding 복귀 (REQ-066/062) - messaging_handlers.py: sell payload qty 파싱(부분 매도) - baseline 4문서(data-model/runtime-flows/interface-contract/project-structure) 정합 (REQ-067) 검증: 단위 378 / 통합(PG+RabbitMQ) 422 passed, ruff clean. run-the-app 사이징 e2e: 매수 10@1000 → 증액 +5@1200 → 15@1066.67(가중평균) → 부분 매도 6 → 9@1066.67(cost basis) → 전량→inactive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+4
-2
@@ -4,7 +4,7 @@
|
|||||||
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
|
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
|
||||||
- Status: draft
|
- Status: draft
|
||||||
- Owner: project maintainers
|
- Owner: project maintainers
|
||||||
- Last Updated (KST): 2026-06-10 (BAT-030 — dcy-020 자산 중심 모델 마감)
|
- Last Updated (KST): 2026-06-10 (BAT-031 — dcy-021 포지션 사이징)
|
||||||
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery)
|
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery)
|
||||||
|
|
||||||
## Project Summary
|
## Project Summary
|
||||||
@@ -66,7 +66,8 @@
|
|||||||
- 전이 전 current status == from_state 여야 한다(레이스 감지).
|
- 전이 전 current status == from_state 여야 한다(레이스 감지).
|
||||||
- **INACTIVE 진입 시**(매수 롤백 / 매도 체결 / error 해소) 포지션·주문 점유 필드(qty/avg_price/limit_price/allocated_capital/order_id/strategy_id/broker_order_id/sell_strategy_id/forced_close_at/SL·TP)를 초기화하되 **자산 정체성(instance_key/portfolio_id/symbol/retired_at)은 보존**한다 — 영구 슬롯 재사용의 전제.
|
- **INACTIVE 진입 시**(매수 롤백 / 매도 체결 / error 해소) 포지션·주문 점유 필드(qty/avg_price/limit_price/allocated_capital/order_id/strategy_id/broker_order_id/sell_strategy_id/forced_close_at/SL·TP)를 초기화하되 **자산 정체성(instance_key/portfolio_id/symbol/retired_at)은 보존**한다 — 영구 슬롯 재사용의 전제.
|
||||||
- buy admission(REQ-059/061): slot_id 정본(portfolio_id dual-accept) → 대상 자산 슬롯 활성화. FCFS, M(`max_active_slots`) 캡, 무치환(over-cap 시 기존 보유 유지·신규만 거부). active = holding+pending_buy+pending_sell.
|
- buy admission(REQ-059/061): slot_id 정본(portfolio_id dual-accept) → 대상 자산 슬롯 활성화. FCFS, M(`max_active_slots`) 캡, 무치환(over-cap 시 기존 보유 유지·신규만 거부). active = holding+pending_buy+pending_sell.
|
||||||
- 청산 수량(REQ-062, D3): 집행 dispatch 의 매도 수량은 **브로커 잔고(hldg_qty) 정본** — slot.qty 미러 fallback 없음, 브로커 0 이면 skip+WARN(divergence 표면화).
|
- 청산 수량(REQ-062, D3): 집행 dispatch 의 매도 수량은 **브로커 잔고(hldg_qty) 정본** — 전량 매도(slot.qty None) = 브로커 잔고, **부분 매도(dcy-021)** = `min(주문 qty, 브로커 잔고)`. 브로커 0 이면 skip+WARN(divergence 표면화).
|
||||||
|
- 포지션 사이징(dcy-021/REQ-066): 보유 슬롯 **증액** = `holding→pending_buy→holding`(추가 매수); 체결 후 보유 qty = 브로커 잔고, `avg_price` = 가중평균 `(기존qty×기존평단 + 체결qty×체결가)/총qty`. **부분 감액** = `holding→pending_sell→holding`(잔량>0, cost basis 유지) / `→inactive`(전량). 부분 체결은 실제 체결분만 반영·ledger 기록, 미체결 잔량 미추적(재수렴은 전략). pending 동안 `slot.qty` = 주문 수량(전량 매도는 None sentinel), 체결/롤백 후 = 브로커 잔고. 증액은 active 자산 수(M 캡) 불변.
|
||||||
|
|
||||||
## Entity: `SlotTransitionRecord`
|
## Entity: `SlotTransitionRecord`
|
||||||
|
|
||||||
@@ -194,3 +195,4 @@
|
|||||||
- 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015).
|
- 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015).
|
||||||
- 2026-06-04 :: BAT-006 — ledger 통합 데이터 모델 반영(LedgerTrade DTO + `ledger_outbox` 테이블/`correlation_id` 멱등/outcome 어휘, FillStatus.execution_id/fee/tax 운반) (REQ-017~020).
|
- 2026-06-04 :: BAT-006 — ledger 통합 데이터 모델 반영(LedgerTrade DTO + `ledger_outbox` 테이블/`correlation_id` 멱등/outcome 어휘, FillStatus.execution_id/fee/tax 운반) (REQ-017~020).
|
||||||
- 2026-06-10 :: BAT-030 (dcy-020 마감) — 자산 중심 영구 슬롯 모델 확정: 5-state(inactive), idle/closed 폐합(마이그레이션 010), trading_date/entry_count DROP(011), instance_key/retired_at 정식화, slot_id intent 계약, M 캡 admission, 매도 수량 브로커 정본, daily_symbol_entries·일일 preallocation(dcy-014)·풀 admission(dcy-001/019) 폐기, DB 재초기화 도구(trex-core-reset — 데이터 보존 cutover 대체, REQ-064 CHG-20260610-03) (REQ-056~065).
|
- 2026-06-10 :: BAT-030 (dcy-020 마감) — 자산 중심 영구 슬롯 모델 확정: 5-state(inactive), idle/closed 폐합(마이그레이션 010), trading_date/entry_count DROP(011), instance_key/retired_at 정식화, slot_id intent 계약, M 캡 admission, 매도 수량 브로커 정본, daily_symbol_entries·일일 preallocation(dcy-014)·풀 admission(dcy-001/019) 폐기, DB 재초기화 도구(trex-core-reset — 데이터 보존 cutover 대체, REQ-064 CHG-20260610-03) (REQ-056~065).
|
||||||
|
- 2026-06-10 :: BAT-031 (dcy-021 포지션 사이징) — 보유 슬롯 수량 가변: 증액(holding→pending_buy, avg_price 가중평균)·부분 매도(min(주문,잔고), 잔량 holding/전량 inactive, cost basis 유지)·부분 체결 실제만 반영. 수량 정본=브로커 잔고 재동기화. (REQ-066/057/061/062)
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ resume_kill_switch 요청·응답 (REQ-046):
|
|||||||
|
|
||||||
### Compatibility Rules
|
### Compatibility Rules
|
||||||
|
|
||||||
- 5 상태 / 12 전이 계약(REQ-057)을 깨는 변경은 breaking 으로 취급한다.
|
- 5 상태 / 13 전이 계약(REQ-057, dcy-021 holding→pending_buy 증액 포함)을 깨는 변경은 breaking 으로 취급한다.
|
||||||
- 상태 변경 API 는 transactional_advisory_lock(인스턴스 스코프 lock id) 안에서만 수행하고 lock 보유 중 외부 I/O 를 금지한다.
|
- 상태 변경 API 는 transactional_advisory_lock(인스턴스 스코프 lock id) 안에서만 수행하고 lock 보유 중 외부 I/O 를 금지한다.
|
||||||
|
|
||||||
## Interface: `SlotRepository (Protocol)`
|
## Interface: `SlotRepository (Protocol)`
|
||||||
@@ -519,3 +519,4 @@ resume_kill_switch 요청·응답 (REQ-046):
|
|||||||
- 2026-06-05 :: BAT-007 — HTTP `/v1` API 인터페이스 제거(adapters/api + fastapi/uvicorn 의존 정리), RabbitMQ messaging 계약 추가(스냅샷 RPC `trex.snapshot.req`, intent RPC `trex.intent.req`, 이벤트 topic `trex.events`, 전략별 durable queue + TTL/max-length, DLQ, publisher confirms, ack-after-commit), `MessagingTransport` Protocol + `TransportHealth` 노출 (REQ-021~026).
|
- 2026-06-05 :: BAT-007 — HTTP `/v1` API 인터페이스 제거(adapters/api + fastapi/uvicorn 의존 정리), RabbitMQ messaging 계약 추가(스냅샷 RPC `trex.snapshot.req`, intent RPC `trex.intent.req`, 이벤트 topic `trex.events`, 전략별 durable queue + TTL/max-length, DLQ, publisher confirms, ack-after-commit), `MessagingTransport` Protocol + `TransportHealth` 노출 (REQ-021~026).
|
||||||
- 2026-06-06 :: BAT-008 — `trex.snapshot.req` envelope 계약 정의(`assets[]/view/available_slots/degraded/reason/message`, no price field), `trex.pause.req` 제어 RPC 추가, `PortfolioHubReadPort`/`SnapshotStore`/`PauseStore`/`SnapshotScheduler` 인터페이스, `BrokerPort.get_balance()` 확장, `trex-core` CLI 추가 (REQ-027~033; REQ-030 옵션 D 는 portfolio-hub#8 배포 후).
|
- 2026-06-06 :: BAT-008 — `trex.snapshot.req` envelope 계약 정의(`assets[]/view/available_slots/degraded/reason/message`, no price field), `trex.pause.req` 제어 RPC 추가, `PortfolioHubReadPort`/`SnapshotStore`/`PauseStore`/`SnapshotScheduler` 인터페이스, `BrokerPort.get_balance()` 확장, `trex-core` CLI 추가 (REQ-027~033; REQ-030 옵션 D 는 portfolio-hub#8 배포 후).
|
||||||
- 2026-06-10 :: BAT-030 (dcy-020 마감) — buy intent 계약 키 `slot_id` 정본(+`portfolio_id` dual-accept, `trading_date` 제거), 거부 어휘(`unknown_slot`/`already_active`/`capacity_full`/`slot_id_or_portfolio_required`), `slots[]` 5-state + `universe_detached`(trading_date 키 제거), 이벤트 payload trading_date 제거, `available_slots`=admission headroom, SlotService/SlotRepository 자산 중심 계약, 매도 체결 `pending_sell→inactive` (REQ-056~065).
|
- 2026-06-10 :: BAT-030 (dcy-020 마감) — buy intent 계약 키 `slot_id` 정본(+`portfolio_id` dual-accept, `trading_date` 제거), 거부 어휘(`unknown_slot`/`already_active`/`capacity_full`/`slot_id_or_portfolio_required`), `slots[]` 5-state + `universe_detached`(trading_date 키 제거), 이벤트 payload trading_date 제거, `available_slots`=admission headroom, SlotService/SlotRepository 자산 중심 계약, 매도 체결 `pending_sell→inactive` (REQ-056~065).
|
||||||
|
- 2026-06-10 :: BAT-031 (dcy-021 포지션 사이징) — 매수 intent 가 **holding 슬롯 대상이면 증액**(qty+price 추가 매수; M 캡 미적용); 매도 intent 에 **`qty`(부분 매도, 선택)** 추가 — 지정 시 `min(qty, 브로커잔고)` 부분 매도(잔량 holding), 미지정 시 전량(→inactive). 상태머신 13 전이(holding→pending_buy). avg_price 증액 가중평균·부분 매도 cost basis 유지. (REQ-066/057/061/062)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
|
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
|
||||||
- Status: draft
|
- Status: draft
|
||||||
- Owner: project maintainers
|
- Owner: project maintainers
|
||||||
- Last Updated (KST): 2026-06-10 (BAT-030 — dcy-020 자산 중심 모델 마감)
|
- Last Updated (KST): 2026-06-10 (BAT-031 — dcy-021 포지션 사이징)
|
||||||
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery, 3-phase lifecycle 밖의 bootstrap 산출물)
|
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery, 3-phase lifecycle 밖의 bootstrap 산출물)
|
||||||
|
|
||||||
## Project Summary
|
## Project Summary
|
||||||
@@ -153,3 +153,4 @@
|
|||||||
- 2026-06-07 :: BAT-012 — 비상 정지/kill-switch(`trex.pause.req`→`trex.control.req` 제어 채널 + action dispatch, `application/killswitch.py` KillSwitchService + `adapters/portfolio/killswitch_store.py` + `005_create_killswitch_exclusion.sql` day-scoped 제외, snapshot masking 에 killswitch_today 추가, `make_control_rpc_handler` + kill_switch/resume_kill_switch, daemon control consumer 전환) (REQ-043~047).
|
- 2026-06-07 :: BAT-012 — 비상 정지/kill-switch(`trex.pause.req`→`trex.control.req` 제어 채널 + action dispatch, `application/killswitch.py` KillSwitchService + `adapters/portfolio/killswitch_store.py` + `005_create_killswitch_exclusion.sql` day-scoped 제외, snapshot masking 에 killswitch_today 추가, `make_control_rpc_handler` + kill_switch/resume_kill_switch, daemon control consumer 전환) (REQ-043~047).
|
||||||
- 2026-06-07 :: BAT-013 — 다중 인스턴스 RabbitMQ 토폴로지(`topology.py` 동적 큐명 빌더 `intent/snapshot/control_queue(instance_key)` default 리터럴 하위호환 + `trex.direct`(topic)/`trex.broadcast`(fanout) exchange + `direct_routing_key`, `rabbitmq.declare_topology(instance_key)` per-instance 큐/바인딩+control dual, `config.DaemonInstanceConfig.from_toml`+`DaemonConfig.from_config`+`instance_key` 필드, daemon `--config` 필수 진입점 + instance_key 큐 구독, `.gitignore` 인스턴스 TOML + `config/instance.example.toml`) (REQ-048~051).
|
- 2026-06-07 :: BAT-013 — 다중 인스턴스 RabbitMQ 토폴로지(`topology.py` 동적 큐명 빌더 `intent/snapshot/control_queue(instance_key)` default 리터럴 하위호환 + `trex.direct`(topic)/`trex.broadcast`(fanout) exchange + `direct_routing_key`, `rabbitmq.declare_topology(instance_key)` per-instance 큐/바인딩+control dual, `config.DaemonInstanceConfig.from_toml`+`DaemonConfig.from_config`+`instance_key` 필드, daemon `--config` 필수 진입점 + instance_key 큐 구독, `.gitignore` 인스턴스 TOML + `config/instance.example.toml`) (REQ-048~051).
|
||||||
- 2026-06-10 :: BAT-030 (dcy-020 마감) — 자산 중심 전환: `runtime/reset.py` + `trex-core-reset` 콘솔 스크립트(데이터 보존 cutover 폐기 — wipe+재초기화로 대체, REQ-064 CHG-20260610-03), `application/preallocation_scheduler.py` 삭제(일일 preallocation 폐기), 마이그레이션 010(inactive CHECK)/011(trading_date·entry_count drop), repo 계약 instance 스코프 개편, `ops_checks.py` transition_log 기반 재작성, config 에서 `total_slots`/`daily_preallocation_time` 제거 (REQ-056~065).
|
- 2026-06-10 :: BAT-030 (dcy-020 마감) — 자산 중심 전환: `runtime/reset.py` + `trex-core-reset` 콘솔 스크립트(데이터 보존 cutover 폐기 — wipe+재초기화로 대체, REQ-064 CHG-20260610-03), `application/preallocation_scheduler.py` 삭제(일일 preallocation 폐기), 마이그레이션 010(inactive CHECK)/011(trading_date·entry_count drop), repo 계약 instance 스코프 개편, `ops_checks.py` transition_log 기반 재작성, config 에서 `total_slots`/`daily_preallocation_time` 제거 (REQ-056~065).
|
||||||
|
- 2026-06-10 :: BAT-031 (dcy-021 포지션 사이징) — state.py(holding→pending_buy 전이), service.py(pending_sell qty sentinel), intents.py(증액 admission·SellIntent.qty), execution.py(부분 매도 dispatch·매수 체결 가중평균·브로커 정본 resync·증액 롤백), messaging_handlers.py(sell qty). 신규 코드 모듈 없음(기존 확장). (REQ-066/067/057/061/062)
|
||||||
|
|||||||
+10
-2
@@ -4,7 +4,7 @@
|
|||||||
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
|
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
|
||||||
- Status: draft
|
- Status: draft
|
||||||
- Owner: project maintainers
|
- Owner: project maintainers
|
||||||
- Last Updated (KST): 2026-06-10 (BAT-030 — dcy-020 자산 중심 모델 마감)
|
- Last Updated (KST): 2026-06-10 (BAT-031 — dcy-021 포지션 사이징)
|
||||||
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002
|
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002
|
||||||
- Primary Runtime: daemon (HTTP API) + library (slot 도메인)
|
- Primary Runtime: daemon (HTTP API) + library (slot 도메인)
|
||||||
|
|
||||||
@@ -266,7 +266,7 @@
|
|||||||
## Flow: `slot 상태 전이 (transit_slot)` (BAT-030 자산 중심)
|
## Flow: `slot 상태 전이 (transit_slot)` (BAT-030 자산 중심)
|
||||||
|
|
||||||
1. 호출자가 `transit_slot(slot_id, from_state, to_state, reason, actor, **fields)` 를 호출한다.
|
1. 호출자가 `transit_slot(slot_id, from_state, to_state, reason, actor, **fields)` 를 호출한다.
|
||||||
2. (from_state, to_state) 가 전이 패밀리(6 base + *→error + error→{inactive,holding} = 12)에 속하는지 먼저 검증한다(아니면 SlotTransitionError).
|
2. (from_state, to_state) 가 전이 패밀리(7 base[dcy-021 holding→pending_buy 증액 포함] + *→error + error→{inactive,holding} = 13)에 속하는지 먼저 검증한다(아니면 SlotTransitionError).
|
||||||
3. 인스턴스 스코프 advisory lock(`slot_advisory_lock_id(instance_key)`) 획득 후 슬롯을 fetch 한다(없으면 SlotNotFoundError).
|
3. 인스턴스 스코프 advisory lock(`slot_advisory_lock_id(instance_key)`) 획득 후 슬롯을 fetch 한다(없으면 SlotNotFoundError).
|
||||||
4. 현재 status == from_state 인지 확인한다(아니면 SlotStateMismatchError — 레이스 감지).
|
4. 현재 status == from_state 인지 확인한다(아니면 SlotStateMismatchError — 레이스 감지).
|
||||||
5. to_state 의미에 맞춰 필드를 계산하고 슬롯 행을 update 한다 — **INACTIVE 진입 시 포지션·주문 점유 필드 초기화 + 자산 정체성(instance_key/portfolio_id/symbol/retired_at) 보존**.
|
5. to_state 의미에 맞춰 필드를 계산하고 슬롯 행을 update 한다 — **INACTIVE 진입 시 포지션·주문 점유 필드 초기화 + 자산 정체성(instance_key/portfolio_id/symbol/retired_at) 보존**.
|
||||||
@@ -302,3 +302,11 @@
|
|||||||
- 2026-06-05 :: BAT-007 — transport 전환(HTTP `/v1` API 제거, RabbitMQ 메시징 도입: 스냅샷/intent RPC + 이벤트 topic, sync+thread daemon, intent_log 멱등 ack-after-commit, publisher confirms + DLQ, per-strategy durable queue + TTL/max-length, broker degrade 분기) (REQ-021~026).
|
- 2026-06-05 :: BAT-007 — transport 전환(HTTP `/v1` API 제거, RabbitMQ 메시징 도입: 스냅샷/intent RPC + 이벤트 topic, sync+thread daemon, intent_log 멱등 ack-after-commit, publisher confirms + DLQ, per-strategy durable queue + TTL/max-length, broker degrade 분기) (REQ-021~026).
|
||||||
- 2026-06-06 :: BAT-008 — portfolio-hub 스냅샷 read 레이어 추가(`PortfolioHubReadPort` + hub_read/stub_read 어댑터, `SnapshotStore` in-memory + `snapshot_pause` Postgres, `SnapshotScheduler` 일일 refresh, `reconcile_positions` 정합 검증 + `BrokerPort.get_balance()` 확장, `trex.snapshot.req` envelope 응답 + degraded 신호, `trex.pause.req` 제어 RPC, `trex-core` CLI) (REQ-027~033; 옵션 D AC-2/AC-3 은 portfolio-hub#8 배포 후 별도 구현).
|
- 2026-06-06 :: BAT-008 — portfolio-hub 스냅샷 read 레이어 추가(`PortfolioHubReadPort` + hub_read/stub_read 어댑터, `SnapshotStore` in-memory + `snapshot_pause` Postgres, `SnapshotScheduler` 일일 refresh, `reconcile_positions` 정합 검증 + `BrokerPort.get_balance()` 확장, `trex.snapshot.req` envelope 응답 + degraded 신호, `trex.pause.req` 제어 RPC, `trex-core` CLI) (REQ-027~033; 옵션 D AC-2/AC-3 은 portfolio-hub#8 배포 후 별도 구현).
|
||||||
- 2026-06-10 :: BAT-030 (dcy-020 마감) — 자산 중심 전환 완료: 일일 preallocation 폐기(→universe reconcile), intent admission slot_id 정본 + M 캡, 집행 매도 수량 브로커 정본 + 인스턴스 스코프 dispatch, 5-state(inactive) 전이, DB 재초기화 도구(`trex-core-reset`, 데이터 보존 cutover 대체 — REQ-064 CHG-20260610-03) (REQ-056~065).
|
- 2026-06-10 :: BAT-030 (dcy-020 마감) — 자산 중심 전환 완료: 일일 preallocation 폐기(→universe reconcile), intent admission slot_id 정본 + M 캡, 집행 매도 수량 브로커 정본 + 인스턴스 스코프 dispatch, 5-state(inactive) 전이, DB 재초기화 도구(`trex-core-reset`, 데이터 보존 cutover 대체 — REQ-064 CHG-20260610-03) (REQ-056~065).
|
||||||
|
|
||||||
|
## Flow: `포지션 사이징` (BAT-031 / dcy-021, REQ-066)
|
||||||
|
보유(holding) 자산 슬롯의 수량을 명령형으로 조정한다. 전략이 스냅샷 cash+quantity 로 qty+price 를 계산해 보내고 core 는 실행만 한다(target 미저장).
|
||||||
|
|
||||||
|
1. **증액**: 보유 슬롯에 매수 intent(qty+price) → admission 이 `holding→pending_buy`(M 캡 미적용, active 수 불변) → dispatch 매수(qty=주문) → 체결 `pending_buy→holding`: 보유 qty=**브로커 잔고**, avg_price=가중평균(`(old_qty×old_avg+filled×fill)/new_total`, old_qty=브로커총−체결). 거부 시 브로커 잔고>0 → holding 복귀(기존 보유 유지), 0 → inactive.
|
||||||
|
2. **부분 감액**: 보유 슬롯에 매도 intent(qty+price) → `holding→pending_sell`(slot.qty=주문) → dispatch 매도 `min(주문, 브로커잔고)` → 체결 후 브로커 잔고>0 → `pending_sell→holding`(잔량, cost basis 유지) / 0 → `pending_sell→inactive`. 전량 매도(qty 미지정, safety-sell/kill-switch)는 slot.qty=None → dispatch=브로커 잔고.
|
||||||
|
3. **부분 체결**: 실제 체결분만 ledger(REQ-018/019)·슬롯 반영(브로커 정본 resync), 미체결 잔량은 core 추적 안 함 — 전략이 다음 스냅샷 기준 재발행.
|
||||||
|
- 2026-06-10 :: BAT-031 (dcy-021) — 포지션 사이징(증액·부분 감액) 흐름 추가, transit 13 전이.
|
||||||
|
|||||||
@@ -77,19 +77,75 @@ def test_sell_lifecycle_returns_to_inactive():
|
|||||||
assert slot.qty is None and slot.portfolio_id == "PF-1"
|
assert slot.qty is None and slot.portfolio_id == "PF-1"
|
||||||
|
|
||||||
|
|
||||||
def test_sell_qty_is_broker_balance_not_slot_mirror():
|
def test_full_sell_qty_is_broker_balance():
|
||||||
"""dcy-020/REQ-062 (D3): sell dispatch quantity = broker hldg_qty even when
|
"""dcy-020/REQ-062 (D3): full sell (slot.qty None) dispatches the broker
|
||||||
the slot mirror qty is stale."""
|
hldg_qty — independent of any stale mirror."""
|
||||||
broker = PaperBroker(fill_after_polls=0)
|
broker = PaperBroker(fill_after_polls=0)
|
||||||
repo, svc, intents, execution = _wire(broker)
|
repo, svc, intents, execution = _wire(broker)
|
||||||
sid = _buy(intents, qty=10).slot_id
|
sid = _buy(intents, qty=10).slot_id
|
||||||
execution.run_once() # holding, broker balance = 10
|
execution.run_once() # holding, broker balance = 10
|
||||||
# corrupt the mirror: slot says 7, broker says 10
|
svc.sell(sid, actor="t", reason="full") # qty=None -> sell all
|
||||||
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL,
|
execution.run_once()
|
||||||
reason="r", actor="t", qty=7.0)
|
|
||||||
execution.run_once() # dispatch sell with broker qty
|
|
||||||
sell_orders = [o for o in broker._orders.values() if o.side == "sell"]
|
sell_orders = [o for o in broker._orders.values() if o.side == "sell"]
|
||||||
assert len(sell_orders) == 1 and sell_orders[0].qty == 10
|
assert len(sell_orders) == 1 and sell_orders[0].qty == 10
|
||||||
|
assert repo.fetch_slot(sid).status is SlotState.INACTIVE # full exit
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_sell_qty_is_min_intent_broker():
|
||||||
|
"""dcy-021/REQ-062 (D4): partial sell dispatches min(intent qty, broker);
|
||||||
|
remainder stays holding (broker-authoritative qty), cost basis preserved."""
|
||||||
|
broker = PaperBroker(fill_after_polls=0)
|
||||||
|
repo, svc, intents, execution = _wire(broker)
|
||||||
|
sid = _buy(intents, qty=10).slot_id
|
||||||
|
execution.run_once() # holding 10 @ avg 1000
|
||||||
|
intents.submit_sell(SellIntent(intent_id="ps", slot_id=sid, qty=4))
|
||||||
|
execution.run_once() # dispatch sell 4 + fill -> holding remainder 6
|
||||||
|
sell_orders = [o for o in broker._orders.values() if o.side == "sell"]
|
||||||
|
assert len(sell_orders) == 1 and sell_orders[0].qty == 4
|
||||||
|
slot = repo.fetch_slot(sid)
|
||||||
|
assert slot.status is SlotState.HOLDING and slot.qty == 6
|
||||||
|
assert slot.avg_price == 1000.0 # cost basis unchanged
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_sell_caps_at_broker_balance():
|
||||||
|
"""intent qty above the broker balance => full sell (min caps at balance)."""
|
||||||
|
broker = PaperBroker(fill_after_polls=0)
|
||||||
|
repo, svc, intents, execution = _wire(broker)
|
||||||
|
sid = _buy(intents, qty=10).slot_id
|
||||||
|
execution.run_once() # holding 10
|
||||||
|
intents.submit_sell(SellIntent(intent_id="ps", slot_id=sid, qty=999))
|
||||||
|
execution.run_once()
|
||||||
|
sell_orders = [o for o in broker._orders.values() if o.side == "sell"]
|
||||||
|
assert sell_orders[0].qty == 10 # capped at balance
|
||||||
|
assert repo.fetch_slot(sid).status is SlotState.INACTIVE # nothing left
|
||||||
|
|
||||||
|
|
||||||
|
def test_increase_accumulates_qty_and_weights_avg_price():
|
||||||
|
"""dcy-021/REQ-066: buy on a holding slot increases the position; held qty =
|
||||||
|
broker balance, avg_price = weighted average."""
|
||||||
|
broker = PaperBroker(fill_after_polls=0)
|
||||||
|
repo, svc, intents, execution = _wire(broker)
|
||||||
|
sid = _buy(intents, pid="PF-1").slot_id # qty 10 @ 1000
|
||||||
|
execution.run_once() # holding 10 @ 1000
|
||||||
|
assert repo.fetch_slot(sid).qty == 10 and repo.fetch_slot(sid).avg_price == 1000.0
|
||||||
|
# increase: same asset, +5 @ 1200 (buy on the holding slot)
|
||||||
|
d = intents.submit_buy(BuyIntent(intent_id="inc", slot_id=sid, qty=5, entry_price=1200))
|
||||||
|
assert d.status == "admitted"
|
||||||
|
assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY
|
||||||
|
execution.run_once() # fill increase -> holding 15, weighted avg
|
||||||
|
slot = repo.fetch_slot(sid)
|
||||||
|
assert slot.status is SlotState.HOLDING and slot.qty == 15
|
||||||
|
assert slot.avg_price == (10 * 1000 + 5 * 1200) / 15 # 1066.67
|
||||||
|
|
||||||
|
|
||||||
|
def test_increase_does_not_consume_capacity():
|
||||||
|
"""dcy-021: increasing an already-active asset is not blocked by M cap."""
|
||||||
|
repo, svc, intents, execution = _wire(PaperBroker(fill_after_polls=0), max_active_slots=1)
|
||||||
|
sid = _buy(intents, iid="b0", pid="PF-0").slot_id
|
||||||
|
execution.run_once() # PF-0 holding (active=1=M)
|
||||||
|
# a NEW asset would be capacity_full, but an increase on PF-0 is allowed
|
||||||
|
d = intents.submit_buy(BuyIntent(intent_id="inc", slot_id=sid, qty=5, entry_price=1000))
|
||||||
|
assert d.status == "admitted"
|
||||||
|
|
||||||
|
|
||||||
def test_sell_skipped_when_broker_flat():
|
def test_sell_skipped_when_broker_flat():
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ def test_partial_fill_then_cancel_records_and_keeps_remainder():
|
|||||||
writer.run_once() # drain the buy ledger item
|
writer.run_once() # drain the buy ledger item
|
||||||
|
|
||||||
intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid))
|
intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid))
|
||||||
# 4 of 10 sold, then externally cancelled
|
# 4 of 10 sold, then externally cancelled. dcy-021/REQ-062: remainder qty is
|
||||||
|
# the BROKER balance (authority) after the partial fill, not a slot subtraction.
|
||||||
|
broker.balance = {"005930": 6}
|
||||||
broker.responder = lambda side: FillStatus(
|
broker.responder = lambda side: FillStatus(
|
||||||
state="partially_filled", filled_qty=4, avg_price=1100.0,
|
state="partially_filled", filled_qty=4, avg_price=1100.0,
|
||||||
execution_id="exec-partial", fee=0.5, tax=0.0, cancelled=True,
|
execution_id="exec-partial", fee=0.5, tax=0.0, cancelled=True,
|
||||||
@@ -111,7 +113,7 @@ def test_partial_fill_then_cancel_records_and_keeps_remainder():
|
|||||||
r = execution.run_once()
|
r = execution.run_once()
|
||||||
slot = repo.fetch_slot(sid)
|
slot = repo.fetch_slot(sid)
|
||||||
assert slot.status is SlotState.HOLDING # remainder stays held
|
assert slot.status is SlotState.HOLDING # remainder stays held
|
||||||
assert slot.qty == 6 # 10 - 4 sold
|
assert slot.qty == 6 # broker balance after partial
|
||||||
assert slot.avg_price == 1000.0 # cost basis preserved (not sell price)
|
assert slot.avg_price == 1000.0 # cost basis preserved (not sell price)
|
||||||
assert slot.broker_order_id is None
|
assert slot.broker_order_id is None
|
||||||
assert r.filled == 1 and r.ledger_enqueued == 1
|
assert r.filled == 1 and r.ledger_enqueued == 1
|
||||||
|
|||||||
@@ -300,3 +300,40 @@ class TestAdvisoryLockSerialization:
|
|||||||
)
|
)
|
||||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
assert elapsed_ms < 50.0, f"transition took {elapsed_ms:.2f} ms"
|
assert elapsed_ms < 50.0, f"transition took {elapsed_ms:.2f} ms"
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# dcy-021: position sizing — increase transition + INACTIVE/sell qty semantics
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
class TestPositionSizing:
|
||||||
|
def _holding(self, service, repo, qty=10.0, avg=1000.0):
|
||||||
|
slot = _seed(repo)[0]
|
||||||
|
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||||
|
reason="buy", actor="t", symbol="005930", qty=qty)
|
||||||
|
return service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||||
|
reason="filled", actor="t", avg_price=avg, qty=qty)
|
||||||
|
|
||||||
|
def test_holding_to_pending_buy_increase_preserves_avg(self, service, repo):
|
||||||
|
"""증액 pending: HOLDING→PENDING_BUY 가 허용되고 avg_price(가중평균용)가 보존된다."""
|
||||||
|
h = self._holding(service, repo)
|
||||||
|
inc = service.transit_slot(h.slot_id, SlotState.HOLDING, SlotState.PENDING_BUY,
|
||||||
|
reason="position_increase", actor="t", qty=5, limit_price=1200)
|
||||||
|
assert inc.status is SlotState.PENDING_BUY
|
||||||
|
assert inc.avg_price == 1000.0 # preserved for weighted-average at fill
|
||||||
|
assert inc.qty == 5 # order qty during pending
|
||||||
|
assert inc.limit_price == 1200
|
||||||
|
|
||||||
|
def test_full_sell_pending_clears_qty_to_none(self, service, repo):
|
||||||
|
"""전량 매도(qty 미지정) PENDING_SELL 진입 시 slot.qty = None('전량' sentinel)."""
|
||||||
|
h = self._holding(service, repo)
|
||||||
|
ps = service.transit_slot(h.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||||
|
reason="sell", actor="t") # qty omitted
|
||||||
|
assert ps.status is SlotState.PENDING_SELL and ps.qty is None
|
||||||
|
|
||||||
|
def test_partial_sell_pending_records_order_qty(self, service, repo):
|
||||||
|
"""부분 매도 PENDING_SELL 진입 시 slot.qty = 주문 수량."""
|
||||||
|
h = self._holding(service, repo)
|
||||||
|
ps = service.transit_slot(h.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||||
|
reason="sell", actor="t", qty=4, limit_price=1100)
|
||||||
|
assert ps.qty == 4 and ps.limit_price == 1100
|
||||||
|
assert ps.avg_price == 1000.0 # cost basis preserved
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ def test_slot_advisory_lock_id_is_instance_scoped():
|
|||||||
(SlotState.INACTIVE, SlotState.PENDING_BUY),
|
(SlotState.INACTIVE, SlotState.PENDING_BUY),
|
||||||
(SlotState.PENDING_BUY, SlotState.HOLDING),
|
(SlotState.PENDING_BUY, SlotState.HOLDING),
|
||||||
(SlotState.PENDING_BUY, SlotState.INACTIVE),
|
(SlotState.PENDING_BUY, SlotState.INACTIVE),
|
||||||
|
(SlotState.HOLDING, SlotState.PENDING_BUY), # dcy-021 increase
|
||||||
(SlotState.HOLDING, SlotState.PENDING_SELL),
|
(SlotState.HOLDING, SlotState.PENDING_SELL),
|
||||||
(SlotState.PENDING_SELL, SlotState.INACTIVE),
|
(SlotState.PENDING_SELL, SlotState.INACTIVE),
|
||||||
(SlotState.PENDING_SELL, SlotState.HOLDING),
|
(SlotState.PENDING_SELL, SlotState.HOLDING),
|
||||||
@@ -83,7 +84,6 @@ def test_allowed_transition(from_state: SlotState, to_state: SlotState):
|
|||||||
(SlotState.INACTIVE, SlotState.HOLDING),
|
(SlotState.INACTIVE, SlotState.HOLDING),
|
||||||
(SlotState.INACTIVE, SlotState.PENDING_SELL),
|
(SlotState.INACTIVE, SlotState.PENDING_SELL),
|
||||||
(SlotState.HOLDING, SlotState.INACTIVE),
|
(SlotState.HOLDING, SlotState.INACTIVE),
|
||||||
(SlotState.HOLDING, SlotState.PENDING_BUY),
|
|
||||||
(SlotState.PENDING_BUY, SlotState.PENDING_SELL),
|
(SlotState.PENDING_BUY, SlotState.PENDING_SELL),
|
||||||
(SlotState.PENDING_SELL, SlotState.PENDING_BUY),
|
(SlotState.PENDING_SELL, SlotState.PENDING_BUY),
|
||||||
# error has restricted exits — only inactive and holding
|
# error has restricted exits — only inactive and holding
|
||||||
@@ -103,5 +103,5 @@ def test_self_loops_are_not_allowed():
|
|||||||
|
|
||||||
|
|
||||||
def test_allowed_transitions_set_size():
|
def test_allowed_transitions_set_size():
|
||||||
"""6 base + 4 to-error + 2 operator-resolve = 12."""
|
"""7 base (incl. dcy-021 holding→pending_buy) + 4 to-error + 2 operator-resolve = 13."""
|
||||||
assert len(ALLOWED_TRANSITIONS) == 12
|
assert len(ALLOWED_TRANSITIONS) == 13
|
||||||
|
|||||||
@@ -114,10 +114,12 @@ class ExecutionService:
|
|||||||
# surfaces via reconcile_core_holdings (REQ-062 A). No mirror
|
# surfaces via reconcile_core_holdings (REQ-062 A). No mirror
|
||||||
# fallback by design.
|
# fallback by design.
|
||||||
if sell_balance is None:
|
if sell_balance is None:
|
||||||
sell_balance = {
|
sell_balance = self._balance_map()
|
||||||
b.symbol: int(b.hldg_qty) for b in self._broker.get_balance()
|
bal = sell_balance.get(slot.symbol or "", 0)
|
||||||
}
|
# dcy-021 / REQ-062 (D4): full sell (slot.qty is None) => broker
|
||||||
qty = sell_balance.get(slot.symbol or "", 0)
|
# balance; partial sell => min(order qty, broker balance). Broker
|
||||||
|
# flat -> skip + WARN (divergence surfaces via reconcile).
|
||||||
|
qty = bal if slot.qty is None else min(int(slot.qty), bal)
|
||||||
if qty <= 0:
|
if qty <= 0:
|
||||||
r.sell_skipped_no_balance += 1
|
r.sell_skipped_no_balance += 1
|
||||||
_log.warning(
|
_log.warning(
|
||||||
@@ -172,21 +174,47 @@ class ExecutionService:
|
|||||||
self._check_stale_pending(slot, now, r)
|
self._check_stale_pending(slot, now, r)
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
|
def _balance_map(self) -> dict:
|
||||||
|
"""Current broker balance as {symbol: hldg_qty} (REQ-062 ground truth)."""
|
||||||
|
return {b.symbol: int(b.hldg_qty) for b in self._broker.get_balance()}
|
||||||
|
|
||||||
def _on_filled(self, slot, fs, side: str, r: ExecutionResult) -> None:
|
def _on_filled(self, slot, fs, side: str, r: ExecutionResult) -> None:
|
||||||
broker_order_id = slot.broker_order_id # captured BEFORE clear_dispatch
|
broker_order_id = slot.broker_order_id # captured BEFORE clear_dispatch
|
||||||
try:
|
try:
|
||||||
if slot.status is SlotState.PENDING_BUY:
|
if slot.status is SlotState.PENDING_BUY:
|
||||||
|
# dcy-021/REQ-066: held qty = broker balance (authority, includes
|
||||||
|
# the just-filled increase); avg_price = weighted average. For an
|
||||||
|
# initial buy slot.avg_price is None (=0) so new_avg = fill price.
|
||||||
|
total = self._balance_map().get(slot.symbol or "", 0)
|
||||||
|
filled = float(fs.filled_qty)
|
||||||
|
new_qty = float(total) if total > 0 else filled
|
||||||
|
old_qty = max(0.0, new_qty - filled)
|
||||||
|
old_avg = float(slot.avg_price) if slot.avg_price else 0.0
|
||||||
|
fill_price = float(fs.avg_price) if fs.avg_price is not None else old_avg
|
||||||
|
new_avg = (
|
||||||
|
(old_qty * old_avg + filled * fill_price) / new_qty
|
||||||
|
if new_qty > 0 else fill_price
|
||||||
|
)
|
||||||
updated = self._svc.transit_slot(
|
updated = self._svc.transit_slot(
|
||||||
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||||
reason="buy_filled", actor="execution_loop",
|
reason="buy_filled", actor="execution_loop",
|
||||||
avg_price=fs.avg_price, qty=float(fs.filled_qty) or slot.qty,
|
avg_price=new_avg, qty=new_qty,
|
||||||
)
|
)
|
||||||
event_type = "slot_filled_buy"
|
event_type = "slot_filled_buy"
|
||||||
else: # PENDING_SELL — asset slot returns to inactive (dcy-020/REQ-057)
|
else: # PENDING_SELL — dcy-021/REQ-066: remainder>0 stays holding,
|
||||||
updated = self._svc.transit_slot(
|
# full exit -> inactive. Held qty = broker balance.
|
||||||
slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
total = self._balance_map().get(slot.symbol or "", 0)
|
||||||
reason="sell_filled", actor="execution_loop",
|
if total > 0:
|
||||||
)
|
updated = self._svc.transit_slot(
|
||||||
|
slot.slot_id, SlotState.PENDING_SELL, SlotState.HOLDING,
|
||||||
|
reason="sell_filled_partial", actor="execution_loop",
|
||||||
|
qty=float(total), # avg_price (cost basis) preserved
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
updated = self._svc.transit_slot(
|
||||||
|
slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
||||||
|
reason="sell_filled", actor="execution_loop",
|
||||||
|
)
|
||||||
event_type = "slot_filled_sell"
|
event_type = "slot_filled_sell"
|
||||||
except SlotStateMismatchError:
|
except SlotStateMismatchError:
|
||||||
return # slot moved concurrently; next tick reconciles
|
return # slot moved concurrently; next tick reconciles
|
||||||
@@ -211,20 +239,36 @@ class ExecutionService:
|
|||||||
filled = float(fs.filled_qty)
|
filled = float(fs.filled_qty)
|
||||||
try:
|
try:
|
||||||
if slot.status is SlotState.PENDING_BUY:
|
if slot.status is SlotState.PENDING_BUY:
|
||||||
# Only the filled portion is held; avg_price = fill price.
|
# dcy-021: held qty = broker balance; avg_price weighted average
|
||||||
|
# (consistent with _on_filled). Only the filled portion recorded.
|
||||||
|
total = self._balance_map().get(slot.symbol or "", 0)
|
||||||
|
new_qty = float(total) if total > 0 else filled
|
||||||
|
old_qty = max(0.0, new_qty - filled)
|
||||||
|
old_avg = float(slot.avg_price) if slot.avg_price else 0.0
|
||||||
|
fill_price = float(fs.avg_price) if fs.avg_price is not None else old_avg
|
||||||
|
new_avg = (
|
||||||
|
(old_qty * old_avg + filled * fill_price) / new_qty
|
||||||
|
if new_qty > 0 else fill_price
|
||||||
|
)
|
||||||
updated = self._svc.transit_slot(
|
updated = self._svc.transit_slot(
|
||||||
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||||
reason="buy_partial_cancel", actor="execution_loop",
|
reason="buy_partial_cancel", actor="execution_loop",
|
||||||
avg_price=fs.avg_price, qty=filled,
|
avg_price=new_avg, qty=new_qty,
|
||||||
)
|
)
|
||||||
event_type = "slot_filled_buy"
|
event_type = "slot_filled_buy"
|
||||||
else: # PENDING_SELL: remainder stays held, cost basis unchanged.
|
else: # PENDING_SELL: remainder = broker balance, cost basis unchanged.
|
||||||
remaining = max(0.0, float(slot.qty or 0) - filled)
|
total = self._balance_map().get(slot.symbol or "", 0)
|
||||||
updated = self._svc.transit_slot(
|
if total > 0:
|
||||||
slot.slot_id, SlotState.PENDING_SELL, SlotState.HOLDING,
|
updated = self._svc.transit_slot(
|
||||||
reason="sell_partial_cancel", actor="execution_loop",
|
slot.slot_id, SlotState.PENDING_SELL, SlotState.HOLDING,
|
||||||
qty=remaining,
|
reason="sell_partial_cancel", actor="execution_loop",
|
||||||
)
|
qty=float(total),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
updated = self._svc.transit_slot(
|
||||||
|
slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
||||||
|
reason="sell_partial_cancel", actor="execution_loop",
|
||||||
|
)
|
||||||
event_type = "slot_filled_sell"
|
event_type = "slot_filled_sell"
|
||||||
except SlotStateMismatchError:
|
except SlotStateMismatchError:
|
||||||
return # slot moved concurrently; next tick reconciles
|
return # slot moved concurrently; next tick reconciles
|
||||||
@@ -297,16 +341,27 @@ class ExecutionService:
|
|||||||
r.ledger_enqueued += 1
|
r.ledger_enqueued += 1
|
||||||
|
|
||||||
def _rollback(self, slot, *, reason: str) -> None:
|
def _rollback(self, slot, *, reason: str) -> None:
|
||||||
|
total = self._balance_map().get(slot.symbol or "", 0)
|
||||||
try:
|
try:
|
||||||
if slot.status is SlotState.PENDING_BUY:
|
if slot.status is SlotState.PENDING_BUY:
|
||||||
self._svc.transit_slot(
|
if total > 0:
|
||||||
slot.slot_id, SlotState.PENDING_BUY, SlotState.INACTIVE,
|
# dcy-021: increase rejected — the prior holding is intact
|
||||||
reason=reason, actor="execution_loop",
|
# (broker still holds), so return to holding, not inactive.
|
||||||
) # inactive reset clears broker_order_id in the domain
|
self._svc.transit_slot(
|
||||||
else: # PENDING_SELL rejected -> back to holding, clear order for re-dispatch
|
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||||
|
reason=reason, actor="execution_loop", qty=float(total),
|
||||||
|
)
|
||||||
|
self._repo.clear_dispatch(slot.slot_id)
|
||||||
|
else: # initial buy rejected -> inactive (clears broker_order_id)
|
||||||
|
self._svc.transit_slot(
|
||||||
|
slot.slot_id, SlotState.PENDING_BUY, SlotState.INACTIVE,
|
||||||
|
reason=reason, actor="execution_loop",
|
||||||
|
)
|
||||||
|
else: # PENDING_SELL rejected -> back to holding (broker authoritative qty)
|
||||||
self._svc.transit_slot(
|
self._svc.transit_slot(
|
||||||
slot.slot_id, SlotState.PENDING_SELL, SlotState.HOLDING,
|
slot.slot_id, SlotState.PENDING_SELL, SlotState.HOLDING,
|
||||||
reason=reason, actor="execution_loop",
|
reason=reason, actor="execution_loop",
|
||||||
|
qty=float(total) if total > 0 else None,
|
||||||
)
|
)
|
||||||
self._repo.clear_dispatch(slot.slot_id)
|
self._repo.clear_dispatch(slot.slot_id)
|
||||||
except SlotStateMismatchError:
|
except SlotStateMismatchError:
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ class SellIntent:
|
|||||||
# dcy-012 / REQ-007: optional sell limit price. None => market sell
|
# dcy-012 / REQ-007: optional sell limit price. None => market sell
|
||||||
# (the path used by safety-sell / kill-switch). Set => limit sell at price.
|
# (the path used by safety-sell / kill-switch). Set => limit sell at price.
|
||||||
limit_price: Optional[float] = None
|
limit_price: Optional[float] = None
|
||||||
|
# dcy-021 / REQ-062: optional partial-sell quantity. None => sell the whole
|
||||||
|
# position (broker balance); set => partial sell of min(qty, broker balance).
|
||||||
|
qty: Optional[float] = None
|
||||||
extra: dict[str, Any] = field(default_factory=dict)
|
extra: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@@ -140,6 +143,7 @@ class IntentService:
|
|||||||
"slot_id": str(intent.slot_id),
|
"slot_id": str(intent.slot_id),
|
||||||
"reason": intent.reason,
|
"reason": intent.reason,
|
||||||
"sell_strategy_id": intent.sell_strategy_id,
|
"sell_strategy_id": intent.sell_strategy_id,
|
||||||
|
"qty": intent.qty,
|
||||||
}
|
}
|
||||||
prior = self._dedup(intent.intent_id, "sell", payload)
|
prior = self._dedup(intent.intent_id, "sell", payload)
|
||||||
if prior is not None:
|
if prior is not None:
|
||||||
@@ -163,15 +167,17 @@ class IntentService:
|
|||||||
return IntentDecision(intent_id, rec.status, rec.slot_id, rec.reason)
|
return IntentDecision(intent_id, rec.status, rec.slot_id, rec.reason)
|
||||||
|
|
||||||
def _admit_buy(self, intent: BuyIntent) -> IntentDecision:
|
def _admit_buy(self, intent: BuyIntent) -> IntentDecision:
|
||||||
"""dcy-020 / REQ-059·REQ-061: activate the asset's permanent slot.
|
"""dcy-020 / REQ-059·REQ-061 + dcy-021/REQ-066: activate or size up the
|
||||||
|
asset's permanent slot.
|
||||||
|
|
||||||
Contract key is slot_id (REQ-061); portfolio_id is accepted as a legacy
|
Contract key is slot_id (REQ-061); portfolio_id is accepted as a legacy
|
||||||
dual-accept mapping. Symbol is resolved from the slot's portfolio_id.
|
dual-accept mapping. Symbol is resolved from the slot's portfolio_id.
|
||||||
FCFS + no eviction: already-active -> already_active; at M ->
|
- INACTIVE slot -> initial buy (FCFS, M cap applies, no eviction).
|
||||||
capacity_full (existing holdings untouched, incl. over-cap). Single
|
- HOLDING slot -> position-sizing INCREASE (dcy-021): adds to the held
|
||||||
intent thread + instance-scoped advisory lock serialize admissions
|
position; M cap does NOT apply (active asset count unchanged).
|
||||||
(dcy-020 OQ-1)."""
|
- PENDING_* slot -> in-flight conflict (already_active).
|
||||||
_ACTIVE = (SlotState.PENDING_BUY, SlotState.HOLDING, SlotState.PENDING_SELL)
|
Single intent thread + instance-scoped advisory lock serialize
|
||||||
|
admissions (dcy-020 OQ-1)."""
|
||||||
|
|
||||||
def _resolve(sl):
|
def _resolve(sl):
|
||||||
pid = sl.portfolio_id
|
pid = sl.portfolio_id
|
||||||
@@ -191,12 +197,20 @@ class IntentService:
|
|||||||
return IntentDecision(intent.intent_id, "rejected", None, "slot_id_or_portfolio_required")
|
return IntentDecision(intent.intent_id, "rejected", None, "slot_id_or_portfolio_required")
|
||||||
if slot is None:
|
if slot is None:
|
||||||
return IntentDecision(intent.intent_id, "rejected", None, miss_reason)
|
return IntentDecision(intent.intent_id, "rejected", None, miss_reason)
|
||||||
if slot.status in _ACTIVE:
|
# dcy-021: a buy on a HOLDING slot is a position-sizing increase.
|
||||||
|
is_increase = slot.status is SlotState.HOLDING
|
||||||
|
if slot.status in (SlotState.PENDING_BUY, SlotState.PENDING_SELL):
|
||||||
return IntentDecision(intent.intent_id, "rejected", None, "already_active")
|
return IntentDecision(intent.intent_id, "rejected", None, "already_active")
|
||||||
|
if slot.status not in (SlotState.INACTIVE, SlotState.HOLDING):
|
||||||
|
return IntentDecision(
|
||||||
|
intent.intent_id, "rejected", None, f"slot_not_buyable:{slot.status.value}"
|
||||||
|
)
|
||||||
symbol = _resolve(slot)
|
symbol = _resolve(slot)
|
||||||
if not symbol:
|
if not symbol:
|
||||||
return IntentDecision(intent.intent_id, "rejected", None, "unknown_portfolio")
|
return IntentDecision(intent.intent_id, "rejected", None, "unknown_portfolio")
|
||||||
if (self._max_active_slots is not None
|
# M cap applies only to NEW activations (initial buy). An increase on
|
||||||
|
# an already-active asset does not change the active asset count.
|
||||||
|
if (not is_increase and self._max_active_slots is not None
|
||||||
and self._repo.count_active(self._instance_key) >= self._max_active_slots):
|
and self._repo.count_active(self._instance_key) >= self._max_active_slots):
|
||||||
return IntentDecision(intent.intent_id, "rejected", None, "capacity_full")
|
return IntentDecision(intent.intent_id, "rejected", None, "capacity_full")
|
||||||
try:
|
try:
|
||||||
@@ -204,7 +218,7 @@ class IntentService:
|
|||||||
slot.slot_id,
|
slot.slot_id,
|
||||||
slot.status,
|
slot.status,
|
||||||
SlotState.PENDING_BUY,
|
SlotState.PENDING_BUY,
|
||||||
reason="buy_intent",
|
reason="position_increase" if is_increase else "buy_intent",
|
||||||
actor="intent_api",
|
actor="intent_api",
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
qty=intent.qty,
|
qty=intent.qty,
|
||||||
@@ -243,6 +257,10 @@ class IntentService:
|
|||||||
sell_strategy_id=intent.sell_strategy_id,
|
sell_strategy_id=intent.sell_strategy_id,
|
||||||
# dcy-012 / REQ-007: optional sell limit (None => market sell).
|
# dcy-012 / REQ-007: optional sell limit (None => market sell).
|
||||||
limit_price=intent.limit_price,
|
limit_price=intent.limit_price,
|
||||||
|
# dcy-021 / REQ-062: partial-sell order qty (None => sell all);
|
||||||
|
# dispatch caps at the broker balance. Entering PENDING_SELL takes
|
||||||
|
# this qty as-is (None = "sell all" sentinel).
|
||||||
|
qty=intent.qty,
|
||||||
extra={"intent_id": intent.intent_id},
|
extra={"intent_id": intent.intent_id},
|
||||||
)
|
)
|
||||||
except SlotStateMismatchError:
|
except SlotStateMismatchError:
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ def make_intent_rpc_handler(
|
|||||||
sell_strategy_id=_opt_str(body.get("sell_strategy_id")),
|
sell_strategy_id=_opt_str(body.get("sell_strategy_id")),
|
||||||
# dcy-012 / REQ-007: optional sell limit price; absent => market sell.
|
# dcy-012 / REQ-007: optional sell limit price; absent => market sell.
|
||||||
limit_price=_opt_float(body.get("limit_price")),
|
limit_price=_opt_float(body.get("limit_price")),
|
||||||
|
# dcy-021 / REQ-062: optional partial-sell qty; absent => sell all.
|
||||||
|
qty=_opt_float(body.get("qty")),
|
||||||
)
|
)
|
||||||
d = intent_service.submit_sell(sell)
|
d = intent_service.submit_sell(sell)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -192,12 +192,22 @@ class SlotService:
|
|||||||
else: # HOLDING / ERROR — pending order limit no longer applies.
|
else: # HOLDING / ERROR — pending order limit no longer applies.
|
||||||
new_limit_price = None
|
new_limit_price = None
|
||||||
|
|
||||||
|
# qty semantics during a pending state = the ORDER quantity (dcy-021 D1/D4).
|
||||||
|
# Entering PENDING_SELL takes the caller qty AS-IS so a full sell
|
||||||
|
# (qty=None) records a "sell all" sentinel rather than the stale held
|
||||||
|
# mirror; a partial-sell intent records its order qty. Other states keep
|
||||||
|
# the caller-override-else-preserve rule.
|
||||||
|
if to_state is SlotState.PENDING_SELL:
|
||||||
|
new_qty = qty
|
||||||
|
else:
|
||||||
|
new_qty = qty if qty is not None else slot.qty
|
||||||
|
|
||||||
# Generic update: caller-supplied fields override; None means "keep".
|
# Generic update: caller-supplied fields override; None means "keep".
|
||||||
return replace(
|
return replace(
|
||||||
slot,
|
slot,
|
||||||
status=to_state,
|
status=to_state,
|
||||||
symbol=symbol if symbol is not None else slot.symbol,
|
symbol=symbol if symbol is not None else slot.symbol,
|
||||||
qty=qty if qty is not None else slot.qty,
|
qty=new_qty,
|
||||||
avg_price=avg_price if avg_price is not None else slot.avg_price,
|
avg_price=avg_price if avg_price is not None else slot.avg_price,
|
||||||
limit_price=new_limit_price,
|
limit_price=new_limit_price,
|
||||||
allocated_capital=(
|
allocated_capital=(
|
||||||
|
|||||||
@@ -37,17 +37,20 @@ class SlotState(str, Enum):
|
|||||||
ERROR = "error"
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
# Canonical transitions (dcy-020/REQ-057). There is no inactive self-loop and
|
# Canonical transitions (dcy-020/REQ-057; dcy-021/REQ-057 CHG-03). There is no
|
||||||
# no closed->idle recovery family — a permanent slot simply returns to
|
# inactive self-loop and no closed->idle recovery family — a permanent slot
|
||||||
# `inactive` and may activate again via inactive->pending_buy.
|
# simply returns to `inactive` and may activate again via inactive->pending_buy.
|
||||||
|
# dcy-021 adds holding->pending_buy for position-sizing increase (the slot stays
|
||||||
|
# a held position; the buy adds to it, returning to holding on fill).
|
||||||
_BASE_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = frozenset(
|
_BASE_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = frozenset(
|
||||||
{
|
{
|
||||||
(SlotState.INACTIVE, SlotState.PENDING_BUY), # 1. buy decision (activate)
|
(SlotState.INACTIVE, SlotState.PENDING_BUY), # 1. buy decision (activate)
|
||||||
(SlotState.PENDING_BUY, SlotState.HOLDING), # 2. buy filled
|
(SlotState.PENDING_BUY, SlotState.HOLDING), # 2. buy filled
|
||||||
(SlotState.PENDING_BUY, SlotState.INACTIVE), # 3. buy rejected/rollback
|
(SlotState.PENDING_BUY, SlotState.INACTIVE), # 3. buy rejected/rollback
|
||||||
|
(SlotState.HOLDING, SlotState.PENDING_BUY), # 7. position-sizing increase (dcy-021)
|
||||||
(SlotState.HOLDING, SlotState.PENDING_SELL), # 4. sell decision
|
(SlotState.HOLDING, SlotState.PENDING_SELL), # 4. sell decision
|
||||||
(SlotState.PENDING_SELL, SlotState.INACTIVE), # 5. sell filled (deactivate)
|
(SlotState.PENDING_SELL, SlotState.INACTIVE), # 5. sell filled (deactivate)
|
||||||
(SlotState.PENDING_SELL, SlotState.HOLDING), # 6. sell rejected/rollback
|
(SlotState.PENDING_SELL, SlotState.HOLDING), # 6. sell rejected/rollback (or partial-sell remainder)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user