summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/App.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 13:36:48 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 13:36:48 +0800
commit336d5e5312eb6fa69ccc1fd875e930a067ff8143 (patch)
tree959ea6d95c873d2b7e6a925fd5797a9362912a77 /src/FundLab.Api/App.fs
parenta3dddc28f33254317d07323037ba5d1db35df3d6 (diff)
downloadfund-lab-336d5e5312eb6fa69ccc1fd875e930a067ff8143.tar.gz
Leader fix: annotate rebalance target rows for F# record ambiguity in parseRebalancePlanCommand (3d-7)
Diffstat (limited to 'src/FundLab.Api/App.fs')
-rw-r--r--src/FundLab.Api/App.fs129
1 files changed, 99 insertions, 30 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index 3de514e..ac4c174 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -525,37 +525,33 @@ module App =
if root.ValueKind <> JsonValueKind.Object then
Error "request body must be a JSON object"
else
- let mutable parsed = None
- let mutable failure = None
- use targetsElement = document.RootElement.Clone()
-
- if not (root.TryGetProperty("targets", &targetsElement)) then
- failure <- Some "targets is required"
- elif targetsElement.ValueKind <> JsonValueKind.Array then
- failure <- Some "targets must be an array"
+ let mutable mutableTargets = Unchecked.defaultof<JsonElement>
+
+ if not (root.TryGetProperty("targets", &mutableTargets)) then
+ Error "targets is required"
else
- let rows = ResizeArray<RebalanceTarget>()
- let mutable rowError = None
-
- for item in targetsElement.EnumerateArray() do
- if rowError.IsNone then
- match tryStringProperty item "instrumentCode", tryStringProperty item "targetPercent" with
- | Some code, Some percentText ->
- match tryDecimal "targetPercent" percentText with
- | Ok percent -> rows.Add({ InstrumentCode = code; TargetPercent = percent })
- | Error message -> rowError <- Some message
- | _ -> rowError <- Some "each target needs instrumentCode and targetPercent"
-
- failure <- rowError
-
- if failure.IsNone then parsed <- Some { Targets = rows |> Seq.toList }
-
- match failure with
- | Some message -> Error message
- | None ->
- match parsed with
- | Some command -> Ok command
- | None -> Error "targets is required"
+
+ let targetsElement = mutableTargets
+ if targetsElement.ValueKind <> JsonValueKind.Array then
+ Error "targets must be an array"
+ else
+ let mutable failure = None
+ let rows = ResizeArray<RebalanceTarget>()
+
+ for item in targetsElement.EnumerateArray() do
+ if failure.IsNone then
+ match tryStringProperty item "instrumentCode", tryStringProperty item "targetPercent" with
+ | Some code, Some percentText ->
+ match tryDecimal "targetPercent" percentText with
+ | Ok percent ->
+ let targetRow: RebalancePolicy.TargetAllocation = { InstrumentCode = code; TargetPercent = percent }
+ rows.Add(targetRow)
+ | Error message -> failure <- Some message
+ | _ -> failure <- Some "each target needs instrumentCode and targetPercent"
+
+ match failure with
+ | Some message -> Error message
+ | None -> Ok { Targets = rows |> Seq.toList }
with
| :? JsonException -> Error "request body must be valid JSON"
@@ -992,6 +988,76 @@ module App =
with _ ->
return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "sip advance failed") next ctx
}
+
+ let private createRebalancePlan (repository: FundRepository) (fundIdText: string) : HttpHandler =
+ fun next ctx ->
+ task {
+ match Guid.TryParse fundIdText with
+ | false, _ ->
+ return! invokeHandler (errorResponse 400 "INVALID_REBALANCE_REQUEST" "fund id must be a UUID") next ctx
+ | true, fundId ->
+ use reader = new StreamReader(ctx.Request.Body)
+ let! body = reader.ReadToEndAsync()
+ let idempotencyKey = ctx.Request.Headers["Idempotency-Key"].ToString()
+
+ match parseRebalancePlanCommand body with
+ | Error message ->
+ return! invokeHandler (errorResponse 400 "INVALID_REBALANCE_REQUEST" message) next ctx
+ | Ok command ->
+ try
+ match repository.CreateRebalancePlan(idempotencyKey, fundId, command) with
+ | RebalanceWriteResult.RebalancePlanCreated plan ->
+ return! invokeHandler (setStatusCode 201 >=> json (rebalancePlanResponse plan)) next ctx
+ | RebalanceWriteResult.RebalancePlanReplayed plan ->
+ return! invokeHandler (json (rebalancePlanResponse plan)) next ctx
+ | RebalanceWriteResult.RebalanceIdempotencyConflict ->
+ return! invokeHandler (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") next ctx
+ | RebalanceWriteResult.RebalanceInvalid message ->
+ return! invokeHandler (errorResponse 400 "INVALID_REBALANCE_REQUEST" message) next ctx
+ | RebalanceWriteResult.RebalanceFundNotFound ->
+ return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx
+ | RebalanceWriteResult.RebalanceInstrumentNotFound ->
+ return! invokeHandler (errorResponse 404 "INSTRUMENT_NOT_FOUND" "instrument code was not found in the instrument catalog") next ctx
+ with _ ->
+ return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "rebalance plan persistence failed") next ctx
+ }
+
+ let private getRebalancePlans (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 plans = repository.GetRebalancePlans fundId
+ json (plans |> List.map rebalancePlanResponse) next ctx
+ with _ ->
+ errorResponse 500 "PERSISTENCE_ERROR" "rebalance plan persistence failed" next ctx
+
+ let private executeRebalancePlan (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 _ ->
+ match repository.ExecuteRebalancePlan planId with
+ | Error message ->
+ errorResponse (if message.Contains("was not found") then 404 else 400) "INVALID_REBALANCE_REQUEST" message next ctx
+ | Ok result ->
+ if result.PlanId <> Guid.Empty && repository.GetRebalancePlans fundId |> List.exists (fun plan -> plan.Id = result.PlanId) |> not then
+ errorResponse 404 "PLAN_NOT_FOUND" "rebalance plan does not belong to this fund" next ctx
+ else
+ json (rebalanceExecutionResponse result) next ctx
+ with _ ->
+ errorResponse 500 "PERSISTENCE_ERROR" "rebalance execution failed" next ctx
let private marketDataError (failure: MarketDataFailure) : HttpHandler =
let status, error, message =
match failure with
@@ -1099,6 +1165,9 @@ module App =
POST >=> routef "/funds/%s/sip/plans" (createSipPlan repository)
GET >=> routef "/funds/%s/sip/plans" (getSipPlans repository)
POST >=> routef "/funds/%s/sip/advance" (advanceSipPlans repository)
+ 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" (getFund repository)
]
@ (marketData |> Option.map marketDataRoutes |> Option.defaultValue [])