summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/App.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 23:47:50 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 23:47:50 +0800
commit36ee23a0c406c16e975276238a861299899ec79a (patch)
tree7085f24566b7c53ad75c1ddf8130e563513f673a /src/FundLab.Api/App.fs
parentd4b539a2791a0cd4d098fecfece0039073a9f7e2 (diff)
downloadfund-lab-36ee23a0c406c16e975276238a861299899ec79a.tar.gz
Add rebalancing execution slice (3d-11)
Diffstat (limited to 'src/FundLab.Api/App.fs')
-rw-r--r--src/FundLab.Api/App.fs109
1 files changed, 109 insertions, 0 deletions
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)