summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/FundLab.Api/Persistence.fs276
1 files changed, 192 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