summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/FundLab.Api/App.fs109
-rw-r--r--src/FundLab.Api/Persistence.fs187
-rw-r--r--src/FundLab.Domain/Rebalance.fs124
-rw-r--r--src/FundLab.Web/App.fs561
-rw-r--r--src/FundLab.Web/src/api.js11
-rw-r--r--src/FundLab.Web/src/styles.css22
6 files changed, 875 insertions, 139 deletions
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;