diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 23:47:50 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 23:47:50 +0800 |
| commit | 36ee23a0c406c16e975276238a861299899ec79a (patch) | |
| tree | 7085f24566b7c53ad75c1ddf8130e563513f673a /src/FundLab.Web/App.fs | |
| parent | d4b539a2791a0cd4d098fecfece0039073a9f7e2 (diff) | |
| download | fund-lab-36ee23a0c406c16e975276238a861299899ec79a.tar.gz | |
Add rebalancing execution slice (3d-11)
Diffstat (limited to 'src/FundLab.Web/App.fs')
| -rw-r--r-- | src/FundLab.Web/App.fs | 561 |
1 files changed, 452 insertions, 109 deletions
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 1386b01..8eb598e 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -222,6 +222,39 @@ type RawRebalanceExecution = outcomes: RawRebalanceOutcome array } +type RawRebalanceExecutionRecord = + { + planId: string + runDate: string + instrumentCode: string + action: string + amount: string + status: string + orderId: obj + pendingReason: obj + executedAt: string + } + +type RawRebalanceWeightRow = + { + instrumentCode: string + targetPercent: string + currentValue: string + currentPercent: string + action: string + deltaAmount: string + deltaUnits: obj + } + +type RawRebalancePreview = + { + planId: string + runDate: string + availableCash: string + equity: string + rows: RawRebalanceWeightRow array + } + type RawSipPlan = { id: string @@ -502,6 +535,45 @@ type CreateRebalancePayload = targets: RebalanceTarget array } +type RebalanceTargetDraft = + { + code: string + percent: string + } + +type RebalanceExecutionRecord = + { + planId: string + runDate: string + instrumentCode: string + action: string + amount: string + status: string + orderId: string option + pendingReason: string option + executedAt: string + } + +type RebalanceWeightRow = + { + instrumentCode: string + targetPercent: string + currentValue: string + currentPercent: string + action: string + deltaAmount: string + deltaUnits: string option + } + +type RebalancePreview = + { + planId: string + runDate: string + availableCash: string + equity: string + rows: RebalanceWeightRow list + } + type OrderDetail = { id: string @@ -649,6 +721,12 @@ module Api = [<Import("executeRebalancePlan", "./src/api.js")>] let executeRebalancePlan (token: string) (fundId: string) (planId: string) : JS.Promise<RawRebalanceExecution> = jsNative + [<Import("previewRebalancePlan", "./src/api.js")>] + let previewRebalancePlan (token: string) (fundId: string) (planId: string) : JS.Promise<RawRebalancePreview> = jsNative + + [<Import("getRebalanceExecutions", "./src/api.js")>] + let getRebalanceExecutions (token: string) (fundId: string) : JS.Promise<RawRebalanceExecutionRecord array> = jsNative + [<Import("createSipPlan", "./src/api.js")>] let createSipPlan (token: string) (fundId: string) (payload: CreateSipPayload) : JS.Promise<RawSipPlan> = jsNative @@ -757,6 +835,39 @@ module Api = pendingReason = decodeOptionalText raw.pendingReason } + let decodeRebalanceExecutionRecord (raw: RawRebalanceExecutionRecord) : RebalanceExecutionRecord = + { + planId = raw.planId + runDate = raw.runDate + instrumentCode = raw.instrumentCode + action = raw.action + amount = raw.amount + status = raw.status + orderId = decodeOptionalText raw.orderId + pendingReason = decodeOptionalText raw.pendingReason + executedAt = raw.executedAt + } + + let decodeRebalanceWeightRow (raw: RawRebalanceWeightRow) : RebalanceWeightRow = + { + instrumentCode = raw.instrumentCode + targetPercent = raw.targetPercent + currentValue = raw.currentValue + currentPercent = raw.currentPercent + action = raw.action + deltaAmount = raw.deltaAmount + deltaUnits = decodeOptionalText raw.deltaUnits + } + + let decodeRebalancePreview (raw: RawRebalancePreview) : RebalancePreview = + { + planId = raw.planId + runDate = raw.runDate + availableCash = raw.availableCash + equity = raw.equity + rows = raw.rows |> Array.map decodeRebalanceWeightRow |> Array.toList + } + let decodeSipPlan (raw: RawSipPlan) : SipPlan = { id = raw.id @@ -918,10 +1029,7 @@ type Model = sipInFlight: bool lastSipAttempt: SipAttempt option sipPlans: SipPlan list - rebalCode1: string - rebalPercent1: string - rebalCode2: string - rebalPercent2: string + rebalTargets: RebalanceTargetDraft list rebalCreateSeq: int rebalReadSeq: int rebalInFlight: bool @@ -929,6 +1037,12 @@ type Model = rebalExecuteInFlight: bool rebalPlans: RebalancePlan list rebalResult: RebalanceOutcome list option + rebalPreviewSeq: int + rebalPreviewInFlight: bool + rebalPreview: RebalancePreview option + rebalExecutionsReadSeq: int + rebalExecutionsInFlight: bool + rebalExecutions: RebalanceExecutionRecord list dividendReadSeq: int dividendInFlight: bool dividends: DividendRecord list @@ -1003,19 +1117,25 @@ type Msg = | SipPlansReadRequested | SipPlansReadCompleted of requestId: int * plans: RawSipPlan array | SipPlansReadFailed of requestId: int * message: string - | RebalCode1Changed of string - | RebalPercent1Changed of string - | RebalCode2Changed of string - | RebalPercent2Changed of string + | RebalTargetCodeChanged of index: int * value: string + | RebalTargetPercentChanged of index: int * value: string + | RebalTargetAdded + | RebalTargetRemoved of index: int | RebalancePlanCreateRequested | RebalancePlanCreateCompleted of requestId: int * fundId: string * plan: RawRebalancePlan | RebalancePlanCreateFailed of requestId: int * fundId: string * message: string | RebalancePlansReadRequested | RebalancePlansReadCompleted of requestId: int * plans: RawRebalancePlan array | RebalancePlansReadFailed of requestId: int * message: string + | RebalancePreviewRequested of planId: string + | RebalancePreviewCompleted of requestId: int * preview: RawRebalancePreview + | RebalancePreviewFailed of requestId: int * message: string | RebalanceExecuteRequested of planId: string | RebalanceExecuted of requestId: int * result: RawRebalanceExecution | RebalanceExecuteFailed of requestId: int * message: string + | RebalanceExecutionsReadRequested + | RebalanceExecutionsReadCompleted of requestId: int * records: RawRebalanceExecutionRecord array + | RebalanceExecutionsReadFailed of requestId: int * message: string | DividendsReadRequested | DividendsReadCompleted of requestId: int * records: RawDividend array | DividendsReadFailed of requestId: int * message: string @@ -1112,10 +1232,7 @@ let init () = sipInFlight = false lastSipAttempt = None sipPlans = [] - rebalCode1 = "" - rebalPercent1 = "" - rebalCode2 = "" - rebalPercent2 = "" + rebalTargets = [ { code = ""; percent = "" }; { code = ""; percent = "" } ] rebalCreateSeq = 0 rebalReadSeq = 0 rebalInFlight = false @@ -1123,6 +1240,12 @@ let init () = rebalExecuteInFlight = false rebalPlans = [] rebalResult = None + rebalPreviewSeq = 0 + rebalPreviewInFlight = false + rebalPreview = None + rebalExecutionsReadSeq = 0 + rebalExecutionsInFlight = false + rebalExecutions = [] dividendReadSeq = 0 dividendInFlight = false dividends = [] @@ -1270,6 +1393,20 @@ let private executeRebalanceCommand token fundId planId requestId = (fun result -> RebalanceExecuted(requestId, result)) (fun error -> RebalanceExecuteFailed(requestId, errorText error)) +let private previewRebalanceCommand token fundId planId requestId = + Cmd.OfPromise.either + (fun () -> Api.previewRebalancePlan token fundId planId) + () + (fun preview -> RebalancePreviewCompleted(requestId, preview)) + (fun error -> RebalancePreviewFailed(requestId, errorText error)) + +let private readRebalanceExecutionsCommand token fundId requestId = + Cmd.OfPromise.either + (fun () -> Api.getRebalanceExecutions token fundId) + () + (fun records -> RebalanceExecutionsReadCompleted(requestId, records)) + (fun error -> RebalanceExecutionsReadFailed(requestId, errorText error)) + let private readDividendsCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getDividends token fundId) @@ -1355,10 +1492,7 @@ let update message model = sipInFlight = false lastSipAttempt = None sipPlans = [] - rebalCode1 = "" - rebalPercent1 = "" - rebalCode2 = "" - rebalPercent2 = "" + rebalTargets = [ { code = ""; percent = "" }; { code = ""; percent = "" } ] rebalCreateSeq = model.rebalCreateSeq + 1 rebalReadSeq = model.rebalReadSeq + 1 rebalInFlight = false @@ -1366,6 +1500,12 @@ let update message model = rebalExecuteInFlight = false rebalPlans = [] rebalResult = None + rebalPreviewSeq = model.rebalPreviewSeq + 1 + rebalPreviewInFlight = false + rebalPreview = None + rebalExecutionsReadSeq = model.rebalExecutionsReadSeq + 1 + rebalExecutionsInFlight = false + rebalExecutions = [] dividendReadSeq = model.dividendReadSeq + 1 dividendInFlight = false dividends = [] @@ -1561,10 +1701,7 @@ let update message model = sipInFlight = false lastSipAttempt = None sipPlans = [] - rebalCode1 = "" - rebalPercent1 = "" - rebalCode2 = "" - rebalPercent2 = "" + rebalTargets = [ { code = ""; percent = "" }; { code = ""; percent = "" } ] rebalCreateSeq = model.rebalCreateSeq + 1 rebalReadSeq = model.rebalReadSeq + 1 rebalInFlight = false @@ -1572,6 +1709,12 @@ let update message model = rebalExecuteInFlight = false rebalPlans = [] rebalResult = None + rebalPreviewSeq = model.rebalPreviewSeq + 1 + rebalPreviewInFlight = false + rebalPreview = None + rebalExecutionsReadSeq = model.rebalExecutionsReadSeq + 1 + rebalExecutionsInFlight = false + rebalExecutions = [] dividendReadSeq = model.dividendReadSeq + 1 dividendInFlight = false dividends = [] @@ -2081,62 +2224,84 @@ let update message model = { model with error = Some message }, Cmd.none else model, Cmd.none - | RebalCode1Changed value -> - { model with rebalCode1 = value; error = None }, Cmd.none - | RebalPercent1Changed value -> - { model with rebalPercent1 = value; error = None }, Cmd.none - | RebalCode2Changed value -> - { model with rebalCode2 = value; error = None }, Cmd.none - | RebalPercent2Changed value -> - { model with rebalPercent2 = value; error = None }, Cmd.none + | RebalTargetCodeChanged (index, value) -> + { + model with + rebalTargets = + model.rebalTargets + |> List.mapi (fun i draft -> if i = index then { draft with code = value } else draft) + error = None + }, + Cmd.none + | RebalTargetPercentChanged (index, value) -> + { + model with + rebalTargets = + model.rebalTargets + |> List.mapi (fun i draft -> if i = index then { draft with percent = value } else draft) + error = None + }, + Cmd.none + | RebalTargetAdded -> + { model with rebalTargets = model.rebalTargets @ [ { code = ""; percent = "" } ]; error = None }, Cmd.none + | RebalTargetRemoved index -> + { + model with + rebalTargets = + model.rebalTargets + |> List.mapi (fun i draft -> i, draft) + |> List.filter (fun (i, _) -> i <> index) + |> List.map snd + error = None + }, + Cmd.none | RebalancePlanCreateRequested -> - let code1 = model.rebalCode1.Trim() - let percent1 = model.rebalPercent1.Trim() - let code2 = model.rebalCode2.Trim() - let percent2 = model.rebalPercent2.Trim() + let participating = + model.rebalTargets + |> List.map (fun draft -> draft.code.Trim(), draft.percent.Trim()) + |> List.filter (fun (code, _) -> code <> "") if String.IsNullOrWhiteSpace model.token then { model with error = Some "请输入 API token" }, Cmd.none elif model.createdFund.IsNone then { model with error = Some "请先创建一个基金" }, Cmd.none - elif code1 = "" || code2 = "" then - { model with error = Some "请输入两个目标基金代码" }, Cmd.none + elif participating.IsEmpty then + { model with error = Some "请输入至少一个目标基金代码" }, Cmd.none elif - not (isValidCashText percent1) - || not (isValidCashText percent2) - || not (isNonNegativeCash percent1) - || not (isNonNegativeCash percent2) + participating + |> List.exists (fun (_, percent) -> not (isValidCashText percent) || not (isNonNegativeCash percent)) then { model with error = Some "目标比例必须是不小于 0 的两位小数百分数,例如 60.00" }, Cmd.none else - let percent1Value = Decimal.Parse(model.rebalPercent1.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture) - let percent2Value = Decimal.Parse(model.rebalPercent2.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture) + let total = + participating + |> List.sumBy (fun (_, percent) -> + Decimal.Parse(percent, NumberStyles.Float, CultureInfo.InvariantCulture)) - if percent1Value + percent2Value <> 100m then + if total <> 100m then { model with error = Some "目标比例合计必须等于 100" }, Cmd.none elif model.rebalInFlight then model, Cmd.none else let requestId = model.rebalCreateSeq + 1 - let idempotencyKey = Guid.NewGuid().ToString("N") { model with rebalCreateSeq = requestId rebalInFlight = true rebalResult = None + rebalPreview = None error = None }, createRebalancePlanCommand model.token model.createdFund.Value.id { - idempotencyKey = idempotencyKey + idempotencyKey = Guid.NewGuid().ToString("N") targets = - [| - { instrumentCode = code1; targetPercent = model.rebalPercent1.Trim() } - { instrumentCode = code2; targetPercent = model.rebalPercent2.Trim() } - |] + participating + |> List.map (fun (code, percent) -> { instrumentCode = code; targetPercent = percent }) + |> List.toArray } requestId | RebalancePlanCreateCompleted (requestId, fundId, plan) -> @@ -2183,6 +2348,38 @@ let update message model = { model with error = Some message }, Cmd.none else model, Cmd.none + | RebalancePreviewRequested planId -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.rebalPreviewInFlight -> + let requestId = model.rebalPreviewSeq + 1 + + { + model with + rebalPreviewSeq = requestId + rebalPreviewInFlight = true + rebalPreview = None + error = None + }, + previewRebalanceCommand model.token fund.id planId requestId + | Some _ when model.rebalPreviewInFlight -> model, Cmd.none + | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none + | None -> model, Cmd.none + | RebalancePreviewCompleted (requestId, raw) -> + if requestId = model.rebalPreviewSeq then + { + model with + rebalPreviewInFlight = false + rebalPreview = Some(Api.decodeRebalancePreview raw) + error = None + }, + Cmd.none + else + model, Cmd.none + | RebalancePreviewFailed (requestId, message) -> + if requestId = model.rebalPreviewSeq then + { model with rebalPreviewInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none | RebalanceExecuteRequested planId -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.rebalExecuteInFlight -> @@ -2204,6 +2401,7 @@ let update message model = { model with rebalExecuteInFlight = false + rebalPreview = None rebalResult = Some (raw.outcomes @@ -2219,7 +2417,13 @@ let update message model = |> Array.toList) error = None }, - Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg OrdersReadRequested; Cmd.ofMsg PositionsReadRequested ] + Cmd.batch + [ + Cmd.ofMsg FundReadRequested + Cmd.ofMsg OrdersReadRequested + Cmd.ofMsg PositionsReadRequested + Cmd.ofMsg RebalanceExecutionsReadRequested + ] else model, Cmd.none | RebalanceExecuteFailed (requestId, message) -> @@ -2227,6 +2431,33 @@ let update message model = { model with rebalExecuteInFlight = false; error = Some message }, Cmd.none else model, Cmd.none + | RebalanceExecutionsReadRequested -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) -> + let requestId = model.rebalExecutionsReadSeq + 1 + + { model with rebalExecutionsReadSeq = requestId; rebalExecutionsInFlight = true }, + readRebalanceExecutionsCommand model.token fund.id requestId + | Some _ -> + { model with error = Some "请输入 API token" }, Cmd.none + | None -> + model, Cmd.none + | RebalanceExecutionsReadCompleted (requestId, records) -> + if requestId = model.rebalExecutionsReadSeq then + { + model with + rebalExecutionsInFlight = false + rebalExecutions = records |> Array.map Api.decodeRebalanceExecutionRecord |> Array.toList + error = None + }, + Cmd.none + else + model, Cmd.none + | RebalanceExecutionsReadFailed (requestId, message) -> + if requestId = model.rebalExecutionsReadSeq then + { model with rebalExecutionsInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none | DividendsReadRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> @@ -3222,6 +3453,94 @@ let private rebalanceOutcomeRow (outcome: RebalanceOutcome) = ] ] +let private rebalanceActionText (action: string) = + if action = "buy" then "买入" + elif action = "sell" then "卖出" + elif action = "hold" then "持有" + else action + +let private rebalanceTargetDraftRow (index: int) (draft: RebalanceTargetDraft) dispatch = + Html.div [ + prop.className "fund-form-row rebalance-target-row" + prop.children [ + Html.label [ + prop.className "field-label" + prop.children [ + Html.span (sprintf "目标基金 %d" (index + 1)) + Html.input [ + prop.className "text-input rebalance-target-code-input" + prop.placeholder "六位基金代码(留空则不参与)" + prop.value draft.code + prop.onChange (fun value -> dispatch (RebalTargetCodeChanged(index, value))) + ] + ] + ] + Html.label [ + prop.className "field-label" + prop.children [ + Html.span "目标比例" + Html.input [ + prop.className "text-input rebalance-target-percent-input" + prop.placeholder "例如 60.00" + prop.value draft.percent + prop.onChange (fun value -> dispatch (RebalTargetPercentChanged(index, value))) + ] + ] + ] + Html.button [ + prop.className "secondary-action rebalance-target-remove-action" + prop.onClick (fun _ -> dispatch (RebalTargetRemoved index)) + prop.text "删除" + ] + ] + ] + +let private rebalanceWeightRow (row: RebalanceWeightRow) = + Html.div [ + prop.className "order-row rebalance-preview-row" + prop.children [ + Html.span [ prop.className "order-code"; prop.text row.instrumentCode ] + Html.span [ + prop.className "order-cell" + prop.text (sprintf "目标 %s%% / 当前 %s%%" row.targetPercent row.currentPercent) + ] + Html.span [ + prop.className "order-cell" + prop.text ( + match row.deltaUnits with + | Some units -> sprintf "%s %s 元 / %s 份" (rebalanceActionText row.action) row.deltaAmount units + | None -> sprintf "%s %s 元" (rebalanceActionText row.action) row.deltaAmount + ) + ] + ] + ] + +let private rebalanceExecutionRow (record: RebalanceExecutionRecord) = + let statusText = + if record.status = "succeeded" then "已确认" + elif record.status = "pending_nav" then "待净值" + elif record.status = "insufficient_cash" then "现金不足" + elif record.status = "insufficient_units" then "持仓不足" + elif record.status = "failed" then "失败" + elif record.status.StartsWith("skipped_") then "已跳过" + else record.status + + Html.div [ + prop.className "order-row rebalance-execution-row" + prop.children [ + Html.span [ prop.className "order-code"; prop.text record.instrumentCode ] + Html.span [ + prop.className "order-cell" + prop.text (sprintf "%s %s %s 元" record.runDate (rebalanceActionText record.action) record.amount) + ] + Html.span [ prop.className "order-status"; prop.text statusText ] + + match record.pendingReason with + | Some reason -> Html.span [ prop.className "order-detail"; prop.text reason ] + | None -> Html.none + ] + ] + let private rebalancePanel model dispatch = Html.section [ prop.className "panel rebalance-panel" @@ -3230,74 +3549,40 @@ let private rebalancePanel model dispatch = prop.className "section-heading" prop.children [ Html.div [ - Html.p [ prop.className "eyebrow"; prop.text "08 / REBALANCE" ] + Html.p [ prop.className "eyebrow"; prop.text "12 / REBALANCE" ] Html.h2 "组合再平衡" ] - Html.span [ prop.className "section-note"; prop.text "Rebalance - manual run" ] + Html.span [ prop.className "section-note"; prop.text "Rebalance - target weights & execution" ] ] ] Html.div [ - prop.className "fund-form-row" + prop.className "rebalance-targets" prop.children [ - Html.label [ - prop.className "field-label" - prop.children [ - Html.span "目标基金 1" - Html.input [ - prop.className "text-input rebalance-code1-input" - prop.placeholder "六位基金代码" - prop.value model.rebalCode1 - prop.onChange (fun value -> dispatch (RebalCode1Changed value)) - ] - ] - ] - Html.label [ - prop.className "field-label" + yield! + (model.rebalTargets + |> List.mapi (fun index draft -> rebalanceTargetDraftRow index draft dispatch)) + Html.div [ + prop.className "fund-form-row" prop.children [ - Html.span "目标比例" - Html.input [ - prop.className "text-input rebalance-percent1-input" - prop.placeholder "例如 60.00" - prop.value model.rebalPercent1 - prop.onChange (fun value -> dispatch (RebalPercent1Changed value)) + Html.button [ + prop.className "secondary-action rebalance-target-add-action" + prop.onClick (fun _ -> dispatch RebalTargetAdded) + prop.text "添加目标" ] - ] - ] - Html.label [ - prop.className "field-label" - prop.children [ - Html.span "目标基金 2" - Html.input [ - prop.className "text-input rebalance-code2-input" - prop.placeholder "六位基金代码" - prop.value model.rebalCode2 - prop.onChange (fun value -> dispatch (RebalCode2Changed value)) + Html.button [ + prop.className "primary-action rebalance-submit-action" + prop.disabled model.rebalInFlight + prop.onClick (fun _ -> dispatch RebalancePlanCreateRequested) + prop.text ((if model.rebalInFlight then "创建中..." else "创建计划"): string) ] ] ] - Html.label [ - prop.className "field-label" - prop.children [ - Html.span "目标比例" - Html.input [ - prop.className "text-input rebalance-percent2-input" - prop.placeholder "例如 40.00" - prop.value model.rebalPercent2 - prop.onChange (fun value -> dispatch (RebalPercent2Changed value)) - ] - ] - ] - Html.button [ - prop.className "primary-action rebalance-submit-action" - prop.disabled model.rebalInFlight - prop.onClick (fun _ -> dispatch RebalancePlanCreateRequested) - prop.text ((if model.rebalInFlight then "创建中..." else "创建计划"): string) - ] ] ] Html.p [ prop.className "hint" - prop.text "两个目标比例合计必须为 100;执行时按估值快照计算差额,经与手工单相同的下单与确认管线成交。" + prop.text + "参与的目标比例合计必须为 100,代码留空的行不参与;执行时按当日估值快照计算差额,经与手工单相同的下单与确认管线成交。" ] Html.div [ prop.className "rebalance-plans" @@ -3309,7 +3594,7 @@ let private rebalancePanel model dispatch = (model.rebalPlans |> List.map (fun plan -> Html.div [ - prop.className "order-row" + prop.className "order-row rebalance-plan-row" prop.children [ Html.span [ prop.className "order-cell" @@ -3319,11 +3604,22 @@ let private rebalancePanel model dispatch = |> String.concat " / ") ] (if plan.status = "active" then - Html.button [ - prop.className "secondary-action rebalance-execute-action" - prop.disabled model.rebalExecuteInFlight - prop.onClick (fun _ -> dispatch (RebalanceExecuteRequested plan.id)) - prop.text ((if model.rebalExecuteInFlight then "执行中..." else "执行再平衡"): string) + Html.span [ + prop.className "order-actions" + prop.children [ + Html.button [ + prop.className "secondary-action rebalance-preview-action" + prop.disabled model.rebalPreviewInFlight + prop.onClick (fun _ -> dispatch (RebalancePreviewRequested plan.id)) + prop.text ((if model.rebalPreviewInFlight then "计算中..." else "预览差额"): string) + ] + Html.button [ + prop.className "secondary-action rebalance-execute-action" + prop.disabled model.rebalExecuteInFlight + prop.onClick (fun _ -> dispatch (RebalanceExecuteRequested plan.id)) + prop.text ((if model.rebalExecuteInFlight then "执行中..." else "执行再平衡"): string) + ] + ] ] else Html.none) @@ -3331,17 +3627,58 @@ let private rebalancePanel model dispatch = ])) ] ] + match model.rebalPreview with + | Some preview -> + let hasDrift = + preview.rows |> List.exists (fun row -> row.action <> "hold") + + Html.div [ + prop.className "rebalance-preview" + prop.children [ + Html.p [ + prop.className "hint" + prop.text ( + sprintf + "估值快照 %s:权益 %s 元 / 可用现金 %s 元%s" + preview.runDate + preview.equity + preview.availableCash + (if hasDrift then "" else ",当前权重已在容差内") + ) + ] + yield! (preview.rows |> List.map rebalanceWeightRow) + ] + ] + | None -> Html.none match model.rebalResult with | Some outcomes -> Html.div [ prop.className "rebalance-result" prop.children [ Html.p [ prop.className "hint"; prop.text "最近一次执行:" ] - yield! (outcomes |> List.map rebalanceOutcomeRow) + + if List.isEmpty outcomes then + Html.p [ + prop.className "hint" + prop.text "当前权重已在容差内,本次未产生任何交易" + ] + else + yield! (outcomes |> List.map rebalanceOutcomeRow) ] ] | None -> Html.none Html.div [ + prop.className "rebalance-executions" + prop.children [ + Html.p [ prop.className "hint"; prop.text "执行记录:" ] + + if List.isEmpty model.rebalExecutions then + Html.p [ prop.className "hint"; prop.text "暂无执行记录" ] + else + yield! (model.rebalExecutions |> List.map rebalanceExecutionRow) + ] + ] + Html.div [ prop.className "panel-actions" prop.children [ Html.button [ @@ -3349,6 +3686,12 @@ let private rebalancePanel model dispatch = prop.onClick (fun _ -> dispatch RebalancePlansReadRequested) prop.text "刷新计划" ] + Html.button [ + prop.className "secondary-action rebalance-executions-refresh-action" + prop.disabled model.rebalExecutionsInFlight + prop.onClick (fun _ -> dispatch RebalanceExecutionsReadRequested) + prop.text ((if model.rebalExecutionsInFlight then "刷新中..." else "刷新执行记录"): string) + ] ] ] ] @@ -3803,10 +4146,10 @@ let view model dispatch = positionsPanel model dispatch redeemPanel model dispatch sipPanel model dispatch - rebalancePanel model dispatch dividendPanel model dispatch returnsPanel model dispatch investmentPlansPanel model dispatch + rebalancePanel model dispatch Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ & SUBSCRIBE" ] ] ] |
