summaryrefslogtreecommitdiff
path: root/src/FundLab.Web/App.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 14:31:13 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 14:31:13 +0800
commit98c41c37896a41c5f2f15ed187e55ab617735342 (patch)
treea57cffc9e9147124af12b79e58ccf083bb12ab2f /src/FundLab.Web/App.fs
parent336d5e5312eb6fa69ccc1fd875e930a067ff8143 (diff)
downloadfund-lab-98c41c37896a41c5f2f15ed187e55ab617735342.tar.gz
Add rebalance first slice (3d-7)
First vertical slice for portfolio rebalancing. Domain gains RebalancePolicy: target-allocation validation (non-empty, per-code shares positive two-decimal, sum exactly 100); diff computation over the union of target and held codes (held without target = full exit) producing BUY amount diffs and SELL unit diffs priced at the current valuation NAV, clamped to available units, with no orders when a weight already balances; and the deterministic idempotency key set (rebalance:{plan}:{date}:{code} plus confirm/redeem counterparts). Rebalance plans persist as rebalance_plans + rebalance_targets with rebalance_idempotencies under the shared replay/conflict contract. ExecuteRebalancePlan snapshots cash plus holdings valued at each position's latest valuation NAV, then walks the diffs SELL-first through the same Create/Confirm pipeline used by manual orders, redemptions and SIP (nothing bypasses confirmation; a held code without a valuation is skipped with an explicit marker; insufficient cash/units fail the period visibly instead of silently trimming). API exposes POST/GET /funds/{id}/rebalance/plans and POST .../plans/{id}/execute. The frontend adds an 08/REBALANCE panel with target editing (two codes, sum must equal 100), a plan list with a manual execute button and post-run outcome rows; executing refreshes orders/positions so the new orders are immediately visible. Domain tests cover split validation, diff orders, exit/clamp logic, key determinism; API tests cover create/execute end-to-end; Web tests cover decoding and stale-execution protection.
Diffstat (limited to 'src/FundLab.Web/App.fs')
-rw-r--r--src/FundLab.Web/App.fs483
1 files changed, 483 insertions, 0 deletions
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index 5d091dc..d6e44d9 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -160,6 +160,29 @@ type RawPosition =
valuationCollectedAt: obj
}
+type RawRebalanceTarget =
+ {
+ instrumentCode: string
+ targetPercent: string
+ }
+
+type RawRebalanceOutcome =
+ {
+ instrumentCode: string
+ action: string
+ amount: string
+ status: string
+ orderId: obj
+ pendingReason: obj
+ }
+
+type RawRebalanceExecution =
+ {
+ planId: string
+ runDate: string
+ outcomes: RawRebalanceOutcome array
+ }
+
type RawSipPlan =
{
id: string
@@ -253,6 +276,15 @@ type CreateCapitalPayload =
note: string
}
+type RawRebalancePlan =
+ {
+ id: string
+ fundId: string
+ targets: RawRebalanceTarget array
+ status: string
+ createdAt: string
+ }
+
type SipAttempt =
{
idempotencyKey: string
@@ -282,6 +314,36 @@ type SipPlan =
lastExecutionDate: string option
}
+type RebalanceTarget =
+ {
+ instrumentCode: string
+ targetPercent: string
+ }
+
+type RebalanceOutcome =
+ {
+ instrumentCode: string
+ action: string
+ amount: string
+ status: string
+ orderId: string option
+ pendingReason: string option
+ }
+
+type RebalancePlan =
+ {
+ id: string
+ targets: RebalanceTarget list
+ status: string
+ createdAt: string
+ }
+
+type CreateRebalancePayload =
+ {
+ idempotencyKey: string
+ targets: RebalanceTarget array
+ }
+
type OrderDetail =
{
id: string
@@ -376,6 +438,15 @@ module Api =
[<Import("createCapitalDeposit", "./src/api.js")>]
let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative
+ [<Import("createRebalancePlan", "./src/api.js")>]
+ let createRebalancePlan (token: string) (fundId: string) (payload: CreateRebalancePayload) : JS.Promise<RawRebalancePlan> = jsNative
+
+ [<Import("getRebalancePlans", "./src/api.js")>]
+ let getRebalancePlans (token: string) (fundId: string) : JS.Promise<RawRebalancePlan array> = jsNative
+
+ [<Import("executeRebalancePlan", "./src/api.js")>]
+ let executeRebalancePlan (token: string) (fundId: string) (planId: string) : JS.Promise<RawRebalanceExecution> = jsNative
+
[<Import("createSipPlan", "./src/api.js")>]
let createSipPlan (token: string) (fundId: string) (payload: CreateSipPayload) : JS.Promise<RawSipPlan> = jsNative
@@ -453,6 +524,31 @@ module Api =
positions = raw.positions |> Array.map decodePosition |> List.ofArray
}
+ let decodeRebalancePlan (raw: RawRebalancePlan) : RebalancePlan =
+ {
+ id = raw.id
+ targets =
+ raw.targets
+ |> Array.map (fun target ->
+ {
+ instrumentCode = target.instrumentCode
+ targetPercent = target.targetPercent
+ })
+ |> Array.toList
+ status = raw.status
+ createdAt = raw.createdAt
+ }
+
+ let decodeRebalanceOutcome (raw: RawRebalanceOutcome) : RebalanceOutcome =
+ {
+ instrumentCode = raw.instrumentCode
+ action = raw.action
+ amount = raw.amount
+ status = raw.status
+ orderId = decodeOptionalText raw.orderId
+ pendingReason = decodeOptionalText raw.pendingReason
+ }
+
let decodeSipPlan (raw: RawSipPlan) : SipPlan =
{
id = raw.id
@@ -538,6 +634,17 @@ type Model =
sipInFlight: bool
lastSipAttempt: SipAttempt option
sipPlans: SipPlan list
+ rebalCode1: string
+ rebalPercent1: string
+ rebalCode2: string
+ rebalPercent2: string
+ rebalCreateSeq: int
+ rebalReadSeq: int
+ rebalInFlight: bool
+ rebalExecuteSeq: int
+ rebalExecuteInFlight: bool
+ rebalPlans: RebalancePlan list
+ rebalResult: RebalanceOutcome list option
error: string option
}
@@ -600,6 +707,19 @@ 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
+ | 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
+ | RebalanceExecuteRequested of planId: string
+ | RebalanceExecuted of requestId: int * result: RawRebalanceExecution
+ | RebalanceExecuteFailed of requestId: int * message: string
let defaultInitialUnitNav = "1.00000000"
@@ -684,6 +804,17 @@ let init () =
sipInFlight = false
lastSipAttempt = None
sipPlans = []
+ rebalCode1 = ""
+ rebalPercent1 = ""
+ rebalCode2 = ""
+ rebalPercent2 = ""
+ rebalCreateSeq = 0
+ rebalReadSeq = 0
+ rebalInFlight = false
+ rebalExecuteSeq = 0
+ rebalExecuteInFlight = false
+ rebalPlans = []
+ rebalResult = None
error = None
}
@@ -798,6 +929,27 @@ let private readSipPlansCommand token fundId requestId =
(fun plans -> SipPlansReadCompleted(requestId, plans))
(fun error -> SipPlansReadFailed(requestId, errorText error))
+let private createRebalancePlanCommand token fundId payload requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.createRebalancePlan token fundId payload)
+ ()
+ (fun plan -> RebalancePlanCreateCompleted(requestId, fundId, plan))
+ (fun error -> RebalancePlanCreateFailed(requestId, fundId, errorText error))
+
+let private readRebalancePlansCommand token fundId requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.getRebalancePlans token fundId)
+ ()
+ (fun plans -> RebalancePlansReadCompleted(requestId, plans))
+ (fun error -> RebalancePlansReadFailed(requestId, errorText error))
+
+let private executeRebalanceCommand token fundId planId requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.executeRebalancePlan token fundId planId)
+ ()
+ (fun result -> RebalanceExecuted(requestId, result))
+ (fun error -> RebalanceExecuteFailed(requestId, errorText error))
+
let update message model =
match message with
| TokenChanged token ->
@@ -855,6 +1007,17 @@ let update message model =
sipInFlight = false
lastSipAttempt = None
sipPlans = []
+ rebalCode1 = ""
+ rebalPercent1 = ""
+ rebalCode2 = ""
+ rebalPercent2 = ""
+ rebalCreateSeq = model.rebalCreateSeq + 1
+ rebalReadSeq = model.rebalReadSeq + 1
+ rebalInFlight = false
+ rebalExecuteSeq = model.rebalExecuteSeq + 1
+ rebalExecuteInFlight = false
+ rebalPlans = []
+ rebalResult = None
error = None
},
Cmd.none
@@ -1038,6 +1201,17 @@ let update message model =
sipInFlight = false
lastSipAttempt = None
sipPlans = []
+ rebalCode1 = ""
+ rebalPercent1 = ""
+ rebalCode2 = ""
+ rebalPercent2 = ""
+ rebalCreateSeq = model.rebalCreateSeq + 1
+ rebalReadSeq = model.rebalReadSeq + 1
+ rebalInFlight = false
+ rebalExecuteSeq = model.rebalExecuteSeq + 1
+ rebalExecuteInFlight = false
+ rebalPlans = []
+ rebalResult = None
error = None
},
Cmd.ofMsg OrdersReadRequested
@@ -1535,6 +1709,153 @@ 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
+ | RebalancePlanCreateRequested ->
+ let code1 = model.rebalCode1.Trim()
+ let percent1 = model.rebalPercent1.Trim()
+ let code2 = model.rebalCode2.Trim()
+ let percent2 = model.rebalPercent2.Trim()
+
+ 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
+ not (isValidCashText percent1)
+ || not (isValidCashText percent2)
+ || not (isNonNegativeCash percent1)
+ || not (isNonNegativeCash percent2)
+ 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)
+
+ if percent1Value + percent2Value <> 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
+ error = None
+ },
+ createRebalancePlanCommand
+ model.token
+ model.createdFund.Value.id
+ {
+ idempotencyKey = idempotencyKey
+ targets =
+ [|
+ { instrumentCode = code1; targetPercent = model.rebalPercent1.Trim() }
+ { instrumentCode = code2; targetPercent = model.rebalPercent2.Trim() }
+ |]
+ }
+ requestId
+ | RebalancePlanCreateCompleted (requestId, fundId, plan) ->
+ if requestId = model.rebalCreateSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ {
+ model with
+ rebalInFlight = false
+ rebalResult = None
+ error = None
+ },
+ Cmd.ofMsg RebalancePlansReadRequested
+ else
+ model, Cmd.none
+ | RebalancePlanCreateFailed (requestId, fundId, message) ->
+ if requestId = model.rebalCreateSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ { model with rebalInFlight = false; error = Some message }, Cmd.none
+ else
+ model, Cmd.none
+ | RebalancePlansReadRequested ->
+ match model.createdFund with
+ | Some fund when not (String.IsNullOrWhiteSpace model.token) ->
+ let requestId = model.rebalReadSeq + 1
+
+ { model with rebalReadSeq = requestId },
+ readRebalancePlansCommand model.token fund.id requestId
+ | Some _ ->
+ { model with error = Some "请输入 API token" }, Cmd.none
+ | None ->
+ model, Cmd.none
+ | RebalancePlansReadCompleted (requestId, plans) ->
+ if requestId = model.rebalReadSeq then
+ {
+ model with
+ rebalPlans = plans |> Array.map Api.decodeRebalancePlan |> Array.toList
+ error = None
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | RebalancePlansReadFailed (requestId, message) ->
+ if requestId = model.rebalReadSeq then
+ { model with 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 ->
+ let requestId = model.rebalExecuteSeq + 1
+
+ {
+ model with
+ rebalExecuteSeq = requestId
+ rebalExecuteInFlight = true
+ rebalResult = None
+ error = None
+ },
+ executeRebalanceCommand model.token fund.id planId requestId
+ | Some _ when model.rebalExecuteInFlight -> model, Cmd.none
+ | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none
+ | None -> model, Cmd.none
+ | RebalanceExecuted (requestId, raw) ->
+ if requestId = model.rebalExecuteSeq then
+ {
+ model with
+ rebalExecuteInFlight = false
+ rebalResult =
+ Some
+ (raw.outcomes
+ |> Array.map (fun outcome ->
+ {
+ instrumentCode = outcome.instrumentCode
+ action = outcome.action
+ amount = outcome.amount
+ status = outcome.status
+ orderId = Api.decodeOptionalText outcome.orderId
+ pendingReason = Api.decodeOptionalText outcome.pendingReason
+ })
+ |> Array.toList)
+ error = None
+ },
+ Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg OrdersReadRequested; Cmd.ofMsg PositionsReadRequested ]
+ else
+ model, Cmd.none
+ | RebalanceExecuteFailed (requestId, message) ->
+ if requestId = model.rebalExecuteSeq then
+ { model with rebalExecuteInFlight = false; error = Some message }, Cmd.none
+ else
+ model, Cmd.none
+
let private navText (text: string) =
match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with
@@ -2369,6 +2690,167 @@ let private sipPanel model dispatch =
]
]
+let private rebalanceOutcomeRow (outcome: RebalanceOutcome) =
+ let statusText =
+ if outcome.status = "succeeded" then "已确认"
+ elif outcome.status = "pending_nav" then "待净值"
+ elif outcome.status = "insufficient_cash" then "现金不足"
+ elif outcome.status = "insufficient_units" then "持仓不足"
+ elif outcome.status = "failed" then "失败"
+ elif outcome.status.StartsWith("skipped_") then "已跳过"
+ else outcome.status
+
+ let actionText =
+ if outcome.action = "buy" then "买入"
+ elif outcome.action = "sell" then "卖出"
+ else outcome.action
+
+ Html.div [
+ prop.className "order-row"
+ prop.children [
+ Html.span [ prop.className "order-code"; prop.text outcome.instrumentCode ]
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "%s %s 元" actionText outcome.amount) ]
+ Html.span [ prop.className "order-status"; prop.text statusText ]
+
+ match outcome.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"
+ prop.children [
+ Html.div [
+ prop.className "section-heading"
+ prop.children [
+ Html.div [
+ Html.p [ prop.className "eyebrow"; prop.text "08 / REBALANCE" ]
+ Html.h2 "组合再平衡"
+ ]
+ Html.span [ prop.className "section-note"; prop.text "Rebalance - manual run" ]
+ ]
+ ]
+ Html.div [
+ prop.className "fund-form-row"
+ 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"
+ 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.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.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;执行时按估值快照计算差额,经与手工单相同的下单与确认管线成交。"
+ ]
+ Html.div [
+ prop.className "rebalance-plans"
+ prop.children [
+ if List.isEmpty model.rebalPlans then
+ Html.p [ prop.className "hint"; prop.text "暂无再平衡计划" ]
+ else
+ yield!
+ (model.rebalPlans
+ |> List.map (fun plan ->
+ Html.div [
+ prop.className "order-row"
+ prop.children [
+ Html.span [
+ prop.className "order-cell"
+ prop.text
+ (plan.targets
+ |> List.map (fun target -> sprintf "%s %s%%" target.instrumentCode target.targetPercent)
+ |> 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)
+ ]
+ else
+ 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)
+ ]
+ ]
+ | None -> Html.none
+ Html.div [
+ prop.className "panel-actions"
+ prop.children [
+ Html.button [
+ prop.className "secondary-action rebalance-plans-refresh-action"
+ prop.onClick (fun _ -> dispatch RebalancePlansReadRequested)
+ prop.text "刷新计划"
+ ]
+ ]
+ ]
+ ]
+ ]
+
+
let view model dispatch =
Html.main [
prop.className "app-shell"
@@ -2428,6 +2910,7 @@ let view model dispatch =
positionsPanel model dispatch
redeemPanel model dispatch
sipPanel model dispatch
+ rebalancePanel model dispatch
Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ & SUBSCRIBE" ]
]
]