summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/App.fs
diff options
context:
space:
mode:
Diffstat (limited to 'src/FundLab.Api/App.fs')
-rw-r--r--src/FundLab.Api/App.fs371
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)