summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/App.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 23:21:54 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 23:21:54 +0800
commitd4b539a2791a0cd4d098fecfece0039073a9f7e2 (patch)
treedfd314b37fa24379ef13dcd66a1de76e2a923bf2 /src/FundLab.Api/App.fs
parent12c4d3625458c830a2746a1f277fb684e9c498cb (diff)
downloadfund-lab-d4b539a2791a0cd4d098fecfece0039073a9f7e2.tar.gz
Add scheduled investment plans (3d-10)
Diffstat (limited to 'src/FundLab.Api/App.fs')
-rw-r--r--src/FundLab.Api/App.fs208
1 files changed, 207 insertions, 1 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index fc174e5..b559ac2 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -148,6 +148,47 @@ type SipPlanResponse =
createdAt: string
}
+type InvestmentPlanResponse =
+ {
+ id: Guid
+ fundId: Guid
+ instrumentCode: string
+ amount: string
+ frequency: string
+ status: string
+ anchorDate: string
+ nextRunDate: string
+ lastRunStatus: string option
+ lastRunDate: string option
+ isSynthetic: bool
+ createdAt: string
+ }
+
+type InvestmentPlanRunOutcomeResponse =
+ {
+ runDate: string
+ status: string
+ orderId: string option
+ pendingReason: string option
+ }
+
+type InvestmentPlanRunPlanResponse =
+ {
+ planId: Guid
+ instrumentCode: string
+ amount: string
+ frequency: string
+ runs: InvestmentPlanRunOutcomeResponse list
+ nextRunDate: string
+ }
+
+type InvestmentPlanRunResponse =
+ {
+ fundId: Guid
+ processingDate: string
+ plans: InvestmentPlanRunPlanResponse list
+ }
+
type DividendResponse =
{
id: Guid
@@ -370,6 +411,47 @@ module App =
createdAt = timestampText plan.CreatedAt
}
+ let private investmentPlanResponse (plan: InvestmentPlanRecord) : InvestmentPlanResponse =
+ {
+ id = plan.Id
+ fundId = plan.FundId
+ instrumentCode = plan.InstrumentCode
+ amount = cashText plan.Amount
+ frequency = InvestmentPlanPolicy.frequencyText plan.Frequency
+ status = plan.Status
+ anchorDate = dateText plan.AnchorDate
+ nextRunDate = dateText plan.NextRunDate
+ lastRunStatus = plan.LastRunStatus
+ lastRunDate = plan.LastRunDate |> Option.map dateText
+ isSynthetic = plan.IsSynthetic
+ createdAt = timestampText plan.CreatedAt
+ }
+
+ let private investmentPlanRunOutcomeResponse (outcome: InvestmentPlanRunOutcome) : InvestmentPlanRunOutcomeResponse =
+ {
+ runDate = dateText outcome.RunDate
+ status = outcome.Status
+ orderId = outcome.OrderId |> Option.map (fun id -> id.ToString("D"))
+ pendingReason = outcome.PendingReason
+ }
+
+ let private investmentPlanRunPlanResponse (result: InvestmentPlanRunPlanResult) : InvestmentPlanRunPlanResponse =
+ {
+ planId = result.PlanId
+ instrumentCode = result.InstrumentCode
+ amount = cashText result.Amount
+ frequency = InvestmentPlanPolicy.frequencyText result.Frequency
+ runs = result.Runs |> List.map investmentPlanRunOutcomeResponse
+ nextRunDate = dateText result.NextRunDate
+ }
+
+ let private investmentPlanRunResponse (result: InvestmentPlanRunResult) : InvestmentPlanRunResponse =
+ {
+ fundId = result.FundId
+ processingDate = dateText result.ProcessingDate
+ plans = result.Plans |> List.map investmentPlanRunPlanResponse
+ }
+
let private rebalancePlanResponse (plan: RebalancePlanRecord) : RebalancePlanResponse =
{
id = plan.Id
@@ -532,7 +614,7 @@ module App =
with
| :? JsonException -> Error "request body must be valid JSON"
- let private parseSipPlanCommand (body: string) =
+ let private parseSipPlanCommand (body: string) : Result<SipPlanCommand, string> =
try
use document = JsonDocument.Parse(body)
let root = document.RootElement
@@ -557,6 +639,31 @@ module App =
with
| :? JsonException -> Error "request body must be valid JSON"
+ let private parseInvestmentPlanCommand (body: string) : Result<InvestmentPlanCommand, string> =
+ try
+ use document = JsonDocument.Parse(body)
+ let root = document.RootElement
+
+ if root.ValueKind <> JsonValueKind.Object then
+ Error "request body must be a JSON object"
+ else
+ match tryStringProperty root "instrumentCode", tryStringProperty root "amount", tryStringProperty root "frequency" with
+ | Some code, Some amountText, Some frequencyText ->
+ match tryDecimal "amount" amountText, InvestmentPlanPolicy.parseFrequency frequencyText with
+ | Ok amount, Some frequency ->
+ Ok
+ {
+ InstrumentCode = code
+ Amount = amount
+ Frequency = frequency
+ }
+ | Error message, _ -> Error message
+ | _, None -> Error "frequency must be one of daily, weekly or monthly"
+ | _ ->
+ Error "instrumentCode, amount and frequency are required"
+ with
+ | :? JsonException -> Error "request body must be valid JSON"
+
let private parseRebalancePlanCommand (body: string) =
try
use document = JsonDocument.Parse(body)
@@ -1078,6 +1185,102 @@ module App =
with _ ->
errorResponse 500 "PERSISTENCE_ERROR" "sip plan persistence failed" next ctx
+ let private createInvestmentPlan (repository: FundRepository) (fundIdText: string) : HttpHandler =
+ fun next ctx ->
+ task {
+ match Guid.TryParse fundIdText with
+ | false, _ ->
+ return! invokeHandler (errorResponse 400 "INVALID_INVESTMENT_PLAN_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 parseInvestmentPlanCommand body with
+ | Error message ->
+ return! invokeHandler (errorResponse 400 "INVALID_INVESTMENT_PLAN_REQUEST" message) next ctx
+ | Ok command ->
+ try
+ match repository.CreateInvestmentPlan(idempotencyKey, fundId, command) with
+ | InvestmentPlanWriteResult.InvestmentPlanCreated plan ->
+ return! invokeHandler (setStatusCode 201 >=> json (investmentPlanResponse plan)) next ctx
+ | InvestmentPlanWriteResult.InvestmentPlanReplayed plan ->
+ return! invokeHandler (json (investmentPlanResponse plan)) next ctx
+ | InvestmentPlanWriteResult.InvestmentPlanIdempotencyConflict ->
+ return! invokeHandler (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") next ctx
+ | InvestmentPlanWriteResult.InvestmentPlanInvalid message ->
+ return! invokeHandler (errorResponse 400 "INVALID_INVESTMENT_PLAN_REQUEST" message) next ctx
+ | InvestmentPlanWriteResult.InvestmentPlanFundNotFound ->
+ return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx
+ | InvestmentPlanWriteResult.InvestmentPlanInstrumentNotFound ->
+ 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" "investment plan persistence failed") next ctx
+ }
+
+ let private getInvestmentPlans (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.GetInvestmentPlans fundId
+ json (plans |> List.map investmentPlanResponse) next ctx
+ with _ ->
+ errorResponse 500 "PERSISTENCE_ERROR" "investment plan persistence failed" next ctx
+
+ let private parseInvestmentPlanRunCommand (body: string) =
+ try
+ if String.IsNullOrWhiteSpace body then
+ Ok None
+ else
+ use document = JsonDocument.Parse(body)
+ let root = document.RootElement
+
+ if root.ValueKind <> JsonValueKind.Object then
+ Error "request body must be a JSON object"
+ else
+ match tryStringProperty root "processingDate" with
+ | None -> Ok None
+ | Some text ->
+ match DateOnly.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None) with
+ | true, date -> Ok(Some date)
+ | _ -> Error "processingDate must be yyyy-MM-dd"
+ with
+ | :? JsonException -> Error "request body must be valid JSON"
+
+ let private runInvestmentPlans (repository: FundRepository) (fundIdText: string) : HttpHandler =
+ fun next ctx ->
+ task {
+ match Guid.TryParse fundIdText with
+ | false, _ ->
+ return! invokeHandler (errorResponse 400 "INVALID_INVESTMENT_PLAN_REQUEST" "fund id must be a UUID") next ctx
+ | true, fundId ->
+ use reader = new StreamReader(ctx.Request.Body)
+ let! body = reader.ReadToEndAsync()
+
+ match parseInvestmentPlanRunCommand body with
+ | Error message ->
+ return! invokeHandler (errorResponse 400 "INVALID_INVESTMENT_PLAN_REQUEST" message) next ctx
+ | Ok processingDateText ->
+ let processingDate =
+ processingDateText
+ |> Option.defaultWith (fun () -> ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow)
+
+ try
+ match repository.GetFund fundId with
+ | None ->
+ return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx
+ | Some _ ->
+ let result = repository.RunInvestmentPlans(fundId, processingDate)
+ return! invokeHandler (json (investmentPlanRunResponse result)) next ctx
+ with _ ->
+ return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "investment plan run failed") next ctx
+ }
+
let private parseSipAdvanceCommand (body: string) =
try
if String.IsNullOrWhiteSpace body then
@@ -1347,6 +1550,9 @@ module App =
POST >=> routef "/funds/%s/dividends" (createDividend repository)
GET >=> routef "/funds/%s/dividends" (getDividends repository)
GET >=> routef "/funds/%s/returns" (getFundReturns repository)
+ POST >=> routef "/funds/%s/investment-plans/run" (runInvestmentPlans repository)
+ POST >=> routef "/funds/%s/investment-plans" (createInvestmentPlan repository)
+ GET >=> routef "/funds/%s/investment-plans" (getInvestmentPlans repository)
GET >=> routef "/funds/%s" (getFund repository)
]
@ (marketData |> Option.map marketDataRoutes |> Option.defaultValue [])