diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/FundLab.Api/App.fs | 7 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 8 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 483 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 28 |
4 files changed, 525 insertions, 1 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index ac4c174..4ae00de 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -544,7 +544,12 @@ module App = | Some code, Some percentText -> match tryDecimal "targetPercent" percentText with | Ok percent -> - let targetRow: RebalancePolicy.TargetAllocation = { InstrumentCode = code; TargetPercent = percent } + let targetRow: RebalancePolicy.TargetAllocation = + { + RebalancePolicy.TargetAllocation.InstrumentCode = code + TargetPercent = percent + } + rows.Add(targetRow) | Error message -> failure <- Some message | _ -> failure <- Some "each target needs instrumentCode and targetPercent" diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs index d64fe98..6c0374a 100644 --- a/src/FundLab.Api/Persistence.fs +++ b/src/FundLab.Api/Persistence.fs @@ -612,6 +612,14 @@ type FundRepository(connectionString: string) = created_at timestamptz NOT NULL DEFAULT now() ); + CREATE TABLE IF NOT EXISTS rebalance_idempotencies ( + idempotency_key text PRIMARY KEY, + request_hash text NOT NULL, + plan_id uuid NOT NULL REFERENCES rebalance_plans(id), + fund_id uuid NOT NULL REFERENCES funds(id), + created_at timestamptz NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS rebalance_targets ( plan_id uuid NOT NULL REFERENCES rebalance_plans(id), instrument_code text NOT NULL REFERENCES instruments(code), 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" ] ] ] diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index c5d4c2c..d4762ee 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -135,6 +135,34 @@ export function getSipPlans(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token); } +export function createRebalancePlan(token, fundId, payload) { + const body = `{"targets":${JSON.stringify(payload.targets)}}`; + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/rebalance/plans`, token, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": payload.idempotencyKey + }, + body + }); +} + +export function getRebalancePlans(token, fundId) { + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/rebalance/plans`, token); +} + +export function executeRebalancePlan(token, fundId, planId) { + return requestJson( + `/api/funds/${encodeURIComponent(fundId)}/rebalance/plans/${encodeURIComponent(planId)}/execute`, + token, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}" + } + ); +} + 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, { |
