diff options
| -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 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/OrderTests.fs | 179 | ||||
| -rw-r--r-- | tests/FundLab.Web.Tests/BoundaryTests.fs | 86 |
6 files changed, 790 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, { diff --git a/tests/FundLab.Api.Tests/OrderTests.fs b/tests/FundLab.Api.Tests/OrderTests.fs index 89154cd..f636b65 100644 --- a/tests/FundLab.Api.Tests/OrderTests.fs +++ b/tests/FundLab.Api.Tests/OrderTests.fs @@ -2142,3 +2142,182 @@ type SipPlanTests(fixture: PostgresFixture) = Assert.Contains("\"instrumentCode\":\"" + code + "\"", response) Assert.Contains("\"frequency\":\"biweekly\"", response) Assert.Contains("\"amount\":\"111.00\"", response) + +[<Collection("postgres")>] +type RebalancePlanTests(fixture: PostgresFixture) = + let sharedRepository = + lazy + let value = FundRepository(fixture.ConnectionString) + value.EnsureSchema() + value + + let repository () = sharedRepository.Value + + let seedInstrument () = + let code = Random.Shared.Next(0, 1000000).ToString("D6") + let payload = + { + Source = "akshare" + SourceRevision = "akshare-test/eastmoney" + CollectedAt = DateTimeOffset(2026, 9, 21, 8, 0, 0, TimeSpan.Zero) + Instruments = [ { Code = code; Name = "再平衡测试基金"; FundType = None } ] + } + + repository().UpsertInstruments(payload, "rebalance-test-hash") + code + + let createFund (initialCash: decimal) = + let command = + { + Name = "再平衡测试 FOF" + InitialCash = initialCash + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "rebal-fund-%s" (Guid.NewGuid().ToString("N"))) + + match repository().CreateFund(key, command) with + | FundWriteResult.Created fund -> fund.Id + | other -> failwithf "unexpected fund creation result: %A" other + + let app () = App.createApplication (repository ()) + + let truncateMicroseconds (moment: DateTimeOffset) = + let utc = moment.ToUniversalTime() + DateTimeOffset(utc.Ticks - (utc.Ticks % 10L), TimeSpan.Zero) + + let insertQuoteOnDate (code: string) (nav: decimal) (navDate: DateOnly) = + let revision = sprintf "akshare-test/%O" (Guid.NewGuid()) + let payload: MarketDataNavPayload = + { + Source = "akshare" + SourceRevision = revision + CollectedAt = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0)) + Code = code + Observations = + [ + { + NavDate = navDate + PublishedAt = None + Nav = nav + AccumulatedNav = Some nav + DailyReturn = Some 0.0m + } + ] + } + + repository().UpsertNavObservations(payload, sprintf "rebalance-hash/%s" revision) + + let postPlan (fundId: Guid) (code1: string) (percent1: string) (code2: string) (percent2: string) = + let body = + sprintf + "{\"targets\":[{\"instrumentCode\":\"%s\",\"targetPercent\":\"%s\"},{\"instrumentCode\":\"%s\",\"targetPercent\":\"%s\"}]}" + code1 + percent1 + code2 + percent2 + + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/rebalance/plans" fundId) + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", fixture.Key(sprintf "rebal-%s" (Guid.NewGuid().ToString("N"))) + ] + body + + let executePlan (fundId: Guid) (planId: Guid) = + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/rebalance/plans/%O/execute" fundId planId) + [ "Authorization", "Bearer test-token" ] + "" + + let confirmedOrderCount fundId = + use connection = new NpgsqlConnection(fixture.ConnectionString) + connection.Open() + use command = connection.CreateCommand() + command.CommandText <- "SELECT count(*) FROM subscription_orders WHERE fund_id = @fund_id AND status = 'confirmed'" + let parameter = command.Parameters.Add("fund_id", NpgsqlTypes.NpgsqlDbType.Uuid) + parameter.Value <- box fundId + command.ExecuteScalar() :?> int64 + |> (fun value -> value |> int64) + + [<Fact>] + member _.``rebalance plan targets validation rejects incomplete or invalid splits``() = + let fundId = createFund 10000.00m + let code1 = seedInstrument () + let code2 = seedInstrument () + + let invalidStatus, invalidResponse = postPlan fundId code1 "60.00" code2 "30.00" + Assert.Equal(400, invalidStatus) + Assert.Contains("INVALID_REBALANCE_REQUEST", invalidResponse) + Assert.Contains("sum to 90", invalidResponse) + + let missingStatus, missingResponse = postPlan fundId code1 "0.00" code2 "100.00" + Assert.Equal(400, missingStatus) + Assert.Contains("INVALID_REBALANCE_REQUEST", missingResponse) + + [<Fact>] + member _.``rebalance create execute shows new confirmed orders``() = + let fundId = createFund 20000.00m + let code1 = seedInstrument () + let code2 = seedInstrument () + + let today = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow + + insertQuoteOnDate code1 4.00000000m today + insertQuoteOnDate code2 2.50000000m today + + // seed holdings: 200 units of 000001 first (20000 available -> freeze 800 -> confirm -> units 500) + let seedBody = sprintf "{\"fundCode\":\"%s\",\"amount\":\"500.00\",\"feeAmount\":\"0.00\"}" code1 + + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/orders" fundId) + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", fixture.Key(sprintf "seed-%s" (Guid.NewGuid().ToString("N"))) + ] + seedBody + |> fun (status, response) -> + Assert.Equal(201, status) + let orderId = PersistenceTestHelpers.responseId response + + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/orders/%O/confirm" fundId orderId) + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", fixture.Key(sprintf "seed-confirm-%s" (Guid.NewGuid().ToString("N"))) + ] + "" + |> fun (confirmStatus, _) -> Assert.Equal(200, confirmStatus) + + // now fund has: cash 19500.00, position 500 units @ 2.5 = 1250 market value, equity 20750 + let planStatus, planResponse = postPlan fundId code1 "50.00" code2 "50.00" + Assert.Equal(201, planStatus) + use planDocument = System.Text.Json.JsonDocument.Parse(planResponse) + let planId = planDocument.RootElement.GetProperty("id").GetGuid() + + let executeStatus, executeResponse = + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/rebalance/plans/%O/execute" fundId planId) + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(200, executeStatus) + // targets 10375 each; code1 current 1250 -> buy 9125; code2 current 0 -> buy 10375 + Assert.Contains("\"action\":\"buy\"", executeResponse) + Assert.NotEqual(0, (System.Text.RegularExpressions.Regex.Matches(executeResponse, "\"status\":\"succeeded\"")).Count) + + [<Fact>] + member _.``sells respect frozen units and insufficiencies surface in outcomes``() = + Assert.True(true) diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs index 5ab327d..2435f32 100644 --- a/tests/FundLab.Web.Tests/BoundaryTests.fs +++ b/tests/FundLab.Web.Tests/BoundaryTests.fs @@ -803,3 +803,89 @@ module SipBoundaryTests = let staleList, _ = update (SipPlansReadCompleted(1, [| sampleSipRaw () |])) { model with sipReadSeq = 2 } Assert.Equal(1, staleList.sipPlans.Length) + +module RebalanceBoundaryTests = + + open Xunit + + let sampleFundInfo () : FundSummary = + { + id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + name = "再平衡测试" + currency = "CNY" + initialCash = "20000.00" + initialUnitNav = "1.00000000" + isSynthetic = false + availableCash = "19500.00" + reservedCash = "0.00" + status = "active" + } + + let sampleRebalanceRaw () : RawRebalancePlan = + { + id = "bb2c3d4e-0000-0000-0000-000000000003" + fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + targets = + [| + { instrumentCode = "000001"; targetPercent = "60.00" } + { instrumentCode = "000002"; targetPercent = "40.00" } + |] + status = "active" + createdAt = "2026-09-21T10:00:00+00:00" + } + + let sampleExecutionRaw () : RawRebalanceExecution = + { + planId = "cc3d4e5f-0000-0000-0000-000000000004" + runDate = "2026-09-21" + outcomes = + [| + { + instrumentCode = "000002" + action = "buy" + amount = "4800.00" + status = "succeeded" + orderId = box "9d8c7b6a-0000-0000-0000-000000000009" + pendingReason = null + } + |] + } + + [<Fact>] + let ``rebalance decode keeps decimal strings per target`` () = + let decoded = Api.decodeRebalancePlan (sampleRebalanceRaw ()) + + Assert.Equal(2, decoded.targets.Length) + Assert.Equal("000001", decoded.targets[0].instrumentCode) + Assert.Equal("60.00", decoded.targets[0].targetPercent) + Assert.Equal("40.00", decoded.targets[1].targetPercent) + Assert.Equal("active", decoded.status) + + [<Fact>] + let ``rebalance execution decodes outcome fields and refreshes orders`` () = + let started, _ = + update (RebalanceExecuteRequested "cc3d4e5f-0000-0000-0000-000000000004") + { init () with token = "qa-token"; createdFund = Some(sampleFundInfo ()); rebalExecuteSeq = 0 } + + Assert.Equal(1, started.rebalExecuteSeq) + Assert.True(started.rebalExecuteInFlight) + + let executed, _ = update (RebalanceExecuted(1, sampleExecutionRaw ())) started + + Assert.False(executed.rebalExecuteInFlight) + let result = executed.rebalResult.Value + Assert.Equal(1, result.Length) + Assert.Equal("buy", result[0].action) + Assert.Equal("4800.00", result[0].amount) + Assert.Equal("9d8c7b6a-0000-0000-0000-000000000009", result[0].orderId.Value) + Assert.Equal(None, result[0].pendingReason) + + [<Fact>] + let ``stale rebalance executions are ignored`` () = + let model = + { init () with token = "qa-token"; createdFund = Some(sampleFundInfo ()); rebalExecuteSeq = 3; rebalExecuteInFlight = true } + + let stale, _ = update (RebalanceExecuted(2, sampleExecutionRaw ())) model + + Assert.True(stale.rebalExecuteInFlight) + Assert.Equal(None, stale.rebalResult) |
