summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/FundLab.Api/Persistence.fs276
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/RebalanceStockTests.fs209
3 files changed, 402 insertions, 84 deletions
diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs
index d1b8250..267eff3 100644
--- a/src/FundLab.Api/Persistence.fs
+++ b/src/FundLab.Api/Persistence.fs
@@ -1074,11 +1074,13 @@ type FundRepository(connectionString: string) =
CREATE TABLE IF NOT EXISTS rebalance_targets (
plan_id uuid NOT NULL REFERENCES rebalance_plans(id),
- instrument_code text NOT NULL REFERENCES instruments(code),
+ instrument_code text NOT NULL,
target_percent numeric(9, 2) NOT NULL CHECK (target_percent > 0 AND target_percent <= 100),
PRIMARY KEY (plan_id, instrument_code)
);
+ ALTER TABLE rebalance_targets DROP CONSTRAINT IF EXISTS rebalance_targets_instrument_code_fkey;
+
CREATE TABLE IF NOT EXISTS rebalance_executions (
plan_id uuid NOT NULL REFERENCES rebalance_plans(id),
run_date date NOT NULL,
@@ -3378,6 +3380,12 @@ type FundRepository(connectionString: string) =
| Error message -> Error message
| Ok() -> Ok()
+ /// A target code outside the fund universe that is a well-formed A-share code is
+ /// treated as a stock leg; the rebalance executor routes it through the stock
+ /// buy/sell pipelines instead of fund subscription/redemption.
+ let isStockInstrumentCode (code: string) =
+ not (isNull code) && code.Length = 6 && code |> Seq.forall Char.IsDigit
+
let rebalanceExecutionRecordFromReader (reader: DbDataReader) : RebalanceExecutionRecord =
{
PlanId = reader.GetGuid(0)
@@ -5094,7 +5102,9 @@ type FundRepository(connectionString: string) =
| Some isSynthetic ->
let missingTarget =
command.Targets
- |> List.tryFind (fun target -> not (instrumentExists connection (Some transaction) target.InstrumentCode))
+ |> List.tryFind (fun target ->
+ not (instrumentExists connection (Some transaction) target.InstrumentCode)
+ && not (isStockInstrumentCode target.InstrumentCode))
match missingTarget with
| Some target ->
@@ -5191,6 +5201,51 @@ type FundRepository(connectionString: string) =
raise error
+ /// Rebalance universe: fund positions priced at their latest valuation NAV, plus stock
+ /// positions priced at their latest persisted snapshot (unpriced holdings stay visible at
+ /// zero value with no NAV, exactly like the fund path). The stock price map is returned
+ /// alongside so the executor can size stock legs without a second source of truth.
+ member private this.RebalanceInputs(fundId: Guid, asOfDate: DateOnly) =
+ let fundSnapshots =
+ this.GetFundPositions fundId
+ |> List.map (fun position ->
+ 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 stockPrices =
+ this.GetLatestSnapshots(fundId, "stock", asOfDate)
+ |> Map.map (fun _ snapshot -> snapshot.Price)
+
+ let stockSnapshots =
+ this.GetStockPositions fundId
+ |> List.map (fun position ->
+ let price = stockPrices |> Map.tryFind position.InstrumentCode
+
+ let marketValue =
+ price
+ |> Option.map (fun value -> Decimal.Round(position.Quantity * value, 2))
+ |> Option.defaultValue 0m
+
+ {
+ RebalancePolicy.RebalancePositionSnapshot.InstrumentCode = position.InstrumentCode
+ RebalancePolicy.RebalancePositionSnapshot.MarketValue = marketValue
+ RebalancePolicy.RebalancePositionSnapshot.Units = position.Quantity
+ RebalancePolicy.RebalancePositionSnapshot.AvailableUnits = position.Quantity
+ RebalancePolicy.RebalancePositionSnapshot.ValuationNav = price
+ })
+
+ fundSnapshots @ stockSnapshots, stockPrices
+
member this.ExecuteRebalancePlan(planId: Guid) : Result<RebalanceExecutionResult, string> =
use connection = new NpgsqlConnection(connectionString)
connection.Open()
@@ -5232,23 +5287,20 @@ type FundRepository(connectionString: string) =
match fund with
| None -> Error "fund was not found"
| Some fund ->
- let positions = this.GetFundPositions fundId
+ let snapshots, stockPrices = this.RebalanceInputs(fundId, runDate)
- let buildSnapshot (position: FundPositionRecord) : RebalancePolicy.RebalancePositionSnapshot =
- let marketValue =
- match position.ValuationNav with
- | Some nav -> Decimal.Round(position.Units * nav, 2)
- | None -> 0m
+ let stockPositions = this.GetStockPositions fundId
- {
- 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 stockCodes =
+ stockPositions |> List.map (fun position -> position.InstrumentCode) |> Set.ofList
+
+ let stockNames =
+ stockPositions
+ |> List.choose (fun position -> position.StockName |> Option.map (fun name -> position.InstrumentCode, name))
+ |> Map.ofList
- let snapshots = positions |> List.map buildSnapshot
+ let isStockLeg (code: string) =
+ stockCodes.Contains code || not (instrumentExists connection None code)
let diffs =
RebalancePolicy.computeOrders plan.Targets snapshots fund.AvailableCash
@@ -5271,65 +5323,136 @@ type FundRepository(connectionString: string) =
match diff.Action with
| RebalancePolicy.Hold -> ()
| RebalancePolicy.Buy ->
- let orderKey = RebalancePolicy.orderKey plan.Id runDate diff.InstrumentCode
+ if isStockLeg diff.InstrumentCode then
+ match stockPrices |> Map.tryFind diff.InstrumentCode with
+ | Some price when price > 0m ->
+ let lot = StockTerms.aShareDefault.MinUnit
+ let lots = Decimal.Floor(diff.Amount / (price * lot))
+ let quantity = lots * lot
- match
- this.CreateSubscriptionOrder(
- orderKey,
- fundId,
- { FundCode = diff.InstrumentCode; Amount = diff.Amount; FeeAmount = 0m }
- )
- with
- | SubscriptionOrderWriteResult.OrderCreated order
- | SubscriptionOrderWriteResult.OrderReplayed order ->
- let confirmKey = RebalancePolicy.confirmKey plan.Id runDate diff.InstrumentCode
-
- match this.ConfirmSubscriptionOrder(confirmKey, fundId, order.Id) with
- | SubscriptionConfirmResult.OrderConfirmed _
- | SubscriptionConfirmResult.ConfirmReplayed _ ->
- outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "succeeded"; OrderId = Some order.Id; PendingReason = None })
- | SubscriptionConfirmResult.ConfirmPendingNav record ->
- outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "pending_nav"; OrderId = Some order.Id; PendingReason = record.PendingReason })
- | SubscriptionConfirmResult.ConfirmIdempotencyConflict ->
- outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "idempotency_conflict"; OrderId = Some order.Id; PendingReason = None })
- | other ->
- outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "failed"; OrderId = Some order.Id; PendingReason = Some (sprintf "%A" other) })
- | SubscriptionOrderWriteResult.OrderInsufficientFunds ->
- outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "insufficient_cash"; OrderId = None; PendingReason = Some "available cash is not enough for the rebalance buy" })
- | other ->
- outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "failed"; OrderId = None; PendingReason = Some (sprintf "%A" other) })
- | RebalancePolicy.Sell ->
- match diff.Units with
- | None ->
- outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "skipped_no_valuation"; OrderId = None; PendingReason = Some "holding has no valuation NAV to price the sell" })
- | Some units when units <= 0m ->
- outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "skipped_no_available_units"; OrderId = None; PendingReason = Some "no available units to redeem" })
- | Some units ->
- let redeemKey = RebalancePolicy.redemptionKey plan.Id runDate diff.InstrumentCode
+ if quantity <= 0m then
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "skipped_below_lot"; OrderId = None; PendingReason = Some "target buy is below one board lot at the persisted price" })
+ else
+ let buyKey = RebalancePolicy.orderKey plan.Id runDate diff.InstrumentCode
+ let executedAt = DateTimeOffset(runDate.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero)
+
+ let command: StockTradeCommand =
+ {
+ InstrumentCode = diff.InstrumentCode
+ StockName = stockNames |> Map.tryFind diff.InstrumentCode
+ Quantity = quantity
+ Price = price
+ }
+
+ match
+ this.CreateStockTrade(
+ buyKey,
+ fundId,
+ command,
+ executedAtOverride = executedAt,
+ debitAvailableCash = true
+ )
+ with
+ | StockTradeWriteResult.StockTradeCreated trade
+ | StockTradeWriteResult.StockTradeReplayed trade ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "succeeded"; OrderId = Some trade.Id; PendingReason = None })
+ | StockTradeWriteResult.StockTradeInsufficientFunds reason ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "insufficient_cash"; OrderId = None; PendingReason = Some reason })
+ | other ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "failed"; OrderId = None; PendingReason = Some (sprintf "%A" other) })
+ | _ ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "skipped_no_quote"; OrderId = None; PendingReason = Some "holding has no persisted stock price to size the buy" })
+ else
+ let orderKey = RebalancePolicy.orderKey plan.Id runDate diff.InstrumentCode
match
- this.CreateRedemptionOrder(
- redeemKey,
+ this.CreateSubscriptionOrder(
+ orderKey,
fundId,
- { InstrumentCode = diff.InstrumentCode; Units = units; FeeAmount = 0m }
+ { FundCode = diff.InstrumentCode; Amount = diff.Amount; FeeAmount = 0m }
)
with
- | RedemptionWriteResult.RedemptionCreated order
- | RedemptionWriteResult.RedemptionReplayed order ->
- let confirmKey = RebalancePolicy.redemptionConfirmKey plan.Id runDate diff.InstrumentCode
-
- match this.ConfirmRedemptionOrder(confirmKey, fundId, order.Id) with
- | RedemptionConfirmResult.RedemptionConfirmed _
- | RedemptionConfirmResult.RedemptionConfirmReplayed _ ->
- outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "succeeded"; OrderId = Some order.Id; PendingReason = None })
- | RedemptionConfirmResult.RedemptionPendingNav record ->
- outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "pending_nav"; OrderId = Some order.Id; PendingReason = record.PendingReason })
+ | SubscriptionOrderWriteResult.OrderCreated order
+ | SubscriptionOrderWriteResult.OrderReplayed order ->
+ let confirmKey = RebalancePolicy.confirmKey plan.Id runDate diff.InstrumentCode
+
+ match this.ConfirmSubscriptionOrder(confirmKey, fundId, order.Id) with
+ | SubscriptionConfirmResult.OrderConfirmed _
+ | SubscriptionConfirmResult.ConfirmReplayed _ ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "succeeded"; OrderId = Some order.Id; PendingReason = None })
+ | SubscriptionConfirmResult.ConfirmPendingNav record ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "pending_nav"; OrderId = Some order.Id; PendingReason = record.PendingReason })
+ | SubscriptionConfirmResult.ConfirmIdempotencyConflict ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "idempotency_conflict"; OrderId = Some order.Id; PendingReason = None })
| other ->
- outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "failed"; OrderId = Some order.Id; PendingReason = Some (sprintf "%A" other) })
- | RedemptionWriteResult.RedemptionInsufficientUnits ->
- outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "insufficient_units"; OrderId = None; PendingReason = Some "available units are not enough for the rebalance sell" })
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "failed"; OrderId = Some order.Id; PendingReason = Some (sprintf "%A" other) })
+ | SubscriptionOrderWriteResult.OrderInsufficientFunds ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "insufficient_cash"; OrderId = None; PendingReason = Some "available cash is not enough for the rebalance buy" })
| other ->
- outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "failed"; OrderId = None; PendingReason = Some (sprintf "%A" other) })
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "buy"; Amount = cashText diff.Amount; Status = "failed"; OrderId = None; PendingReason = Some (sprintf "%A" other) })
+ | RebalancePolicy.Sell ->
+ if isStockLeg diff.InstrumentCode then
+ match stockPrices |> Map.tryFind diff.InstrumentCode, diff.Units with
+ | None, _ ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "skipped_no_quote"; OrderId = None; PendingReason = Some "holding has no persisted stock price to price the sell" })
+ | Some _, None ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "skipped_no_valuation"; OrderId = None; PendingReason = Some "holding has no persisted stock price to price the sell" })
+ | Some _, Some units when Decimal.Floor units <= 0m ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "skipped_no_available_units"; OrderId = None; PendingReason = Some "no available stock shares to sell" })
+ | Some price, Some units ->
+ let quantity = Decimal.Floor units
+ let sellKey = RebalancePolicy.redemptionKey plan.Id runDate diff.InstrumentCode
+ let executedAt = DateTimeOffset(runDate.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero)
+
+ let command: StockSellCommand =
+ {
+ InstrumentCode = diff.InstrumentCode
+ StockName = stockNames |> Map.tryFind diff.InstrumentCode
+ Quantity = quantity
+ Price = price
+ FeeAmount = 0m
+ }
+
+ match this.CreateStockSell(sellKey, fundId, command, executedAtOverride = executedAt) with
+ | StockSellWriteResult.StockSellCreated sell
+ | StockSellWriteResult.StockSellReplayed sell ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "succeeded"; OrderId = Some sell.Id; PendingReason = None })
+ | StockSellWriteResult.StockSellInsufficientHoldings reason ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "insufficient_units"; OrderId = None; PendingReason = Some reason })
+ | other ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "failed"; OrderId = None; PendingReason = Some (sprintf "%A" other) })
+ else
+ match diff.Units with
+ | None ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "skipped_no_valuation"; OrderId = None; PendingReason = Some "holding has no valuation NAV to price the sell" })
+ | Some units when units <= 0m ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "skipped_no_available_units"; OrderId = None; PendingReason = Some "no available units to redeem" })
+ | Some units ->
+ let redeemKey = RebalancePolicy.redemptionKey plan.Id runDate diff.InstrumentCode
+
+ match
+ this.CreateRedemptionOrder(
+ redeemKey,
+ fundId,
+ { InstrumentCode = diff.InstrumentCode; Units = units; FeeAmount = 0m }
+ )
+ with
+ | RedemptionWriteResult.RedemptionCreated order
+ | RedemptionWriteResult.RedemptionReplayed order ->
+ let confirmKey = RebalancePolicy.redemptionConfirmKey plan.Id runDate diff.InstrumentCode
+
+ match this.ConfirmRedemptionOrder(confirmKey, fundId, order.Id) with
+ | RedemptionConfirmResult.RedemptionConfirmed _
+ | RedemptionConfirmResult.RedemptionConfirmReplayed _ ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "succeeded"; OrderId = Some order.Id; PendingReason = None })
+ | RedemptionConfirmResult.RedemptionPendingNav record ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "pending_nav"; OrderId = Some order.Id; PendingReason = record.PendingReason })
+ | other ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "failed"; OrderId = Some order.Id; PendingReason = Some (sprintf "%A" other) })
+ | RedemptionWriteResult.RedemptionInsufficientUnits ->
+ outcomes.Add({ InstrumentCode = diff.InstrumentCode; Action = "sell"; Amount = cashText diff.Amount; Status = "insufficient_units"; OrderId = None; PendingReason = Some "available units are not enough for the rebalance sell" })
+ | 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
@@ -5392,23 +5515,8 @@ type FundRepository(connectionString: string) =
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
+ let asOfDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow
+ let snapshots, _ = this.RebalanceInputs(plan.FundId, asOfDate)
match RebalancePolicy.weightRows plan.Targets snapshots fund.AvailableCash with
| Error message -> Error message
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index b2e587f..f37dce4 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -42,6 +42,7 @@
<Compile Include="FundValuationTests.fs" />
<Compile Include="SipPlanStatusTests.fs" />
<Compile Include="RebalanceArchiveTests.fs" />
+ <Compile Include="RebalanceStockTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
diff --git a/tests/FundLab.Api.Tests/RebalanceStockTests.fs b/tests/FundLab.Api.Tests/RebalanceStockTests.fs
new file mode 100644
index 0000000..3aed5ae
--- /dev/null
+++ b/tests/FundLab.Api.Tests/RebalanceStockTests.fs
@@ -0,0 +1,209 @@
+namespace FundLab.Api.Tests
+
+open System
+open System.Text.Json
+open Xunit
+open FundLab.Api
+open FundLab.Domain
+
+[<Collection("postgres")>]
+type RebalanceStockTests(fixture: PostgresFixture) =
+ let sharedRepository =
+ lazy
+ let value = FundRepository(fixture.ConnectionString)
+ value.EnsureSchema()
+ value
+
+ let repository () = sharedRepository.Value
+
+ let today = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow
+
+ let createFund (initialCash: decimal) =
+ let command =
+ {
+ Name = "股票再平衡测试 FOF"
+ InitialCash = initialCash
+ InitialUnitNav = 1.00000000m
+ IsSynthetic = true
+ }
+
+ let key = fixture.Key(sprintf "rebalance-stock-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 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-stock-test-hash")
+ code
+
+ let seedStockSnapshot (code: string) (price: decimal) =
+ let snapshot: InstrumentSnapshotRecord =
+ {
+ InstrumentCode = code
+ AssetClass = "stock"
+ SnapshotDate = today
+ Price = price
+ Source = "akshare"
+ SourceRevision = "akshare-test/stock-daily"
+ SourceCollectedAt = DateTimeOffset(today.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero)
+ SourcePayloadHash = sprintf "rebalance-stock-hash/%s/%O" code (Guid.NewGuid())
+ }
+
+ repository().UpsertInstrumentSnapshots [ snapshot ]
+
+ let buyStock fundId code quantity price =
+ let key = fixture.Key(sprintf "rebalance-stock-buy-%s" (Guid.NewGuid().ToString("N")))
+
+ match
+ repository().CreateStockTrade(
+ key,
+ fundId,
+ { InstrumentCode = code; StockName = Some "贵州茅台"; Quantity = quantity; Price = price }
+ )
+ with
+ | StockTradeWriteResult.StockTradeCreated _ -> ()
+ | other -> failwithf "unexpected stock trade result: %A" other
+
+ let app () = App.createApplication (repository ())
+
+ let createPlanId fundId targets =
+ let body =
+ targets
+ |> List.map (fun (code, percent) -> sprintf "{\"instrumentCode\":\"%s\",\"targetPercent\":\"%s\"}" code percent)
+ |> String.concat ","
+ |> sprintf "{\"targets\":[%s]}"
+
+ let status, response =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/rebalance/plans" fundId)
+ [
+ "Authorization", "Bearer test-token"
+ "Idempotency-Key", fixture.Key(sprintf "rebalance-stock-plan-%s" (Guid.NewGuid().ToString("N")))
+ ]
+ body
+
+ match status with
+ | 201 ->
+ use document = JsonDocument.Parse(response)
+ Guid.Parse(document.RootElement.GetProperty("id").GetString())
+ | _ -> failwithf "unexpected plan status %d: %s" status response
+
+ 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 stockQuantity fundId code =
+ repository().GetStockPositions fundId
+ |> List.filter (fun position -> position.InstrumentCode = code)
+ |> List.sumBy (fun position -> position.Quantity)
+
+ let availableCash fundId =
+ match repository().GetFund fundId with
+ | Some fund -> fund.AvailableCash
+ | None -> failwith "fund was not found"
+
+ [<Fact>]
+ member _.``preview counts a stock holding in the rebalance universe``() =
+ let fundId = createFund 5000.00m
+ buyStock fundId "600519" 100m 100.00m
+ seedStockSnapshot "600519" 100.00m
+ let planId = createPlanId fundId [ "600519", "100.00" ]
+
+ let status, body = previewViaApi fundId planId
+ Assert.Equal(200, status)
+
+ use document = JsonDocument.Parse(body)
+ Assert.Equal("15000.00", document.RootElement.GetProperty("equity").GetString())
+ Assert.Equal("5000.00", document.RootElement.GetProperty("availableCash").GetString())
+
+ let row =
+ document.RootElement.GetProperty("rows").EnumerateArray()
+ |> Seq.find (fun row -> row.GetProperty("instrumentCode").GetString() = "600519")
+
+ Assert.Equal("10000.00", row.GetProperty("currentValue").GetString())
+ Assert.Equal("66.66", row.GetProperty("currentPercent").GetString())
+ Assert.Equal("buy", row.GetProperty("action").GetString())
+ Assert.Equal("5000.00", row.GetProperty("deltaAmount").GetString())
+
+ [<Fact>]
+ member _.``a stock buy target is executed through the stock trade pipeline and debits cash``() =
+ let fundId = createFund 10000.00m
+ seedStockSnapshot "600519" 10.00m
+ let planId = createPlanId fundId [ "600519", "100.00" ]
+
+ let status, body = executeViaApi fundId planId
+ Assert.Equal(200, status)
+ Assert.Contains("\"instrumentCode\":\"600519\"", body)
+ Assert.Contains("\"action\":\"buy\"", body)
+ Assert.Contains("\"status\":\"succeeded\"", body)
+
+ Assert.Equal(1000m, stockQuantity fundId "600519")
+ Assert.Equal(0.00m, availableCash fundId)
+
+ let buyEvents =
+ repository().GetStockCashflows fundId
+ |> List.filter (fun record -> record.EventType = "buy" && record.InstrumentCode = "600519")
+
+ Assert.Single(buyEvents) |> ignore
+ Assert.Equal(10000.00m, buyEvents.Head.Amount)
+
+ [<Fact>]
+ member _.``an overweight stock holding is sold through the stock sell pipeline``() =
+ let fundId = createFund 0.00m
+ let fundCode = seedInstrument ()
+ buyStock fundId "600519" 100m 100.00m
+ seedStockSnapshot "600519" 100.00m
+ let planId = createPlanId fundId [ fundCode, "100.00" ]
+
+ let status, body = executeViaApi fundId planId
+ Assert.Equal(200, status)
+ Assert.Contains("\"instrumentCode\":\"600519\"", body)
+ Assert.Contains("\"action\":\"sell\"", body)
+ Assert.Contains("\"status\":\"succeeded\"", body)
+
+ Assert.Equal(0m, stockQuantity fundId "600519")
+
+ let sellEvents =
+ repository().GetStockCashflows fundId
+ |> List.filter (fun record -> record.EventType = "sell" && record.InstrumentCode = "600519")
+
+ Assert.Single(sellEvents) |> ignore
+ Assert.Equal(10000.00m, sellEvents.Head.Amount)
+
+ [<Fact>]
+ member _.``a stock target below one board lot is skipped without trading``() =
+ let fundId = createFund 500.00m
+ seedStockSnapshot "600519" 10.00m
+ let planId = createPlanId fundId [ "600519", "100.00" ]
+
+ let status, body = executeViaApi fundId planId
+ Assert.Equal(200, status)
+ Assert.Contains("\"status\":\"skipped_below_lot\"", body)
+
+ Assert.Equal(0m, stockQuantity fundId "600519")
+ Assert.Equal(500.00m, availableCash fundId)