From 36ee23a0c406c16e975276238a861299899ec79a Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 23:47:50 +0800 Subject: Add rebalancing execution slice (3d-11) --- src/FundLab.Api/App.fs | 109 ++++++++++++++++++++++++ src/FundLab.Api/Persistence.fs | 187 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 295 insertions(+), 1 deletion(-) (limited to 'src/FundLab.Api') diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index b559ac2..34b4f2d 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -224,6 +224,39 @@ type RebalanceExecutionResponse = outcomes: {| instrumentCode: string; action: string; amount: string; status: string; orderId: string option; pendingReason: string option |} list } +type RebalanceExecutionRecordResponse = + { + planId: Guid + runDate: string + instrumentCode: string + action: string + amount: string + status: string + orderId: string option + pendingReason: string option + executedAt: string + } + +type RebalanceWeightRowResponse = + { + instrumentCode: string + targetPercent: string + currentValue: string + currentPercent: string + action: string + deltaAmount: string + deltaUnits: string option + } + +type RebalancePreviewResponse = + { + planId: Guid + runDate: string + availableCash: string + equity: string + rows: RebalanceWeightRowResponse list + } + type CapitalDepositResponse = { id: Guid @@ -484,6 +517,45 @@ module App = |}) } + let private rebalanceExecutionRecordResponse (record: RebalanceExecutionRecord) : RebalanceExecutionRecordResponse = + { + planId = record.PlanId + runDate = dateText record.RunDate + instrumentCode = record.InstrumentCode + action = record.Action + amount = cashText record.Amount + status = record.Status + orderId = record.OrderId |> Option.map (fun id -> id.ToString("D")) + pendingReason = record.PendingReason + executedAt = timestampText record.ExecutedAt + } + + let private rebalanceActionText (action: RebalancePolicy.RebalanceAction) = + match action with + | RebalancePolicy.Buy -> "buy" + | RebalancePolicy.Sell -> "sell" + | RebalancePolicy.Hold -> "hold" + + let private rebalanceWeightRowResponse (row: RebalancePolicy.RebalanceWeightRow) : RebalanceWeightRowResponse = + { + instrumentCode = row.InstrumentCode + targetPercent = cashText row.TargetPercent + currentValue = cashText row.CurrentValue + currentPercent = cashText row.CurrentPercent + action = rebalanceActionText row.Action + deltaAmount = cashText row.DeltaAmount + deltaUnits = row.DeltaUnits |> Option.map decimalText + } + + let private rebalancePreviewResponse (preview: RebalancePreview) : RebalancePreviewResponse = + { + planId = preview.PlanId + runDate = dateText preview.RunDate + availableCash = cashText preview.AvailableCash + equity = cashText preview.Equity + rows = preview.Rows |> List.map rebalanceWeightRowResponse + } + let private errorResponse status error message : HttpHandler = setStatusCode status >=> json ({ @@ -1437,6 +1509,41 @@ module App = json (rebalanceExecutionResponse result) next ctx with _ -> errorResponse 500 "PERSISTENCE_ERROR" "rebalance execution failed" next ctx + + let private getRebalanceExecutions (repository: FundRepository) (fundIdText: string) : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText with + | false, _ -> errorResponse 400 "INVALID_FUND_ID" "fund id must be a UUID" next ctx + | true, fundId -> + try + match repository.GetFund fundId with + | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx + | Some _ -> + let records = repository.GetRebalanceExecutions fundId + json (records |> List.map rebalanceExecutionRecordResponse) next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "rebalance execution persistence failed" next ctx + + let private previewRebalancePlan (repository: FundRepository) (fundIdText: string) (planIdText: string) : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText, Guid.TryParse planIdText with + | (false, _), _ + | _, (false, _) -> + errorResponse 400 "INVALID_REBALANCE_REQUEST" "fund id and plan id must be UUIDs" next ctx + | (true, fundId), (true, planId) -> + try + match repository.GetFund fundId with + | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx + | Some _ -> + if repository.GetRebalancePlans fundId |> List.exists (fun plan -> plan.Id = planId) |> not then + errorResponse 404 "PLAN_NOT_FOUND" "rebalance plan does not belong to this fund" next ctx + else + match repository.PreviewRebalancePlan planId with + | Error message -> errorResponse 400 "INVALID_REBALANCE_REQUEST" message next ctx + | Ok preview -> json (rebalancePreviewResponse preview) next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "rebalance preview failed" next ctx + let private marketDataError (failure: MarketDataFailure) : HttpHandler = let status, error, message = match failure with @@ -1547,6 +1654,8 @@ module App = POST >=> routef "/funds/%s/rebalance/plans" (createRebalancePlan repository) GET >=> routef "/funds/%s/rebalance/plans" (getRebalancePlans repository) POST >=> routef "/funds/%s/rebalance/plans/%s/execute" (fun (fundId, planId) -> executeRebalancePlan repository fundId planId) + GET >=> routef "/funds/%s/rebalance/plans/%s/preview" (fun (fundId, planId) -> previewRebalancePlan repository fundId planId) + GET >=> routef "/funds/%s/rebalance/executions" (getRebalanceExecutions repository) POST >=> routef "/funds/%s/dividends" (createDividend repository) GET >=> routef "/funds/%s/dividends" (getDividends repository) GET >=> routef "/funds/%s/returns" (getFundReturns repository) diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs index e1cbb6d..7a7ab32 100644 --- a/src/FundLab.Api/Persistence.fs +++ b/src/FundLab.Api/Persistence.fs @@ -423,6 +423,28 @@ type RebalanceExecutionResult = Outcomes: RebalanceOrderOutcome list } +type RebalanceExecutionRecord = + { + PlanId: Guid + RunDate: DateOnly + InstrumentCode: string + Action: string + Amount: decimal + Status: string + OrderId: Guid option + PendingReason: string option + ExecutedAt: DateTimeOffset + } + +type RebalancePreview = + { + PlanId: Guid + RunDate: DateOnly + AvailableCash: decimal + Equity: decimal + Rows: RebalancePolicy.RebalanceWeightRow list + } + type DividendMode = DividendPolicy.DividendMode type DividendCommand = @@ -765,6 +787,19 @@ type FundRepository(connectionString: string) = PRIMARY KEY (plan_id, instrument_code) ); + CREATE TABLE IF NOT EXISTS rebalance_executions ( + plan_id uuid NOT NULL REFERENCES rebalance_plans(id), + run_date date NOT NULL, + instrument_code text NOT NULL, + action text NOT NULL, + amount numeric(20, 2) NOT NULL, + status text NOT NULL, + order_id uuid NULL, + pending_reason text NULL, + executed_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (plan_id, run_date, instrument_code) + ); + CREATE TABLE IF NOT EXISTS dividend_records ( id uuid PRIMARY KEY, fund_id uuid NOT NULL REFERENCES funds(id), @@ -2016,6 +2051,70 @@ type FundRepository(connectionString: string) = match RebalancePolicy.validateTargets command.Targets with | Error message -> Error message | Ok() -> Ok() + + let rebalanceExecutionRecordFromReader (reader: DbDataReader) : RebalanceExecutionRecord = + { + PlanId = reader.GetGuid(0) + RunDate = reader.GetFieldValue(1) + InstrumentCode = reader.GetString(2) + Action = reader.GetString(3) + Amount = reader.GetDecimal(4) + Status = reader.GetString(5) + OrderId = if reader.IsDBNull(6) then None else Some(reader.GetGuid(6)) + PendingReason = readStringOption reader 7 + ExecutedAt = reader.GetFieldValue(8) + } + + let upsertRebalanceExecution + connection + transaction + (planId: Guid) + (runDate: DateOnly) + (outcome: RebalanceOrderOutcome) + = + let amount = + match Decimal.TryParse(outcome.Amount, NumberStyles.Float, CultureInfo.InvariantCulture) with + | true, value -> value + | false, _ -> 0m + + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO rebalance_executions (plan_id, run_date, instrument_code, action, amount, status, order_id, pending_reason) + VALUES (@plan_id, @run_date, @instrument_code, @action, @amount, @status, @order_id, @pending_reason) + ON CONFLICT (plan_id, run_date, instrument_code) DO UPDATE + SET action = EXCLUDED.action, + amount = EXCLUDED.amount, + status = EXCLUDED.status, + order_id = EXCLUDED.order_id, + pending_reason = EXCLUDED.pending_reason, + executed_at = now() + """ + + addParameter command "plan_id" NpgsqlDbType.Uuid (box planId) |> ignore + addParameter command "run_date" NpgsqlDbType.Date (box runDate) |> ignore + addParameter command "instrument_code" NpgsqlDbType.Text (box outcome.InstrumentCode) |> ignore + addParameter command "action" NpgsqlDbType.Text (box outcome.Action) |> ignore + addParameter command "amount" NpgsqlDbType.Numeric (box amount) |> ignore + addParameter command "status" NpgsqlDbType.Text (box outcome.Status) |> ignore + + let orderParameter = + match outcome.OrderId with + | Some value -> box value + | None -> box DBNull.Value + + addParameter command "order_id" NpgsqlDbType.Uuid orderParameter |> ignore + + let reasonParameter = + match outcome.PendingReason with + | Some value -> box value + | None -> box DBNull.Value + + addParameter command "pending_reason" NpgsqlDbType.Text reasonParameter |> ignore + command.ExecuteNonQuery() |> ignore + let dividendRecordFromReader (reader: DbDataReader) : DividendRecord = { Id = reader.GetGuid(0) @@ -3496,11 +3595,97 @@ type FundRepository(connectionString: string) = | other -> outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "failed"; OrderId = None; PendingReason = Some (sprintf "%A" other) }) + let outcomeList = outcomes |> Seq.toList + + use persistTransaction = connection.BeginTransaction(IsolationLevel.ReadCommitted) + + try + for outcome in outcomeList do + upsertRebalanceExecution connection (Some persistTransaction) plan.Id runDate outcome + + persistTransaction.Commit() + with error -> + try + persistTransaction.Rollback() + with _ -> + () + + raise error + Ok { PlanId = plan.Id RunDate = runDate - Outcomes = outcomes |> Seq.toList + Outcomes = outcomeList + } + + member _.GetRebalanceExecutions(fundId: Guid) = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + + use command = + commandWithTransaction + connection + None + """ + SELECT e.plan_id, e.run_date, e.instrument_code, e.action, e.amount, e.status, e.order_id, e.pending_reason, e.executed_at + FROM rebalance_executions e + JOIN rebalance_plans p ON p.id = e.plan_id + WHERE p.fund_id = @fund_id + ORDER BY e.executed_at DESC, e.run_date DESC, e.instrument_code + """ + + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + + use reader = command.ExecuteReader() + let records = ResizeArray() + + while reader.Read() do + records.Add(rebalanceExecutionRecordFromReader reader) + + records |> Seq.toList + + member this.PreviewRebalancePlan(planId: Guid) : Result = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + + match findRebalancePlan connection None planId with + | None -> Error "rebalance plan was not found" + | Some plan -> + match this.GetFund plan.FundId with + | None -> Error "fund was not found" + | Some fund -> + let positions = this.GetFundPositions plan.FundId + + let buildSnapshot (position: FundPositionRecord) : RebalancePolicy.RebalancePositionSnapshot = + let marketValue = + match position.ValuationNav with + | Some nav -> Decimal.Round(position.Units * nav, 2) + | None -> 0m + + { + RebalancePolicy.RebalancePositionSnapshot.InstrumentCode = position.InstrumentCode + RebalancePolicy.RebalancePositionSnapshot.MarketValue = marketValue + RebalancePolicy.RebalancePositionSnapshot.Units = position.Units + RebalancePolicy.RebalancePositionSnapshot.AvailableUnits = position.Units - position.ReservedUnits + RebalancePolicy.RebalancePositionSnapshot.ValuationNav = position.ValuationNav + } + + let snapshots = positions |> List.map buildSnapshot + + match RebalancePolicy.weightRows plan.Targets snapshots fund.AvailableCash with + | Error message -> Error message + | Ok rows -> + let equity = + fund.AvailableCash + (snapshots |> List.sumBy (fun snapshot -> snapshot.MarketValue)) + + Ok + { + PlanId = plan.Id + RunDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow + AvailableCash = fund.AvailableCash + Equity = equity + Rows = rows } member this.RegisterDividend(idempotencyKey: string, fundId: Guid, command: DividendCommand) : DividendWriteResult = -- cgit v1.2.3