diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 04:45:10 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 04:45:10 +0800 |
| commit | 0183b86f18d9897ff9487e6d864898954397991d (patch) | |
| tree | 245c8f197f5803802dd630f278e9f2a08affa4ec /src | |
| parent | b6008d67232756d743699472865025159f6ec533 (diff) | |
| download | fund-lab-0183b86f18d9897ff9487e6d864898954397991d.tar.gz | |
Add bond buy to holdings minimal vertical slice (3d-22)
Diffstat (limited to 'src')
| -rw-r--r-- | src/FundLab.Api/App.fs | 158 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 350 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 294 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 16 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 19 |
5 files changed, 837 insertions, 0 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index fb55743..36a8565 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -160,6 +160,34 @@ type StockPositionsResponse = positions: StockPositionResponse list } +type BondTradeResponse = + { + id: Guid + fundId: Guid + instrumentCode: string + bondName: string option + quantity: string + price: string + costCash: string + executedAt: string + isSynthetic: bool + } + +type BondPositionResponse = + { + instrumentCode: string + bondName: string option + quantity: string + costCash: string + lastTradedAt: string + } + +type BondPositionsResponse = + { + fundId: Guid + positions: BondPositionResponse list + } + type SipPlanResponse = { id: Guid @@ -532,6 +560,28 @@ module App = lastTradedAt = timestampText position.LastTradedAt } + let private bondTradeResponse (trade: BondTradeRecord) : BondTradeResponse = + { + id = trade.Id + fundId = trade.FundId + instrumentCode = trade.InstrumentCode + bondName = trade.BondName + quantity = decimalText trade.Quantity + price = decimalText trade.Price + costCash = cashText trade.CostCash + executedAt = timestampText trade.ExecutedAt + isSynthetic = trade.IsSynthetic + } + + let private bondPositionResponse (position: BondPositionRecord) : BondPositionResponse = + { + instrumentCode = position.InstrumentCode + bondName = position.BondName + quantity = decimalText position.Quantity + costCash = cashText position.CostCash + lastTradedAt = timestampText position.LastTradedAt + } + let private capitalDepositResponse (deposit: CapitalDepositRecord) : CapitalDepositResponse = { id = deposit.Id @@ -830,6 +880,36 @@ module App = with | :? JsonException -> Error "request body must be valid JSON" + let private parseBondTradeCommand (body: string) : Result<BondTradeCommand, string> = + try + use document = JsonDocument.Parse(body) + let root = document.RootElement + + if root.ValueKind <> JsonValueKind.Object then + Error "request body must be a JSON object" + else + match tryStringProperty root "instrumentCode" with + | None -> Error "instrumentCode is required" + | Some code -> + if code.Trim().Length <> 6 || not (code.Trim() |> Seq.forall Char.IsDigit) then + Error "instrumentCode must contain exactly six digits" + else + match tryStringProperty root "quantity" with + | None -> Error "quantity is required" + | Some quantityText -> + match tryDecimal "quantity" quantityText with + | Error message -> Error message + | Ok quantity -> + Ok + { + InstrumentCode = code.Trim() + BondName = tryStringProperty root "bondName" + Quantity = quantity + Price = 0m + } + with + | :? JsonException -> Error "request body must be valid JSON" + let private parseSipPlanCommand (body: string) : Result<SipPlanCommand, string> = try use document = JsonDocument.Parse(body) @@ -1966,6 +2046,82 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "stock position persistence failed" next ctx + let private createBondTrade (repository: FundRepository) (probes: MarketProbes option) (fundIdText: string) : HttpHandler = + fun next ctx -> + task { + match Guid.TryParse fundIdText with + | false, _ -> + return! invokeHandler (errorResponse 400 "INVALID_BOND_TRADE_REQUEST" "fund id must be a UUID") next ctx + | true, fundId -> + use reader = new StreamReader(ctx.Request.Body) + let! body = reader.ReadToEndAsync() + let idempotencyKey = ctx.Request.Headers["Idempotency-Key"].ToString() + + match parseBondTradeCommand body with + | Error message -> + return! invokeHandler (errorResponse 400 "INVALID_BOND_TRADE_REQUEST" message) next ctx + | Ok command -> + match probes with + | None -> + return! invokeHandler (marketDataError (MarketDataCollectorUnavailable "bond quote probe is not configured")) next ctx + | Some configured -> + let quoteResult = configured.BondQuotes.GetQuote(command.InstrumentCode, ctx.RequestAborted) + + match quoteResult with + | Error failure -> + return! invokeHandler (marketDataError failure) next ctx + | Ok quote -> + match quote.Price with + | None -> + return! + invokeHandler + (marketDataError (InvalidMarketDataPayload "bond quote did not include a price")) + next + ctx + | Some price -> + let resolvedName = + match command.BondName with + | Some name when not (String.IsNullOrWhiteSpace name) -> Some name + | _ -> + match quote.Name with + | Some name when not (String.IsNullOrWhiteSpace name) -> Some name + | _ -> None + + let priced = { command with Price = price; BondName = resolvedName } + + try + match repository.CreateBondTrade(idempotencyKey, fundId, priced) with + | BondTradeWriteResult.BondTradeCreated trade -> + return! invokeHandler (setStatusCode 201 >=> json (bondTradeResponse trade)) next ctx + | BondTradeWriteResult.BondTradeReplayed trade -> + return! invokeHandler (json (bondTradeResponse trade)) next ctx + | BondTradeWriteResult.BondTradeIdempotencyConflict -> + return! invokeHandler (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") next ctx + | BondTradeWriteResult.BondTradeInvalid message -> + return! invokeHandler (errorResponse 400 "INVALID_BOND_TRADE_REQUEST" message) next ctx + | BondTradeWriteResult.BondTradeFundNotFound -> + return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx + with _ -> + return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "bond trade persistence failed") next ctx + } + + let private getBondPositions (repository: FundRepository) (fundIdText: string) : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText with + | false, _ -> errorResponse 400 "INVALID_BOND_TRADE_REQUEST" "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 fund -> + let positions = + repository.GetBondPositions fundId + |> List.map bondPositionResponse + + json ({ fundId = fund.Id; positions = positions } : BondPositionsResponse) next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "bond position persistence failed" next ctx + let private marketProbeRoutes (probes: MarketProbes) = [ GET >=> route "/market/nav-dates" >=> getMarketNavDates probes.NavDates @@ -2009,6 +2165,8 @@ module App = GET >=> routef "/funds/%s/investment-plans" (getInvestmentPlans repository) POST >=> routef "/funds/%s/stock-trades" (createStockTrade repository probes) GET >=> routef "/funds/%s/stock-positions" (getStockPositions repository) + POST >=> routef "/funds/%s/bond-trades" (createBondTrade repository probes) + GET >=> routef "/funds/%s/bond-positions" (getBondPositions repository) GET >=> routef "/funds/%s" (getFund repository) ] @ (marketData |> Option.map marketDataRoutes |> Option.defaultValue []) diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs index ab3fe0a..5000161 100644 --- a/src/FundLab.Api/Persistence.fs +++ b/src/FundLab.Api/Persistence.fs @@ -327,6 +327,44 @@ type StockTradeWriteResult = | StockTradeInvalid of string | StockTradeFundNotFound +type BondTradeCommand = + { + InstrumentCode: string + BondName: string option + Quantity: decimal + Price: decimal + } + +type BondTradeRecord = + { + Id: Guid + FundId: Guid + InstrumentCode: string + BondName: string option + Quantity: decimal + Price: decimal + CostCash: decimal + IsSynthetic: bool + ExecutedAt: DateTimeOffset + } + +type BondPositionRecord = + { + FundId: Guid + InstrumentCode: string + BondName: string option + Quantity: decimal + CostCash: decimal + LastTradedAt: DateTimeOffset + } + +type BondTradeWriteResult = + | BondTradeCreated of BondTradeRecord + | BondTradeReplayed of BondTradeRecord + | BondTradeIdempotencyConflict + | BondTradeInvalid of string + | BondTradeFundNotFound + type SipPlanRecord = { Id: Guid @@ -897,6 +935,36 @@ type FundRepository(connectionString: string) = PRIMARY KEY (fund_id, instrument_code) ); + CREATE TABLE IF NOT EXISTS bond_trades ( + id uuid PRIMARY KEY, + fund_id uuid NOT NULL REFERENCES funds(id), + instrument_code text NOT NULL, + bond_name text NULL, + quantity numeric(28, 8) NOT NULL CHECK (quantity > 0), + price numeric(20, 4) NOT NULL CHECK (price > 0), + cost_cash numeric(20, 2) NOT NULL CHECK (cost_cash >= 0), + is_synthetic boolean NOT NULL, + executed_at timestamptz NOT NULL + ); + + CREATE TABLE IF NOT EXISTS bond_trade_idempotencies ( + idempotency_key text PRIMARY KEY, + request_hash text NOT NULL, + trade_id uuid NOT NULL REFERENCES bond_trades(id), + fund_id uuid NOT NULL REFERENCES funds(id), + created_at timestamptz NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS bond_positions ( + fund_id uuid NOT NULL REFERENCES funds(id), + instrument_code text NOT NULL, + bond_name text NULL, + quantity numeric(28, 8) NOT NULL CHECK (quantity > 0), + cost_cash numeric(20, 2) NOT NULL CHECK (cost_cash >= 0), + last_traded_at timestamptz NOT NULL, + PRIMARY KEY (fund_id, instrument_code) + ); + CREATE TABLE IF NOT EXISTS dividend_idempotencies ( idempotency_key text PRIMARY KEY, request_hash text NOT NULL, @@ -1872,6 +1940,123 @@ type FundRepository(connectionString: string) = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))) + let bondTradeRecordFromReader (reader: DbDataReader) : BondTradeRecord = + { + Id = reader.GetGuid(0) + FundId = reader.GetGuid(1) + InstrumentCode = reader.GetString(2) + BondName = if reader.IsDBNull(3) then None else Some(reader.GetString(3)) + Quantity = reader.GetDecimal(4) + Price = reader.GetDecimal(5) + CostCash = reader.GetDecimal(6) + IsSynthetic = reader.GetBoolean(7) + ExecutedAt = reader.GetFieldValue<DateTimeOffset>(8) + } + + let insertBondTrade connection transaction (trade: BondTradeRecord) = + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO bond_trades + (id, fund_id, instrument_code, bond_name, quantity, price, cost_cash, is_synthetic, executed_at) + VALUES + (@id, @fund_id, @instrument_code, @bond_name, @quantity, @price, @cost_cash, @is_synthetic, @executed_at) + """ + + addParameter command "id" NpgsqlDbType.Uuid (box trade.Id) |> ignore + addParameter command "fund_id" NpgsqlDbType.Uuid (box trade.FundId) |> ignore + addParameter command "instrument_code" NpgsqlDbType.Text (box trade.InstrumentCode) |> ignore + + let nameParameter = + match trade.BondName with + | Some name -> box name + | None -> box DBNull.Value + + addParameter command "bond_name" NpgsqlDbType.Text nameParameter |> ignore + addParameter command "quantity" NpgsqlDbType.Numeric (box trade.Quantity) |> ignore + addParameter command "price" NpgsqlDbType.Numeric (box trade.Price) |> ignore + addParameter command "cost_cash" NpgsqlDbType.Numeric (box trade.CostCash) |> ignore + addParameter command "is_synthetic" NpgsqlDbType.Boolean (box trade.IsSynthetic) |> ignore + addParameter command "executed_at" NpgsqlDbType.TimestampTz (box trade.ExecutedAt) |> ignore + command.ExecuteNonQuery() |> ignore + + let insertBondTradeIdempotency connection transaction key requestHash tradeId fundId = + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO bond_trade_idempotencies (idempotency_key, request_hash, trade_id, fund_id) + VALUES (@idempotency_key, @request_hash, @trade_id, @fund_id) + """ + + addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore + addParameter command "request_hash" NpgsqlDbType.Text (box requestHash) |> ignore + addParameter command "trade_id" NpgsqlDbType.Uuid (box tradeId) |> ignore + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + command.ExecuteNonQuery() |> ignore + + let findBondTradeIdempotency connection transaction key = + use command = + commandWithTransaction + connection + transaction + """ + SELECT request_hash, fund_id, trade_id + FROM bond_trade_idempotencies + WHERE idempotency_key = @idempotency_key + """ + + addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore + + use reader = command.ExecuteReader() + + if reader.Read() then + Some(reader.GetString(0), reader.GetGuid(1), reader.GetGuid(2)) + else + None + + let findBondTrade connection transaction tradeId = + use command = + commandWithTransaction + connection + transaction + """ + SELECT id, fund_id, instrument_code, bond_name, quantity, price, cost_cash, is_synthetic, executed_at + FROM bond_trades + WHERE id = @id + """ + + addParameter command "id" NpgsqlDbType.Uuid (box tradeId) |> ignore + + use reader = command.ExecuteReader() + + if reader.Read() then + Some(bondTradeRecordFromReader reader) + else + None + + let bondTradeRequestHash (fundId: Guid) (command: BondTradeCommand) = + let invariant = CultureInfo.InvariantCulture + let encoded (value: string) = sprintf "%d:%s" value.Length value + let name = command.BondName |> Option.defaultValue "" + + let payload = + String.concat + "|" + [ + "bond-trade" + encoded (fundId.ToString("D")) + encoded command.InstrumentCode + encoded name + encoded (command.Quantity.ToString("G29", invariant)) + encoded (command.Price.ToString("G29", invariant)) + ] + + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))) + let sipPlanRecordFromReader (reader: DbDataReader) : SipPlanRecord = { Id = reader.GetGuid(0) @@ -4371,6 +4556,171 @@ type FundRepository(connectionString: string) = records |> Seq.toList + member _.CreateBondTrade(idempotencyKey: string, fundId: Guid, command: BondTradeCommand, ?executedAtOverride: DateTimeOffset) : BondTradeWriteResult = + if String.IsNullOrWhiteSpace idempotencyKey then + BondTradeWriteResult.BondTradeInvalid "idempotency key cannot be empty" + else + let code = if isNull command.InstrumentCode then "" else command.InstrumentCode.Trim() + + if code.Length <> 6 || not (code |> Seq.forall Char.IsDigit) then + BondTradeWriteResult.BondTradeInvalid "bond code must contain exactly six digits" + elif command.Quantity <= 0m then + BondTradeWriteResult.BondTradeInvalid "quantity must be positive" + elif command.Price <= 0m then + BondTradeWriteResult.BondTradeInvalid "price must be positive" + else + let normalized = { command with InstrumentCode = code } + let fingerprint = bondTradeRequestHash fundId normalized + use connection = new NpgsqlConnection(connectionString) + connection.Open() + use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted) + + try + use lockCommand = + commandWithTransaction + connection + (Some transaction) + "SELECT pg_advisory_xact_lock(hashtext(@lock_key))" + + addParameter lockCommand "lock_key" NpgsqlDbType.Text (box idempotencyKey) |> ignore + lockCommand.ExecuteNonQuery() |> ignore + + match findBondTradeIdempotency connection (Some transaction) idempotencyKey with + | Some(existingHash, existingFundId, tradeId) + when existingHash = fingerprint && existingFundId = fundId -> + match findBondTrade connection (Some transaction) tradeId with + | Some trade -> + transaction.Commit() + BondTradeWriteResult.BondTradeReplayed trade + | None -> + transaction.Rollback() + BondTradeWriteResult.BondTradeInvalid "idempotency record references a missing trade" + | Some _ -> + transaction.Rollback() + BondTradeWriteResult.BondTradeIdempotencyConflict + | None -> + match lockFundForOrder connection (Some transaction) fundId with + | None -> + transaction.Rollback() + BondTradeWriteResult.BondTradeFundNotFound + | Some isSynthetic -> + let executedAt = defaultArg executedAtOverride DateTimeOffset.UtcNow + let costCash = Decimal.Round(normalized.Quantity * normalized.Price, 2, MidpointRounding.AwayFromZero) + + let trade: BondTradeRecord = + { + Id = Guid.NewGuid() + FundId = fundId + InstrumentCode = normalized.InstrumentCode + BondName = normalized.BondName + Quantity = normalized.Quantity + Price = normalized.Price + CostCash = costCash + IsSynthetic = isSynthetic + ExecutedAt = executedAt + } + + insertBondTrade connection (Some transaction) trade + insertBondTradeIdempotency connection (Some transaction) idempotencyKey fingerprint trade.Id fundId + + use positionCommand = + commandWithTransaction + connection + (Some transaction) + """ + INSERT INTO bond_positions + (fund_id, instrument_code, bond_name, quantity, cost_cash, last_traded_at) + VALUES (@fund_id, @code, @name, @quantity, @cost_cash, @last_traded_at) + ON CONFLICT (fund_id, instrument_code) DO UPDATE + SET quantity = bond_positions.quantity + EXCLUDED.quantity, + cost_cash = bond_positions.cost_cash + EXCLUDED.cost_cash, + bond_name = COALESCE(EXCLUDED.bond_name, bond_positions.bond_name), + last_traded_at = EXCLUDED.last_traded_at + """ + + addParameter positionCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + addParameter positionCommand "code" NpgsqlDbType.Text (box normalized.InstrumentCode) |> ignore + + let nameParameter = + match normalized.BondName with + | Some name -> box name + | None -> box DBNull.Value + + addParameter positionCommand "name" NpgsqlDbType.Text nameParameter |> ignore + addParameter positionCommand "quantity" NpgsqlDbType.Numeric (box normalized.Quantity) |> ignore + addParameter positionCommand "cost_cash" NpgsqlDbType.Numeric (box costCash) |> ignore + addParameter positionCommand "last_traded_at" NpgsqlDbType.TimestampTz (box executedAt) |> ignore + positionCommand.ExecuteNonQuery() |> ignore + + transaction.Commit() + BondTradeWriteResult.BondTradeCreated trade + with error -> + try + transaction.Rollback() + with _ -> + () + + raise error + + member _.GetBondTrades(fundId: Guid) : BondTradeRecord list = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + + use command = + commandWithTransaction + connection + None + """ + SELECT id, fund_id, instrument_code, bond_name, quantity, price, cost_cash, is_synthetic, executed_at + FROM bond_trades + WHERE fund_id = @fund_id + ORDER BY executed_at, id + """ + + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + + use reader = command.ExecuteReader() + let records = ResizeArray<BondTradeRecord>() + + while reader.Read() do + records.Add(bondTradeRecordFromReader reader) + + records |> Seq.toList + + member _.GetBondPositions(fundId: Guid) : BondPositionRecord list = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + + use command = + commandWithTransaction + connection + None + """ + SELECT instrument_code, bond_name, quantity, cost_cash, last_traded_at + FROM bond_positions + WHERE fund_id = @fund_id + ORDER BY instrument_code + """ + + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + + use reader = command.ExecuteReader() + let records = ResizeArray<BondPositionRecord>() + + while reader.Read() do + records.Add( + { + FundId = fundId + InstrumentCode = reader.GetString(0) + BondName = if reader.IsDBNull(1) then None else Some(reader.GetString(1)) + Quantity = reader.GetDecimal(2) + CostCash = reader.GetDecimal(3) + LastTradedAt = reader.GetFieldValue<DateTimeOffset>(4) + } + ) + + records |> Seq.toList + member _.GetCapitalDeposits(fundId: Guid) = use connection = new NpgsqlConnection(connectionString) connection.Open() diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 772b97e..8b8f3b9 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -453,6 +453,34 @@ type RawStockPositions = positions: RawStockPosition array } +type RawBondTrade = + { + id: string + fundId: string + instrumentCode: string + bondName: obj + quantity: string + price: string + costCash: string + executedAt: string + isSynthetic: bool + } + +type RawBondPosition = + { + instrumentCode: string + bondName: obj + quantity: string + costCash: string + lastTradedAt: string + } + +type RawBondPositions = + { + fundId: string + positions: RawBondPosition array + } + type CreateAttempt = { idempotencyKey: string @@ -541,6 +569,13 @@ type CreateStockTradePayload = quantity: string } +type CreateBondTradePayload = + { + idempotencyKey: string + instrumentCode: string + quantity: string + } + type SipPlan = { id: string @@ -814,6 +849,33 @@ type StockTradeAttempt = quantity: string } +type BondTradeView = + { + id: string + instrumentCode: string + bondName: string option + quantity: string + price: string + costCash: string + executedAt: string + } + +type BondPositionView = + { + instrumentCode: string + bondName: string option + quantity: string + costCash: string + lastTradedAt: string + } + +type BondTradeAttempt = + { + idempotencyKey: string + instrumentCode: string + quantity: string + } + module Api = [<Import("searchInstruments", "./src/api.js")>] let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative @@ -878,6 +940,12 @@ module Api = [<Import("getStockPositions", "./src/api.js")>] let getStockPositions (token: string) (fundId: string) : JS.Promise<RawStockPositions> = jsNative + [<Import("createBondTrade", "./src/api.js")>] + let createBondTrade (token: string) (fundId: string) (payload: CreateBondTradePayload) : JS.Promise<RawBondTrade> = jsNative + + [<Import("getBondPositions", "./src/api.js")>] + let getBondPositions (token: string) (fundId: string) : JS.Promise<RawBondPositions> = jsNative + [<Import("createCapitalDeposit", "./src/api.js")>] let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative @@ -1197,6 +1265,26 @@ module Api = lastTradedAt = raw.lastTradedAt } + let decodeBondTrade (raw: RawBondTrade) : BondTradeView = + { + id = raw.id + instrumentCode = raw.instrumentCode + bondName = decodeOptionalText raw.bondName + quantity = raw.quantity + price = raw.price + costCash = raw.costCash + executedAt = raw.executedAt + } + + let decodeBondPosition (raw: RawBondPosition) : BondPositionView = + { + instrumentCode = raw.instrumentCode + bondName = decodeOptionalText raw.bondName + quantity = raw.quantity + costCash = raw.costCash + lastTradedAt = raw.lastTradedAt + } + type Model = { token: string @@ -1304,6 +1392,16 @@ type Model = stockPositionsInFlight: bool stockPositions: StockPositionView list stockPositionsMessage: string option + bondTradeQuantity: string + bondTradeSeq: int + bondTradeInFlight: bool + lastBondTrade: BondTradeView option + lastBondTradeAttempt: BondTradeAttempt option + bondTradeMessage: string option + bondPositionsReadSeq: int + bondPositionsInFlight: bool + bondPositions: BondPositionView list + bondPositionsMessage: string option returnsSelectedDate: string option planReadSeq: int planInFlight: bool @@ -1422,6 +1520,13 @@ type Msg = | StockPositionsReadRequested | StockPositionsReadCompleted of requestId: int * fundId: string * positions: RawStockPositions | StockPositionsReadFailed of requestId: int * fundId: string * message: string + | BondTradeQuantityChanged of string + | BondTradeRequested + | BondTradeCompleted of requestId: int * fundId: string * trade: RawBondTrade + | BondTradeFailed of requestId: int * fundId: string * message: string + | BondPositionsReadRequested + | BondPositionsReadCompleted of requestId: int * fundId: string * positions: RawBondPositions + | BondPositionsReadFailed of requestId: int * fundId: string * message: string | ReturnsDateChanged of string | InvestmentPlansReadRequested | InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array @@ -1574,6 +1679,16 @@ let init () = stockPositionsInFlight = false stockPositions = [] stockPositionsMessage = None + bondTradeQuantity = "" + bondTradeSeq = 0 + bondTradeInFlight = false + lastBondTrade = None + lastBondTradeAttempt = None + bondTradeMessage = None + bondPositionsReadSeq = 0 + bondPositionsInFlight = false + bondPositions = [] + bondPositionsMessage = None returnsSelectedDate = None planReadSeq = 0 planInFlight = false @@ -1793,6 +1908,20 @@ let private readStockPositionsCommand token fundId requestId = (fun positions -> StockPositionsReadCompleted(requestId, fundId, positions)) (fun error -> StockPositionsReadFailed(requestId, fundId, errorText error)) +let private createBondTradeCommand token fundId payload requestId = + Cmd.OfPromise.either + (fun () -> Api.createBondTrade token fundId payload) + () + (fun trade -> BondTradeCompleted(requestId, fundId, trade)) + (fun error -> BondTradeFailed(requestId, fundId, errorText error)) + +let private readBondPositionsCommand token fundId requestId = + Cmd.OfPromise.either + (fun () -> Api.getBondPositions token fundId) + () + (fun positions -> BondPositionsReadCompleted(requestId, fundId, positions)) + (fun error -> BondPositionsReadFailed(requestId, fundId, errorText error)) + let private readInvestmentPlansCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getInvestmentPlans token fundId) @@ -3248,6 +3377,105 @@ let update message model = Cmd.none else model, Cmd.none + | BondTradeQuantityChanged value -> { model with bondTradeQuantity = value }, Cmd.none + | BondTradeRequested -> + let code = model.bondCode.Trim() + let quantity = model.bondTradeQuantity.Trim() + + if String.IsNullOrWhiteSpace model.token then + { model with bondTradeMessage = Some "请输入 API token" }, Cmd.none + elif model.createdFund.IsNone then + { model with bondTradeMessage = Some "请先创建一个基金" }, Cmd.none + elif code.Length <> 6 || not (code |> Seq.forall Char.IsDigit) then + { model with bondTradeMessage = Some "无效代码 — 请输入六位债券代码" }, Cmd.none + elif not (isValidQuantityText quantity) then + { model with bondTradeMessage = Some "数量必须是大于零的整数张数,例如 10" }, Cmd.none + elif model.bondTradeInFlight then + model, Cmd.none + else + let requestId = model.bondTradeSeq + 1 + + let idempotencyKey = + match model.lastBondTradeAttempt with + | Some attempt when attempt.instrumentCode = code && attempt.quantity = quantity -> attempt.idempotencyKey + | _ -> Guid.NewGuid().ToString("N") + + { + model with + bondCode = code + bondTradeQuantity = quantity + bondTradeSeq = requestId + bondTradeInFlight = true + lastBondTradeAttempt = + Some + { + idempotencyKey = idempotencyKey + instrumentCode = code + quantity = quantity + } + bondTradeMessage = None + }, + createBondTradeCommand + model.token + model.createdFund.Value.id + { idempotencyKey = idempotencyKey; instrumentCode = code; quantity = quantity } + requestId + | BondTradeCompleted (requestId, fundId, trade) -> + if requestId = model.bondTradeSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + bondTradeInFlight = false + lastBondTrade = Some(Api.decodeBondTrade trade) + lastBondTradeAttempt = None + bondTradeMessage = None + }, + Cmd.ofMsg BondPositionsReadRequested + else + model, Cmd.none + | BondTradeFailed (requestId, fundId, message) -> + if requestId = model.bondTradeSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { model with bondTradeInFlight = false; bondTradeMessage = Some(sprintf "买入失败 — %s" message) }, Cmd.none + else + model, Cmd.none + | BondPositionsReadRequested -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) -> + let requestId = model.bondPositionsReadSeq + 1 + + { + model with + bondPositionsReadSeq = requestId + bondPositionsInFlight = true + bondPositionsMessage = None + }, + readBondPositionsCommand model.token fund.id requestId + | _ -> model, Cmd.none + | BondPositionsReadCompleted (requestId, fundId, positions) -> + if requestId = model.bondPositionsReadSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + bondPositions = positions.positions |> Array.toList |> List.map Api.decodeBondPosition + bondPositionsInFlight = false + bondPositionsMessage = None + }, + Cmd.none + else + model, Cmd.none + | BondPositionsReadFailed (requestId, fundId, message) -> + if requestId = model.bondPositionsReadSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + bondPositions = [] + bondPositionsInFlight = false + bondPositionsMessage = Some(sprintf "持仓不可用 — %s" message) + }, + Cmd.none + else + model, Cmd.none | ReturnsDateChanged value -> { model with returnsSelectedDate = (if String.IsNullOrWhiteSpace value then None else Some value) }, Cmd.none | InvestmentPlansReadRequested -> @@ -5015,6 +5243,72 @@ let private bondsPanel model dispatch = ] | None -> Html.p [ prop.className "hint"; prop.text "查询后显示名称/价格/到期日;缺失字段以「—」表示,不补零。" ] + Html.div [ + prop.className "bond-trade" + prop.children [ + Html.p [ prop.className "returns-subheading"; prop.text "买入(以实时行情价成交)" ] + Html.div [ + prop.className "search-row" + prop.children [ + Html.input [ + prop.className "text-input bond-trade-quantity-input" + prop.placeholder "买入数量(整数张),如 10" + prop.value model.bondTradeQuantity + prop.onChange (fun value -> dispatch (BondTradeQuantityChanged value)) + ] + Html.button [ + prop.className "primary-action bond-trade-action" + prop.disabled model.bondTradeInFlight + prop.onClick (fun _ -> dispatch BondTradeRequested) + prop.text ((if model.bondTradeInFlight then "买入中..." else "买入"): string) + ] + Html.button [ + prop.className "secondary-action bond-positions-refresh-action" + prop.disabled model.bondPositionsInFlight + prop.onClick (fun _ -> dispatch BondPositionsReadRequested) + prop.text ((if model.bondPositionsInFlight then "读取中..." else "刷新持仓"): string) + ] + ] + ] + Html.p [ + prop.className "bond-trade-unavailable" + prop.hidden (model.bondTradeMessage.IsNone) + prop.text (model.bondTradeMessage |> Option.defaultValue "") + ] + ] + ] + Html.div [ + prop.className "bond-positions" + prop.hidden (List.isEmpty model.bondPositions) + prop.children ( + Html.p [ prop.className "returns-subheading"; prop.text "债券持仓" ] + :: Html.div [ + prop.className "bond-positions-row bond-positions-header" + prop.children [ + Html.span [ prop.className "fund-detail-label"; prop.text "代码" ] + Html.span [ prop.className "fund-detail-label"; prop.text "名称" ] + Html.span [ prop.className "fund-detail-label"; prop.text "数量" ] + Html.span [ prop.className "fund-detail-label"; prop.text "成本" ] + ] + ] + :: (model.bondPositions + |> List.map (fun position -> + Html.div [ + prop.className "bond-positions-row bond-position-row" + prop.children [ + Html.span [ prop.className "fund-detail-value"; prop.text position.instrumentCode ] + Html.span [ prop.className "fund-detail-value"; prop.text (position.bondName |> Option.defaultValue "—") ] + Html.span [ prop.className "fund-detail-value"; prop.text position.quantity ] + Html.span [ prop.className "fund-detail-value"; prop.text position.costCash ] + ] + ])) + ) + ] + Html.p [ + prop.className "bond-positions-unavailable" + prop.hidden (model.bondPositionsMessage.IsNone) + prop.text (model.bondPositionsMessage |> Option.defaultValue "") + ] ] ] diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index 968b86f..026464a 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -163,6 +163,22 @@ export function getStockPositions(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}/stock-positions`, token); } +export function createBondTrade(token, fundId, payload) { + const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"quantity":${JSON.stringify(payload.quantity)}}`; + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/bond-trades`, token, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": payload.idempotencyKey + }, + body + }); +} + +export function getBondPositions(token, fundId) { + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/bond-positions`, token); +} + export function createSipPlan(token, fundId, payload) { const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"amount":${JSON.stringify(payload.amount)},"frequency":${JSON.stringify(payload.frequency)}}`; return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token, { diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css index bf0627b..08829c2 100644 --- a/src/FundLab.Web/src/styles.css +++ b/src/FundLab.Web/src/styles.css @@ -605,6 +605,25 @@ h2 { font-size: 12px; } +.bond-trade-unavailable, +.bond-positions-unavailable { + color: #b45309; + font-size: 12px; +} + +.bond-positions-header .fund-detail-label { + font-weight: 600; +} + +.bond-positions-row { + display: grid; + grid-template-columns: 1fr 1.4fr 1fr 1fr; + gap: 8px; + align-items: center; + padding: 4px 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); +} + .stock-positions-header .fund-detail-label { font-weight: 600; } |
