diff options
Diffstat (limited to 'src/FundLab.Api/App.fs')
| -rw-r--r-- | src/FundLab.Api/App.fs | 371 |
1 files changed, 336 insertions, 35 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 896a2c4..16da159 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -182,6 +182,14 @@ type BondTradeResponse = bondName: string option quantity: string price: string + cleanPrice: string + accruedInterest: string + parValue: string + settlementDate: string + tradeDate: string + couponRate: string option + valueDate: string option + maturityDate: string option costCash: string executedAt: string isSynthetic: bool @@ -202,6 +210,27 @@ type BondPositionsResponse = positions: BondPositionResponse list } +type BondCashflowResponse = + { + id: Guid + fundId: Guid + instrumentCode: string + bondName: string option + eventType: string + eventDate: string + quantity: string + amount: string + note: string option + isSynthetic: bool + createdAt: string + } + +type BondCashflowsResponse = + { + fundId: Guid + events: BondCashflowResponse list + } + type ValuationPositionResponse = { instrumentCode: string @@ -210,6 +239,10 @@ type ValuationPositionResponse = quantity: string price: string option priceSource: string option + cleanPrice: string option + accruedInterest: string option + dirtyPrice: string option + valueBasis: string option marketValue: string option status: string } @@ -489,6 +522,20 @@ type BondQuoteApiResponse = accruedInterest: string option date: string option maturityDate: string option + parValue: string option + issuePrice: string option + valueDate: string option + listingDate: string option + publishDate: string option + payInterestDay: string option + couponRate: string option + couponRateExplain: string option + bondExpireYears: string option + rating: string option + dataStatus: string option + accruedInterestComputed: string option + dirtyPrice: string option + valuationDate: string option } type StockQuoteApiResponse = @@ -644,11 +691,34 @@ module App = bondName = trade.BondName quantity = decimalText trade.Quantity price = decimalText trade.Price + cleanPrice = decimalText trade.CleanPrice + accruedInterest = decimalText trade.AccruedInterest + parValue = decimalText trade.ParValue + settlementDate = dateText trade.SettlementDate + tradeDate = dateText trade.TradeDate + couponRate = trade.CouponRate |> Option.map decimalText + valueDate = trade.ValueDate |> Option.map dateText + maturityDate = trade.MaturityDate |> Option.map dateText costCash = cashText trade.CostCash executedAt = timestampText trade.ExecutedAt isSynthetic = trade.IsSynthetic } + let private bondCashflowResponse (record: BondCashflowRecord) : BondCashflowResponse = + { + id = record.Id + fundId = record.FundId + instrumentCode = record.InstrumentCode + bondName = record.BondName + eventType = record.EventType + eventDate = dateText record.EventDate + quantity = decimalText record.Quantity + amount = cashText record.Amount + note = record.Note + isSynthetic = record.IsSynthetic + createdAt = timestampText record.CreatedAt + } + let private bondPositionResponse (position: BondPositionRecord) : BondPositionResponse = { instrumentCode = position.InstrumentCode @@ -1021,13 +1091,32 @@ module App = match tryDecimal "quantity" quantityText with | Error message -> Error message | Ok quantity -> - Ok - { - InstrumentCode = code.Trim() - BondName = tryStringProperty root "bondName" - Quantity = quantity - Price = 0m - } + let tradeDate = + match tryStringProperty root "tradeDate" with + | None -> Ok None + | Some text -> + match DateOnly.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None) with + | true, date -> Ok(Some date) + | false, _ -> Error "tradeDate must be an ISO date (yyyy-MM-dd)" + + match tradeDate with + | Error message -> Error message + | Ok tradeDate -> + Ok + { + InstrumentCode = code.Trim() + BondName = tryStringProperty root "bondName" + Quantity = quantity + Price = 0m + CleanPrice = 0m + AccruedInterest = 0m + ParValue = 100m + SettlementDate = DateOnly.FromDateTime DateTime.UtcNow + CouponRate = None + ValueDate = None + MaturityDate = None + TradeDate = tradeDate + } with | :? JsonException -> Error "request body must be valid JSON" @@ -2082,6 +2171,20 @@ module App = match probe.GetQuote(code, ctx.RequestAborted) with | Ok quote -> + let terms = BondQuote.tryTerms quote + + let valuationDate = + quote.Date + |> Option.orElse quote.PublishDate + |> Option.defaultValue (DateOnly.FromDateTime DateTime.UtcNow) + + let computedAccrued = terms |> Option.map (fun value -> BondRules.accruedInterest value valuationDate) + + let dirtyPrice = + match quote.CleanPrice |> Option.orElse quote.Price, computedAccrued with + | Some clean, Some accrued -> Some(BondRules.dirtyPrice clean accrued) + | _ -> None + json ({ code = quote.Code sourceRevision = quote.SourceRevision @@ -2090,7 +2193,21 @@ module App = cleanPrice = quote.CleanPrice |> Option.map decimalText accruedInterest = quote.AccruedInterest |> Option.map decimalText date = quote.Date |> Option.map dateText - maturityDate = quote.MaturityDate |> Option.map dateText } + maturityDate = quote.MaturityDate |> Option.map dateText + parValue = quote.ParValue |> Option.map decimalText + issuePrice = quote.IssuePrice |> Option.map decimalText + valueDate = quote.ValueDate |> Option.map dateText + listingDate = quote.ListingDate |> Option.map dateText + publishDate = quote.PublishDate |> Option.map dateText + payInterestDay = quote.PayInterestDay + couponRate = quote.CouponRate |> Option.map decimalText + couponRateExplain = quote.CouponRateExplain + bondExpireYears = quote.BondExpireYears + rating = quote.Rating + dataStatus = quote.DataStatus + accruedInterestComputed = computedAccrued |> Option.map decimalText + dirtyPrice = dirtyPrice |> Option.map decimalText + valuationDate = Some(dateText valuationDate) } : BondQuoteApiResponse) next ctx @@ -2352,38 +2469,81 @@ module App = | Error failure -> return! invokeHandler (marketDataError failure) next ctx | Ok quote -> - match quote.Price with + let terms = BondQuote.tryTerms quote + let cleanPrice = quote.CleanPrice |> Option.orElse quote.Price + + match cleanPrice 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 clean -> + let asOf = + command.TradeDate + |> Option.orElse quote.Date + |> Option.orElse quote.PublishDate + |> Option.defaultValue (DateOnly.FromDateTime DateTime.UtcNow) + + let computedAccrued = + terms |> Option.map (fun value -> BondRules.accruedInterest value asOf) + + let accrued = + quote.AccruedInterest |> Option.orElse computedAccrued |> Option.defaultValue 0m + + let dirtyPrice = BondRules.dirtyPrice clean accrued + let parValue = quote.ParValue |> Option.defaultValue 100m + + let settlement = + terms + |> Option.map (fun value -> BondRules.settlementDate value asOf) + |> Option.defaultValue asOf + + let quantityCheck = + terms |> Option.map (fun value -> BondRules.validateQuantity value command.Quantity) + + match quantityCheck with + | Some(Error message) -> + return! invokeHandler (errorResponse 400 "INVALID_BOND_TRADE_REQUEST" message) next ctx + | _ -> + let resolvedName = + match command.BondName with | Some name when not (String.IsNullOrWhiteSpace name) -> Some name - | _ -> None + | _ -> + match quote.Name with + | Some name when not (String.IsNullOrWhiteSpace name) -> Some name + | _ -> None - let priced = { command with Price = price; BondName = resolvedName } + let priced = + { + command with + Price = dirtyPrice + CleanPrice = clean + AccruedInterest = accrued + ParValue = parValue + SettlementDate = settlement + CouponRate = terms |> Option.map (fun value -> value.CouponRate) + ValueDate = terms |> Option.map (fun value -> value.ValueDate) + MaturityDate = terms |> Option.map (fun value -> value.MaturityDate) + TradeDate = Some asOf + 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 + 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 = @@ -2403,11 +2563,112 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "bond position persistence failed" next ctx + let private parseBondCashflowCommand (body: string) : Result<BondCashflowCommand, 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", + tryStringProperty root "eventType", + tryStringProperty root "eventDate", + tryStringProperty root "quantity", + tryStringProperty root "amount" + with + | Some code, Some eventType, Some eventDateText, Some quantityText, Some amountText -> + if code.Trim().Length <> 6 || not (code.Trim() |> Seq.forall Char.IsDigit) then + Error "instrumentCode must contain exactly six digits" + else + match + DateOnly.TryParseExact(eventDateText, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None), + tryDecimal "quantity" quantityText, + tryDecimal "amount" amountText + with + | (true, eventDate), Ok quantity, Ok amount -> + Ok + { + InstrumentCode = code.Trim() + BondName = tryStringProperty root "bondName" + EventType = eventType + EventDate = eventDate + Quantity = quantity + Amount = amount + Note = tryStringProperty root "note" + } + | (false, _), _, _ -> Error "eventDate must be an ISO date (yyyy-MM-dd)" + | _, Error message, _ -> Error message + | _, _, Error message -> Error message + | _ -> Error "instrumentCode, eventType, eventDate, quantity and amount are required" + with + | :? JsonException -> Error "request body must be valid JSON" + + let private recordBondCashflow (repository: FundRepository) (fundIdText: string) : HttpHandler = + fun next ctx -> + task { + match Guid.TryParse fundIdText with + | false, _ -> + return! invokeHandler (errorResponse 400 "INVALID_BOND_CASHFLOW_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 parseBondCashflowCommand body with + | Error message -> + return! invokeHandler (errorResponse 400 "INVALID_BOND_CASHFLOW_REQUEST" message) next ctx + | Ok command -> + try + match repository.RecordBondCashflow(idempotencyKey, fundId, command) with + | BondCashflowWriteResult.BondCashflowCreated record -> + return! invokeHandler (setStatusCode 201 >=> json (bondCashflowResponse record)) next ctx + | BondCashflowWriteResult.BondCashflowReplayed record -> + return! invokeHandler (json (bondCashflowResponse record)) next ctx + | BondCashflowWriteResult.BondCashflowIdempotencyConflict -> + return! + invokeHandler + (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") + next + ctx + | BondCashflowWriteResult.BondCashflowInvalid message -> + return! invokeHandler (errorResponse 400 "INVALID_BOND_CASHFLOW_REQUEST" message) next ctx + | BondCashflowWriteResult.BondCashflowFundNotFound -> + return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx + | BondCashflowWriteResult.BondCashflowPositionNotFound -> + return! invokeHandler (errorResponse 404 "BOND_POSITION_NOT_FOUND" "bond position was not found") next ctx + with _ -> + return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "bond cashflow persistence failed") next ctx + } + + let private getBondCashflows (repository: FundRepository) (fundIdText: string) : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText with + | false, _ -> errorResponse 400 "INVALID_BOND_CASHFLOW_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 events = + repository.GetBondCashflows fundId |> List.map bondCashflowResponse + + json ({ fundId = fund.Id; events = events } : BondCashflowsResponse) next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "bond cashflow persistence failed" next ctx + + /// Point-in-time valuation row. For bonds the clean snapshot price is + /// converted to a dirty (全价) value using accrued interest as of the + /// valuation date, so the holdings value is reproducible and never uses + /// future coupon information. Stocks keep the legacy clean basis. let private valuationPositionResponse (assetClass: string) (code: string) (fallbackName: string option) (quantity: decimal) + (accrued: decimal option) + (parValue: decimal) (resolvedPrice: (string * decimal) option) = let resolvedName = @@ -2417,13 +2678,23 @@ module App = match resolvedPrice with | Some(source, price) -> + let accruedValue = accrued |> Option.defaultValue 0m + let dirtyPrice = BondRules.dirtyPrice price accruedValue + + let marketValue = + Decimal.Round(quantity * dirtyPrice * parValue / 100m, 2, MidpointRounding.AwayFromZero) + { instrumentCode = code name = resolvedName assetClass = assetClass quantity = decimalText quantity price = Some(decimalText price) priceSource = Some source - marketValue = Some(cashText (Decimal.Round(quantity * price, 2, MidpointRounding.AwayFromZero))) + cleanPrice = Some(decimalText price) + accruedInterest = accrued |> Option.map decimalText + dirtyPrice = Some(decimalText dirtyPrice) + valueBasis = Some(if accruedValue > 0m then "dirty" else "clean") + marketValue = Some(cashText marketValue) status = "priced" } | None -> { instrumentCode = code @@ -2432,6 +2703,10 @@ module App = quantity = decimalText quantity price = None priceSource = None + cleanPrice = None + accruedInterest = accrued |> Option.map decimalText + dirtyPrice = None + valueBasis = None marketValue = None status = "unavailable" } @@ -2489,7 +2764,20 @@ module App = probes.Value.StockQuotes.GetQuote(position.InstrumentCode, token) |> Result.map (fun quote -> quote.Price))) - valuationPositionResponse "stock" position.InstrumentCode position.StockName position.Quantity resolved) + valuationPositionResponse "stock" position.InstrumentCode position.StockName position.Quantity None 100m resolved) + + let bondTermsByCode = + repository.GetBondTrades fundId + |> List.fold + (fun acc trade -> + match trade.ValueDate, trade.MaturityDate, trade.CouponRate with + | Some valueDate, Some maturityDate, Some couponRate -> + Map.add + trade.InstrumentCode + (BondTerms.create trade.ParValue couponRate 1 valueDate maturityDate 10m 0 0m) + acc + | _ -> acc) + Map.empty let bondRows = repository.GetBondPositions fundId @@ -2501,9 +2789,20 @@ module App = resolveValuationPrice snapshotPrice (fun () -> priceOf (fun () -> probes.Value.BondQuotes.GetQuote(position.InstrumentCode, token) - |> Result.map (fun quote -> quote.Price))) + |> Result.map (fun quote -> quote.CleanPrice |> Option.orElse quote.Price))) + + let terms = bondTermsByCode |> Map.tryFind position.InstrumentCode + let accrued = terms |> Option.map (fun value -> BondRules.accruedInterest value asOfDate) + let parValue = terms |> Option.map (fun value -> value.ParValue) |> Option.defaultValue 100m - valuationPositionResponse "bond" position.InstrumentCode position.BondName position.Quantity resolved) + valuationPositionResponse + "bond" + position.InstrumentCode + position.BondName + position.Quantity + accrued + parValue + resolved) let positions = stockRows @ bondRows @@ -2766,6 +3065,8 @@ module App = 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) + POST >=> routef "/funds/%s/bond-cashflows" (recordBondCashflow repository) + GET >=> routef "/funds/%s/bond-cashflows" (getBondCashflows repository) GET >=> routef "/funds/%s/valuation" (getFundValuation repository probes) POST >=> routef "/funds/%s/market-data/refresh" (fun fundId -> refreshFundMarketData repository marketData probes fundId) GET >=> routef "/funds/%s" (getFund repository) |
