summaryrefslogtreecommitdiff
path: root/src/FundLab.Web
diff options
context:
space:
mode:
Diffstat (limited to 'src/FundLab.Web')
-rw-r--r--src/FundLab.Web/App.fs561
-rw-r--r--src/FundLab.Web/src/api.js11
-rw-r--r--src/FundLab.Web/src/styles.css22
3 files changed, 485 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" ]
]
]
diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js
index 4f3f47c..79b20b0 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -183,6 +183,17 @@ export function executeRebalancePlan(token, fundId, planId) {
);
}
+export function previewRebalancePlan(token, fundId, planId) {
+ return requestJson(
+ `/api/funds/${encodeURIComponent(fundId)}/rebalance/plans/${encodeURIComponent(planId)}/preview`,
+ token
+ );
+}
+
+export function getRebalanceExecutions(token, fundId) {
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}/rebalance/executions`, token);
+}
+
export function createCapitalDeposit(token, fundId, payload) {
const body = `{"amount":${JSON.stringify(payload.amount)},"note":${JSON.stringify(payload.note || null)}}`;
return requestJson(`/api/funds/${encodeURIComponent(fundId)}/capital/deposit`, token, {
diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css
index de00388..a3bdfba 100644
--- a/src/FundLab.Web/src/styles.css
+++ b/src/FundLab.Web/src/styles.css
@@ -560,6 +560,28 @@ h2 {
border-top: 1px dashed #e2e8f0;
}
+.rebalance-targets,
+.rebalance-plans,
+.rebalance-preview,
+.rebalance-executions {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.rebalance-preview,
+.rebalance-executions {
+ margin-top: 10px;
+ padding-top: 8px;
+ border-top: 1px dashed #e2e8f0;
+}
+
+.rebalance-panel .order-actions {
+ display: inline-flex;
+ gap: 8px;
+ margin-left: auto;
+}
+
.footer-note {
padding: 16px 0 34px;
font-family: "SFMono-Regular", Consolas, monospace;