summaryrefslogtreecommitdiff
path: root/src/FundLab.Web/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.Web/App.fs
parent12c4d3625458c830a2746a1f277fb684e9c498cb (diff)
downloadfund-lab-d4b539a2791a0cd4d098fecfece0039073a9f7e2.tar.gz
Add scheduled investment plans (3d-10)
Diffstat (limited to 'src/FundLab.Web/App.fs')
-rw-r--r--src/FundLab.Web/App.fs358
1 files changed, 357 insertions, 1 deletions
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index f1e6df6..1386b01 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -235,6 +235,47 @@ type RawSipPlan =
lastExecutionDate: obj
}
+type RawInvestmentPlan =
+ {
+ id: string
+ fundId: string
+ instrumentCode: string
+ amount: string
+ frequency: string
+ status: string
+ anchorDate: string
+ nextRunDate: string
+ lastRunStatus: obj
+ lastRunDate: obj
+ isSynthetic: bool
+ createdAt: string
+ }
+
+type RawInvestmentPlanRunOutcome =
+ {
+ runDate: string
+ status: string
+ orderId: obj
+ pendingReason: obj
+ }
+
+type RawInvestmentPlanRunPlan =
+ {
+ planId: string
+ instrumentCode: string
+ amount: string
+ frequency: string
+ runs: RawInvestmentPlanRunOutcome array
+ nextRunDate: string
+ }
+
+type RawInvestmentPlanRun =
+ {
+ fundId: string
+ processingDate: string
+ plans: RawInvestmentPlanRunPlan array
+ }
+
type RawRedemption =
{
id: string
@@ -393,6 +434,44 @@ type SipPlan =
lastExecutionDate: string option
}
+type InvestmentPlan =
+ {
+ id: string
+ instrumentCode: string
+ amount: string
+ frequency: string
+ status: string
+ anchorDate: string
+ nextRunDate: string
+ lastRunStatus: string option
+ lastRunDate: string option
+ }
+
+type InvestmentPlanRunOutcome =
+ {
+ runDate: string
+ status: string
+ orderId: string option
+ pendingReason: string option
+ }
+
+type InvestmentPlanRunPlan =
+ {
+ planId: string
+ instrumentCode: string
+ amount: string
+ frequency: string
+ runs: InvestmentPlanRunOutcome list
+ nextRunDate: string
+ }
+
+type InvestmentPlanRun =
+ {
+ fundId: string
+ processingDate: string
+ plans: InvestmentPlanRunPlan list
+ }
+
type RebalanceTarget =
{
instrumentCode: string
@@ -576,6 +655,12 @@ module Api =
[<Import("getSipPlans", "./src/api.js")>]
let getSipPlans (token: string) (fundId: string) : JS.Promise<RawSipPlan array> = jsNative
+ [<Import("getInvestmentPlans", "./src/api.js")>]
+ let getInvestmentPlans (token: string) (fundId: string) : JS.Promise<RawInvestmentPlan array> = jsNative
+
+ [<Import("runInvestmentPlans", "./src/api.js")>]
+ let runInvestmentPlans (token: string) (fundId: string) : JS.Promise<RawInvestmentPlanRun> = jsNative
+
let decodeOptionalText (raw: obj) : string option =
if isNull raw then
None
@@ -685,6 +770,44 @@ module Api =
lastExecutionDate = decodeOptionalText raw.lastExecutionDate
}
+ let decodeInvestmentPlan (raw: RawInvestmentPlan) : InvestmentPlan =
+ {
+ id = raw.id
+ instrumentCode = raw.instrumentCode
+ amount = raw.amount
+ frequency = raw.frequency
+ status = raw.status
+ anchorDate = raw.anchorDate
+ nextRunDate = raw.nextRunDate
+ lastRunStatus = decodeOptionalText raw.lastRunStatus
+ lastRunDate = decodeOptionalText raw.lastRunDate
+ }
+
+ let decodeInvestmentPlanRunOutcome (raw: RawInvestmentPlanRunOutcome) : InvestmentPlanRunOutcome =
+ {
+ runDate = raw.runDate
+ status = raw.status
+ orderId = decodeOptionalText raw.orderId
+ pendingReason = decodeOptionalText raw.pendingReason
+ }
+
+ let decodeInvestmentPlanRunPlan (raw: RawInvestmentPlanRunPlan) : InvestmentPlanRunPlan =
+ {
+ planId = raw.planId
+ instrumentCode = raw.instrumentCode
+ amount = raw.amount
+ frequency = raw.frequency
+ runs = raw.runs |> Array.map decodeInvestmentPlanRunOutcome |> Array.toList
+ nextRunDate = raw.nextRunDate
+ }
+
+ let decodeInvestmentPlanRun (raw: RawInvestmentPlanRun) : InvestmentPlanRun =
+ {
+ fundId = raw.fundId
+ processingDate = raw.processingDate
+ plans = raw.plans |> Array.map decodeInvestmentPlanRunPlan |> Array.toList
+ }
+
let decodeRedemption (raw: RawRedemption) : RedemptionDetail =
{
id = raw.id
@@ -812,6 +935,12 @@ type Model =
returnsReadSeq: int
returnsInFlight: bool
returns: FundReturns option
+ planReadSeq: int
+ planInFlight: bool
+ planRunSeq: int
+ planRunInFlight: bool
+ investmentPlans: InvestmentPlan list
+ lastPlanRun: InvestmentPlanRun option
error: string option
}
@@ -893,6 +1022,12 @@ type Msg =
| ReturnsReadRequested
| ReturnsReadCompleted of requestId: int * returns: RawReturns
| ReturnsReadFailed of requestId: int * message: string
+ | InvestmentPlansReadRequested
+ | InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array
+ | InvestmentPlansReadFailed of requestId: int * message: string
+ | InvestmentPlansRunRequested
+ | InvestmentPlansRunCompleted of requestId: int * fundId: string * run: RawInvestmentPlanRun
+ | InvestmentPlansRunFailed of requestId: int * fundId: string * message: string
let defaultInitialUnitNav = "1.00000000"
@@ -994,6 +1129,12 @@ let init () =
returnsReadSeq = 0
returnsInFlight = false
returns = None
+ planReadSeq = 0
+ planInFlight = false
+ planRunSeq = 0
+ planRunInFlight = false
+ investmentPlans = []
+ lastPlanRun = None
error = None
}
@@ -1143,6 +1284,20 @@ let private readReturnsCommand token fundId requestId =
(fun returns -> ReturnsReadCompleted(requestId, returns))
(fun error -> ReturnsReadFailed(requestId, errorText error))
+let private readInvestmentPlansCommand token fundId requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.getInvestmentPlans token fundId)
+ ()
+ (fun plans -> InvestmentPlansReadCompleted(requestId, plans))
+ (fun error -> InvestmentPlansReadFailed(requestId, errorText error))
+
+let private runInvestmentPlansCommand token fundId requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.runInvestmentPlans token fundId)
+ ()
+ (fun run -> InvestmentPlansRunCompleted(requestId, fundId, run))
+ (fun error -> InvestmentPlansRunFailed(requestId, fundId, errorText error))
+
let update message model =
match message with
| TokenChanged token ->
@@ -1217,6 +1372,12 @@ let update message model =
returnsReadSeq = model.returnsReadSeq + 1
returnsInFlight = false
returns = None
+ planReadSeq = model.planReadSeq + 1
+ planInFlight = false
+ planRunSeq = model.planRunSeq + 1
+ planRunInFlight = false
+ investmentPlans = []
+ lastPlanRun = None
error = None
},
Cmd.none
@@ -1417,9 +1578,15 @@ let update message model =
returnsReadSeq = model.returnsReadSeq + 1
returnsInFlight = false
returns = None
+ planReadSeq = model.planReadSeq + 1
+ planInFlight = false
+ planRunSeq = model.planRunSeq + 1
+ planRunInFlight = false
+ investmentPlans = []
+ lastPlanRun = None
error = None
},
- Cmd.batch [ Cmd.ofMsg OrdersReadRequested; Cmd.ofMsg ReturnsReadRequested ]
+ Cmd.batch [ Cmd.ofMsg OrdersReadRequested; Cmd.ofMsg ReturnsReadRequested; Cmd.ofMsg InvestmentPlansReadRequested ]
else
model, Cmd.none
| FundCreateFailed (requestId, message) ->
@@ -2120,7 +2287,79 @@ let update message model =
{ model with returnsInFlight = false; error = Some message }, Cmd.none
else
model, Cmd.none
+ | InvestmentPlansReadRequested ->
+ match model.createdFund with
+ | Some fund when not (String.IsNullOrWhiteSpace model.token) ->
+ let requestId = model.planReadSeq + 1
+
+ {
+ model with
+ planReadSeq = requestId
+ planInFlight = true
+ error = None
+ },
+ readInvestmentPlansCommand model.token fund.id requestId
+ | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none
+ | None -> model, Cmd.none
+ | InvestmentPlansReadCompleted (requestId, plans) ->
+ if requestId = model.planReadSeq then
+ {
+ model with
+ investmentPlans = plans |> Array.map Api.decodeInvestmentPlan |> Array.toList
+ planInFlight = false
+ error = None
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | InvestmentPlansReadFailed (requestId, message) ->
+ if requestId = model.planReadSeq then
+ { model with planInFlight = false; error = Some message }, Cmd.none
+ else
+ model, Cmd.none
+ | InvestmentPlansRunRequested ->
+ match model.createdFund with
+ | Some fund when not (String.IsNullOrWhiteSpace model.token) ->
+ if model.planRunInFlight then
+ model, Cmd.none
+ else
+ let requestId = model.planRunSeq + 1
+ {
+ model with
+ planRunSeq = requestId
+ planRunInFlight = true
+ error = None
+ },
+ runInvestmentPlansCommand model.token fund.id requestId
+ | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none
+ | None -> model, Cmd.none
+ | InvestmentPlansRunCompleted (requestId, fundId, run) ->
+ if requestId = model.planRunSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ let decoded = Api.decodeInvestmentPlanRun run
+
+ {
+ model with
+ planRunInFlight = false
+ lastPlanRun = Some decoded
+ error = None
+ },
+ Cmd.batch
+ [
+ Cmd.ofMsg InvestmentPlansReadRequested
+ Cmd.ofMsg FundReadRequested
+ Cmd.ofMsg PositionsReadRequested
+ Cmd.ofMsg ReturnsReadRequested
+ ]
+ else
+ model, Cmd.none
+ | InvestmentPlansRunFailed (requestId, fundId, message) ->
+ if requestId = model.planRunSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ { model with planRunInFlight = false; error = Some message }, Cmd.none
+ else
+ model, Cmd.none
let private navText (text: string) =
match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with
@@ -3389,6 +3628,122 @@ let private returnsPanel model dispatch =
]
]
+let private investmentPlanFrequencyText (frequency: string) =
+ if frequency = "daily" then "每日"
+ elif frequency = "weekly" then "每周"
+ elif frequency = "monthly" then "每月"
+ else frequency
+
+let private investmentPlanRunStatusText (status: string) =
+ if status = "succeeded" then "已执行"
+ elif status = "pending_nav" then "待净值"
+ elif status = "insufficient_cash" then "现金不足"
+ elif status = "failed" then "失败"
+ else status
+
+let private investmentPlanRow (plan: InvestmentPlan) =
+ Html.div [
+ prop.className "order-row"
+ prop.children [
+ Html.span [ prop.className "order-code"; prop.text plan.instrumentCode ]
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "金额 %s" plan.amount) ]
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "频率 %s" (investmentPlanFrequencyText plan.frequency)) ]
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "起始日 %s" plan.anchorDate) ]
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "下次执行 %s" plan.nextRunDate) ]
+
+ let lastRun =
+ match plan.lastRunStatus with
+ | Some status ->
+ sprintf "最近执行 %s(%s)" (investmentPlanRunStatusText status) (plan.lastRunDate |> Option.defaultValue "—")
+ | None -> "最近执行 暂无"
+
+ Html.span [ prop.className "order-cell"; prop.text lastRun ]
+ Html.span [ prop.className "order-status"; prop.text (if plan.status = "active" then "进行中" else plan.status) ]
+ ]
+ ]
+
+let private investmentPlanRunRow (plan: InvestmentPlanRunPlan) =
+ Html.div [
+ prop.className "order-row"
+ prop.children [
+ Html.span [ prop.className "order-code"; prop.text plan.instrumentCode ]
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "金额 %s" plan.amount) ]
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "频率 %s" (investmentPlanFrequencyText plan.frequency)) ]
+
+ let runs =
+ if List.isEmpty plan.runs then
+ "本次无到期扣款"
+ else
+ plan.runs
+ |> List.map (fun run -> sprintf "%s %s" run.runDate (investmentPlanRunStatusText run.status))
+ |> String.concat "、"
+
+ Html.span [ prop.className "order-cell"; prop.text runs ]
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "下次执行 %s" plan.nextRunDate) ]
+ ]
+ ]
+
+let private investmentPlansPanel model dispatch =
+ Html.section [
+ prop.className "panel plans-panel"
+ prop.children [
+ Html.div [
+ prop.className "section-heading"
+ prop.children [
+ Html.div [
+ Html.p [ prop.className "eyebrow"; prop.text "11 / PLANS" ]
+ Html.h2 "定投计划执行"
+ ]
+ Html.span [ prop.className "section-note"; prop.text "scheduled contribution" ]
+ ]
+ ]
+ Html.p [
+ prop.className "hint"
+ prop.text "执行到期计划:按当日净值扣款买入,重复执行不产生第二笔;存款与买入都不计入收益。"
+ ]
+ Html.div [
+ prop.className "sip-plans plans-list"
+ prop.children [
+ if List.isEmpty model.investmentPlans then
+ Html.p [ prop.className "hint"; prop.text "暂无定投计划" ]
+ else
+ yield! (model.investmentPlans |> List.map investmentPlanRow)
+ ]
+ ]
+ match model.lastPlanRun with
+ | Some run ->
+ Html.div [
+ prop.className "plans-run-result"
+ prop.children [
+ Html.p [ prop.className "hint"; prop.text (sprintf "最近执行处理日 %s" run.processingDate) ]
+
+ if List.isEmpty run.plans then
+ Html.p [ prop.className "hint"; prop.text "本次没有可执行的计划" ]
+ else
+ yield! (run.plans |> List.map investmentPlanRunRow)
+ ]
+ ]
+ | None -> Html.none
+ Html.div [
+ prop.className "panel-actions"
+ prop.children [
+ Html.button [
+ prop.className "primary-action plans-run-action"
+ prop.disabled model.planRunInFlight
+ prop.onClick (fun _ -> dispatch InvestmentPlansRunRequested)
+ prop.text ((if model.planRunInFlight then "执行中..." else "执行到期计划"): string)
+ ]
+ Html.button [
+ prop.className "secondary-action plans-refresh-action"
+ prop.disabled model.planInFlight
+ prop.onClick (fun _ -> dispatch InvestmentPlansReadRequested)
+ prop.text ((if model.planInFlight then "读取中..." else "刷新计划"): string)
+ ]
+ ]
+ ]
+ ]
+ ]
+
let view model dispatch =
Html.main [
prop.className "app-shell"
@@ -3451,6 +3806,7 @@ let view model dispatch =
rebalancePanel model dispatch
dividendPanel model dispatch
returnsPanel model dispatch
+ investmentPlansPanel model dispatch
Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ & SUBSCRIBE" ]
]
]