From d72422ccb6d0cd2925200c238bf2e5b028b17986 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 19:56:28 +0000 Subject: [PATCH 01/13] docs: add market-researched high-value code brainstorm (2026-07) Adds a prioritized, market-evidence-backed brainstorm of the ten highest-value implementable features, grounded in the roadmap registry (W6/W7 planned lanes), the competitive-landscape reference, and July 2026 market research on fund-ops platforms, TCA, open-source quant stacks, and market-data pricing. Records the session in the brainstorm history ledger. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011vonQATmobZwPb2KcouHnC --- .../brainstorm-history.jsonl | 1 + .../high-value-code-brainstorm-2026-07.md | 376 ++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 docs/plans/high-value-code-brainstorm-2026-07.md diff --git a/.claude/skills/meridian-brainstorm/brainstorm-history.jsonl b/.claude/skills/meridian-brainstorm/brainstorm-history.jsonl index 9d29fbf348..e8c106098a 100644 --- a/.claude/skills/meridian-brainstorm/brainstorm-history.jsonl +++ b/.claude/skills/meridian-brainstorm/brainstorm-history.jsonl @@ -6,3 +6,4 @@ {"session_date": "2026-03-27", "mode": "Domain-Focused", "themes": ["Script-to-strategy promotion wizard", "Script notebook mode (multi-cell)", "Script library / import system", "Scheduled scripts and Alert API", "QuantScript parameter grid search API", "Live streaming data in scripts (v2)", "L3 x QuantScript bridge (L3.Simulate())", "Calibration quality report card", "Toxic flow tagging on fill tape", "L3 simulation replay visualizer", "Options queue model extension", "Symbol-partitioned collection (sharding)", "Leader-elected coordinator instance", "Cross-instance deduplication ledger (IDedupStore)", "Distributed backfill work queue", "Cluster health dashboard panel", "Distributed grid search (Track G x I)", "Cross-symbol correlation regime signals"], "ideas_count": 18, "track": "G-H-I", "notes": "First round of Track G-I brainstorm. Defined L3.Simulate() as highest-leverage cross-track bridge. IDedupStore as foundational Track I seam. Recommended sequencing: CalibrationReportCard -> ScriptLibrary -> L3Bridge -> GridSearch -> IDedupStore -> LeaderElection -> Sharding -> DistributedBackfill."} {"session_date": "2026-03-27", "mode": "Domain-Focused", "themes": ["Script debugger (step/watch/breakpoint)", "Script run history and result diffing", "Security Master access in QuantScript (Securities global)", "Script output export (PDF/Parquet/Excel/HTML)", "Script expression REPL", "Venue-specific L3 calibration profiles (VenueCalibrationPriors)", "TCA report: execution cost attribution", "Multi-day queue state continuity (carry-state)", "Dark pool / hidden liquidity estimator", "Zero-downtime rolling upgrade orchestration", "Geo-distributed collection by exchange timezone", "Split-brain detection and recovery"], "ideas_count": 12, "track": "G-H-I", "notes": "Second round of Track G-I brainstorm. TCA Report identified as highest-leverage institutional positioning item. VenueCalibrationPriors and SplitBrainDetector identified as platform bets. Script REPL flagged as surprise high-leverage small-effort item. Sequencing: REPL + SecurityMaster -> TCA Report -> RunHistory + VenueCalibration -> SplitBrain -> RollingUpgrade + DarkPool + GeoDistributed + Debugger + MultiDayContinuity."} {"session_date": "2026-06-25", "mode": "Domain-Focused", "themes": ["Provider Setup Wizard with Live Certification", "Hot-Reload Provider Re-Registration", "Credential Vault with Encrypted Persistence", "Runtime Failover Chain Editor", "Per-Provider Partial Config Mutation API", "Ingestion Session Continuity on Restart", "Provider Capability Browser in Data Workspace"], "ideas_count": 7, "document_updated": null, "notes": "Fresh domain covering runtime provider lifecycle management — an unexplored area in all prior sessions. Platform bets: Hot-Reload Re-Registration (ConfigWatcher already exists, ProviderRegistry.RegisterStreamingFactory already supports replace) and Per-Provider Partial Config Mutation (ConfigStore.PatchAsync primitive). Recommended sequence: Capability Browser (S) -> Partial Config API (M) -> Hot-Reload (M) -> Failover Editor (S) -> Setup Wizard (M) -> Credential Vault (M) -> Session Continuity (L)."} +{"session_date": "2026-07-01", "mode": "Competitive / Open Exploration", "themes": ["live/paper TCA best-execution report pack", "Alpaca live broker adapter (IOrderGateway)", "reconciliation break-resolution agent (MCP + CoS gates)", "custodian/broker statement connector library (IB Flex, OFX/CSV profiles)", "DuckDB analytics workbench + /api/query", "Python meridian client package (promoted)", "W6 backtesting studio evidence loop (manifest + run comparison)", "LEAN/NautilusTrader data-feed bridge", "governed investor report delivery portal-lite", "data-cost savings meter"], "ideas_count": 10, "document_updated": "docs/plans/high-value-code-brainstorm-2026-07.md", "notes": "Market-researched prioritization session. Signals: TCA now mandatory infrastructure (~$2.8B market), Arcesium productizing Reconciliation Agent, LEAN BYO-data pain, Databento/Polygon pricing validates self-hosted economics. Sequencing: cost meter + TCA + IB Flex connector first; DuckDB -> Python package; Alpaca adapter feeding W7; W6 studio slice; recon agent after connector coverage."} diff --git a/docs/plans/high-value-code-brainstorm-2026-07.md b/docs/plans/high-value-code-brainstorm-2026-07.md new file mode 100644 index 0000000000..3b2f66ff2d --- /dev/null +++ b/docs/plans/high-value-code-brainstorm-2026-07.md @@ -0,0 +1,376 @@ +# High-Value Code Brainstorm — Market-Researched Prioritization (2026-07) + +> **Mode:** Competitive / Open Exploration hybrid — the request asks for market research plus a +> prioritized set of implementable, high-end-user-value features that strengthen the total product +> value proposition. +> +> **Grounding:** `.claude/skills/_shared/project-context.md`, roadmap registry +> (`docs/roadmap/generated/ROADMAP_SUMMARY.md`, snapshot 2026-06-24), competitive-landscape +> reference, and July 2026 web research (sources at the end). +> +> **Roadmap state:** Waves 1–5X are done (data trust gate, paper cockpit, promotion evidence, +> reconciliation readiness, governed report packs, accounting records, multi-asset proof lane, +> financial-operations control center, shared record explorers). The two planned lanes are +> **W6-BTSTUDIO-001** (Backtesting Studio evidence loop) and **W7-LIVE-001** (live-readiness +> governance). The highest-value code either feeds those lanes directly or widens the moat the +> market research says buyers now pay for. + +--- + +## Market Research Summary + +Five signals from the July 2026 landscape shape this prioritization: + +1. **Fund-ops platforms are converging on AI-assisted reconciliation.** Enfusion (now part of + Clearwater Analytics), Arcesium, and SS&C all market front-to-back cloud platforms; Arcesium is + explicitly productizing a "Reconciliation Agent." Industry surveys report workflow automation + cutting manual reconciliation effort by ~51% and real-time compliance monitoring in use at ~47% + of firms. Reconciliation is described as the most demanded but messiest agentic use case. + Meridian already owns a `CanonicalReconciliationEngine`, a CoS approval-gated runtime, and an + MCP server layer — the ingredients no incumbent ships together in a self-hosted package. +2. **TCA is now mandatory infrastructure, not optional reporting.** The TCA market reached ~$2.8B + with MiFID II-style best-execution obligations spreading beyond Europe; vendors (ION LookOut, + ACA, Quod) sell 80+ metric engines, and quant funds credit TCA with 10–30 bps/yr. Meridian has + TCA models only on the backtest side (`PostSimulationTcaReporter`, `TcaReportModels`) — not on + the paper/live fill tape where the regulatory and operator value lives. +3. **Open-source quant stacks still lack an ops/accounting story.** QuantConnect LEAN self-hosting + is "significant DevOps work and bring-your-own-data"; NautilusTrader is execution-mechanics + strong but has no ledger, reconciliation, or governed reporting. Meridian's front-to-back + self-hosted platform is unique — and interop bridges capture those communities instead of + fighting them. +4. **Market-data economics favor self-hosted collection.** Databento live tiers run $1,399–$3,500/mo + and metered pricing "scales aggressively"; Polygon Stocks Advanced is $199/mo with no + structured local storage. Meridian's multi-provider, own-your-data collection amortizes to near + zero marginal cost — but the value story is invisible unless the product surfaces it and makes + the local store queryable. +5. **Buyers expect multi-asset, real-time consolidation, and open integration.** ~61% of hedge-fund + platform users need multi-asset coverage; 2026 platform-selection commentary emphasizes open + integration frameworks and AI-agent readiness as the reason firms leave legacy systems. + +--- + +## Ideas at a Glance + +| # | Idea | Effort | Audience | Impact | Depends On | +|---|------|--------|----------|--------|------------| +| 1 | Live/Paper TCA & best-execution report pack | M | I | High | — | +| 2 | Alpaca live broker adapter behind `IOrderGateway` | M | H, I | High | — | +| 3 | Reconciliation break-resolution agent (MCP + CoS gates) | M | I | High | — | +| 4 | Custodian/broker statement connector library | M | I, H | High | — | +| 5 | DuckDB analytics workbench in the Data workspace | M | Q, H | High | — | +| 6 | Python `meridian` client package | M | H, Q | High | 5 (optional) | +| 7 | W6 Backtesting Studio evidence loop (run comparison + reproducibility manifest) | L | all | High | — | +| 8 | LEAN / NautilusTrader data-feed bridge | M | H, Q | Med-High | 6 (optional) | +| 9 | Governed investor report delivery ("portal-lite") | L | I | Med-High | — | +| 10 | Data-cost savings meter ("what this would have cost") | S | all | Med | — | + +Effort: **S** = days, **M** = 1–2 weeks, **L** = 1+ month. Audience: **H** = hobbyist quant, +**Q** = academic, **I** = institutional/professional. + +> Continuity note: ideas 1, 5, and 6 were surfaced in earlier sessions (2026-03 ledger) as +> promising; this session **promotes them with market evidence** — TCA regulatory demand, data-cost +> economics, and the Python-SDK competitive gap respectively. Ideas 2, 3, 4, 9, and 10 are new +> territory for the ledger. + +--- + +## The Ideas + +### 1. Live/Paper TCA & Best-Execution Report Pack + +Meridian already computes TCA for simulations — `PostSimulationTcaReporter` in +`src/Meridian.Backtesting/Metrics/` and the shared models in +`src/Meridian.Backtesting.Sdk/TcaReportModels.cs`. But the fill tape that operators actually +answer for — paper sessions today, live sessions under W7 — has no cost attribution at all. The +market research is unambiguous: TCA has shifted from optional to mandatory infrastructure, and it +is sold as a $2.8B standalone category. Meridian can ship it as a built-in. + +The move is to lift the TCA model out of the backtest pillar into a shared surface: run the same +implementation-shortfall / arrival-price / spread-capture decomposition over +`OrderManagementSystem` fill history from `PaperTradingGateway` (and any future live gateway), and +emit the result through the existing governed report-pack machinery from W4-RPT-001. The operator +moment: in the Trading workspace, a completed session shows a "Execution cost" chip; clicking it +opens per-order slippage decomposition with benchmark comparisons, and "Add to report pack" +produces the auditable best-execution artifact compliance asks for. + +Tradeoffs: benchmark prices need trustworthy market data at fill timestamps — the W1 data-trust +gate helps, but missing-tick handling must be explicit (grade the report by data confidence rather +than silently interpolating). This is also the single strongest tie-in between the Trading and +Reporting workspaces, so contract placement matters: models belong in a shared SDK location, not +duplicated from Backtesting.Sdk. + +**Why now:** feeds W7 live-readiness directly; institutional differentiator no open-source +competitor has; models already exist and are tested on the backtest side. + +### 2. Alpaca Live Broker Adapter Behind `IOrderGateway` + +`PaperTradingGateway` (`src/Meridian.Execution/Adapters/PaperTradingGateway.cs`) is still the only +live-capable adapter. W7-LIVE-001 — live-readiness governance — has nothing real to govern until at +least one broker adapter exists. The competitive scan makes this the sharpest gap: NautilusTrader's +whole pitch is surviving the backtest→live jump, and Meridian's promotion workflow +(`BacktestToLivePromoter`, paper-first gate pattern from ADR-015/016) is architecturally ahead but +ends at paper. + +Alpaca is the right first adapter: Meridian already ships `AlpacaMarketDataClient` +(`src/Meridian.Infrastructure/Adapters/Alpaca/`), so credentials, HTTP resilience patterns, and +symbol mapping exist; the trading API is REST+SSE with a free paper environment for CI-safe +integration tests. Implement `IExecutionGateway` (`src/Meridian.Execution.Sdk/`), wire it through +`OrderManagementSystem`, and keep it gated behind `ExecutionMode.Live` plus the +`CompositeRiskValidator` pre-trade chain. The user moment: the promotion workflow's final step +changes from "paper only" to a governed live toggle with an explicit operator sign-off — exactly +the approval-gate pattern the CoS runtime was built for. + +Tradeoffs: live order routing is the highest-blast-radius code in the product. Scope tightly — +market/limit/day orders, single account, equities only — and let the risk-rule chain and W7 +governance carry the safety story. Order-state reconciliation against broker truth (fills arriving +after disconnect) is the hard 20%. + +**Why now:** unlocks W7, completes the "collection → backtest → paper → live" value proposition +that the differentiation matrix already promises, and converts the promotion workflow from demo to +product. + +### 3. Reconciliation Break-Resolution Agent (MCP + CoS Approval Gates) + +Arcesium is marketing a "Reconciliation Agent"; surveys put reconciliation automation at the top of +fund-ops AI demand while calling it the messiest use case. Meridian is unusually well positioned: +`CanonicalReconciliationEngine` and `StatementReconciliationOrchestrator` +(`src/Meridian.FinancialOperations/Reconciliation/`) already produce structured breaks, the MCP +server layer (`src/Meridian.Mcp/`, `src/Meridian.McpServer/`) already exposes tools to AI agents, +and the CoS runtime already does approval-gated, evidence-retained orchestration. No competitor +ships this combination self-hosted. + +Build an MCP toolset over the reconciliation surface: `list_open_breaks`, `get_break_evidence` +(both sides, lineage, prior similar breaks), `propose_resolution` (match, adjust, journal-entry +draft), and `submit_for_approval` — the last routed through the CoS approval gate so a human +operator signs off in the accounting screen's close cockpit before anything posts to the `Ledger`. +The operator moment: the accounting workspace's break queue gains a "suggested resolution" column +with confidence and cited evidence; the operator approves, edits, or rejects, and every action +lands in the evidence timeline that already exists in the workstation shell. + +Tradeoffs: the agent must never write directly — the tool contract has to be propose-only with the +approval gate as the sole mutation path, and evaluation needs a replayable corpus of historical +breaks to measure suggestion quality before operators see it. Start with the highest-frequency +break classes (quantity/price mismatches on plain-vanilla positions), not the long tail. + +**Why now:** this is the single feature where Meridian's existing MCP moat converts into +fund-operations differentiation the market is actively naming and pricing. + +### 4. Custodian/Broker Statement Connector Library + +Reconciliation readiness (W4-RECON-001) shipped the engine; `StatementMappingProfiles` shows the +canonical mapping seam. What limits real-world adoption is the unglamorous part: getting actual +custodian and broker statements into canonical form. Every fund-ops platform selection guide +weights connector breadth heavily, and for small funds the connectors *are* the product. + +Ship a connector library as data, not code, wherever possible: an IB Flex Report connector (XML, +well-documented, huge overlap with Meridian's IB users), an Alpaca account-activity connector +(pairs with idea 2), and a declarative CSV/OFX mapping-profile format so operators can onboard a +new custodian format without a release. The operator moment: in the Accounting workspace, "Import +statement" accepts a file or a scheduled fetch, previews the canonical mapping with per-column +confidence, and drops straight into the existing reconciliation queue. A mapping-profile editor +with live preview turns a support burden into a self-service surface. + +Tradeoffs: statement formats drift; each connector needs golden-file regression tests and a +versioned profile schema (respect ADR-014 source-generated JSON and route persistence through +`AtomicFileWriter`). Scheduled fetching pulls in credential storage — reuse the desktop persistence +baseline rather than inventing a new secret store. + +**Why now:** multiplies the value of already-shipped W4/W5 lanes and of idea 3 (the agent is only +as good as statement coverage); high end-user value per unit effort for the fund-ops persona. + +### 5. DuckDB Analytics Workbench in the Data Workspace + +The data-economics research shows Meridian's core advantage — self-hosted, no per-query fees — but +that advantage is latent while the local Parquet/JSONL store is only reachable through files. +`PortableDataPackager` already generates DuckDB SQL scripts +(`src/Meridian.Storage/Packaging/PortableDataPackager.Scripts.Sql.cs`), so the schema mapping is +proven; the step is embedding DuckDB (via `DuckDB.NET`, added through central package management) +behind a read-only query service and a workbench panel in the Data workspace. + +The user moment: a "Query" tab in the Data screen with a SQL editor, schema browser listing the +catalog from `StorageCatalogService`, result grid, and one-click export to Parquet/CSV. A hobbyist +answers "show me AAPL spread by minute for March" without leaving the app; an academic validates a +dataset before export. Expose the same service as `/api/query` through +`WorkstationEndpoints` so the browser and WPF surfaces share it — and idea 6 gets a query +transport for free. + +Tradeoffs: unbounded SQL over large stores needs guardrails — read-only connection, query timeout, +row limits, and memory caps; queries must run out-of-process from the hot ingestion path so a bad +scan can't stall `EventPipeline` flushes. + +**Why now:** turns the "own your data" cost advantage into a daily-felt feature; validated demand +(Polygon now sells an SQL query option as a premium capability); prior-session idea now backed by +market evidence. + +### 6. Python `meridian` Client Package + +The competitive matrix has shown "Python SDK: No / Planned" against Bloomberg, Databento, and +Polygon all shipping one since the first landscape scan — and the 2026 backtesting-landscape +commentary confirms Python remains the community's center of gravity. Every quarter without it, +Meridian's collection and backtest evidence is invisible to the tools quants actually analyze in. + +Scope v1 as a thin, honest client over the existing HTTP surface (`WorkstationEndpoints` plus +`/api/query` from idea 5): `meridian.history(symbols, start, end)` returning a pandas DataFrame, +`meridian.live(symbols)` as an async iterator over the WebSocket feed, `meridian.runs()` / +`meridian.run(id)` fetching backtest and paper-session results with their metrics, and +`meridian.sql(query)` when the workbench lands. Publish to PyPI with a README notebook that goes +from `pip install meridian-client` to a plotted equity curve in ten lines. The user moment is the +hobbyist's first Jupyter cell showing live ticks from their own self-hosted collector. + +Tradeoffs: it's a second artifact with its own release cadence and API-compatibility surface — +version it against the workstation API contract and generate models from the OpenAPI spec rather +than hand-maintaining them. Resist scope creep toward a full ORM; the DataFrame boundary is the +product. + +**Why now:** cheapest way to widen the funnel to the largest persona; unlocks idea 8; repeatedly +deferred while remaining the most-cited competitive gap. + +### 7. W6 Backtesting Studio Evidence Loop — Run Comparison + Reproducibility Manifest + +This is the planned roadmap lane (W6-BTSTUDIO-001), and prior sessions already ideated its parts: +multi-run comparison, reproducibility manifests, promotion evidence. The market angle from this +session's research: LEAN's weakness is DevOps and data friction, NautilusTrader's is +research-to-ops workflow — a studio where every backtest is a *governed, reproducible evidence +artifact* is the differentiated version of "backtesting UI," consistent with Meridian's +evidence-led identity rather than a me-too chart page. + +Concretely: every run in `StrategyRunStore` gains a reproducibility manifest (code/strategy +version, parameter set, data-catalog snapshot hash, fill-model config, seed); the Strategy +workspace gains a run-comparison view (equity curves overlaid, metric deltas, parameter diff) built +on `StrategyRunReadService`; and the existing paper-promotion workflow consumes the manifest as its +evidence input, closing the loop W2/W3 opened. The operator moment: select two runs, see exactly +what changed and what it did to the numbers, and promote with the manifest attached — the +comparison view *is* the promotion justification. + +Tradeoffs: it's the largest item here; sequence it as manifest first (S–M, pure data model), then +comparison view (M), then promotion-evidence wiring (S). Data-snapshot hashing needs a cheap +catalog-level identity, not content-hashing terabytes. + +**Why now:** it is literally the next roadmap item, and manifest + comparison is the highest-value +slice of it; ideas 1 and 2 both strengthen it (TCA metrics appear in run comparison; live adapter +gives promotion somewhere to go). + +### 8. LEAN / NautilusTrader Data-Feed Bridge + +The open-source scan's clearest finding: LEAN self-hosters must "bring your own data," and that +pain is documented in their own community. Meridian can be the collector that feeds your +backtesting framework — an on-ramp that meets quants where they already are and makes Meridian's +data layer sticky even for users who never open the workstation. + +Ship two thin bridges: a LEAN-format exporter (LEAN's zip/csv data folder layout for equities, +minute/second resolution) runnable as `meridian export --format lean`, and a NautilusTrader loader +in the Python package (idea 6) mapping Meridian Parquet to Nautilus data objects. The user moment: +a LEAN user points their config at a Meridian-exported data folder and their existing algorithm +just runs — with better, gap-audited data than they had. + +Tradeoffs: format fidelity is the whole game — golden-file tests against LEAN's own sample data; +corporate-action handling differences need explicit documentation. Keep it export-shaped (batch) +rather than implementing LEAN's live `IDataQueueHandler` in v1. + +**Why now:** cheap growth surface with a defined target format; converts competitor communities +into Meridian data users rather than fighting for their whole stack. + +### 9. Governed Investor Report Delivery ("Portal-Lite") + +W4-RPT-001 and W5X shipped governed report packs and delivery-history UI plumbing +(`reporting-screen.delivery-history.tsx`, branding-access modules already exist in the dashboard). +The fund-ops market research shows client-facing transparency is a top selection criterion, and +"client portal" is already a sanctioned expansion lane in the project context. Portal-lite is the +minimal version: branded PDF/HTML render of an approved report pack, tokenized read-only share +links with expiry, and a delivery audit trail (who accessed what, when) folded into the existing +evidence timeline. + +The operator moment: from the Reporting workspace, "Deliver" on an approved pack generates the +branded artifact and a revocable link; the delivery-history panel shows access events. No investor +login system, no new top-level navigation — it stays inside the Reporting workspace and the +existing approval gates. + +Tradeoffs: anything outward-facing raises the security bar — signed URLs, expiry, revocation, and +rate limiting are table stakes; rendering fidelity for branded PDFs is a real time sink. Defer +investor identity/accounts entirely; that's the full-portal lane. + +**Why now:** high perceived value for the fund persona at modest incremental cost because the +approval, branding, and delivery-history seams already exist. + +### 10. Data-Cost Savings Meter + +Small, sharp, and directly from the pricing research: Meridian knows exactly what it collected +(symbols × days × depth via `StorageCatalogService` and Prometheus counters). Price that against +public rate cards (Databento ~$0.10–$1.00/symbol-day historical, live tiers $1,399+/mo; Polygon +$199/mo) and show a running "estimated replacement cost" of the user's self-hosted dataset — on the +Data workspace and in the governed report pack summary. + +The user moment: the Data screen header reads "Your local store: 214 GB · est. replacement cost +$3,120/mo on metered vendors," with a drill-down by asset class and a methodology note. For a fund +operator it quantifies the platform in budget language; for a hobbyist it's the screenshot they +post. It's also honest marketing: the rate-card table lives in a config file with citations and a +"last verified" date, not hardcoded claims. + +Tradeoffs: pricing comparisons go stale and must be conservative and clearly labeled as estimates; +keep the mapping simple (per symbol-day by resolution tier) rather than simulating vendor bills +precisely. + +**Why now:** days of effort, makes the platform's core economic moat visible every single session, +and strengthens every sales/adoption conversation. + +--- + +## Synthesis + +**Highest-leverage single item: #1, Live/Paper TCA.** The models exist, the report-pack machinery +exists, the market prices TCA as mandatory infrastructure, and it upgrades three surfaces at once +(Trading, Reporting, and the W6 run-comparison view). Best impact-per-effort in the set. + +**Platform bets:** #2 (live broker adapter) and #5 (DuckDB service). The adapter converts the +promotion workflow and W7 from governance-without-an-object into the completed +collection→backtest→paper→live story; the query service becomes the shared transport under the +workbench UI, the Python package, the cost meter's drill-downs, and future analytics surfaces. + +**Cross-cutting theme:** almost everything here is *evidence productization* — TCA artifacts, +reconciliation evidence, reproducibility manifests, delivery audit trails, even the cost meter. +Meridian's identity ("evidence-backed investment operations") is also its differentiation; a shared +evidence-artifact pattern (typed payload + lineage + approval state + timeline entry) would keep +these five features from inventing five formats. + +**Sequencing recommendation:** + +1. **Now (parallel lanes):** #10 cost meter (S, instant story) · #1 TCA on the paper fill tape (M) + · #4 statement connectors starting with IB Flex (M). +2. **Next:** #5 DuckDB service + workbench (M) → #6 Python package (M) riding the same API · #2 + Alpaca adapter (M) feeding W7. +3. **Then:** #7 W6 studio slice (manifest → comparison → promotion evidence), enriched by TCA + metrics from step 1 · #3 reconciliation agent once connector coverage (#4) gives it enough + break volume to learn from. +4. **Later:** #8 LEAN bridge (after #6) · #9 portal-lite (after a security review of outward + surfaces). + +**Competitive signals:** Bloomberg-tier incumbents and the fund-ops platforms (Enfusion/Clearwater, +Arcesium, SS&C) are selling exactly two things Meridian can ship in code this quarter — +AI-assisted reconciliation and best-execution evidence — but only as cloud services with +per-seat pricing. Databento/Polygon own developer mindshare through Python-first ergonomics and +now sell SQL-over-your-data as premium features; Meridian's self-hosted architecture delivers both +without metering, and the DuckDB + Python pair is the most adaptable pattern to borrow. The +open-source engines (LEAN, NautilusTrader) validate that no one else owns the governed +research→paper→live→books loop — which is why the W6/W7 lanes plus TCA are the moat-wideners, not +just roadmap chores. + +--- + +## Sources + +- [Limina — Enfusion (now part of Clearwater Analytics) competitors & alternatives](https://www.limina.com/enfusion) +- [Arcesium — The Arrival of the Reconciliation Agent](https://www.arcesium.com/blog/real-time-reconciliation-agent) +- [Arcesium — Data & operations approach for launching a new hedge fund](https://www.arcesium.com/blog/what-is-the-best-data-operations-approach-for-launching-a-new-hedge-fund) +- [FundCount — Best hedge fund portfolio management software (2026)](https://fundcount.com/best-hedge-fund-portfolio-management-software/) +- [360MarketUpdates — Hedge fund software market size and forecast 2026–2035](https://www.360marketupdates.com/market-reports/hedge-fund-software-market-400051) +- [StackAI — Agentic AI in multi-strategy hedge fund operations](https://www.stackai.com/insights/agentic-ai-in-multi-strategy-hedge-fund-operations-practical-use-cases-automation-and-governance-for-bam-style-platforms) +- [Digiqt — AI agents in hedge funds: use cases (2026)](https://digiqt.com/blog/ai-agents-in-hedge-funds/) +- [Finantrix — Buyer's guide: TCA tools for quantitative funds](https://www.finantrix.com/buyer-guides/transaction-cost-analysis-tools-quant-funds) +- [PR Newswire — ION LookOut TCA wins RegTech Insight Award Europe 2026](https://www.prnewswire.com/news-releases/ion-lookout-tca-wins-best-transaction-cost-analysis-solution-for-best-execution-at-regtech-insight-awards--europe-2026-302778146.html) +- [A-Team Insight — Top transaction cost analysis solutions](https://a-teaminsight.com/blog/the-top-transaction-cost-analysis-tca-solutions/) +- [python.financial — The Python backtesting landscape (2026)](https://python.financial/) +- [QuantConnect LEAN — GitHub](https://github.com/QuantConnect/Lean) +- [AI Fin Hub — Market data APIs compared: Databento vs Polygon 2026](https://aifinhub.io/articles/market-data-apis-compared-2026/) +- [edgeful — Futures data API comparison: Polygon vs Databento (2026)](https://www.edgeful.com/blog/posts/futures-data-api-polygon-databento-edgeful-comparison) +- [Databento — Pricing](https://databento.com/pricing) +- [Alphanume — Best market data APIs for algorithmic trading in 2026](https://www.alphanume.com/blog/best-market-data-apis-for-algorithmic-trading-in-2026) From 611210d3cf9e002b4011c144e09bf729bf7623d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 20:01:38 +0000 Subject: [PATCH 02/13] docs: regenerate doc dashboards and clarify Alpaca adapter contract Regenerates the generated status docs (doc health dashboard, coverage report, API contract coverage, TODO index, example validation, repository structure) so the regenerate-docs check passes with the new brainstorm document included. Also renames the Alpaca adapter idea to reference IExecutionGateway, the broker adapter contract it would implement, rather than the strategy-facing IOrderGateway, per review feedback. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011vonQATmobZwPb2KcouHnC --- .../brainstorm-history.jsonl | 2 +- docs/generated/repository-structure.md | 227 +++++++++++++++++- .../high-value-code-brainstorm-2026-07.md | 4 +- docs/status/TODO.md | 46 ++-- .../api-contract-coverage-dashboard.json | 152 ++++++------ .../status/api-contract-coverage-dashboard.md | 152 ++++++------ docs/status/coverage-report.md | 104 ++++---- docs/status/doc-health-dashboard.json | 41 ++-- docs/status/doc-health-dashboard.md | 12 +- docs/status/example-validation.md | 2 +- 10 files changed, 481 insertions(+), 261 deletions(-) diff --git a/.claude/skills/meridian-brainstorm/brainstorm-history.jsonl b/.claude/skills/meridian-brainstorm/brainstorm-history.jsonl index e8c106098a..9421ee594a 100644 --- a/.claude/skills/meridian-brainstorm/brainstorm-history.jsonl +++ b/.claude/skills/meridian-brainstorm/brainstorm-history.jsonl @@ -6,4 +6,4 @@ {"session_date": "2026-03-27", "mode": "Domain-Focused", "themes": ["Script-to-strategy promotion wizard", "Script notebook mode (multi-cell)", "Script library / import system", "Scheduled scripts and Alert API", "QuantScript parameter grid search API", "Live streaming data in scripts (v2)", "L3 x QuantScript bridge (L3.Simulate())", "Calibration quality report card", "Toxic flow tagging on fill tape", "L3 simulation replay visualizer", "Options queue model extension", "Symbol-partitioned collection (sharding)", "Leader-elected coordinator instance", "Cross-instance deduplication ledger (IDedupStore)", "Distributed backfill work queue", "Cluster health dashboard panel", "Distributed grid search (Track G x I)", "Cross-symbol correlation regime signals"], "ideas_count": 18, "track": "G-H-I", "notes": "First round of Track G-I brainstorm. Defined L3.Simulate() as highest-leverage cross-track bridge. IDedupStore as foundational Track I seam. Recommended sequencing: CalibrationReportCard -> ScriptLibrary -> L3Bridge -> GridSearch -> IDedupStore -> LeaderElection -> Sharding -> DistributedBackfill."} {"session_date": "2026-03-27", "mode": "Domain-Focused", "themes": ["Script debugger (step/watch/breakpoint)", "Script run history and result diffing", "Security Master access in QuantScript (Securities global)", "Script output export (PDF/Parquet/Excel/HTML)", "Script expression REPL", "Venue-specific L3 calibration profiles (VenueCalibrationPriors)", "TCA report: execution cost attribution", "Multi-day queue state continuity (carry-state)", "Dark pool / hidden liquidity estimator", "Zero-downtime rolling upgrade orchestration", "Geo-distributed collection by exchange timezone", "Split-brain detection and recovery"], "ideas_count": 12, "track": "G-H-I", "notes": "Second round of Track G-I brainstorm. TCA Report identified as highest-leverage institutional positioning item. VenueCalibrationPriors and SplitBrainDetector identified as platform bets. Script REPL flagged as surprise high-leverage small-effort item. Sequencing: REPL + SecurityMaster -> TCA Report -> RunHistory + VenueCalibration -> SplitBrain -> RollingUpgrade + DarkPool + GeoDistributed + Debugger + MultiDayContinuity."} {"session_date": "2026-06-25", "mode": "Domain-Focused", "themes": ["Provider Setup Wizard with Live Certification", "Hot-Reload Provider Re-Registration", "Credential Vault with Encrypted Persistence", "Runtime Failover Chain Editor", "Per-Provider Partial Config Mutation API", "Ingestion Session Continuity on Restart", "Provider Capability Browser in Data Workspace"], "ideas_count": 7, "document_updated": null, "notes": "Fresh domain covering runtime provider lifecycle management — an unexplored area in all prior sessions. Platform bets: Hot-Reload Re-Registration (ConfigWatcher already exists, ProviderRegistry.RegisterStreamingFactory already supports replace) and Per-Provider Partial Config Mutation (ConfigStore.PatchAsync primitive). Recommended sequence: Capability Browser (S) -> Partial Config API (M) -> Hot-Reload (M) -> Failover Editor (S) -> Setup Wizard (M) -> Credential Vault (M) -> Session Continuity (L)."} -{"session_date": "2026-07-01", "mode": "Competitive / Open Exploration", "themes": ["live/paper TCA best-execution report pack", "Alpaca live broker adapter (IOrderGateway)", "reconciliation break-resolution agent (MCP + CoS gates)", "custodian/broker statement connector library (IB Flex, OFX/CSV profiles)", "DuckDB analytics workbench + /api/query", "Python meridian client package (promoted)", "W6 backtesting studio evidence loop (manifest + run comparison)", "LEAN/NautilusTrader data-feed bridge", "governed investor report delivery portal-lite", "data-cost savings meter"], "ideas_count": 10, "document_updated": "docs/plans/high-value-code-brainstorm-2026-07.md", "notes": "Market-researched prioritization session. Signals: TCA now mandatory infrastructure (~$2.8B market), Arcesium productizing Reconciliation Agent, LEAN BYO-data pain, Databento/Polygon pricing validates self-hosted economics. Sequencing: cost meter + TCA + IB Flex connector first; DuckDB -> Python package; Alpaca adapter feeding W7; W6 studio slice; recon agent after connector coverage."} +{"session_date": "2026-07-01", "mode": "Competitive / Open Exploration", "themes": ["live/paper TCA best-execution report pack", "Alpaca live broker adapter (IExecutionGateway)", "reconciliation break-resolution agent (MCP + CoS gates)", "custodian/broker statement connector library (IB Flex, OFX/CSV profiles)", "DuckDB analytics workbench + /api/query", "Python meridian client package (promoted)", "W6 backtesting studio evidence loop (manifest + run comparison)", "LEAN/NautilusTrader data-feed bridge", "governed investor report delivery portal-lite", "data-cost savings meter"], "ideas_count": 10, "document_updated": "docs/plans/high-value-code-brainstorm-2026-07.md", "notes": "Market-researched prioritization session. Signals: TCA now mandatory infrastructure (~$2.8B market), Arcesium productizing Reconciliation Agent, LEAN BYO-data pain, Databento/Polygon pricing validates self-hosted economics. Sequencing: cost meter + TCA + IB Flex connector first; DuckDB -> Python package; Alpaca adapter feeding W7; W6 studio slice; recon agent after connector coverage."} diff --git a/docs/generated/repository-structure.md b/docs/generated/repository-structure.md index b38d35bc6b..2e8d93ba20 100644 --- a/docs/generated/repository-structure.md +++ b/docs/generated/repository-structure.md @@ -307,6 +307,7 @@ Meridian-main │ │ │ ├── CHANGELOG.md │ │ │ └── SKILL.md │ │ └── skills_provider.py +│ ├── launch.json │ ├── settings.json │ └── settings.local.json ├── .codex @@ -1716,6 +1717,7 @@ Meridian-main │ │ ├── fund-management-pr-sequenced-roadmap.md │ │ ├── fund-management-product-vision-and-capability-matrix.md │ │ ├── governance-fund-ops-blueprint.md +│ │ ├── high-value-code-brainstorm-2026-07.md │ │ ├── kernel-parity-migration-blueprint.md │ │ ├── l3-inference-implementation-plan.md │ │ ├── ledger.md @@ -1960,18 +1962,26 @@ Meridian-main │ │ │ ├── wpf-workflow-library.png │ │ │ └── wpf-workspaces.png │ │ ├── web +│ │ │ ├── web-accounting-account-detail.png +│ │ │ ├── web-accounting-approval-inbox.png │ │ │ ├── web-accounting-approvals.png +│ │ │ ├── web-accounting-asset-detail.png │ │ │ ├── web-accounting-capital-accounts.png +│ │ │ ├── web-accounting-close-calendar.png │ │ │ ├── web-accounting-configure.png │ │ │ ├── web-accounting-entity-setup.png +│ │ │ ├── web-accounting-evidence-detail.png │ │ │ ├── web-accounting-evidence-workbench.png │ │ │ ├── web-accounting-exceptions.png │ │ │ ├── web-accounting-external-gl-reconciliation.png │ │ │ ├── web-accounting-journal-entries.png +│ │ │ ├── web-accounting-journal-entry-detail.png │ │ │ ├── web-accounting-ledger.png │ │ │ ├── web-accounting-operations-continuity.png +│ │ │ ├── web-accounting-reconciliation-match.png │ │ │ ├── web-accounting-reconciliation.png │ │ │ ├── web-accounting-security-master.png +│ │ │ ├── web-accounting-trial-balance-detail.png │ │ │ ├── web-accounting-workspace.png │ │ │ ├── web-daily-control-tower.png │ │ │ ├── web-data-alerts.png @@ -1983,6 +1993,7 @@ Meridian-main │ │ │ ├── web-data-workspace.png │ │ │ ├── web-operator-readiness-console.png │ │ │ ├── web-overview-workspace.png +│ │ │ ├── web-portfolio-asset-detail.png │ │ │ ├── web-portfolio-attribution.png │ │ │ ├── web-portfolio-brokerage-sync.png │ │ │ ├── web-portfolio-family-office.png @@ -1990,10 +2001,15 @@ Meridian-main │ │ │ ├── web-reporting-evidence-workbench.png │ │ │ ├── web-reporting-exports.png │ │ │ ├── web-reporting-governance.png +│ │ │ ├── web-reporting-library.png │ │ │ ├── web-reporting-operations-record.png +│ │ │ ├── web-reporting-preview-validation.png │ │ │ ├── web-reporting-report-builder.png │ │ │ ├── web-reporting-report-packs.png +│ │ │ ├── web-reporting-run-detail.png +│ │ │ ├── web-reporting-run-parameters.png │ │ │ ├── web-reporting-run-status.png +│ │ │ ├── web-reporting-scheduled.png │ │ │ ├── web-reporting-workspace.png │ │ │ ├── web-settings-alpaca-provider-setup.png │ │ │ ├── web-settings-backend-capability-coverage.png @@ -3256,7 +3272,9 @@ Meridian-main │ │ ├── StrategyEngine │ │ │ └── StrategyEngineContracts.cs │ │ ├── Tenancy -│ │ │ └── FundProfileTenancy.cs +│ │ │ ├── FundProfileTenancy.cs +│ │ │ ├── FundScopedWriteTenantGate.cs +│ │ │ └── TenantReadPredicate.cs │ │ ├── Treasury │ │ │ └── MoneyMarketFundDtos.cs │ │ ├── Workstation @@ -3855,6 +3873,7 @@ Meridian-main │ │ │ │ └── StreamingFailoverService.cs │ │ │ ├── Finnhub │ │ │ │ ├── FinnhubConstants.cs +│ │ │ │ ├── FinnhubCorporateActionProvider.cs │ │ │ │ ├── FinnhubHistoricalDataProvider.cs │ │ │ │ └── FinnhubSymbolSearchProviderRefactored.cs │ │ │ ├── Fred @@ -3878,7 +3897,8 @@ Meridian-main │ │ │ │ └── IBSimulationClient.cs │ │ │ ├── NasdaqDataLink │ │ │ │ ├── NasdaqDataLinkCorporateActionProvider.cs -│ │ │ │ └── NasdaqDataLinkHistoricalDataProvider.cs +│ │ │ │ ├── NasdaqDataLinkHistoricalDataProvider.cs +│ │ │ │ └── NasdaqDataLinkSymbolSearchProvider.cs │ │ │ ├── NYSE │ │ │ │ ├── NYSEDataSource.cs │ │ │ │ ├── NyseMarketDataClient.cs @@ -4377,7 +4397,8 @@ Meridian-main │ │ ├── FundAccounts │ │ │ ├── Migrations │ │ │ │ ├── 001_fund_accounts.sql -│ │ │ │ └── 002_add_operational_status.sql +│ │ │ │ ├── 002_add_operational_status.sql +│ │ │ │ └── 003_fund_account_tenant_column.sql │ │ │ ├── FundAccountMigrationRunner.cs │ │ │ ├── FundAccountStoreOptions.cs │ │ │ ├── IFundAccountStore.cs @@ -4422,7 +4443,9 @@ Meridian-main │ │ │ │ ├── V_ledger_017__accounting_configuration_audit_tenant_scope.sql │ │ │ │ ├── V_ledger_018__accounting_audit_fund_lower_index.sql │ │ │ │ ├── V_ledger_019__fund_profile_tenancy.sql -│ │ │ │ └── V_ledger_020__fund_scope_tenant_columns.sql +│ │ │ │ ├── V_ledger_020__fund_scope_tenant_columns.sql +│ │ │ │ ├── V_ledger_021__operations_continuity_tenant_column.sql +│ │ │ │ └── V_ledger_022__tenant_lower_indexes.sql │ │ │ ├── AccountingPostingCommandValidator.cs │ │ │ ├── ILedgerJournalStore.cs │ │ │ ├── LedgerBookServiceException.cs @@ -4634,18 +4657,127 @@ Meridian-main │ │ │ │ └── smoke-workstation.mjs │ │ │ ├── src │ │ │ │ ├── assets -│ │ │ │ │ └── brand -│ │ │ │ │ └── meridian-mark.svg +│ │ │ │ │ ├── brand +│ │ │ │ │ │ ├── meridian-hero.svg +│ │ │ │ │ │ ├── meridian-mark-light.svg +│ │ │ │ │ │ ├── meridian-mark-monochrome.svg +│ │ │ │ │ │ ├── meridian-mark.svg +│ │ │ │ │ │ ├── meridian-symbol.svg +│ │ │ │ │ │ ├── meridian-tile.svg +│ │ │ │ │ │ ├── meridian-wordmark-stacked.svg +│ │ │ │ │ │ └── meridian-wordmark.svg +│ │ │ │ │ └── icons +│ │ │ │ │ ├── account-portfolio.svg +│ │ │ │ │ ├── admin-maintenance.svg +│ │ │ │ │ ├── aggregate-portfolio.svg +│ │ │ │ │ ├── archive-health.svg +│ │ │ │ │ ├── backfill.svg +│ │ │ │ │ ├── backtest.svg +│ │ │ │ │ ├── charting.svg +│ │ │ │ │ ├── collection-sessions.svg +│ │ │ │ │ ├── dashboard.svg +│ │ │ │ │ ├── data-browser.svg +│ │ │ │ │ ├── data-calendar.svg +│ │ │ │ │ ├── data-export.svg +│ │ │ │ │ ├── data-operations.svg +│ │ │ │ │ ├── data-quality.svg +│ │ │ │ │ ├── data-sampling.svg +│ │ │ │ │ ├── data-sources.svg +│ │ │ │ │ ├── diagnostics.svg +│ │ │ │ │ ├── event-replay.svg +│ │ │ │ │ ├── formula.svg +│ │ │ │ │ ├── governance.svg +│ │ │ │ │ ├── help.svg +│ │ │ │ │ ├── index-subscription.svg +│ │ │ │ │ ├── keyboard-shortcuts.svg +│ │ │ │ │ ├── lean-integration.svg +│ │ │ │ │ ├── live-data.svg +│ │ │ │ │ ├── order-book.svg +│ │ │ │ │ ├── portfolio-import.svg +│ │ │ │ │ ├── provider-health.svg +│ │ │ │ │ ├── research.svg +│ │ │ │ │ ├── retention-assurance.svg +│ │ │ │ │ ├── run-detail.svg +│ │ │ │ │ ├── run-ledger.svg +│ │ │ │ │ ├── run-mat.svg +│ │ │ │ │ ├── run-portfolio.svg +│ │ │ │ │ ├── schedule-manager.svg +│ │ │ │ │ ├── security-master.svg +│ │ │ │ │ ├── service-manager.svg +│ │ │ │ │ ├── settings.svg +│ │ │ │ │ ├── storage-optimization.svg +│ │ │ │ │ ├── storage.svg +│ │ │ │ │ ├── strategy-builder.svg +│ │ │ │ │ ├── strategy-runs.svg +│ │ │ │ │ ├── symbol-storage.svg +│ │ │ │ │ ├── symbols.svg +│ │ │ │ │ ├── system-health.svg +│ │ │ │ │ ├── trading-hours.svg +│ │ │ │ │ ├── trading.svg +│ │ │ │ │ └── watchlist.svg │ │ │ │ ├── components +│ │ │ │ │ ├── accounting +│ │ │ │ │ │ ├── AccountTree.test.tsx +│ │ │ │ │ │ ├── AccountTree.tsx +│ │ │ │ │ │ ├── AmountCell.test.tsx +│ │ │ │ │ │ ├── AmountCell.tsx +│ │ │ │ │ │ ├── index.ts +│ │ │ │ │ │ ├── JournalEntryForm.test.tsx +│ │ │ │ │ │ ├── JournalEntryForm.tsx +│ │ │ │ │ │ ├── LedgerTable.test.tsx +│ │ │ │ │ │ ├── LedgerTable.tsx +│ │ │ │ │ │ ├── money.test.ts +│ │ │ │ │ │ ├── money.ts +│ │ │ │ │ │ ├── ReconciliationPanel.test.tsx +│ │ │ │ │ │ ├── ReconciliationPanel.tsx +│ │ │ │ │ │ ├── StatementTable.test.tsx +│ │ │ │ │ │ ├── StatementTable.tsx +│ │ │ │ │ │ ├── TaxLotTable.test.tsx +│ │ │ │ │ │ ├── TaxLotTable.tsx +│ │ │ │ │ │ └── TrialBalanceRowDetail.tsx +│ │ │ │ │ ├── charts +│ │ │ │ │ │ ├── CandleChart.tsx +│ │ │ │ │ │ ├── ChartCard.tsx +│ │ │ │ │ │ ├── charts.test.tsx +│ │ │ │ │ │ ├── CorrelationHeatmap.tsx +│ │ │ │ │ │ ├── DepthChart.tsx +│ │ │ │ │ │ ├── DrawdownChart.tsx +│ │ │ │ │ │ ├── EquityCurve.tsx +│ │ │ │ │ │ ├── Histogram.tsx +│ │ │ │ │ │ ├── index.ts +│ │ │ │ │ │ ├── Sparkline.tsx +│ │ │ │ │ │ └── ticks.ts │ │ │ │ │ ├── data │ │ │ │ │ │ ├── add-provider-drawer.tsx │ │ │ │ │ │ ├── backfill-validation-dashboard.tsx +│ │ │ │ │ │ ├── column-chooser.concrete.test.tsx +│ │ │ │ │ │ ├── column-chooser.tsx +│ │ │ │ │ │ ├── concrete.ts +│ │ │ │ │ │ ├── dense-data-table.concrete.test.tsx +│ │ │ │ │ │ ├── dense-data-table.tsx │ │ │ │ │ │ ├── edit-provider-drawer.tsx +│ │ │ │ │ │ ├── empty-state.concrete.test.tsx +│ │ │ │ │ │ ├── empty-state.tsx +│ │ │ │ │ │ ├── entity-summary.tsx +│ │ │ │ │ │ ├── expandable-data-table.concrete.test.tsx +│ │ │ │ │ │ ├── expandable-data-table.tsx +│ │ │ │ │ │ ├── filtered-data-table.concrete.test.tsx +│ │ │ │ │ │ ├── filtered-data-table.tsx +│ │ │ │ │ │ ├── key-value-grid.concrete.test.tsx +│ │ │ │ │ │ ├── key-value-grid.tsx +│ │ │ │ │ │ ├── metric-card.concrete.test.tsx +│ │ │ │ │ │ ├── metric-card.tsx +│ │ │ │ │ │ ├── pagination.concrete.test.tsx +│ │ │ │ │ │ ├── pagination.tsx │ │ │ │ │ │ ├── provider-capability-badges.tsx │ │ │ │ │ │ ├── provider-setup-panel.test.tsx │ │ │ │ │ │ ├── provider-setup-panel.tsx +│ │ │ │ │ │ ├── skeleton.concrete.test.tsx +│ │ │ │ │ │ ├── skeleton.tsx │ │ │ │ │ │ ├── symbol-universe-manager.test.tsx -│ │ │ │ │ │ └── symbol-universe-manager.tsx +│ │ │ │ │ │ ├── symbol-universe-manager.tsx +│ │ │ │ │ │ ├── use-table-state.test.ts +│ │ │ │ │ │ └── use-table-state.ts │ │ │ │ │ ├── meridian │ │ │ │ │ │ ├── command-palette.test.tsx │ │ │ │ │ │ ├── command-palette.tsx @@ -4708,40 +4840,91 @@ Meridian-main │ │ │ │ │ │ ├── workspace-nav.view-model.ts │ │ │ │ │ │ ├── workspace-primitives.test.tsx │ │ │ │ │ │ └── workspace-primitives.tsx +│ │ │ │ │ ├── operations +│ │ │ │ │ │ ├── evidence-link.test.tsx +│ │ │ │ │ │ ├── evidence-link.tsx +│ │ │ │ │ │ ├── gate-rail.test.tsx +│ │ │ │ │ │ ├── gate-rail.tsx +│ │ │ │ │ │ ├── index.ts +│ │ │ │ │ │ ├── inject-style.ts +│ │ │ │ │ │ ├── readiness-panel.test.tsx +│ │ │ │ │ │ ├── readiness-panel.tsx +│ │ │ │ │ │ ├── severity-badge.test.tsx +│ │ │ │ │ │ ├── severity-badge.tsx +│ │ │ │ │ │ ├── status.test.ts +│ │ │ │ │ │ ├── status.ts +│ │ │ │ │ │ ├── trust-strip.test.tsx +│ │ │ │ │ │ ├── trust-strip.tsx +│ │ │ │ │ │ ├── validation-issue-list.test.tsx +│ │ │ │ │ │ ├── validation-issue-list.tsx +│ │ │ │ │ │ ├── workspace-section.test.tsx +│ │ │ │ │ │ └── workspace-section.tsx │ │ │ │ │ └── ui +│ │ │ │ │ ├── accordion.test.tsx +│ │ │ │ │ ├── accordion.tsx │ │ │ │ │ ├── badge.tsx │ │ │ │ │ ├── breadcrumb.tsx │ │ │ │ │ ├── button.test.tsx │ │ │ │ │ ├── button.tsx │ │ │ │ │ ├── button.view-model.test.ts │ │ │ │ │ ├── button.view-model.ts +│ │ │ │ │ ├── callout.tsx │ │ │ │ │ ├── card.tsx │ │ │ │ │ ├── checkbox.tsx +│ │ │ │ │ ├── combobox.test.tsx +│ │ │ │ │ ├── combobox.tsx │ │ │ │ │ ├── context-menu.tsx +│ │ │ │ │ ├── date-picker.test.tsx +│ │ │ │ │ ├── date-picker.tsx +│ │ │ │ │ ├── date-range-picker.tsx +│ │ │ │ │ ├── density-toggle.test.tsx +│ │ │ │ │ ├── density-toggle.tsx │ │ │ │ │ ├── dialog.test.tsx │ │ │ │ │ ├── dialog.tsx │ │ │ │ │ ├── dialog.view-model.test.ts │ │ │ │ │ ├── dialog.view-model.ts +│ │ │ │ │ ├── drawer.test.tsx +│ │ │ │ │ ├── drawer.tsx │ │ │ │ │ ├── eyebrow.tsx +│ │ │ │ │ ├── feedback-primitives.test.tsx │ │ │ │ │ ├── field-support.test.tsx │ │ │ │ │ ├── field-support.tsx +│ │ │ │ │ ├── file-upload.test.tsx +│ │ │ │ │ ├── file-upload.tsx │ │ │ │ │ ├── form.tsx +│ │ │ │ │ ├── gauge.test.tsx +│ │ │ │ │ ├── gauge.tsx │ │ │ │ │ ├── input.tsx +│ │ │ │ │ ├── kbd.tsx │ │ │ │ │ ├── label.tsx +│ │ │ │ │ ├── layout.test.tsx +│ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── modal.tsx │ │ │ │ │ ├── multi-select.tsx +│ │ │ │ │ ├── number-input.test.tsx +│ │ │ │ │ ├── number-input.tsx │ │ │ │ │ ├── panel-surface.tsx +│ │ │ │ │ ├── popover.test.tsx +│ │ │ │ │ ├── popover.tsx │ │ │ │ │ ├── primitives.test.tsx │ │ │ │ │ ├── progress.tsx +│ │ │ │ │ ├── radio-group.test.tsx +│ │ │ │ │ ├── radio-group.tsx │ │ │ │ │ ├── risk-control-panel.test.tsx │ │ │ │ │ ├── risk-control-panel.tsx │ │ │ │ │ ├── risk-control-panel.view-model.test.ts │ │ │ │ │ ├── risk-control-panel.view-model.ts +│ │ │ │ │ ├── segmented-control.test.tsx +│ │ │ │ │ ├── segmented-control.tsx │ │ │ │ │ ├── select.tsx │ │ │ │ │ ├── sheet.test.tsx │ │ │ │ │ ├── sheet.tsx +│ │ │ │ │ ├── spinner.tsx │ │ │ │ │ ├── status-banner.tsx +│ │ │ │ │ ├── stepper.test.tsx +│ │ │ │ │ ├── stepper.tsx │ │ │ │ │ ├── tabs.tsx +│ │ │ │ │ ├── text-area.tsx │ │ │ │ │ ├── toast.tsx │ │ │ │ │ └── tooltip.tsx │ │ │ │ ├── features @@ -4821,6 +5004,10 @@ Meridian-main │ │ │ │ │ ├── accounting-screen.view-model.ts │ │ │ │ │ ├── accounting-screen.workbench-context.tsx │ │ │ │ │ ├── accounting-screen.workflow-continuity.ts +│ │ │ │ │ ├── asset-detail-screen.test.tsx +│ │ │ │ │ ├── asset-detail-screen.tsx +│ │ │ │ │ ├── asset-detail-screen.view-model.test.ts +│ │ │ │ │ ├── asset-detail-screen.view-model.ts │ │ │ │ │ ├── covered-call-screen.test.tsx │ │ │ │ │ ├── covered-call-screen.tsx │ │ │ │ │ ├── covered-call-screen.view-model.test.ts @@ -4842,6 +5029,11 @@ Meridian-main │ │ │ │ │ ├── family-office-screen.tsx │ │ │ │ │ ├── family-office-screen.view-model.test.ts │ │ │ │ │ ├── family-office-screen.view-model.ts +│ │ │ │ │ ├── finance-standard-pages-screen.test.tsx +│ │ │ │ │ ├── finance-standard-pages-screen.tsx +│ │ │ │ │ ├── journal-entry-detail-screen.test.tsx +│ │ │ │ │ ├── journal-entry-detail-screen.tsx +│ │ │ │ │ ├── journal-entry-detail-screen.view-model.ts │ │ │ │ │ ├── live-quotes-screen.test.tsx │ │ │ │ │ ├── live-quotes-screen.tsx │ │ │ │ │ ├── live-quotes-screen.view-model.ts @@ -4876,6 +5068,12 @@ Meridian-main │ │ │ │ │ ├── quant-lab-screen.tsx │ │ │ │ │ ├── quant-lab-screen.view-model.test.ts │ │ │ │ │ ├── quant-lab-screen.view-model.ts +│ │ │ │ │ ├── report-library-screen.test.tsx +│ │ │ │ │ ├── report-library-screen.tsx +│ │ │ │ │ ├── report-run-parameters-screen.test.tsx +│ │ │ │ │ ├── report-run-parameters-screen.tsx +│ │ │ │ │ ├── report-run-parameters-screen.view-model.test.ts +│ │ │ │ │ ├── report-run-parameters-screen.view-model.ts │ │ │ │ │ ├── reporting-screen.branding-access.tsx │ │ │ │ │ ├── reporting-screen.delivery-history.tsx │ │ │ │ │ ├── reporting-screen.exports-runner.tsx @@ -4924,6 +5122,10 @@ Meridian-main │ │ │ │ │ ├── trading-screen.view-model.test.ts │ │ │ │ │ ├── trading-screen.view-model.ts │ │ │ │ │ ├── trading-screen.workflow-continuity.ts +│ │ │ │ │ ├── trial-balance-screen.test.tsx +│ │ │ │ │ ├── trial-balance-screen.tsx +│ │ │ │ │ ├── trial-balance-screen.view-model.test.ts +│ │ │ │ │ ├── trial-balance-screen.view-model.ts │ │ │ │ │ ├── w4-acceptance-parity.test.ts │ │ │ │ │ ├── watchlist-screen.test.tsx │ │ │ │ │ ├── watchlist-screen.tsx @@ -4938,7 +5140,8 @@ Meridian-main │ │ │ │ │ ├── ui-kit-primitives.css │ │ │ │ │ ├── workflow-continuity-dock.css │ │ │ │ │ ├── workspace-nav.css -│ │ │ │ │ └── workspace-primitives.css +│ │ │ │ │ ├── workspace-primitives.css +│ │ │ │ │ └── workspace-surface.css │ │ │ │ ├── test │ │ │ │ │ ├── render.tsx │ │ │ │ │ └── setup.ts @@ -6575,9 +6778,11 @@ Meridian-main │ │ │ │ ├── AlphaVantageHistoricalDataProviderTests.cs │ │ │ │ ├── AlphaVantageSymbolSearchProviderTests.cs │ │ │ │ ├── BackfillRetryAfterTests.cs +│ │ │ │ ├── BaseSymbolSearchProviderTests.cs │ │ │ │ ├── EdgarReferenceDataProviderTests.cs │ │ │ │ ├── EdgarSymbolSearchProviderTests.cs │ │ │ │ ├── FailoverAwareMarketDataClientTests.cs +│ │ │ │ ├── FinnhubCorporateActionProviderTests.cs │ │ │ │ ├── FinnhubHistoricalDataProviderTests.cs │ │ │ │ ├── FinnhubSymbolSearchProviderTests.cs │ │ │ │ ├── FredHistoricalDataProviderTests.cs @@ -6596,6 +6801,7 @@ Meridian-main │ │ │ │ ├── MarketDataClientContractTests.cs │ │ │ │ ├── NasdaqDataLinkCorporateActionProviderTests.cs │ │ │ │ ├── NasdaqDataLinkHistoricalDataProviderTests.cs +│ │ │ │ ├── NasdaqDataLinkSymbolSearchProviderTests.cs │ │ │ │ ├── NYSECredentialAndRateLimitTests.cs │ │ │ │ ├── NyseMarketDataClientContractTests.cs │ │ │ │ ├── NyseMarketDataClientTests.cs @@ -6832,6 +7038,8 @@ Meridian-main │ │ │ ├── EventBufferTests.cs │ │ │ ├── ExportValidatorTests.cs │ │ │ ├── FilePermissionsServiceTests.cs +│ │ │ ├── FundAccountTenantColumnMigrationTests.cs +│ │ │ ├── FundScopedWriteTenantGateTests.cs │ │ │ ├── FundScopeTenantColumnMigrationTests.cs │ │ │ ├── JsonlBatchWriteTests.cs │ │ │ ├── JsonlReplayerTests.cs @@ -6843,6 +7051,7 @@ Meridian-main │ │ │ ├── MaintenancePersistenceTests.cs │ │ │ ├── MemoryMappedJsonlReaderTests.cs │ │ │ ├── MetadataTagServiceTests.cs +│ │ │ ├── OperationsContinuityTenantColumnMigrationTests.cs │ │ │ ├── ParquetConversionServiceTests.cs │ │ │ ├── ParquetStorageSinkTests.cs │ │ │ ├── PortableDataPackagerTests.cs @@ -6855,6 +7064,8 @@ Meridian-main │ │ │ ├── StorageProfilePresetsTests.cs │ │ │ ├── StorageSinkRegistryTests.cs │ │ │ ├── SymbolRegistryServiceTests.cs +│ │ │ ├── TenantLowerIndexMigrationTests.cs +│ │ │ ├── TenantReadPredicateTests.cs │ │ │ ├── TierMigrationServiceTests.cs │ │ │ ├── WriteAheadLogCorruptionModeTests.cs │ │ │ ├── WriteAheadLogFuzzTests.cs diff --git a/docs/plans/high-value-code-brainstorm-2026-07.md b/docs/plans/high-value-code-brainstorm-2026-07.md index 3b2f66ff2d..638e4039fc 100644 --- a/docs/plans/high-value-code-brainstorm-2026-07.md +++ b/docs/plans/high-value-code-brainstorm-2026-07.md @@ -54,7 +54,7 @@ Five signals from the July 2026 landscape shape this prioritization: | # | Idea | Effort | Audience | Impact | Depends On | |---|------|--------|----------|--------|------------| | 1 | Live/Paper TCA & best-execution report pack | M | I | High | — | -| 2 | Alpaca live broker adapter behind `IOrderGateway` | M | H, I | High | — | +| 2 | Alpaca live broker adapter implementing `IExecutionGateway` | M | H, I | High | — | | 3 | Reconciliation break-resolution agent (MCP + CoS gates) | M | I | High | — | | 4 | Custodian/broker statement connector library | M | I, H | High | — | | 5 | DuckDB analytics workbench in the Data workspace | M | Q, H | High | — | @@ -102,7 +102,7 @@ duplicated from Backtesting.Sdk. **Why now:** feeds W7 live-readiness directly; institutional differentiator no open-source competitor has; models already exist and are tested on the backtest side. -### 2. Alpaca Live Broker Adapter Behind `IOrderGateway` +### 2. Alpaca Live Broker Adapter Implementing `IExecutionGateway` `PaperTradingGateway` (`src/Meridian.Execution/Adapters/PaperTradingGateway.cs`) is still the only live-capable adapter. W7-LIVE-001 — live-readiness governance — has nothing real to govern until at diff --git a/docs/status/TODO.md b/docs/status/TODO.md index 84ea8fc891..306b8206d1 100644 --- a/docs/status/TODO.md +++ b/docs/status/TODO.md @@ -52,10 +52,10 @@ Total items: **200** | `src/Meridian.Ui.Shared/Services/ReportPackDeliveryService.cs` | 240 | `NOTE` | ❌ | Note: NormalizeNullable(target.Note) ?? $"Scheduled delivery for {normalizedTemplateId}.", | | `src/Meridian.Ui.Shared/Services/ReportPackRunReadService.cs` | 2047 | `NOTE` | ❌ | Note: NormalizeOptional(target.Note), | | `src/Meridian.Ui.Shared/Services/ReportPackRunReadService.cs` | 2101 | `NOTE` | ❌ | Note: NormalizeOptional(target.Note), | -| `src/Meridian.Ui/dashboard/src/app-shell.view-model.test.ts` | 364 | `NOTE` | ❌ | note: "Heartbeat delayed" | -| `src/Meridian.Ui/dashboard/src/app-shell.view-model.test.ts` | 715 | `NOTE` | ❌ | note: "Streaming quote path is healthy." | -| `src/Meridian.Ui/dashboard/src/app-shell.view-model.test.ts` | 790 | `NOTE` | ❌ | note: "Streaming quote path is healthy." | -| `src/Meridian.Ui/dashboard/src/app-shell.view-model.test.ts` | 1247 | `NOTE` | ❌ | note: "Paper endpoint returned intermittent quote gaps.", | +| `src/Meridian.Ui/dashboard/src/app-shell.view-model.test.ts` | 367 | `NOTE` | ❌ | note: "Heartbeat delayed" | +| `src/Meridian.Ui/dashboard/src/app-shell.view-model.test.ts` | 718 | `NOTE` | ❌ | note: "Streaming quote path is healthy." | +| `src/Meridian.Ui/dashboard/src/app-shell.view-model.test.ts` | 793 | `NOTE` | ❌ | note: "Streaming quote path is healthy." | +| `src/Meridian.Ui/dashboard/src/app-shell.view-model.test.ts` | 1250 | `NOTE` | ❌ | note: "Paper endpoint returned intermittent quote gaps.", | | `src/Meridian.Ui/dashboard/src/app.test.tsx` | 219 | `NOTE` | ❌ | note: "Paper endpoint returned intermittent quote gaps.", | | `src/Meridian.Ui/dashboard/src/app.test.tsx` | 375 | `NOTE` | ❌ | note: "Credential check failed" | | `src/Meridian.Ui/dashboard/src/app.test.tsx` | 619 | `NOTE` | ❌ | note: "Paper endpoint returned intermittent quote gaps.", | @@ -89,9 +89,9 @@ Total items: **200** | `src/Meridian.Ui/dashboard/src/lib/price-alerts/storage.ts` | 154 | `NOTE` | ❌ | note: asString(raw.note) ?? null | | `src/Meridian.Ui/dashboard/src/lib/price-alerts/types.ts` | 11 | `NOTE` | ❌ | note: string \| null; | | `src/Meridian.Ui/dashboard/src/lib/price-alerts/types.ts` | 30 | `NOTE` | ❌ | note: string \| null; | -| `src/Meridian.Ui/dashboard/src/screens/accounting-screen.view-model.test.ts` | 498 | `NOTE` | ❌ | note: "Coupon posted." | -| `src/Meridian.Ui/dashboard/src/screens/accounting-screen.view-model.test.ts` | 518 | `NOTE` | ❌ | note: "Expected-versus-actual variance." | -| `src/Meridian.Ui/dashboard/src/screens/accounting-screen.view-model.test.ts` | 8578 | `NOTE` | ❌ | note: "Expected-versus-actual variance." | +| `src/Meridian.Ui/dashboard/src/screens/accounting-screen.view-model.test.ts` | 503 | `NOTE` | ❌ | note: "Coupon posted." | +| `src/Meridian.Ui/dashboard/src/screens/accounting-screen.view-model.test.ts` | 523 | `NOTE` | ❌ | note: "Expected-versus-actual variance." | +| `src/Meridian.Ui/dashboard/src/screens/accounting-screen.view-model.test.ts` | 8608 | `NOTE` | ❌ | note: "Expected-versus-actual variance." | | `src/Meridian.Ui/dashboard/src/screens/covered-call-screen.view-model.ts` | 287 | `NOTE` | ❌ | note: string; | | `src/Meridian.Ui/dashboard/src/screens/covered-call-screen.view-model.ts` | 990 | `NOTE` | ❌ | note: "Covered-call net curve requires the underlying cost basis which is not yet threaded through the API. The chart shows the short-call leg only." | | `src/Meridian.Ui/dashboard/src/screens/data-screen.security-master.ts` | 80 | `NOTE` | ❌ | note: string; | @@ -151,26 +151,26 @@ Total items: **200** | `src/Meridian.Ui/dashboard/src/screens/reporting-screen.test.tsx` | 4896 | `NOTE` | ❌ | note: "Board package." | | `src/Meridian.Ui/dashboard/src/screens/reporting-screen.test.tsx` | 4939 | `NOTE` | ❌ | note: "Board package.", | | `src/Meridian.Ui/dashboard/src/screens/reporting-screen.test.tsx` | 5092 | `NOTE` | ❌ | note: "pricing-correction" | -| `src/Meridian.Ui/dashboard/src/screens/reporting-screen.tsx` | 2816 | `NOTE` | ❌ | note: note \|\| null | -| `src/Meridian.Ui/dashboard/src/screens/reporting-screen.tsx` | 2827 | `NOTE` | ❌ | note: draft.deliveryNote, | -| `src/Meridian.Ui/dashboard/src/screens/reporting-screen.tsx` | 3582 | `NOTE` | ❌ | note: "Delivered from browser Reporting workspace.", | -| `src/Meridian.Ui/dashboard/src/screens/reporting-screen.tsx` | 3605 | `NOTE` | ❌ | note: "Published from browser Reporting workspace." | -| `src/Meridian.Ui/dashboard/src/screens/reporting-screen.tsx` | 3704 | `NOTE` | ❌ | note: `Delivery failure recorded from Reporting workspace for ${attempt.recipient}.`, | +| `src/Meridian.Ui/dashboard/src/screens/reporting-screen.tsx` | 2837 | `NOTE` | ❌ | note: note \|\| null | +| `src/Meridian.Ui/dashboard/src/screens/reporting-screen.tsx` | 2848 | `NOTE` | ❌ | note: draft.deliveryNote, | +| `src/Meridian.Ui/dashboard/src/screens/reporting-screen.tsx` | 3603 | `NOTE` | ❌ | note: "Delivered from browser Reporting workspace.", | +| `src/Meridian.Ui/dashboard/src/screens/reporting-screen.tsx` | 3626 | `NOTE` | ❌ | note: "Published from browser Reporting workspace." | +| `src/Meridian.Ui/dashboard/src/screens/reporting-screen.tsx` | 3725 | `NOTE` | ❌ | note: `Delivery failure recorded from Reporting workspace for ${attempt.recipient}.`, | | `src/Meridian.Ui/dashboard/src/screens/reporting-screen.view-model.test.ts` | 195 | `NOTE` | ❌ | note: "pricing-correction" | | `src/Meridian.Ui/dashboard/src/screens/reporting-screen.view-model.test.ts` | 528 | `NOTE` | ❌ | note: "Board package." | | `src/Meridian.Ui/dashboard/src/screens/reporting-screen.view-model.ts` | 384 | `NOTE` | ❌ | note: string \| null; | | `src/Meridian.Ui/dashboard/src/screens/reporting-screen.view-model.ts` | 1853 | `NOTE` | ❌ | note: plan.note, | -| `src/Meridian.Ui/dashboard/src/screens/settings-screen.test.tsx` | 1295 | `NOTE` | ❌ | note: "Reviewed from the Settings Provider Connection Center runtime evidence panel." | -| `src/Meridian.Ui/dashboard/src/screens/settings-screen.test.tsx` | 1303 | `NOTE` | ❌ | note: "Marked from the Settings Provider Connection Center for replay after mapping changes." | -| `src/Meridian.Ui/dashboard/src/screens/settings-screen.test.tsx` | 1313 | `NOTE` | ❌ | note: "Ignored from the Settings Provider Connection Center after operator review." | -| `src/Meridian.Ui/dashboard/src/screens/settings-screen.test.tsx` | 1435 | `NOTE` | ❌ | note: "Approved after identity review.", | -| `src/Meridian.Ui/dashboard/src/screens/settings-screen.test.tsx` | 1535 | `NOTE` | ❌ | note: "Reviewed from the Settings Provider Connection Center runtime evidence panel." | -| `src/Meridian.Ui/dashboard/src/screens/settings-screen.test.tsx` | 1617 | `NOTE` | ❌ | note: "Approved from the Settings Provider Connection Center promotion readiness panel.", | -| `src/Meridian.Ui/dashboard/src/screens/settings-screen.test.tsx` | 1634 | `NOTE` | ❌ | note: "Reviewed from the Settings Provider Connection Center runtime evidence panel." | -| `src/Meridian.Ui/dashboard/src/screens/settings-screen.test.tsx` | 1681 | `NOTE` | ❌ | note: "Marked from the Settings Provider Connection Center for replay after mapping changes." | -| `src/Meridian.Ui/dashboard/src/screens/settings-screen.test.tsx` | 1701 | `NOTE` | ❌ | note: "Ignored from the Settings Provider Connection Center after operator review." | -| `src/Meridian.Ui/dashboard/src/screens/settings-screen.tsx` | 1483 | `NOTE` | ❌ | note: "Approved from the Settings Provider Connection Center promotion readiness panel.", | -| `src/Meridian.Ui/dashboard/src/screens/settings-screen.tsx` | 1610 | `NOTE` | ❌ | note: providerRuntimeQuarantineActionNote(action) | +| `src/Meridian.Ui/dashboard/src/screens/settings-screen.test.tsx` | 1300 | `NOTE` | ❌ | note: "Reviewed from the Settings Provider Connection Center runtime evidence panel." | +| `src/Meridian.Ui/dashboard/src/screens/settings-screen.test.tsx` | 1308 | `NOTE` | ❌ | note: "Marked from the Settings Provider Connection Center for replay after mapping changes." | +| `src/Meridian.Ui/dashboard/src/screens/settings-screen.test.tsx` | 1318 | `NOTE` | ❌ | note: "Ignored from the Settings Provider Connection Center after operator review." | +| `src/Meridian.Ui/dashboard/src/screens/settings-screen.test.tsx` | 1440 | `NOTE` | ❌ | note: "Approved after identity review.", | +| `src/Meridian.Ui/dashboard/src/screens/settings-screen.test.tsx` | 1540 | `NOTE` | ❌ | note: "Reviewed from the Settings Provider Connection Center runtime evidence panel." | +| `src/Meridian.Ui/dashboard/src/screens/settings-screen.test.tsx` | 1622 | `NOTE` | ❌ | note: "Approved from the Settings Provider Connection Center promotion readiness panel.", | +| `src/Meridian.Ui/dashboard/src/screens/settings-screen.test.tsx` | 1639 | `NOTE` | ❌ | note: "Reviewed from the Settings Provider Connection Center runtime evidence panel." | +| `src/Meridian.Ui/dashboard/src/screens/settings-screen.test.tsx` | 1686 | `NOTE` | ❌ | note: "Marked from the Settings Provider Connection Center for replay after mapping changes." | +| `src/Meridian.Ui/dashboard/src/screens/settings-screen.test.tsx` | 1706 | `NOTE` | ❌ | note: "Ignored from the Settings Provider Connection Center after operator review." | +| `src/Meridian.Ui/dashboard/src/screens/settings-screen.tsx` | 1502 | `NOTE` | ❌ | note: "Approved from the Settings Provider Connection Center promotion readiness panel.", | +| `src/Meridian.Ui/dashboard/src/screens/settings-screen.tsx` | 1629 | `NOTE` | ❌ | note: providerRuntimeQuarantineActionNote(action) | | `src/Meridian.Ui/dashboard/src/screens/w4-acceptance-parity.test.ts` | 429 | `NOTE` | ❌ | note: "Close evidence reviewed." | | `src/Meridian.Ui/dashboard/src/screens/w4-acceptance-parity.test.ts` | 437 | `NOTE` | ❌ | note: "Published to investor portal." | | `src/Meridian.Ui/dashboard/src/types.ts` | 3924 | `NOTE` | ❌ | note: string; | diff --git a/docs/status/api-contract-coverage-dashboard.json b/docs/status/api-contract-coverage-dashboard.json index f978054764..bf113ba3a8 100644 --- a/docs/status/api-contract-coverage-dashboard.json +++ b/docs/status/api-contract-coverage-dashboard.json @@ -71,97 +71,97 @@ { "method": "GET", "path": "/api/accounting-system/production-certification-profile", - "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:122", + "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:123", "documented": true }, { "method": "POST", "path": "/api/accounting-system/production-certification-profile", - "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:149", + "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:150", "documented": true }, { "method": "POST", "path": "/api/accounting-system/migration-runs", - "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:189", + "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:191", "documented": true }, { "method": "GET", "path": "/api/accounting-system/migration-run-artifacts", - "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:225", + "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:228", "documented": true }, { "method": "POST", "path": "/api/accounting-system/migration-run-artifacts", - "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:253", + "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:256", "documented": true }, { "method": "GET", "path": "/api/accounting-system/migration-worker-plans", - "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:293", + "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:297", "documented": true }, { "method": "POST", "path": "/api/accounting-system/migration-worker-plans", - "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:323", + "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:327", "documented": true }, { "method": "POST", "path": "/api/accounting-system/import/preview", - "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:360", + "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:365", "documented": true }, { "method": "GET", "path": "/api/accounting-system/import/latest", - "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:384", + "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:390", "documented": true }, { "method": "GET", "path": "/api/accounting-system/reconciliation/latest", - "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:413", + "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:419", "documented": true }, { "method": "GET", "path": "/api/accounting-system/mapping-profiles", - "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:442", + "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:448", "documented": true }, { "method": "POST", "path": "/api/accounting-system/mapping-profiles", - "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:471", + "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:477", "documented": true }, { "method": "GET", "path": "/api/accounting-system/export-packages", - "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:518", + "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:525", "documented": true }, { "method": "POST", "path": "/api/accounting-system/export-packages", - "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:551", + "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:558", "documented": true }, { "method": "GET", "path": "/api/accounting-system/export-packages/{exportPackageId}/manifest", - "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:598", + "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:606", "documented": true }, { "method": "POST", "path": "/api/accounting-system/export-packages/certification", - "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:636", + "source": "src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:644", "documented": true }, { @@ -1379,73 +1379,73 @@ { "method": "GET", "path": "/api/projections/{projectionRunId:guid}/flows", - "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:899", + "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:923", "documented": true }, { "method": "POST", "path": "/api/journals/{journalEntryId:guid}/post", - "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:916", + "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:940", "documented": true }, { "method": "GET", "path": "/api/reconciliation/{runId:guid}/results", - "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:966", + "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:992", "documented": true }, { "method": "GET", "path": "/api/reconciliation/exceptions", - "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:975", + "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1001", "documented": true }, { "method": "POST", "path": "/api/reconciliation/exceptions/{exceptionId:guid}/resolve", - "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:984", + "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1010", "documented": true }, { "method": "POST", "path": "/api/servicer-reports", - "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1005", + "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1032", "documented": true }, { "method": "GET", "path": "/api/servicer-reports/{batchId:guid}", - "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1031", + "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1059", "documented": true }, { "method": "GET", "path": "/api/servicer-reports/{batchId:guid}/position-lines", - "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1039", + "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1067", "documented": true }, { "method": "GET", "path": "/api/servicer-reports/{batchId:guid}/transaction-lines", - "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1048", + "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1076", "documented": true }, { "method": "GET", "path": "/api/loans/rebuild-checkpoints", - "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1057", + "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1085", "documented": true }, { "method": "GET", "path": "/api/loans/portfolio", - "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1066", + "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1094", "documented": true }, { "method": "POST", "path": "/api/loans/rebuild-all", - "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1077", + "source": "src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1105", "documented": true }, { @@ -1859,289 +1859,289 @@ { "method": "POST", "path": "/api/ledger/books/rollout-assessment", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:105", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:106", "documented": true }, { "method": "GET", "path": "/api/ledger/periods", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:146", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:147", "documented": true }, { "method": "POST", "path": "/api/ledger/periods", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:184", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:185", "documented": true }, { "method": "POST", "path": "/api/ledger/periods/{periodId:guid}/close", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:215", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:217", "documented": true }, { "method": "GET", "path": "/api/ledger/periods/{periodId:guid}/journal-entries", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:258", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:261", "documented": true }, { "method": "GET", "path": "/api/ledger/aggregates/{aggregateId:guid}/journal-entries", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:302", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:305", "documented": true }, { "method": "GET", "path": "/api/ledger/periods/{periodId:guid}/trial-balance", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:351", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:354", "documented": true }, { "method": "GET", "path": "/api/ledger/periods/{periodId:guid}/trial-balance-report", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:376", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:379", "documented": true }, { "method": "GET", "path": "/api/ledger/periods/{periodId:guid}/pnl-summary", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:401", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:404", "documented": true }, { "method": "GET", "path": "/api/ledger/reports/trial-balance", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:426", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:429", "documented": true }, { "method": "GET", "path": "/api/ledger/reports/pnl-summary", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:485", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:488", "documented": true }, { "method": "GET", "path": "/api/ledger/accounting-configuration", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:544", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:547", "documented": true }, { "method": "POST", "path": "/api/ledger/accounting-configuration/chart", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:572", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:575", "documented": true }, { "method": "POST", "path": "/api/ledger/accounting-configuration/templates", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:602", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:606", "documented": true }, { "method": "POST", "path": "/api/ledger/accounting-configuration/posting-rules", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:632", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:637", "documented": true }, { "method": "POST", "path": "/api/ledger/accounting-configuration/posting-rules/promotion-approvals", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:662", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:668", "documented": true }, { "method": "POST", "path": "/api/ledger/accounting-configuration/posting-rules/test-cases", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:697", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:704", "documented": true }, { "method": "POST", "path": "/api/ledger/accounting-configuration/preview", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:727", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:735", "documented": true }, { "method": "POST", "path": "/api/ledger/accounting-configuration/posting-rules/dry-run", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:759", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:770", "documented": true }, { "method": "POST", "path": "/api/ledger/accounting-configuration/posting-rules/candidates", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:799", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:811", "documented": true }, { "method": "POST", "path": "/api/ledger/accounting-configuration/posting-rules/candidates/post", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:841", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:854", "documented": true }, { "method": "POST", "path": "/api/ledger/accounting-configuration/posting-rules/projection-sets", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:884", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:898", "documented": true }, { "method": "POST", "path": "/api/ledger/accounting-configuration/posting-rules/tests", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:926", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:941", "documented": true }, { "method": "POST", "path": "/api/ledger/accounting-configuration/activate", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:966", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:982", "documented": true }, { "method": "GET", "path": "/api/ledger/accounting-configuration/audit", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1000", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1017", "documented": true }, { "method": "GET", "path": "/api/ledger/close-management/period-plan/{workflowId:guid}", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1028", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1045", "documented": true }, { "method": "POST", "path": "/api/ledger/close-management/period-plan/configuration", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1054", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1071", "documented": true }, { "method": "POST", "path": "/api/ledger/close-management/late-adjustments", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1103", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1121", "documented": true }, { "method": "POST", "path": "/api/ledger/close-management/late-adjustments/review", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1152", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1171", "documented": true }, { "method": "POST", "path": "/api/ledger/close-management/task-signoffs", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1201", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1221", "documented": true }, { "method": "POST", "path": "/api/ledger/close-management/evidence-review", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1250", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1271", "documented": true }, { "method": "POST", "path": "/api/ledger/close-management/period-lock", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1299", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1321", "documented": true }, { "method": "POST", "path": "/api/ledger/reports/accounting-package", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1352", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1375", "documented": true }, { "method": "POST", "path": "/api/ledger/reports/accounting-package/certification", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1400", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1426", "documented": true }, { "method": "GET", "path": "/api/ledger/reports/accounting-packages", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1452", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1479", "documented": true }, { "method": "GET", "path": "/api/ledger/reports/accounting-packages/{packageId}/exports/{artifactId}", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1489", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1516", "documented": true }, { "method": "GET", "path": "/api/ledger/journal-entry-workbench", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1535", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1562", "documented": true }, { "method": "GET", "path": "/api/ledger/private-capital/activity", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1561", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1588", "documented": true }, { "method": "GET", "path": "/api/ledger/private-capital/fund-event-record", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1593", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1620", "documented": true }, { "method": "GET", "path": "/api/ledger/private-capital/fund-event-command-center", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1636", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1663", "documented": true }, { "method": "GET", "path": "/api/ledger/private-capital/capital-account-subledger", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1677", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1704", "documented": true }, { "method": "GET", "path": "/api/ledger/private-capital/report-output", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1738", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1765", "documented": true }, { "method": "GET", "path": "/api/ledger/private-capital/capital-account-workbench", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1809", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1836", "documented": true }, { "method": "POST", "path": "/api/ledger/journal-entry-workbench/drafts", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1856", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1883", "documented": true }, { "method": "POST", "path": "/api/ledger/journal-entry-workbench/validate", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1906", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1934", "documented": true }, { "method": "POST", "path": "/api/ledger/journal-entry-workbench/submit-approval", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1946", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1975", "documented": true }, { "method": "POST", "path": "/api/ledger/journal-entry-workbench/evidence", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1988", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:2018", "documented": true }, { "method": "POST", "path": "/api/ledger/journal-entry-workbench/lifecycle-action", - "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:2030", + "source": "src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:2061", "documented": true }, { diff --git a/docs/status/api-contract-coverage-dashboard.md b/docs/status/api-contract-coverage-dashboard.md index 7626c1a735..a726583009 100644 --- a/docs/status/api-contract-coverage-dashboard.md +++ b/docs/status/api-contract-coverage-dashboard.md @@ -21,24 +21,24 @@ Tracks whether mapped API routes and workstation DTO contracts are visible in th | Method | Route | Status | Source | |---|---|---|---| -| `GET` | `/api/accounting-system/export-packages` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:518` | -| `POST` | `/api/accounting-system/export-packages` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:551` | -| `POST` | `/api/accounting-system/export-packages/certification` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:636` | -| `GET` | `/api/accounting-system/export-packages/{exportPackageId}/manifest` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:598` | -| `GET` | `/api/accounting-system/import/latest` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:384` | -| `POST` | `/api/accounting-system/import/preview` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:360` | -| `GET` | `/api/accounting-system/mapping-profiles` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:442` | -| `POST` | `/api/accounting-system/mapping-profiles` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:471` | -| `GET` | `/api/accounting-system/migration-run-artifacts` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:225` | -| `POST` | `/api/accounting-system/migration-run-artifacts` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:253` | -| `POST` | `/api/accounting-system/migration-runs` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:189` | -| `GET` | `/api/accounting-system/migration-worker-plans` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:293` | -| `POST` | `/api/accounting-system/migration-worker-plans` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:323` | -| `GET` | `/api/accounting-system/production-certification-profile` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:122` | -| `POST` | `/api/accounting-system/production-certification-profile` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:149` | +| `GET` | `/api/accounting-system/export-packages` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:525` | +| `POST` | `/api/accounting-system/export-packages` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:558` | +| `POST` | `/api/accounting-system/export-packages/certification` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:644` | +| `GET` | `/api/accounting-system/export-packages/{exportPackageId}/manifest` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:606` | +| `GET` | `/api/accounting-system/import/latest` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:390` | +| `POST` | `/api/accounting-system/import/preview` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:365` | +| `GET` | `/api/accounting-system/mapping-profiles` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:448` | +| `POST` | `/api/accounting-system/mapping-profiles` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:477` | +| `GET` | `/api/accounting-system/migration-run-artifacts` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:228` | +| `POST` | `/api/accounting-system/migration-run-artifacts` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:256` | +| `POST` | `/api/accounting-system/migration-runs` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:191` | +| `GET` | `/api/accounting-system/migration-worker-plans` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:297` | +| `POST` | `/api/accounting-system/migration-worker-plans` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:327` | +| `GET` | `/api/accounting-system/production-certification-profile` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:123` | +| `POST` | `/api/accounting-system/production-certification-profile` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:150` | | `POST` | `/api/accounting-system/production-readiness` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:35` | | `GET` | `/api/accounting-system/providers` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:20` | -| `GET` | `/api/accounting-system/reconciliation/latest` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:413` | +| `GET` | `/api/accounting-system/reconciliation/latest` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:419` | | `GET` | `/api/accounting-system/tenant-administration-profile` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:58` | | `POST` | `/api/accounting-system/tenant-administration-profile` | Documented | `src/Meridian.Ui.Shared/Endpoints/AccountingSystemEndpoints.cs:82` | | `POST` | `/api/admin/cleanup/execute` | Documented | `src/Meridian.Ui.Shared/Endpoints/AdminEndpoints.cs:272` | @@ -216,7 +216,7 @@ Tracks whether mapped API routes and workstation DTO contracts are visible in th | `GET` | `/api/ingestion/jobs/{jobId}` | Documented | `src/Meridian.Ui.Shared/Endpoints/IngestionJobEndpoints.cs:51` | | `POST` | `/api/ingestion/jobs/{jobId}/transition` | Documented | `src/Meridian.Ui.Shared/Endpoints/IngestionJobEndpoints.cs:66` | | `GET` | `/api/ingestion/summary` | Documented | `src/Meridian.Ui.Shared/Endpoints/IngestionJobEndpoints.cs:160` | -| `POST` | `/api/journals/{journalEntryId:guid}/post` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:916` | +| `POST` | `/api/journals/{journalEntryId:guid}/post` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:940` | | `GET` | `/api/lean/algorithms` | Documented | `src/Meridian.Ui.Shared/Endpoints/LeanEndpoints.cs:149` | | `GET` | `/api/lean/auto-export` | Documented | `src/Meridian.Ui.Shared/Endpoints/LeanEndpoints.cs:389` | | `POST` | `/api/lean/auto-export/configure` | Documented | `src/Meridian.Ui.Shared/Endpoints/LeanEndpoints.cs:420` | @@ -233,60 +233,60 @@ Tracks whether mapped API routes and workstation DTO contracts are visible in th | `POST` | `/api/lean/sync` | Documented | `src/Meridian.Ui.Shared/Endpoints/LeanEndpoints.cs:179` | | `GET` | `/api/lean/sync/status` | Documented | `src/Meridian.Ui.Shared/Endpoints/LeanEndpoints.cs:230` | | `POST` | `/api/lean/verify` | Documented | `src/Meridian.Ui.Shared/Endpoints/LeanEndpoints.cs:87` | -| `GET` | `/api/ledger/accounting-configuration` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:544` | -| `POST` | `/api/ledger/accounting-configuration/activate` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:966` | -| `GET` | `/api/ledger/accounting-configuration/audit` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1000` | -| `POST` | `/api/ledger/accounting-configuration/chart` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:572` | -| `POST` | `/api/ledger/accounting-configuration/posting-rules` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:632` | -| `POST` | `/api/ledger/accounting-configuration/posting-rules/candidates` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:799` | -| `POST` | `/api/ledger/accounting-configuration/posting-rules/candidates/post` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:841` | -| `POST` | `/api/ledger/accounting-configuration/posting-rules/dry-run` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:759` | -| `POST` | `/api/ledger/accounting-configuration/posting-rules/projection-sets` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:884` | -| `POST` | `/api/ledger/accounting-configuration/posting-rules/promotion-approvals` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:662` | -| `POST` | `/api/ledger/accounting-configuration/posting-rules/test-cases` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:697` | -| `POST` | `/api/ledger/accounting-configuration/posting-rules/tests` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:926` | -| `POST` | `/api/ledger/accounting-configuration/preview` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:727` | -| `POST` | `/api/ledger/accounting-configuration/templates` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:602` | -| `GET` | `/api/ledger/aggregates/{aggregateId:guid}/journal-entries` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:302` | +| `GET` | `/api/ledger/accounting-configuration` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:547` | +| `POST` | `/api/ledger/accounting-configuration/activate` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:982` | +| `GET` | `/api/ledger/accounting-configuration/audit` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1017` | +| `POST` | `/api/ledger/accounting-configuration/chart` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:575` | +| `POST` | `/api/ledger/accounting-configuration/posting-rules` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:637` | +| `POST` | `/api/ledger/accounting-configuration/posting-rules/candidates` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:811` | +| `POST` | `/api/ledger/accounting-configuration/posting-rules/candidates/post` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:854` | +| `POST` | `/api/ledger/accounting-configuration/posting-rules/dry-run` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:770` | +| `POST` | `/api/ledger/accounting-configuration/posting-rules/projection-sets` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:898` | +| `POST` | `/api/ledger/accounting-configuration/posting-rules/promotion-approvals` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:668` | +| `POST` | `/api/ledger/accounting-configuration/posting-rules/test-cases` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:704` | +| `POST` | `/api/ledger/accounting-configuration/posting-rules/tests` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:941` | +| `POST` | `/api/ledger/accounting-configuration/preview` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:735` | +| `POST` | `/api/ledger/accounting-configuration/templates` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:606` | +| `GET` | `/api/ledger/aggregates/{aggregateId:guid}/journal-entries` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:305` | | `GET` | `/api/ledger/books` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:25` | | `POST` | `/api/ledger/books` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:75` | -| `POST` | `/api/ledger/books/rollout-assessment` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:105` | +| `POST` | `/api/ledger/books/rollout-assessment` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:106` | | `GET` | `/api/ledger/books/{ledgerBookId:guid}` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:52` | -| `POST` | `/api/ledger/close-management/evidence-review` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1250` | -| `POST` | `/api/ledger/close-management/late-adjustments` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1103` | -| `POST` | `/api/ledger/close-management/late-adjustments/review` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1152` | -| `POST` | `/api/ledger/close-management/period-lock` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1299` | -| `POST` | `/api/ledger/close-management/period-plan/configuration` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1054` | -| `GET` | `/api/ledger/close-management/period-plan/{workflowId:guid}` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1028` | -| `POST` | `/api/ledger/close-management/task-signoffs` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1201` | -| `GET` | `/api/ledger/journal-entry-workbench` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1535` | -| `POST` | `/api/ledger/journal-entry-workbench/drafts` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1856` | -| `POST` | `/api/ledger/journal-entry-workbench/evidence` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1988` | -| `POST` | `/api/ledger/journal-entry-workbench/lifecycle-action` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:2030` | -| `POST` | `/api/ledger/journal-entry-workbench/submit-approval` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1946` | -| `POST` | `/api/ledger/journal-entry-workbench/validate` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1906` | -| `GET` | `/api/ledger/periods` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:146` | -| `POST` | `/api/ledger/periods` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:184` | -| `POST` | `/api/ledger/periods/{periodId:guid}/close` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:215` | -| `GET` | `/api/ledger/periods/{periodId:guid}/journal-entries` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:258` | -| `GET` | `/api/ledger/periods/{periodId:guid}/pnl-summary` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:401` | -| `GET` | `/api/ledger/periods/{periodId:guid}/trial-balance` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:351` | -| `GET` | `/api/ledger/periods/{periodId:guid}/trial-balance-report` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:376` | -| `GET` | `/api/ledger/private-capital/activity` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1561` | -| `GET` | `/api/ledger/private-capital/capital-account-subledger` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1677` | -| `GET` | `/api/ledger/private-capital/capital-account-workbench` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1809` | -| `GET` | `/api/ledger/private-capital/fund-event-command-center` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1636` | -| `GET` | `/api/ledger/private-capital/fund-event-record` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1593` | -| `GET` | `/api/ledger/private-capital/report-output` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1738` | -| `POST` | `/api/ledger/reports/accounting-package` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1352` | -| `POST` | `/api/ledger/reports/accounting-package/certification` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1400` | -| `GET` | `/api/ledger/reports/accounting-packages` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1452` | -| `GET` | `/api/ledger/reports/accounting-packages/{packageId}/exports/{artifactId}` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1489` | -| `GET` | `/api/ledger/reports/pnl-summary` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:485` | -| `GET` | `/api/ledger/reports/trial-balance` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:426` | -| `GET` | `/api/loans/portfolio` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1066` | -| `POST` | `/api/loans/rebuild-all` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1077` | -| `GET` | `/api/loans/rebuild-checkpoints` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1057` | +| `POST` | `/api/ledger/close-management/evidence-review` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1271` | +| `POST` | `/api/ledger/close-management/late-adjustments` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1121` | +| `POST` | `/api/ledger/close-management/late-adjustments/review` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1171` | +| `POST` | `/api/ledger/close-management/period-lock` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1321` | +| `POST` | `/api/ledger/close-management/period-plan/configuration` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1071` | +| `GET` | `/api/ledger/close-management/period-plan/{workflowId:guid}` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1045` | +| `POST` | `/api/ledger/close-management/task-signoffs` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1221` | +| `GET` | `/api/ledger/journal-entry-workbench` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1562` | +| `POST` | `/api/ledger/journal-entry-workbench/drafts` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1883` | +| `POST` | `/api/ledger/journal-entry-workbench/evidence` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:2018` | +| `POST` | `/api/ledger/journal-entry-workbench/lifecycle-action` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:2061` | +| `POST` | `/api/ledger/journal-entry-workbench/submit-approval` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1975` | +| `POST` | `/api/ledger/journal-entry-workbench/validate` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1934` | +| `GET` | `/api/ledger/periods` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:147` | +| `POST` | `/api/ledger/periods` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:185` | +| `POST` | `/api/ledger/periods/{periodId:guid}/close` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:217` | +| `GET` | `/api/ledger/periods/{periodId:guid}/journal-entries` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:261` | +| `GET` | `/api/ledger/periods/{periodId:guid}/pnl-summary` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:404` | +| `GET` | `/api/ledger/periods/{periodId:guid}/trial-balance` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:354` | +| `GET` | `/api/ledger/periods/{periodId:guid}/trial-balance-report` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:379` | +| `GET` | `/api/ledger/private-capital/activity` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1588` | +| `GET` | `/api/ledger/private-capital/capital-account-subledger` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1704` | +| `GET` | `/api/ledger/private-capital/capital-account-workbench` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1836` | +| `GET` | `/api/ledger/private-capital/fund-event-command-center` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1663` | +| `GET` | `/api/ledger/private-capital/fund-event-record` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1620` | +| `GET` | `/api/ledger/private-capital/report-output` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1765` | +| `POST` | `/api/ledger/reports/accounting-package` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1375` | +| `POST` | `/api/ledger/reports/accounting-package/certification` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1426` | +| `GET` | `/api/ledger/reports/accounting-packages` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1479` | +| `GET` | `/api/ledger/reports/accounting-packages/{packageId}/exports/{artifactId}` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:1516` | +| `GET` | `/api/ledger/reports/pnl-summary` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:488` | +| `GET` | `/api/ledger/reports/trial-balance` | Documented | `src/Meridian.Ui.Shared/Endpoints/LedgerEndpoints.cs:429` | +| `GET` | `/api/loans/portfolio` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1094` | +| `POST` | `/api/loans/rebuild-all` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1105` | +| `GET` | `/api/loans/rebuild-checkpoints` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1085` | | `POST` | `/api/maintenance/execute` | Documented | `src/Meridian.Ui.Shared/Endpoints/ArchiveMaintenanceEndpoints.cs:145` | | `GET` | `/api/maintenance/executions` | Documented | `src/Meridian.Ui.Shared/Endpoints/ArchiveMaintenanceEndpoints.cs:196` | | `POST` | `/api/maintenance/executions/cleanup` | Documented | `src/Meridian.Ui.Shared/Endpoints/ArchiveMaintenanceEndpoints.cs:500` | @@ -346,7 +346,7 @@ Tracks whether mapped API routes and workstation DTO contracts are visible in th | `POST` | `/api/plaid/transfers/sandbox` | Documented | `src/Meridian.Ui.Shared/Endpoints/PlaidEndpoints.cs:176` | | `POST` | `/api/plaid/webhook` | Documented | `src/Meridian.Ui.Shared/Endpoints/PlaidEndpoints.cs:154` | | `GET` | `/api/portfolio/household` | Documented | `src/Meridian.Ui.Shared/Endpoints/FundAccountEndpoints.cs:259` | -| `GET` | `/api/projections/{projectionRunId:guid}/flows` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:899` | +| `GET` | `/api/projections/{projectionRunId:guid}/flows` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:923` | | `GET` | `/api/provider-routing/bindings` | Documented | `src/Meridian.Ui.Shared/Endpoints/ProviderRoutingEndpoints.cs:36` | | `GET` | `/api/provider-routing/connections` | Documented | `src/Meridian.Ui.Shared/Endpoints/ProviderRoutingEndpoints.cs:20` | | `POST` | `/api/provider-routing/preview` | Documented | `src/Meridian.Ui.Shared/Endpoints/ProviderRoutingEndpoints.cs:68` | @@ -427,9 +427,9 @@ Tracks whether mapped API routes and workstation DTO contracts are visible in th | `POST` | `/api/quant/parameters` | Documented | `src/Meridian.Ui.Shared/Endpoints/QuantLabEndpoints.cs:87` | | `POST` | `/api/quant/run` | Documented | `src/Meridian.Ui.Shared/Endpoints/QuantLabEndpoints.cs:35` | | `GET` | `/api/quant/templates` | Documented | `src/Meridian.Ui.Shared/Endpoints/QuantLabEndpoints.cs:123` | -| `GET` | `/api/reconciliation/exceptions` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:975` | -| `POST` | `/api/reconciliation/exceptions/{exceptionId:guid}/resolve` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:984` | -| `GET` | `/api/reconciliation/{runId:guid}/results` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:966` | +| `GET` | `/api/reconciliation/exceptions` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1001` | +| `POST` | `/api/reconciliation/exceptions/{exceptionId:guid}/resolve` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1010` | +| `GET` | `/api/reconciliation/{runId:guid}/results` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:992` | | `GET` | `/api/reference-data/bonds/issuer-ladder` | Documented | `src/Meridian.Ui.Shared/Endpoints/BondReferenceEndpoints.cs:63` | | `GET` | `/api/reference-data/bonds/maturity-ladder` | Documented | `src/Meridian.Ui.Shared/Endpoints/BondReferenceEndpoints.cs:87` | | `GET` | `/api/reference-data/bonds/{securityId:guid}` | Documented | `src/Meridian.Ui.Shared/Endpoints/BondReferenceEndpoints.cs:24` | @@ -533,10 +533,10 @@ Tracks whether mapped API routes and workstation DTO contracts are visible in th | `POST` | `/api/security-master/{securityId:guid}/raw-price` | Documented | `src/Meridian.Ui.Shared/Endpoints/SecurityMasterEndpoints.cs:1013` | | `GET` | `/api/security-master/{securityId:guid}/trading-parameters` | Documented | `src/Meridian.Ui.Shared/Endpoints/SecurityMasterEndpoints.cs:452` | | `GET` | `/api/security-master/{securityId:guid}/validation` | Documented | `src/Meridian.Ui.Shared/Endpoints/SecurityMasterEndpoints.cs:60` | -| `POST` | `/api/servicer-reports` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1005` | -| `GET` | `/api/servicer-reports/{batchId:guid}` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1031` | -| `GET` | `/api/servicer-reports/{batchId:guid}/position-lines` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1039` | -| `GET` | `/api/servicer-reports/{batchId:guid}/transaction-lines` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1048` | +| `POST` | `/api/servicer-reports` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1032` | +| `GET` | `/api/servicer-reports/{batchId:guid}` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1059` | +| `GET` | `/api/servicer-reports/{batchId:guid}/position-lines` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1067` | +| `GET` | `/api/servicer-reports/{batchId:guid}/transaction-lines` | Documented | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:1076` | | `GET` | `/api/sla/health` | Documented | `src/Meridian.Ui.Shared/Endpoints/DataQualityEndpoints.cs:555` | | `GET` | `/api/sla/metrics` | Documented | `src/Meridian.Ui.Shared/Endpoints/DataQualityEndpoints.cs:581` | | `GET` | `/api/sla/status` | Documented | `src/Meridian.Ui.Shared/Endpoints/DataQualityEndpoints.cs:527` | diff --git a/docs/status/coverage-report.md b/docs/status/coverage-report.md index 63424a5529..9f67f11ffd 100644 --- a/docs/status/coverage-report.md +++ b/docs/status/coverage-report.md @@ -5,17 +5,17 @@ ## Overall Coverage -**2586 / 7219** items documented (**35.8%**) — Grade: **F** +**2596 / 7237** items documented (**35.9%**) — Grade: **F** ```text -[=======-------------] 35.8% +[=======-------------] 35.9% ``` ## Coverage by Category | Category | Documented | Total | Coverage | Grade | | ---------- | ----------- | ------- | ---------- | ------- | -| Public Classes / Interfaces | 2465 | 6724 | 36.7% | F | +| Public Classes / Interfaces | 2475 | 6742 | 36.7% | F | | API Endpoints | 107 | 348 | 30.7% | F | | Configuration Options | 3 | 136 | 2.2% | F | | Provider Implementations | 0 | 0 | 100.0% | A | @@ -23,19 +23,25 @@ ## Undocumented Items -### Public Classes / Interfaces (4259 undocumented) +### Public Classes / Interfaces (4267 undocumented) | Item | Location | | ------ | ---------- | +| `BackfillRemediationSlaTier` | `src/Meridian.Application/Backfill/AutoGapRemediationService.cs:46` | +| `BackfillRemediationSlaDecision` | `src/Meridian.Application/Backfill/AutoGapRemediationService.cs:52` | +| `BackfillRemediationSlaStatus` | `src/Meridian.Application/Backfill/AutoGapRemediationService.cs:59` | +| `BackfillRemediationSlaStatusItem` | `src/Meridian.Application/Backfill/AutoGapRemediationService.cs:68` | +| `BackfillRemediationSlaSnapshot` | `src/Meridian.Application/Backfill/AutoGapRemediationService.cs:85` | +| `BackfillRemediationSlaPolicy` | `src/Meridian.Application/Backfill/AutoGapRemediationService.cs:93` | | `BackfillPartitionEstimate` | `src/Meridian.Application/Backfill/BackfillPartitionPlanner.cs:147` | -| `CrossSourceBackfillReconciliationRequest` | `src/Meridian.Application/Backfill/CrossSourceBackfillReconciliationService.cs:427` | -| `CrossSourceBackfillBatchReconciliationRequest` | `src/Meridian.Application/Backfill/CrossSourceBackfillReconciliationService.cs:436` | -| `CrossSourceBackfillReconciliationResult` | `src/Meridian.Application/Backfill/CrossSourceBackfillReconciliationService.cs:445` | -| `CrossSourceBackfillBatchReconciliationResult` | `src/Meridian.Application/Backfill/CrossSourceBackfillReconciliationService.cs:465` | -| `CrossSourceBackfillClosureStatus` | `src/Meridian.Application/Backfill/CrossSourceBackfillReconciliationService.cs:479` | -| `CrossSourceBackfillClosureDecision` | `src/Meridian.Application/Backfill/CrossSourceBackfillReconciliationService.cs:487` | -| `CrossSourceBackfillDiscrepancy` | `src/Meridian.Application/Backfill/CrossSourceBackfillReconciliationService.cs:544` | -| `CrossSourceBackfillProviderError` | `src/Meridian.Application/Backfill/CrossSourceBackfillReconciliationService.cs:557` | +| `CrossSourceBackfillReconciliationRequest` | `src/Meridian.Application/Backfill/CrossSourceBackfillReconciliationService.cs:473` | +| `CrossSourceBackfillBatchReconciliationRequest` | `src/Meridian.Application/Backfill/CrossSourceBackfillReconciliationService.cs:482` | +| `CrossSourceBackfillReconciliationResult` | `src/Meridian.Application/Backfill/CrossSourceBackfillReconciliationService.cs:491` | +| `CrossSourceBackfillBatchReconciliationResult` | `src/Meridian.Application/Backfill/CrossSourceBackfillReconciliationService.cs:511` | +| `CrossSourceBackfillClosureStatus` | `src/Meridian.Application/Backfill/CrossSourceBackfillReconciliationService.cs:525` | +| `CrossSourceBackfillClosureDecision` | `src/Meridian.Application/Backfill/CrossSourceBackfillReconciliationService.cs:533` | +| `CrossSourceBackfillDiscrepancy` | `src/Meridian.Application/Backfill/CrossSourceBackfillReconciliationService.cs:605` | +| `CrossSourceBackfillProviderError` | `src/Meridian.Application/Backfill/CrossSourceBackfillReconciliationService.cs:618` | | `BackfillExecutionHistory` | `src/Meridian.Application/Scheduling/BackfillExecutionLog.cs:250` | | `ScheduleExecutionSummary` | `src/Meridian.Application/Scheduling/BackfillExecutionLog.cs:407` | | `SystemExecutionSummary` | `src/Meridian.Application/Scheduling/BackfillExecutionLog.cs:427` | @@ -71,13 +77,7 @@ | `SecurityMasterActiveImportStatus` | `src/Meridian.Application/SecurityMaster/SecurityMasterIngestStatusService.cs:13` | | `SecurityMasterCompletedImportStatus` | `src/Meridian.Application/SecurityMaster/SecurityMasterIngestStatusService.cs:26` | | `ISecurityMasterIngestStatusService` | `src/Meridian.Application/SecurityMaster/SecurityMasterIngestStatusService.cs:41` | -| `ISecurityMasterLedgerBridge` | `src/Meridian.Application/SecurityMaster/SecurityMasterLedgerBridge.cs:13` | -| `SecurityMasterOperationalReadinessRequest` | `src/Meridian.Application/SecurityMaster/SecurityMasterOperationalReadinessService.cs:8` | -| `SecurityMasterOperationalEvidenceSnapshot` | `src/Meridian.Application/SecurityMaster/SecurityMasterOperationalReadinessService.cs:14` | -| `SecurityMasterOperationalEvidenceItem` | `src/Meridian.Application/SecurityMaster/SecurityMasterOperationalReadinessService.cs:21` | -| `ISecurityMasterOperationalReadinessService` | `src/Meridian.Application/SecurityMaster/SecurityMasterOperationalReadinessService.cs:33` | -| `NullSecurityValidationGateService` | `src/Meridian.Application/SecurityMaster/SecurityValidationGateService.cs:160` | -| ... and 4209 more | | +| ... and 4217 more | | ### API Endpoints (241 undocumented) @@ -101,38 +101,38 @@ | `/api/compliance/controls/attestation` | `src/Meridian.Ui.Shared/Endpoints/Compliance/ComplianceEndpoints.cs:37` | | `/api/compliance/access-reviews/run` | `src/Meridian.Ui.Shared/Endpoints/Compliance/ComplianceEndpoints.cs:52` | | `/api/compliance/access-reviews` | `src/Meridian.Ui.Shared/Endpoints/Compliance/ComplianceEndpoints.cs:61` | -| `/{loanId:guid}` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:61` | -| `/{loanId:guid}/projections/contract` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:76` | -| `/{loanId:guid}/rebuild-state` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:105` | -| `/{loanId:guid}/terms-versions` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:122` | -| `/{loanId:guid}/projections/terms-versions` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:136` | -| `/{loanId:guid}/terms` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:150` | -| `/{loanId:guid}/activate` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:180` | -| `/{loanId:guid}/servicing-state` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:202` | -| `/{loanId:guid}/projections/servicing` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:217` | -| `/{loanId:guid}/projections/drawdown-lots` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:232` | -| `/{loanId:guid}/projections/revisions` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:246` | -| `/{loanId:guid}/projections/accruals` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:260` | -| `/{loanId:guid}/drawdowns` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:274` | -| `/{loanId:guid}/rate-resets` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:304` | -| `/{loanId:guid}/payments/principal` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:334` | -| `/{loanId:guid}/accruals/daily` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:364` | -| `/{loanId:guid}/payments` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:394` | -| `/{loanId:guid}/cash-transactions` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:420` | -| `/{loanId:guid}/payment-allocations` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:428` | -| `/{loanId:guid}/fees` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:436` | -| `/{loanId:guid}/fee-balances` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:462` | -| `/servicer-statements/preview` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:483` | -| `/servicer-statements/import` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:506` | -| `/servicer-statements/{batchId:guid}` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:529` | -| `/servicer-statements/{batchId:guid}/rows` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:539` | -| `/servicer-statements/{batchId:guid}/apply` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:549` | -| `/{loanId:guid}/collateral` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:572` | -| `/{loanId:guid}/collateral/remove` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:611` | -| `/{loanId:guid}/collateral/value` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:640` | -| `/{loanId:guid}/status-transitions` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:669` | -| `/{loanId:guid}/pik` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:698` | -| `/{loanId:guid}/restructures` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:727` | +| `/{loanId:guid}` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:62` | +| `/{loanId:guid}/projections/contract` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:77` | +| `/{loanId:guid}/rebuild-state` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:106` | +| `/{loanId:guid}/terms-versions` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:124` | +| `/{loanId:guid}/projections/terms-versions` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:138` | +| `/{loanId:guid}/terms` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:152` | +| `/{loanId:guid}/activate` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:183` | +| `/{loanId:guid}/servicing-state` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:206` | +| `/{loanId:guid}/projections/servicing` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:221` | +| `/{loanId:guid}/projections/drawdown-lots` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:236` | +| `/{loanId:guid}/projections/revisions` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:250` | +| `/{loanId:guid}/projections/accruals` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:264` | +| `/{loanId:guid}/drawdowns` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:278` | +| `/{loanId:guid}/rate-resets` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:309` | +| `/{loanId:guid}/payments/principal` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:340` | +| `/{loanId:guid}/accruals/daily` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:371` | +| `/{loanId:guid}/payments` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:402` | +| `/{loanId:guid}/cash-transactions` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:429` | +| `/{loanId:guid}/payment-allocations` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:437` | +| `/{loanId:guid}/fees` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:445` | +| `/{loanId:guid}/fee-balances` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:472` | +| `/servicer-statements/preview` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:493` | +| `/servicer-statements/import` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:517` | +| `/servicer-statements/{batchId:guid}` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:541` | +| `/servicer-statements/{batchId:guid}/rows` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:551` | +| `/servicer-statements/{batchId:guid}/apply` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:561` | +| `/{loanId:guid}/collateral` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:585` | +| `/{loanId:guid}/collateral/remove` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:625` | +| `/{loanId:guid}/collateral/value` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:655` | +| `/{loanId:guid}/status-transitions` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:685` | +| `/{loanId:guid}/pik` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:715` | +| `/{loanId:guid}/restructures` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:745` | | ... and 191 more | | ### Configuration Options (133 undocumented) @@ -193,7 +193,7 @@ ## Recommendations -1. **Public Classes / Interfaces**: 4259 undocumented types. Consider generating API docs with DocFX (`docfx docfx.json`) to cover the long tail of public types automatically. +1. **Public Classes / Interfaces**: 4267 undocumented types. Consider generating API docs with DocFX (`docfx docfx.json`) to cover the long tail of public types automatically. 2. **API Endpoints**: 241 endpoint(s) missing from `docs/reference/api-reference.md`. Run the endpoint audit and update the API reference table. 3. **Configuration Options**: 133 config key(s) not found in `docs/generated/configuration-schema.md`. Re-run the configuration schema generator to synchronise. diff --git a/docs/status/doc-health-dashboard.json b/docs/status/doc-health-dashboard.json index 8ca758d979..939c2824e7 100644 --- a/docs/status/doc-health-dashboard.json +++ b/docs/status/doc-health-dashboard.json @@ -1,11 +1,11 @@ { - "total_files": 534, - "total_lines": 86936, - "orphaned_count": 204, + "total_files": 535, + "total_lines": 87608, + "orphaned_count": 205, "no_heading_count": 38, "stale_count": 0, "todo_count": 199, - "average_lines": 162.8, + "average_lines": 163.8, "health_score": 83, "orphaned_files": [ ".agents/skills/meridian-archive-organizer/SKILL.md", @@ -126,6 +126,7 @@ "docs/plans/fund-management-pr-sequenced-roadmap.md", "docs/plans/fund-management-product-vision-and-capability-matrix.md", "docs/plans/governance-fund-ops-blueprint.md", + "docs/plans/high-value-code-brainstorm-2026-07.md", "docs/plans/kernel-parity-migration-blueprint.md", "docs/plans/l3-inference-implementation-plan.md", "docs/plans/ledger.md", @@ -1489,7 +1490,7 @@ }, { "path": "docs/architecture/provider-management.md", - "line_count": 843, + "line_count": 866, "has_heading": true, "todo_count": 0, "last_modified_utc": "1970-01-01T00:00:00+00:00", @@ -1513,7 +1514,7 @@ }, { "path": "docs/architecture/storage-design.md", - "line_count": 3261, + "line_count": 3264, "has_heading": true, "todo_count": 0, "last_modified_utc": "1970-01-01T00:00:00+00:00", @@ -2129,7 +2130,7 @@ }, { "path": "docs/generated/repository-structure.md", - "line_count": 7449, + "line_count": 7660, "has_heading": true, "todo_count": 9, "last_modified_utc": "1970-01-01T00:00:00+00:00", @@ -2481,7 +2482,7 @@ }, { "path": "docs/operators/provider-backfill-operations.md", - "line_count": 172, + "line_count": 178, "has_heading": true, "todo_count": 0, "last_modified_utc": "1970-01-01T00:00:00+00:00", @@ -2671,6 +2672,14 @@ "last_modified_utc": "1970-01-01T00:00:00+00:00", "stale": false }, + { + "path": "docs/plans/high-value-code-brainstorm-2026-07.md", + "line_count": 376, + "has_heading": true, + "todo_count": 0, + "last_modified_utc": "1970-01-01T00:00:00+00:00", + "stale": false + }, { "path": "docs/plans/kernel-parity-migration-blueprint.md", "line_count": 16, @@ -3273,7 +3282,7 @@ }, { "path": "docs/reference/provider-validation-evidence-schema.md", - "line_count": 56, + "line_count": 65, "has_heading": true, "todo_count": 0, "last_modified_utc": "1970-01-01T00:00:00+00:00", @@ -3425,7 +3434,7 @@ }, { "path": "docs/screenshots/README.md", - "line_count": 39, + "line_count": 40, "has_heading": true, "todo_count": 0, "last_modified_utc": "1970-01-01T00:00:00+00:00", @@ -3545,7 +3554,7 @@ }, { "path": "docs/security/security-remediation-backlog.md", - "line_count": 115, + "line_count": 122, "has_heading": true, "todo_count": 0, "last_modified_utc": "1970-01-01T00:00:00+00:00", @@ -3553,7 +3562,7 @@ }, { "path": "docs/security/threat-model-current-state.md", - "line_count": 109, + "line_count": 118, "has_heading": true, "todo_count": 0, "last_modified_utc": "1970-01-01T00:00:00+00:00", @@ -4057,7 +4066,7 @@ }, { "path": "src/Meridian.Application/README.md", - "line_count": 454, + "line_count": 459, "has_heading": true, "todo_count": 3, "last_modified_utc": "1970-01-01T00:00:00+00:00", @@ -4209,7 +4218,7 @@ }, { "path": "src/Meridian.Infrastructure/README.md", - "line_count": 154, + "line_count": 165, "has_heading": true, "todo_count": 3, "last_modified_utc": "1970-01-01T00:00:00+00:00", @@ -4297,7 +4306,7 @@ }, { "path": "src/Meridian.Storage/README.md", - "line_count": 313, + "line_count": 315, "has_heading": true, "todo_count": 1, "last_modified_utc": "1970-01-01T00:00:00+00:00", @@ -4329,7 +4338,7 @@ }, { "path": "src/Meridian.Ui/dashboard/README.md", - "line_count": 1191, + "line_count": 1200, "has_heading": true, "todo_count": 4, "last_modified_utc": "1970-01-01T00:00:00+00:00", diff --git a/docs/status/doc-health-dashboard.md b/docs/status/doc-health-dashboard.md index 97848c3b94..d2bb458757 100644 --- a/docs/status/doc-health-dashboard.md +++ b/docs/status/doc-health-dashboard.md @@ -19,10 +19,10 @@ Data sources: `repo markdown (*.md)`, `file modification metadata` | Metric | Value | | -------- | ------- | -| Total documentation files | 534 | -| Total lines | 86,936 | -| Average file size (lines) | 162.8 | -| Orphaned files | 204 | +| Total documentation files | 535 | +| Total lines | 87,608 | +| Average file size (lines) | 163.8 | +| Orphaned files | 205 | | Files without headings | 38 | | Stale files (>90 days) | 0 | | TODO/FIXME markers | 199 | @@ -85,7 +85,7 @@ These files are not linked from any other Markdown file in the repository: - `Meridian Design System/VISUAL_FOUNDATIONS.md` - `Meridian Design System/components/charts/CandleChart.prompt.md` - `Meridian Design System/components/charts/ChartCard.prompt.md` -- ... and 184 more +- ... and 185 more ## Trend @@ -93,7 +93,7 @@ These files are not linked from any other Markdown file in the repository: | Date | Score | Files | Orphans | Stale | | ------ | ------- | ------- | --------- | ------- | -| 1970-01-01 | 83 | 534 | 204 | 0 | +| 1970-01-01 | 83 | 535 | 205 | 0 | --- diff --git a/docs/status/example-validation.md b/docs/status/example-validation.md index c8f8906f91..170437edfc 100644 --- a/docs/status/example-validation.md +++ b/docs/status/example-validation.md @@ -2,7 +2,7 @@ > Auto-generated by `validate-examples.py`. Do not edit manually. > Scan time: 1970-01-01T00:00:00+00:00 -> Files scanned: 418 +> Files scanned: 419 ## Overall Summary From 826a4d593a8a5b28a30c4624b976da36fbeb91ec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 20:22:17 +0000 Subject: [PATCH 03/13] feat: add paper-session TCA reports and data replacement-cost meter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the two 'Now'-lane items from the high-value brainstorm: - SessionTcaReporter (Meridian.Execution): TCA over paper/live session fill tapes — commission attribution, per-symbol VWAP breakdowns, median-based outlier detection, and execution-quality stats (time-to-fill, notional-weighted limit-price improvement) — exposed at GET /api/execution/sessions/{sessionId}/tca. - DataReplacementCostEstimator (Meridian.Storage): conservative replacement-cost estimate of the local market-data store priced against a configurable vendor rate card (lower of per-symbol-day and per-gigabyte bases), exposed at GET /api/workstation/data/replacement-cost and as an 'Est. Replacement Cost' metric card on the Data workspace payload. - 18 new xUnit tests (SessionTcaReporterTests, DataReplacementCostEstimatorTests); workstation endpoint and execution suites pass (523 tests). Also addresses review feedback: relocates the brainstorm doc to docs/product/ per the docs/plans migration rule and links it from the product index; retargets brainstorm idea #2 to hardening the existing AlpacaBrokerageGateway (promotion wiring, broker-state reconciliation, live-readiness evidence) instead of building a duplicate adapter; and regenerates the generated status docs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011vonQATmobZwPb2KcouHnC --- .../brainstorm-history.jsonl | 2 +- docs/generated/repository-structure.md | 8 +- docs/product/README.md | 3 + .../high-value-code-brainstorm-2026-07.md | 50 +-- docs/status/TODO.md | 10 +- .../api-contract-coverage-dashboard.json | 4 +- .../status/api-contract-coverage-dashboard.md | 4 +- docs/status/coverage-report.md | 18 +- docs/status/doc-health-dashboard.json | 25 +- docs/status/doc-health-dashboard.md | 8 +- src/Meridian.Contracts/Api/UiApiRoutes.cs | 1 + .../Services/SessionTcaReporter.cs | 347 ++++++++++++++++++ .../Services/DataReplacementCostEstimator.cs | 206 +++++++++++ .../Endpoints/ExecutionEndpoints.cs | 22 ++ .../Endpoints/WorkstationEndpoints.cs | 71 +++- .../Execution/SessionTcaReporterTests.cs | 234 ++++++++++++ .../DataReplacementCostEstimatorTests.cs | 177 +++++++++ 17 files changed, 1121 insertions(+), 69 deletions(-) rename docs/{plans => product}/high-value-code-brainstorm-2026-07.md (91%) create mode 100644 src/Meridian.Execution/Services/SessionTcaReporter.cs create mode 100644 src/Meridian.Storage/Services/DataReplacementCostEstimator.cs create mode 100644 tests/Meridian.Tests/Execution/SessionTcaReporterTests.cs create mode 100644 tests/Meridian.Tests/Storage/DataReplacementCostEstimatorTests.cs diff --git a/.claude/skills/meridian-brainstorm/brainstorm-history.jsonl b/.claude/skills/meridian-brainstorm/brainstorm-history.jsonl index 9421ee594a..87656ceb48 100644 --- a/.claude/skills/meridian-brainstorm/brainstorm-history.jsonl +++ b/.claude/skills/meridian-brainstorm/brainstorm-history.jsonl @@ -6,4 +6,4 @@ {"session_date": "2026-03-27", "mode": "Domain-Focused", "themes": ["Script-to-strategy promotion wizard", "Script notebook mode (multi-cell)", "Script library / import system", "Scheduled scripts and Alert API", "QuantScript parameter grid search API", "Live streaming data in scripts (v2)", "L3 x QuantScript bridge (L3.Simulate())", "Calibration quality report card", "Toxic flow tagging on fill tape", "L3 simulation replay visualizer", "Options queue model extension", "Symbol-partitioned collection (sharding)", "Leader-elected coordinator instance", "Cross-instance deduplication ledger (IDedupStore)", "Distributed backfill work queue", "Cluster health dashboard panel", "Distributed grid search (Track G x I)", "Cross-symbol correlation regime signals"], "ideas_count": 18, "track": "G-H-I", "notes": "First round of Track G-I brainstorm. Defined L3.Simulate() as highest-leverage cross-track bridge. IDedupStore as foundational Track I seam. Recommended sequencing: CalibrationReportCard -> ScriptLibrary -> L3Bridge -> GridSearch -> IDedupStore -> LeaderElection -> Sharding -> DistributedBackfill."} {"session_date": "2026-03-27", "mode": "Domain-Focused", "themes": ["Script debugger (step/watch/breakpoint)", "Script run history and result diffing", "Security Master access in QuantScript (Securities global)", "Script output export (PDF/Parquet/Excel/HTML)", "Script expression REPL", "Venue-specific L3 calibration profiles (VenueCalibrationPriors)", "TCA report: execution cost attribution", "Multi-day queue state continuity (carry-state)", "Dark pool / hidden liquidity estimator", "Zero-downtime rolling upgrade orchestration", "Geo-distributed collection by exchange timezone", "Split-brain detection and recovery"], "ideas_count": 12, "track": "G-H-I", "notes": "Second round of Track G-I brainstorm. TCA Report identified as highest-leverage institutional positioning item. VenueCalibrationPriors and SplitBrainDetector identified as platform bets. Script REPL flagged as surprise high-leverage small-effort item. Sequencing: REPL + SecurityMaster -> TCA Report -> RunHistory + VenueCalibration -> SplitBrain -> RollingUpgrade + DarkPool + GeoDistributed + Debugger + MultiDayContinuity."} {"session_date": "2026-06-25", "mode": "Domain-Focused", "themes": ["Provider Setup Wizard with Live Certification", "Hot-Reload Provider Re-Registration", "Credential Vault with Encrypted Persistence", "Runtime Failover Chain Editor", "Per-Provider Partial Config Mutation API", "Ingestion Session Continuity on Restart", "Provider Capability Browser in Data Workspace"], "ideas_count": 7, "document_updated": null, "notes": "Fresh domain covering runtime provider lifecycle management — an unexplored area in all prior sessions. Platform bets: Hot-Reload Re-Registration (ConfigWatcher already exists, ProviderRegistry.RegisterStreamingFactory already supports replace) and Per-Provider Partial Config Mutation (ConfigStore.PatchAsync primitive). Recommended sequence: Capability Browser (S) -> Partial Config API (M) -> Hot-Reload (M) -> Failover Editor (S) -> Setup Wizard (M) -> Credential Vault (M) -> Session Continuity (L)."} -{"session_date": "2026-07-01", "mode": "Competitive / Open Exploration", "themes": ["live/paper TCA best-execution report pack", "Alpaca live broker adapter (IExecutionGateway)", "reconciliation break-resolution agent (MCP + CoS gates)", "custodian/broker statement connector library (IB Flex, OFX/CSV profiles)", "DuckDB analytics workbench + /api/query", "Python meridian client package (promoted)", "W6 backtesting studio evidence loop (manifest + run comparison)", "LEAN/NautilusTrader data-feed bridge", "governed investor report delivery portal-lite", "data-cost savings meter"], "ideas_count": 10, "document_updated": "docs/plans/high-value-code-brainstorm-2026-07.md", "notes": "Market-researched prioritization session. Signals: TCA now mandatory infrastructure (~$2.8B market), Arcesium productizing Reconciliation Agent, LEAN BYO-data pain, Databento/Polygon pricing validates self-hosted economics. Sequencing: cost meter + TCA + IB Flex connector first; DuckDB -> Python package; Alpaca adapter feeding W7; W6 studio slice; recon agent after connector coverage."} +{"session_date": "2026-07-01", "mode": "Competitive / Open Exploration", "themes": ["live/paper TCA best-execution report pack", "Alpaca live broker adapter (IExecutionGateway)", "reconciliation break-resolution agent (MCP + CoS gates)", "custodian/broker statement connector library (IB Flex, OFX/CSV profiles)", "DuckDB analytics workbench + /api/query", "Python meridian client package (promoted)", "W6 backtesting studio evidence loop (manifest + run comparison)", "LEAN/NautilusTrader data-feed bridge", "governed investor report delivery portal-lite", "data-cost savings meter"], "ideas_count": 10, "document_updated": "docs/product/high-value-code-brainstorm-2026-07.md", "notes": "Market-researched prioritization session. Signals: TCA now mandatory infrastructure (~$2.8B market), Arcesium productizing Reconciliation Agent, LEAN BYO-data pain, Databento/Polygon pricing validates self-hosted economics. Sequencing: cost meter + TCA + IB Flex connector first; DuckDB -> Python package; Alpaca adapter feeding W7; W6 studio slice; recon agent after connector coverage."} diff --git a/docs/generated/repository-structure.md b/docs/generated/repository-structure.md index 2e8d93ba20..522229166b 100644 --- a/docs/generated/repository-structure.md +++ b/docs/generated/repository-structure.md @@ -1717,7 +1717,6 @@ Meridian-main │ │ ├── fund-management-pr-sequenced-roadmap.md │ │ ├── fund-management-product-vision-and-capability-matrix.md │ │ ├── governance-fund-ops-blueprint.md -│ │ ├── high-value-code-brainstorm-2026-07.md │ │ ├── kernel-parity-migration-blueprint.md │ │ ├── l3-inference-implementation-plan.md │ │ ├── ledger.md @@ -1767,6 +1766,7 @@ Meridian-main │ │ └── web-ui-development-pivot.md │ ├── product │ │ ├── deferred-expansion-boundaries.md +│ │ ├── high-value-code-brainstorm-2026-07.md │ │ ├── implementation-todo-list.md │ │ ├── meridian-design-document.md │ │ └── README.md @@ -3582,7 +3582,8 @@ Meridian-main │ │ │ ├── PositionLotSelector.cs │ │ │ ├── PositionReconciliationService.cs │ │ │ ├── PositionSyncOptions.cs -│ │ │ └── ReplayDriftDetector.cs +│ │ │ ├── ReplayDriftDetector.cs +│ │ │ └── SessionTcaReporter.cs │ │ ├── TaxLotAccounting │ │ │ ├── ITaxLotSelector.cs │ │ │ ├── TaxLotAccountingMethod.cs @@ -4559,6 +4560,7 @@ Meridian-main │ │ │ ├── DataLineageService.cs │ │ │ ├── DataQualityScoringService.cs │ │ │ ├── DataQualityService.cs +│ │ │ ├── DataReplacementCostEstimator.cs │ │ │ ├── EventBuffer.cs │ │ │ ├── FileMaintenanceService.cs │ │ │ ├── FilePermissionsService.cs @@ -6705,6 +6707,7 @@ Meridian-main │ │ │ ├── PaperTradingPortfolioLotSnapshotTests.cs │ │ │ ├── PaperTradingPortfolioTests.cs │ │ │ ├── PositionLotSelectorTests.cs +│ │ │ ├── SessionTcaReporterTests.cs │ │ │ └── TradierExecutionReconciliationTests.cs │ │ ├── FinancialOperations │ │ │ ├── AccountingClose @@ -7033,6 +7036,7 @@ Meridian-main │ │ │ ├── CompositeSinkTests.cs │ │ │ ├── DataLineageServiceTests.cs │ │ │ ├── DataQualityScoringServiceTests.cs +│ │ │ ├── DataReplacementCostEstimatorTests.cs │ │ │ ├── DataValidatorTests.cs │ │ │ ├── DirectLendingMigrationTests.cs │ │ │ ├── EventBufferTests.cs diff --git a/docs/product/README.md b/docs/product/README.md index 1a949d3fe1..487f152a84 100644 --- a/docs/product/README.md +++ b/docs/product/README.md @@ -14,6 +14,9 @@ It routes non-technical audiences to verified evidence and prevents duplicate cl - [Meridian Implementation TODO List](implementation-todo-list.md) — single planning-tooling tracker for implemented design-document items and remaining TODOs - [Roadmap Registry](../roadmap/README.md) - [Roadmap Generated Summary](../roadmap/generated/ROADMAP_SUMMARY.md) + - [High-Value Code Brainstorm (2026-07)](high-value-code-brainstorm-2026-07.md) — market-researched + prioritization of the next highest-value implementable features, sequenced against the W6/W7 + roadmap lanes - If you need evidence-backed examples of current operations, check: - [generated roadmap outputs](../roadmap/generated/) - Current project source-of-truth references listed below. diff --git a/docs/plans/high-value-code-brainstorm-2026-07.md b/docs/product/high-value-code-brainstorm-2026-07.md similarity index 91% rename from docs/plans/high-value-code-brainstorm-2026-07.md rename to docs/product/high-value-code-brainstorm-2026-07.md index 638e4039fc..dac0cecc7c 100644 --- a/docs/plans/high-value-code-brainstorm-2026-07.md +++ b/docs/product/high-value-code-brainstorm-2026-07.md @@ -54,7 +54,7 @@ Five signals from the July 2026 landscape shape this prioritization: | # | Idea | Effort | Audience | Impact | Depends On | |---|------|--------|----------|--------|------------| | 1 | Live/Paper TCA & best-execution report pack | M | I | High | — | -| 2 | Alpaca live broker adapter implementing `IExecutionGateway` | M | H, I | High | — | +| 2 | Live execution hardening: wire `AlpacaBrokerageGateway` into promotion, reconciliation, and governance | M | H, I | High | — | | 3 | Reconciliation break-resolution agent (MCP + CoS gates) | M | I | High | — | | 4 | Custodian/broker statement connector library | M | I, H | High | — | | 5 | DuckDB analytics workbench in the Data workspace | M | Q, H | High | — | @@ -102,28 +102,32 @@ duplicated from Backtesting.Sdk. **Why now:** feeds W7 live-readiness directly; institutional differentiator no open-source competitor has; models already exist and are tested on the backtest side. -### 2. Alpaca Live Broker Adapter Implementing `IExecutionGateway` - -`PaperTradingGateway` (`src/Meridian.Execution/Adapters/PaperTradingGateway.cs`) is still the only -live-capable adapter. W7-LIVE-001 — live-readiness governance — has nothing real to govern until at -least one broker adapter exists. The competitive scan makes this the sharpest gap: NautilusTrader's -whole pitch is surviving the backtest→live jump, and Meridian's promotion workflow -(`BacktestToLivePromoter`, paper-first gate pattern from ADR-015/016) is architecturally ahead but -ends at paper. - -Alpaca is the right first adapter: Meridian already ships `AlpacaMarketDataClient` -(`src/Meridian.Infrastructure/Adapters/Alpaca/`), so credentials, HTTP resilience patterns, and -symbol mapping exist; the trading API is REST+SSE with a free paper environment for CI-safe -integration tests. Implement `IExecutionGateway` (`src/Meridian.Execution.Sdk/`), wire it through -`OrderManagementSystem`, and keep it gated behind `ExecutionMode.Live` plus the -`CompositeRiskValidator` pre-trade chain. The user moment: the promotion workflow's final step -changes from "paper only" to a governed live toggle with an explicit operator sign-off — exactly -the approval-gate pattern the CoS runtime was built for. - -Tradeoffs: live order routing is the highest-blast-radius code in the product. Scope tightly — -market/limit/day orders, single account, equities only — and let the risk-rule chain and W7 -governance carry the safety story. Order-state reconciliation against broker truth (fills arriving -after disconnect) is the hard 20%. +### 2. Live Execution Hardening: Wire `AlpacaBrokerageGateway` Into Promotion, Reconciliation, and Governance + +A broker adapter already exists — `AlpacaBrokerageGateway` +(`src/Meridian.Infrastructure/Adapters/Alpaca/AlpacaBrokerageGateway.cs`) implements +`IBrokerageGateway : IExecutionGateway` (`src/Meridian.Execution.Sdk/IBrokerageGateway.cs`) with +account, position, open-order, and health queries, and the host registers it under the `alpaca` +key. What's missing is everything around it that makes live execution a governed product rather +than a wired-up capability: the promotion workflow (`BacktestToLivePromoter`, paper-first gate +pattern from ADR-015/016) still terminates at paper, and W7-LIVE-001 — live-readiness governance — +has no evidence surface over the real gateway. The competitive scan makes this the sharpest gap: +NautilusTrader's whole pitch is surviving the backtest→live jump. + +The work is hardening, not adapter-building: route the promotion workflow's final step to the +registered brokerage gateway behind `ExecutionMode.Live` plus the `CompositeRiskValidator` +pre-trade chain with an explicit operator sign-off (the approval-gate pattern the CoS runtime was +built for); add broker-state reconciliation that compares `GetPositionsAsync` / +`GetOpenOrdersAsync` broker truth against the local `OrderManagementSystem` view and surfaces +divergence as reconciliation breaks (fills arriving after disconnect are the hard 20%); and emit +live-readiness evidence (gateway health checks, capability declarations, reconciliation status) +into the W7 governance surface. The user moment: the promotion workflow's final step changes from +"paper only" to a governed live toggle whose evidence panel shows broker health and position parity +before the operator signs off. + +Tradeoffs: live order routing is the highest-blast-radius surface in the product. Scope tightly — +promotion wiring, reconciliation, and evidence first; order-type breadth and multi-account later — +and let the risk-rule chain and W7 governance carry the safety story. **Why now:** unlocks W7, completes the "collection → backtest → paper → live" value proposition that the differentiation matrix already promises, and converts the promotion workflow from demo to diff --git a/docs/status/TODO.md b/docs/status/TODO.md index 306b8206d1..3cde7a9fe1 100644 --- a/docs/status/TODO.md +++ b/docs/status/TODO.md @@ -43,11 +43,11 @@ Total items: **200** | `src/Meridian.Ui.Services/Services/ProviderHealthService.cs` | 530 | `NOTE` | ❌ | // NOTE: ProviderComparison is defined in AdvancedAnalyticsModels.cs for cross-provider comparison | | `src/Meridian.Ui.Shared/Endpoints/ArchiveMaintenanceEndpoints.cs` | 32 | `NOTE` | ❌ | // NOTE: GET /schedules, GET /schedules/{id}, POST /schedules, POST /schedules/{id}/enable, | | `src/Meridian.Ui.Shared/Endpoints/ArchiveMaintenanceEndpoints.cs` | 117 | `NOTE` | ❌ | // NOTE: POST /schedules/{id}/enable and POST /schedules/{id}/disable are registered | -| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4245 | `NOTE` | ❌ | note: "Paper adapter routing is available.", | -| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4257 | `NOTE` | ❌ | note: "Realtime subscriptions are steady.", | -| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4269 | `NOTE` | ❌ | note: "Replay queue is elevated but within tolerance.", | -| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4381 | `NOTE` | ❌ | Note: note, | -| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4464 | `NOTE` | ❌ | Note: note, | +| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4300 | `NOTE` | ❌ | note: "Paper adapter routing is available.", | +| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4312 | `NOTE` | ❌ | note: "Realtime subscriptions are steady.", | +| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4324 | `NOTE` | ❌ | note: "Replay queue is elevated but within tolerance.", | +| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4436 | `NOTE` | ❌ | Note: note, | +| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4519 | `NOTE` | ❌ | Note: note, | | `src/Meridian.Ui.Shared/Services/ProviderLedgerReconciliationService.cs` | 1974 | `NOTE` | ❌ | Note: "Provider-ledger reconciliation break signed off.", | | `src/Meridian.Ui.Shared/Services/ReportPackDeliveryService.cs` | 240 | `NOTE` | ❌ | Note: NormalizeNullable(target.Note) ?? $"Scheduled delivery for {normalizedTemplateId}.", | | `src/Meridian.Ui.Shared/Services/ReportPackRunReadService.cs` | 2047 | `NOTE` | ❌ | Note: NormalizeOptional(target.Note), | diff --git a/docs/status/api-contract-coverage-dashboard.json b/docs/status/api-contract-coverage-dashboard.json index bf113ba3a8..e86b00672f 100644 --- a/docs/status/api-contract-coverage-dashboard.json +++ b/docs/status/api-contract-coverage-dashboard.json @@ -3533,13 +3533,13 @@ { "method": "GET", "path": "/api/strategies/{strategyId}/runs", - "source": "src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs:2528", + "source": "src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs:2544", "documented": true }, { "method": "GET", "path": "/api/strategies/runs/compare", - "source": "src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs:2649", + "source": "src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs:2665", "documented": true } ], diff --git a/docs/status/api-contract-coverage-dashboard.md b/docs/status/api-contract-coverage-dashboard.md index a726583009..af74033cda 100644 --- a/docs/status/api-contract-coverage-dashboard.md +++ b/docs/status/api-contract-coverage-dashboard.md @@ -579,8 +579,8 @@ Tracks whether mapped API routes and workstation DTO contracts are visible in th | `POST` | `/api/strategies/covered-call/runs/{runId}/cancel` | Documented | `src/Meridian.Ui.Shared/Endpoints/CoveredCallEndpoints.cs:135` | | `GET` | `/api/strategies/covered-call/runs/{runId}/result` | Documented | `src/Meridian.Ui.Shared/Endpoints/CoveredCallEndpoints.cs:93` | | `GET` | `/api/strategies/covered-call/runs/{runId}/status` | Documented | `src/Meridian.Ui.Shared/Endpoints/CoveredCallEndpoints.cs:70` | -| `GET` | `/api/strategies/runs/compare` | Documented | `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs:2649` | -| `GET` | `/api/strategies/{strategyId}/runs` | Documented | `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs:2528` | +| `GET` | `/api/strategies/runs/compare` | Documented | `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs:2665` | +| `GET` | `/api/strategies/{strategyId}/runs` | Documented | `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs:2544` | | `GET` | `/api/subscriptions/active` | Documented | `src/Meridian.Ui.Shared/Endpoints/SubscriptionEndpoints.cs:21` | | `POST` | `/api/subscriptions/subscribe` | Documented | `src/Meridian.Ui.Shared/Endpoints/SubscriptionEndpoints.cs:43` | | `POST` | `/api/subscriptions/unsubscribe/{symbol}` | Documented | `src/Meridian.Ui.Shared/Endpoints/SubscriptionEndpoints.cs:72` | diff --git a/docs/status/coverage-report.md b/docs/status/coverage-report.md index 9f67f11ffd..c5548b5e7f 100644 --- a/docs/status/coverage-report.md +++ b/docs/status/coverage-report.md @@ -5,7 +5,7 @@ ## Overall Coverage -**2596 / 7237** items documented (**35.9%**) — Grade: **F** +**2599 / 7248** items documented (**35.9%**) — Grade: **F** ```text [=======-------------] 35.9% @@ -15,15 +15,15 @@ | Category | Documented | Total | Coverage | Grade | | ---------- | ----------- | ------- | ---------- | ------- | -| Public Classes / Interfaces | 2475 | 6742 | 36.7% | F | -| API Endpoints | 107 | 348 | 30.7% | F | +| Public Classes / Interfaces | 2478 | 6752 | 36.7% | F | +| API Endpoints | 107 | 349 | 30.7% | F | | Configuration Options | 3 | 136 | 2.2% | F | | Provider Implementations | 0 | 0 | 100.0% | A | | ADR Implementations | 11 | 11 | 100.0% | A | ## Undocumented Items -### Public Classes / Interfaces (4267 undocumented) +### Public Classes / Interfaces (4274 undocumented) | Item | Location | | ------ | ---------- | @@ -77,9 +77,9 @@ | `SecurityMasterActiveImportStatus` | `src/Meridian.Application/SecurityMaster/SecurityMasterIngestStatusService.cs:13` | | `SecurityMasterCompletedImportStatus` | `src/Meridian.Application/SecurityMaster/SecurityMasterIngestStatusService.cs:26` | | `ISecurityMasterIngestStatusService` | `src/Meridian.Application/SecurityMaster/SecurityMasterIngestStatusService.cs:41` | -| ... and 4217 more | | +| ... and 4224 more | | -### API Endpoints (241 undocumented) +### API Endpoints (242 undocumented) | Item | Location | | ------ | ---------- | @@ -133,7 +133,7 @@ | `/{loanId:guid}/status-transitions` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:685` | | `/{loanId:guid}/pik` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:715` | | `/{loanId:guid}/restructures` | `src/Meridian.Ui.Shared/Endpoints/DirectLendingEndpoints.cs:745` | -| ... and 191 more | | +| ... and 192 more | | ### Configuration Options (133 undocumented) @@ -193,8 +193,8 @@ ## Recommendations -1. **Public Classes / Interfaces**: 4267 undocumented types. Consider generating API docs with DocFX (`docfx docfx.json`) to cover the long tail of public types automatically. -2. **API Endpoints**: 241 endpoint(s) missing from `docs/reference/api-reference.md`. Run the endpoint audit and update the API reference table. +1. **Public Classes / Interfaces**: 4274 undocumented types. Consider generating API docs with DocFX (`docfx docfx.json`) to cover the long tail of public types automatically. +2. **API Endpoints**: 242 endpoint(s) missing from `docs/reference/api-reference.md`. Run the endpoint audit and update the API reference table. 3. **Configuration Options**: 133 config key(s) not found in `docs/generated/configuration-schema.md`. Re-run the configuration schema generator to synchronise. --- diff --git a/docs/status/doc-health-dashboard.json b/docs/status/doc-health-dashboard.json index 939c2824e7..11d09d4ba1 100644 --- a/docs/status/doc-health-dashboard.json +++ b/docs/status/doc-health-dashboard.json @@ -1,7 +1,7 @@ { "total_files": 535, - "total_lines": 87608, - "orphaned_count": 205, + "total_lines": 87619, + "orphaned_count": 204, "no_heading_count": 38, "stale_count": 0, "todo_count": 199, @@ -126,7 +126,6 @@ "docs/plans/fund-management-pr-sequenced-roadmap.md", "docs/plans/fund-management-product-vision-and-capability-matrix.md", "docs/plans/governance-fund-ops-blueprint.md", - "docs/plans/high-value-code-brainstorm-2026-07.md", "docs/plans/kernel-parity-migration-blueprint.md", "docs/plans/l3-inference-implementation-plan.md", "docs/plans/ledger.md", @@ -2130,7 +2129,7 @@ }, { "path": "docs/generated/repository-structure.md", - "line_count": 7660, + "line_count": 7664, "has_heading": true, "todo_count": 9, "last_modified_utc": "1970-01-01T00:00:00+00:00", @@ -2672,14 +2671,6 @@ "last_modified_utc": "1970-01-01T00:00:00+00:00", "stale": false }, - { - "path": "docs/plans/high-value-code-brainstorm-2026-07.md", - "line_count": 376, - "has_heading": true, - "todo_count": 0, - "last_modified_utc": "1970-01-01T00:00:00+00:00", - "stale": false - }, { "path": "docs/plans/kernel-parity-migration-blueprint.md", "line_count": 16, @@ -3064,6 +3055,14 @@ "last_modified_utc": "1970-01-01T00:00:00+00:00", "stale": false }, + { + "path": "docs/product/high-value-code-brainstorm-2026-07.md", + "line_count": 380, + "has_heading": true, + "todo_count": 0, + "last_modified_utc": "1970-01-01T00:00:00+00:00", + "stale": false + }, { "path": "docs/product/implementation-todo-list.md", "line_count": 186, @@ -3082,7 +3081,7 @@ }, { "path": "docs/product/README.md", - "line_count": 198, + "line_count": 201, "has_heading": true, "todo_count": 2, "last_modified_utc": "1970-01-01T00:00:00+00:00", diff --git a/docs/status/doc-health-dashboard.md b/docs/status/doc-health-dashboard.md index d2bb458757..3d9a8974c4 100644 --- a/docs/status/doc-health-dashboard.md +++ b/docs/status/doc-health-dashboard.md @@ -20,9 +20,9 @@ Data sources: `repo markdown (*.md)`, `file modification metadata` | Metric | Value | | -------- | ------- | | Total documentation files | 535 | -| Total lines | 87,608 | +| Total lines | 87,619 | | Average file size (lines) | 163.8 | -| Orphaned files | 205 | +| Orphaned files | 204 | | Files without headings | 38 | | Stale files (>90 days) | 0 | | TODO/FIXME markers | 199 | @@ -85,7 +85,7 @@ These files are not linked from any other Markdown file in the repository: - `Meridian Design System/VISUAL_FOUNDATIONS.md` - `Meridian Design System/components/charts/CandleChart.prompt.md` - `Meridian Design System/components/charts/ChartCard.prompt.md` -- ... and 185 more +- ... and 184 more ## Trend @@ -93,7 +93,7 @@ These files are not linked from any other Markdown file in the repository: | Date | Score | Files | Orphans | Stale | | ------ | ------- | ------- | --------- | ------- | -| 1970-01-01 | 83 | 535 | 205 | 0 | +| 1970-01-01 | 83 | 535 | 204 | 0 | --- diff --git a/src/Meridian.Contracts/Api/UiApiRoutes.cs b/src/Meridian.Contracts/Api/UiApiRoutes.cs index 330ac7ac73..707df685e7 100644 --- a/src/Meridian.Contracts/Api/UiApiRoutes.cs +++ b/src/Meridian.Contracts/Api/UiApiRoutes.cs @@ -606,6 +606,7 @@ public static class UiApiRoutes public const string WorkstationTrading = "/api/workstation/trading"; public const string WorkstationDataOperations = "/api/workstation/data-operations"; public const string WorkstationData = "/api/workstation/data"; + public const string WorkstationDataReplacementCost = "/api/workstation/data/replacement-cost"; public const string WorkstationDataUploadTemplates = "/api/workstation/data/uploads/templates"; public const string WorkstationDataUploadPreview = "/api/workstation/data/uploads/preview"; public const string WorkstationBankStatementImport = "/api/workstation/data/uploads/bank-statements/import"; diff --git a/src/Meridian.Execution/Services/SessionTcaReporter.cs b/src/Meridian.Execution/Services/SessionTcaReporter.cs new file mode 100644 index 0000000000..7782a76b01 --- /dev/null +++ b/src/Meridian.Execution/Services/SessionTcaReporter.cs @@ -0,0 +1,347 @@ +using Meridian.Execution.Sdk; + +namespace Meridian.Execution.Services; + +/// +/// Generates a Transaction Cost Analysis (TCA) report from a paper/live session fill tape. +/// Computes commission attribution, per-symbol cost breakdowns, outlier detection, and +/// execution-quality statistics (time-to-fill, limit-price improvement). This is a +/// post-processing pass over the session's persisted history; +/// it requires no market data beyond the fills and orders already recorded by the session. +/// +public static class SessionTcaReporter +{ + // Fills whose commission rate exceeds this multiplier × the median per-fill rate are flagged. + // The median (not the aggregate mean) is used so that a single outlier fill cannot inflate + // the baseline threshold enough to avoid detection. + private const double OutlierThresholdMultiplier = 3.0; + + // Suppress outlier flagging for fills below this minimum rate (bps) to avoid noise on + // zero- or near-zero-commission fills. + private const double OutlierMinimumRateBps = 1.0; + + /// + /// Generates a for from the + /// session's fill history. Only reports of type or + /// with a fill price and positive filled + /// quantity contribute. is optional and enriches the report with + /// time-to-fill and limit-price-improvement statistics when supplied. + /// Returns a zero-valued report when no usable fills exist. + /// + public static SessionTcaReport Generate( + string sessionId, + string strategyId, + IReadOnlyList fills, + IReadOnlyList? orders = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentNullException.ThrowIfNull(fills); + + var usable = new List(fills.Count); + foreach (var fill in fills) + { + if (fill.ReportType is ExecutionReportType.Fill or ExecutionReportType.PartialFill + && fill.FillPrice is > 0m + && fill.FilledQuantity > 0m) + { + usable.Add(fill); + } + } + + if (usable.Count == 0) + { + return new SessionTcaReport( + sessionId, + strategyId, + DateTimeOffset.UtcNow, + new SessionTcaCostSummary(0m, 0m, 0m, 0m, 0.0, 0, 0, 0), + Array.Empty(), + Array.Empty(), + SessionTcaExecutionQuality.Empty); + } + + var costSummary = BuildCostSummary(usable); + var symbolSummaries = BuildSymbolSummaries(usable); + var outliers = DetectOutliers(usable); + var executionQuality = BuildExecutionQuality(usable, orders); + + return new SessionTcaReport( + sessionId, + strategyId, + DateTimeOffset.UtcNow, + costSummary, + symbolSummaries, + outliers, + executionQuality); + } + + private static SessionTcaCostSummary BuildCostSummary(List fills) + { + decimal totalCommissions = 0m; + decimal totalBuyNotional = 0m; + decimal totalSellNotional = 0m; + int buyFills = 0; + int sellFills = 0; + + foreach (var fill in fills) + { + var notional = fill.FilledQuantity * fill.FillPrice!.Value; + totalCommissions += fill.Commission ?? 0m; + + if (fill.Side == OrderSide.Buy) + { + totalBuyNotional += notional; + buyFills++; + } + else + { + totalSellNotional += notional; + sellFills++; + } + } + + var totalNotional = totalBuyNotional + totalSellNotional; + var avgCommissionRateBps = totalNotional > 0 + ? (double)(totalCommissions / totalNotional) * 10_000.0 + : 0.0; + + return new SessionTcaCostSummary( + totalCommissions, + totalBuyNotional, + totalSellNotional, + totalNotional, + Math.Round(avgCommissionRateBps, 2), + fills.Count, + buyFills, + sellFills); + } + + private static IReadOnlyList BuildSymbolSummaries(List fills) + { + var grouped = new Dictionary>(StringComparer.Ordinal); + foreach (var fill in fills) + { + if (!grouped.TryGetValue(fill.Symbol, out var bucket)) + { + bucket = new List(); + grouped[fill.Symbol] = bucket; + } + bucket.Add(fill); + } + + var summaries = new List(grouped.Count); + foreach (var (symbol, symbolFills) in grouped) + { + decimal buyNotional = 0m, sellNotional = 0m, commission = 0m; + decimal buyQty = 0m, sellQty = 0m; + decimal buyWeighted = 0m, sellWeighted = 0m; + + foreach (var fill in symbolFills) + { + var price = fill.FillPrice!.Value; + var notional = fill.FilledQuantity * price; + commission += fill.Commission ?? 0m; + + if (fill.Side == OrderSide.Buy) + { + buyNotional += notional; + buyQty += fill.FilledQuantity; + buyWeighted += fill.FilledQuantity * price; + } + else + { + sellNotional += notional; + sellQty += fill.FilledQuantity; + sellWeighted += fill.FilledQuantity * price; + } + } + + var symbolNotional = buyNotional + sellNotional; + var symbolBps = symbolNotional > 0 + ? (double)(commission / symbolNotional) * 10_000.0 + : 0.0; + + summaries.Add(new SessionSymbolTcaSummary( + symbol, + buyNotional, + sellNotional, + Math.Round(buyQty > 0 ? buyWeighted / buyQty : 0m, 4), + Math.Round(sellQty > 0 ? sellWeighted / sellQty : 0m, 4), + commission, + Math.Round(symbolBps, 2), + symbolFills.Count)); + } + + // Sort descending by total commission (highest cost symbols first). + summaries.Sort(static (a, b) => b.TotalCommission.CompareTo(a.TotalCommission)); + return summaries; + } + + private static IReadOnlyList DetectOutliers(List fills) + { + // The median (not the aggregate mean) is used as the baseline so that a single high-cost + // fill cannot inflate the threshold enough to avoid detection. + var perFillBps = new List(fills.Count); + foreach (var fill in fills) + { + var notional = fill.FilledQuantity * fill.FillPrice!.Value; + if (notional > 0m) + perFillBps.Add((double)((fill.Commission ?? 0m) / notional) * 10_000.0); + } + + double medianBps = 0.0; + if (perFillBps.Count > 0) + { + perFillBps.Sort(); + var mid = perFillBps.Count / 2; + medianBps = perFillBps.Count % 2 == 0 + ? (perFillBps[mid - 1] + perFillBps[mid]) / 2.0 + : perFillBps[mid]; + } + + var outliers = new List(); + foreach (var fill in fills) + { + var notional = fill.FilledQuantity * fill.FillPrice!.Value; + if (notional <= 0m) + continue; + + var fillBps = (double)((fill.Commission ?? 0m) / notional) * 10_000.0; + if (fillBps > medianBps * OutlierThresholdMultiplier + && fillBps > OutlierMinimumRateBps) + { + outliers.Add(new SessionTcaFillOutlier( + fill.OrderId, + fill.Symbol, + notional, + fill.Commission ?? 0m, + Math.Round(fillBps, 2), + fill.Timestamp)); + } + } + + // Sort descending by commission rate (worst outliers first). + outliers.Sort(static (a, b) => b.CommissionRateBps.CompareTo(a.CommissionRateBps)); + return outliers; + } + + private static SessionTcaExecutionQuality BuildExecutionQuality( + List fills, + IReadOnlyList? orders) + { + if (orders is not { Count: > 0 }) + return SessionTcaExecutionQuality.Empty; + + // Last-writer-wins on duplicate order updates: the latest OrderState for an order id + // carries the final limit price and creation timestamp. + var ordersById = new Dictionary(StringComparer.Ordinal); + foreach (var order in orders) + ordersById[order.OrderId] = order; + + var timesToFillSeconds = new List(); + decimal improvementWeightedBps = 0m; + decimal improvementNotional = 0m; + var limitOrderIds = new HashSet(StringComparer.Ordinal); + + foreach (var fill in fills) + { + if (!ordersById.TryGetValue(fill.OrderId, out var order)) + continue; + + var elapsed = (fill.Timestamp - order.CreatedAt).TotalSeconds; + if (elapsed >= 0) + timesToFillSeconds.Add(elapsed); + + if (order.LimitPrice is > 0m) + { + limitOrderIds.Add(order.OrderId); + var limit = order.LimitPrice.Value; + var price = fill.FillPrice!.Value; + var notional = fill.FilledQuantity * price; + + // Positive = filled better than the limit (price improvement). + var improvementBps = fill.Side == OrderSide.Buy + ? (limit - price) / limit * 10_000m + : (price - limit) / limit * 10_000m; + + improvementWeightedBps += improvementBps * notional; + improvementNotional += notional; + } + } + + double medianTimeToFill = 0.0; + if (timesToFillSeconds.Count > 0) + { + timesToFillSeconds.Sort(); + var mid = timesToFillSeconds.Count / 2; + medianTimeToFill = timesToFillSeconds.Count % 2 == 0 + ? (timesToFillSeconds[mid - 1] + timesToFillSeconds[mid]) / 2.0 + : timesToFillSeconds[mid]; + } + + var avgImprovementBps = improvementNotional > 0m + ? (double)(improvementWeightedBps / improvementNotional) + : 0.0; + + return new SessionTcaExecutionQuality( + limitOrderIds.Count, + Math.Round(avgImprovementBps, 2), + Math.Round(medianTimeToFill, 3), + timesToFillSeconds.Count); + } +} + +/// TCA report over a paper/live session fill tape. +public sealed record SessionTcaReport( + string SessionId, + string StrategyId, + DateTimeOffset GeneratedAtUtc, + SessionTcaCostSummary CostSummary, + IReadOnlyList SymbolSummaries, + IReadOnlyList Outliers, + SessionTcaExecutionQuality ExecutionQuality); + +/// Aggregate session-level cost summary. +public sealed record SessionTcaCostSummary( + decimal TotalCommissions, + decimal TotalBuyNotional, + decimal TotalSellNotional, + decimal TotalNotional, + double CommissionRateBps, + int TotalFills, + int BuyFills, + int SellFills); + +/// Per-symbol cost breakdown, sorted by total commission descending. +public sealed record SessionSymbolTcaSummary( + string Symbol, + decimal TotalBuyNotional, + decimal TotalSellNotional, + decimal AvgBuyPrice, + decimal AvgSellPrice, + decimal TotalCommission, + double CommissionRateBps, + int TotalFills); + +/// A fill whose commission rate significantly exceeds the session median. +public sealed record SessionTcaFillOutlier( + string OrderId, + string Symbol, + decimal Notional, + decimal Commission, + double CommissionRateBps, + DateTimeOffset FilledAt); + +/// +/// Execution-quality statistics derived by joining fills to their originating orders. +/// is notional-weighted; positive values mean fills +/// executed better than their limit price. +/// +public sealed record SessionTcaExecutionQuality( + int OrdersWithLimitPrice, + double AvgLimitPriceImprovementBps, + double MedianTimeToFillSeconds, + int TimedFillCount) +{ + public static SessionTcaExecutionQuality Empty { get; } = new(0, 0.0, 0.0, 0); +} diff --git a/src/Meridian.Storage/Services/DataReplacementCostEstimator.cs b/src/Meridian.Storage/Services/DataReplacementCostEstimator.cs new file mode 100644 index 0000000000..7ef2e9ee86 --- /dev/null +++ b/src/Meridian.Storage/Services/DataReplacementCostEstimator.cs @@ -0,0 +1,206 @@ +using Meridian.Contracts.Catalog; + +namespace Meridian.Storage.Services; + +/// +/// Estimates what the locally collected market-data store would cost to repurchase from +/// commercial metered vendors, priced against a configurable rate card. Estimates are +/// deliberately conservative: two independent bases are computed (per symbol-day and per +/// gigabyte) and the lower one is reported as the headline figure. This is a pure +/// post-processing pass over the ; it never touches the data files. +/// +public static class DataReplacementCostEstimator +{ + /// + /// Computes a replacement-cost estimate for using + /// . Returns a zero-valued estimate when the catalog is empty + /// or the meter is disabled. + /// + public static DataReplacementCostEstimate Estimate(StorageCatalog catalog, DataReplacementCostOptions options) + { + ArgumentNullException.ThrowIfNull(catalog); + ArgumentNullException.ThrowIfNull(options); + + if (!options.Enabled || catalog.Symbols.Count == 0) + { + return DataReplacementCostEstimate.Empty(options); + } + + // ── Symbol-day basis ─────────────────────────────────────────────── + // Each (symbol, event type) pair is priced per covered trading day, matching how + // metered vendors price historical downloads. Trading days are preferred; calendar + // days are the fallback when the catalog has not computed them. + var byEventType = new Dictionary(StringComparer.OrdinalIgnoreCase); + long totalSymbolDays = 0; + decimal symbolDayBasisUsd = 0m; + + foreach (var entry in catalog.Symbols.Values) + { + var days = ResolveCoveredDays(entry); + if (days <= 0) + continue; + + var eventTypes = entry.EventTypes is { Length: > 0 } + ? entry.EventTypes + : UnclassifiedEventTypes; + + foreach (var eventType in eventTypes) + { + var rate = ResolveRate(options, eventType); + var cost = days * rate; + + symbolDayBasisUsd += cost; + totalSymbolDays += days; + + byEventType.TryGetValue(eventType, out var bucket); + byEventType[eventType] = (bucket.SymbolDays + days, bucket.Usd + cost); + } + } + + // ── Volume basis ─────────────────────────────────────────────────── + // Uncompressed size is what a vendor would meter; fall back to on-disk size when the + // raw figure is unavailable so the estimate stays conservative. + var meteredBytes = catalog.Statistics.TotalBytesRaw > 0 + ? catalog.Statistics.TotalBytesRaw + : catalog.Statistics.TotalBytesCompressed; + var totalGigabytes = meteredBytes / 1_000_000_000m; + var volumeBasisUsd = totalGigabytes * options.RatePerGigabyteUsd; + + // ── Conservative headline ────────────────────────────────────────── + var positiveBases = new List(2); + if (symbolDayBasisUsd > 0m) + positiveBases.Add(symbolDayBasisUsd); + if (volumeBasisUsd > 0m) + positiveBases.Add(volumeBasisUsd); + var conservative = positiveBases.Count > 0 ? positiveBases.Min() : 0m; + + var breakdown = byEventType + .Select(static kvp => new DataReplacementCostByEventType( + kvp.Key, + kvp.Value.SymbolDays, + Math.Round(kvp.Value.Usd, 2))) + .OrderByDescending(static row => row.EstimatedUsd) + .ToArray(); + + return new DataReplacementCostEstimate( + GeneratedAtUtc: DateTimeOffset.UtcNow, + Currency: options.Currency, + ConservativeEstimateUsd: Math.Round(conservative, 2), + SymbolDayBasisUsd: Math.Round(symbolDayBasisUsd, 2), + VolumeBasisUsd: Math.Round(volumeBasisUsd, 2), + TotalSymbols: catalog.Symbols.Count, + TotalSymbolDays: totalSymbolDays, + TotalGigabytes: Math.Round(totalGigabytes, 2), + EventTypeBreakdown: breakdown, + RateCardSource: options.RateCardSource, + RateCardLastVerified: options.RateCardLastVerified, + MethodologyNote: MethodologyNote); + } + + private const string MethodologyNote = + "Lower bound of two independent bases: per-symbol-day rates applied to covered trading days " + + "per event type, and a per-gigabyte rate applied to uncompressed stored volume. Rates are " + + "conservative low-end list prices from the configured rate card, not a vendor quote."; + + private static readonly string[] UnclassifiedEventTypes = ["Unclassified"]; + + private static int ResolveCoveredDays(SymbolCatalogEntry entry) + { + if (entry.DateRange is { } range) + { + if (range.TradingDays > 0) + return range.TradingDays; + if (range.CalendarDays > 0) + return range.CalendarDays; + } + + // A symbol with recorded events but no computed range still covers at least one day. + return entry.EventCount > 0 ? 1 : 0; + } + + private static decimal ResolveRate(DataReplacementCostOptions options, string eventType) + { + if (options.HistoricalRatePerSymbolDayUsd is { } rates + && rates.TryGetValue(eventType, out var rate) + && rate >= 0m) + { + return rate; + } + + return options.DefaultRatePerSymbolDayUsd; + } +} + +/// +/// Rate card and toggles for the data replacement-cost meter, bound from the +/// DataReplacementCost configuration section. Defaults are conservative low-end +/// list prices as of the recorded verification date; operators should refresh them from +/// current vendor rate cards rather than treating them as quotes. +/// +public sealed class DataReplacementCostOptions +{ + public const string SectionName = "DataReplacementCost"; + + public bool Enabled { get; set; } = true; + + public string Currency { get; set; } = "USD"; + + /// Historical per-symbol-day rates keyed by event type (e.g. Trade, Quote, Depth). + public Dictionary? HistoricalRatePerSymbolDayUsd { get; set; } = + new(StringComparer.OrdinalIgnoreCase) + { + ["Trade"] = 0.10m, + ["Quote"] = 0.10m, + ["Depth"] = 0.25m, + ["OrderBook"] = 0.25m, + }; + + /// Fallback rate applied to event types not present in the rate card. + public decimal DefaultRatePerSymbolDayUsd { get; set; } = 0.10m; + + /// Metered per-gigabyte rate applied to uncompressed stored volume. + public decimal RatePerGigabyteUsd { get; set; } = 5.00m; + + /// Provenance note for the configured rates. + public string RateCardSource { get; set; } = + "Conservative low-end of published metered vendor list prices (per-symbol-day historical and per-GB tick data)."; + + /// Date the rate card values were last checked against vendor pricing pages. + public string RateCardLastVerified { get; set; } = "2026-07-01"; +} + +/// Replacement-cost estimate for the local market-data store. +public sealed record DataReplacementCostEstimate( + DateTimeOffset GeneratedAtUtc, + string Currency, + decimal ConservativeEstimateUsd, + decimal SymbolDayBasisUsd, + decimal VolumeBasisUsd, + int TotalSymbols, + long TotalSymbolDays, + decimal TotalGigabytes, + IReadOnlyList EventTypeBreakdown, + string RateCardSource, + string RateCardLastVerified, + string MethodologyNote) +{ + public static DataReplacementCostEstimate Empty(DataReplacementCostOptions options) => new( + DateTimeOffset.UtcNow, + options.Currency, + 0m, + 0m, + 0m, + 0, + 0, + 0m, + Array.Empty(), + options.RateCardSource, + options.RateCardLastVerified, + "No catalogued data or the meter is disabled."); +} + +/// Per-event-type slice of the symbol-day cost basis. +public sealed record DataReplacementCostByEventType( + string EventType, + long SymbolDays, + decimal EstimatedUsd); diff --git a/src/Meridian.Ui.Shared/Endpoints/ExecutionEndpoints.cs b/src/Meridian.Ui.Shared/Endpoints/ExecutionEndpoints.cs index f94b7581dd..153e3fa3d1 100644 --- a/src/Meridian.Ui.Shared/Endpoints/ExecutionEndpoints.cs +++ b/src/Meridian.Ui.Shared/Endpoints/ExecutionEndpoints.cs @@ -581,6 +581,28 @@ public static void MapExecutionEndpoints(this WebApplication app, JsonSerializer .Produces(200) .Produces(404); + group.MapGet("/sessions/{sessionId}/tca", async (string sessionId, HttpContext context) => + { + var persistence = context.RequestServices.GetService(); + if (persistence is null) + return Results.NotFound(); + + await persistence.InitialiseAsync(context.RequestAborted).ConfigureAwait(false); + var session = persistence.GetSession(sessionId); + if (session is null) + return Results.NotFound(); + + var report = SessionTcaReporter.Generate( + sessionId, + session.Summary.StrategyId, + session.FillHistory ?? Array.Empty(), + session.OrderHistory); + return Results.Json(report, jsonOptions); + }) + .WithName("GetExecutionSessionTcaReport") + .Produces(200) + .Produces(404); + group.MapPost("/sessions/create", async (CreatePaperSessionRequest request, HttpContext context) => { if (!HasExecutionTradingPermission(context, UserPermission.ExecuteTrades)) diff --git a/src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs b/src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs index 35b735904d..c323ba128d 100644 --- a/src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs +++ b/src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs @@ -23,6 +23,8 @@ using Meridian.Instruments.AssetOperations; using Meridian.QuantScript.Compilation; using Meridian.Storage.Export; +using Meridian.Storage.Interfaces; +using Meridian.Storage.Services; using Meridian.Strategies.Interfaces; using Meridian.Strategies.Models; using Meridian.Strategies.Promotions; @@ -36,6 +38,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using ContractSecurityMasterQueryService = Meridian.Contracts.SecurityMaster.ISecurityMasterQueryService; namespace Meridian.Ui.Shared.Endpoints; @@ -385,6 +388,19 @@ public static void MapWorkstationEndpoints(this WebApplication app, JsonSerializ }) .WithName("GetWorkstationData"); + group.MapGet(WorkstationSubroute(UiApiRoutes.WorkstationDataReplacementCost), (HttpContext context) => + { + var estimate = TryBuildDataReplacementCostEstimate(context); + return estimate is null + ? Results.Problem( + "Storage catalog is not available.", + statusCode: StatusCodes.Status503ServiceUnavailable) + : Results.Ok(estimate); + }) + .WithName("GetWorkstationDataReplacementCost") + .Produces(200) + .Produces(503); + group.MapGet(WorkstationSubroute(UiApiRoutes.WorkstationGovernance), async (HttpContext context) => { return await BuildAccountingPayloadAsync(context).ConfigureAwait(false); @@ -4119,15 +4135,28 @@ private static async Task BuildDataPayloadAsync(HttpCont backfills = []; } + var metrics = new List + { + new("providers-healthy", "Providers Healthy", healthyProviderCount.ToString(CultureInfo.InvariantCulture), "0", healthyProviderCount > 0 ? "success" : "default"), + new("backfills-running", "Backfills Running", activeRuns.ToString(CultureInfo.InvariantCulture), activeRuns == 0 ? "0" : $"+{activeRuns}", activeRuns > 0 ? "default" : "success"), + new("exports-ready", "Exports Ready", "0", "0", "default"), + new("ops-review", "Needs Review", reviewRuns.ToString(CultureInfo.InvariantCulture), reviewRuns == 0 ? "0" : $"+{reviewRuns}", reviewRuns == 0 ? "default" : "warning"), + new("kernel-critical-jumps", "Kernel Jump Alerts", GetKernelActiveAlertCount(kernelObservability).ToString(CultureInfo.InvariantCulture), FormatKernelJumpAlertDelta(kernelObservability), GetKernelJumpAlertTone(kernelObservability)) + }; + + var replacementCost = TryBuildDataReplacementCostEstimate(context); + if (replacementCost is { ConservativeEstimateUsd: > 0m }) + { + metrics.Add(new WorkstationMetricCard( + "data-replacement-cost", + "Est. Replacement Cost", + FormatCompactUsd(replacementCost.ConservativeEstimateUsd), + $"{replacementCost.TotalGigabytes.ToString("0.#", CultureInfo.InvariantCulture)} GB local", + "success")); + } + return new WorkstationDataPayload( - Metrics: - [ - new WorkstationMetricCard("providers-healthy", "Providers Healthy", healthyProviderCount.ToString(CultureInfo.InvariantCulture), "0", healthyProviderCount > 0 ? "success" : "default"), - new WorkstationMetricCard("backfills-running", "Backfills Running", activeRuns.ToString(CultureInfo.InvariantCulture), activeRuns == 0 ? "0" : $"+{activeRuns}", activeRuns > 0 ? "default" : "success"), - new WorkstationMetricCard("exports-ready", "Exports Ready", "0", "0", "default"), - new WorkstationMetricCard("ops-review", "Needs Review", reviewRuns.ToString(CultureInfo.InvariantCulture), reviewRuns == 0 ? "0" : $"+{reviewRuns}", reviewRuns == 0 ? "default" : "warning"), - new WorkstationMetricCard("kernel-critical-jumps", "Kernel Jump Alerts", GetKernelActiveAlertCount(kernelObservability).ToString(CultureInfo.InvariantCulture), FormatKernelJumpAlertDelta(kernelObservability), GetKernelJumpAlertTone(kernelObservability)) - ], + Metrics: metrics, Providers: providers, Backfills: backfills, Exports: [], @@ -4135,6 +4164,32 @@ private static async Task BuildDataPayloadAsync(HttpCont KernelObservability: BuildKernelObservabilityPayload(kernelObservability)); } + /// + /// Builds the replacement-cost estimate from the storage catalog, or returns + /// when the catalog service is unavailable or the meter is disabled. + /// + private static DataReplacementCostEstimate? TryBuildDataReplacementCostEstimate(HttpContext context) + { + var catalogService = context.RequestServices.GetService(); + if (catalogService is null) + return null; + + var options = context.RequestServices + .GetService>()?.CurrentValue + ?? new DataReplacementCostOptions(); + if (!options.Enabled) + return null; + + return DataReplacementCostEstimator.Estimate(catalogService.GetCatalog(), options); + } + + private static string FormatCompactUsd(decimal usd) => usd switch + { + >= 1_000_000m => "$" + (usd / 1_000_000m).ToString("0.0", CultureInfo.InvariantCulture) + "M", + >= 1_000m => "$" + (usd / 1_000m).ToString("0.0", CultureInfo.InvariantCulture) + "k", + _ => "$" + usd.ToString("0", CultureInfo.InvariantCulture) + }; + private static bool MatchesLedgerDimensionFilter( LedgerDimensionSetDto? dimensions, string? fundId, diff --git a/tests/Meridian.Tests/Execution/SessionTcaReporterTests.cs b/tests/Meridian.Tests/Execution/SessionTcaReporterTests.cs new file mode 100644 index 0000000000..cde11cb1e4 --- /dev/null +++ b/tests/Meridian.Tests/Execution/SessionTcaReporterTests.cs @@ -0,0 +1,234 @@ +using FluentAssertions; +using Meridian.Execution.Sdk; +using Meridian.Execution.Services; +using Xunit; + +namespace Meridian.Tests.Execution; + +public sealed class SessionTcaReporterTests +{ + private static ExecutionReport MakeFill( + string symbol, + OrderSide side, + decimal quantity, + decimal price, + decimal? commission = null, + string? orderId = null, + DateTimeOffset? timestamp = null, + ExecutionReportType reportType = ExecutionReportType.Fill) => new() + { + OrderId = orderId ?? $"order-{Guid.NewGuid():N}", + ReportType = reportType, + Symbol = symbol, + Side = side, + OrderStatus = OrderStatus.Filled, + OrderQuantity = quantity, + FilledQuantity = quantity, + FillPrice = price, + Commission = commission, + Timestamp = timestamp ?? DateTimeOffset.UtcNow + }; + + private static OrderState MakeOrder( + string orderId, + string symbol, + OrderSide side, + decimal quantity, + decimal? limitPrice = null, + DateTimeOffset? createdAt = null) => new() + { + OrderId = orderId, + Symbol = symbol, + Side = side, + Type = limitPrice.HasValue ? OrderType.Limit : OrderType.Market, + Quantity = quantity, + FilledQuantity = quantity, + LimitPrice = limitPrice, + Status = OrderStatus.Filled, + CreatedAt = createdAt ?? DateTimeOffset.UtcNow + }; + + [Fact] + public void Generate_EmptyFills_ReturnsZeroedReport() + { + var report = SessionTcaReporter.Generate("session-1", "strategy-1", Array.Empty()); + + report.SessionId.Should().Be("session-1"); + report.StrategyId.Should().Be("strategy-1"); + report.CostSummary.TotalFills.Should().Be(0); + report.CostSummary.TotalNotional.Should().Be(0m); + report.CostSummary.CommissionRateBps.Should().Be(0.0); + report.SymbolSummaries.Should().BeEmpty(); + report.Outliers.Should().BeEmpty(); + report.ExecutionQuality.Should().Be(SessionTcaExecutionQuality.Empty); + } + + [Fact] + public void Generate_IgnoresNonFillReportsAndFillsWithoutPrice() + { + var fills = new[] + { + MakeFill("SPY", OrderSide.Buy, 100m, 400m, commission: 1m), + MakeFill("SPY", OrderSide.Buy, 100m, 400m, reportType: ExecutionReportType.Rejected), + MakeFill("SPY", OrderSide.Buy, 100m, 400m, reportType: ExecutionReportType.Cancelled), + MakeFill("SPY", OrderSide.Buy, 100m, 400m) with { FillPrice = null }, + MakeFill("SPY", OrderSide.Buy, 0m, 400m) + }; + + var report = SessionTcaReporter.Generate("s", "strat", fills); + + report.CostSummary.TotalFills.Should().Be(1); + report.CostSummary.TotalBuyNotional.Should().Be(40_000m); + } + + [Fact] + public void Generate_ComputesSideSplitAndCommissionRate() + { + var fills = new[] + { + // Buy: 100 × 400 = 40,000 notional, $2 commission + MakeFill("SPY", OrderSide.Buy, 100m, 400m, commission: 2m), + // Sell: 50 × 402 = 20,100 notional, $1 commission + MakeFill("SPY", OrderSide.Sell, 50m, 402m, commission: 1m) + }; + + var report = SessionTcaReporter.Generate("s", "strat", fills); + + report.CostSummary.TotalBuyNotional.Should().Be(40_000m); + report.CostSummary.TotalSellNotional.Should().Be(20_100m); + report.CostSummary.TotalNotional.Should().Be(60_100m); + report.CostSummary.TotalCommissions.Should().Be(3m); + report.CostSummary.BuyFills.Should().Be(1); + report.CostSummary.SellFills.Should().Be(1); + // 3 / 60,100 × 10,000 ≈ 0.499 bps + report.CostSummary.CommissionRateBps.Should().BeApproximately(0.5, 0.01); + } + + [Fact] + public void Generate_ComputesPerSymbolVwapAndSortsByCommission() + { + var fills = new[] + { + MakeFill("AAPL", OrderSide.Buy, 100m, 200m, commission: 0.5m), + MakeFill("AAPL", OrderSide.Buy, 300m, 210m, commission: 0.5m), + MakeFill("MSFT", OrderSide.Sell, 10m, 500m, commission: 5m) + }; + + var report = SessionTcaReporter.Generate("s", "strat", fills); + + report.SymbolSummaries.Should().HaveCount(2); + // MSFT first: higher total commission. + report.SymbolSummaries[0].Symbol.Should().Be("MSFT"); + report.SymbolSummaries[0].AvgSellPrice.Should().Be(500m); + + var aapl = report.SymbolSummaries[1]; + aapl.Symbol.Should().Be("AAPL"); + // VWAP = (100×200 + 300×210) / 400 = 207.5 + aapl.AvgBuyPrice.Should().Be(207.5m); + aapl.TotalBuyNotional.Should().Be(83_000m); + aapl.TotalFills.Should().Be(2); + } + + [Fact] + public void Generate_FlagsCommissionOutliersAboveMedianMultiple() + { + var fills = new[] + { + // Baseline fills at ~0.25 bps (1 / 40,000 × 10,000) + MakeFill("SPY", OrderSide.Buy, 100m, 400m, commission: 1m), + MakeFill("SPY", OrderSide.Buy, 100m, 400m, commission: 1m), + MakeFill("SPY", OrderSide.Buy, 100m, 400m, commission: 1m), + // Outlier at 25 bps (100 / 40,000 × 10,000) — > 3× median and > 1 bps floor. + MakeFill("SPY", OrderSide.Buy, 100m, 400m, commission: 100m, orderId: "outlier-order") + }; + + var report = SessionTcaReporter.Generate("s", "strat", fills); + + report.Outliers.Should().ContainSingle(); + report.Outliers[0].OrderId.Should().Be("outlier-order"); + report.Outliers[0].CommissionRateBps.Should().BeApproximately(25.0, 0.01); + } + + [Fact] + public void Generate_DoesNotFlagOutliersBelowMinimumRateFloor() + { + var fills = new[] + { + // All commissions well below the 1 bps outlier floor. + MakeFill("SPY", OrderSide.Buy, 100m, 400m, commission: 0.001m), + MakeFill("SPY", OrderSide.Buy, 100m, 400m, commission: 0.001m), + MakeFill("SPY", OrderSide.Buy, 100m, 400m, commission: 0.03m) + }; + + var report = SessionTcaReporter.Generate("s", "strat", fills); + + report.Outliers.Should().BeEmpty(); + } + + [Fact] + public void Generate_TreatsMissingCommissionAsZero() + { + var fills = new[] { MakeFill("SPY", OrderSide.Buy, 100m, 400m, commission: null) }; + + var report = SessionTcaReporter.Generate("s", "strat", fills); + + report.CostSummary.TotalCommissions.Should().Be(0m); + report.CostSummary.CommissionRateBps.Should().Be(0.0); + report.Outliers.Should().BeEmpty(); + } + + [Fact] + public void Generate_ComputesTimeToFillFromOrderHistory() + { + var created = DateTimeOffset.UtcNow; + var orders = new[] + { + MakeOrder("o1", "SPY", OrderSide.Buy, 100m, createdAt: created), + MakeOrder("o2", "SPY", OrderSide.Buy, 100m, createdAt: created) + }; + var fills = new[] + { + MakeFill("SPY", OrderSide.Buy, 100m, 400m, orderId: "o1", timestamp: created.AddSeconds(2)), + MakeFill("SPY", OrderSide.Buy, 100m, 400m, orderId: "o2", timestamp: created.AddSeconds(6)) + }; + + var report = SessionTcaReporter.Generate("s", "strat", fills, orders); + + report.ExecutionQuality.TimedFillCount.Should().Be(2); + report.ExecutionQuality.MedianTimeToFillSeconds.Should().BeApproximately(4.0, 0.001); + } + + [Fact] + public void Generate_ComputesLimitPriceImprovement() + { + var created = DateTimeOffset.UtcNow; + var orders = new[] + { + // Buy limit at 100, filled at 99.90 → 10 bps improvement. + MakeOrder("buy-order", "SPY", OrderSide.Buy, 100m, limitPrice: 100m, createdAt: created), + // Sell limit at 200, filled at 200.20 → 10 bps improvement. + MakeOrder("sell-order", "SPY", OrderSide.Sell, 100m, limitPrice: 200m, createdAt: created) + }; + var fills = new[] + { + MakeFill("SPY", OrderSide.Buy, 100m, 99.90m, orderId: "buy-order", timestamp: created.AddSeconds(1)), + MakeFill("SPY", OrderSide.Sell, 100m, 200.20m, orderId: "sell-order", timestamp: created.AddSeconds(1)) + }; + + var report = SessionTcaReporter.Generate("s", "strat", fills, orders); + + report.ExecutionQuality.OrdersWithLimitPrice.Should().Be(2); + // Both legs improve by 10 bps; the notional-weighted average stays ≈ 10 bps. + report.ExecutionQuality.AvgLimitPriceImprovementBps.Should().BeApproximately(10.0, 0.05); + } + + [Fact] + public void Generate_WithoutOrderHistory_ReturnsEmptyExecutionQuality() + { + var fills = new[] { MakeFill("SPY", OrderSide.Buy, 100m, 400m, commission: 1m) }; + + var report = SessionTcaReporter.Generate("s", "strat", fills, orders: null); + + report.ExecutionQuality.Should().Be(SessionTcaExecutionQuality.Empty); + } +} diff --git a/tests/Meridian.Tests/Storage/DataReplacementCostEstimatorTests.cs b/tests/Meridian.Tests/Storage/DataReplacementCostEstimatorTests.cs new file mode 100644 index 0000000000..1e707ddef7 --- /dev/null +++ b/tests/Meridian.Tests/Storage/DataReplacementCostEstimatorTests.cs @@ -0,0 +1,177 @@ +using FluentAssertions; +using Meridian.Contracts.Catalog; +using Meridian.Storage.Services; +using Xunit; + +namespace Meridian.Tests.Storage; + +public sealed class DataReplacementCostEstimatorTests +{ + private static SymbolCatalogEntry MakeSymbol( + string symbol, + int tradingDays, + string[] eventTypes, + long eventCount = 1_000) => new() + { + Symbol = symbol, + EventCount = eventCount, + EventTypes = eventTypes, + DateRange = new CatalogDateRange + { + Earliest = new DateTime(2026, 1, 1), + Latest = new DateTime(2026, 3, 31), + CalendarDays = 90, + TradingDays = tradingDays + } + }; + + private static StorageCatalog MakeCatalog( + long totalBytesRaw = 0, + params SymbolCatalogEntry[] symbols) + { + var catalog = new StorageCatalog + { + Statistics = new CatalogStatistics { TotalBytesRaw = totalBytesRaw } + }; + foreach (var symbol in symbols) + catalog.Symbols[symbol.Symbol] = symbol; + return catalog; + } + + [Fact] + public void Estimate_EmptyCatalog_ReturnsZeroedEstimate() + { + var estimate = DataReplacementCostEstimator.Estimate(MakeCatalog(), new DataReplacementCostOptions()); + + estimate.ConservativeEstimateUsd.Should().Be(0m); + estimate.TotalSymbols.Should().Be(0); + estimate.EventTypeBreakdown.Should().BeEmpty(); + } + + [Fact] + public void Estimate_Disabled_ReturnsZeroedEstimate() + { + var catalog = MakeCatalog( + totalBytesRaw: 5_000_000_000, + MakeSymbol("SPY", tradingDays: 60, eventTypes: ["Trade"])); + + var estimate = DataReplacementCostEstimator.Estimate( + catalog, + new DataReplacementCostOptions { Enabled = false }); + + estimate.ConservativeEstimateUsd.Should().Be(0m); + } + + [Fact] + public void Estimate_PricesSymbolDaysPerEventType() + { + var options = new DataReplacementCostOptions + { + HistoricalRatePerSymbolDayUsd = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Trade"] = 0.10m, + ["Quote"] = 0.20m + }, + // Large per-GB rate so the symbol-day basis is the conservative one. + RatePerGigabyteUsd = 1_000m + }; + var catalog = MakeCatalog( + totalBytesRaw: 10_000_000_000, + MakeSymbol("SPY", tradingDays: 60, eventTypes: ["Trade", "Quote"])); + + var estimate = DataReplacementCostEstimator.Estimate(catalog, options); + + // 60 days × (0.10 + 0.20) = 18.00 + estimate.SymbolDayBasisUsd.Should().Be(18.00m); + estimate.ConservativeEstimateUsd.Should().Be(18.00m); + estimate.TotalSymbolDays.Should().Be(120); + estimate.EventTypeBreakdown.Should().HaveCount(2); + estimate.EventTypeBreakdown[0].EventType.Should().Be("Quote"); + estimate.EventTypeBreakdown[0].EstimatedUsd.Should().Be(12.00m); + } + + [Fact] + public void Estimate_TakesLowerOfTheTwoBases() + { + var options = new DataReplacementCostOptions + { + HistoricalRatePerSymbolDayUsd = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Trade"] = 1.00m + }, + RatePerGigabyteUsd = 5.00m + }; + // Symbol-day basis: 100 days × $1 = $100. Volume basis: 2 GB × $5 = $10. + var catalog = MakeCatalog( + totalBytesRaw: 2_000_000_000, + MakeSymbol("SPY", tradingDays: 100, eventTypes: ["Trade"])); + + var estimate = DataReplacementCostEstimator.Estimate(catalog, options); + + estimate.SymbolDayBasisUsd.Should().Be(100.00m); + estimate.VolumeBasisUsd.Should().Be(10.00m); + estimate.ConservativeEstimateUsd.Should().Be(10.00m); + } + + [Fact] + public void Estimate_FallsBackToDefaultRateForUnknownEventTypes() + { + var options = new DataReplacementCostOptions + { + HistoricalRatePerSymbolDayUsd = new Dictionary(StringComparer.OrdinalIgnoreCase), + DefaultRatePerSymbolDayUsd = 0.50m, + RatePerGigabyteUsd = 1_000m + }; + var catalog = MakeCatalog( + totalBytesRaw: 1_000_000_000, + MakeSymbol("ES", tradingDays: 10, eventTypes: ["FuturesSettlement"])); + + var estimate = DataReplacementCostEstimator.Estimate(catalog, options); + + estimate.SymbolDayBasisUsd.Should().Be(5.00m); + } + + [Fact] + public void Estimate_UsesCalendarDaysWhenTradingDaysMissing() + { + var symbol = MakeSymbol("BTC-USD", tradingDays: 0, eventTypes: ["Trade"]); + symbol.DateRange!.CalendarDays = 30; + var catalog = MakeCatalog(totalBytesRaw: 0, symbol); + + var estimate = DataReplacementCostEstimator.Estimate(catalog, new DataReplacementCostOptions()); + + estimate.TotalSymbolDays.Should().Be(30); + } + + [Fact] + public void Estimate_CountsSingleDayForSymbolsWithoutDateRange() + { + var symbol = new SymbolCatalogEntry + { + Symbol = "AAPL", + EventCount = 500, + EventTypes = ["Trade"] + }; + var catalog = MakeCatalog(totalBytesRaw: 0, symbol); + + var estimate = DataReplacementCostEstimator.Estimate(catalog, new DataReplacementCostOptions()); + + estimate.TotalSymbolDays.Should().Be(1); + } + + [Fact] + public void Estimate_FallsBackToCompressedBytesForVolumeBasis() + { + var catalog = MakeCatalog( + totalBytesRaw: 0, + MakeSymbol("SPY", tradingDays: 10, eventTypes: ["Trade"])); + catalog.Statistics.TotalBytesCompressed = 3_000_000_000; + + var estimate = DataReplacementCostEstimator.Estimate( + catalog, + new DataReplacementCostOptions { RatePerGigabyteUsd = 5.00m }); + + estimate.TotalGigabytes.Should().Be(3.00m); + estimate.VolumeBasisUsd.Should().Be(15.00m); + } +} From b16263d03acf28c27786684884ad03a9b73261db Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 20:32:21 +0000 Subject: [PATCH 04/13] fix: bind replacement-cost options and normalize cumulative fill tapes Addresses two review findings: - Registers DataReplacementCostOptions with BindConfiguration in the UiServer host so DataReplacementCost:Enabled=false and rate-card overrides in configuration take effect instead of silently falling back to compiled defaults. - SessionTcaReporter now detects per-order cumulative quantity sequences (running totals, as emitted by the IB brokerage gateway) and converts them to incremental fills before aggregation, so a 40-share partial followed by a 100-share final report counts as 100 executed shares rather than 140. Incremental tapes are unaffected; sequences with unknown order quantity are left untouched. Adds three regression tests covering cumulative, incremental-complete, and incremental-working sequences. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011vonQATmobZwPb2KcouHnC --- .../Services/SessionTcaReporter.cs | 176 +++++++++++++----- src/Meridian/UiServer.cs | 2 + .../Execution/SessionTcaReporterTests.cs | 68 ++++++- 3 files changed, 194 insertions(+), 52 deletions(-) diff --git a/src/Meridian.Execution/Services/SessionTcaReporter.cs b/src/Meridian.Execution/Services/SessionTcaReporter.cs index 7782a76b01..d592e6a9ac 100644 --- a/src/Meridian.Execution/Services/SessionTcaReporter.cs +++ b/src/Meridian.Execution/Services/SessionTcaReporter.cs @@ -24,8 +24,10 @@ public static class SessionTcaReporter /// Generates a for from the /// session's fill history. Only reports of type or /// with a fill price and positive filled - /// quantity contribute. is optional and enriches the report with - /// time-to-fill and limit-price-improvement statistics when supplied. + /// quantity contribute. Report sequences whose per-order quantities are cumulative (as + /// emitted by brokers that report running totals, e.g. the IB gateway) are converted to + /// incremental quantities before aggregation. is optional and + /// enriches the report with time-to-fill and limit-price-improvement statistics. /// Returns a zero-valued report when no usable fills exist. /// public static SessionTcaReport Generate( @@ -37,18 +39,9 @@ public static SessionTcaReport Generate( ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); ArgumentNullException.ThrowIfNull(fills); - var usable = new List(fills.Count); - foreach (var fill in fills) - { - if (fill.ReportType is ExecutionReportType.Fill or ExecutionReportType.PartialFill - && fill.FillPrice is > 0m - && fill.FilledQuantity > 0m) - { - usable.Add(fill); - } - } + var normalized = NormalizeFills(fills); - if (usable.Count == 0) + if (normalized.Count == 0) { return new SessionTcaReport( sessionId, @@ -60,10 +53,10 @@ public static SessionTcaReport Generate( SessionTcaExecutionQuality.Empty); } - var costSummary = BuildCostSummary(usable); - var symbolSummaries = BuildSymbolSummaries(usable); - var outliers = DetectOutliers(usable); - var executionQuality = BuildExecutionQuality(usable, orders); + var costSummary = BuildCostSummary(normalized); + var symbolSummaries = BuildSymbolSummaries(normalized); + var outliers = DetectOutliers(normalized); + var executionQuality = BuildExecutionQuality(normalized, orders); return new SessionTcaReport( sessionId, @@ -75,7 +68,97 @@ public static SessionTcaReport Generate( executionQuality); } - private static SessionTcaCostSummary BuildCostSummary(List fills) + /// A fill with its effective (incremental) executed quantity. + private sealed record TcaFill( + string OrderId, + string Symbol, + OrderSide Side, + decimal Quantity, + decimal Price, + decimal Commission, + DateTimeOffset Timestamp) + { + public decimal Notional => Quantity * Price; + } + + /// + /// Filters the raw report tape down to usable fills and converts cumulative per-order + /// quantity sequences into incremental ones. A per-order sequence is treated as cumulative + /// when it contains multiple reports whose quantities never decrease and whose sum exceeds + /// the order quantity — the signature of running totals (a 40-share partial followed by a + /// 100-share final report describes 100 executed shares, not 140). Sequences that already + /// sum to at most the order quantity are left untouched, so incremental tapes (e.g. the + /// paper gateway) are unaffected. When the order quantity is unknown the sequence is left + /// as-is because the two encodings cannot be distinguished. + /// + private static List NormalizeFills(IReadOnlyList fills) + { + var usable = new List(fills.Count); + foreach (var fill in fills) + { + if (fill.ReportType is ExecutionReportType.Fill or ExecutionReportType.PartialFill + && fill.FillPrice is > 0m + && fill.FilledQuantity > 0m) + { + usable.Add(fill); + } + } + + var result = new List(usable.Count); + foreach (var orderGroup in usable.GroupBy(static f => f.OrderId, StringComparer.Ordinal)) + { + var sequence = orderGroup.OrderBy(static f => f.Timestamp).ToList(); + var isCumulative = IsCumulativeSequence(sequence); + + decimal previousCumulative = 0m; + foreach (var report in sequence) + { + var quantity = isCumulative + ? report.FilledQuantity - previousCumulative + : report.FilledQuantity; + previousCumulative = report.FilledQuantity; + + if (quantity <= 0m) + continue; + + result.Add(new TcaFill( + report.OrderId, + report.Symbol, + report.Side, + quantity, + report.FillPrice!.Value, + report.Commission ?? 0m, + report.Timestamp)); + } + } + + result.Sort(static (a, b) => a.Timestamp.CompareTo(b.Timestamp)); + return result; + } + + private static bool IsCumulativeSequence(List sequence) + { + if (sequence.Count < 2) + return false; + + var orderQuantity = sequence[^1].OrderQuantity; + if (orderQuantity <= 0m) + return false; + + decimal sum = 0m; + decimal previous = 0m; + foreach (var report in sequence) + { + if (report.FilledQuantity < previous) + return false; + previous = report.FilledQuantity; + sum += report.FilledQuantity; + } + + return sum > orderQuantity; + } + + private static SessionTcaCostSummary BuildCostSummary(List fills) { decimal totalCommissions = 0m; decimal totalBuyNotional = 0m; @@ -85,17 +168,16 @@ private static SessionTcaCostSummary BuildCostSummary(List fill foreach (var fill in fills) { - var notional = fill.FilledQuantity * fill.FillPrice!.Value; - totalCommissions += fill.Commission ?? 0m; + totalCommissions += fill.Commission; if (fill.Side == OrderSide.Buy) { - totalBuyNotional += notional; + totalBuyNotional += fill.Notional; buyFills++; } else { - totalSellNotional += notional; + totalSellNotional += fill.Notional; sellFills++; } } @@ -116,14 +198,14 @@ private static SessionTcaCostSummary BuildCostSummary(List fill sellFills); } - private static IReadOnlyList BuildSymbolSummaries(List fills) + private static IReadOnlyList BuildSymbolSummaries(List fills) { - var grouped = new Dictionary>(StringComparer.Ordinal); + var grouped = new Dictionary>(StringComparer.Ordinal); foreach (var fill in fills) { if (!grouped.TryGetValue(fill.Symbol, out var bucket)) { - bucket = new List(); + bucket = new List(); grouped[fill.Symbol] = bucket; } bucket.Add(fill); @@ -138,21 +220,19 @@ private static IReadOnlyList BuildSymbolSummaries(List< foreach (var fill in symbolFills) { - var price = fill.FillPrice!.Value; - var notional = fill.FilledQuantity * price; - commission += fill.Commission ?? 0m; + commission += fill.Commission; if (fill.Side == OrderSide.Buy) { - buyNotional += notional; - buyQty += fill.FilledQuantity; - buyWeighted += fill.FilledQuantity * price; + buyNotional += fill.Notional; + buyQty += fill.Quantity; + buyWeighted += fill.Quantity * fill.Price; } else { - sellNotional += notional; - sellQty += fill.FilledQuantity; - sellWeighted += fill.FilledQuantity * price; + sellNotional += fill.Notional; + sellQty += fill.Quantity; + sellWeighted += fill.Quantity * fill.Price; } } @@ -177,16 +257,15 @@ private static IReadOnlyList BuildSymbolSummaries(List< return summaries; } - private static IReadOnlyList DetectOutliers(List fills) + private static IReadOnlyList DetectOutliers(List fills) { // The median (not the aggregate mean) is used as the baseline so that a single high-cost // fill cannot inflate the threshold enough to avoid detection. var perFillBps = new List(fills.Count); foreach (var fill in fills) { - var notional = fill.FilledQuantity * fill.FillPrice!.Value; - if (notional > 0m) - perFillBps.Add((double)((fill.Commission ?? 0m) / notional) * 10_000.0); + if (fill.Notional > 0m) + perFillBps.Add((double)(fill.Commission / fill.Notional) * 10_000.0); } double medianBps = 0.0; @@ -202,19 +281,18 @@ private static IReadOnlyList DetectOutliers(List(); foreach (var fill in fills) { - var notional = fill.FilledQuantity * fill.FillPrice!.Value; - if (notional <= 0m) + if (fill.Notional <= 0m) continue; - var fillBps = (double)((fill.Commission ?? 0m) / notional) * 10_000.0; + var fillBps = (double)(fill.Commission / fill.Notional) * 10_000.0; if (fillBps > medianBps * OutlierThresholdMultiplier && fillBps > OutlierMinimumRateBps) { outliers.Add(new SessionTcaFillOutlier( fill.OrderId, fill.Symbol, - notional, - fill.Commission ?? 0m, + fill.Notional, + fill.Commission, Math.Round(fillBps, 2), fill.Timestamp)); } @@ -226,7 +304,7 @@ private static IReadOnlyList DetectOutliers(List fills, + List fills, IReadOnlyList? orders) { if (orders is not { Count: > 0 }) @@ -256,16 +334,14 @@ private static SessionTcaExecutionQuality BuildExecutionQuality( { limitOrderIds.Add(order.OrderId); var limit = order.LimitPrice.Value; - var price = fill.FillPrice!.Value; - var notional = fill.FilledQuantity * price; // Positive = filled better than the limit (price improvement). var improvementBps = fill.Side == OrderSide.Buy - ? (limit - price) / limit * 10_000m - : (price - limit) / limit * 10_000m; + ? (limit - fill.Price) / limit * 10_000m + : (fill.Price - limit) / limit * 10_000m; - improvementWeightedBps += improvementBps * notional; - improvementNotional += notional; + improvementWeightedBps += improvementBps * fill.Notional; + improvementNotional += fill.Notional; } } diff --git a/src/Meridian/UiServer.cs b/src/Meridian/UiServer.cs index 2e603a0fae..567ef7f322 100644 --- a/src/Meridian/UiServer.cs +++ b/src/Meridian/UiServer.cs @@ -121,6 +121,8 @@ public UiServer( builder.Services.AddSingleton(_lifecycle); builder.Services.AddSingleton(new StrategyDesignStoreOptions(Path.Combine(resolvedDataRoot, "strategies", "designer"))); + builder.Services.AddOptions() + .BindConfiguration(Meridian.Storage.Services.DataReplacementCostOptions.SectionName); builder.Services.AddWorkstationSharedServices(); builder.Services.AddOmsIntegrationApiHandlers(); diff --git a/tests/Meridian.Tests/Execution/SessionTcaReporterTests.cs b/tests/Meridian.Tests/Execution/SessionTcaReporterTests.cs index cde11cb1e4..bc34e37cf8 100644 --- a/tests/Meridian.Tests/Execution/SessionTcaReporterTests.cs +++ b/tests/Meridian.Tests/Execution/SessionTcaReporterTests.cs @@ -15,14 +15,15 @@ private static ExecutionReport MakeFill( decimal? commission = null, string? orderId = null, DateTimeOffset? timestamp = null, - ExecutionReportType reportType = ExecutionReportType.Fill) => new() + ExecutionReportType reportType = ExecutionReportType.Fill, + decimal? orderQuantity = null) => new() { OrderId = orderId ?? $"order-{Guid.NewGuid():N}", ReportType = reportType, Symbol = symbol, Side = side, OrderStatus = OrderStatus.Filled, - OrderQuantity = quantity, + OrderQuantity = orderQuantity ?? quantity, FilledQuantity = quantity, FillPrice = price, Commission = commission, @@ -222,6 +223,69 @@ public void Generate_ComputesLimitPriceImprovement() report.ExecutionQuality.AvgLimitPriceImprovementBps.Should().BeApproximately(10.0, 0.05); } + [Fact] + public void Generate_CumulativeQuantitySequence_IsConvertedToIncrements() + { + var start = DateTimeOffset.UtcNow; + // IB-style running totals: a 40-share partial then a 100-share final report + // describe 100 executed shares (40 @ 400 + 60 @ 401), not 140. + var fills = new[] + { + MakeFill("SPY", OrderSide.Buy, 40m, 400m, commission: 0.4m, + orderId: "ib-1", timestamp: start, + reportType: ExecutionReportType.PartialFill, orderQuantity: 100m), + MakeFill("SPY", OrderSide.Buy, 100m, 401m, commission: 0.6m, + orderId: "ib-1", timestamp: start.AddSeconds(1), + orderQuantity: 100m) + }; + + var report = SessionTcaReporter.Generate("s", "strat", fills); + + report.CostSummary.TotalFills.Should().Be(2); + // 40 × 400 + 60 × 401 = 40,060 + report.CostSummary.TotalBuyNotional.Should().Be(40_060m); + report.CostSummary.TotalCommissions.Should().Be(1.0m); + // VWAP = 40,060 / 100 = 400.6 + report.SymbolSummaries[0].AvgBuyPrice.Should().Be(400.6m); + } + + [Fact] + public void Generate_IncrementalSequenceSummingToOrderQuantity_IsUnchanged() + { + var start = DateTimeOffset.UtcNow; + // Incremental tape: two partials of 40 + 60 against a 100-share order. + var fills = new[] + { + MakeFill("SPY", OrderSide.Buy, 40m, 400m, orderId: "p-1", timestamp: start, + reportType: ExecutionReportType.PartialFill, orderQuantity: 100m), + MakeFill("SPY", OrderSide.Buy, 60m, 401m, orderId: "p-1", timestamp: start.AddSeconds(1), + orderQuantity: 100m) + }; + + var report = SessionTcaReporter.Generate("s", "strat", fills); + + report.CostSummary.TotalBuyNotional.Should().Be(40m * 400m + 60m * 401m); + } + + [Fact] + public void Generate_IncrementalPartialFillsOnWorkingOrder_AreUnchanged() + { + var start = DateTimeOffset.UtcNow; + // Working order: only 30 of 100 shares filled so far across two increments. + var fills = new[] + { + MakeFill("SPY", OrderSide.Buy, 10m, 400m, orderId: "w-1", timestamp: start, + reportType: ExecutionReportType.PartialFill, orderQuantity: 100m), + MakeFill("SPY", OrderSide.Buy, 20m, 400m, orderId: "w-1", timestamp: start.AddSeconds(1), + reportType: ExecutionReportType.PartialFill, orderQuantity: 100m) + }; + + var report = SessionTcaReporter.Generate("s", "strat", fills); + + report.CostSummary.TotalBuyNotional.Should().Be(30m * 400m); + report.CostSummary.TotalFills.Should().Be(2); + } + [Fact] public void Generate_WithoutOrderHistory_ReturnsEmptyExecutionQuality() { From 2a62d58d4a3cc75c486bd256d73c905d1a903ab0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 20:43:06 +0000 Subject: [PATCH 05/13] feat: add IB Flex statement connector and strengthen TCA fill normalization Implements the custodian/broker statement connector lane from the brainstorm (idea #4), starting with Interactive Brokers Flex Query XML: - IbFlexBrokerStatementService parses Flex reports (Trades, OpenPositions, CashTransactions) into canonical statement rows with the same duplicate-key, checksum, and persistence flow as the CSV path, so matching and case intake are source-agnostic. XML loading prohibits DTD processing. - RoutingBrokerStatementService dispatches imports by broker alias (ib-flex, ibflex, ibkr, interactive-brokers) or .xml extension, keeping all existing CSV flows unchanged; registered as the IBrokerStatementService implementation. - Registers an 'ib-flex-v1' statement mapping profile (Flex attribute names, IB transaction-code mappings) resolvable by source kind. - 19 connector tests covering validation, section mapping, Flex date formats, duplicate detection, routing, matching flow, and profile registration. Also addresses review feedback on SessionTcaReporter: cumulative sequences on still-working orders (partials summing below the order quantity) are now detected via the order history's executed quantity, so mid-session TCA no longer overstates in-progress IB orders. Adds a regression test and updates the storage feature registration test for the routing service. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011vonQATmobZwPb2KcouHnC --- docs/generated/repository-structure.md | 2 + .../api-contract-coverage-dashboard.json | 4 +- .../status/api-contract-coverage-dashboard.md | 4 +- docs/status/coverage-report.md | 12 +- docs/status/doc-health-dashboard.json | 4 +- docs/status/doc-health-dashboard.md | 2 +- .../Services/SessionTcaReporter.cs | 56 ++-- .../ReconciliationServiceRegistration.cs | 15 +- .../StatementMappingProfiles.cs | 35 ++ .../Reconciliation/IbFlexStatementService.cs | 309 ++++++++++++++++++ .../StorageFeatureRegistrationTests.cs | 4 +- .../Execution/SessionTcaReporterTests.cs | 26 ++ .../IbFlexStatementServiceTests.cs | 262 +++++++++++++++ 13 files changed, 696 insertions(+), 39 deletions(-) create mode 100644 src/Meridian.Infrastructure/Reconciliation/IbFlexStatementService.cs create mode 100644 tests/Meridian.Tests/Reconciliation/IbFlexStatementServiceTests.cs diff --git a/docs/generated/repository-structure.md b/docs/generated/repository-structure.md index 522229166b..f9131394f6 100644 --- a/docs/generated/repository-structure.md +++ b/docs/generated/repository-structure.md @@ -3976,6 +3976,7 @@ Meridian-main │ │ ├── Reconciliation │ │ │ ├── BrokerStatementInfrastructure.cs │ │ │ ├── BrokerStatementNormalizer.cs +│ │ │ ├── IbFlexStatementService.cs │ │ │ └── ReconciliationCaseInfrastructure.cs │ │ ├── Resilience │ │ │ ├── HttpResiliencePolicy.cs @@ -6957,6 +6958,7 @@ Meridian-main │ │ │ │ ├── statement-invalid-blockers.csv │ │ │ │ └── statement-unresolved-breaks.csv │ │ │ ├── BrokerCustodianMatchingPipelineTests.cs +│ │ │ ├── IbFlexStatementServiceTests.cs │ │ │ ├── ReconciliationCaseServiceTests.cs │ │ │ ├── ReconciliationContractsTests.cs │ │ │ ├── StatementBreakClassifierTests.cs diff --git a/docs/status/api-contract-coverage-dashboard.json b/docs/status/api-contract-coverage-dashboard.json index e86b00672f..972f168c9b 100644 --- a/docs/status/api-contract-coverage-dashboard.json +++ b/docs/status/api-contract-coverage-dashboard.json @@ -17,13 +17,13 @@ { "method": "GET", "path": "/api/system/lifecycle", - "source": "src/Meridian/UiServer.cs:349", + "source": "src/Meridian/UiServer.cs:351", "documented": true }, { "method": "POST", "path": "/api/system/shutdown", - "source": "src/Meridian/UiServer.cs:371", + "source": "src/Meridian/UiServer.cs:373", "documented": true }, { diff --git a/docs/status/api-contract-coverage-dashboard.md b/docs/status/api-contract-coverage-dashboard.md index af74033cda..b38c032eaa 100644 --- a/docs/status/api-contract-coverage-dashboard.md +++ b/docs/status/api-contract-coverage-dashboard.md @@ -604,8 +604,8 @@ Tracks whether mapped API routes and workstation DTO contracts are visible in th | `GET` | `/api/symbols/{symbol}/status` | Documented | `src/Meridian.Ui.Shared/Endpoints/SymbolEndpoints.cs:103` | | `GET` | `/api/symbols/{symbol}/trades` | Documented | `src/Meridian.Ui.Shared/Endpoints/SymbolEndpoints.cs:187` | | `POST` | `/api/symbols/{symbol}/update` | Documented | `src/Meridian.Ui.Shared/Endpoints/SymbolEndpoints.cs:480` | -| `GET` | `/api/system/lifecycle` | Documented | `src/Meridian/UiServer.cs:349` | -| `POST` | `/api/system/shutdown` | Documented | `src/Meridian/UiServer.cs:371` | +| `GET` | `/api/system/lifecycle` | Documented | `src/Meridian/UiServer.cs:351` | +| `POST` | `/api/system/shutdown` | Documented | `src/Meridian/UiServer.cs:373` | | `GET` | `/health` | Documented | `src/Meridian.Application/Composition/HostAdapters.cs:60` | | `GET` | `/live` | Documented | `src/Meridian.Application/Composition/HostAdapters.cs:73` | | `GET` | `/ready` | Documented | `src/Meridian.Application/Composition/HostAdapters.cs:72` | diff --git a/docs/status/coverage-report.md b/docs/status/coverage-report.md index c5548b5e7f..45460088ab 100644 --- a/docs/status/coverage-report.md +++ b/docs/status/coverage-report.md @@ -5,17 +5,17 @@ ## Overall Coverage -**2599 / 7248** items documented (**35.9%**) — Grade: **F** +**2599 / 7250** items documented (**35.8%**) — Grade: **F** ```text -[=======-------------] 35.9% +[=======-------------] 35.8% ``` ## Coverage by Category | Category | Documented | Total | Coverage | Grade | | ---------- | ----------- | ------- | ---------- | ------- | -| Public Classes / Interfaces | 2478 | 6752 | 36.7% | F | +| Public Classes / Interfaces | 2478 | 6754 | 36.7% | F | | API Endpoints | 107 | 349 | 30.7% | F | | Configuration Options | 3 | 136 | 2.2% | F | | Provider Implementations | 0 | 0 | 100.0% | A | @@ -23,7 +23,7 @@ ## Undocumented Items -### Public Classes / Interfaces (4274 undocumented) +### Public Classes / Interfaces (4276 undocumented) | Item | Location | | ------ | ---------- | @@ -77,7 +77,7 @@ | `SecurityMasterActiveImportStatus` | `src/Meridian.Application/SecurityMaster/SecurityMasterIngestStatusService.cs:13` | | `SecurityMasterCompletedImportStatus` | `src/Meridian.Application/SecurityMaster/SecurityMasterIngestStatusService.cs:26` | | `ISecurityMasterIngestStatusService` | `src/Meridian.Application/SecurityMaster/SecurityMasterIngestStatusService.cs:41` | -| ... and 4224 more | | +| ... and 4226 more | | ### API Endpoints (242 undocumented) @@ -193,7 +193,7 @@ ## Recommendations -1. **Public Classes / Interfaces**: 4274 undocumented types. Consider generating API docs with DocFX (`docfx docfx.json`) to cover the long tail of public types automatically. +1. **Public Classes / Interfaces**: 4276 undocumented types. Consider generating API docs with DocFX (`docfx docfx.json`) to cover the long tail of public types automatically. 2. **API Endpoints**: 242 endpoint(s) missing from `docs/reference/api-reference.md`. Run the endpoint audit and update the API reference table. 3. **Configuration Options**: 133 config key(s) not found in `docs/generated/configuration-schema.md`. Re-run the configuration schema generator to synchronise. diff --git a/docs/status/doc-health-dashboard.json b/docs/status/doc-health-dashboard.json index 11d09d4ba1..9d71e2ee93 100644 --- a/docs/status/doc-health-dashboard.json +++ b/docs/status/doc-health-dashboard.json @@ -1,6 +1,6 @@ { "total_files": 535, - "total_lines": 87619, + "total_lines": 87621, "orphaned_count": 204, "no_heading_count": 38, "stale_count": 0, @@ -2129,7 +2129,7 @@ }, { "path": "docs/generated/repository-structure.md", - "line_count": 7664, + "line_count": 7666, "has_heading": true, "todo_count": 9, "last_modified_utc": "1970-01-01T00:00:00+00:00", diff --git a/docs/status/doc-health-dashboard.md b/docs/status/doc-health-dashboard.md index 3d9a8974c4..fff6a46c64 100644 --- a/docs/status/doc-health-dashboard.md +++ b/docs/status/doc-health-dashboard.md @@ -20,7 +20,7 @@ Data sources: `repo markdown (*.md)`, `file modification metadata` | Metric | Value | | -------- | ------- | | Total documentation files | 535 | -| Total lines | 87,619 | +| Total lines | 87,621 | | Average file size (lines) | 163.8 | | Orphaned files | 204 | | Files without headings | 38 | diff --git a/src/Meridian.Execution/Services/SessionTcaReporter.cs b/src/Meridian.Execution/Services/SessionTcaReporter.cs index d592e6a9ac..753e24deba 100644 --- a/src/Meridian.Execution/Services/SessionTcaReporter.cs +++ b/src/Meridian.Execution/Services/SessionTcaReporter.cs @@ -39,7 +39,16 @@ public static SessionTcaReport Generate( ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); ArgumentNullException.ThrowIfNull(fills); - var normalized = NormalizeFills(fills); + // Last-writer-wins on duplicate order updates: the latest OrderState for an order id + // carries the final limit price, creation timestamp, and executed quantity. + var ordersById = new Dictionary(StringComparer.Ordinal); + if (orders is not null) + { + foreach (var order in orders) + ordersById[order.OrderId] = order; + } + + var normalized = NormalizeFills(fills, ordersById); if (normalized.Count == 0) { @@ -56,7 +65,7 @@ public static SessionTcaReport Generate( var costSummary = BuildCostSummary(normalized); var symbolSummaries = BuildSymbolSummaries(normalized); var outliers = DetectOutliers(normalized); - var executionQuality = BuildExecutionQuality(normalized, orders); + var executionQuality = BuildExecutionQuality(normalized, ordersById); return new SessionTcaReport( sessionId, @@ -85,13 +94,17 @@ private sealed record TcaFill( /// Filters the raw report tape down to usable fills and converts cumulative per-order /// quantity sequences into incremental ones. A per-order sequence is treated as cumulative /// when it contains multiple reports whose quantities never decrease and whose sum exceeds - /// the order quantity — the signature of running totals (a 40-share partial followed by a - /// 100-share final report describes 100 executed shares, not 140). Sequences that already - /// sum to at most the order quantity are left untouched, so incremental tapes (e.g. the - /// paper gateway) are unaffected. When the order quantity is unknown the sequence is left - /// as-is because the two encodings cannot be distinguished. + /// the executed quantity known for the order — either the order quantity (a 40-share + /// partial followed by a 100-share final report on a 100-share order describes 100 executed + /// shares, not 140) or, for orders still working, the executed quantity recorded in order + /// history (cumulative partials of 10 then 20 against a 100-share order describe 20 + /// executed shares, not 30). Incremental tapes (e.g. the paper gateway) sum exactly to the + /// executed quantity and are unaffected. When neither signal is available the sequence is + /// left as-is because the two encodings cannot be distinguished. /// - private static List NormalizeFills(IReadOnlyList fills) + private static List NormalizeFills( + IReadOnlyList fills, + IReadOnlyDictionary ordersById) { var usable = new List(fills.Count); foreach (var fill in fills) @@ -108,7 +121,8 @@ private static List NormalizeFills(IReadOnlyList fills foreach (var orderGroup in usable.GroupBy(static f => f.OrderId, StringComparer.Ordinal)) { var sequence = orderGroup.OrderBy(static f => f.Timestamp).ToList(); - var isCumulative = IsCumulativeSequence(sequence); + ordersById.TryGetValue(orderGroup.Key, out var orderState); + var isCumulative = IsCumulativeSequence(sequence, orderState); decimal previousCumulative = 0m; foreach (var report in sequence) @@ -136,15 +150,11 @@ private static List NormalizeFills(IReadOnlyList fills return result; } - private static bool IsCumulativeSequence(List sequence) + private static bool IsCumulativeSequence(List sequence, OrderState? orderState) { if (sequence.Count < 2) return false; - var orderQuantity = sequence[^1].OrderQuantity; - if (orderQuantity <= 0m) - return false; - decimal sum = 0m; decimal previous = 0m; foreach (var report in sequence) @@ -155,7 +165,13 @@ private static bool IsCumulativeSequence(List sequence) sum += report.FilledQuantity; } - return sum > orderQuantity; + var orderQuantity = sequence[^1].OrderQuantity; + if (orderQuantity > 0m && sum > orderQuantity) + return true; + + // Order still working: compare against the executed quantity the order history knows + // about. A cumulative tape always sums past it; an incremental tape sums exactly to it. + return orderState is { FilledQuantity: > 0m } && sum > orderState.FilledQuantity; } private static SessionTcaCostSummary BuildCostSummary(List fills) @@ -305,17 +321,11 @@ private static IReadOnlyList DetectOutliers(List private static SessionTcaExecutionQuality BuildExecutionQuality( List fills, - IReadOnlyList? orders) + IReadOnlyDictionary ordersById) { - if (orders is not { Count: > 0 }) + if (ordersById.Count == 0) return SessionTcaExecutionQuality.Empty; - // Last-writer-wins on duplicate order updates: the latest OrderState for an order id - // carries the final limit price and creation timestamp. - var ordersById = new Dictionary(StringComparer.Ordinal); - foreach (var order in orders) - ordersById[order.OrderId] = order; - var timesToFillSeconds = new List(); decimal improvementWeightedBps = 0m; decimal improvementNotional = 0m; diff --git a/src/Meridian.FinancialOperations/Reconciliation/ReconciliationServiceRegistration.cs b/src/Meridian.FinancialOperations/Reconciliation/ReconciliationServiceRegistration.cs index a31e091dc3..c400e47c67 100644 --- a/src/Meridian.FinancialOperations/Reconciliation/ReconciliationServiceRegistration.cs +++ b/src/Meridian.FinancialOperations/Reconciliation/ReconciliationServiceRegistration.cs @@ -21,7 +21,7 @@ public static IServiceCollection AddStatementReconciliationServices(this IServic services.TryAddSingleton(sp => new JsonCanonicalStatementStore(sp.GetRequiredService().RootPath)); services.TryAddSingleton(sp => new JsonReconciliationCaseStore(sp.GetRequiredService().RootPath)); services.TryAddSingleton(sp => new JsonReconciliationBreakStore(sp.GetRequiredService().RootPath)); - services.TryAddSingleton(sp => new CsvBrokerStatementService(sp.GetRequiredService())); + AddBrokerStatementServices(services); return services; } @@ -38,10 +38,21 @@ public static IServiceCollection AddStatementReconciliationServices(this IServic services.TryAddSingleton(_ => new JsonCanonicalStatementStore(dataRoot)); services.TryAddSingleton(_ => new JsonReconciliationCaseStore(dataRoot)); services.TryAddSingleton(_ => new JsonReconciliationBreakStore(dataRoot)); - services.TryAddSingleton(sp => new CsvBrokerStatementService(sp.GetRequiredService())); + AddBrokerStatementServices(services); return services; } + private static void AddBrokerStatementServices(IServiceCollection services) + { + services.TryAddSingleton(sp => + new CsvBrokerStatementService(sp.GetRequiredService())); + services.TryAddSingleton(sp => + new IbFlexBrokerStatementService(sp.GetRequiredService())); + services.TryAddSingleton(sp => new RoutingBrokerStatementService( + sp.GetRequiredService(), + sp.GetRequiredService())); + } + private static void AddSharedServices(IServiceCollection services) { services.TryAddSingleton(); diff --git a/src/Meridian.FinancialOperations/Reconciliation/StatementMappingProfiles.cs b/src/Meridian.FinancialOperations/Reconciliation/StatementMappingProfiles.cs index 0f10786803..407c93ef07 100644 --- a/src/Meridian.FinancialOperations/Reconciliation/StatementMappingProfiles.cs +++ b/src/Meridian.FinancialOperations/Reconciliation/StatementMappingProfiles.cs @@ -54,6 +54,7 @@ public sealed class StatementMappingProfileRegistry { public const string CanonicalCsvV1ProfileId = "canonical-csv-v1"; public const string SampleBrokerCsvV1ProfileId = "sample-broker-csv-v1"; + public const string IbFlexV1ProfileId = "ib-flex-v1"; private readonly Dictionary _profiles; @@ -85,6 +86,7 @@ public StatementMappingProfile ResolveForSourceKind(string normalizedSourceKind, return Resolve(normalizedSourceKind switch { "sample-broker" => SampleBrokerCsvV1ProfileId, + "ib-flex" or "ibflex" or "ibkr" or "interactive-brokers" or "interactivebrokers" => IbFlexV1ProfileId, _ => CanonicalCsvV1ProfileId }); } @@ -149,6 +151,39 @@ private static IReadOnlyList CreateDefaultProfiles() => new("SELL", "trade"), new("FEE", "fee"), new("DIV", "dividend") + ]), + new StatementMappingProfile( + IbFlexV1ProfileId, + "Interactive Brokers Flex Query v1", + [ + // Source columns are Flex XML attribute names; the Flex importer emits the + // canonical activity types (trade/position/cash) directly per section. + new(StatementCanonicalField.Account, "accountId"), + new(StatementCanonicalField.SecurityIdentifier, "symbol"), + new(StatementCanonicalField.Quantity, "quantity"), + new(StatementCanonicalField.Price, "tradePrice"), + new(StatementCanonicalField.CashAmount, "netCash"), + new(StatementCanonicalField.ActivityType, "activityType"), + new(StatementCanonicalField.TradeDate, "tradeDate"), + new(StatementCanonicalField.SettlementDate, "settleDateTarget", Required: false), + new(StatementCanonicalField.Currency, "currency", Required: false), + new(StatementCanonicalField.FeesCommission, "ibCommission", Required: false), + new(StatementCanonicalField.ExternalTransactionId, "tradeID", Required: false), + new(StatementCanonicalField.MarketValue, "positionValue", Required: false), + new(StatementCanonicalField.Amount, "amount", Required: false) + ], + [ + new("BUY", "trade"), + new("SELL", "trade"), + new("trade", "trade"), + new("position", "position"), + new("cash", "cash"), + new("Dividends", "cash"), + new("Deposits/Withdrawals", "cash"), + new("Broker Interest Paid", "cash"), + new("Broker Interest Received", "cash"), + new("Withholding Tax", "cash"), + new("Other Fees", "fee") ]) ]; } diff --git a/src/Meridian.Infrastructure/Reconciliation/IbFlexStatementService.cs b/src/Meridian.Infrastructure/Reconciliation/IbFlexStatementService.cs new file mode 100644 index 0000000000..14cf53d04c --- /dev/null +++ b/src/Meridian.Infrastructure/Reconciliation/IbFlexStatementService.cs @@ -0,0 +1,309 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Meridian.Domain.Reconciliation; + +namespace Meridian.Infrastructure.Reconciliation; + +/// +/// Imports Interactive Brokers Flex Query XML statements into canonical statement rows. +/// Supports the three Flex sections used by reconciliation: Trades/Trade (activity +/// type trade), OpenPositions/OpenPosition (position), and +/// CashTransactions/CashTransaction (cash). Follows the same duplicate-key, +/// checksum, and persistence flow as so downstream +/// matching and case intake treat both sources identically. +/// +public sealed class IbFlexBrokerStatementService(ICanonicalStatementStore store) : IBrokerStatementService +{ + private static readonly string[] SupportedBrokerAliases = + ["ibflex", "ib-flex", "ibkr", "interactivebrokers", "interactive-brokers"]; + + /// Returns whether names the IB Flex source. + public static bool IsIbFlexSource(string? broker) => + broker is not null + && SupportedBrokerAliases.Contains(broker.Trim(), StringComparer.OrdinalIgnoreCase); + + public async Task ValidateAsync( + BrokerStatementImportRequest request, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + + var errors = new List(); + if (!File.Exists(request.SourcePath)) + { + errors.Add("Source file not found."); + return new BrokerStatementValidationResult(false, errors, 0); + } + + XDocument document; + try + { + document = await LoadDocumentAsync(request.SourcePath, ct).ConfigureAwait(false); + } + catch (XmlException ex) + { + errors.Add($"Source file is not well-formed XML: {ex.Message}"); + return new BrokerStatementValidationResult(false, errors, 0); + } + + if (!string.Equals(document.Root?.Name.LocalName, "FlexQueryResponse", StringComparison.Ordinal)) + { + errors.Add("Root element is not FlexQueryResponse; not an IB Flex Query report."); + return new BrokerStatementValidationResult(false, errors, 0); + } + + var statements = document.Root!.Descendants("FlexStatement").ToList(); + if (statements.Count == 0) + { + errors.Add("Flex report contains no FlexStatement elements."); + return new BrokerStatementValidationResult(false, errors, 0); + } + + var rowCount = statements.Sum(static statement => + statement.Descendants("Trade").Count() + + statement.Descendants("OpenPosition").Count() + + statement.Descendants("CashTransaction").Count()); + + if (rowCount == 0) + { + errors.Add("Flex report contains no Trade, OpenPosition, or CashTransaction rows; " + + "include those sections in the Flex Query definition."); + } + + return new BrokerStatementValidationResult(errors.Count == 0, errors, rowCount); + } + + public async Task ImportAsync( + BrokerStatementImportRequest request, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + + var fileBytes = await File.ReadAllBytesAsync(request.SourcePath, ct).ConfigureAwait(false); + var sourceFileHash = string.IsNullOrWhiteSpace(request.SourceFileHash) + ? Convert.ToHexString(SHA256.HashData(fileBytes)) + : request.SourceFileHash.Trim().ToUpperInvariant(); + var duplicateKey = StatementDuplicateKey.Create( + request.FundAccountId, + request.StatementPeriodStart, + request.StatementPeriodEnd, + sourceFileHash); + + if (await store.ImportExistsByDuplicateKeyAsync(duplicateKey, ct).ConfigureAwait(false)) + throw new InvalidOperationException( + "Statement already imported (fund account, statement period, and source file hash match)."); + + var document = await LoadDocumentAsync(request.SourcePath, ct).ConfigureAwait(false); + if (!string.Equals(document.Root?.Name.LocalName, "FlexQueryResponse", StringComparison.Ordinal)) + throw new InvalidDataException("Root element is not FlexQueryResponse; not an IB Flex Query report."); + + var importId = duplicateKey; + var normalizedRequest = request.WithSourceFileHash(sourceFileHash); + var rows = ParseRows(document, importId).ToList(); + + var import = new CanonicalStatementImport( + importId, + normalizedRequest.Broker, + normalizedRequest.StatementPeriodEnd, + DateTimeOffset.UtcNow, + normalizedRequest.SourcePath, + sourceFileHash, + rows.Count, + rows.Count) + { + SourceInstitution = normalizedRequest.SourceInstitution, + FundAccountId = normalizedRequest.FundAccountId, + ExternalAccountId = normalizedRequest.ExternalAccountId, + StatementPeriodStart = normalizedRequest.StatementPeriodStart, + StatementPeriodEnd = normalizedRequest.StatementPeriodEnd, + OriginalFileName = normalizedRequest.OriginalFileName, + MappingProfileId = normalizedRequest.MappingProfileId, + ToleranceProfileId = normalizedRequest.ToleranceProfileId, + ImportedBy = normalizedRequest.ImportedBy, + SourceFileHash = sourceFileHash, + DuplicateKey = duplicateKey + }; + + await store.SaveImportAsync(import, rows, ct).ConfigureAwait(false); + return new BrokerStatementImportResult(import, rows); + } + + private static async Task LoadDocumentAsync(string path, CancellationToken ct) + { + // DTD processing stays disabled: Flex reports never carry DTDs, and prohibiting them + // blocks XXE-style payloads in operator-supplied files. + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + Async = true, + CloseInput = true + }; + + using var reader = XmlReader.Create(File.OpenRead(path), settings); + return await XDocument.LoadAsync(reader, LoadOptions.None, ct).ConfigureAwait(false); + } + + private static IEnumerable ParseRows(XDocument document, string importId) + { + var rowNumber = 0; + foreach (var statement in document.Root!.Descendants("FlexStatement")) + { + var statementAccount = (string?)statement.Attribute("accountId") ?? string.Empty; + var statementToDate = ParseFlexDate((string?)statement.Attribute("toDate")); + + foreach (var trade in statement.Descendants("Trade")) + { + rowNumber++; + yield return new CanonicalStatementRow( + importId, + rowNumber, + Account(trade, statementAccount), + (string?)trade.Attribute("symbol") ?? string.Empty, + ParseDecimal(trade, "quantity"), + ParseDecimal(trade, "tradePrice"), + ParseFirstDecimal(trade, "netCash", "proceeds"), + "trade", + RequireDate(trade, "tradeDate", statementToDate, rowNumber), + HashElement(trade)); + } + + foreach (var position in statement.Descendants("OpenPosition")) + { + rowNumber++; + yield return new CanonicalStatementRow( + importId, + rowNumber, + Account(position, statementAccount), + (string?)position.Attribute("symbol") ?? string.Empty, + ParseDecimal(position, "position"), + ParseFirstDecimal(position, "markPrice", "costBasisPrice"), + 0m, + "position", + RequireDate(position, "reportDate", statementToDate, rowNumber), + HashElement(position)); + } + + foreach (var cash in statement.Descendants("CashTransaction")) + { + rowNumber++; + yield return new CanonicalStatementRow( + importId, + rowNumber, + Account(cash, statementAccount), + (string?)cash.Attribute("symbol") ?? string.Empty, + 0m, + 0m, + ParseDecimal(cash, "amount"), + "cash", + RequireFirstDate(cash, ["dateTime", "reportDate", "settleDate"], statementToDate, rowNumber), + HashElement(cash)); + } + } + } + + private static string Account(XElement element, string statementAccount) => + (string?)element.Attribute("accountId") is { Length: > 0 } account ? account : statementAccount; + + private static decimal ParseDecimal(XElement element, string attribute) + { + var raw = (string?)element.Attribute(attribute); + return string.IsNullOrWhiteSpace(raw) + ? 0m + : decimal.Parse(raw, NumberStyles.Number, CultureInfo.InvariantCulture); + } + + private static decimal ParseFirstDecimal(XElement element, params string[] attributes) + { + foreach (var attribute in attributes) + { + var raw = (string?)element.Attribute(attribute); + if (!string.IsNullOrWhiteSpace(raw)) + return decimal.Parse(raw, NumberStyles.Number, CultureInfo.InvariantCulture); + } + + return 0m; + } + + private static DateOnly RequireDate(XElement element, string attribute, DateOnly? fallback, int rowNumber) => + ParseFlexDate((string?)element.Attribute(attribute)) + ?? fallback + ?? throw new InvalidDataException( + $"Flex row {rowNumber} has no parseable '{attribute}' date and the statement has no toDate fallback."); + + private static DateOnly RequireFirstDate( + XElement element, + string[] attributes, + DateOnly? fallback, + int rowNumber) + { + foreach (var attribute in attributes) + { + if (ParseFlexDate((string?)element.Attribute(attribute)) is { } parsed) + return parsed; + } + + return fallback ?? throw new InvalidDataException( + $"Flex row {rowNumber} has no parseable date attribute and the statement has no toDate fallback."); + } + + /// + /// Parses the date formats Flex Queries emit depending on report configuration: + /// yyyyMMdd, yyyy-MM-dd, and datetime variants with a ;HHmmss or + /// HH:mm:ss suffix (only the date part is kept). + /// + private static DateOnly? ParseFlexDate(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var datePart = raw.Split(';', ' ', 'T')[0]; + + if (DateOnly.TryParseExact(datePart, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var compact)) + return compact; + if (DateOnly.TryParseExact(datePart, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var iso)) + return iso; + + return null; + } + + private static string HashElement(XElement element) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(element.ToString(SaveOptions.DisableFormatting)))); +} + +/// +/// Routes statement imports to the format-appropriate implementation: IB Flex XML sources go +/// to ; everything else keeps the existing CSV path. +/// Routing is by broker alias first, then by an .xml source extension so operators who +/// pick a generic broker kind with a Flex file still land on the XML parser. +/// +public sealed class RoutingBrokerStatementService( + CsvBrokerStatementService csvService, + IbFlexBrokerStatementService ibFlexService) : IBrokerStatementService +{ + public Task ValidateAsync( + BrokerStatementImportRequest request, + CancellationToken ct = default) + => Resolve(request).ValidateAsync(request, ct); + + public Task ImportAsync( + BrokerStatementImportRequest request, + CancellationToken ct = default) + => Resolve(request).ImportAsync(request, ct); + + private IBrokerStatementService Resolve(BrokerStatementImportRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + if (IbFlexBrokerStatementService.IsIbFlexSource(request.Broker)) + return ibFlexService; + + if (string.Equals(Path.GetExtension(request.SourcePath), ".xml", StringComparison.OrdinalIgnoreCase)) + return ibFlexService; + + return csvService; + } +} diff --git a/tests/Meridian.Tests/Application/Composition/StorageFeatureRegistrationTests.cs b/tests/Meridian.Tests/Application/Composition/StorageFeatureRegistrationTests.cs index 51c8208957..bf3aea64ac 100644 --- a/tests/Meridian.Tests/Application/Composition/StorageFeatureRegistrationTests.cs +++ b/tests/Meridian.Tests/Application/Composition/StorageFeatureRegistrationTests.cs @@ -146,7 +146,9 @@ public void Register_AddsStatementReconciliationContextServices() provider.GetRequiredService() .Should().BeOfType(); provider.GetRequiredService() - .Should().BeOfType(); + .Should().BeOfType(); + provider.GetRequiredService().Should().NotBeNull(); + provider.GetRequiredService().Should().NotBeNull(); provider.GetRequiredService() .Should().BeOfType(); provider.GetRequiredService().Should().NotBeNull(); diff --git a/tests/Meridian.Tests/Execution/SessionTcaReporterTests.cs b/tests/Meridian.Tests/Execution/SessionTcaReporterTests.cs index bc34e37cf8..f01c257f7c 100644 --- a/tests/Meridian.Tests/Execution/SessionTcaReporterTests.cs +++ b/tests/Meridian.Tests/Execution/SessionTcaReporterTests.cs @@ -267,6 +267,32 @@ public void Generate_IncrementalSequenceSummingToOrderQuantity_IsUnchanged() report.CostSummary.TotalBuyNotional.Should().Be(40m * 400m + 60m * 401m); } + [Fact] + public void Generate_CumulativePartialsOnWorkingOrder_AreNormalizedUsingOrderHistory() + { + var start = DateTimeOffset.UtcNow; + // In-progress IB-style order: cumulative partials 10 then 20 on a 100-share order. + // The sum (30) does not exceed the order quantity, so the order history's executed + // quantity (20) is the disambiguating signal. + var fills = new[] + { + MakeFill("SPY", OrderSide.Buy, 10m, 400m, orderId: "ib-w1", timestamp: start, + reportType: ExecutionReportType.PartialFill, orderQuantity: 100m), + MakeFill("SPY", OrderSide.Buy, 20m, 401m, orderId: "ib-w1", timestamp: start.AddSeconds(1), + reportType: ExecutionReportType.PartialFill, orderQuantity: 100m) + }; + var orders = new[] + { + MakeOrder("ib-w1", "SPY", OrderSide.Buy, 100m, createdAt: start) with { FilledQuantity = 20m } + }; + + var report = SessionTcaReporter.Generate("s", "strat", fills, orders); + + // 10 @ 400 + 10 @ 401 = 8,010 — not 10 @ 400 + 20 @ 401 = 12,020. + report.CostSummary.TotalBuyNotional.Should().Be(8_010m); + report.CostSummary.TotalFills.Should().Be(2); + } + [Fact] public void Generate_IncrementalPartialFillsOnWorkingOrder_AreUnchanged() { diff --git a/tests/Meridian.Tests/Reconciliation/IbFlexStatementServiceTests.cs b/tests/Meridian.Tests/Reconciliation/IbFlexStatementServiceTests.cs new file mode 100644 index 0000000000..b9956621fb --- /dev/null +++ b/tests/Meridian.Tests/Reconciliation/IbFlexStatementServiceTests.cs @@ -0,0 +1,262 @@ +using FluentAssertions; +using Meridian.Domain.Reconciliation; +using Meridian.FinancialOperations.Reconciliation; +using Meridian.Infrastructure.Reconciliation; +using Xunit; + +namespace Meridian.Tests.Reconciliation; + +public sealed class IbFlexStatementServiceTests : IDisposable +{ + private const string SampleFlexXml = """ + + + + + + + + + + + + + + + + + + """; + + private readonly string _tempDir; + private readonly JsonCanonicalStatementStore _store; + private readonly IbFlexBrokerStatementService _service; + + public IbFlexStatementServiceTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"meridian-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _store = new JsonCanonicalStatementStore(_tempDir); + _service = new IbFlexBrokerStatementService(_store); + } + + public void Dispose() + { + try + { + Directory.Delete(_tempDir, recursive: true); + } + catch (IOException) + { + // Best-effort cleanup of the per-test temp directory. + } + } + + private string WriteFlexFile(string content, string fileName = "flex-report.xml") + { + var path = Path.Combine(_tempDir, fileName); + File.WriteAllText(path, content); + return path; + } + + private static BrokerStatementImportRequest MakeRequest(string path) => + new BrokerStatementImportRequest("ib-flex", path, new DateOnly(2026, 6, 30)) with + { + FundAccountId = "FUND-1", + ExternalAccountId = "U1234567", + StatementPeriodStart = new DateOnly(2026, 6, 1), + StatementPeriodEnd = new DateOnly(2026, 6, 30) + }; + + [Fact] + public async Task Validate_WellFormedFlexReport_CountsAllSections() + { + var path = WriteFlexFile(SampleFlexXml); + + var result = await _service.ValidateAsync(MakeRequest(path)); + + result.IsValid.Should().BeTrue(); + result.Errors.Should().BeEmpty(); + result.RowCount.Should().Be(5); // 2 trades + 1 position + 2 cash + } + + [Fact] + public async Task Validate_MissingFile_Fails() + { + var result = await _service.ValidateAsync(MakeRequest(Path.Combine(_tempDir, "missing.xml"))); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainSingle(static e => e.Contains("not found")); + } + + [Fact] + public async Task Validate_NonFlexXml_Fails() + { + var path = WriteFlexFile(""); + + var result = await _service.ValidateAsync(MakeRequest(path)); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainSingle(static e => e.Contains("FlexQueryResponse")); + } + + [Fact] + public async Task Validate_MalformedXml_Fails() + { + var path = WriteFlexFile(""); + + var result = await _service.ValidateAsync(MakeRequest(path)); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainSingle(static e => e.Contains("well-formed")); + } + + [Fact] + public async Task Import_MapsTradesPositionsAndCashToCanonicalRows() + { + var path = WriteFlexFile(SampleFlexXml); + + var result = await _service.ImportAsync(MakeRequest(path)); + + result.Rows.Should().HaveCount(5); + result.Import.NormalizedRowCount.Should().Be(5); + + var buy = result.Rows[0]; + buy.ActivityType.Should().Be("trade"); + buy.Account.Should().Be("U1234567"); + buy.Symbol.Should().Be("AAPL"); + buy.Quantity.Should().Be(100m); + buy.Price.Should().Be(201.35m); + buy.CashAmount.Should().Be(-20136.00m); // netCash preferred + buy.TradeDate.Should().Be(new DateOnly(2026, 6, 15)); // yyyyMMdd format + + var sell = result.Rows[1]; + sell.Quantity.Should().Be(-50m); + sell.CashAmount.Should().Be(25005.00m); // proceeds fallback + sell.TradeDate.Should().Be(new DateOnly(2026, 6, 16)); // yyyy-MM-dd format + + var position = result.Rows[2]; + position.ActivityType.Should().Be("position"); + position.Quantity.Should().Be(100m); + position.Price.Should().Be(205.10m); + position.CashAmount.Should().Be(0m); + position.TradeDate.Should().Be(new DateOnly(2026, 6, 30)); + + var dividend = result.Rows[3]; + dividend.ActivityType.Should().Be("cash"); + dividend.CashAmount.Should().Be(24.00m); + dividend.TradeDate.Should().Be(new DateOnly(2026, 6, 10)); // date part of dateTime + + var fee = result.Rows[4]; + fee.CashAmount.Should().Be(-3.50m); + fee.TradeDate.Should().Be(new DateOnly(2026, 6, 30)); // reportDate fallback + } + + [Fact] + public async Task Import_AssignsUniqueRowChecksums() + { + var path = WriteFlexFile(SampleFlexXml); + + var result = await _service.ImportAsync(MakeRequest(path)); + + result.Rows.Select(static r => r.RawChecksum).Should().OnlyHaveUniqueItems(); + } + + [Fact] + public async Task Import_SameFileTwice_ThrowsDuplicate() + { + var path = WriteFlexFile(SampleFlexXml); + + await _service.ImportAsync(MakeRequest(path)); + var secondImport = async () => await _service.ImportAsync(MakeRequest(path)); + + await secondImport.Should().ThrowAsync() + .WithMessage("*already imported*"); + } + + [Fact] + public async Task Import_RowsFlowThroughStatementMatching() + { + var path = WriteFlexFile(SampleFlexXml); + var imported = await _service.ImportAsync(MakeRequest(path)); + + var outcomes = new StatementMatchingService().MatchRows(imported.Rows); + + outcomes.Should().HaveCount(5); + // Position row has non-trivial quantity and a symbol → matches. + outcomes[2].OutcomeType.Should().Be("matched"); + } + + [Theory] + [InlineData("ib-flex")] + [InlineData("ibflex")] + [InlineData("IBKR")] + [InlineData("InteractiveBrokers")] + [InlineData("interactive-brokers")] + public void IsIbFlexSource_RecognizesAliases(string broker) + { + IbFlexBrokerStatementService.IsIbFlexSource(broker).Should().BeTrue(); + } + + [Theory] + [InlineData("samplebroker")] + [InlineData("custodian")] + [InlineData(null)] + public void IsIbFlexSource_RejectsOtherSources(string? broker) + { + IbFlexBrokerStatementService.IsIbFlexSource(broker).Should().BeFalse(); + } + + [Fact] + public async Task Router_SendsFlexBrokerToFlexService_AndCsvBrokerToCsvService() + { + var router = new RoutingBrokerStatementService( + new CsvBrokerStatementService(_store), + _service); + + // Flex broker alias with a Flex file → validates via the XML parser. + var flexPath = WriteFlexFile(SampleFlexXml); + var flexResult = await router.ValidateAsync(MakeRequest(flexPath)); + flexResult.IsValid.Should().BeTrue(); + flexResult.RowCount.Should().Be(5); + + // CSV broker with a CSV file → validates via the CSV parser. + var csvPath = Path.Combine(_tempDir, "statement.csv"); + File.WriteAllText(csvPath, "account,symbol,quantity,price,cashAmount,activityType,tradeDate\nA1,SPY,10,500,0,position,2026-06-30\n"); + var csvRequest = new BrokerStatementImportRequest("samplebroker", csvPath, new DateOnly(2026, 6, 30)); + var csvResult = await router.ValidateAsync(csvRequest); + csvResult.IsValid.Should().BeTrue(); + csvResult.RowCount.Should().Be(1); + } + + [Fact] + public async Task Router_RoutesGenericBrokerWithXmlExtensionToFlexService() + { + var router = new RoutingBrokerStatementService( + new CsvBrokerStatementService(_store), + _service); + var path = WriteFlexFile(SampleFlexXml); + var request = new BrokerStatementImportRequest("broker", path, new DateOnly(2026, 6, 30)); + + var result = await router.ValidateAsync(request); + + result.IsValid.Should().BeTrue(); + result.RowCount.Should().Be(5); + } + + [Fact] + public void MappingProfileRegistry_RegistersIbFlexProfile() + { + var registry = StatementMappingProfileRegistry.Defaults; + + var profile = registry.Resolve(StatementMappingProfileRegistry.IbFlexV1ProfileId); + profile.DisplayName.Should().Contain("Interactive Brokers"); + profile.MapActivityType("Dividends").Should().Be("cash"); + profile.MapActivityType("BUY").Should().Be("trade"); + + registry.ResolveForSourceKind("ib-flex").ProfileId + .Should().Be(StatementMappingProfileRegistry.IbFlexV1ProfileId); + registry.ListProfiles().Should().Contain(static p => + p.ProfileId == StatementMappingProfileRegistry.IbFlexV1ProfileId); + } +} From ef672d03ea2c93a90acaa62e0861a9ba9bc20542 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 20:54:46 +0000 Subject: [PATCH 06/13] fix: accept IB Flex source kind in statement-run validation and ingestion The statement-run workflow validates through StatementReconciliationService before the broker statement router runs, and its source-kind whitelist rejected 'ib-flex' (and applied CSV header checks to XML when a mapping profile was supplied), blocking Flex imports end-to-end. Per review feedback: - ValidateSourceAccess now recognizes the Flex aliases (ib-flex, ibflex, ibkr, interactive-brokers) and normalizes them to 'ib-flex'. - Validation checks the Flex XML document shape (FlexQueryResponse root, DTD processing prohibited) instead of CSV headers. - The checkpointed ingestion stage reads Flex reports into source-row references (one per Trade/OpenPosition/CashTransaction element with attribute snapshots) so row counts are real; canonical row construction stays with the Flex-aware broker statement importer. - Case intake never routes Flex files through the canonical CSV path. Adds an end-to-end StatementRunWorkflowService test importing a Flex statement through validation, routing, matching, and case intake, plus validation-service coverage for accepted and rejected Flex inputs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011vonQATmobZwPb2KcouHnC --- .../StatementReconciliationService.cs | 108 +++++++++++++++++- .../IbFlexStatementServiceTests.cs | 59 ++++++++++ 2 files changed, 163 insertions(+), 4 deletions(-) diff --git a/src/Meridian.FinancialOperations/Reconciliation/StatementReconciliationService.cs b/src/Meridian.FinancialOperations/Reconciliation/StatementReconciliationService.cs index 08bf10aa4f..8abcf42a7f 100644 --- a/src/Meridian.FinancialOperations/Reconciliation/StatementReconciliationService.cs +++ b/src/Meridian.FinancialOperations/Reconciliation/StatementReconciliationService.cs @@ -22,7 +22,16 @@ public Task ValidateAsync(string sourceKind, string sourcePath, string? ct.ThrowIfCancellationRequested(); var normalizedSourceKind = ValidateSourceAccess(sourceKind, sourcePath); var profileId = mappingProfileId; - if (UsesCanonicalSchema(normalizedSourceKind, profileId)) + if (IsIbFlexSourceKind(normalizedSourceKind)) + { + // Flex reports are XML; the canonical CSV header check does not apply. Validate the + // document shape instead so the workflow rejects non-Flex files before import. + ValidateFlexDocument(sourcePath); + profileId = string.IsNullOrWhiteSpace(profileId) + ? StatementMappingProfileRegistry.IbFlexV1ProfileId + : profileId; + } + else if (UsesCanonicalSchema(normalizedSourceKind, profileId)) { var profile = ValidateStatementHeader(normalizedSourceKind, sourcePath, profileId); profileId = profile.ProfileId; @@ -39,6 +48,11 @@ public async Task ImportAsync(string sourceKind { var normalizedSourceKind = ValidateSourceAccess(sourceKind, sourcePath); ct.ThrowIfCancellationRequested(); + if (IsIbFlexSourceKind(normalizedSourceKind)) + { + return await ReadFlexStatementImportAsync(normalizedSourceKind, sourcePath, ct).ConfigureAwait(false); + } + if (UsesCanonicalSchema(normalizedSourceKind, mappingProfileId)) { return await ReadNormalizedStatementImportAsync(normalizedSourceKind, sourcePath, mappingProfileId, ct).ConfigureAwait(false); @@ -96,11 +110,17 @@ private static string ValidateSourceAccess(string sourceKind, string sourcePath) throw new ArgumentException("Statement source path is required.", nameof(sourcePath)); var normalizedSourceKind = sourceKind.Trim().ToLowerInvariant(); - if (!string.Equals(normalizedSourceKind, "local", StringComparison.Ordinal) + if (IsIbFlexSourceKind(normalizedSourceKind)) + { + normalizedSourceKind = IbFlexSourceKind; + } + else if (!string.Equals(normalizedSourceKind, "local", StringComparison.Ordinal) && !string.Equals(normalizedSourceKind, "broker", StringComparison.Ordinal) && !string.Equals(normalizedSourceKind, "custodian", StringComparison.Ordinal) && !string.Equals(normalizedSourceKind, "sample-broker", StringComparison.Ordinal)) - throw new NotSupportedException($"Statement source kind '{sourceKind}' is not supported. Use 'local', 'broker', 'custodian', or 'sample-broker'."); + { + throw new NotSupportedException($"Statement source kind '{sourceKind}' is not supported. Use 'local', 'broker', 'custodian', 'sample-broker', or 'ib-flex'."); + } if (!File.Exists(sourcePath)) throw new FileNotFoundException($"Statement source file '{sourcePath}' was not found.", sourcePath); @@ -108,6 +128,83 @@ private static string ValidateSourceAccess(string sourceKind, string sourcePath) return normalizedSourceKind; } + private const string IbFlexSourceKind = "ib-flex"; + + private static bool IsIbFlexSourceKind(string normalizedSourceKind) => + normalizedSourceKind is "ib-flex" or "ibflex" or "ibkr" or "interactive-brokers" or "interactivebrokers"; + + /// + /// Validates that is a well-formed IB Flex Query document + /// (root element FlexQueryResponse) without loading the full report. + /// + private static void ValidateFlexDocument(string sourcePath) + { + var settings = new System.Xml.XmlReaderSettings + { + DtdProcessing = System.Xml.DtdProcessing.Prohibit, + XmlResolver = null, + CloseInput = true + }; + + try + { + using var reader = System.Xml.XmlReader.Create(File.OpenRead(sourcePath), settings); + reader.MoveToContent(); + if (!string.Equals(reader.LocalName, "FlexQueryResponse", StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"Statement source '{sourcePath}' is not an IB Flex Query report (root element '{reader.LocalName}')."); + } + } + catch (System.Xml.XmlException ex) + { + throw new InvalidDataException($"Statement source '{sourcePath}' is not well-formed XML: {ex.Message}", ex); + } + } + + /// + /// Reads an IB Flex report into source-row references (one per Trade, OpenPosition, and + /// CashTransaction element, with the element's attributes as the raw snapshot) so the + /// checkpointed ingestion stage reports real row counts. Canonical row construction for + /// the workflow path is owned by the Flex-aware broker statement importer. + /// + private static async Task ReadFlexStatementImportAsync( + string normalizedSourceKind, + string sourcePath, + CancellationToken ct) + { + var content = await File.ReadAllTextAsync(sourcePath, ct).ConfigureAwait(false); + var importId = DeterministicFingerprint.Compute($"{normalizedSourceKind}|{sourcePath}|{content}"); + + var document = System.Xml.Linq.XDocument.Parse(content); + var sourceRows = new List(); + var rowNumber = 0; + foreach (var element in document.Descendants() + .Where(static e => e.Name.LocalName is "Trade" or "OpenPosition" or "CashTransaction")) + { + ct.ThrowIfCancellationRequested(); + rowNumber++; + var snapshot = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["sourceKind"] = normalizedSourceKind, + ["sourcePath"] = sourcePath, + ["elementName"] = element.Name.LocalName + }; + foreach (var attribute in element.Attributes()) + { + snapshot[attribute.Name.LocalName] = attribute.Value; + } + + sourceRows.Add(CreateSourceRowReference( + importId, + rowNumber, + element.ToString(System.Xml.Linq.SaveOptions.DisableFormatting), + snapshot)); + } + + return new NormalizedStatementImportResult(importId, normalizedSourceKind, sourcePath, sourceRows.Count, [], [], [], [], sourceRows); + } + private static bool RequiresCanonicalStatementSchema(string normalizedSourceKind) => string.Equals(normalizedSourceKind, "broker", StringComparison.Ordinal) || string.Equals(normalizedSourceKind, "custodian", StringComparison.Ordinal) @@ -123,7 +220,10 @@ private static bool UsesCanonicalSchema(string normalizedSourceKind, string? map private ExternalStatementCaseIntakeResult CreateExternalStatementCases(string normalizedSourceKind, string sourcePath, string? mappingProfileId = null) { - if (!UsesCanonicalSchema(normalizedSourceKind, mappingProfileId)) + // Flex XML never parses through the canonical CSV path, regardless of any mapping + // profile the caller selected; case intake for Flex runs through the workflow's + // Flex-aware broker statement importer instead. + if (IsIbFlexSourceKind(normalizedSourceKind) || !UsesCanonicalSchema(normalizedSourceKind, mappingProfileId)) { var content = File.ReadAllText(sourcePath); var importId = DeterministicFingerprint.Compute($"{normalizedSourceKind}|{sourcePath}|{content}"); diff --git a/tests/Meridian.Tests/Reconciliation/IbFlexStatementServiceTests.cs b/tests/Meridian.Tests/Reconciliation/IbFlexStatementServiceTests.cs index b9956621fb..1a4e6f9516 100644 --- a/tests/Meridian.Tests/Reconciliation/IbFlexStatementServiceTests.cs +++ b/tests/Meridian.Tests/Reconciliation/IbFlexStatementServiceTests.cs @@ -244,6 +244,65 @@ public async Task Router_RoutesGenericBrokerWithXmlExtensionToFlexService() result.RowCount.Should().Be(5); } + [Fact] + public async Task StatementReconciliationService_ValidatesAndIngestsFlexSourceKind() + { + var service = new StatementReconciliationService(); + var path = WriteFlexFile(SampleFlexXml); + + // Validation must accept the flex source kind instead of rejecting it or applying + // CSV header checks, even when a mapping profile is supplied. + var message = await service.ValidateAsync("ib-flex", path, StatementMappingProfileRegistry.IbFlexV1ProfileId, CancellationToken.None); + message.Should().Contain("ib-flex"); + + var import = await service.ImportAsync("IBKR", path, StatementMappingProfileRegistry.IbFlexV1ProfileId, CancellationToken.None); + import.SourceKind.Should().Be("ib-flex"); + import.RowCount.Should().Be(5); + import.SourceRows.Should().HaveCount(5); + import.SourceRows[0].RawSnapshot.Should().ContainKey("symbol"); + } + + [Fact] + public async Task StatementReconciliationService_RejectsNonFlexXmlForFlexSourceKind() + { + var service = new StatementReconciliationService(); + var path = WriteFlexFile("", "other.xml"); + + var validate = async () => await service.ValidateAsync("ib-flex", path, null, CancellationToken.None); + + await validate.Should().ThrowAsync().WithMessage("*not an IB Flex Query report*"); + } + + [Fact] + public async Task StatementRunWorkflow_ImportsFlexStatementEndToEnd() + { + var workflow = new StatementRunWorkflowService( + _store, + new JsonReconciliationCaseStore(_tempDir), + new JsonReconciliationBreakStore(_tempDir), + new RoutingBrokerStatementService(new CsvBrokerStatementService(_store), _service), + new StatementReconciliationContextAdapter(new StatementReconciliationService())); + var path = WriteFlexFile(SampleFlexXml); + var request = new StatementRunRequest( + Broker: "ib-flex", + SourceInstitution: "Interactive Brokers", + FundAccountId: "FUND-1", + ExternalAccountId: "U1234567", + StatementPeriodStart: new DateOnly(2026, 6, 1), + StatementPeriodEnd: new DateOnly(2026, 6, 30), + SourcePath: path, + OriginalFileName: "flex-report.xml", + MappingProfileId: StatementMappingProfileRegistry.IbFlexV1ProfileId, + ToleranceProfileId: "statement-default", + ImportedBy: "test", + SourceFileHash: string.Empty); + + var result = await workflow.CreateAsync(request); + + result.Import.NormalizedRowCount.Should().Be(5); + result.Import.Broker.Should().Be("ib-flex"); + } + [Fact] public void MappingProfileRegistry_RegistersIbFlexProfile() { From 699b18113be12c90b97f7fc8340c178f26534f8d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 21:10:36 +0000 Subject: [PATCH 07/13] fix: route generic-broker XML statement runs through Flex validation A statement run with Broker='broker' and a Flex .xml file was rejected by CSV header validation before the import router's extension fallback could select the Flex parser. StatementReconciliationService now mirrors the router: canonical broker/custodian kinds with an .xml source validate and ingest as Flex. Adds validation-service and end-to-end workflow regression tests for the generic-broker XML path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011vonQATmobZwPb2KcouHnC --- .../StatementReconciliationService.cs | 14 ++++-- .../IbFlexStatementServiceTests.cs | 44 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/Meridian.FinancialOperations/Reconciliation/StatementReconciliationService.cs b/src/Meridian.FinancialOperations/Reconciliation/StatementReconciliationService.cs index 8abcf42a7f..7295d16fd5 100644 --- a/src/Meridian.FinancialOperations/Reconciliation/StatementReconciliationService.cs +++ b/src/Meridian.FinancialOperations/Reconciliation/StatementReconciliationService.cs @@ -22,7 +22,7 @@ public Task ValidateAsync(string sourceKind, string sourcePath, string? ct.ThrowIfCancellationRequested(); var normalizedSourceKind = ValidateSourceAccess(sourceKind, sourcePath); var profileId = mappingProfileId; - if (IsIbFlexSourceKind(normalizedSourceKind)) + if (UsesFlexProcessing(normalizedSourceKind, sourcePath)) { // Flex reports are XML; the canonical CSV header check does not apply. Validate the // document shape instead so the workflow rejects non-Flex files before import. @@ -48,7 +48,7 @@ public async Task ImportAsync(string sourceKind { var normalizedSourceKind = ValidateSourceAccess(sourceKind, sourcePath); ct.ThrowIfCancellationRequested(); - if (IsIbFlexSourceKind(normalizedSourceKind)) + if (UsesFlexProcessing(normalizedSourceKind, sourcePath)) { return await ReadFlexStatementImportAsync(normalizedSourceKind, sourcePath, ct).ConfigureAwait(false); } @@ -133,6 +133,14 @@ private static string ValidateSourceAccess(string sourceKind, string sourcePath) private static bool IsIbFlexSourceKind(string normalizedSourceKind) => normalizedSourceKind is "ib-flex" or "ibflex" or "ibkr" or "interactive-brokers" or "interactivebrokers"; + // Mirrors RoutingBrokerStatementService: an explicit Flex source kind always uses Flex + // processing, and a canonical broker/custodian kind with an .xml file routes to Flex too, + // so the workflow's validation stage agrees with where the import router will send the file. + private static bool UsesFlexProcessing(string normalizedSourceKind, string sourcePath) => + IsIbFlexSourceKind(normalizedSourceKind) + || (RequiresCanonicalStatementSchema(normalizedSourceKind) + && string.Equals(Path.GetExtension(sourcePath), ".xml", StringComparison.OrdinalIgnoreCase)); + /// /// Validates that is a well-formed IB Flex Query document /// (root element FlexQueryResponse) without loading the full report. @@ -223,7 +231,7 @@ private ExternalStatementCaseIntakeResult CreateExternalStatementCases(string no // Flex XML never parses through the canonical CSV path, regardless of any mapping // profile the caller selected; case intake for Flex runs through the workflow's // Flex-aware broker statement importer instead. - if (IsIbFlexSourceKind(normalizedSourceKind) || !UsesCanonicalSchema(normalizedSourceKind, mappingProfileId)) + if (UsesFlexProcessing(normalizedSourceKind, sourcePath) || !UsesCanonicalSchema(normalizedSourceKind, mappingProfileId)) { var content = File.ReadAllText(sourcePath); var importId = DeterministicFingerprint.Compute($"{normalizedSourceKind}|{sourcePath}|{content}"); diff --git a/tests/Meridian.Tests/Reconciliation/IbFlexStatementServiceTests.cs b/tests/Meridian.Tests/Reconciliation/IbFlexStatementServiceTests.cs index 1a4e6f9516..cbfaf052a5 100644 --- a/tests/Meridian.Tests/Reconciliation/IbFlexStatementServiceTests.cs +++ b/tests/Meridian.Tests/Reconciliation/IbFlexStatementServiceTests.cs @@ -303,6 +303,50 @@ public async Task StatementRunWorkflow_ImportsFlexStatementEndToEnd() result.Import.Broker.Should().Be("ib-flex"); } + [Fact] + public async Task StatementReconciliationService_RoutesGenericBrokerXmlThroughFlexValidation() + { + var service = new StatementReconciliationService(); + var path = WriteFlexFile(SampleFlexXml); + + // A canonical 'broker' kind with a Flex .xml file must validate as Flex, matching where + // RoutingBrokerStatementService will send the import — not fail CSV header checks. + var message = await service.ValidateAsync("broker", path, null, CancellationToken.None); + message.Should().Contain("broker"); + + var import = await service.ImportAsync("broker", path, null, CancellationToken.None); + import.RowCount.Should().Be(5); + } + + [Fact] + public async Task StatementRunWorkflow_ImportsFlexStatementWithGenericBrokerKind() + { + var workflow = new StatementRunWorkflowService( + _store, + new JsonReconciliationCaseStore(_tempDir), + new JsonReconciliationBreakStore(_tempDir), + new RoutingBrokerStatementService(new CsvBrokerStatementService(_store), _service), + new StatementReconciliationContextAdapter(new StatementReconciliationService())); + var path = WriteFlexFile(SampleFlexXml, "generic-broker-report.xml"); + var request = new StatementRunRequest( + Broker: "broker", + SourceInstitution: "Interactive Brokers", + FundAccountId: "FUND-2", + ExternalAccountId: "U1234567", + StatementPeriodStart: new DateOnly(2026, 6, 1), + StatementPeriodEnd: new DateOnly(2026, 6, 30), + SourcePath: path, + OriginalFileName: "generic-broker-report.xml", + MappingProfileId: string.Empty, + ToleranceProfileId: "statement-default", + ImportedBy: "test", + SourceFileHash: string.Empty); + + var result = await workflow.CreateAsync(request); + + result.Import.NormalizedRowCount.Should().Be(5); + } + [Fact] public void MappingProfileRegistry_RegistersIbFlexProfile() { From a5af62ccbd7481692b65e24af1934b652c85fd5e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 21:14:31 +0000 Subject: [PATCH 08/13] style: apply dotnet format whitespace to new test files Satisfies the format gate introduced on main: indents the expression-bodied object initializers in SessionTcaReporterTests and DataReplacementCostEstimatorTests the way dotnet format expects. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011vonQATmobZwPb2KcouHnC --- .../Execution/SessionTcaReporterTests.cs | 46 +++++++++---------- .../DataReplacementCostEstimatorTests.cs | 22 ++++----- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/tests/Meridian.Tests/Execution/SessionTcaReporterTests.cs b/tests/Meridian.Tests/Execution/SessionTcaReporterTests.cs index f01c257f7c..eb620aa7fe 100644 --- a/tests/Meridian.Tests/Execution/SessionTcaReporterTests.cs +++ b/tests/Meridian.Tests/Execution/SessionTcaReporterTests.cs @@ -17,18 +17,18 @@ private static ExecutionReport MakeFill( DateTimeOffset? timestamp = null, ExecutionReportType reportType = ExecutionReportType.Fill, decimal? orderQuantity = null) => new() - { - OrderId = orderId ?? $"order-{Guid.NewGuid():N}", - ReportType = reportType, - Symbol = symbol, - Side = side, - OrderStatus = OrderStatus.Filled, - OrderQuantity = orderQuantity ?? quantity, - FilledQuantity = quantity, - FillPrice = price, - Commission = commission, - Timestamp = timestamp ?? DateTimeOffset.UtcNow - }; + { + OrderId = orderId ?? $"order-{Guid.NewGuid():N}", + ReportType = reportType, + Symbol = symbol, + Side = side, + OrderStatus = OrderStatus.Filled, + OrderQuantity = orderQuantity ?? quantity, + FilledQuantity = quantity, + FillPrice = price, + Commission = commission, + Timestamp = timestamp ?? DateTimeOffset.UtcNow + }; private static OrderState MakeOrder( string orderId, @@ -37,17 +37,17 @@ private static OrderState MakeOrder( decimal quantity, decimal? limitPrice = null, DateTimeOffset? createdAt = null) => new() - { - OrderId = orderId, - Symbol = symbol, - Side = side, - Type = limitPrice.HasValue ? OrderType.Limit : OrderType.Market, - Quantity = quantity, - FilledQuantity = quantity, - LimitPrice = limitPrice, - Status = OrderStatus.Filled, - CreatedAt = createdAt ?? DateTimeOffset.UtcNow - }; + { + OrderId = orderId, + Symbol = symbol, + Side = side, + Type = limitPrice.HasValue ? OrderType.Limit : OrderType.Market, + Quantity = quantity, + FilledQuantity = quantity, + LimitPrice = limitPrice, + Status = OrderStatus.Filled, + CreatedAt = createdAt ?? DateTimeOffset.UtcNow + }; [Fact] public void Generate_EmptyFills_ReturnsZeroedReport() diff --git a/tests/Meridian.Tests/Storage/DataReplacementCostEstimatorTests.cs b/tests/Meridian.Tests/Storage/DataReplacementCostEstimatorTests.cs index 1e707ddef7..4787de5af3 100644 --- a/tests/Meridian.Tests/Storage/DataReplacementCostEstimatorTests.cs +++ b/tests/Meridian.Tests/Storage/DataReplacementCostEstimatorTests.cs @@ -12,18 +12,18 @@ private static SymbolCatalogEntry MakeSymbol( int tradingDays, string[] eventTypes, long eventCount = 1_000) => new() - { - Symbol = symbol, - EventCount = eventCount, - EventTypes = eventTypes, - DateRange = new CatalogDateRange { - Earliest = new DateTime(2026, 1, 1), - Latest = new DateTime(2026, 3, 31), - CalendarDays = 90, - TradingDays = tradingDays - } - }; + Symbol = symbol, + EventCount = eventCount, + EventTypes = eventTypes, + DateRange = new CatalogDateRange + { + Earliest = new DateTime(2026, 1, 1), + Latest = new DateTime(2026, 3, 31), + CalendarDays = 90, + TradingDays = tradingDays + } + }; private static StorageCatalog MakeCatalog( long totalBytesRaw = 0, From 19c61a0aeea61768dbb51723cf118c857448dbfe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 21:25:45 +0000 Subject: [PATCH 09/13] fix: match Flex rows in case intake and preserve fee activity types Addresses two review findings on the IB Flex connector: - Case intake (CLI/orchestrator path) no longer short-circuits Flex files to zero rows/cases: Flex sections are read into normalized statement rows and matched with the same engine as canonical CSV, so --statement-reconcile reports real match/unresolved counts. - Fee-like CashTransaction types ('Other Fees' etc.) map to the canonical 'fee' activity instead of being hard-coded to 'cash', so fee rows get fee tolerance/break handling in matching. Dividends, deposits, interest, and withholding tax stay 'cash'. Adds regression tests for both paths (356 reconciliation-area tests pass). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011vonQATmobZwPb2KcouHnC --- .../StatementReconciliationService.cs | 134 +++++++++++++++++- .../Reconciliation/IbFlexStatementService.cs | 15 +- .../IbFlexStatementServiceTests.cs | 14 ++ 3 files changed, 158 insertions(+), 5 deletions(-) diff --git a/src/Meridian.FinancialOperations/Reconciliation/StatementReconciliationService.cs b/src/Meridian.FinancialOperations/Reconciliation/StatementReconciliationService.cs index 7295d16fd5..5cc5e6f4ee 100644 --- a/src/Meridian.FinancialOperations/Reconciliation/StatementReconciliationService.cs +++ b/src/Meridian.FinancialOperations/Reconciliation/StatementReconciliationService.cs @@ -170,6 +170,119 @@ private static void ValidateFlexDocument(string sourcePath) } } + /// + /// Reads an IB Flex report into normalized statement rows (one per Trade, OpenPosition, + /// and CashTransaction element) so case intake matches Flex statements with the same + /// engine as canonical CSV rows. Fee-like cash transactions keep fee row semantics via + /// . + /// + private static IReadOnlyList ReadFlexStatementRows( + string importId, + string normalizedSourceKind, + string sourcePath, + string content) + { + var document = System.Xml.Linq.XDocument.Parse(content); + var rows = new List(); + var rowNumber = 0; + + foreach (var statement in document.Descendants("FlexStatement")) + { + var statementToDate = Infrastructure.Reconciliation.IbFlexBrokerStatementService + .ParseFlexDate((string?)statement.Attribute("toDate")); + + foreach (var element in statement.Descendants() + .Where(static e => e.Name.LocalName is "Trade" or "OpenPosition" or "CashTransaction")) + { + rowNumber++; + var (activityType, quantity, amount, date) = element.Name.LocalName switch + { + "Trade" => ( + "trade", + FlexDecimal(element, "quantity"), + FlexFirstDecimal(element, "netCash", "proceeds") is var cash && cash != 0m + ? cash + : FlexDecimal(element, "quantity") * FlexDecimal(element, "tradePrice"), + FlexFirstDate(element, ["tradeDate"], statementToDate)), + "OpenPosition" => ( + "position", + FlexDecimal(element, "position"), + FlexFirstDecimal(element, "positionValue") is var value && value != 0m + ? value + : FlexDecimal(element, "position") * FlexFirstDecimal(element, "markPrice", "costBasisPrice"), + FlexFirstDate(element, ["reportDate"], statementToDate)), + _ => ( + Infrastructure.Reconciliation.IbFlexBrokerStatementService + .MapCashTransactionActivity((string?)element.Attribute("type")), + 0m, + FlexDecimal(element, "amount"), + FlexFirstDate(element, ["dateTime", "reportDate", "settleDate"], statementToDate)) + }; + + var snapshot = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["importId"] = importId, + ["sourceKind"] = normalizedSourceKind, + ["sourcePath"] = sourcePath, + ["elementName"] = element.Name.LocalName, + ["activityType"] = activityType, + ["rowNumber"] = rowNumber.ToString(CultureInfo.InvariantCulture) + }; + foreach (var attribute in element.Attributes()) + { + snapshot[attribute.Name.LocalName] = attribute.Value; + } + + var elementText = element.ToString(System.Xml.Linq.SaveOptions.DisableFormatting); + rows.Add(new NormalizedStatementRow( + $"{importId}:{rowNumber}", + ToStatementRowKind(activityType), + (string?)element.Attribute("symbol") ?? string.Empty, + quantity, + amount, + new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero), + (string?)element.Attribute("currency") ?? "USD", + DeterministicFingerprint.Compute($"{importId}|{rowNumber}|{elementText}"), + snapshot)); + } + } + + return rows; + } + + private static decimal FlexDecimal(System.Xml.Linq.XElement element, string attribute) + { + var raw = (string?)element.Attribute(attribute); + return string.IsNullOrWhiteSpace(raw) + ? 0m + : decimal.Parse(raw, NumberStyles.Number, CultureInfo.InvariantCulture); + } + + private static decimal FlexFirstDecimal(System.Xml.Linq.XElement element, params string[] attributes) + { + foreach (var attribute in attributes) + { + var raw = (string?)element.Attribute(attribute); + if (!string.IsNullOrWhiteSpace(raw)) + return decimal.Parse(raw, NumberStyles.Number, CultureInfo.InvariantCulture); + } + + return 0m; + } + + private static DateOnly FlexFirstDate(System.Xml.Linq.XElement element, string[] attributes, DateOnly? fallback) + { + foreach (var attribute in attributes) + { + if (Infrastructure.Reconciliation.IbFlexBrokerStatementService + .ParseFlexDate((string?)element.Attribute(attribute)) is { } parsed) + return parsed; + } + + return fallback ?? throw new InvalidDataException( + "Flex row has no parseable date attribute and the statement has no toDate fallback."); + } + /// /// Reads an IB Flex report into source-row references (one per Trade, OpenPosition, and /// CashTransaction element, with the element's attributes as the raw snapshot) so the @@ -229,9 +342,24 @@ private static bool UsesCanonicalSchema(string normalizedSourceKind, string? map private ExternalStatementCaseIntakeResult CreateExternalStatementCases(string normalizedSourceKind, string sourcePath, string? mappingProfileId = null) { // Flex XML never parses through the canonical CSV path, regardless of any mapping - // profile the caller selected; case intake for Flex runs through the workflow's - // Flex-aware broker statement importer instead. - if (UsesFlexProcessing(normalizedSourceKind, sourcePath) || !UsesCanonicalSchema(normalizedSourceKind, mappingProfileId)) + // profile the caller selected; its rows are read from the XML sections and matched + // with the same engine so CLI/orchestrator intake reports real match/unresolved counts. + if (UsesFlexProcessing(normalizedSourceKind, sourcePath)) + { + var flexContent = File.ReadAllText(sourcePath); + var flexImportId = DeterministicFingerprint.Compute($"{normalizedSourceKind}|{sourcePath}|{flexContent}"); + var flexRows = ReadFlexStatementRows(flexImportId, normalizedSourceKind, sourcePath, flexContent); + var (flexMatches, flexCases) = MatchRows(flexRows); + return new ExternalStatementCaseIntakeResult( + flexImportId, + normalizedSourceKind, + sourcePath, + flexRows.Count, + flexMatches.Count, + flexCases); + } + + if (!UsesCanonicalSchema(normalizedSourceKind, mappingProfileId)) { var content = File.ReadAllText(sourcePath); var importId = DeterministicFingerprint.Compute($"{normalizedSourceKind}|{sourcePath}|{content}"); diff --git a/src/Meridian.Infrastructure/Reconciliation/IbFlexStatementService.cs b/src/Meridian.Infrastructure/Reconciliation/IbFlexStatementService.cs index 14cf53d04c..f3e63ecb2e 100644 --- a/src/Meridian.Infrastructure/Reconciliation/IbFlexStatementService.cs +++ b/src/Meridian.Infrastructure/Reconciliation/IbFlexStatementService.cs @@ -198,7 +198,7 @@ private static IEnumerable ParseRows(XDocument document, 0m, 0m, ParseDecimal(cash, "amount"), - "cash", + MapCashTransactionActivity((string?)cash.Attribute("type")), RequireFirstDate(cash, ["dateTime", "reportDate", "settleDate"], statementToDate, rowNumber), HashElement(cash)); } @@ -208,6 +208,17 @@ private static IEnumerable ParseRows(XDocument document, private static string Account(XElement element, string statementAccount) => (string?)element.Attribute("accountId") is { Length: > 0 } account ? account : statementAccount; + /// + /// Maps a Flex CashTransaction type to the canonical activity type so downstream + /// matching applies fee handling to fee-like rows ("Other Fees", "Advisor Fees", …) instead + /// of cash tolerance rules. All other cash transaction types (dividends, deposits, + /// withholding tax, interest) stay canonical cash. + /// + public static string MapCashTransactionActivity(string? flexType) => + flexType is not null && flexType.Contains("fee", StringComparison.OrdinalIgnoreCase) + ? "fee" + : "cash"; + private static decimal ParseDecimal(XElement element, string attribute) { var raw = (string?)element.Attribute(attribute); @@ -255,7 +266,7 @@ private static DateOnly RequireFirstDate( /// yyyyMMdd, yyyy-MM-dd, and datetime variants with a ;HHmmss or /// HH:mm:ss suffix (only the date part is kept). /// - private static DateOnly? ParseFlexDate(string? raw) + public static DateOnly? ParseFlexDate(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return null; diff --git a/tests/Meridian.Tests/Reconciliation/IbFlexStatementServiceTests.cs b/tests/Meridian.Tests/Reconciliation/IbFlexStatementServiceTests.cs index cbfaf052a5..285cb867cd 100644 --- a/tests/Meridian.Tests/Reconciliation/IbFlexStatementServiceTests.cs +++ b/tests/Meridian.Tests/Reconciliation/IbFlexStatementServiceTests.cs @@ -148,6 +148,7 @@ public async Task Import_MapsTradesPositionsAndCashToCanonicalRows() dividend.TradeDate.Should().Be(new DateOnly(2026, 6, 10)); // date part of dateTime var fee = result.Rows[4]; + fee.ActivityType.Should().Be("fee"); // "Other Fees" keeps fee semantics fee.CashAmount.Should().Be(-3.50m); fee.TradeDate.Should().Be(new DateOnly(2026, 6, 30)); // reportDate fallback } @@ -262,6 +263,19 @@ public async Task StatementReconciliationService_ValidatesAndIngestsFlexSourceKi import.SourceRows[0].RawSnapshot.Should().ContainKey("symbol"); } + [Fact] + public async Task StatementReconciliationService_CaseIntakeMatchesFlexRows() + { + var service = new StatementReconciliationService(); + var path = WriteFlexFile(SampleFlexXml); + + var intake = await service.CreateExternalStatementCasesAsync("ib-flex", path, CancellationToken.None); + + // Intake must process real Flex rows, not short-circuit to zero counts. + intake.RowCount.Should().Be(5); + (intake.MatchCount + intake.Cases.Count).Should().BeGreaterThan(0); + } + [Fact] public async Task StatementReconciliationService_RejectsNonFlexXmlForFlexSourceKind() { From 991f122c9c5c7f36ea9e0e010b40a420c72215a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 21:47:57 +0000 Subject: [PATCH 10/13] feat: add DuckDB SQL query workbench for the local data store Implements the DuckDB analytics workbench from the brainstorm (idea #5): - DuckDbQueryService (Meridian.Storage): read-only SQL over the local Parquet/JSONL store via an in-memory DuckDB session per query, with a meridian_files catalog view (symbol, event type, date, format, absolute path, size, event count) for discovery. Session memory and thread limits keep analytics off the ingestion hot path. - SqlStatementGuard: single SELECT-family statement enforcement plus a write/config keyword blocklist (COPY/EXPORT/ATTACH/INSTALL/CREATE/ SET/...), row cap with truncation flag, and query timeout. - POST /api/workstation/data/query endpoint (rate-limited; guard and SQL errors returned in-band as success=false payloads) with options bound from the DataQuery configuration section. - Data screen 'SQL query' panel: statement editor, run button, row count/elapsed status, inline error banner, and results table, with a dedicated view-model hook and vitest coverage. - DuckDB.NET.Data.Full added via central package management. Also addresses two review findings: Flex imports with no supported rows now fail loudly instead of persisting a clean zero-row run, and the DataReplacementCost/DataQuery options bindings moved into the shared workstation service registration so every endpoint host honors operator configuration. 36 new .NET tests (guard + service including catalog view, truncation, escaping) and 4 vitest tests; affected suites pass (641 .NET, dashboard suite green) and the dashboard builds. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011vonQATmobZwPb2KcouHnC --- Directory.Packages.props | 1 + docs/generated/repository-structure.md | 5 + docs/status/TODO.md | 18 +- .../api-contract-coverage-dashboard.json | 8 +- .../status/api-contract-coverage-dashboard.md | 8 +- docs/status/coverage-report.md | 10 +- docs/status/doc-health-dashboard.json | 4 +- docs/status/doc-health-dashboard.md | 2 +- .../Features/StorageFeatureRegistration.cs | 4 + src/Meridian.Contracts/Api/UiApiRoutes.cs | 1 + .../Reconciliation/IbFlexStatementService.cs | 8 + src/Meridian.Storage/Meridian.Storage.csproj | 1 + .../Query/DuckDbQueryService.cs | 262 ++++++++++++++++++ .../Endpoints/WorkstationEndpoints.cs | 32 +++ .../WorkstationServiceCollectionExtensions.cs | 8 + src/Meridian.Ui/dashboard/src/lib/api.ts | 6 + .../src/lib/ui-api-routes.generated.ts | 2 + .../src/lib/workstation-endpoints.ts | 1 + ...data-screen.query-panel.view-model.test.ts | 76 +++++ .../data-screen.query-panel.view-model.ts | 49 ++++ .../dashboard/src/screens/data-screen.tsx | 63 +++++ src/Meridian.Ui/dashboard/src/types.ts | 15 + src/Meridian/UiServer.cs | 2 - .../IbFlexStatementServiceTests.cs | 17 ++ .../Storage/DuckDbQueryServiceTests.cs | 217 +++++++++++++++ 25 files changed, 793 insertions(+), 27 deletions(-) create mode 100644 src/Meridian.Storage/Query/DuckDbQueryService.cs create mode 100644 src/Meridian.Ui/dashboard/src/screens/data-screen.query-panel.view-model.test.ts create mode 100644 src/Meridian.Ui/dashboard/src/screens/data-screen.query-panel.view-model.ts create mode 100644 tests/Meridian.Tests/Storage/DuckDbQueryServiceTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 9bee5686aa..cf407d5686 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -133,6 +133,7 @@ + diff --git a/docs/generated/repository-structure.md b/docs/generated/repository-structure.md index bf98a41670..f829d9887b 100644 --- a/docs/generated/repository-structure.md +++ b/docs/generated/repository-structure.md @@ -4485,6 +4485,8 @@ Meridian-main │ │ │ └── PortableDataPackager.Validation.cs │ │ ├── Policies │ │ │ └── JsonlStoragePolicy.cs +│ │ ├── Query +│ │ │ └── DuckDbQueryService.cs │ │ ├── Replay │ │ │ ├── JsonlReplayer.cs │ │ │ └── MemoryMappedJsonlReader.cs @@ -5021,6 +5023,8 @@ Meridian-main │ │ │ │ │ ├── data-screen.evidence-timeline.ts │ │ │ │ │ ├── data-screen.linked-context.ts │ │ │ │ │ ├── data-screen.operator-focus.ts +│ │ │ │ │ ├── data-screen.query-panel.view-model.test.ts +│ │ │ │ │ ├── data-screen.query-panel.view-model.ts │ │ │ │ │ ├── data-screen.security-master.ts │ │ │ │ │ ├── data-screen.test.tsx │ │ │ │ │ ├── data-screen.tsx @@ -7045,6 +7049,7 @@ Meridian-main │ │ │ ├── DataReplacementCostEstimatorTests.cs │ │ │ ├── DataValidatorTests.cs │ │ │ ├── DirectLendingMigrationTests.cs +│ │ │ ├── DuckDbQueryServiceTests.cs │ │ │ ├── EventBufferTests.cs │ │ │ ├── ExportValidatorTests.cs │ │ │ ├── FilePermissionsServiceTests.cs diff --git a/docs/status/TODO.md b/docs/status/TODO.md index 8ac48d2a61..0b83773efe 100644 --- a/docs/status/TODO.md +++ b/docs/status/TODO.md @@ -43,11 +43,11 @@ Total items: **200** | `src/Meridian.Ui.Services/Services/ProviderHealthService.cs` | 530 | `NOTE` | ❌ | // NOTE: ProviderComparison is defined in AdvancedAnalyticsModels.cs for cross-provider comparison | | `src/Meridian.Ui.Shared/Endpoints/ArchiveMaintenanceEndpoints.cs` | 32 | `NOTE` | ❌ | // NOTE: GET /schedules, GET /schedules/{id}, POST /schedules, POST /schedules/{id}/enable, | | `src/Meridian.Ui.Shared/Endpoints/ArchiveMaintenanceEndpoints.cs` | 117 | `NOTE` | ❌ | // NOTE: POST /schedules/{id}/enable and POST /schedules/{id}/disable are registered | -| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4300 | `NOTE` | ❌ | note: "Paper adapter routing is available.", | -| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4312 | `NOTE` | ❌ | note: "Realtime subscriptions are steady.", | -| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4324 | `NOTE` | ❌ | note: "Replay queue is elevated but within tolerance.", | -| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4436 | `NOTE` | ❌ | Note: note, | -| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4519 | `NOTE` | ❌ | Note: note, | +| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4332 | `NOTE` | ❌ | note: "Paper adapter routing is available.", | +| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4344 | `NOTE` | ❌ | note: "Realtime subscriptions are steady.", | +| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4356 | `NOTE` | ❌ | note: "Replay queue is elevated but within tolerance.", | +| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4468 | `NOTE` | ❌ | Note: note, | +| `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs` | 4551 | `NOTE` | ❌ | Note: note, | | `src/Meridian.Ui.Shared/Services/ProviderLedgerReconciliationService.cs` | 1974 | `NOTE` | ❌ | Note: "Provider-ledger reconciliation break signed off.", | | `src/Meridian.Ui.Shared/Services/ReportPackDeliveryService.cs` | 240 | `NOTE` | ❌ | Note: NormalizeNullable(target.Note) ?? $"Scheduled delivery for {normalizedTemplateId}.", | | `src/Meridian.Ui.Shared/Services/ReportPackRunReadService.cs` | 2047 | `NOTE` | ❌ | Note: NormalizeOptional(target.Note), | @@ -174,10 +174,10 @@ Total items: **200** | `src/Meridian.Ui/dashboard/src/screens/w4-acceptance-parity.test.ts` | 429 | `NOTE` | ❌ | note: "Close evidence reviewed." | | `src/Meridian.Ui/dashboard/src/screens/w4-acceptance-parity.test.ts` | 437 | `NOTE` | ❌ | note: "Published to investor portal." | | `src/Meridian.Ui/dashboard/src/types.ts` | 3924 | `NOTE` | ❌ | note: string; | -| `src/Meridian.Ui/dashboard/src/types.ts` | 4895 | `NOTE` | ❌ | note: string \| null; | -| `src/Meridian.Ui/dashboard/src/types.ts` | 5248 | `NOTE` | ❌ | note: string \| null; | -| `src/Meridian.Ui/dashboard/src/types.ts` | 5297 | `NOTE` | ❌ | note: string \| null; | -| `src/Meridian.Ui/dashboard/src/types.ts` | 5494 | `NOTE` | ❌ | note: string \| null; | +| `src/Meridian.Ui/dashboard/src/types.ts` | 4910 | `NOTE` | ❌ | note: string \| null; | +| `src/Meridian.Ui/dashboard/src/types.ts` | 5263 | `NOTE` | ❌ | note: string \| null; | +| `src/Meridian.Ui/dashboard/src/types.ts` | 5312 | `NOTE` | ❌ | note: string \| null; | +| `src/Meridian.Ui/dashboard/src/types.ts` | 5509 | `NOTE` | ❌ | note: string \| null; | | `src/Meridian.Wpf/GlobalUsings.cs` | 7 | `NOTE` | ❌ | // NOTE: Type aliases and Contracts namespaces are NOT re-defined here because | | `src/Meridian.Wpf/ViewModels/SecurityPassportEditorViewModel.cs` | 262 | `NOTE` | ❌ | Note: null, | | `tests/Meridian.Tests/Application/Backfill/BackfillWorkerServiceTests.cs` | 28 | `NOTE` | ❌ | // NOTE: Using null! because validation throws before dependencies are accessed | diff --git a/docs/status/api-contract-coverage-dashboard.json b/docs/status/api-contract-coverage-dashboard.json index 972f168c9b..0d2c7710fd 100644 --- a/docs/status/api-contract-coverage-dashboard.json +++ b/docs/status/api-contract-coverage-dashboard.json @@ -17,13 +17,13 @@ { "method": "GET", "path": "/api/system/lifecycle", - "source": "src/Meridian/UiServer.cs:351", + "source": "src/Meridian/UiServer.cs:349", "documented": true }, { "method": "POST", "path": "/api/system/shutdown", - "source": "src/Meridian/UiServer.cs:373", + "source": "src/Meridian/UiServer.cs:371", "documented": true }, { @@ -3533,13 +3533,13 @@ { "method": "GET", "path": "/api/strategies/{strategyId}/runs", - "source": "src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs:2544", + "source": "src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs:2576", "documented": true }, { "method": "GET", "path": "/api/strategies/runs/compare", - "source": "src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs:2665", + "source": "src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs:2697", "documented": true } ], diff --git a/docs/status/api-contract-coverage-dashboard.md b/docs/status/api-contract-coverage-dashboard.md index b38c032eaa..1024f01265 100644 --- a/docs/status/api-contract-coverage-dashboard.md +++ b/docs/status/api-contract-coverage-dashboard.md @@ -579,8 +579,8 @@ Tracks whether mapped API routes and workstation DTO contracts are visible in th | `POST` | `/api/strategies/covered-call/runs/{runId}/cancel` | Documented | `src/Meridian.Ui.Shared/Endpoints/CoveredCallEndpoints.cs:135` | | `GET` | `/api/strategies/covered-call/runs/{runId}/result` | Documented | `src/Meridian.Ui.Shared/Endpoints/CoveredCallEndpoints.cs:93` | | `GET` | `/api/strategies/covered-call/runs/{runId}/status` | Documented | `src/Meridian.Ui.Shared/Endpoints/CoveredCallEndpoints.cs:70` | -| `GET` | `/api/strategies/runs/compare` | Documented | `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs:2665` | -| `GET` | `/api/strategies/{strategyId}/runs` | Documented | `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs:2544` | +| `GET` | `/api/strategies/runs/compare` | Documented | `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs:2697` | +| `GET` | `/api/strategies/{strategyId}/runs` | Documented | `src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs:2576` | | `GET` | `/api/subscriptions/active` | Documented | `src/Meridian.Ui.Shared/Endpoints/SubscriptionEndpoints.cs:21` | | `POST` | `/api/subscriptions/subscribe` | Documented | `src/Meridian.Ui.Shared/Endpoints/SubscriptionEndpoints.cs:43` | | `POST` | `/api/subscriptions/unsubscribe/{symbol}` | Documented | `src/Meridian.Ui.Shared/Endpoints/SubscriptionEndpoints.cs:72` | @@ -604,8 +604,8 @@ Tracks whether mapped API routes and workstation DTO contracts are visible in th | `GET` | `/api/symbols/{symbol}/status` | Documented | `src/Meridian.Ui.Shared/Endpoints/SymbolEndpoints.cs:103` | | `GET` | `/api/symbols/{symbol}/trades` | Documented | `src/Meridian.Ui.Shared/Endpoints/SymbolEndpoints.cs:187` | | `POST` | `/api/symbols/{symbol}/update` | Documented | `src/Meridian.Ui.Shared/Endpoints/SymbolEndpoints.cs:480` | -| `GET` | `/api/system/lifecycle` | Documented | `src/Meridian/UiServer.cs:351` | -| `POST` | `/api/system/shutdown` | Documented | `src/Meridian/UiServer.cs:373` | +| `GET` | `/api/system/lifecycle` | Documented | `src/Meridian/UiServer.cs:349` | +| `POST` | `/api/system/shutdown` | Documented | `src/Meridian/UiServer.cs:371` | | `GET` | `/health` | Documented | `src/Meridian.Application/Composition/HostAdapters.cs:60` | | `GET` | `/live` | Documented | `src/Meridian.Application/Composition/HostAdapters.cs:73` | | `GET` | `/ready` | Documented | `src/Meridian.Application/Composition/HostAdapters.cs:72` | diff --git a/docs/status/coverage-report.md b/docs/status/coverage-report.md index b841259636..d88c519907 100644 --- a/docs/status/coverage-report.md +++ b/docs/status/coverage-report.md @@ -5,7 +5,7 @@ ## Overall Coverage -**2601 / 7253** items documented (**35.9%**) — Grade: **F** +**2602 / 7258** items documented (**35.9%**) — Grade: **F** ```text [=======-------------] 35.9% @@ -15,7 +15,7 @@ | Category | Documented | Total | Coverage | Grade | | ---------- | ----------- | ------- | ---------- | ------- | -| Public Classes / Interfaces | 2480 | 6757 | 36.7% | F | +| Public Classes / Interfaces | 2481 | 6762 | 36.7% | F | | API Endpoints | 107 | 349 | 30.7% | F | | Configuration Options | 3 | 136 | 2.2% | F | | Provider Implementations | 0 | 0 | 100.0% | A | @@ -23,7 +23,7 @@ ## Undocumented Items -### Public Classes / Interfaces (4277 undocumented) +### Public Classes / Interfaces (4281 undocumented) | Item | Location | | ------ | ---------- | @@ -77,7 +77,7 @@ | `SecurityMasterActiveImportStatus` | `src/Meridian.Application/SecurityMaster/SecurityMasterIngestStatusService.cs:13` | | `SecurityMasterCompletedImportStatus` | `src/Meridian.Application/SecurityMaster/SecurityMasterIngestStatusService.cs:26` | | `ISecurityMasterIngestStatusService` | `src/Meridian.Application/SecurityMaster/SecurityMasterIngestStatusService.cs:41` | -| ... and 4227 more | | +| ... and 4231 more | | ### API Endpoints (242 undocumented) @@ -193,7 +193,7 @@ ## Recommendations -1. **Public Classes / Interfaces**: 4277 undocumented types. Consider generating API docs with DocFX (`docfx docfx.json`) to cover the long tail of public types automatically. +1. **Public Classes / Interfaces**: 4281 undocumented types. Consider generating API docs with DocFX (`docfx docfx.json`) to cover the long tail of public types automatically. 2. **API Endpoints**: 242 endpoint(s) missing from `docs/reference/api-reference.md`. Run the endpoint audit and update the API reference table. 3. **Configuration Options**: 133 config key(s) not found in `docs/generated/configuration-schema.md`. Re-run the configuration schema generator to synchronise. diff --git a/docs/status/doc-health-dashboard.json b/docs/status/doc-health-dashboard.json index 200f854cb3..f251df4cc6 100644 --- a/docs/status/doc-health-dashboard.json +++ b/docs/status/doc-health-dashboard.json @@ -1,6 +1,6 @@ { "total_files": 535, - "total_lines": 87653, + "total_lines": 87658, "orphaned_count": 204, "no_heading_count": 38, "stale_count": 0, @@ -2129,7 +2129,7 @@ }, { "path": "docs/generated/repository-structure.md", - "line_count": 7670, + "line_count": 7675, "has_heading": true, "todo_count": 9, "last_modified_utc": "1970-01-01T00:00:00+00:00", diff --git a/docs/status/doc-health-dashboard.md b/docs/status/doc-health-dashboard.md index edc88f1367..534ca34228 100644 --- a/docs/status/doc-health-dashboard.md +++ b/docs/status/doc-health-dashboard.md @@ -20,7 +20,7 @@ Data sources: `repo markdown (*.md)`, `file modification metadata` | Metric | Value | | -------- | ------- | | Total documentation files | 535 | -| Total lines | 87,653 | +| Total lines | 87,658 | | Average file size (lines) | 163.8 | | Orphaned files | 204 | | Files without headings | 38 | diff --git a/src/Meridian.Application/Composition/Features/StorageFeatureRegistration.cs b/src/Meridian.Application/Composition/Features/StorageFeatureRegistration.cs index d5430a17e0..ad55e92264 100644 --- a/src/Meridian.Application/Composition/Features/StorageFeatureRegistration.cs +++ b/src/Meridian.Application/Composition/Features/StorageFeatureRegistration.cs @@ -55,6 +55,7 @@ using Meridian.Storage.Ledger; using Meridian.Storage.Maintenance; using Meridian.Storage.Policies; +using Meridian.Storage.Query; using Meridian.Storage.SecurityMaster; using Meridian.Storage.Services; using Meridian.Workflow.EnvironmentDesign; @@ -164,6 +165,9 @@ public IServiceCollection Register(IServiceCollection services, CompositionOptio }); services.TryAddSingleton(sp => sp.GetRequiredService()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddSingleton(sp => new DuckDbQueryService( + sp.GetRequiredService(), + sp.GetRequiredService())); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Meridian.Contracts/Api/UiApiRoutes.cs b/src/Meridian.Contracts/Api/UiApiRoutes.cs index 707df685e7..fb7b6cf503 100644 --- a/src/Meridian.Contracts/Api/UiApiRoutes.cs +++ b/src/Meridian.Contracts/Api/UiApiRoutes.cs @@ -606,6 +606,7 @@ public static class UiApiRoutes public const string WorkstationTrading = "/api/workstation/trading"; public const string WorkstationDataOperations = "/api/workstation/data-operations"; public const string WorkstationData = "/api/workstation/data"; + public const string WorkstationDataQuery = "/api/workstation/data/query"; public const string WorkstationDataReplacementCost = "/api/workstation/data/replacement-cost"; public const string WorkstationDataUploadTemplates = "/api/workstation/data/uploads/templates"; public const string WorkstationDataUploadPreview = "/api/workstation/data/uploads/preview"; diff --git a/src/Meridian.Infrastructure/Reconciliation/IbFlexStatementService.cs b/src/Meridian.Infrastructure/Reconciliation/IbFlexStatementService.cs index f3e63ecb2e..464bdf3c46 100644 --- a/src/Meridian.Infrastructure/Reconciliation/IbFlexStatementService.cs +++ b/src/Meridian.Infrastructure/Reconciliation/IbFlexStatementService.cs @@ -103,6 +103,14 @@ public async Task ImportAsync( var importId = duplicateKey; var normalizedRequest = request.WithSourceFileHash(sourceFileHash); var rows = ParseRows(document, importId).ToList(); + if (rows.Count == 0) + { + // A Flex query configured without the supported sections must fail loudly instead + // of being recorded as a clean zero-row run with no breaks or cases. + throw new InvalidDataException( + "Flex report contains no Trade, OpenPosition, or CashTransaction rows; " + + "include those sections in the Flex Query definition."); + } var import = new CanonicalStatementImport( importId, diff --git a/src/Meridian.Storage/Meridian.Storage.csproj b/src/Meridian.Storage/Meridian.Storage.csproj index 1647a5b0d7..7702b55104 100644 --- a/src/Meridian.Storage/Meridian.Storage.csproj +++ b/src/Meridian.Storage/Meridian.Storage.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Meridian.Storage/Query/DuckDbQueryService.cs b/src/Meridian.Storage/Query/DuckDbQueryService.cs new file mode 100644 index 0000000000..fb8f071acd --- /dev/null +++ b/src/Meridian.Storage/Query/DuckDbQueryService.cs @@ -0,0 +1,262 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text.RegularExpressions; +using DuckDB.NET.Data; +using Meridian.Storage.Interfaces; +using Microsoft.Extensions.Logging; + +namespace Meridian.Storage.Query; + +/// +/// Read-only SQL analytics over the local market-data store, backed by an in-memory DuckDB +/// session per query. The catalog is exposed as a meridian_files view (symbol, event +/// type, date, format, absolute path, size, event count) so operators can discover files and +/// feed paths into read_parquet/read_json_auto without leaving SQL. Statements +/// are guarded: single SELECT-family statement, keyword blocklist, row cap, and timeout — +/// a runaway query can never touch the ingestion hot path or mutate the store. +/// +public sealed partial class DuckDbQueryService( + IStorageCatalogService catalogService, + StorageOptions storageOptions, + ILogger? logger = null) +{ + /// + /// Executes and returns a bounded, string-projected result set. + /// Guard violations and execution errors are returned as a failed result, not thrown, + /// so the endpoint can map them to a 400 with the message intact. + /// + public async Task ExecuteAsync( + string? sql, + DataQueryOptions options, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(options); + + var guardError = SqlStatementGuard.Validate(sql, options.MaxSqlLength); + if (guardError is not null) + return DataQueryResult.Failed(guardError); + + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutSource.CancelAfter(TimeSpan.FromSeconds(options.TimeoutSeconds)); + + var stopwatch = Stopwatch.StartNew(); + try + { + using var connection = new DuckDBConnection("DataSource=:memory:"); + await connection.OpenAsync(timeoutSource.Token).ConfigureAwait(false); + + await ApplySessionLimitsAsync(connection, options, timeoutSource.Token).ConfigureAwait(false); + await CreateCatalogViewAsync(connection, timeoutSource.Token).ConfigureAwait(false); + + using var command = connection.CreateCommand(); + command.CommandText = sql!; + + using var reader = await command.ExecuteReaderAsync(timeoutSource.Token).ConfigureAwait(false); + + var columns = new string[reader.FieldCount]; + var columnTypes = new string[reader.FieldCount]; + for (var i = 0; i < reader.FieldCount; i++) + { + columns[i] = reader.GetName(i); + columnTypes[i] = reader.GetDataTypeName(i); + } + + var rows = new List(); + var truncated = false; + while (await reader.ReadAsync(timeoutSource.Token).ConfigureAwait(false)) + { + if (rows.Count >= options.MaxRows) + { + truncated = true; + break; + } + + var row = new string?[reader.FieldCount]; + for (var i = 0; i < reader.FieldCount; i++) + { + row[i] = reader.IsDBNull(i) + ? null + : Convert.ToString(reader.GetValue(i), CultureInfo.InvariantCulture); + } + rows.Add(row); + } + + stopwatch.Stop(); + return new DataQueryResult( + Success: true, + Error: null, + Columns: columns, + ColumnTypes: columnTypes, + Rows: rows, + RowCount: rows.Count, + Truncated: truncated, + ElapsedMs: stopwatch.ElapsedMilliseconds); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return DataQueryResult.Failed( + $"Query exceeded the {options.TimeoutSeconds}s time limit and was cancelled."); + } + catch (Exception ex) when (ex is DuckDBException or InvalidOperationException or FormatException) + { + logger?.LogDebug(ex, "Data query failed"); + return DataQueryResult.Failed(ex.Message); + } + } + + private static async Task ApplySessionLimitsAsync( + DuckDBConnection connection, + DataQueryOptions options, + CancellationToken ct) + { + using var command = connection.CreateCommand(); + // Session-scoped resource limits; these SET statements are issued by the service + // itself before the (blocklist-guarded) user statement runs. + command.CommandText = + $"SET memory_limit='{options.MemoryLimitMb}MB'; SET threads={options.MaxThreads};"; + await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + private const int CatalogInsertBatchSize = 500; + + private async Task CreateCatalogViewAsync(DuckDBConnection connection, CancellationToken ct) + { + using var create = connection.CreateCommand(); + create.CommandText = + "CREATE TABLE meridian_files (symbol VARCHAR, event_type VARCHAR, source VARCHAR, " + + "date DATE, format VARCHAR, compressed BOOLEAN, path VARCHAR, size_bytes BIGINT, event_count BIGINT)"; + await create.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + + var batch = new List(CatalogInsertBatchSize); + foreach (var file in catalogService.SearchFiles(new CatalogSearchCriteria())) + { + var absolutePath = Path.GetFullPath(Path.Combine(storageOptions.RootPath, file.RelativePath)); + batch.Add( + $"({Quote(file.Symbol)}, {Quote(file.EventType)}, {Quote(file.Source)}, " + + $"{Quote(file.Date?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture))}, " + + $"{Quote(file.Format)}, {(file.IsCompressed ? "true" : "false")}, {Quote(absolutePath)}, " + + $"{file.SizeBytes.ToString(CultureInfo.InvariantCulture)}, {file.EventCount.ToString(CultureInfo.InvariantCulture)})"); + + if (batch.Count >= CatalogInsertBatchSize) + { + await InsertBatchAsync(connection, batch, ct).ConfigureAwait(false); + batch.Clear(); + } + } + + if (batch.Count > 0) + await InsertBatchAsync(connection, batch, ct).ConfigureAwait(false); + } + + private static async Task InsertBatchAsync( + DuckDBConnection connection, + IReadOnlyList valueTuples, + CancellationToken ct) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO meridian_files VALUES " + string.Join(", ", valueTuples); + await insert.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + private static string Quote(string? value) => + value is null ? "NULL" : "'" + value.Replace("'", "''", StringComparison.Ordinal) + "'"; +} + +/// +/// Validates that a statement is a single, read-only SELECT-family query. The first token +/// must be a query verb and no write/config verb may appear anywhere — DuckDB's in-memory +/// session cannot mutate the store through queries, but COPY/EXPORT/ATTACH/INSTALL can touch +/// the filesystem or network, so they are rejected outright. +/// +public static partial class SqlStatementGuard +{ + private static readonly string[] AllowedFirstTokens = + ["SELECT", "WITH", "FROM", "DESCRIBE", "SHOW", "EXPLAIN", "SUMMARIZE", "VALUES"]; + + [GeneratedRegex( + @"\b(COPY|EXPORT|IMPORT|INSTALL|LOAD|ATTACH|DETACH|CREATE|INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE|PRAGMA|SET|RESET|CALL|BEGIN|COMMIT|ROLLBACK|CHECKPOINT|VACUUM|GRANT|USE)\b", + RegexOptions.IgnoreCase)] + private static partial Regex BlockedKeywords(); + + [GeneratedRegex(@"^(\s*(--[^\n]*\n|/\*.*?\*/))*\s*", RegexOptions.Singleline)] + private static partial Regex LeadingCommentsAndWhitespace(); + + /// Returns an error message, or when the statement is allowed. + public static string? Validate(string? sql, int maxSqlLength) + { + if (string.IsNullOrWhiteSpace(sql)) + return "A SQL statement is required."; + + if (sql.Length > maxSqlLength) + return $"Statement exceeds the {maxSqlLength}-character limit."; + + var body = LeadingCommentsAndWhitespace().Replace(sql, string.Empty, 1).TrimEnd(); + if (body.Length == 0) + return "A SQL statement is required."; + + // Single statement only: a trailing semicolon is fine, embedded ones are not. + var withoutTrailing = body.TrimEnd(';', ' ', '\t', '\r', '\n'); + if (withoutTrailing.Contains(';', StringComparison.Ordinal)) + return "Only a single statement is allowed."; + + var firstToken = FirstToken(withoutTrailing); + if (!AllowedFirstTokens.Contains(firstToken, StringComparer.OrdinalIgnoreCase)) + return $"Only read-only queries are allowed ({string.Join(", ", AllowedFirstTokens)}); got '{firstToken}'."; + + var blocked = BlockedKeywords().Match(withoutTrailing); + if (blocked.Success) + return $"Statement contains the blocked keyword '{blocked.Value.ToUpperInvariant()}'."; + + return null; + } + + private static string FirstToken(string body) + { + var end = 0; + while (end < body.Length && (char.IsLetter(body[end]) || body[end] == '_')) + end++; + return end == 0 ? body[..Math.Min(body.Length, 12)] : body[..end]; + } +} + +/// +/// Limits for the data query workbench, bound from the DataQuery configuration section. +/// +public sealed class DataQueryOptions +{ + public const string SectionName = "DataQuery"; + + public bool Enabled { get; set; } = true; + + /// Maximum rows returned to the client; additional rows set Truncated. + public int MaxRows { get; set; } = 10_000; + + /// Wall-clock limit for a single query. + public int TimeoutSeconds { get; set; } = 30; + + /// DuckDB session memory limit. + public int MemoryLimitMb { get; set; } = 512; + + /// DuckDB session thread cap, keeping analytics off the ingestion cores. + public int MaxThreads { get; set; } = 2; + + public int MaxSqlLength { get; set; } = 20_000; +} + +/// Request body for the data query endpoint. +public sealed record DataQueryRequest(string? Sql); + +/// Bounded, string-projected query result. +public sealed record DataQueryResult( + bool Success, + string? Error, + IReadOnlyList Columns, + IReadOnlyList ColumnTypes, + IReadOnlyList Rows, + int RowCount, + bool Truncated, + long ElapsedMs) +{ + public static DataQueryResult Failed(string error) => new( + false, error, Array.Empty(), Array.Empty(), Array.Empty(), 0, false, 0); +} diff --git a/src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs b/src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs index c323ba128d..4c7c270cb0 100644 --- a/src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs +++ b/src/Meridian.Ui.Shared/Endpoints/WorkstationEndpoints.cs @@ -24,6 +24,7 @@ using Meridian.QuantScript.Compilation; using Meridian.Storage.Export; using Meridian.Storage.Interfaces; +using Meridian.Storage.Query; using Meridian.Storage.Services; using Meridian.Strategies.Interfaces; using Meridian.Strategies.Models; @@ -388,6 +389,37 @@ public static void MapWorkstationEndpoints(this WebApplication app, JsonSerializ }) .WithName("GetWorkstationData"); + group.MapPost(WorkstationSubroute(UiApiRoutes.WorkstationDataQuery), async (DataQueryRequest request, HttpContext context) => + { + var queryService = context.RequestServices.GetService(); + if (queryService is null) + { + return Results.Problem( + "The data query service is not available.", + statusCode: StatusCodes.Status503ServiceUnavailable); + } + + var options = context.RequestServices + .GetService>()?.CurrentValue + ?? new DataQueryOptions(); + if (!options.Enabled) + { + return Results.Problem( + "The data query workbench is disabled.", + statusCode: StatusCodes.Status503ServiceUnavailable); + } + + // Guard violations and SQL errors are part of the result payload (Success=false) + // so the workbench can render them inline without parsing problem responses. + var result = await queryService.ExecuteAsync(request.Sql, options, context.RequestAborted) + .ConfigureAwait(false); + return Results.Ok(result); + }) + .WithName("PostWorkstationDataQuery") + .Produces(200) + .Produces(503) + .RequireRateLimiting(UiEndpoints.MutationRateLimitPolicy); + group.MapGet(WorkstationSubroute(UiApiRoutes.WorkstationDataReplacementCost), (HttpContext context) => { var estimate = TryBuildDataReplacementCostEstimate(context); diff --git a/src/Meridian.Ui.Shared/Services/WorkstationServiceCollectionExtensions.cs b/src/Meridian.Ui.Shared/Services/WorkstationServiceCollectionExtensions.cs index e02fbd8789..d0d88c1aba 100644 --- a/src/Meridian.Ui.Shared/Services/WorkstationServiceCollectionExtensions.cs +++ b/src/Meridian.Ui.Shared/Services/WorkstationServiceCollectionExtensions.cs @@ -80,6 +80,14 @@ public static IServiceCollection AddWorkstationSharedServices(this IServiceColle services.AddHttpClient(); services.AddHttpContextAccessor(); services.AddMemoryCache(); + + // Options consumed by workstation endpoints must bind in the shared registration so + // every host of these endpoints (UiServer and AddUiSharedServices alike) honors + // operator configuration rather than compiled defaults. + services.AddOptions() + .BindConfiguration(Meridian.Storage.Services.DataReplacementCostOptions.SectionName); + services.AddOptions() + .BindConfiguration(Meridian.Storage.Query.DataQueryOptions.SectionName); services.TryAddScoped(); // SEC-005 slice 4c-ii: ambient caller-tenant accessor consumed by the singleton Postgres ledger // store for tenant read predicates. Singleton + IHttpContextAccessor-backed (no captive scope). diff --git a/src/Meridian.Ui/dashboard/src/lib/api.ts b/src/Meridian.Ui/dashboard/src/lib/api.ts index 3681b8a8ef..cc7d5e5a30 100644 --- a/src/Meridian.Ui/dashboard/src/lib/api.ts +++ b/src/Meridian.Ui/dashboard/src/lib/api.ts @@ -50,6 +50,8 @@ import type { ExternalGlMappingProfile, DataFetchRequest, DataFetchResult, + DataQueryRequest, + DataQueryResult, DataUploadPreviewResult, DataUploadTemplateCatalog, DataWorkspaceResponse, @@ -1843,6 +1845,10 @@ export function getDataWorkspace(options: ApiRequestOptions = {}) { return getJson(WORKSTATION_API_ENDPOINTS.data, options); } +export function runDataQuery(request: DataQueryRequest, options: ApiRequestOptions = {}) { + return postJson(WORKSTATION_API_ENDPOINTS.dataQuery, request, options); +} + export function getDataUploadTemplates(options: ApiRequestOptions = {}) { return getJson(WORKSTATION_API_ENDPOINTS.dataUploadTemplates, options); } diff --git a/src/Meridian.Ui/dashboard/src/lib/ui-api-routes.generated.ts b/src/Meridian.Ui/dashboard/src/lib/ui-api-routes.generated.ts index 9d280d318d..092842399d 100644 --- a/src/Meridian.Ui/dashboard/src/lib/ui-api-routes.generated.ts +++ b/src/Meridian.Ui/dashboard/src/lib/ui-api-routes.generated.ts @@ -488,6 +488,8 @@ export const UI_API_ROUTES = { WorkstationTrading: "/api/workstation/trading", WorkstationDataOperations: "/api/workstation/data-operations", WorkstationData: "/api/workstation/data", + WorkstationDataQuery: "/api/workstation/data/query", + WorkstationDataReplacementCost: "/api/workstation/data/replacement-cost", WorkstationDataUploadTemplates: "/api/workstation/data/uploads/templates", WorkstationDataUploadPreview: "/api/workstation/data/uploads/preview", WorkstationBankStatementImport: "/api/workstation/data/uploads/bank-statements/import", diff --git a/src/Meridian.Ui/dashboard/src/lib/workstation-endpoints.ts b/src/Meridian.Ui/dashboard/src/lib/workstation-endpoints.ts index ad37b9c7b0..1ee84c8134 100644 --- a/src/Meridian.Ui/dashboard/src/lib/workstation-endpoints.ts +++ b/src/Meridian.Ui/dashboard/src/lib/workstation-endpoints.ts @@ -16,6 +16,7 @@ export const WORKSTATION_API_ENDPOINTS = { financialRecordExplorerRecord: UI_API_ROUTES.WorkstationFinancialRecordExplorerRecord, financialRecordExplorerSavedViews: UI_API_ROUTES.WorkstationFinancialRecordExplorerSavedViews, data: UI_API_ROUTES.WorkstationData, + dataQuery: UI_API_ROUTES.WorkstationDataQuery, dataUploadTemplates: UI_API_ROUTES.WorkstationDataUploadTemplates, dataUploadPreview: UI_API_ROUTES.WorkstationDataUploadPreview, accounting: UI_API_ROUTES.WorkstationAccounting, diff --git a/src/Meridian.Ui/dashboard/src/screens/data-screen.query-panel.view-model.test.ts b/src/Meridian.Ui/dashboard/src/screens/data-screen.query-panel.view-model.test.ts new file mode 100644 index 0000000000..737155032c --- /dev/null +++ b/src/Meridian.Ui/dashboard/src/screens/data-screen.query-panel.view-model.test.ts @@ -0,0 +1,76 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { + DEFAULT_DATA_QUERY_SQL, + useDataQueryPanel, + type DataQueryRunner, +} from "@/screens/data-screen.query-panel.view-model"; +import type { DataQueryResult } from "@/types"; + +function successResult(overrides: Partial = {}): DataQueryResult { + return { + success: true, + error: null, + columns: ["symbol", "event_count"], + columnTypes: ["VARCHAR", "BIGINT"], + rows: [["SPY", "5000"]], + rowCount: 1, + truncated: false, + elapsedMs: 12, + ...overrides, + }; +} + +describe("data query panel view model", () => { + it("starts with the discovery query and no result", () => { + const { result } = renderHook(() => useDataQueryPanel(vi.fn() as DataQueryRunner)); + + expect(result.current.sql).toBe(DEFAULT_DATA_QUERY_SQL); + expect(result.current.busy).toBe(false); + expect(result.current.result).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it("runs the current sql and stores the result", async () => { + const runQuery = vi.fn().mockResolvedValue(successResult()); + const { result } = renderHook(() => useDataQueryPanel(runQuery)); + + act(() => { + result.current.setSql("SELECT 1"); + }); + await act(async () => { + await result.current.run(); + }); + + expect(runQuery).toHaveBeenCalledWith({ sql: "SELECT 1" }); + expect(result.current.result?.rowCount).toBe(1); + expect(result.current.error).toBeNull(); + expect(result.current.busy).toBe(false); + }); + + it("surfaces guard rejections from success=false payloads", async () => { + const runQuery = vi.fn().mockResolvedValue( + successResult({ success: false, error: "Only read-only queries are allowed.", rows: [], rowCount: 0 }), + ); + const { result } = renderHook(() => useDataQueryPanel(runQuery)); + + await act(async () => { + await result.current.run(); + }); + + expect(result.current.result).toBeNull(); + expect(result.current.error).toContain("read-only"); + }); + + it("surfaces transport failures as errors", async () => { + const runQuery = vi.fn().mockRejectedValue(new Error("network down")); + const { result } = renderHook(() => useDataQueryPanel(runQuery)); + + await act(async () => { + await result.current.run(); + }); + + expect(result.current.result).toBeNull(); + expect(result.current.error).toBe("network down"); + }); +}); diff --git a/src/Meridian.Ui/dashboard/src/screens/data-screen.query-panel.view-model.ts b/src/Meridian.Ui/dashboard/src/screens/data-screen.query-panel.view-model.ts new file mode 100644 index 0000000000..9739cad149 --- /dev/null +++ b/src/Meridian.Ui/dashboard/src/screens/data-screen.query-panel.view-model.ts @@ -0,0 +1,49 @@ +import { useCallback, useState } from "react"; +import { runDataQuery } from "@/lib/api"; +import type { DataQueryResult } from "@/types"; + +export const DEFAULT_DATA_QUERY_SQL = + "SELECT symbol, event_type, date, format, size_bytes, event_count\nFROM meridian_files\nORDER BY symbol\nLIMIT 100"; + +export type DataQueryRunner = typeof runDataQuery; + +export interface DataQueryPanelViewModel { + sql: string; + setSql: (sql: string) => void; + busy: boolean; + result: DataQueryResult | null; + error: string | null; + run: () => Promise; +} + +/** + * View-model for the Data workspace SQL query panel. Guard violations and SQL errors arrive + * as a successful response with `success: false`, so they render inline; only transport + * failures surface through the catch path. + */ +export function useDataQueryPanel(runQuery: DataQueryRunner = runDataQuery): DataQueryPanelViewModel { + const [sql, setSql] = useState(DEFAULT_DATA_QUERY_SQL); + const [busy, setBusy] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + const run = useCallback(async () => { + if (busy) { + return; + } + setBusy(true); + setError(null); + try { + const response = await runQuery({ sql }); + setResult(response.success ? response : null); + setError(response.success ? null : (response.error ?? "Query failed.")); + } catch (err) { + setResult(null); + setError(err instanceof Error ? err.message : "Query failed."); + } finally { + setBusy(false); + } + }, [busy, runQuery, sql]); + + return { sql, setSql, busy, result, error, run }; +} diff --git a/src/Meridian.Ui/dashboard/src/screens/data-screen.tsx b/src/Meridian.Ui/dashboard/src/screens/data-screen.tsx index 122b631dc8..78be1d5cea 100644 --- a/src/Meridian.Ui/dashboard/src/screens/data-screen.tsx +++ b/src/Meridian.Ui/dashboard/src/screens/data-screen.tsx @@ -33,6 +33,7 @@ import { StatusBanner } from "@/components/ui/status-banner"; import { TabPanel, Tabs } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import { workspaceForPath } from "@/lib/workspace"; +import { useDataQueryPanel } from "@/screens/data-screen.query-panel.view-model"; import { DATA_BACKFILL_DETAIL_PANEL_ID, DATA_EXPORT_DETAIL_PANEL_ID, @@ -257,6 +258,7 @@ export function DataScreen({ providerRoutingTrustSnapshots ]); const vm = useDataViewModel(data, pathname, undefined, providerSetupLifecycle, providerEvidence); + const queryPanel = useDataQueryPanel(); if (!data) { return ; @@ -316,6 +318,7 @@ export function DataScreen({ Provider catalog Backfill queue Export packages + SQL query Watchlist Live quotes @@ -550,6 +553,66 @@ export function DataScreen({ + +
+ + SQL query + + Read-only DuckDB analytics over the local store. Start from the meridian_files catalog + view, then feed paths into read_parquet or read_json_auto. + + + +
+