summaryrefslogtreecommitdiff
path: root/src/FundLab.Api
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 08:07:00 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 08:07:00 +0800
commitb05e728ae23d088ca6c9ecccf6ed00d2ab6f3839 (patch)
tree4846648aa87263742c1181c189287b53c3bf84de /src/FundLab.Api
parent27a85d9070abd000245b2a2e6460ddf9fd5eb97e (diff)
downloadfund-lab-b05e728ae23d088ca6c9ecccf6ed00d2ab6f3839.tar.gz
Add bond full milestone: profile probe, coupon/accrual rules, ledger and point-in-time valuation (3d-29)
Diffstat (limited to 'src/FundLab.Api')
-rw-r--r--src/FundLab.Api/App.fs371
-rw-r--r--src/FundLab.Api/BondQuoteProbe.fs45
-rw-r--r--src/FundLab.Api/MarketData.fs67
-rw-r--r--src/FundLab.Api/Persistence.fs454
-rw-r--r--src/FundLab.Api/akshare_collector.py58
5 files changed, 932 insertions, 63 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,
},
}