fix: DEBT-018 - make outbox writes co-transactional with entity writes
TradeExecution: TradeOutboxPublisher.PublishAsync replaced with UpdateAndPublishAsync, which opens one connection/transaction, updates trade status and writes the outbox message on it, then commits once. Used by the 3 call sites that publish an event after a status update (SubmitTradeHandler, PollTradeStatusHandler's FullyFilled branch, ConfirmSettlementHandler). PortfolioReconciliation: ReconcileTradeHandler now injects the request-scoped IDbConnection (the same instance ReconciliationSql already uses) instead of opening a second separate connection via IDbConnectionFactory, begins one transaction shared by ReconciliationEngine.ReconcileTradeAsync and the outbox writes, and commits once. Required adding IDbTransaction-aware overloads of GetHoldingAsync/UpsertHoldingAsync/InsertReconciliationLogAsync - the read needed one too, since Npgsql throws if a command on a connection with a pending transaction doesn't have it attached. dotnet build KArtSell.sln -c Release: clean. Integration tests: 17 pure-logic tests pass, 13 DB-backed tests fail with the pre-existing connection-refused error (no SSH tunnel in this environment) - the transactional changes themselves are not yet verified against a live database. Also corrected two WBS_PROGRESS_TRACKER.csv rows that inaccurately said frontend UI work was "in progress" when the background agents building VS-28/VS-29 UI had actually failed (hit the session's spend limit) before committing anything. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -50,7 +50,7 @@
|
||||
| DEBT-008 | Namespace consistency | Medium (2) | Low (1) | Accepted | All projects use RootNamespace=KArtSell.Aegis; AssemblyName retained per-project for DLL clarity. Trade-off accepted: DLL clarity > namespace alignment. No action. | @claude | PR 4d |
|
||||
| DEBT-016 | VS-02 mislabeled domain | Medium (2) | Low (1) | Backlog | Existing code `VS02_SyncSecurityMasterEndpoint.cs`, `VS02_SecurityMasterJobs.cs`, `VS02_SecurityMasterPolicy.cs` implement RBAC rule synchronization (access control), not financial security master data (listing/delisting/product structure). Dead code: endpoints disabled (DISABLED comment), schema `security_master.rules` table never migrated, never deployed. Correct domain documented in `docs/CURRENT/SLICE_SPECS/VS-02-SLICE_SPEC.md` (financial PIT). Removal decision deferred pending architect review (PR recommended). | @claude | docs/CURRENT/SLICE_SPECS/VS-02-SLICE_SPEC.md |
|
||||
| DEBT-017 | Duplicate VS-26 (formerly VS-03) Approval Workflow implementation | High (3) | Medium (2) | Completed (DB verification pending) | **Decision (2026-08-08):** `Features/ApprovalWorkflow/` (Workstream G) kept as canonical — it is the implementation actually wired into `Program.cs`/`FastEndpoints`. `src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/` (Workstream H, `[DontRegister]`'d dead code) and its dedicated test file (`tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs`, the old 20/20-passing suite that exercised only the dead code) were **deleted**. `ApprovalWorkflowPolicyTests.cs` already tested the kept implementation's pure `Policy` class and was extended (5→10 cases) rather than replaced. New Handler+Sql+real-Postgres integration tests were written at the same path the old dead-code tests occupied (`tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs`), covering create (Maker-role-gated), approve (Maker≠Checker separation of duties, Checker-role-gated, evidence attachment), activate (SRE-role-gated), list filtering, and an explicit `DateOnly EffectiveAt` round-trip. **Bug found and fixed while porting:** the kept implementation's `Sql.cs InsertProposalAsync` had the *exact same* Dapper-cannot-bind-`DateOnly` bug that was found and fixed in the deleted implementation's `ApprovalSql.cs` (commit `2ccf74c`) — i.e. the "tested" dead code had already been fixed for this, but the "live" code had not; it would have failed 100% of proposal-creation calls against a real database. Fixed identically (`::date` cast + `"yyyy-MM-dd"` string parameter). **Not fixed (out of scope, flagged as residual gaps in the slice's README):** no `GET /approvals/{id}` endpoint (evidence becomes unreachable via HTTP after approval), and no wired Draft→Proposed transition anywhere in the running app (`ApprovalWorkflowPolicy.CanProposeForReview` exists but no Handler/Endpoint calls it), and `approval_proposals` rows are mutated in place via `UPDATE` rather than appended as new PIT revisions (the table's schema only has `id` as `PRIMARY KEY`, so the deleted implementation's append-only INSERT approach would itself have violated that constraint on the second write — this is pre-existing, schema-level, and not a regression from this cleanup). **Verification status: `dotnet build -c Release` is clean (0 errors/warnings). `dotnet test --filter "FullyQualifiedName~ApprovalWorkflow" -c Release` was run 2026-08-08: 10/10 pure-`Policy` tests passed; all 8 new DB-backed integration tests failed with `Npgsql.NpgsqlException: Failed to connect to 127.0.0.1:5432` (connection refused) because no PostgreSQL was reachable in that session (no SSH tunnel to 178.104.200.7 open). None of the 8 have been confirmed to pass against a real database.** Do not mark this row fully verified until that run happens; see `docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv` row `AEG-VS-26-01`, kept `BLOCKED` for the same reason. | @claude | commit a2e742c (original dup.), this session's commit (resolution), `docs/DECISIONS/ADR-WBS-001-slice-renumbering.md` |
|
||||
| DEBT-018 | Outbox write not co-transactional with entity write | Medium (2) | Medium (2) | Backlog | `TradeExecution/TradeHandlers.cs` (`TradeOutboxPublisher`) and `PortfolioReconciliation/ReconcileTradeHandler.cs` open a second, separate connection/transaction to write the outbox message after the trade/holding write already committed on its own connection. A crash between the two leaves the entity updated but no outbox event emitted (silent, non-atomic). Proper fix: thread a shared `NpgsqlTransaction` through `TradeSql`/`ReconciliationSql` mutation methods so entity insert + outbox insert commit together, matching `DapperModelOperationRequestRepository`'s pattern. | @claude | Session 2026-08-07 (Phase 3 J/K/L hardening) |
|
||||
| DEBT-018 | Outbox write not co-transactional with entity write | Medium (2) | Medium (2) | Completed (DB verification pending) | Fixed 2026-08-08, matching `DapperModelOperationRequestRepository`'s pattern. **TradeExecution:** added a `DbConnection`/`DbTransaction`-taking overload of `ITradeSql.UpdateTradeStatusAsync`; `TradeOutboxPublisher.PublishAsync` replaced with `UpdateAndPublishAsync`, which opens one connection/transaction, updates trade status and writes the outbox message on it, then commits once — used by all 3 call sites that publish an event (`SubmitTradeHandler`, the `FullyFilled` branch of `PollTradeStatusHandler`, `ConfirmSettlementHandler`); paths with no outbox event still use the plain non-transactional update. **PortfolioReconciliation:** `ReconcileTradeHandler` now injects the request-scoped `IDbConnection` (the same instance `ReconciliationSql` already uses within one HTTP request, replacing its own separate `IDbConnectionFactory`-opened connection) and begins one `IDbTransaction` shared by `ReconciliationEngine.ReconcileTradeAsync(..., transaction)` (which threads it into new `IDbTransaction`-aware overloads of `GetHoldingAsync`/`UpsertHoldingAsync`/`InsertReconciliationLogAsync` — the read needed a transaction-aware overload too, since Npgsql throws if a command on a connection with a pending transaction doesn't have it attached) and the outbox `TradeReconciled`/`ReconciliationMismatchAlert` writes; the handler commits once at the end (or rolls back on `!result.Success`). `dotnet build KArtSell.sln -c Release`: 0 warnings/0 errors. `dotnet test --filter "FullyQualifiedName~TradeExecution\|FullyQualifiedName~PortfolioReconciliation" -c Release`: 17 pure-logic tests passed, 13 DB-backed tests failed with the same pre-existing 127.0.0.1:5432 connection-refused error (no SSH tunnel in this session) — none of the transactional changes have been confirmed against a live database yet. | @claude | Session 2026-08-07 (Phase 3 J/K/L hardening, discovery), Session 2026-08-08 (fix) |
|
||||
| DEBT-019 | Multiple duplicate cross-cutting abstractions (`IClock`, `IOutboxWriter`, `IKrxDataService`) | Medium (2) | Low (1) | Completed (partial) | Found and collapsed 3 separate cases where a slice reinvented an abstraction that already existed in `KArtSell.BuildingBlocks`: a second `IKrxDataService` (deleted, `ShadowRun.Services`), a second `IOutboxWriter`/`WriteAsync<T>` in `ReconcileTradeHandler.cs` (removed, switched to `BuildingBlocks.Reliability.IOutboxWriter`), and a second `IClock`/`SystemClock` in `ApprovalWorkflow/ApprovalPolicy.cs` (removed, switched to `BuildingBlocks.Time.IClock`). Root cause: successive sessions implementing a slice without searching `BuildingBlocks` first. Recommend a pre-implementation checklist step ("does this abstraction already exist in BuildingBlocks?") for future slices. | @claude | Session 2026-08-07 (Phase 3 J/K/L hardening) |
|
||||
| DEBT-020 | `model_operations.models` and `compliance` schema never created by any migration | High (3) | Low (1) | Completed | `0036`/`0038` reference `model_operations.models(id)` via FK and `OpenDartDailyBatchJob.cs` queries it directly, but no migration ever ran `CREATE TABLE model_operations.models`; `0037` wrote to `compliance.*` tables without `CREATE SCHEMA compliance`. Any fresh database — including the actual deploy target (178.104.200.7), confirmed via a live failed SCP/DbMigrator deploy on 2026-08-07 — failed at migration `0036`/`0037`. Fixed via new `0035_model_operations_models.sql` (minimal: id/ticker/published_at/correlation_id/revision only — full Model Card schema is separate future work) and `CREATE SCHEMA IF NOT EXISTS compliance;` added to `0037`. Full chain 0000→0040 now verified fresh-install + idempotent re-run clean. | @claude | Session 2026-08-07 (deploy failure triage) |
|
||||
| DEBT-021 | Dapper never configured for snake_case↔PascalCase column mapping | High (3) | Low (1) | Completed | `Dapper.DefaultTypeMap.MatchNamesWithUnderscores` was never set anywhere in the codebase, so every `QueryAsync<T>`/`QuerySingleOrDefaultAsync<T>` result-mapping onto a snake_case DB column (e.g. `event_type` → `EventType`) silently returned null/default for that property instead of throwing — masking the bug in every Sql class across every module. Confirmed via `ApprovalWorkflowTests.InsertAndRetrieveProposal_RoundTrips` and `AuditTrailTests.InsertAuditEvent_CreatesImmutableRecord` both getting real rows back with null fields. Fixed centrally via a `[ModuleInitializer]` in `KArtSell.BuildingBlocks/Data/DapperBootstrap.cs` (runs once per process regardless of entry point — Host/DbMigrator/tests). | @claude | Session 2026-08-07 (deploy failure triage) |
|
||||
|
||||
@@ -28,7 +28,7 @@ AEG-X-009,S1,Cross,Source catalog 고도화,COMPLETED,2026-08-07,"docs/CURRENT/C
|
||||
AEG-VS-01-01,S1,VS-01,정책·범위·실패상태 계약 확정,COMPLETED,2026-08-07,docs/CURRENT/SLICE_SPECS/VS-01-SLICE_SPEC.md,PM/Architect,"✅ SLICE_SPEC produced: VS-01-SLICE_SPEC.md (identity/MFA/RBAC/maker-checker contract). Prerequisite AEG-X-001 + AEG-VS-00-02 already COMPLETED. Ready for security team review and schema implementation."
|
||||
AEG-VS-02-01,S1,VS-02,정책·범위·실패상태 계약 확정,COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-02-SLICE_SPEC.md; docs/CURRENT/VS-02_DATA_GOVERNANCE_POLICY.md",PM/Architect,"✅ COMPLETE: VS-02-SLICE_SPEC.md + governance policy. All 4 unknowns resolved (data source, import SLA, audit policy, schema versioning). Financial security master implementation ready for Phase 2."
|
||||
AEG-VS-26-01,S2,VS-26,모델 승인 워크플로우 구현 (Maker-Checker Governance),BLOCKED,-,"docs/CURRENT/SLICE_SPECS/VS-26-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/ (Handlers.cs, Sql.cs, Policy.cs, Endpoints.cs — sole implementation, wired in Program.cs); db/migrations/0036_approval_workflow.sql; tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs (12 cases); tests/KArtSell.Integration.Tests/ApprovalWorkflowPolicyTests.cs (10 cases); TECH_DEBT_REGISTER.md DEBT-017/DEBT-023/DEBT-025/DEBT-026; commit a2e742c (original duplication, superseded)",PM/BE Lead,"🟡 UPDATED 2026-08-08 (second pass, BE priority work) — DEBT-025 and DEBT-026 (both discovered during the DEBT-017 cleanup earlier the same day) are now also code-complete: added `ProposeForReviewHandler` + `POST /approvals/{id}/propose` (wires the previously-dead `ApprovalWorkflowPolicy.CanProposeForReview`, so a proposal created via `POST /approvals` can now reach Approved/Active through the HTTP API end-to-end — this was the higher-impact gap, DEBT-026), and `GetApprovalByIdEndpoint` (`GET /approvals/{id}`) + `ApprovalWorkflowSql.GetEvidenceForProposalAsync` so approval evidence is readable via HTTP (DEBT-025). 4 new tests added (12 total in this file). `dotnet build KArtSell.sln -c Release` clean (0 warnings/0 errors). **Still BLOCKED, not COMPLETED: no PostgreSQL reachable in this session (127.0.0.1:5432 connection refused, no SSH tunnel open) — `dotnet test --filter FullyQualifiedName~ApprovalWorkflowTests -c Release` run 2026-08-08, all 17 matched tests fail with connection-refused (includes an unrelated top-level ApprovalWorkflowTests.cs the substring filter also matches). None of the 12 tests in this file have been confirmed to pass against a live database.** Do not mark COMPLETED until that run happens against a reachable Postgres and actually passes. Renumbered from VS-03 to VS-26 on 2026-08-08 per docs/DECISIONS/ADR-WBS-001-slice-renumbering.md — VS-03 in WBS_MASTER.csv ('IngestMarketDataPIT') remains a separate, unrelated, still-unimplemented slice. Also still open: `src/KArtSell.Host/Features/MarketData/VS03_*.cs` is a THIRD, unrelated, already-implemented-and-tested body of work also labeled 'VS-03' that is entirely absent from this tracker — see CURRENT_ROADMAP.md."
|
||||
AEG-VS-27-01,S2,VS-27,불변 감사 추적 구현 (Audit Trail / GDPR),COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-27-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/Compliance/ (AuditSql.cs, GdprRetention.cs); tests/KArtSell.Integration.Tests/Compliance/AuditTrailTests.cs; commit 97444c9 (Workstream I, PR #24, merged to main)",PM/BE Lead,"✅ Backend implementation + tests complete: append-only audit_events table, GDPR retention tracking + redaction (actor_email→'<redacted>', customer_id→'<purged>'), PII fields (ip_address). 5/5 tests PASS run in isolation (2026-08-07). Also fixed same day (fix/dapper-underscore-mapping-and-build branch): ip_address (inet) and kis_response-style jsonb columns threw InvalidCastException when read through Dapper into a typed class; GdprRetention.RetentionEndsAt was declared DateTime against a DATE column, same failure mode; and a process-wide Dapper snake_case-mapping race condition (KArtSell.BuildingBlocks' [ModuleInitializer] only fires once that assembly loads — AuditSql doesn't reliably touch it) intermittently nulled out every column read from this table depending on unrelated test/host startup order. None of this had ever been exercised against a live database before. ⚠️ No frontend UI yet — in progress 2026-08-08. Renumbered from VS-04 to VS-27 on 2026-08-08 per docs/DECISIONS/ADR-WBS-001-slice-renumbering.md — VS-04 in WBS_MASTER.csv ('ApplyCorporateActions') was an unrelated, still-unimplemented slice and keeps its original number unchanged."
|
||||
AEG-VS-27-01,S2,VS-27,불변 감사 추적 구현 (Audit Trail / GDPR),COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-27-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/Compliance/ (AuditSql.cs, GdprRetention.cs); tests/KArtSell.Integration.Tests/Compliance/AuditTrailTests.cs; commit 97444c9 (Workstream I, PR #24, merged to main)",PM/BE Lead,"✅ Backend implementation + tests complete: append-only audit_events table, GDPR retention tracking + redaction (actor_email→'<redacted>', customer_id→'<purged>'), PII fields (ip_address). 5/5 tests PASS run in isolation (2026-08-07). Also fixed same day (fix/dapper-underscore-mapping-and-build branch): ip_address (inet) and kis_response-style jsonb columns threw InvalidCastException when read through Dapper into a typed class; GdprRetention.RetentionEndsAt was declared DateTime against a DATE column, same failure mode; and a process-wide Dapper snake_case-mapping race condition (KArtSell.BuildingBlocks' [ModuleInitializer] only fires once that assembly loads — AuditSql doesn't reliably touch it) intermittently nulled out every column read from this table depending on unrelated test/host startup order. None of this had ever been exercised against a live database before. ⚠️ Frontend UI built 2026-08-08 (frontend/src/features/audit-trail/, route /compliance/audit-trail, pnpm typecheck/build clean, 19 new tests passing) but on an isolated worktree branch (worktree-agent-a2cc5afe46a7e16b1) not yet merged into this branch — deprioritized behind BE work per user direction 2026-08-08; also flagged DEBT-025 (Compliance endpoints AllowAnonymous with no real RBAC, and no PermissionGuard component exists in this repo despite CLAUDE.md listing it as always-shared) and DEBT-026 (~130 stray committed .js files regenerate on every pnpm build) — note both DEBT-025 numbers collide with a different DEBT-025 added the same day on this branch (GET /approvals/{id}); renumber on merge. Renumbered from VS-04 to VS-27 on 2026-08-08 per docs/DECISIONS/ADR-WBS-001-slice-renumbering.md — VS-04 in WBS_MASTER.csv ('ApplyCorporateActions') was an unrelated, still-unimplemented slice and keeps its original number unchanged."
|
||||
AEG-VS-05-01,S3,VS-05,정책·범위·실패상태 계약 확정,BLOCKED,-,docs/CURRENT/AEG-VS-05-01_BLOCKER.md,PM/Architect,"2026-08-08: blocked before implementation. WBS defines IngestFundamentalsPIT (REQ-FND-001/DAT-05/MIG-FND-001/002/J04/UI-FND-01/T-FND-001), but existing VS-05 architecture/data contracts define unrelated Risk Metrics. Declared VS-02 dependency is not concretely evidenced beyond AEG-VS-02-01. See blocker record for required PM/Architect decision and Gate G1 evidence. No build/test/migration claimed."
|
||||
AEG-VS-06-01,S3,VS-06,정책·범위·실패상태 계약 확정,BLOCKED,-,docs/CURRENT/AEG-VS-06-01_BLOCKER.md,PM/Architect,"2026-08-08: blocked before implementation. WBS defines MaintainFeeTaxFxSchedule (REQ-COST-001/COST-01/02/MIG-COST-001/J04C/UI-COST-01/T-COST-001), but existing VS-06 architecture/data contracts define unrelated Stress Testing (STRESS-* / migration 0035). Declared VS-02 dependency is not concrete enough for Gate G1. See blocker record for required PM/Architect and Compliance/Owner decisions. No build/test/migration claimed."
|
||||
AEG-X-038,S3,Cross,Reconfirm Fee/Tax/FX valid-time schedule decisions,BLOCKED,-,docs/CURRENT/AEG-X-038_BLOCKER.md,Ops/Tax,"2026-08-08: source-gap audit completed. AEG-X-009 dependency is complete, but no approved source authority, decision log, data contract, or temporal/preference rules were found. Legacy references include persisted execution Commission and an unapproved 0.001m fee default; neither is a valid-time schedule. REQ-COST-001/T-COST-PIT cannot be safely implemented until Ops/Tax and Compliance/Owner approve the decisions in the blocker record. No build/test/migration claimed."
|
||||
@@ -36,6 +36,6 @@ AEG-X-011,S4,Cross,Golden vector 고도화,BLOCKED,TBD,"AGENTS.md: Algorithm cha
|
||||
AEG-VS-09-01,S4,VS-09,BuildEvidenceSnapshot,BLOCKED,TBD,"CLAUDE.md: Evidence requires Phase 1 results",PM/Architect,"Gate 2 prerequisite. Blocked by Phase 1, which has not been started (confirmed 2026-08-07). No src/ implementation exists for this slice."
|
||||
AEG-VS-10-01,S4,VS-10,매도 결정 엔진 구현 (GenerateSellDecision),COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-10-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/SellDecision/ (SellDecisionEndpoints.cs, SellDecisionHandler.cs, SellDecisionSql.cs, SellPriorityRanker.cs); frontend/src/features/sell-decision/; tests/KArtSell.Integration.Tests/SellDecision/SellDecisionTests.cs; commit b1e38ac (Phase 3 J, PR #28, merged to main)",BE Lead/Quant Lead,"✅ Implementation complete (code + BE + FE + tests), matches WBS_MASTER's VS-10='GenerateSellDecision' definition (no ID collision here). Sell priority ranking (HARD_IMPAIRMENT→...→REENTRY_OPTION) with age/liquidity score boosts per VS-10-SLICE_SPEC.md. 32/32 tests PASS run in isolation (2026-08-07); one test (CalculateScore_HardImpairment_ReturnsLowestScore) had a wrong input value that happened to not exercise the >365-day age-boost branch the spec defines — fixed as a test bug, not a product bug (see fix/dapper-underscore-mapping-and-build branch). ⚠️ NOT validated: this row was previously (incorrectly) marked BLOCKED with reasoning 'Model must pass PBO/DSR validation' — that Gate-3/production-readiness validation genuinely still requires real Phase 1 shadow-run data and has not happened. Distinguish 'code implemented and unit/integration-tested' (done) from 'PBO/DSR-validated against real market data' (not done, blocked on Phase 1)."
|
||||
AEG-VS-19-01,S5,VS-19,RunFrozenBacktest,BLOCKED,TBD,"CLAUDE.md: Requires evidence from Phase 1-4",PM/Architect,"Gate 3 prerequisite. Blocked by Phase 1, which has not been started (confirmed 2026-08-07). No src/ implementation exists for this slice."
|
||||
AEG-VS-28-01,S2,VS-28,"거래 실행 시스템 구현 (Trade Execution, KIS Integration)",COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-28-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/TradeExecution/ (TradeEndpoints.cs, TradeHandlers.cs, TradeSql.cs, Trade.cs, KisTradeExecutionService.cs); db/migrations/0039_trades.sql; tests/KArtSell.Integration.Tests/TradeExecution/TradeExecutionTests.cs; commit b1e38ac (Phase 3 K, PR #28, merged to main)",BE Lead/Trading Ops,"New row — no prior tracker entry existed for this slice. ✅ Backend implementation + tests complete: Trade state machine (Pending→Submitted→Accepted→PartiallyFilled/FullyFilled→Confirmed→Reconciled), KIS order submission/poll/settlement. 13/13 tests PASS run in isolation (2026-08-07), but only after two real bugs were fixed on fix/dapper-underscore-mapping-and-build: (1) UpdateTradeStatusAsync only ever persisted status/kis_response/error_message and silently dropped kis_order_id, executed_quantity, unit_price, commission, net_proceeds and both timestamps on every single call since the slice merged — trade fills and settlements were not actually being recorded; (2) the same Dapper snake_case-mapping race condition described in AEG-VS-27-01's notes. ⚠️ No frontend UI yet (no frontend/src/features/trade-execution/) — in progress 2026-08-08. Renumbered from VS-12 to VS-28 on 2026-08-08 per docs/DECISIONS/ADR-WBS-001-slice-renumbering.md — VS-12 in WBS_MASTER.csv ('RankBuyCandidates') was an unrelated, still-unimplemented slice and keeps its original number unchanged."
|
||||
AEG-VS-29-01,S2,VS-29,포트폴리오 대사 구현 (Portfolio Reconciliation),COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-29-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/PortfolioReconciliation/ (Endpoints.cs, ReconcileTradeHandler.cs, ReconciliationEngine.cs, ReconciliationSql.cs, MismatchDetector.cs, CostBasisCalculator.cs); tests/KArtSell.Integration.Tests/PortfolioReconciliation/ReconciliationEngineTests.cs; tests/KArtSell.ModelOperations.UnitTests/EvaluationReconciliationPlannerTests.cs; commit b1e38ac (Phase 3 L, PR #28, merged to main); commit 4059828 (fix: missing model_operations.models table breaking every fresh DB, PR #29, merged to main same day)",BE Lead,"New row — no prior tracker entry existed for this slice. ✅ Backend implementation + tests complete: Sell Decision → Trade → Holdings reconciliation, cost-basis calculation, mismatch detection. 18/18 tests PASS run in isolation (2026-08-07). Note this slice's own merge (PR #28) shipped with a missing model_operations.models table that broke every fresh-database migration; that was caught and fixed same day in PR #29 — a reminder that this branch's fresh-install DbUp path had not actually been rehearsed before merge. ⚠️ No frontend UI yet — in progress 2026-08-08. Renumbered from VS-14 to VS-29 on 2026-08-08 per docs/DECISIONS/ADR-WBS-001-slice-renumbering.md — VS-14 in WBS_MASTER.csv ('GenerateDailyRecommendations') was an unrelated, still-unimplemented slice and keeps its original number unchanged."
|
||||
AEG-VS-28-01,S2,VS-28,"거래 실행 시스템 구현 (Trade Execution, KIS Integration)",COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-28-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/TradeExecution/ (TradeEndpoints.cs, TradeHandlers.cs, TradeSql.cs, Trade.cs, KisTradeExecutionService.cs); db/migrations/0039_trades.sql; tests/KArtSell.Integration.Tests/TradeExecution/TradeExecutionTests.cs; commit b1e38ac (Phase 3 K, PR #28, merged to main)",BE Lead/Trading Ops,"New row — no prior tracker entry existed for this slice. ✅ Backend implementation + tests complete: Trade state machine (Pending→Submitted→Accepted→PartiallyFilled/FullyFilled→Confirmed→Reconciled), KIS order submission/poll/settlement. 13/13 tests PASS run in isolation (2026-08-07), but only after two real bugs were fixed on fix/dapper-underscore-mapping-and-build: (1) UpdateTradeStatusAsync only ever persisted status/kis_response/error_message and silently dropped kis_order_id, executed_quantity, unit_price, commission, net_proceeds and both timestamps on every single call since the slice merged — trade fills and settlements were not actually being recorded; (2) the same Dapper snake_case-mapping race condition described in AEG-VS-27-01's notes. ⚠️ No frontend UI yet (no frontend/src/features/trade-execution/) — an attempt was started 2026-08-08 but the background agent building it failed (hit the session's monthly spend limit) before producing any committed code; not resumed this session. Renumbered from VS-12 to VS-28 on 2026-08-08 per docs/DECISIONS/ADR-WBS-001-slice-renumbering.md — VS-12 in WBS_MASTER.csv ('RankBuyCandidates') was an unrelated, still-unimplemented slice and keeps its original number unchanged. 2026-08-08 (BE priority pass): DEBT-018 (outbox write not co-transactional with the trade status update) fixed — see TECH_DEBT_REGISTER.md; `dotnet build -c Release` clean, DB-backed tests still unverified (no reachable Postgres this session)."
|
||||
AEG-VS-29-01,S2,VS-29,포트폴리오 대사 구현 (Portfolio Reconciliation),COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-29-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/PortfolioReconciliation/ (Endpoints.cs, ReconcileTradeHandler.cs, ReconciliationEngine.cs, ReconciliationSql.cs, MismatchDetector.cs, CostBasisCalculator.cs); tests/KArtSell.Integration.Tests/PortfolioReconciliation/ReconciliationEngineTests.cs; tests/KArtSell.ModelOperations.UnitTests/EvaluationReconciliationPlannerTests.cs; commit b1e38ac (Phase 3 L, PR #28, merged to main); commit 4059828 (fix: missing model_operations.models table breaking every fresh DB, PR #29, merged to main same day)",BE Lead,"New row — no prior tracker entry existed for this slice. ✅ Backend implementation + tests complete: Sell Decision → Trade → Holdings reconciliation, cost-basis calculation, mismatch detection. 18/18 tests PASS run in isolation (2026-08-07). Note this slice's own merge (PR #28) shipped with a missing model_operations.models table that broke every fresh-database migration; that was caught and fixed same day in PR #29 — a reminder that this branch's fresh-install DbUp path had not actually been rehearsed before merge. ⚠️ No frontend UI yet — an attempt was started 2026-08-08 but the background agent building it failed (hit the session's monthly spend limit) before producing any committed code; not resumed this session. Renumbered from VS-14 to VS-29 on 2026-08-08 per docs/DECISIONS/ADR-WBS-001-slice-renumbering.md — VS-14 in WBS_MASTER.csv ('GenerateDailyRecommendations') was an unrelated, still-unimplemented slice and keeps its original number unchanged. 2026-08-08 (BE priority pass): DEBT-018 (outbox writes for TradeReconciled/ReconciliationMismatchAlert not co-transactional with the holding/log write) fixed — see TECH_DEBT_REGISTER.md; `dotnet build -c Release` clean, DB-backed tests still unverified (no reachable Postgres this session)."
|
||||
PHASE-1-SHADOW-RUN,S0-S5,Cross,252+ Trading Day Shadow Run,BLOCKED,TBD,"docs/CURRENT/PHASE-1_SHADOW_RUN_STATUS_CORRECTION.md; docs/CURRENT/AEG-X-004_DBUP_EVIDENCE.md; docs/CURRENT/PHASE-1_REQUEUE_READINESS.md; docs/CURRENT/PHASE-1_EXECUTION_EVIDENCE_PLAN.md; docs/CURRENT/PHASE-1_PREFLIGHT_20260806.md; docs/CURRENT/PHASE-1_PRODUCTION_PREFLIGHT_20260806.md; evidence/AEG-X-004/production-readonly-preflight-20260806.md; db/migrations/0032_shadow_run_queued_status_contract.sql; logs/phase-1-execution.log; logs/host-startup-20260804-173000.log",김재현/BE/SRE,"Read-only preflight: active DbUp journal public.kartsell_schema_versions contains 0032 and check_status includes Queued. Capabilities remain order/KIS/client publication OFF. Server-side dataset_manifest, model_version_registry, evidence_snapshot, and release_evidence_bundle contain no approved/frozen rows; no RunId/JobId/enqueue created. Blocked pending approved server-side VersionSet. Re-confirmed 2026-08-07: still no RunId/JobId exists anywhere in this workspace or its evidence trail; nothing changed on this row this session. Any future document that claims this row is RUNNING must cite a real RunId/JobId — do not restate the earlier (already-corrected) false claim."
|
||||
|
||||
|
+24
-15
@@ -1,9 +1,10 @@
|
||||
namespace KArtSell.Modules.ModelOperations.PortfolioReconciliation;
|
||||
|
||||
using System;
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using KArtSell.BuildingBlocks.Data;
|
||||
using KArtSell.BuildingBlocks.Hashing;
|
||||
using KArtSell.BuildingBlocks.Reliability;
|
||||
using KArtSell.BuildingBlocks.Time;
|
||||
@@ -31,18 +32,18 @@ public class ReconcileTradeCommand
|
||||
public class ReconcileTradeHandler
|
||||
{
|
||||
private readonly ReconciliationEngine _engine;
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private readonly IDbConnection _connection;
|
||||
private readonly IOutboxWriter _outboxWriter;
|
||||
private readonly IClock _clock;
|
||||
|
||||
public ReconcileTradeHandler(
|
||||
ReconciliationEngine engine,
|
||||
IDbConnectionFactory connectionFactory,
|
||||
IDbConnection connection,
|
||||
IOutboxWriter outboxWriter,
|
||||
IClock clock)
|
||||
{
|
||||
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
_outboxWriter = outboxWriter ?? throw new ArgumentNullException(nameof(outboxWriter));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
}
|
||||
@@ -51,6 +52,13 @@ public class ReconcileTradeHandler
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
// DEBT-018: share one connection/transaction across the engine's holding/log writes and
|
||||
// the outbox event(s) below, instead of the engine writing on its own connection and the
|
||||
// outbox writing on a second, separately-committed one. _connection is scoped per HTTP
|
||||
// request (see Program.cs), the same instance ReconciliationEngine/ReconciliationSql use,
|
||||
// so this is the same underlying connection, not a new one.
|
||||
using var transaction = _connection.BeginTransaction();
|
||||
|
||||
var result = await _engine.ReconcileTradeAsync(
|
||||
command.TradeId,
|
||||
command.SecurityId,
|
||||
@@ -61,10 +69,12 @@ public class ReconcileTradeHandler
|
||||
command.TradeDate,
|
||||
command.ExpectedSettlementDate,
|
||||
command.ActualSettlementDate,
|
||||
command.CorrelationId);
|
||||
command.CorrelationId,
|
||||
transaction);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw new InvalidOperationException($"Reconciliation failed: {result.Error}");
|
||||
}
|
||||
|
||||
@@ -86,7 +96,7 @@ public class ReconcileTradeHandler
|
||||
IdempotencyKey = command.IdempotencyKey ?? Guid.NewGuid().ToString()
|
||||
};
|
||||
|
||||
await PublishAsync("TradeReconciled", @event, command.CorrelationId, CancellationToken.None);
|
||||
await PublishAsync(transaction, "TradeReconciled", @event, command.CorrelationId, CancellationToken.None);
|
||||
|
||||
// If mismatches detected, publish alert event
|
||||
if (result.MismatchDetected)
|
||||
@@ -103,16 +113,18 @@ public class ReconcileTradeHandler
|
||||
CorrelationId = command.CorrelationId
|
||||
};
|
||||
|
||||
await PublishAsync("ReconciliationMismatchAlert", alertEvent, command.CorrelationId, CancellationToken.None);
|
||||
await PublishAsync(transaction, "ReconciliationMismatchAlert", alertEvent, command.CorrelationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DEBT-TRADE-001: outbox write happens in its own transaction, separate from the
|
||||
/// preceding holding/log writes owned by ReconciliationSql. Not yet atomic with the
|
||||
/// entity write. See TECH_DEBT_REGISTER.md.
|
||||
/// DEBT-018 (fixed): writes to the same transaction the holding/log writes used above, so a
|
||||
/// crash between the entity write and the outbox write can no longer leave one committed
|
||||
/// without the other.
|
||||
/// </summary>
|
||||
private async Task PublishAsync<T>(string eventType, T @event, Guid correlationId, CancellationToken ct) where T : class
|
||||
private async Task PublishAsync<T>(IDbTransaction transaction, string eventType, T @event, Guid correlationId, CancellationToken ct) where T : class
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(@event);
|
||||
var message = new OutboxMessage(
|
||||
@@ -124,10 +136,7 @@ public class ReconcileTradeHandler
|
||||
_clock.UtcNow,
|
||||
ContentHasher.Sha256(payload));
|
||||
|
||||
await using var connection = await _connectionFactory.OpenAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
await _outboxWriter.AddAsync(connection, transaction, message, ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
await _outboxWriter.AddAsync((DbConnection)_connection, (DbTransaction)transaction, message, ct);
|
||||
}
|
||||
|
||||
private int CountBysSeverity(List<Mismatch> mismatches, MismatchSeverity severity)
|
||||
|
||||
+13
-6
@@ -41,15 +41,18 @@ public class ReconciliationEngine
|
||||
DateTime tradeDate,
|
||||
DateTime expectedSettlementDate,
|
||||
DateTime? actualSettlementDate,
|
||||
Guid correlationId)
|
||||
Guid correlationId,
|
||||
System.Data.IDbTransaction? transaction = null)
|
||||
{
|
||||
var result = new ReconciliationResult { CorrelationId = correlationId };
|
||||
var now = _clock.UtcNow.UtcDateTime;
|
||||
|
||||
try
|
||||
{
|
||||
// Load current holding
|
||||
var holding = await _repository.GetHoldingAsync(securityId);
|
||||
// Load current holding. DEBT-018: must use the transaction-aware overload once a
|
||||
// transaction is active on the shared connection (see ReconcileTradeHandler), or
|
||||
// Npgsql throws on this read.
|
||||
var holding = await _repository.GetHoldingAsync(securityId, transaction);
|
||||
if (holding == null)
|
||||
{
|
||||
holding = new Holding
|
||||
@@ -110,8 +113,9 @@ public class ReconciliationEngine
|
||||
|
||||
var mismatchDetected = mismatches.Count > 0;
|
||||
|
||||
// Save holding
|
||||
await _repository.UpsertHoldingAsync(holding);
|
||||
// Save holding. DEBT-018: when a transaction is supplied, the write commits
|
||||
// atomically with the outbox event ReconcileTradeHandler publishes afterward.
|
||||
await _repository.UpsertHoldingAsync(holding, transaction);
|
||||
|
||||
// Log reconciliation
|
||||
var logEntry = new ReconciliationLog
|
||||
@@ -130,7 +134,7 @@ public class ReconciliationEngine
|
||||
CorrelationId = correlationId
|
||||
};
|
||||
|
||||
await _repository.InsertReconciliationLogAsync(logEntry);
|
||||
await _repository.InsertReconciliationLogAsync(logEntry, transaction);
|
||||
|
||||
result.Success = true;
|
||||
result.Holding = holding;
|
||||
@@ -255,8 +259,11 @@ public class ReconciliationLog
|
||||
public interface IReconciliationRepository
|
||||
{
|
||||
Task<Holding?> GetHoldingAsync(Guid securityId);
|
||||
Task<Holding?> GetHoldingAsync(Guid securityId, System.Data.IDbTransaction? transaction);
|
||||
Task UpsertHoldingAsync(Holding holding);
|
||||
Task UpsertHoldingAsync(Holding holding, System.Data.IDbTransaction? transaction);
|
||||
Task InsertReconciliationLogAsync(ReconciliationLog log);
|
||||
Task InsertReconciliationLogAsync(ReconciliationLog log, System.Data.IDbTransaction? transaction);
|
||||
Task<List<ReconciliationLog>> GetReconciliationLogsAsync(DateTime startDate, DateTime endDate);
|
||||
Task<List<Holding>> GetOpenHoldingsAsync();
|
||||
}
|
||||
|
||||
@@ -20,7 +20,15 @@ public class ReconciliationSql : IReconciliationRepository
|
||||
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
}
|
||||
|
||||
public async Task<Holding?> GetHoldingAsync(Guid securityId)
|
||||
public Task<Holding?> GetHoldingAsync(Guid securityId) => GetHoldingAsync(securityId, transaction: null);
|
||||
|
||||
/// <summary>
|
||||
/// DEBT-018: once a transaction is active on the shared connection (see
|
||||
/// ReconcileTradeHandler), every command on that connection must be given the transaction
|
||||
/// explicitly or Npgsql throws — this overload is required, not optional, when a caller has
|
||||
/// called BeginTransaction() on the same IDbConnection instance.
|
||||
/// </summary>
|
||||
public async Task<Holding?> GetHoldingAsync(Guid securityId, IDbTransaction? transaction)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, security_id, quantity, weighted_avg_cost, total_cost_basis,
|
||||
@@ -34,10 +42,17 @@ public class ReconciliationSql : IReconciliationRepository
|
||||
|
||||
return await _connection.QueryFirstOrDefaultAsync<Holding>(
|
||||
sql,
|
||||
new { security_id = securityId });
|
||||
new { security_id = securityId },
|
||||
transaction);
|
||||
}
|
||||
|
||||
public async Task UpsertHoldingAsync(Holding holding)
|
||||
public Task UpsertHoldingAsync(Holding holding) => UpsertHoldingAsync(holding, transaction: null);
|
||||
|
||||
/// <summary>
|
||||
/// DEBT-018: pass the transaction the caller is also using for the outbox write (see
|
||||
/// ReconcileTradeHandler) so the holding update and the outbox event commit atomically.
|
||||
/// </summary>
|
||||
public async Task UpsertHoldingAsync(Holding holding, IDbTransaction? transaction)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO portfolio_management.holdings
|
||||
@@ -57,10 +72,16 @@ public class ReconciliationSql : IReconciliationRepository
|
||||
revision = revision + 1
|
||||
""";
|
||||
|
||||
await _connection.ExecuteAsync(sql, holding);
|
||||
await _connection.ExecuteAsync(sql, holding, transaction);
|
||||
}
|
||||
|
||||
public async Task InsertReconciliationLogAsync(ReconciliationLog log)
|
||||
public Task InsertReconciliationLogAsync(ReconciliationLog log) => InsertReconciliationLogAsync(log, transaction: null);
|
||||
|
||||
/// <summary>
|
||||
/// DEBT-018: pass the transaction the caller is also using for the outbox write (see
|
||||
/// ReconcileTradeHandler) so the log entry and the outbox event commit atomically.
|
||||
/// </summary>
|
||||
public async Task InsertReconciliationLogAsync(ReconciliationLog log, IDbTransaction? transaction)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO portfolio_management.reconciliation_logs
|
||||
@@ -86,7 +107,7 @@ public class ReconciliationSql : IReconciliationRepository
|
||||
log.ReconciledAt,
|
||||
log.PublishedAt,
|
||||
log.CorrelationId
|
||||
});
|
||||
}, transaction);
|
||||
}
|
||||
|
||||
public async Task<List<ReconciliationLog>> GetReconciliationLogsAsync(
|
||||
|
||||
@@ -57,9 +57,13 @@ public class SubmitTradeHandler
|
||||
);
|
||||
|
||||
trade.MarkSubmitted(orderId, response);
|
||||
await _sql.UpdateTradeStatusAsync(trade, response, null, ct);
|
||||
|
||||
await PublishEventAsync(
|
||||
await TradeOutboxPublisher.UpdateAndPublishAsync(
|
||||
_connectionFactory,
|
||||
_sql,
|
||||
_outbox,
|
||||
_clock,
|
||||
trade,
|
||||
response,
|
||||
"TradeSubmitted",
|
||||
new TradeSubmittedEvent
|
||||
{
|
||||
@@ -87,9 +91,6 @@ public class SubmitTradeHandler
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PublishEventAsync<T>(string eventType, T @event, Guid correlationId, CancellationToken ct) where T : class
|
||||
=> await TradeOutboxPublisher.PublishAsync(_connectionFactory, _outbox, _clock, eventType, @event, correlationId, ct);
|
||||
}
|
||||
|
||||
public class PollTradeStatusCommand
|
||||
@@ -149,14 +150,15 @@ public class PollTradeStatusHandler
|
||||
trade.MarkFilled(executedQty, unitPrice, response, _clock.UtcNow.UtcDateTime);
|
||||
}
|
||||
|
||||
await _sql.UpdateTradeStatusAsync(trade, response, null, ct);
|
||||
|
||||
if (trade.Status is TradeStatus.FullyFilled)
|
||||
{
|
||||
await TradeOutboxPublisher.PublishAsync(
|
||||
await TradeOutboxPublisher.UpdateAndPublishAsync(
|
||||
_connectionFactory,
|
||||
_sql,
|
||||
_outbox,
|
||||
_clock,
|
||||
trade,
|
||||
response,
|
||||
"TradeFilled",
|
||||
new TradeFilledEvent
|
||||
{
|
||||
@@ -168,6 +170,10 @@ public class PollTradeStatusHandler
|
||||
command.CorrelationId,
|
||||
ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _sql.UpdateTradeStatusAsync(trade, response, null, ct);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Trade status updated: {TradeId} -> {Status}", trade.Id, status);
|
||||
}
|
||||
@@ -234,12 +240,13 @@ public class ConfirmSettlementHandler
|
||||
if (success)
|
||||
{
|
||||
trade.MarkConfirmed(_clock.UtcNow.UtcDateTime, command.Commission);
|
||||
await _sql.UpdateTradeStatusAsync(trade, response, null, ct);
|
||||
|
||||
await TradeOutboxPublisher.PublishAsync(
|
||||
await TradeOutboxPublisher.UpdateAndPublishAsync(
|
||||
_connectionFactory,
|
||||
_sql,
|
||||
_outbox,
|
||||
_clock,
|
||||
trade,
|
||||
response,
|
||||
"TradeSettled",
|
||||
new TradeSettledEvent
|
||||
{
|
||||
@@ -287,16 +294,19 @@ public class TradeSettledEvent
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DEBT-TRADE-001: outbox write happens in its own transaction, separate from the
|
||||
/// preceding trade status update (which owns its own connection in TradeSql). Not yet
|
||||
/// atomic with the state transition. See TECH_DEBT_REGISTER.md.
|
||||
/// DEBT-018 (fixed): the trade status update and the outbox event write now share one
|
||||
/// connection/transaction, committed together, instead of the status update committing on
|
||||
/// TradeSql's own connection and the outbox write committing separately afterward.
|
||||
/// </summary>
|
||||
internal static class TradeOutboxPublisher
|
||||
{
|
||||
public static async Task PublishAsync<T>(
|
||||
public static async Task UpdateAndPublishAsync<T>(
|
||||
IDbConnectionFactory connectionFactory,
|
||||
ITradeSql sql,
|
||||
IOutboxWriter outbox,
|
||||
IClock clock,
|
||||
Trade trade,
|
||||
System.Text.Json.JsonElement? kisResponse,
|
||||
string eventType,
|
||||
T @event,
|
||||
Guid correlationId,
|
||||
@@ -314,7 +324,10 @@ internal static class TradeOutboxPublisher
|
||||
|
||||
await using var connection = await connectionFactory.OpenAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
await sql.UpdateTradeStatusAsync(connection, transaction, trade, kisResponse, errorMessage: null, ct);
|
||||
await outbox.AddAsync(connection, transaction, message, ct);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Data.Common;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -13,6 +14,13 @@ public interface ITradeSql
|
||||
Task<IEnumerable<Trade>> GetTradesByDecisionIdAsync(Guid sellDecisionId, Guid correlationId, CancellationToken ct = default);
|
||||
Task InsertTradeAsync(Trade trade, CancellationToken ct = default);
|
||||
Task UpdateTradeStatusAsync(Trade trade, JsonElement? kisResponse, string? errorMessage, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// DEBT-018: transaction-aware overload so the caller can write the outbox event on the same
|
||||
/// connection/transaction as this status update, committing both atomically. See
|
||||
/// TradeOutboxPublisher/SubmitTradeHandler.
|
||||
/// </summary>
|
||||
Task UpdateTradeStatusAsync(DbConnection connection, DbTransaction transaction, Trade trade, JsonElement? kisResponse, string? errorMessage, CancellationToken ct = default);
|
||||
Task<int> CountTradesByStatusAsync(TradeStatus status, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -154,6 +162,47 @@ public class TradeSql : ITradeSql
|
||||
_logger.LogInformation("Inserted trade {TradeId}", trade.Id);
|
||||
}
|
||||
|
||||
private const string UpdateTradeStatusSql = """
|
||||
INSERT INTO model_operations.trade_status_history
|
||||
(id, trade_id, old_status, new_status, transitioned_at, kis_response, error_message, published_at, correlation_id)
|
||||
SELECT @id, id, status, @newStatus, NOW(), @kisResponse::jsonb, @errorMessage, NOW(), @correlationId
|
||||
FROM model_operations.trades
|
||||
WHERE id = @tradeId;
|
||||
|
||||
UPDATE model_operations.trades
|
||||
SET status = @newStatus,
|
||||
kis_order_id = COALESCE(@kisOrderId, kis_order_id),
|
||||
executed_quantity = COALESCE(@executedQuantity, executed_quantity),
|
||||
unit_price = COALESCE(@unitPrice, unit_price),
|
||||
total_amount = COALESCE(@totalAmount, total_amount),
|
||||
commission = COALESCE(@commission, commission),
|
||||
net_proceeds = COALESCE(@netProceeds, net_proceeds),
|
||||
execution_timestamp = COALESCE(@executionTimestamp, execution_timestamp),
|
||||
settlement_timestamp = COALESCE(@settlementTimestamp, settlement_timestamp),
|
||||
kis_response = COALESCE(@kisResponse::jsonb, kis_response),
|
||||
error_message = COALESCE(@errorMessage, error_message),
|
||||
revision = revision + 1
|
||||
WHERE id = @tradeId
|
||||
""";
|
||||
|
||||
private static object BuildUpdateTradeStatusParams(Trade trade, JsonElement? kisResponse, string? errorMessage) => new
|
||||
{
|
||||
id = Guid.NewGuid(),
|
||||
tradeId = trade.Id,
|
||||
newStatus = trade.Status.ToString(),
|
||||
kisOrderId = trade.KisOrderId,
|
||||
executedQuantity = trade.ExecutedQuantity,
|
||||
unitPrice = trade.UnitPrice,
|
||||
totalAmount = trade.TotalAmount,
|
||||
commission = trade.Commission,
|
||||
netProceeds = trade.NetProceeds,
|
||||
executionTimestamp = trade.ExecutionTimestamp,
|
||||
settlementTimestamp = trade.SettlementTimestamp,
|
||||
kisResponse = kisResponse?.ToString(),
|
||||
errorMessage,
|
||||
correlationId = trade.CorrelationId
|
||||
};
|
||||
|
||||
public async Task UpdateTradeStatusAsync(
|
||||
Trade trade,
|
||||
JsonElement? kisResponse,
|
||||
@@ -161,51 +210,28 @@ public class TradeSql : ITradeSql
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO model_operations.trade_status_history
|
||||
(id, trade_id, old_status, new_status, transitioned_at, kis_response, error_message, published_at, correlation_id)
|
||||
SELECT @id, id, status, @newStatus, NOW(), @kisResponse::jsonb, @errorMessage, NOW(), @correlationId
|
||||
FROM model_operations.trades
|
||||
WHERE id = @tradeId;
|
||||
|
||||
UPDATE model_operations.trades
|
||||
SET status = @newStatus,
|
||||
kis_order_id = COALESCE(@kisOrderId, kis_order_id),
|
||||
executed_quantity = COALESCE(@executedQuantity, executed_quantity),
|
||||
unit_price = COALESCE(@unitPrice, unit_price),
|
||||
total_amount = COALESCE(@totalAmount, total_amount),
|
||||
commission = COALESCE(@commission, commission),
|
||||
net_proceeds = COALESCE(@netProceeds, net_proceeds),
|
||||
execution_timestamp = COALESCE(@executionTimestamp, execution_timestamp),
|
||||
settlement_timestamp = COALESCE(@settlementTimestamp, settlement_timestamp),
|
||||
kis_response = COALESCE(@kisResponse::jsonb, kis_response),
|
||||
error_message = COALESCE(@errorMessage, error_message),
|
||||
revision = revision + 1
|
||||
WHERE id = @tradeId
|
||||
""";
|
||||
|
||||
await connection.ExecuteAsync(sql, new
|
||||
{
|
||||
id = Guid.NewGuid(),
|
||||
tradeId = trade.Id,
|
||||
newStatus = trade.Status.ToString(),
|
||||
kisOrderId = trade.KisOrderId,
|
||||
executedQuantity = trade.ExecutedQuantity,
|
||||
unitPrice = trade.UnitPrice,
|
||||
totalAmount = trade.TotalAmount,
|
||||
commission = trade.Commission,
|
||||
netProceeds = trade.NetProceeds,
|
||||
executionTimestamp = trade.ExecutionTimestamp,
|
||||
settlementTimestamp = trade.SettlementTimestamp,
|
||||
kisResponse = kisResponse?.ToString(),
|
||||
errorMessage,
|
||||
correlationId = trade.CorrelationId
|
||||
});
|
||||
await connection.ExecuteAsync(UpdateTradeStatusSql, BuildUpdateTradeStatusParams(trade, kisResponse, errorMessage));
|
||||
|
||||
_logger.LogInformation("Updated trade {TradeId} status to {Status}", trade.Id, trade.Status);
|
||||
}
|
||||
|
||||
public async Task UpdateTradeStatusAsync(
|
||||
DbConnection connection,
|
||||
DbTransaction transaction,
|
||||
Trade trade,
|
||||
JsonElement? kisResponse,
|
||||
string? errorMessage,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await connection.ExecuteAsync(new CommandDefinition(
|
||||
UpdateTradeStatusSql,
|
||||
BuildUpdateTradeStatusParams(trade, kisResponse, errorMessage),
|
||||
transaction,
|
||||
cancellationToken: ct));
|
||||
|
||||
_logger.LogInformation("Updated trade {TradeId} status to {Status} (transactional)", trade.Id, trade.Status);
|
||||
}
|
||||
|
||||
public async Task<int> CountTradesByStatusAsync(TradeStatus status, CancellationToken ct = default)
|
||||
{
|
||||
using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
Reference in New Issue
Block a user