diff options
| -rw-r--r-- | docs/qa-snapshots/qa-2026-09-21-rebalance.png | bin | 0 -> 128119 bytes | |||
| -rw-r--r-- | qa/driver/browser-test.js | 139 | ||||
| -rwxr-xr-x | qa/run.sh | 4 | ||||
| -rw-r--r-- | src/FundLab.Api/App.fs | 109 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 187 | ||||
| -rw-r--r-- | src/FundLab.Domain/Rebalance.fs | 124 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 561 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 11 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 22 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/RebalanceTests.fs | 278 | ||||
| -rw-r--r-- | tests/FundLab.Domain.Tests/DomainTests.fs | 48 |
12 files changed, 1343 insertions, 141 deletions
diff --git a/docs/qa-snapshots/qa-2026-09-21-rebalance.png b/docs/qa-snapshots/qa-2026-09-21-rebalance.png Binary files differnew file mode 100644 index 0000000..5f64122 --- /dev/null +++ b/docs/qa-snapshots/qa-2026-09-21-rebalance.png diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js index 6b2d2ec..c7ee468 100644 --- a/qa/driver/browser-test.js +++ b/qa/driver/browser-test.js @@ -28,6 +28,10 @@ let lastRedemptionConfirmResponse = null; let lastReturnsResponse = null; let lastPlanRunResponse = null; let lastPlansResponse = null; +let lastRebalancePlansResponse = null; +let lastRebalancePreviewResponse = null; +let lastRebalanceExecuteResponse = null; +let lastRebalanceExecutionsResponse = null; function check(name, ok, detail) { results.push({ name, ok, detail: detail || "" }); @@ -147,6 +151,26 @@ async function summaryLabelExists(page, label, name) { lastPlansResponse = await r.json(); } catch {} } + if (/\/rebalance\/plans$/i.test(new URL(r.url()).pathname) && r.request().method() === "GET" && r.status() < 400) { + try { + lastRebalancePlansResponse = await r.json(); + } catch {} + } + if (/\/rebalance\/plans\/[0-9a-f-]+\/preview$/i.test(new URL(r.url()).pathname) && r.request().method() === "GET" && r.status() < 400) { + try { + lastRebalancePreviewResponse = await r.json(); + } catch {} + } + if (/\/rebalance\/plans\/[0-9a-f-]+\/execute$/i.test(new URL(r.url()).pathname) && r.request().method() === "POST" && r.status() < 400) { + try { + lastRebalanceExecuteResponse = await r.json(); + } catch {} + } + if (/\/rebalance\/executions$/i.test(new URL(r.url()).pathname) && r.request().method() === "GET" && r.status() < 400) { + try { + lastRebalanceExecutionsResponse = await r.json(); + } catch {} + } if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url())) { consoleErrors.push("resource " + r.status() + ": " + r.url()); } @@ -195,6 +219,7 @@ async function summaryLabelExists(page, label, name) { await dividendScenario(page); await returnsScenario(page); await plansScenario(page); + await rebalanceScenario(page); } finally { check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); await browser.close(); @@ -574,6 +599,120 @@ async function plansScenario(page) { await page.screenshot({ path: SHOTS + "/17-investment-plans.png" }); } +async function rebalanceScenario(page) { + // R 系列: 再平衡最小闭环(接 P 场景:现金 5343.00,持仓 134.01949252 @1.1902) + await page.waitForSelector(".rebalance-panel", { timeout: 10000 }); + + const eyebrow = ((await page.textContent(".rebalance-panel .eyebrow")) || "").trim(); + check("P3 再平衡面板 eyebrow 为 12 / REBALANCE", eyebrow === "12 / REBALANCE", eyebrow); + await page.locator(".rebalance-target-code-input").first().fill("000001"); + await page.locator(".rebalance-target-percent-input").first().fill("100.00"); + await page.click(".rebalance-submit-action"); + const planRowAppeared = await page + .waitForSelector(".rebalance-plan-row", { timeout: 15000 }) + .then(() => true) + .catch(() => false); + + for (let i = 0; i < 200 && lastRebalancePlansResponse === null; i++) { + await page.waitForTimeout(100); + } + + const plans = Array.isArray(lastRebalancePlansResponse) ? lastRebalancePlansResponse : []; + const plan = plans.length > 0 ? plans[0] : {}; + const planText = ((await page.textContent(".rebalance-plans")) || ""); + + check( + "P4 目标权重计划创建(000001 100%)", + planRowAppeared && planText.includes("000001 100.00%") && Array.isArray(plan.targets) && plan.targets[0]?.targetPercent === "100.00", + `plans=${JSON.stringify(plans).slice(0, 200)}` + ); + + lastRebalancePreviewResponse = null; + await page.click(".rebalance-preview-action"); + for (let i = 0; i < 200 && lastRebalancePreviewResponse === null; i++) { + await page.waitForTimeout(100); + } + + const preview = lastRebalancePreviewResponse || {}; + const previewRows = Array.isArray(preview.rows) ? preview.rows : []; + const previewRow = previewRows.length > 0 ? previewRows[0] : {}; + const previewText = ((await page.textContent(".rebalance-preview")) || ""); + + check( + "P5 预览展示目标对比当前及差额买入", + previewRow.instrumentCode === "000001" && + previewRow.targetPercent === "100.00" && + previewRow.currentValue === "159.51" && + previewRow.currentPercent === "2.89" && + previewRow.action === "buy" && + previewRow.deltaAmount === "5343.00" && + preview.availableCash === "5343.00" && + preview.equity === "5502.51" && + previewText.includes("目标 100.00%") && + previewText.includes("当前 2.89%"), + `preview=${JSON.stringify(preview).slice(0, 260)}` + ); + + lastRebalanceExecuteResponse = null; + await page.click(".rebalance-execute-action"); + for (let i = 0; i < 200 && lastRebalanceExecuteResponse === null; i++) { + await page.waitForTimeout(100); + } + + const execution = lastRebalanceExecuteResponse || {}; + const outcomes = Array.isArray(execution.outcomes) ? execution.outcomes : []; + + for (let i = 0; i < 200 && (lastPositionsResponse === null || lastPositionsResponse.availableCash !== "0.01"); i++) { + await page.waitForTimeout(100); + } + + for (let i = 0; i < 200 && lastRebalanceExecutionsResponse === null; i++) { + await page.waitForTimeout(100); + } + + const executions = Array.isArray(lastRebalanceExecutionsResponse) ? lastRebalanceExecutionsResponse : []; + const boughtUnits = parseFloat(lastPositionsResponse?.positions?.[0]?.units || "0"); + + check( + "P6 执行再平衡按当日净值补足差额", + execution.runDate === "2026-09-21" && + outcomes.length === 1 && + outcomes[0].instrumentCode === "000001" && + outcomes[0].action === "buy" && + outcomes[0].amount === "5343.00" && + outcomes[0].status === "succeeded" && + lastPositionsResponse && + lastPositionsResponse.availableCash === "0.01" && + boughtUnits > 134.01949252 && + executions.length === 1 && + executions[0].action === "buy" && + executions[0].status === "succeeded", + `outcomes=${JSON.stringify(outcomes).slice(0, 200)} cash=${lastPositionsResponse?.availableCash} units=${lastPositionsResponse?.positions?.[0]?.units}` + ); + + const unitsAfterFirst = lastPositionsResponse?.positions?.[0]?.units; + + lastRebalanceExecuteResponse = null; + await page.click(".rebalance-execute-action"); + for (let i = 0; i < 200 && lastRebalanceExecuteResponse === null; i++) { + await page.waitForTimeout(100); + } + + const replay = lastRebalanceExecuteResponse || {}; + const replayOutcomes = Array.isArray(replay.outcomes) ? replay.outcomes : []; + + check( + "P7 再次执行容差内无新交易", + replayOutcomes.length === 0 && + lastPositionsResponse && + lastPositionsResponse.availableCash === "0.01" && + lastPositionsResponse.positions?.[0]?.units === unitsAfterFirst, + `outcomes=${JSON.stringify(replayOutcomes).slice(0, 160)} cash=${lastPositionsResponse?.availableCash}` + ); + + await page.screenshot({ path: SHOTS + "/18-rebalance.png" }); +} + const failed = results.filter((r) => !r.ok); console.log(`\n==== ${results.length - failed.length}/${results.length} passed ====`); process.exit(failed.length > 0 ? 1 : 0); @@ -9,8 +9,8 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" QA_DIR="$ROOT/qa" API_PORT=5095 WEB_PORT=5175 -PG_PORT=55434 -PG_CONTAINER=fund-lab-qa-pg-3c1 +PG_PORT="${PG_PORT:-55434}" +PG_CONTAINER="${PG_CONTAINER:-fund-lab-qa-pg-3c1}" PG_IMAGE=postgres:16-alpine PG_DB=fund_lab_qa PG_USER=fund_qa diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index b559ac2..34b4f2d 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -224,6 +224,39 @@ type RebalanceExecutionResponse = outcomes: {| instrumentCode: string; action: string; amount: string; status: string; orderId: string option; pendingReason: string option |} list } +type RebalanceExecutionRecordResponse = + { + planId: Guid + runDate: string + instrumentCode: string + action: string + amount: string + status: string + orderId: string option + pendingReason: string option + executedAt: string + } + +type RebalanceWeightRowResponse = + { + instrumentCode: string + targetPercent: string + currentValue: string + currentPercent: string + action: string + deltaAmount: string + deltaUnits: string option + } + +type RebalancePreviewResponse = + { + planId: Guid + runDate: string + availableCash: string + equity: string + rows: RebalanceWeightRowResponse list + } + type CapitalDepositResponse = { id: Guid @@ -484,6 +517,45 @@ module App = |}) } + let private rebalanceExecutionRecordResponse (record: RebalanceExecutionRecord) : RebalanceExecutionRecordResponse = + { + planId = record.PlanId + runDate = dateText record.RunDate + instrumentCode = record.InstrumentCode + action = record.Action + amount = cashText record.Amount + status = record.Status + orderId = record.OrderId |> Option.map (fun id -> id.ToString("D")) + pendingReason = record.PendingReason + executedAt = timestampText record.ExecutedAt + } + + let private rebalanceActionText (action: RebalancePolicy.RebalanceAction) = + match action with + | RebalancePolicy.Buy -> "buy" + | RebalancePolicy.Sell -> "sell" + | RebalancePolicy.Hold -> "hold" + + let private rebalanceWeightRowResponse (row: RebalancePolicy.RebalanceWeightRow) : RebalanceWeightRowResponse = + { + instrumentCode = row.InstrumentCode + targetPercent = cashText row.TargetPercent + currentValue = cashText row.CurrentValue + currentPercent = cashText row.CurrentPercent + action = rebalanceActionText row.Action + deltaAmount = cashText row.DeltaAmount + deltaUnits = row.DeltaUnits |> Option.map decimalText + } + + let private rebalancePreviewResponse (preview: RebalancePreview) : RebalancePreviewResponse = + { + planId = preview.PlanId + runDate = dateText preview.RunDate + availableCash = cashText preview.AvailableCash + equity = cashText preview.Equity + rows = preview.Rows |> List.map rebalanceWeightRowResponse + } + let private errorResponse status error message : HttpHandler = setStatusCode status >=> json ({ @@ -1437,6 +1509,41 @@ module App = json (rebalanceExecutionResponse result) next ctx with _ -> errorResponse 500 "PERSISTENCE_ERROR" "rebalance execution failed" next ctx + + let private getRebalanceExecutions (repository: FundRepository) (fundIdText: string) : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText with + | false, _ -> errorResponse 400 "INVALID_FUND_ID" "fund id must be a UUID" next ctx + | true, fundId -> + try + match repository.GetFund fundId with + | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx + | Some _ -> + let records = repository.GetRebalanceExecutions fundId + json (records |> List.map rebalanceExecutionRecordResponse) next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "rebalance execution persistence failed" next ctx + + let private previewRebalancePlan (repository: FundRepository) (fundIdText: string) (planIdText: string) : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText, Guid.TryParse planIdText with + | (false, _), _ + | _, (false, _) -> + errorResponse 400 "INVALID_REBALANCE_REQUEST" "fund id and plan id must be UUIDs" next ctx + | (true, fundId), (true, planId) -> + try + match repository.GetFund fundId with + | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx + | Some _ -> + if repository.GetRebalancePlans fundId |> List.exists (fun plan -> plan.Id = planId) |> not then + errorResponse 404 "PLAN_NOT_FOUND" "rebalance plan does not belong to this fund" next ctx + else + match repository.PreviewRebalancePlan planId with + | Error message -> errorResponse 400 "INVALID_REBALANCE_REQUEST" message next ctx + | Ok preview -> json (rebalancePreviewResponse preview) next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "rebalance preview failed" next ctx + let private marketDataError (failure: MarketDataFailure) : HttpHandler = let status, error, message = match failure with @@ -1547,6 +1654,8 @@ module App = POST >=> routef "/funds/%s/rebalance/plans" (createRebalancePlan repository) GET >=> routef "/funds/%s/rebalance/plans" (getRebalancePlans repository) POST >=> routef "/funds/%s/rebalance/plans/%s/execute" (fun (fundId, planId) -> executeRebalancePlan repository fundId planId) + GET >=> routef "/funds/%s/rebalance/plans/%s/preview" (fun (fundId, planId) -> previewRebalancePlan repository fundId planId) + GET >=> routef "/funds/%s/rebalance/executions" (getRebalanceExecutions repository) POST >=> routef "/funds/%s/dividends" (createDividend repository) GET >=> routef "/funds/%s/dividends" (getDividends repository) GET >=> routef "/funds/%s/returns" (getFundReturns repository) diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs index e1cbb6d..7a7ab32 100644 --- a/src/FundLab.Api/Persistence.fs +++ b/src/FundLab.Api/Persistence.fs @@ -423,6 +423,28 @@ type RebalanceExecutionResult = Outcomes: RebalanceOrderOutcome list } +type RebalanceExecutionRecord = + { + PlanId: Guid + RunDate: DateOnly + InstrumentCode: string + Action: string + Amount: decimal + Status: string + OrderId: Guid option + PendingReason: string option + ExecutedAt: DateTimeOffset + } + +type RebalancePreview = + { + PlanId: Guid + RunDate: DateOnly + AvailableCash: decimal + Equity: decimal + Rows: RebalancePolicy.RebalanceWeightRow list + } + type DividendMode = DividendPolicy.DividendMode type DividendCommand = @@ -765,6 +787,19 @@ type FundRepository(connectionString: string) = PRIMARY KEY (plan_id, instrument_code) ); + CREATE TABLE IF NOT EXISTS rebalance_executions ( + plan_id uuid NOT NULL REFERENCES rebalance_plans(id), + run_date date NOT NULL, + instrument_code text NOT NULL, + action text NOT NULL, + amount numeric(20, 2) NOT NULL, + status text NOT NULL, + order_id uuid NULL, + pending_reason text NULL, + executed_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (plan_id, run_date, instrument_code) + ); + CREATE TABLE IF NOT EXISTS dividend_records ( id uuid PRIMARY KEY, fund_id uuid NOT NULL REFERENCES funds(id), @@ -2016,6 +2051,70 @@ type FundRepository(connectionString: string) = match RebalancePolicy.validateTargets command.Targets with | Error message -> Error message | Ok() -> Ok() + + let rebalanceExecutionRecordFromReader (reader: DbDataReader) : RebalanceExecutionRecord = + { + PlanId = reader.GetGuid(0) + RunDate = reader.GetFieldValue<DateOnly>(1) + InstrumentCode = reader.GetString(2) + Action = reader.GetString(3) + Amount = reader.GetDecimal(4) + Status = reader.GetString(5) + OrderId = if reader.IsDBNull(6) then None else Some(reader.GetGuid(6)) + PendingReason = readStringOption reader 7 + ExecutedAt = reader.GetFieldValue<DateTimeOffset>(8) + } + + let upsertRebalanceExecution + connection + transaction + (planId: Guid) + (runDate: DateOnly) + (outcome: RebalanceOrderOutcome) + = + let amount = + match Decimal.TryParse(outcome.Amount, NumberStyles.Float, CultureInfo.InvariantCulture) with + | true, value -> value + | false, _ -> 0m + + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO rebalance_executions (plan_id, run_date, instrument_code, action, amount, status, order_id, pending_reason) + VALUES (@plan_id, @run_date, @instrument_code, @action, @amount, @status, @order_id, @pending_reason) + ON CONFLICT (plan_id, run_date, instrument_code) DO UPDATE + SET action = EXCLUDED.action, + amount = EXCLUDED.amount, + status = EXCLUDED.status, + order_id = EXCLUDED.order_id, + pending_reason = EXCLUDED.pending_reason, + executed_at = now() + """ + + addParameter command "plan_id" NpgsqlDbType.Uuid (box planId) |> ignore + addParameter command "run_date" NpgsqlDbType.Date (box runDate) |> ignore + addParameter command "instrument_code" NpgsqlDbType.Text (box outcome.InstrumentCode) |> ignore + addParameter command "action" NpgsqlDbType.Text (box outcome.Action) |> ignore + addParameter command "amount" NpgsqlDbType.Numeric (box amount) |> ignore + addParameter command "status" NpgsqlDbType.Text (box outcome.Status) |> ignore + + let orderParameter = + match outcome.OrderId with + | Some value -> box value + | None -> box DBNull.Value + + addParameter command "order_id" NpgsqlDbType.Uuid orderParameter |> ignore + + let reasonParameter = + match outcome.PendingReason with + | Some value -> box value + | None -> box DBNull.Value + + addParameter command "pending_reason" NpgsqlDbType.Text reasonParameter |> ignore + command.ExecuteNonQuery() |> ignore + let dividendRecordFromReader (reader: DbDataReader) : DividendRecord = { Id = reader.GetGuid(0) @@ -3496,11 +3595,97 @@ type FundRepository(connectionString: string) = | other -> outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "failed"; OrderId = None; PendingReason = Some (sprintf "%A" other) }) + let outcomeList = outcomes |> Seq.toList + + use persistTransaction = connection.BeginTransaction(IsolationLevel.ReadCommitted) + + try + for outcome in outcomeList do + upsertRebalanceExecution connection (Some persistTransaction) plan.Id runDate outcome + + persistTransaction.Commit() + with error -> + try + persistTransaction.Rollback() + with _ -> + () + + raise error + Ok { PlanId = plan.Id RunDate = runDate - Outcomes = outcomes |> Seq.toList + Outcomes = outcomeList + } + + member _.GetRebalanceExecutions(fundId: Guid) = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + + use command = + commandWithTransaction + connection + None + """ + SELECT e.plan_id, e.run_date, e.instrument_code, e.action, e.amount, e.status, e.order_id, e.pending_reason, e.executed_at + FROM rebalance_executions e + JOIN rebalance_plans p ON p.id = e.plan_id + WHERE p.fund_id = @fund_id + ORDER BY e.executed_at DESC, e.run_date DESC, e.instrument_code + """ + + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + + use reader = command.ExecuteReader() + let records = ResizeArray<RebalanceExecutionRecord>() + + while reader.Read() do + records.Add(rebalanceExecutionRecordFromReader reader) + + records |> Seq.toList + + member this.PreviewRebalancePlan(planId: Guid) : Result<RebalancePreview, string> = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + + match findRebalancePlan connection None planId with + | None -> Error "rebalance plan was not found" + | Some plan -> + match this.GetFund plan.FundId with + | None -> Error "fund was not found" + | Some fund -> + let positions = this.GetFundPositions plan.FundId + + let buildSnapshot (position: FundPositionRecord) : RebalancePolicy.RebalancePositionSnapshot = + let marketValue = + match position.ValuationNav with + | Some nav -> Decimal.Round(position.Units * nav, 2) + | None -> 0m + + { + RebalancePolicy.RebalancePositionSnapshot.InstrumentCode = position.InstrumentCode + RebalancePolicy.RebalancePositionSnapshot.MarketValue = marketValue + RebalancePolicy.RebalancePositionSnapshot.Units = position.Units + RebalancePolicy.RebalancePositionSnapshot.AvailableUnits = position.Units - position.ReservedUnits + RebalancePolicy.RebalancePositionSnapshot.ValuationNav = position.ValuationNav + } + + let snapshots = positions |> List.map buildSnapshot + + match RebalancePolicy.weightRows plan.Targets snapshots fund.AvailableCash with + | Error message -> Error message + | Ok rows -> + let equity = + fund.AvailableCash + (snapshots |> List.sumBy (fun snapshot -> snapshot.MarketValue)) + + Ok + { + PlanId = plan.Id + RunDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow + AvailableCash = fund.AvailableCash + Equity = equity + Rows = rows } member this.RegisterDividend(idempotencyKey: string, fundId: Guid, command: DividendCommand) : DividendWriteResult = diff --git a/src/FundLab.Domain/Rebalance.fs b/src/FundLab.Domain/Rebalance.fs index 5d1d508..0107513 100644 --- a/src/FundLab.Domain/Rebalance.fs +++ b/src/FundLab.Domain/Rebalance.fs @@ -33,6 +33,23 @@ module RebalancePolicy = Units: decimal option } + /// Per-code comparison of the current holding against its target share. This is the + /// read-only view shown as "proposed orders": no order is created from it. + type RebalanceWeightRow = + { + InstrumentCode: string + TargetPercent: decimal + CurrentValue: decimal + CurrentPercent: decimal + Action: RebalanceAction + DeltaAmount: decimal + DeltaUnits: decimal option + } + + /// A delta below one cent is treated as already on target; this is the rounding + /// tolerance for share/price arithmetic, not a policy choice. + let driftTolerance = 0.01m + let private roundDown (scale: int) (value: decimal) : decimal = let factor = decimal (pown 10 scale) Decimal.Truncate(value * factor) / factor @@ -69,17 +86,19 @@ module RebalancePolicy = else Ok () - /// Diff orders for one rebalance run. The universe is the union of target codes and - /// held codes (held codes without a target imply a 0%% target, i.e. full exit). - /// BUY diffs request the full shortfall amount; cash sufficiency is enforced by the - /// shared order pipeline, not silently trimmed. SELL diffs convert the excess value - /// into units at the current valuation NAV, clamped to available units; a held code - /// without a valuation NAV yields Units=None and the executor skips it visibly. - let computeOrders + /// Current vs target share per code, including the proposed action. This is the single + /// source of the rebalancing arithmetic; `computeOrders` is the order-shaped projection + /// of these rows. The universe is the union of target codes and held codes (held codes + /// without a target imply a 0% target, i.e. full exit). BUY diffs request the full + /// shortfall amount; cash sufficiency is enforced by the shared order pipeline, not + /// silently trimmed. SELL diffs convert the excess value into units at the current + /// valuation NAV, clamped to available units; a held code without a valuation NAV yields + /// no units and the executor skips it visibly. Deltas within `driftTolerance` hold. + let weightRows (targets: TargetAllocation list) (positions: RebalancePositionSnapshot list) (availableCash: decimal) - : Result<RebalanceDiff list, string> = + : Result<RebalanceWeightRow list, string> = match validateTargets targets with | Error message -> Error message | Ok() -> @@ -93,7 +112,7 @@ module RebalancePolicy = let equity = availableCash + (positions |> List.sumBy (fun position -> position.MarketValue)) - let results = ResizeArray<RebalanceDiff>() + let results = ResizeArray<RebalanceWeightRow>() let codes = (targets |> List.map (fun target -> target.InstrumentCode)) @@ -122,29 +141,76 @@ module RebalancePolicy = let targetValue = roundDown 2 (targetPercent / 100m * equity) let delta = targetValue - position.MarketValue - if delta > 0.01m then - results.Add({ InstrumentCode = code; Action = Buy; Amount = delta; Units = None }) - elif delta < -0.01m then - match position.ValuationNav with - | None -> - results.Add({ InstrumentCode = code; Action = Sell; Amount = -delta; Units = None }) - | Some nav when nav > 0m -> - let desiredUnits = roundDown 8 (-delta / nav) - - let units = - min desiredUnits position.AvailableUnits - - if units <= 0m then - results.Add({ InstrumentCode = code; Action = Hold; Amount = 0m; Units = Some 0m }) - else - results.Add({ InstrumentCode = code; Action = Sell; Amount = -delta; Units = Some units }) - | Some _ -> - results.Add({ InstrumentCode = code; Action = Sell; Amount = -delta; Units = None }) - else - results.Add({ InstrumentCode = code; Action = Hold; Amount = 0m; Units = None }) + let currentPercent = + if equity > 0m then + roundDown 2 (position.MarketValue / equity * 100m) + else + 0m + + let action, deltaUnits = + if delta > driftTolerance then + Buy, None + elif delta < -driftTolerance then + match position.ValuationNav with + | None -> Sell, None + | Some nav when nav > 0m -> + let desiredUnits = roundDown 8 (-delta / nav) + let units = min desiredUnits position.AvailableUnits + + if units <= 0m then Hold, Some 0m else Sell, Some units + | Some _ -> Sell, None + else + Hold, None + + results.Add( + { + InstrumentCode = code + TargetPercent = targetPercent + CurrentValue = position.MarketValue + CurrentPercent = currentPercent + Action = action + DeltaAmount = delta + DeltaUnits = deltaUnits + } + ) Ok(results |> Seq.toList) + /// Order-shaped projection of `weightRows`, used by the executor. + let computeOrders + (targets: TargetAllocation list) + (positions: RebalancePositionSnapshot list) + (availableCash: decimal) + : Result<RebalanceDiff list, string> = + match weightRows targets positions availableCash with + | Error message -> Error message + | Ok rows -> + rows + |> List.map (fun row -> + match row.Action with + | Buy -> + { + InstrumentCode = row.InstrumentCode + Action = Buy + Amount = row.DeltaAmount + Units = None + } + | Sell -> + { + InstrumentCode = row.InstrumentCode + Action = Sell + Amount = -row.DeltaAmount + Units = row.DeltaUnits + } + | Hold -> + { + InstrumentCode = row.InstrumentCode + Action = Hold + Amount = 0m + Units = row.DeltaUnits + }) + |> Ok + /// Deterministic idempotency keys: one run per plan per date; per-code suffixes keep /// multiple orders of the same run under distinct keys. let runDateText (runDate: DateOnly) : string = runDate.ToString("yyyy-MM-dd") 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; diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index ca5bde8..374c63e 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -28,6 +28,7 @@ <Compile Include="DividendTests.fs" /> <Compile Include="ReturnsTests.fs" /> <Compile Include="InvestmentPlanTests.fs" /> + <Compile Include="RebalanceTests.fs" /> <Compile Include="Program.fs" /> </ItemGroup> </Project> diff --git a/tests/FundLab.Api.Tests/RebalanceTests.fs b/tests/FundLab.Api.Tests/RebalanceTests.fs new file mode 100644 index 0000000..88ec479 --- /dev/null +++ b/tests/FundLab.Api.Tests/RebalanceTests.fs @@ -0,0 +1,278 @@ +namespace FundLab.Api.Tests + +open System +open System.Text.Json +open Xunit +open FundLab.Api +open FundLab.Domain + +[<Collection("postgres")>] +type RebalanceTests(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 "rebalance-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 today = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow + + let buyHolding fundId (code: string) (amount: decimal) (nav: decimal) = + insertQuoteOnDate code nav today + + let order = + match + repository().CreateSubscriptionOrder( + fixture.Key(sprintf "rebalance-hold-%s" (Guid.NewGuid().ToString("N"))), + fundId, + { FundCode = code; Amount = amount; FeeAmount = 0m }, + today + ) + with + | SubscriptionOrderWriteResult.OrderCreated order + | SubscriptionOrderWriteResult.OrderReplayed order -> order + | other -> failwithf "unexpected holding order result: %A" other + + match + repository().ConfirmSubscriptionOrder( + fixture.Key(sprintf "rebalance-confirm-%s" (Guid.NewGuid().ToString("N"))), + fundId, + order.Id + ) + with + | SubscriptionConfirmResult.OrderConfirmed _ -> () + | confirmResult -> failwithf "unexpected holding confirm: %A" confirmResult + + let createPlanViaApi fundId (targets: (string * string) list) = + let body = + targets + |> List.map (fun (code, percent) -> sprintf "{\"instrumentCode\":\"%s\",\"targetPercent\":\"%s\"}" code percent) + |> String.concat "," + |> sprintf "{\"targets\":[%s]}" + + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/rebalance/plans" fundId) + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", fixture.Key(sprintf "rebalance-plan-%s" (Guid.NewGuid().ToString("N"))) + ] + body + + let createPlanId fundId targets = + match createPlanViaApi fundId targets with + | 201, body -> + use document = JsonDocument.Parse(body) + Guid.Parse(document.RootElement.GetProperty("id").GetString()) + | status, body -> failwithf "unexpected plan status %d: %s" status body + + let previewViaApi fundId planId = + PersistenceTestHelpers.invoke + (app ()) + "GET" + (sprintf "/api/funds/%O/rebalance/plans/%O/preview" fundId planId) + [ "Authorization", "Bearer test-token" ] + "" + + let executeViaApi fundId planId = + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/rebalance/plans/%O/execute" fundId planId) + [ "Authorization", "Bearer test-token" ] + "{}" + + let executionsViaApi fundId = + PersistenceTestHelpers.invoke + (app ()) + "GET" + (sprintf "/api/funds/%O/rebalance/executions" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + let rowOn (code: string) (body: string) = + use document = JsonDocument.Parse(body) + + document.RootElement.GetProperty("rows").EnumerateArray() + |> Seq.find (fun row -> row.GetProperty("instrumentCode").GetString() = code) + |> fun row -> row.Clone() + + let unitsFor fundId (code: string) = + repository().GetFundPositions fundId + |> List.tryFind (fun position -> position.InstrumentCode = code) + |> Option.map (fun position -> position.Units) + |> Option.defaultValue 0m + + let availableCash fundId = + match repository().GetFund fundId with + | Some fund -> fund.AvailableCash + | None -> failwith "fund was not found" + + [<Fact>] + member _.``preview reports target versus current weights without creating orders``() = + let fundId = createFund 10000.00m + let codeA = seedInstrument () + let codeB = seedInstrument () + buyHolding fundId codeA 100.00m 2.0m + let planId = createPlanId fundId [ codeA, "50.00"; codeB, "50.00" ] + + let status, body = previewViaApi fundId planId + Assert.Equal(200, status) + + let rowA = rowOn codeA body + Assert.Equal("50.00", rowA.GetProperty("targetPercent").GetString()) + Assert.Equal("100.00", rowA.GetProperty("currentValue").GetString()) + Assert.Equal("1.00", rowA.GetProperty("currentPercent").GetString()) + Assert.Equal("buy", rowA.GetProperty("action").GetString()) + Assert.Equal("4900.00", rowA.GetProperty("deltaAmount").GetString()) + + let rowB = rowOn codeB body + Assert.Equal("buy", rowB.GetProperty("action").GetString()) + Assert.Equal("5000.00", rowB.GetProperty("deltaAmount").GetString()) + + // preview is read-only: the holding is untouched + Assert.Equal(50.00000000m, unitsFor fundId codeA) + Assert.Equal(9900.00m, availableCash fundId) + + [<Fact>] + member _.``execute applies buy and sell through the shared pipeline and records executions``() = + let fundId = createFund 10000.00m + let codeA = seedInstrument () + let codeB = seedInstrument () + buyHolding fundId codeA 8000.00m 5.0m + insertQuoteOnDate codeB 2.5m today + let planId = createPlanId fundId [ codeA, "50.00"; codeB, "50.00" ] + + let status, body = executeViaApi fundId planId + Assert.Equal(200, status) + Assert.Contains("\"action\":\"sell\"", body) + Assert.Contains("\"action\":\"buy\"", body) + Assert.Contains("\"status\":\"succeeded\"", body) + + Assert.Equal(1000.00000000m, unitsFor fundId codeA) + Assert.Equal(2000.00000000m, unitsFor fundId codeB) + Assert.Equal(0.00m, availableCash fundId) + + let executionsStatus, executionsBody = executionsViaApi fundId + Assert.Equal(200, executionsStatus) + Assert.Contains("\"instrumentCode\":\"" + codeA + "\"", executionsBody) + Assert.Contains("\"instrumentCode\":\"" + codeB + "\"", executionsBody) + Assert.Contains("\"action\":\"sell\"", executionsBody) + Assert.Contains("\"action\":\"buy\"", executionsBody) + + use executionsDocument = JsonDocument.Parse(executionsBody) + Assert.Equal(2, executionsDocument.RootElement.GetArrayLength()) + + [<Fact>] + member _.``re-executing a balanced plan produces no new operations``() = + let fundId = createFund 10000.00m + let codeA = seedInstrument () + let codeB = seedInstrument () + buyHolding fundId codeA 8000.00m 5.0m + insertQuoteOnDate codeB 2.5m today + let planId = createPlanId fundId [ codeA, "50.00"; codeB, "50.00" ] + + let firstStatus, _ = executeViaApi fundId planId + Assert.Equal(200, firstStatus) + + let secondStatus, secondBody = executeViaApi fundId planId + Assert.Equal(200, secondStatus) + Assert.Contains("\"outcomes\":[]", secondBody) + + Assert.Equal(1000.00000000m, unitsFor fundId codeA) + Assert.Equal(2000.00000000m, unitsFor fundId codeB) + + let _, executionsBody = executionsViaApi fundId + use executionsDocument = JsonDocument.Parse(executionsBody) + Assert.Equal(2, executionsDocument.RootElement.GetArrayLength()) + + [<Fact>] + member _.``preview for a plan owned by another fund is not found``() = + let fundId = createFund 10000.00m + let otherFundId = createFund 10000.00m + let codeA = seedInstrument () + let codeB = seedInstrument () + let planId = createPlanId fundId [ codeA, "50.00"; codeB, "50.00" ] + + let status, body = previewViaApi otherFundId planId + Assert.Equal(404, status) + Assert.Contains("PLAN_NOT_FOUND", body) + + [<Fact>] + member _.``preview and executions for unknown identifiers are not found``() = + let fundId = createFund 10000.00m + + let previewStatus, previewBody = previewViaApi fundId (Guid.NewGuid()) + Assert.Equal(404, previewStatus) + Assert.Contains("PLAN_NOT_FOUND", previewBody) + + let executionsStatus, executionsBody = executionsViaApi (Guid.NewGuid()) + Assert.Equal(404, executionsStatus) + Assert.Contains("FUND_NOT_FOUND", executionsBody) + + [<Fact>] + member _.``executions are empty for a fund without a run``() = + let fundId = createFund 10000.00m + let status, body = executionsViaApi fundId + Assert.Equal(200, status) + Assert.Equal("[]", body) diff --git a/tests/FundLab.Domain.Tests/DomainTests.fs b/tests/FundLab.Domain.Tests/DomainTests.fs index ff5245e..63bcab2 100644 --- a/tests/FundLab.Domain.Tests/DomainTests.fs +++ b/tests/FundLab.Domain.Tests/DomainTests.fs @@ -748,6 +748,54 @@ module RebalancePolicyTests = Assert.All(diffs, fun diff -> Assert.Equal(RebalancePolicy.Hold, diff.Action)) [<Fact>] + let ``weight rows expose current percent and proposed buy`` () = + let targets = [ target "000001" 60.00m; target "000002" 40.00m ] + let positions = [ position "000001" 5000.00m 1000m 1000m (Some 5m) ] + + let rows: RebalancePolicy.RebalanceWeightRow list = + RebalancePolicy.weightRows targets positions 7000.00m + |> unwrap + + let row = rows |> List.find (fun item -> item.InstrumentCode = "000001") + + Assert.Equal(60.00m, row.TargetPercent) + Assert.Equal(5000.00m, row.CurrentValue) + Assert.Equal(41.66m, row.CurrentPercent) + Assert.Equal(RebalancePolicy.Buy, row.Action) + Assert.Equal(2200.00m, row.DeltaAmount) + Assert.Equal(None, row.DeltaUnits) + + [<Fact>] + let ``weight rows propose a sell clamped to available units`` () = + let targets = [ target "000001" 50.00m; target "000002" 50.00m ] + let positions = [ position "000001" 1000.00m 400m 400m (Some 2.5m) ] + + let rows: RebalancePolicy.RebalanceWeightRow list = + RebalancePolicy.weightRows targets positions 0m + |> unwrap + + let row = rows |> List.find (fun item -> item.InstrumentCode = "000001") + + Assert.Equal(100.00m, row.CurrentPercent) + Assert.Equal(RebalancePolicy.Sell, row.Action) + Assert.Equal(-500.00m, row.DeltaAmount) + Assert.Equal(200.00000000m, row.DeltaUnits.Value) + + [<Fact>] + let ``weight rows hold when the drift is within the one cent tolerance`` () = + let targets = [ target "000001" 100.00m ] + let positions = [ position "000001" 199.99m 100m 100m (Some 1.9999m) ] + + let rows: RebalancePolicy.RebalanceWeightRow list = + RebalancePolicy.weightRows targets positions 0.01m + |> unwrap + + let row = rows |> List.find (fun item -> item.InstrumentCode = "000001") + + Assert.Equal(RebalancePolicy.Hold, row.Action) + Assert.Equal(0.01m, row.DeltaAmount) + + [<Fact>] let ``idempotency keys are deterministic per plan run and code`` () = let planId = Guid "7c9e6679-7425-40de-944b-e07fc1f90ae7" let runDate = DateOnly(2026, 9, 21) |
