summaryrefslogtreecommitdiff
path: root/src/FundLab.Api
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 05:23:52 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 05:23:52 +0800
commit6abd4c206fbfe8369b3fde4c905619d08f13c5d1 (patch)
treef53ce1746591b640f78cd11d63c8411160059d98 /src/FundLab.Api
parent545f2a507fd0138f7fd50ed7fb2631d0273db625 (diff)
downloadfund-lab-6abd4c206fbfe8369b3fde4c905619d08f13c5d1.tar.gz
Add SIP plan pause/resume and rebalance plan archive lifecycle (3d-24)
Diffstat (limited to 'src/FundLab.Api')
-rw-r--r--src/FundLab.Api/App.fs55
-rw-r--r--src/FundLab.Api/Persistence.fs105
2 files changed, 158 insertions, 2 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index 138230f..5ff7fdd 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -1505,6 +1505,29 @@ module App =
with _ ->
errorResponse 500 "PERSISTENCE_ERROR" "sip plan persistence failed" next ctx
+ let private setSipPlanStatus
+ (repository: FundRepository)
+ (targetStatus: string)
+ (fundIdText: string)
+ (planIdText: string)
+ : HttpHandler =
+ fun next ctx ->
+ match Guid.TryParse fundIdText, Guid.TryParse planIdText with
+ | (false, _), _
+ | _, (false, _) ->
+ errorResponse 400 "INVALID_SIP_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.SetSipPlanStatus(fundId, planId, targetStatus) with
+ | SipPlanStatusResult.SipPlanStatusChanged plan -> json (sipPlanResponse plan) next ctx
+ | SipPlanStatusResult.SipPlanStatusNotFound ->
+ errorResponse 404 "PLAN_NOT_FOUND" "sip plan does not belong to this fund" next ctx
+ with _ ->
+ errorResponse 500 "PERSISTENCE_ERROR" "sip plan status update failed" next ctx
+
let private createInvestmentPlan (repository: FundRepository) (fundIdText: string) : HttpHandler =
fun next ctx ->
task {
@@ -1735,6 +1758,24 @@ module App =
with _ ->
errorResponse 500 "PERSISTENCE_ERROR" "rebalance plan persistence failed" next ctx
+ let private archiveRebalancePlan (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.ArchiveRebalancePlan(fundId, planId) with
+ | RebalanceArchiveResult.RebalancePlanArchived plan -> json (rebalancePlanResponse plan) next ctx
+ | RebalanceArchiveResult.RebalanceArchiveNotFound ->
+ errorResponse 404 "PLAN_NOT_FOUND" "rebalance plan does not belong to this fund" next ctx
+ with _ ->
+ errorResponse 500 "PERSISTENCE_ERROR" "rebalance plan archive failed" next ctx
+
let private executeRebalancePlan (repository: FundRepository) (fundIdText: string) (planIdText: string) : HttpHandler =
fun next ctx ->
match Guid.TryParse fundIdText, Guid.TryParse planIdText with
@@ -1749,7 +1790,10 @@ module App =
| 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
+ if message.Contains("is archived") then
+ errorResponse 409 "PLAN_ARCHIVED" "rebalance plan is archived" next ctx
+ else
+ 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
@@ -1787,7 +1831,11 @@ module App =
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
+ | Error message ->
+ if message.Contains("is archived") then
+ errorResponse 409 "PLAN_ARCHIVED" "rebalance plan is archived" next ctx
+ else
+ 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
@@ -2275,8 +2323,11 @@ 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/sip/plans/%s/pause" (fun (fundId, planId) -> setSipPlanStatus repository "paused" fundId planId)
+ POST >=> routef "/funds/%s/sip/plans/%s/resume" (fun (fundId, planId) -> setSipPlanStatus repository "active" fundId planId)
POST >=> routef "/funds/%s/rebalance/plans" (createRebalancePlan repository)
GET >=> routef "/funds/%s/rebalance/plans" (getRebalancePlans repository)
+ POST >=> routef "/funds/%s/rebalance/plans/%s/archive" (fun (fundId, planId) -> archiveRebalancePlan repository fundId planId)
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)
diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs
index 5000161..379c10d 100644
--- a/src/FundLab.Api/Persistence.fs
+++ b/src/FundLab.Api/Persistence.fs
@@ -389,6 +389,10 @@ type SipPlanWriteResult =
| SipPlanFundNotFound
| SipPlanInstrumentNotFound
+type SipPlanStatusResult =
+ | SipPlanStatusChanged of SipPlanRecord
+ | SipPlanStatusNotFound
+
type SipExecutionOutcome =
{
TradeDate: DateOnly
@@ -494,6 +498,10 @@ type RebalanceWriteResult =
| RebalanceFundNotFound
| RebalanceInstrumentNotFound
+type RebalanceArchiveResult =
+ | RebalancePlanArchived of RebalancePlanRecord
+ | RebalanceArchiveNotFound
+
type RebalanceOrderOutcome =
{
InstrumentCode: string
@@ -3347,6 +3355,54 @@ type FundRepository(connectionString: string) =
records |> Seq.toList
+ member _.SetSipPlanStatus(fundId: Guid, planId: Guid, status: string) : SipPlanStatusResult =
+ use connection = new NpgsqlConnection(connectionString)
+ connection.Open()
+ use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted)
+
+ try
+ use lockCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ "SELECT pg_advisory_xact_lock(hashtext(@lock_key))"
+
+ addParameter lockCommand "lock_key" NpgsqlDbType.Text (box (sprintf "sip-plan-status:%O" planId)) |> ignore
+ lockCommand.ExecuteNonQuery() |> ignore
+
+ match findSipPlan connection (Some transaction) planId with
+ | Some plan when plan.FundId = fundId ->
+ if plan.Status = status then
+ transaction.Commit()
+ SipPlanStatusResult.SipPlanStatusChanged plan
+ else
+ use updateCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ "UPDATE sip_plans SET status = @status WHERE id = @plan_id"
+
+ addParameter updateCommand "status" NpgsqlDbType.Text (box status) |> ignore
+ addParameter updateCommand "plan_id" NpgsqlDbType.Uuid (box planId) |> ignore
+ updateCommand.ExecuteNonQuery() |> ignore
+
+ let updated = findSipPlan connection (Some transaction) planId
+ transaction.Commit()
+
+ match updated with
+ | Some updatedPlan -> SipPlanStatusResult.SipPlanStatusChanged updatedPlan
+ | None -> SipPlanStatusResult.SipPlanStatusNotFound
+ | _ ->
+ transaction.Rollback()
+ SipPlanStatusResult.SipPlanStatusNotFound
+ with error ->
+ try
+ transaction.Rollback()
+ with _ ->
+ ()
+
+ raise error
+
member _.CreateInvestmentPlan(idempotencyKey: string, fundId: Guid, command: InvestmentPlanCommand, ?anchorOverride: DateOnly) : InvestmentPlanWriteResult =
if String.IsNullOrWhiteSpace idempotencyKey then
InvestmentPlanWriteResult.InvestmentPlanInvalid "idempotency key cannot be empty"
@@ -3838,6 +3894,53 @@ type FundRepository(connectionString: string) =
reader.Close()
plans |> Seq.toList |> List.map (rebalancePlanWithTargets connection None)
+ member _.ArchiveRebalancePlan(fundId: Guid, planId: Guid) : RebalanceArchiveResult =
+ use connection = new NpgsqlConnection(connectionString)
+ connection.Open()
+ use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted)
+
+ try
+ use lockCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ "SELECT pg_advisory_xact_lock(hashtext(@lock_key))"
+
+ addParameter lockCommand "lock_key" NpgsqlDbType.Text (box (sprintf "rebalance-archive:%O" planId)) |> ignore
+ lockCommand.ExecuteNonQuery() |> ignore
+
+ match findRebalancePlan connection (Some transaction) planId with
+ | Some plan when plan.FundId = fundId ->
+ if plan.Status = "archived" then
+ transaction.Commit()
+ RebalanceArchiveResult.RebalancePlanArchived plan
+ else
+ use updateCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ "UPDATE rebalance_plans SET status = 'archived' WHERE id = @plan_id"
+
+ addParameter updateCommand "plan_id" NpgsqlDbType.Uuid (box planId) |> ignore
+ updateCommand.ExecuteNonQuery() |> ignore
+
+ let updated = findRebalancePlan connection (Some transaction) planId
+ transaction.Commit()
+
+ match updated with
+ | Some updatedPlan -> RebalanceArchiveResult.RebalancePlanArchived updatedPlan
+ | None -> RebalanceArchiveResult.RebalanceArchiveNotFound
+ | _ ->
+ transaction.Rollback()
+ RebalanceArchiveResult.RebalanceArchiveNotFound
+ with error ->
+ try
+ transaction.Rollback()
+ with _ ->
+ ()
+
+ raise error
+
member this.ExecuteRebalancePlan(planId: Guid) : Result<RebalanceExecutionResult, string> =
use connection = new NpgsqlConnection(connectionString)
connection.Open()
@@ -3868,6 +3971,7 @@ type FundRepository(connectionString: string) =
match plan with
| None -> Error "rebalance plan was not found"
+ | Some plan when plan.Status = "archived" -> Error "rebalance plan is archived"
| Some plan ->
let fundId = plan.FundId
let runDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow
@@ -4033,6 +4137,7 @@ type FundRepository(connectionString: string) =
match findRebalancePlan connection None planId with
| None -> Error "rebalance plan was not found"
+ | Some plan when plan.Status = "archived" -> Error "rebalance plan is archived"
| Some plan ->
match this.GetFund plan.FundId with
| None -> Error "fund was not found"