diff options
| -rw-r--r-- | src/FundLab.Api/App.fs | 371 | ||||
| -rw-r--r-- | src/FundLab.Api/BondQuoteProbe.fs | 45 | ||||
| -rw-r--r-- | src/FundLab.Api/MarketData.fs | 67 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 454 | ||||
| -rw-r--r-- | src/FundLab.Api/akshare_collector.py | 58 | ||||
| -rw-r--r-- | src/FundLab.Domain/BondRules.fs | 141 | ||||
| -rw-r--r-- | src/FundLab.Domain/FundLab.Domain.fsproj | 1 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 35 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/BondQuoteProbeTests.fs | 11 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/BondTradeTests.fs | 139 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/FundValuationTests.fs | 77 | ||||
| -rw-r--r-- | tests/FundLab.Domain.Tests/BondRulesTests.fs | 70 | ||||
| -rw-r--r-- | tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj | 1 |
13 files changed, 1398 insertions, 72 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) diff --git a/src/FundLab.Api/BondQuoteProbe.fs b/src/FundLab.Api/BondQuoteProbe.fs index 311dec5..b01ff18 100644 --- a/src/FundLab.Api/BondQuoteProbe.fs +++ b/src/FundLab.Api/BondQuoteProbe.fs @@ -17,8 +17,42 @@ type BondQuote = AccruedInterest: decimal option Date: DateOnly option MaturityDate: DateOnly option + ParValue: decimal option + IssuePrice: decimal option + ValueDate: DateOnly option + ListingDate: DateOnly option + PublishDate: DateOnly option + PayInterestDay: string option + CouponRate: decimal option + CouponRateExplain: string option + BondExpireYears: string option + Rating: string option + DataStatus: string option } +module BondQuote = + open FundLab.Domain + + /// Best-effort conversion of a quote into pricing terms. Requires the value + /// date, maturity and coupon rate; when any is missing the caller gets None + /// rather than a fabricated coupon. Exchange convertible bonds pay annually + /// (a single 付息日), so the frequency defaults to 1. + let tryTerms (quote: BondQuote) : BondTerms option = + match quote.ValueDate, quote.MaturityDate, quote.CouponRate with + | Some valueDate, Some maturityDate, Some couponRate -> + Some( + BondTerms.create + (defaultArg quote.ParValue 100m) + couponRate + 1 + valueDate + maturityDate + 10m + 0 + 0m + ) + | _ -> None + /// Read-only probe that returns the current quote for a bond code. /// /// Like INavDateProbe there is no synthetic fallback: a failed probe surfaces as @@ -70,6 +104,17 @@ type AkshareBondQuoteProbe(collector: IMarketDataCollector) = AccruedInterest = payload.AccruedInterest Date = payload.Date MaturityDate = payload.MaturityDate + ParValue = payload.ParValue + IssuePrice = payload.IssuePrice + ValueDate = payload.ValueDate + ListingDate = payload.ListingDate + PublishDate = payload.PublishDate + PayInterestDay = payload.PayInterestDay + CouponRate = payload.CouponRate + CouponRateExplain = payload.CouponRateExplain + BondExpireYears = payload.BondExpireYears + Rating = payload.Rating + DataStatus = payload.DataStatus } cache.[normalized] <- quote diff --git a/src/FundLab.Api/MarketData.fs b/src/FundLab.Api/MarketData.fs index e621740..152ffd7 100644 --- a/src/FundLab.Api/MarketData.fs +++ b/src/FundLab.Api/MarketData.fs @@ -49,6 +49,17 @@ type MarketDataBondQuotePayload = AccruedInterest: decimal option Date: DateOnly option MaturityDate: DateOnly option + ParValue: decimal option + IssuePrice: decimal option + ValueDate: DateOnly option + ListingDate: DateOnly option + PublishDate: DateOnly option + PayInterestDay: string option + CouponRate: decimal option + CouponRateExplain: string option + BondExpireYears: string option + Rating: string option + DataStatus: string option } type MarketDataStockQuotePayload = @@ -379,17 +390,44 @@ module MarketData = let! name = optionalString quoteProperty "name" let! priceProperty = requiredProperty quoteProperty "price" let! price = optionalDecimal "price" priceProperty - let! cleanPrice = - optionalProperty quoteProperty "clean_price" - |> Result.bind (function Some property -> optionalDecimal "clean_price" property | None -> Ok None) - let! accruedInterest = - optionalProperty quoteProperty "accrued_interest" - |> Result.bind (function Some property -> optionalDecimal "accrued_interest" property | None -> Ok None) - let! date = - optionalProperty quoteProperty "date" - |> Result.bind (function Some property -> optionalIsoDate "date" property | None -> Ok None) + let optionalDecimalField fieldName = + optionalProperty quoteProperty fieldName + |> Result.bind (function + | Some property -> optionalDecimal fieldName property + | None -> Ok None) + + let optionalDateField fieldName = + optionalProperty quoteProperty fieldName + |> Result.bind (function + | Some property -> optionalIsoDate fieldName property + | None -> Ok None) + + let optionalStringField fieldName = + optionalProperty quoteProperty fieldName + |> Result.bind (function + | Some property when property.ValueKind = JsonValueKind.Null -> Ok None + | Some property when property.ValueKind = JsonValueKind.String -> + let value = property.GetString() + if String.IsNullOrWhiteSpace value then Ok None else Ok(Some value) + | Some _ -> Error(sprintf "payload property '%s' must be null or a string" fieldName) + | None -> Ok None) + + let! cleanPrice = optionalDecimalField "clean_price" + let! accruedInterest = optionalDecimalField "accrued_interest" + let! date = optionalDateField "date" let! maturityProperty = requiredProperty quoteProperty "maturity_date" let! maturityDate = optionalIsoDate "maturity_date" maturityProperty + let! parValue = optionalDecimalField "par_value" + let! issuePrice = optionalDecimalField "issue_price" + let! valueDate = optionalDateField "value_date" + let! listingDate = optionalDateField "listing_date" + let! publishDate = optionalDateField "publish_date" + let! payInterestDay = optionalStringField "pay_interest_day" + let! couponRate = optionalDecimalField "coupon_rate" + let! couponRateExplain = optionalStringField "coupon_rate_explain" + let! bondExpireYears = optionalStringField "bond_expire_years" + let! rating = optionalStringField "rating" + let! dataStatus = optionalStringField "data_status" return { @@ -403,6 +441,17 @@ module MarketData = AccruedInterest = accruedInterest Date = date MaturityDate = maturityDate + ParValue = parValue + IssuePrice = issuePrice + ValueDate = valueDate + ListingDate = listingDate + PublishDate = publishDate + PayInterestDay = payInterestDay + CouponRate = couponRate + CouponRateExplain = couponRateExplain + BondExpireYears = bondExpireYears + Rating = rating + DataStatus = dataStatus } } with diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs index 1cf68cd..2471c58 100644 --- a/src/FundLab.Api/Persistence.fs +++ b/src/FundLab.Api/Persistence.fs @@ -363,7 +363,18 @@ type BondTradeCommand = InstrumentCode: string BondName: string option Quantity: decimal + /// All-in (dirty/全价) execution price per 100 of face value. Price: decimal + CleanPrice: decimal + AccruedInterest: decimal + ParValue: decimal + SettlementDate: DateOnly + CouponRate: decimal option + ValueDate: DateOnly option + MaturityDate: DateOnly option + /// Explicit trade/valuation date supplied by the caller (wins over the + /// quote's own date so tests and backfills stay deterministic). + TradeDate: DateOnly option } type BondTradeRecord = @@ -374,11 +385,55 @@ type BondTradeRecord = BondName: string option Quantity: decimal Price: decimal + CleanPrice: decimal + AccruedInterest: decimal + ParValue: decimal + SettlementDate: DateOnly + CouponRate: decimal option + ValueDate: DateOnly option + MaturityDate: DateOnly option + TradeDate: DateOnly CostCash: decimal IsSynthetic: bool ExecutedAt: DateTimeOffset } +type BondCashflowCommand = + { + InstrumentCode: string + BondName: string option + /// "coupon" (付息) or "maturity" (到期). + EventType: string + EventDate: DateOnly + Quantity: decimal + /// Cash credited to the fund's available cash. + Amount: decimal + Note: string option + } + +type BondCashflowRecord = + { + Id: Guid + FundId: Guid + InstrumentCode: string + BondName: string option + EventType: string + EventDate: DateOnly + Quantity: decimal + Amount: decimal + Note: string option + IsSynthetic: bool + CreatedAt: DateTimeOffset + } + +type BondCashflowWriteResult = + | BondCashflowCreated of BondCashflowRecord + | BondCashflowReplayed of BondCashflowRecord + | BondCashflowIdempotencyConflict + | BondCashflowInvalid of string + | BondCashflowFundNotFound + | BondCashflowPositionNotFound + type BondPositionRecord = { FundId: Guid @@ -1019,6 +1074,15 @@ type FundRepository(connectionString: string) = executed_at timestamptz NOT NULL ); + ALTER TABLE bond_trades ADD COLUMN IF NOT EXISTS clean_price numeric(20, 4) NOT NULL DEFAULT 0; + ALTER TABLE bond_trades ADD COLUMN IF NOT EXISTS accrued_interest numeric(20, 4) NOT NULL DEFAULT 0; + ALTER TABLE bond_trades ADD COLUMN IF NOT EXISTS par_value numeric(20, 4) NOT NULL DEFAULT 100; + ALTER TABLE bond_trades ADD COLUMN IF NOT EXISTS settlement_date date NOT NULL DEFAULT CURRENT_DATE; + ALTER TABLE bond_trades ADD COLUMN IF NOT EXISTS coupon_rate numeric(12, 6) NULL; + ALTER TABLE bond_trades ADD COLUMN IF NOT EXISTS value_date date NULL; + ALTER TABLE bond_trades ADD COLUMN IF NOT EXISTS maturity_date date NULL; + ALTER TABLE bond_trades ADD COLUMN IF NOT EXISTS trade_date date NOT NULL DEFAULT CURRENT_DATE; + CREATE TABLE IF NOT EXISTS bond_trade_idempotencies ( idempotency_key text PRIMARY KEY, request_hash text NOT NULL, @@ -1027,6 +1091,28 @@ type FundRepository(connectionString: string) = created_at timestamptz NOT NULL DEFAULT now() ); + CREATE TABLE IF NOT EXISTS bond_cashflow_events ( + id uuid PRIMARY KEY, + fund_id uuid NOT NULL REFERENCES funds(id), + instrument_code text NOT NULL, + bond_name text NULL, + event_type text NOT NULL CHECK (event_type IN ('coupon', 'maturity')), + event_date date NOT NULL, + quantity numeric(28, 8) NOT NULL CHECK (quantity > 0), + amount numeric(20, 2) NOT NULL CHECK (amount >= 0), + note text NULL, + is_synthetic boolean NOT NULL, + created_at timestamptz NOT NULL + ); + + CREATE TABLE IF NOT EXISTS bond_cashflow_idempotencies ( + idempotency_key text PRIMARY KEY, + request_hash text NOT NULL, + event_id uuid NOT NULL REFERENCES bond_cashflow_events(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, @@ -2157,11 +2243,22 @@ type FundRepository(connectionString: string) = 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) + CleanPrice = reader.GetDecimal(6) + AccruedInterest = reader.GetDecimal(7) + ParValue = reader.GetDecimal(8) + SettlementDate = reader.GetFieldValue<DateOnly>(9) + CouponRate = readDecimalOption reader 10 + ValueDate = if reader.IsDBNull(11) then None else Some(reader.GetFieldValue<DateOnly>(11)) + MaturityDate = if reader.IsDBNull(12) then None else Some(reader.GetFieldValue<DateOnly>(12)) + TradeDate = reader.GetFieldValue<DateOnly>(13) + CostCash = reader.GetDecimal(14) + IsSynthetic = reader.GetBoolean(15) + ExecutedAt = reader.GetFieldValue<DateTimeOffset>(16) } + let bondTradeColumns = + "id, fund_id, instrument_code, bond_name, quantity, price, clean_price, accrued_interest, par_value, settlement_date, coupon_rate, value_date, maturity_date, trade_date, cost_cash, is_synthetic, executed_at" + let insertBondTrade connection transaction (trade: BondTradeRecord) = use command = commandWithTransaction @@ -2169,9 +2266,13 @@ type FundRepository(connectionString: string) = transaction """ INSERT INTO bond_trades - (id, fund_id, instrument_code, bond_name, quantity, price, cost_cash, is_synthetic, executed_at) + (id, fund_id, instrument_code, bond_name, quantity, price, clean_price, accrued_interest, + par_value, settlement_date, coupon_rate, value_date, maturity_date, trade_date, cost_cash, + is_synthetic, executed_at) VALUES - (@id, @fund_id, @instrument_code, @bond_name, @quantity, @price, @cost_cash, @is_synthetic, @executed_at) + (@id, @fund_id, @instrument_code, @bond_name, @quantity, @price, @clean_price, @accrued_interest, + @par_value, @settlement_date, @coupon_rate, @value_date, @maturity_date, @trade_date, @cost_cash, + @is_synthetic, @executed_at) """ addParameter command "id" NpgsqlDbType.Uuid (box trade.Id) |> ignore @@ -2186,6 +2287,32 @@ type FundRepository(connectionString: string) = 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 "clean_price" NpgsqlDbType.Numeric (box trade.CleanPrice) |> ignore + addParameter command "accrued_interest" NpgsqlDbType.Numeric (box trade.AccruedInterest) |> ignore + addParameter command "par_value" NpgsqlDbType.Numeric (box trade.ParValue) |> ignore + addParameter command "settlement_date" NpgsqlDbType.Date (box trade.SettlementDate) |> ignore + + let couponParameter = + match trade.CouponRate with + | Some value -> box value + | None -> box DBNull.Value + + addParameter command "coupon_rate" NpgsqlDbType.Numeric couponParameter |> ignore + + let valueDateParameter = + match trade.ValueDate with + | Some value -> box value + | None -> box DBNull.Value + + addParameter command "value_date" NpgsqlDbType.Date valueDateParameter |> ignore + + let maturityParameter = + match trade.MaturityDate with + | Some value -> box value + | None -> box DBNull.Value + + addParameter command "maturity_date" NpgsqlDbType.Date maturityParameter |> ignore + addParameter command "trade_date" NpgsqlDbType.Date (box trade.TradeDate) |> 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 @@ -2232,8 +2359,8 @@ type FundRepository(connectionString: string) = commandWithTransaction connection transaction - """ - SELECT id, fund_id, instrument_code, bond_name, quantity, price, cost_cash, is_synthetic, executed_at + $""" + SELECT {bondTradeColumns} FROM bond_trades WHERE id = @id """ @@ -2251,6 +2378,11 @@ type FundRepository(connectionString: string) = let invariant = CultureInfo.InvariantCulture let encoded (value: string) = sprintf "%d:%s" value.Length value let name = command.BondName |> Option.defaultValue "" + let dateText (value: DateOnly) = value.ToString("yyyy-MM-dd", invariant) + let optionDate = command.ValueDate |> Option.map dateText |> Option.defaultValue "" + let optionMaturity = command.MaturityDate |> Option.map dateText |> Option.defaultValue "" + let optionCoupon = command.CouponRate |> Option.map (fun v -> v.ToString("G29", invariant)) |> Option.defaultValue "" + let optionTradeDate = command.TradeDate |> Option.map dateText |> Option.defaultValue "" let payload = String.concat @@ -2262,6 +2394,148 @@ type FundRepository(connectionString: string) = encoded name encoded (command.Quantity.ToString("G29", invariant)) encoded (command.Price.ToString("G29", invariant)) + encoded (command.CleanPrice.ToString("G29", invariant)) + encoded (command.AccruedInterest.ToString("G29", invariant)) + encoded (command.ParValue.ToString("G29", invariant)) + encoded (dateText command.SettlementDate) + encoded optionTradeDate + encoded optionCoupon + encoded optionDate + encoded optionMaturity + ] + + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))) + + let bondCashflowRecordFromReader (reader: DbDataReader) : BondCashflowRecord = + { + Id = reader.GetGuid(0) + FundId = reader.GetGuid(1) + InstrumentCode = reader.GetString(2) + BondName = if reader.IsDBNull(3) then None else Some(reader.GetString(3)) + EventType = reader.GetString(4) + EventDate = reader.GetFieldValue<DateOnly>(5) + Quantity = reader.GetDecimal(6) + Amount = reader.GetDecimal(7) + Note = readStringOption reader 8 + IsSynthetic = reader.GetBoolean(9) + CreatedAt = reader.GetFieldValue<DateTimeOffset>(10) + } + + let bondCashflowColumns = + "id, fund_id, instrument_code, bond_name, event_type, event_date, quantity, amount, note, is_synthetic, created_at" + + let insertBondCashflow connection transaction (record: BondCashflowRecord) = + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO bond_cashflow_events + (id, fund_id, instrument_code, bond_name, event_type, event_date, quantity, amount, note, is_synthetic, created_at) + VALUES + (@id, @fund_id, @instrument_code, @bond_name, @event_type, @event_date, @quantity, @amount, @note, @is_synthetic, @created_at) + """ + + addParameter command "id" NpgsqlDbType.Uuid (box record.Id) |> ignore + addParameter command "fund_id" NpgsqlDbType.Uuid (box record.FundId) |> ignore + addParameter command "instrument_code" NpgsqlDbType.Text (box record.InstrumentCode) |> ignore + + let nameParameter = + match record.BondName with + | Some name -> box name + | None -> box DBNull.Value + + addParameter command "bond_name" NpgsqlDbType.Text nameParameter |> ignore + addParameter command "event_type" NpgsqlDbType.Text (box record.EventType) |> ignore + addParameter command "event_date" NpgsqlDbType.Date (box record.EventDate) |> ignore + addParameter command "quantity" NpgsqlDbType.Numeric (box record.Quantity) |> ignore + addParameter command "amount" NpgsqlDbType.Numeric (box record.Amount) |> ignore + + let noteParameter = + match record.Note with + | Some note -> box note + | None -> box DBNull.Value + + addParameter command "note" NpgsqlDbType.Text noteParameter |> ignore + addParameter command "is_synthetic" NpgsqlDbType.Boolean (box record.IsSynthetic) |> ignore + addParameter command "created_at" NpgsqlDbType.TimestampTz (box record.CreatedAt) |> ignore + command.ExecuteNonQuery() |> ignore + + let insertBondCashflowIdempotency connection transaction key requestHash eventId fundId = + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO bond_cashflow_idempotencies (idempotency_key, request_hash, event_id, fund_id) + VALUES (@idempotency_key, @request_hash, @event_id, @fund_id) + """ + + addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore + addParameter command "request_hash" NpgsqlDbType.Text (box requestHash) |> ignore + addParameter command "event_id" NpgsqlDbType.Uuid (box eventId) |> ignore + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + command.ExecuteNonQuery() |> ignore + + let findBondCashflowIdempotency connection transaction key = + use command = + commandWithTransaction + connection + transaction + """ + SELECT request_hash, fund_id, event_id + FROM bond_cashflow_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 findBondCashflow connection transaction eventId = + use command = + commandWithTransaction + connection + transaction + $""" + SELECT {bondCashflowColumns} + FROM bond_cashflow_events + WHERE id = @id + """ + + addParameter command "id" NpgsqlDbType.Uuid (box eventId) |> ignore + + use reader = command.ExecuteReader() + + if reader.Read() then + Some(bondCashflowRecordFromReader reader) + else + None + + let bondCashflowRequestHash (fundId: Guid) (command: BondCashflowCommand) = + let invariant = CultureInfo.InvariantCulture + let encoded (value: string) = sprintf "%d:%s" value.Length value + let name = command.BondName |> Option.defaultValue "" + let note = command.Note |> Option.defaultValue "" + + let payload = + String.concat + "|" + [ + "bond-cashflow" + encoded (fundId.ToString("D")) + encoded command.InstrumentCode + encoded name + encoded command.EventType + encoded (command.EventDate.ToString("yyyy-MM-dd", invariant)) + encoded (command.Quantity.ToString("G29", invariant)) + encoded (command.Amount.ToString("G29", invariant)) + encoded note ] Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))) @@ -5169,6 +5443,12 @@ type FundRepository(connectionString: string) = BondTradeWriteResult.BondTradeInvalid "quantity must be positive" elif command.Price <= 0m then BondTradeWriteResult.BondTradeInvalid "price must be positive" + elif command.CleanPrice <= 0m then + BondTradeWriteResult.BondTradeInvalid "clean price must be positive" + elif command.ParValue <= 0m then + BondTradeWriteResult.BondTradeInvalid "par value must be positive" + elif command.AccruedInterest < 0m then + BondTradeWriteResult.BondTradeInvalid "accrued interest cannot be negative" else let normalized = { command with InstrumentCode = code } let fingerprint = bondTradeRequestHash fundId normalized @@ -5206,7 +5486,13 @@ type FundRepository(connectionString: string) = BondTradeWriteResult.BondTradeFundNotFound | Some isSynthetic -> let executedAt = defaultArg executedAtOverride DateTimeOffset.UtcNow - let costCash = Decimal.Round(normalized.Quantity * normalized.Price, 2, MidpointRounding.AwayFromZero) + + let costCash = + Decimal.Round( + normalized.Quantity * normalized.Price * normalized.ParValue / 100m, + 2, + MidpointRounding.AwayFromZero + ) let trade: BondTradeRecord = { @@ -5216,6 +5502,14 @@ type FundRepository(connectionString: string) = BondName = normalized.BondName Quantity = normalized.Quantity Price = normalized.Price + CleanPrice = normalized.CleanPrice + AccruedInterest = normalized.AccruedInterest + ParValue = normalized.ParValue + SettlementDate = normalized.SettlementDate + CouponRate = normalized.CouponRate + ValueDate = normalized.ValueDate + MaturityDate = normalized.MaturityDate + TradeDate = normalized.TradeDate |> Option.defaultValue normalized.SettlementDate CostCash = costCash IsSynthetic = isSynthetic ExecutedAt = executedAt @@ -5271,8 +5565,8 @@ type FundRepository(connectionString: string) = commandWithTransaction connection None - """ - SELECT id, fund_id, instrument_code, bond_name, quantity, price, cost_cash, is_synthetic, executed_at + $""" + SELECT {bondTradeColumns} FROM bond_trades WHERE fund_id = @fund_id ORDER BY executed_at, id @@ -5288,6 +5582,146 @@ type FundRepository(connectionString: string) = records |> Seq.toList + member _.RecordBondCashflow(idempotencyKey: string, fundId: Guid, command: BondCashflowCommand) : BondCashflowWriteResult = + let code = if isNull command.InstrumentCode then "" else command.InstrumentCode.Trim() + let eventType = if isNull command.EventType then "" else command.EventType.Trim().ToLowerInvariant() + + if String.IsNullOrWhiteSpace idempotencyKey then + BondCashflowWriteResult.BondCashflowInvalid "idempotency key cannot be empty" + elif code.Length <> 6 || not (code |> Seq.forall Char.IsDigit) then + BondCashflowWriteResult.BondCashflowInvalid "bond code must contain exactly six digits" + elif eventType <> "coupon" && eventType <> "maturity" then + BondCashflowWriteResult.BondCashflowInvalid "event type must be coupon or maturity" + elif command.Quantity <= 0m then + BondCashflowWriteResult.BondCashflowInvalid "quantity must be positive" + elif command.Amount < 0m then + BondCashflowWriteResult.BondCashflowInvalid "amount cannot be negative" + else + let normalized = { command with InstrumentCode = code; EventType = eventType } + let fingerprint = bondCashflowRequestHash 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 findBondCashflowIdempotency connection (Some transaction) idempotencyKey with + | Some(existingHash, existingFundId, eventId) + when existingHash = fingerprint && existingFundId = fundId -> + match findBondCashflow connection (Some transaction) eventId with + | Some record -> + transaction.Commit() + BondCashflowWriteResult.BondCashflowReplayed record + | None -> + transaction.Rollback() + BondCashflowWriteResult.BondCashflowInvalid "idempotency record references a missing event" + | Some _ -> + transaction.Rollback() + BondCashflowWriteResult.BondCashflowIdempotencyConflict + | None -> + match lockFundForOrder connection (Some transaction) fundId with + | None -> + transaction.Rollback() + BondCashflowWriteResult.BondCashflowFundNotFound + | Some isSynthetic -> + let hasPosition = + use positionQuery = + commandWithTransaction + connection + (Some transaction) + "SELECT 1 FROM bond_positions WHERE fund_id = @fund_id AND instrument_code = @code" + + addParameter positionQuery "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + addParameter positionQuery "code" NpgsqlDbType.Text (box normalized.InstrumentCode) |> ignore + use reader = positionQuery.ExecuteReader() + reader.Read() + + if not hasPosition then + transaction.Rollback() + BondCashflowWriteResult.BondCashflowPositionNotFound + else + let record: BondCashflowRecord = + { + Id = Guid.NewGuid() + FundId = fundId + InstrumentCode = normalized.InstrumentCode + BondName = normalized.BondName + EventType = normalized.EventType + EventDate = normalized.EventDate + Quantity = normalized.Quantity + Amount = normalized.Amount + Note = normalized.Note + IsSynthetic = isSynthetic + CreatedAt = DateTimeOffset.UtcNow + } + + insertBondCashflow connection (Some transaction) record + insertBondCashflowIdempotency connection (Some transaction) idempotencyKey fingerprint record.Id fundId + + use cashCommand = + commandWithTransaction + connection + (Some transaction) + "UPDATE funds SET available_cash = available_cash + @amount WHERE id = @fund_id" + + addParameter cashCommand "amount" NpgsqlDbType.Numeric (box normalized.Amount) |> ignore + addParameter cashCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + cashCommand.ExecuteNonQuery() |> ignore + + if normalized.EventType = "maturity" then + use removeCommand = + commandWithTransaction + connection + (Some transaction) + "DELETE FROM bond_positions WHERE fund_id = @fund_id AND instrument_code = @code" + + addParameter removeCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + addParameter removeCommand "code" NpgsqlDbType.Text (box normalized.InstrumentCode) |> ignore + removeCommand.ExecuteNonQuery() |> ignore + + transaction.Commit() + BondCashflowWriteResult.BondCashflowCreated record + with error -> + try + transaction.Rollback() + with _ -> + () + + raise error + + member _.GetBondCashflows(fundId: Guid) : BondCashflowRecord list = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + + use command = + commandWithTransaction + connection + None + $""" + SELECT {bondCashflowColumns} + FROM bond_cashflow_events + WHERE fund_id = @fund_id + ORDER BY event_date, created_at, id + """ + + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + + use reader = command.ExecuteReader() + let records = ResizeArray<BondCashflowRecord>() + + while reader.Read() do + records.Add(bondCashflowRecordFromReader reader) + + records |> Seq.toList + member _.GetBondPositions(fundId: Guid) : BondPositionRecord list = use connection = new NpgsqlConnection(connectionString) connection.Open() diff --git a/src/FundLab.Api/akshare_collector.py b/src/FundLab.Api/akshare_collector.py index 2fb837e..9234d81 100644 --- a/src/FundLab.Api/akshare_collector.py +++ b/src/FundLab.Api/akshare_collector.py @@ -150,16 +150,34 @@ def nav(code): } -def bond_maturity_date(code): - # Maturity is a best-effort enrichment: the quote endpoint has no expiry - # date, so a failure here only means maturity_date stays null. +def bond_profile(code): + # Convertible-bond reference data (eastmoney). Best-effort enrichment: any + # failure leaves every descriptive field null rather than fabricating one. + # Plain treasury/policy bonds are not covered by this endpoint, so those + # fields legitimately stay null for them. try: frame = ak.bond_zh_cov_info(symbol=code, indicator="基本信息") if frame is None or frame.empty: - return None - return date_text(frame.iloc[0].get("EXPIRE_DATE")) + return {} + row = frame.iloc[0] + coupon_ir = decimal_text(row.get("COUPON_IR")) + coupon_rate = None + if coupon_ir is not None: + coupon_rate = format(Decimal(coupon_ir) / Decimal(100), "f") + return { + "par_value": decimal_text(row.get("PAR_VALUE")), + "issue_price": decimal_text(row.get("ISSUE_PRICE")), + "value_date": date_text(row.get("VALUE_DATE")), + "listing_date": date_text(row.get("LISTING_DATE")), + "maturity_date": date_text(row.get("EXPIRE_DATE")), + "pay_interest_day": text(row.get("PAY_INTEREST_DAY")), + "coupon_rate": coupon_rate, + "coupon_rate_explain": text(row.get("INTEREST_RATE_EXPLAIN")), + "bond_expire_years": text(row.get("BOND_EXPIRE")), + "rating": text(row.get("RATING")), + } except Exception: - return None + return {} def bond_quote_from_spot(code): @@ -199,7 +217,6 @@ def bond_quote_from_spot(code): "clean_price": price, "accrued_interest": None, "date": None, - "maturity_date": bond_maturity_date(code), "symbol": symbol, } @@ -244,7 +261,6 @@ def bond_quote_from_daily(code): "clean_price": close, "accrued_interest": None, "date": row_date, - "maturity_date": bond_maturity_date(code), "symbol": symbol, } @@ -263,6 +279,19 @@ def bond_quote(code): quote = None if quote is not None: + profile = bond_profile(code) + has_price = quote["price"] is not None + has_profile = profile.get("value_date") is not None or profile.get("maturity_date") is not None + + if has_price and has_profile: + data_status = "complete" + elif has_price: + data_status = "price_only" + elif has_profile: + data_status = "profile_only" + else: + data_status = "missing" + return { "schema_version": SCHEMA_VERSION, "operation": "bond-quote", @@ -276,7 +305,18 @@ def bond_quote(code): "clean_price": quote["clean_price"], "accrued_interest": quote["accrued_interest"], "date": quote["date"], - "maturity_date": quote["maturity_date"], + "publish_date": quote["date"], + "par_value": profile.get("par_value"), + "issue_price": profile.get("issue_price"), + "value_date": profile.get("value_date"), + "listing_date": profile.get("listing_date"), + "maturity_date": profile.get("maturity_date"), + "pay_interest_day": profile.get("pay_interest_day"), + "coupon_rate": profile.get("coupon_rate"), + "coupon_rate_explain": profile.get("coupon_rate_explain"), + "bond_expire_years": profile.get("bond_expire_years"), + "rating": profile.get("rating"), + "data_status": data_status, }, } diff --git a/src/FundLab.Domain/BondRules.fs b/src/FundLab.Domain/BondRules.fs new file mode 100644 index 0000000..21f1245 --- /dev/null +++ b/src/FundLab.Domain/BondRules.fs @@ -0,0 +1,141 @@ +namespace FundLab.Domain + +open System + +/// Bond pricing/ledger terms. Every value is supplied by instrument metadata +/// (the AKShare profile probe); nothing about a specific bond is hardcoded here. +/// +/// Prices for Chinese exchange bonds are quoted per 100 yuan of face value, so +/// the cash amount of `quantity` 张 at a price `p` is `quantity * p * ParValue / 100`. +type BondTerms = + { + /// Face value of one 张, e.g. 100. + ParValue: decimal + /// Applicable annual coupon rate as a fraction, e.g. 0.02 for 2%. + CouponRate: decimal + /// Coupons per year (1 = annual, 2 = semiannual). + CouponFrequency: int + /// 起息日 (interest accrual start). + ValueDate: DateOnly + /// 到期日. + MaturityDate: DateOnly + /// Minimum tradable unit in 张 (exchange convertible bonds: 10). + MinUnit: decimal + /// Settlement lag in trading days (exchange bonds: 0 = T+0). + SettlementDays: int + /// Commission rate applied to the dirty cash amount. + FeeRate: decimal + } + +module BondTerms = + /// Conservative defaults used only when the probe cannot supply a value; + /// callers should override with real instrument metadata when available. + let create + (parValue: decimal) + (couponRate: decimal) + (couponFrequency: int) + (valueDate: DateOnly) + (maturityDate: DateOnly) + (minUnit: decimal) + (settlementDays: int) + (feeRate: decimal) + : BondTerms = + { + ParValue = parValue + CouponRate = couponRate + CouponFrequency = couponFrequency + ValueDate = valueDate + MaturityDate = maturityDate + MinUnit = minUnit + SettlementDays = settlementDays + FeeRate = feeRate + } + +/// Pure bond rules: coupon schedule, accrued interest (actual/actual), dirty +/// price, cash settlement and minimum-unit validation. +module BondRules = + let private dayCount (a: DateOnly) (b: DateOnly) = b.DayNumber - a.DayNumber + + /// Coupon/period boundary dates from 起息日 through 到期日 inclusive. + let couponSchedule (terms: BondTerms) : DateOnly list = + if terms.CouponFrequency <= 0 || terms.MaturityDate <= terms.ValueDate then + [] + else + let monthsPer = 12 / terms.CouponFrequency + + let rec loop (date: DateOnly) (acc: DateOnly list) = + if date >= terms.MaturityDate then + List.rev (terms.MaturityDate :: acc) + else + loop (date.AddMonths monthsPer) (date :: acc) + + loop terms.ValueDate [] + + /// Accrued interest as of `asOf`, actual/actual within the current coupon + /// period. Returns 0 when the bond has no coupon, has matured, or accrues + /// no interest yet. + let accruedInterest (terms: BondTerms) (asOf: DateOnly) : decimal = + let schedule = couponSchedule terms + + if schedule.IsEmpty || terms.ParValue <= 0m || terms.CouponRate <= 0m then + 0m + else + let couponPerPeriod = terms.ParValue * terms.CouponRate / decimal terms.CouponFrequency + let previous = schedule |> List.filter (fun date -> date <= asOf) |> List.tryLast + let next = schedule |> List.tryFind (fun date -> date > asOf) + + match previous, next with + | Some startDate, Some nextDate -> + let periodDays = dayCount startDate nextDate + + if periodDays <= 0 then + 0m + else + let accruedDays = dayCount startDate asOf + + Decimal.Round( + couponPerPeriod * decimal accruedDays / decimal periodDays, + 6, + MidpointRounding.AwayFromZero + ) + | _ -> 0m + + /// Dirty (全价) price = clean (净价) price + accrued interest. + let dirtyPrice (cleanPrice: decimal) (accrued: decimal) : decimal = cleanPrice + accrued + + /// Cash cost of buying `quantity` 张 at the given dirty price, including the + /// commission. `FeeRate` is taken from the terms, not hardcoded. + let tradeCost (terms: BondTerms) (quantity: decimal) (dirty: decimal) : decimal = + let gross = quantity * dirty * terms.ParValue / 100m + let rounded = Decimal.Round(gross, 2, MidpointRounding.AwayFromZero) + let fee = Decimal.Round(rounded * terms.FeeRate, 2, MidpointRounding.AwayFromZero) + rounded + fee + + /// Settlement date for a trade, skipping weekends. A real exchange trading + /// calendar would extend this; weekends are the minimum correctness bar and + /// `SettlementDays = 0` keeps T+0 exchange bonds unchanged. + let settlementDate (terms: BondTerms) (tradeDate: DateOnly) : DateOnly = + let rec advance (date: DateOnly) (remaining: int) = + if remaining <= 0 then + date + else + let next = date.AddDays 1 + + let adjusted = + match next.DayOfWeek with + | DayOfWeek.Saturday -> next.AddDays 2 + | DayOfWeek.Sunday -> next.AddDays 1 + | _ -> next + + advance adjusted (remaining - 1) + + advance tradeDate terms.SettlementDays + + /// Quantity must be positive and a whole multiple of the minimum unit. + let validateQuantity (terms: BondTerms) (quantity: decimal) : Result<decimal, string> = + if quantity <= 0m then + Error "quantity must be positive" + elif terms.MinUnit > 0m && (quantity % terms.MinUnit) <> 0m then + Error(sprintf "quantity must be a multiple of %O 张" terms.MinUnit) + else + Ok quantity diff --git a/src/FundLab.Domain/FundLab.Domain.fsproj b/src/FundLab.Domain/FundLab.Domain.fsproj index 1ecbd5d..9417a4c 100644 --- a/src/FundLab.Domain/FundLab.Domain.fsproj +++ b/src/FundLab.Domain/FundLab.Domain.fsproj @@ -14,6 +14,7 @@ <Compile Include="InvestmentPlan.fs" /> <Compile Include="Rebalance.fs" /> <Compile Include="Dividend.fs" /> + <Compile Include="BondRules.fs" /> <Compile Include="Performance.fs" /> </ItemGroup> </Project> diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index c150596..5e891ae 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -401,6 +401,14 @@ type RawBondQuote = accruedInterest: obj date: obj maturityDate: obj + parValue: obj + valueDate: obj + couponRate: obj + rating: obj + dataStatus: obj + accruedInterestComputed: obj + dirtyPrice: obj + valuationDate: obj } type RawStockQuote = @@ -879,6 +887,14 @@ type BondQuoteView = accruedInterest: string option quoteDate: string option maturityDate: string option + parValue: string option + valueDate: string option + couponRate: string option + rating: string option + dataStatus: string option + accruedInterestComputed: string option + dirtyPrice: string option + valuationDate: string option } type StockQuoteView = @@ -1343,6 +1359,14 @@ module Api = accruedInterest = decodeOptionalText raw.accruedInterest quoteDate = decodeOptionalText raw.date maturityDate = decodeOptionalText raw.maturityDate + parValue = decodeOptionalText raw.parValue + valueDate = decodeOptionalText raw.valueDate + couponRate = decodeOptionalText raw.couponRate + rating = decodeOptionalText raw.rating + dataStatus = decodeOptionalText raw.dataStatus + accruedInterestComputed = decodeOptionalText raw.accruedInterestComputed + dirtyPrice = decodeOptionalText raw.dirtyPrice + valuationDate = decodeOptionalText raw.valuationDate } let decodeStockQuote (raw: RawStockQuote) : StockQuoteView = @@ -5685,11 +5709,16 @@ let private bondsPanel model dispatch = prop.className "metric-strip" prop.children [ bondQuoteMetric "名称" quote.name - bondQuoteMetric "价格" quote.price bondQuoteMetric "净价" quote.cleanPrice - bondQuoteMetric "应计利息" quote.accruedInterest - bondQuoteMetric "行情日期" quote.quoteDate + bondQuoteMetric "应计利息" quote.accruedInterestComputed + bondQuoteMetric "全价" quote.dirtyPrice + bondQuoteMetric "票息率" quote.couponRate + bondQuoteMetric "面值" quote.parValue + bondQuoteMetric "起息日" quote.valueDate bondQuoteMetric "到期日" quote.maturityDate + bondQuoteMetric "评级" quote.rating + bondQuoteMetric "估值日期" quote.valuationDate + bondQuoteMetric "数据质量" quote.dataStatus ] ] ] diff --git a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs index 1e19f77..e8194fa 100644 --- a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs +++ b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs @@ -225,6 +225,17 @@ type BondQuoteProbeTests(fixture: PostgresFixture) = AccruedInterest = None Date = None MaturityDate = Some(DateOnly(2026, 6, 3)) + ParValue = None + IssuePrice = None + ValueDate = None + ListingDate = None + PublishDate = None + PayInterestDay = None + CouponRate = None + CouponRateExplain = None + BondExpireYears = None + Rating = None + DataStatus = None }) let status, body = diff --git a/tests/FundLab.Api.Tests/BondTradeTests.fs b/tests/FundLab.Api.Tests/BondTradeTests.fs index 76827f5..b176aa8 100644 --- a/tests/FundLab.Api.Tests/BondTradeTests.fs +++ b/tests/FundLab.Api.Tests/BondTradeTests.fs @@ -24,6 +24,17 @@ module BondTradeSupport = AccruedInterest = None Date = None MaturityDate = Some(DateOnly(2026, 10, 15)) + ParValue = Some 100m + IssuePrice = Some 100m + ValueDate = Some(DateOnly(2020, 10, 15)) + ListingDate = Some(DateOnly(2020, 11, 3)) + PublishDate = None + PayInterestDay = Some "10-15" + CouponRate = Some 0.02m + CouponRateExplain = None + BondExpireYears = Some "6" + Rating = Some "AAA" + DataStatus = Some "complete" } let appWithQuote (probe: IBondQuoteProbe) = @@ -78,25 +89,143 @@ type BondTradeTests(fixture: PostgresFixture) = [ "Authorization", "Bearer test-token" ] "" + let recordCashflow (probe: IBondQuoteProbe) fundId body idempotencyKey = + PersistenceTestHelpers.invoke + (app probe) + "POST" + (sprintf "/api/funds/%O/bond-cashflows" fundId) + [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ] + body + + let readCashflows (probe: IBondQuoteProbe) fundId = + PersistenceTestHelpers.invoke + (app probe) + "GET" + (sprintf "/api/funds/%O/bond-cashflows" fundId) + [ "Authorization", "Bearer test-token" ] + "" + [<Fact>] - member _.``buying a bond with a live quote records a trade and a position``() = + member _.``buying a bond records clean price, accrued interest, dirty price and settlement``() = let fundId = createFund () let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 let status, body = - buy probe fundId """{"instrumentCode":"110075","quantity":"10"}""" (fixture.Key "bond-trade-buy-ok") + buy + probe + fundId + """{"instrumentCode":"110075","quantity":"10","tradeDate":"2026-09-21"}""" + (fixture.Key "bond-trade-buy-ok") Assert.Equal(201, status) Assert.Contains("\"instrumentCode\":\"110075\"", body) Assert.Contains("南航转债", body) - Assert.Contains("106.368", body) - Assert.Contains("1063.68", body) + Assert.Contains("\"cleanPrice\":\"106.36800000\"", body) + Assert.Contains("\"accruedInterest\":\"1.86849300\"", body) + Assert.Contains("\"price\":\"108.23649300\"", body) + Assert.Contains("\"parValue\":\"100.00000000\"", body) + Assert.Contains("\"couponRate\":\"0.02000000\"", body) + Assert.Contains("\"valueDate\":\"2020-10-15\"", body) + Assert.Contains("\"maturityDate\":\"2026-10-15\"", body) + Assert.Contains("\"settlementDate\":\"2026-09-21\"", body) + Assert.Contains("\"tradeDate\":\"2026-09-21\"", body) + Assert.Contains("1082.36", body) let status, positionsBody = readPositions probe fundId Assert.Equal(200, status) Assert.Contains("\"instrumentCode\":\"110075\"", positionsBody) Assert.Contains("\"quantity\":\"10.00000000\"", positionsBody) - Assert.Contains("1063.68", positionsBody) + Assert.Contains("1082.36", positionsBody) + + [<Fact>] + member _.``buying a quantity below the minimum unit is rejected``() = + let fundId = createFund () + let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 + + let status, body = + buy + probe + fundId + """{"instrumentCode":"110075","quantity":"5","tradeDate":"2026-09-21"}""" + (fixture.Key "bond-trade-min-unit") + + Assert.Equal(400, status) + Assert.Contains("INVALID_BOND_TRADE_REQUEST", body) + Assert.Contains("multiple", body) + + [<Fact>] + member _.``a coupon event credits cash and is recorded idempotently``() = + let fundId = createFund () + let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 + + let buyStatus, _ = + buy + probe + fundId + """{"instrumentCode":"110075","quantity":"10","tradeDate":"2026-09-21"}""" + (fixture.Key "bond-coupon-buy") + + Assert.Equal(201, buyStatus) + + let key = fixture.Key "bond-coupon-event" + + let body = + """{"instrumentCode":"110075","eventType":"coupon","eventDate":"2026-10-15","quantity":"10","amount":"20.00"}""" + + let status, response = recordCashflow probe fundId body key + Assert.Equal(201, status) + Assert.Contains("\"eventType\":\"coupon\"", response) + Assert.Contains("\"amount\":\"20.00\"", response) + + let replayStatus, _ = recordCashflow probe fundId body key + Assert.Equal(200, replayStatus) + + let status, events = readCashflows probe fundId + Assert.Equal(200, status) + Assert.Contains("\"eventType\":\"coupon\"", events) + Assert.DoesNotContain("\"eventType\":\"maturity\"", events) + + [<Fact>] + member _.``a maturity event settles the position and records the cashflow``() = + let fundId = createFund () + let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 + + let buyStatus, _ = + buy + probe + fundId + """{"instrumentCode":"110075","quantity":"10","tradeDate":"2026-09-21"}""" + (fixture.Key "bond-maturity-buy") + + Assert.Equal(201, buyStatus) + + let status, response = + recordCashflow + probe + fundId + """{"instrumentCode":"110075","eventType":"maturity","eventDate":"2026-10-15","quantity":"10","amount":"1000.00"}""" + (fixture.Key "bond-maturity-event") + + Assert.Equal(201, status) + Assert.Contains("\"eventType\":\"maturity\"", response) + + let _, positionsBody = readPositions probe fundId + Assert.Contains("\"positions\":[]", positionsBody) + + [<Fact>] + member _.``a cashflow for a bond the fund does not hold answers 404``() = + let fundId = createFund () + let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075 + + let status, body = + recordCashflow + probe + fundId + """{"instrumentCode":"110075","eventType":"coupon","eventDate":"2026-10-15","quantity":"10","amount":"20.00"}""" + (fixture.Key "bond-coupon-no-position") + + Assert.Equal(404, status) + Assert.Contains("BOND_POSITION_NOT_FOUND", body) [<Fact>] member _.``buying an unknown code with an unavailable quote does not create a position``() = diff --git a/tests/FundLab.Api.Tests/FundValuationTests.fs b/tests/FundLab.Api.Tests/FundValuationTests.fs index 1897fd3..197686d 100644 --- a/tests/FundLab.Api.Tests/FundValuationTests.fs +++ b/tests/FundLab.Api.Tests/FundValuationTests.fs @@ -19,6 +19,17 @@ module FundValuationSupport = AccruedInterest = None Date = None MaturityDate = None + ParValue = None + IssuePrice = None + ValueDate = None + ListingDate = None + PublishDate = None + PayInterestDay = None + CouponRate = None + CouponRateExplain = None + BondExpireYears = None + Rating = None + DataStatus = None } let stockProbeReturning (prices: Map<string, decimal>) = @@ -109,12 +120,52 @@ type FundValuationTests(fixture: PostgresFixture) = repository().CreateBondTrade( key, fundId, - { InstrumentCode = code; BondName = Some "南航转债"; Quantity = quantity; Price = price } + { + InstrumentCode = code + BondName = Some "南航转债" + Quantity = quantity + Price = price + CleanPrice = price + AccruedInterest = 0m + ParValue = 100m + SettlementDate = DateOnly(2026, 9, 21) + CouponRate = None + ValueDate = None + MaturityDate = None + TradeDate = Some(DateOnly(2026, 9, 21)) + } ) with | BondTradeWriteResult.BondTradeCreated _ -> () | other -> failwithf "unexpected bond trade result: %A" other + let buyBondWithTerms fundId code quantity cleanPrice = + let key = fixture.Key(sprintf "valuation-bond-terms-%s" (Guid.NewGuid().ToString("N"))) + + match + repository().CreateBondTrade( + key, + fundId, + { + InstrumentCode = code + BondName = Some "南航转债" + Quantity = quantity + Price = cleanPrice + CleanPrice = cleanPrice + AccruedInterest = 0m + ParValue = 100m + SettlementDate = DateOnly(2026, 9, 21) + CouponRate = Some 0.02m + ValueDate = Some(DateOnly(2020, 10, 15)) + MaturityDate = Some(DateOnly(2026, 10, 15)) + TradeDate = Some(DateOnly(2026, 9, 21)) + } + ) + with + | BondTradeWriteResult.BondTradeCreated _ -> () + | other -> failwithf "unexpected bond trade result: %A" other + + let app stockPrices bondPrices = App.createApplicationWithProbes (repository ()) (FundValuationSupport.probes stockPrices bondPrices) @@ -143,6 +194,30 @@ type FundValuationTests(fixture: PostgresFixture) = Assert.Contains("\"unavailablePositions\":0", body) [<Fact>] + member _.``bond valuation accrues interest point-in-time and values on a dirty basis``() = + let fundId = createFund 10000.00m + buyBondWithTerms fundId "110075" 10m 106.368m + + let valuationAt asOf = + PersistenceTestHelpers.invoke + (app Map.empty (Map.ofList [ "110075", 106.368m ])) + "GET" + (sprintf "/api/funds/%O/valuation?asOfDate=%s" fundId asOf) + [ "Authorization", "Bearer test-token" ] + "" + + let status, early = valuationAt "2026-09-21" + Assert.Equal(200, status) + Assert.Contains("\"accruedInterest\":\"1.86849300\"", early) + Assert.Contains("\"dirtyPrice\":\"108.23649300\"", early) + Assert.Contains("\"valueBasis\":\"dirty\"", early) + Assert.Contains("\"marketValue\":\"1082.36\"", early) + + let _, later = valuationAt "2026-10-01" + Assert.Contains("\"accruedInterest\":\"1.92328800\"", later) + Assert.Contains("\"marketValue\":\"1082.91\"", later) + + [<Fact>] member _.``all-unavailable valuation keeps cash intact and marks positions unavailable without zeroing``() = let fundId = createFund 5000.00m buyStock fundId "600519" 100m 1000.00m diff --git a/tests/FundLab.Domain.Tests/BondRulesTests.fs b/tests/FundLab.Domain.Tests/BondRulesTests.fs new file mode 100644 index 0000000..31069f1 --- /dev/null +++ b/tests/FundLab.Domain.Tests/BondRulesTests.fs @@ -0,0 +1,70 @@ +namespace FundLab.Domain.Tests + +module BondRulesTests = + + open System + open Xunit + open FundLab.Domain + + let private annualCb = + BondTerms.create + 100m + 0.02m + 1 + (DateOnly(2020, 10, 15)) + (DateOnly(2026, 10, 15)) + 10m + 0 + 0m + + [<Fact>] + let ``coupon schedule runs from value date through maturity inclusive`` () = + let schedule = BondRules.couponSchedule annualCb + + Assert.Equal(7, List.length schedule) + Assert.Equal(DateOnly(2020, 10, 15), List.head schedule) + Assert.Equal(DateOnly(2026, 10, 15), List.last schedule) + Assert.Equal(DateOnly(2025, 10, 15), List.item 5 schedule) + + [<Fact>] + let ``accrued interest uses actual over actual days in the current period`` () = + // Period 2025-10-15 .. 2026-10-15 is 365 days; 2026-04-15 is 182 days in. + let accrued = BondRules.accruedInterest annualCb (DateOnly(2026, 4, 15)) + Assert.Equal(0.997260m, accrued) + + [<Fact>] + let ``accrued interest is zero before the value date and after maturity`` () = + Assert.Equal(0m, BondRules.accruedInterest annualCb (DateOnly(2020, 10, 14))) + Assert.Equal(0m, BondRules.accruedInterest annualCb (DateOnly(2027, 1, 1))) + + [<Fact>] + let ``dirty price is clean price plus accrued interest`` () = + Assert.Equal(106.368m, BondRules.dirtyPrice 106.368m 0m) + Assert.Equal(107.368m, BondRules.dirtyPrice 106.368m 1.0m) + + [<Fact>] + let ``trade cost is quantity times price for a 100 par bond`` () = + // 10 张 * 106.368 = 1063.68, matching the existing bond-trade contract. + Assert.Equal(1063.68m, BondRules.tradeCost annualCb 10m 106.368m) + + [<Fact>] + let ``trade cost applies the instrument fee rate`` () = + let terms = { annualCb with FeeRate = 0.0002m } + // 1063.68 gross + 0.212736 -> 0.21 fee. + Assert.Equal(1063.89m, BondRules.tradeCost terms 10m 106.368m) + + [<Fact>] + let ``settlement date is T+0 for exchange bonds and skips weekends otherwise`` () = + Assert.Equal(DateOnly(2026, 9, 18), BondRules.settlementDate annualCb (DateOnly(2026, 9, 18))) + let t1 = { annualCb with SettlementDays = 1 } + Assert.Equal(DateOnly(2026, 9, 21), BondRules.settlementDate t1 (DateOnly(2026, 9, 18))) + + [<Fact>] + let ``quantity must be positive and a whole multiple of the minimum unit`` () = + match BondRules.validateQuantity annualCb 10m with + | Ok value -> Assert.Equal(10m, value) + | Error message -> failwith message + + Assert.True(BondRules.validateQuantity annualCb 15m |> Result.isError) + Assert.True(BondRules.validateQuantity annualCb 0m |> Result.isError) + Assert.True(BondRules.validateQuantity annualCb -10m |> Result.isError) diff --git a/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj b/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj index a9c6fc0..468ee16 100644 --- a/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj +++ b/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj @@ -21,6 +21,7 @@ <ItemGroup> <Compile Include="DomainTests.fs" /> <Compile Include="InvestmentPlanTests.fs" /> + <Compile Include="BondRulesTests.fs" /> <Compile Include="Program.fs" /> </ItemGroup> </Project> |
