summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--qa/driver/browser-test.js53
-rw-r--r--src/FundLab.Api/App.fs124
-rw-r--r--src/FundLab.Web/App.fs187
-rw-r--r--src/FundLab.Web/src/api.js4
-rw-r--r--src/FundLab.Web/src/styles.css18
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/FundValuationTests.fs178
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs69
8 files changed, 633 insertions, 1 deletions
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js
index 14cffc7..8ad9ab5 100644
--- a/qa/driver/browser-test.js
+++ b/qa/driver/browser-test.js
@@ -41,6 +41,7 @@ let lastStockTradeResponse = null;
let lastStockPositionsResponse = null;
let lastBondTradeResponse = null;
let lastBondPositionsResponse = null;
+let lastValuationResponse = null;
function check(name, ok, detail) {
results.push({ name, ok, detail: detail || "" });
@@ -225,7 +226,12 @@ async function summaryLabelExists(page, label, name) {
lastBondPositionsResponse = await r.json();
} catch {}
}
- if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/api\/market\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url()) && !/\/stock-trades/.test(r.url()) && !/\/stock-positions/.test(r.url()) && !/\/bond-trades/.test(r.url()) && !/\/bond-positions/.test(r.url())) {
+ if (/\/valuation$/i.test(new URL(r.url()).pathname) && r.request().method() === "GET" && r.status() < 400) {
+ try {
+ lastValuationResponse = await r.json();
+ } catch {}
+ }
+ if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/api\/market\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url()) && !/\/stock-trades/.test(r.url()) && !/\/stock-positions/.test(r.url()) && !/\/bond-trades/.test(r.url()) && !/\/bond-positions/.test(r.url()) && !/\/valuation/.test(r.url())) {
consoleErrors.push("resource " + r.status() + ": " + r.url());
}
});
@@ -281,6 +287,7 @@ async function summaryLabelExists(page, label, name) {
await stockDailyScenario(page);
await stockTradeScenario(page);
await bondTradeScenario(page);
+ await valuationScenario(page);
} finally {
check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | "));
await browser.close();
@@ -1071,6 +1078,50 @@ async function bondTradeScenario(page) {
await page.screenshot({ path: SHOTS + "/25-bond-trade.png" });
}
+async function valuationScenario(page) {
+ // B6: 股票+债券混合持仓组合估值(现金守恒 + 实时市值合计)。
+ await page.waitForSelector(".fund-valuation", { timeout: 10000 });
+
+ lastValuationResponse = null;
+ await page.click(".valuation-refresh-action");
+ await page.waitForFunction(
+ () => {
+ const element = document.querySelector(".valuation-content");
+ return element && element.textContent.includes("组合合计");
+ },
+ { timeout: 10000 }
+ );
+
+ const valuation = lastValuationResponse || {};
+ const positions = Array.isArray(valuation.positions) ? valuation.positions : [];
+ const stock = positions.find((item) => item.instrumentCode === "600519") || {};
+ const bond = positions.find((item) => item.instrumentCode === "110075") || {};
+
+ const cash = parseFloat(valuation.cash || "0");
+ const marketValue = parseFloat(valuation.positionsMarketValue || "0");
+ const portfolio = parseFloat(valuation.portfolioValue || "0");
+
+ const rowText = ((await page.textContent(".fund-valuation")) || "").trim();
+
+ check(
+ "B6 混合持仓估值现金守恒且实时市值合计正确",
+ valuation.portfolioValue &&
+ Math.abs(portfolio - (cash + marketValue)) < 0.005 &&
+ stock.status === "priced" &&
+ stock.priceSource === "live" &&
+ stock.marketValue === "125257.00" &&
+ bond.status === "priced" &&
+ bond.marketValue === "1063.68" &&
+ Math.abs(marketValue - 126320.68) < 0.005 &&
+ valuation.pricedPositions === 2 &&
+ valuation.unavailablePositions === 0 &&
+ rowText.includes("组合合计"),
+ `valuation=${JSON.stringify(valuation).slice(0, 240)}`
+ );
+
+ await page.screenshot({ path: SHOTS + "/26-valuation.png" });
+}
+
async function postInvestmentPlanViaApi(page, fundId, token) {
return page.evaluate(
async ({ fundId, token }) => {
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index 36a8565..138230f 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -188,6 +188,30 @@ type BondPositionsResponse =
positions: BondPositionResponse list
}
+type ValuationPositionResponse =
+ {
+ instrumentCode: string
+ name: string option
+ assetClass: string
+ quantity: string
+ price: string option
+ priceSource: string option
+ marketValue: string option
+ status: string
+ }
+
+type FundValuationResponse =
+ {
+ fundId: Guid
+ currency: string
+ cash: string
+ positionsMarketValue: string
+ portfolioValue: string
+ pricedPositions: int
+ unavailablePositions: int
+ positions: ValuationPositionResponse list
+ }
+
type SipPlanResponse =
{
id: Guid
@@ -2122,6 +2146,105 @@ module App =
with _ ->
errorResponse 500 "PERSISTENCE_ERROR" "bond position persistence failed" next ctx
+ let private valuationPositionResponse
+ (assetClass: string)
+ (code: string)
+ (fallbackName: string option)
+ (quantity: decimal)
+ (livePrice: decimal option)
+ =
+ let resolvedName =
+ match fallbackName with
+ | Some name when not (String.IsNullOrWhiteSpace name) -> Some name
+ | _ -> None
+
+ match livePrice with
+ | Some price ->
+ { instrumentCode = code
+ name = resolvedName
+ assetClass = assetClass
+ quantity = decimalText quantity
+ price = Some(decimalText price)
+ priceSource = Some "live"
+ marketValue = Some(cashText (Decimal.Round(quantity * price, 2, MidpointRounding.AwayFromZero)))
+ status = "priced" }
+ | None ->
+ { instrumentCode = code
+ name = resolvedName
+ assetClass = assetClass
+ quantity = decimalText quantity
+ price = None
+ priceSource = None
+ marketValue = None
+ status = "unavailable" }
+
+ let private getFundValuation (repository: FundRepository) (probes: MarketProbes option) (fundIdText: string) : HttpHandler =
+ fun next ctx ->
+ match Guid.TryParse fundIdText with
+ | false, _ -> errorResponse 400 "INVALID_VALUATION_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 token = ctx.RequestAborted
+
+ let priceOf (probe: unit -> Result<decimal option, MarketDataFailure>) =
+ if probes.IsNone then
+ None
+ else
+ match probe () with
+ | Ok(Some price) -> Some price
+ | _ -> None
+
+ let stockRows =
+ repository.GetStockPositions fundId
+ |> List.map (fun position ->
+ let live =
+ priceOf (fun () ->
+ probes.Value.StockQuotes.GetQuote(position.InstrumentCode, token)
+ |> Result.map (fun quote -> quote.Price))
+
+ valuationPositionResponse "stock" position.InstrumentCode position.StockName position.Quantity live)
+
+ let bondRows =
+ repository.GetBondPositions fundId
+ |> List.map (fun position ->
+ let live =
+ priceOf (fun () ->
+ probes.Value.BondQuotes.GetQuote(position.InstrumentCode, token)
+ |> Result.map (fun quote -> quote.Price))
+
+ valuationPositionResponse "bond" position.InstrumentCode position.BondName position.Quantity live)
+
+ let positions = stockRows @ bondRows
+
+ let positionsMarketValue =
+ positions
+ |> List.sumBy (fun position ->
+ match position.marketValue with
+ | Some text -> Decimal.Parse(text, invariant)
+ | None -> 0m)
+
+ let unavailable =
+ positions |> List.filter (fun position -> position.status = "unavailable") |> List.length
+
+ let response: FundValuationResponse =
+ {
+ fundId = fund.Id
+ currency = fund.Currency
+ cash = cashText fund.AvailableCash
+ positionsMarketValue = cashText positionsMarketValue
+ portfolioValue = cashText (fund.AvailableCash + positionsMarketValue)
+ pricedPositions = positions.Length - unavailable
+ unavailablePositions = unavailable
+ positions = positions
+ }
+
+ json response next ctx
+ with _ ->
+ errorResponse 500 "PERSISTENCE_ERROR" "fund valuation failed" next ctx
+
let private marketProbeRoutes (probes: MarketProbes) =
[
GET >=> route "/market/nav-dates" >=> getMarketNavDates probes.NavDates
@@ -2167,6 +2290,7 @@ 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)
+ GET >=> routef "/funds/%s/valuation" (getFundValuation repository probes)
GET >=> routef "/funds/%s" (getFund repository)
]
@ (marketData |> Option.map marketDataRoutes |> Option.defaultValue [])
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index 8b8f3b9..62bb20e 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -481,6 +481,30 @@ type RawBondPositions =
positions: RawBondPosition array
}
+type RawValuationPosition =
+ {
+ instrumentCode: string
+ name: obj
+ assetClass: string
+ quantity: string
+ price: obj
+ priceSource: obj
+ marketValue: obj
+ status: string
+ }
+
+type RawFundValuation =
+ {
+ fundId: string
+ currency: string
+ cash: string
+ positionsMarketValue: string
+ portfolioValue: string
+ pricedPositions: int
+ unavailablePositions: int
+ positions: RawValuationPosition array
+ }
+
type CreateAttempt =
{
idempotencyKey: string
@@ -869,6 +893,29 @@ type BondPositionView =
lastTradedAt: string
}
+type ValuationPositionView =
+ {
+ instrumentCode: string
+ name: string option
+ assetClass: string
+ quantity: string
+ price: string option
+ priceSource: string option
+ marketValue: string option
+ status: string
+ }
+
+type FundValuationView =
+ {
+ currency: string
+ cash: string
+ positionsMarketValue: string
+ portfolioValue: string
+ pricedPositions: int
+ unavailablePositions: int
+ positions: ValuationPositionView list
+ }
+
type BondTradeAttempt =
{
idempotencyKey: string
@@ -946,6 +993,9 @@ module Api =
[<Import("getBondPositions", "./src/api.js")>]
let getBondPositions (token: string) (fundId: string) : JS.Promise<RawBondPositions> = jsNative
+ [<Import("getFundValuation", "./src/api.js")>]
+ let getFundValuation (token: string) (fundId: string) : JS.Promise<RawFundValuation> = jsNative
+
[<Import("createCapitalDeposit", "./src/api.js")>]
let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative
@@ -1285,6 +1335,29 @@ module Api =
lastTradedAt = raw.lastTradedAt
}
+ let decodeValuationPosition (raw: RawValuationPosition) : ValuationPositionView =
+ {
+ instrumentCode = raw.instrumentCode
+ name = decodeOptionalText raw.name
+ assetClass = raw.assetClass
+ quantity = raw.quantity
+ price = decodeOptionalText raw.price
+ priceSource = decodeOptionalText raw.priceSource
+ marketValue = decodeOptionalText raw.marketValue
+ status = raw.status
+ }
+
+ let decodeFundValuation (raw: RawFundValuation) : FundValuationView =
+ {
+ currency = raw.currency
+ cash = raw.cash
+ positionsMarketValue = raw.positionsMarketValue
+ portfolioValue = raw.portfolioValue
+ pricedPositions = raw.pricedPositions
+ unavailablePositions = raw.unavailablePositions
+ positions = raw.positions |> Array.toList |> List.map decodeValuationPosition
+ }
+
type Model =
{
token: string
@@ -1402,6 +1475,10 @@ type Model =
bondPositionsInFlight: bool
bondPositions: BondPositionView list
bondPositionsMessage: string option
+ valuationReadSeq: int
+ valuationInFlight: bool
+ valuation: FundValuationView option
+ valuationMessage: string option
returnsSelectedDate: string option
planReadSeq: int
planInFlight: bool
@@ -1527,6 +1604,9 @@ type Msg =
| BondPositionsReadRequested
| BondPositionsReadCompleted of requestId: int * fundId: string * positions: RawBondPositions
| BondPositionsReadFailed of requestId: int * fundId: string * message: string
+ | ValuationReadRequested
+ | ValuationReadCompleted of requestId: int * fundId: string * valuation: RawFundValuation
+ | ValuationReadFailed of requestId: int * fundId: string * message: string
| ReturnsDateChanged of string
| InvestmentPlansReadRequested
| InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array
@@ -1689,6 +1769,10 @@ let init () =
bondPositionsInFlight = false
bondPositions = []
bondPositionsMessage = None
+ valuationReadSeq = 0
+ valuationInFlight = false
+ valuation = None
+ valuationMessage = None
returnsSelectedDate = None
planReadSeq = 0
planInFlight = false
@@ -1922,6 +2006,13 @@ let private readBondPositionsCommand token fundId requestId =
(fun positions -> BondPositionsReadCompleted(requestId, fundId, positions))
(fun error -> BondPositionsReadFailed(requestId, fundId, errorText error))
+let private readValuationCommand token fundId requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.getFundValuation token fundId)
+ ()
+ (fun valuation -> ValuationReadCompleted(requestId, fundId, valuation))
+ (fun error -> ValuationReadFailed(requestId, fundId, errorText error))
+
let private readInvestmentPlansCommand token fundId requestId =
Cmd.OfPromise.either
(fun () -> Api.getInvestmentPlans token fundId)
@@ -3476,6 +3567,43 @@ let update message model =
Cmd.none
else
model, Cmd.none
+ | ValuationReadRequested ->
+ match model.createdFund with
+ | Some fund when not (String.IsNullOrWhiteSpace model.token) ->
+ let requestId = model.valuationReadSeq + 1
+
+ {
+ model with
+ valuationReadSeq = requestId
+ valuationInFlight = true
+ valuationMessage = None
+ },
+ readValuationCommand model.token fund.id requestId
+ | _ -> model, Cmd.none
+ | ValuationReadCompleted (requestId, fundId, valuation) ->
+ if requestId = model.valuationReadSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ {
+ model with
+ valuation = Some(Api.decodeFundValuation valuation)
+ valuationInFlight = false
+ valuationMessage = None
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | ValuationReadFailed (requestId, fundId, message) ->
+ if requestId = model.valuationReadSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ {
+ model with
+ valuation = None
+ valuationInFlight = false
+ valuationMessage = Some(sprintf "组合估值不可用 — %s" message)
+ },
+ Cmd.none
+ else
+ model, Cmd.none
| ReturnsDateChanged value ->
{ model with returnsSelectedDate = (if String.IsNullOrWhiteSpace value then None else Some value) }, Cmd.none
| InvestmentPlansReadRequested ->
@@ -5462,6 +5590,65 @@ let private stocksPanel model dispatch =
prop.hidden (model.stockPositionsMessage.IsNone)
prop.text (model.stockPositionsMessage |> Option.defaultValue "")
]
+ Html.div [
+ prop.className "fund-valuation"
+ prop.children [
+ Html.p [ prop.className "returns-subheading"; prop.text "组合估值" ]
+ Html.div [
+ prop.className "search-row"
+ prop.children [
+ Html.button [
+ prop.className "secondary-action valuation-refresh-action"
+ prop.disabled model.valuationInFlight
+ prop.onClick (fun _ -> dispatch ValuationReadRequested)
+ prop.text ((if model.valuationInFlight then "估值中..." else "刷新估值"): string)
+ ]
+ ]
+ ]
+ match model.valuation with
+ | Some valuation ->
+ Html.div [
+ prop.className "valuation-content"
+ prop.children (
+ [ Html.div [
+ prop.className "metric-strip valuation-metrics"
+ prop.children [
+ bondQuoteMetric "现金" (Some valuation.cash)
+ bondQuoteMetric "持仓市值" (Some valuation.positionsMarketValue)
+ bondQuoteMetric "组合合计" (Some valuation.portfolioValue)
+ bondQuoteMetric "已定价/缺失" (Some(sprintf "%d / %d" valuation.pricedPositions valuation.unavailablePositions))
+ ]
+ ] ]
+ @ (valuation.positions
+ |> List.map (fun position ->
+ Html.div [
+ prop.className (
+ if position.status = "unavailable" then
+ "valuation-row valuation-position-row valuation-unavailable"
+ else
+ "valuation-row valuation-position-row"
+ )
+ prop.children [
+ Html.span [ prop.className "fund-detail-label"; prop.text position.instrumentCode ]
+ Html.span [ prop.className "fund-detail-value"; prop.text (position.name |> Option.defaultValue "—") ]
+ Html.span [ prop.className "fund-detail-value"; prop.text position.quantity ]
+ Html.span [
+ prop.className "fund-detail-value"
+ prop.text (position.marketValue |> Option.defaultValue "缺失")
+ ]
+ Html.span [ prop.className "fund-detail-value"; prop.text (position.priceSource |> Option.defaultValue "—") ]
+ ]
+ ]))
+ )
+ ]
+ | None -> Html.p [ prop.className "hint"; prop.text "刷新后显示现金/持仓市值/组合合计;缺失行情标的不补零,标记为缺失。" ]
+ Html.p [
+ prop.className "valuation-unavailable"
+ prop.hidden (model.valuationMessage.IsNone)
+ prop.text (model.valuationMessage |> Option.defaultValue "")
+ ]
+ ]
+ ]
]
]
diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js
index 026464a..031ff74 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -179,6 +179,10 @@ export function getBondPositions(token, fundId) {
return requestJson(`/api/funds/${encodeURIComponent(fundId)}/bond-positions`, token);
}
+export function getFundValuation(token, fundId) {
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}/valuation`, token);
+}
+
export function createSipPlan(token, fundId, payload) {
const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"amount":${JSON.stringify(payload.amount)},"frequency":${JSON.stringify(payload.frequency)}}`;
return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token, {
diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css
index 08829c2..dbd53ed 100644
--- a/src/FundLab.Web/src/styles.css
+++ b/src/FundLab.Web/src/styles.css
@@ -624,6 +624,24 @@ h2 {
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
+.valuation-unavailable {
+ color: #b45309;
+ font-size: 12px;
+}
+
+.valuation-row {
+ display: grid;
+ grid-template-columns: 1fr 1.4fr 1fr 1fr 0.8fr;
+ gap: 8px;
+ align-items: center;
+ padding: 4px 0;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.2);
+}
+
+.valuation-row.valuation-unavailable .fund-detail-value {
+ color: #b45309;
+}
+
.stock-positions-header .fund-detail-label {
font-weight: 600;
}
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index dffe48f..38a9e7e 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -36,6 +36,7 @@
<Compile Include="StockDailyProbeTests.fs" />
<Compile Include="StockTradeTests.fs" />
<Compile Include="BondTradeTests.fs" />
+ <Compile Include="FundValuationTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
diff --git a/tests/FundLab.Api.Tests/FundValuationTests.fs b/tests/FundLab.Api.Tests/FundValuationTests.fs
new file mode 100644
index 0000000..0cfee18
--- /dev/null
+++ b/tests/FundLab.Api.Tests/FundValuationTests.fs
@@ -0,0 +1,178 @@
+namespace FundLab.Api.Tests
+
+open System
+open System.Threading
+open Xunit
+open FundLab.Api
+
+module FundValuationSupport =
+ let stockQuote (code: string) (price: decimal) : StockQuote =
+ { Code = code; Name = Some "贵州茅台"; Price = Some price; Currency = "CNY" }
+
+ let bondQuote (code: string) (price: decimal) : BondQuote =
+ {
+ Code = code
+ SourceRevision = "akshare-test/hs-cov-spot"
+ Name = Some "南航转债"
+ Price = Some price
+ CleanPrice = Some price
+ AccruedInterest = None
+ Date = None
+ MaturityDate = None
+ }
+
+ let stockProbeReturning (prices: Map<string, decimal>) =
+ { new IStockQuoteProbe with
+ member _.GetQuote(code, _) =
+ match prices.TryFind code with
+ | Some price -> Ok(stockQuote code price)
+ | None -> Error(MarketDataCollectorUnavailable(sprintf "no valid quote for %s" code)) }
+
+ let bondProbeReturning (prices: Map<string, decimal>) =
+ { new IBondQuoteProbe with
+ member _.GetQuote(code, _) =
+ match prices.TryFind code with
+ | Some price -> Ok(bondQuote code price)
+ | None -> Error(MarketDataCollectorUnavailable(sprintf "no valid quote for %s" code)) }
+
+ let probes (stockPrices: Map<string, decimal>) (bondPrices: Map<string, decimal>) =
+ { NavDates = NavDateProbeSupport.stubNavDateProbe
+ NavSeries = NavDateProbeSupport.stubNavSeriesProbe
+ BondQuotes = bondProbeReturning bondPrices
+ StockQuotes = stockProbeReturning stockPrices
+ StockDaily = NavDateProbeSupport.stubStockDailyProbe }
+
+[<Collection("postgres")>]
+type FundValuationTests(fixture: PostgresFixture) =
+ let sharedRepository =
+ lazy
+ let value = FundRepository(fixture.ConnectionString)
+ value.EnsureSchema()
+ value
+
+ let repository () = sharedRepository.Value
+
+ let createFund (initialCash: decimal) =
+ let command =
+ {
+ Name = "组合估值测试 FOF"
+ InitialCash = initialCash
+ InitialUnitNav = 1.00000000m
+ IsSynthetic = true
+ }
+
+ let key = fixture.Key(sprintf "valuation-fund-%s" (Guid.NewGuid().ToString("N")))
+
+ match repository().CreateFund(key, command) with
+ | FundWriteResult.Created fund -> fund.Id
+ | other -> failwithf "unexpected fund creation result: %A" other
+
+ let buyStock fundId code quantity price =
+ let key = fixture.Key(sprintf "valuation-stock-%s" (Guid.NewGuid().ToString("N")))
+
+ match
+ repository().CreateStockTrade(
+ key,
+ fundId,
+ { InstrumentCode = code; StockName = Some "贵州茅台"; Quantity = quantity; Price = price }
+ )
+ with
+ | StockTradeWriteResult.StockTradeCreated _ -> ()
+ | other -> failwithf "unexpected stock trade result: %A" other
+
+ let buyBond fundId code quantity price =
+ let key = fixture.Key(sprintf "valuation-bond-%s" (Guid.NewGuid().ToString("N")))
+
+ match
+ repository().CreateBondTrade(
+ key,
+ fundId,
+ { InstrumentCode = code; BondName = Some "南航转债"; Quantity = quantity; Price = price }
+ )
+ with
+ | BondTradeWriteResult.BondTradeCreated _ -> ()
+ | other -> failwithf "unexpected bond trade result: %A" other
+
+ let app stockPrices bondPrices =
+ App.createApplicationWithProbes (repository ()) (FundValuationSupport.probes stockPrices bondPrices)
+
+ let getValuation stockPrices bondPrices fundId =
+ PersistenceTestHelpers.invoke
+ (app stockPrices bondPrices)
+ "GET"
+ (sprintf "/api/funds/%O/valuation" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ [<Fact>]
+ member _.``mixed valuation prices live positions and flags the unavailable one``() =
+ let fundId = createFund 10000.00m
+ buyStock fundId "600519" 100m 1000.00m
+ buyBond fundId "110075" 10m 100.00m
+
+ let status, body =
+ getValuation (Map.ofList [ "600519", 1252.57m ]) (Map.ofList [ "110075", 106.368m ]) fundId
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"cash\":\"10000.00\"", body)
+ Assert.Contains("\"positionsMarketValue\":\"126320.68\"", body)
+ Assert.Contains("\"portfolioValue\":\"136320.68\"", body)
+ Assert.Contains("\"pricedPositions\":2", body)
+ Assert.Contains("\"unavailablePositions\":0", body)
+
+ [<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
+ buyBond fundId "110075" 10m 100.00m
+
+ let status, body = getValuation Map.empty Map.empty fundId
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"cash\":\"5000.00\"", body)
+ Assert.Contains("\"positionsMarketValue\":\"0.00\"", body)
+ Assert.Contains("\"portfolioValue\":\"5000.00\"", body)
+ Assert.Contains("\"unavailablePositions\":2", body)
+ Assert.Contains("\"status\":\"unavailable\"", body)
+ Assert.Contains("\"quantity\":\"100.00000000\"", body)
+ Assert.Contains("\"quantity\":\"10.00000000\"", body)
+
+ [<Fact>]
+ member _.``partial failure values available positions and does not inflate on missing price``() =
+ let fundId = createFund 2000.00m
+ buyStock fundId "600519" 100m 1000.00m
+ buyBond fundId "110075" 10m 100.00m
+
+ let status, body = getValuation (Map.ofList [ "600519", 10.00m ]) Map.empty fundId
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"positionsMarketValue\":\"1000.00\"", body)
+ Assert.Contains("\"portfolioValue\":\"3000.00\"", body)
+ Assert.Contains("\"pricedPositions\":1", body)
+ Assert.Contains("\"unavailablePositions\":1", body)
+
+ [<Fact>]
+ member _.``without probes every position is marked unavailable and excluded from market value``() =
+ let fundId = createFund 0.00m
+ buyStock fundId "600519" 100m 1000.00m
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (App.createApplication (repository ()))
+ "GET"
+ (sprintf "/api/funds/%O/valuation" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"positionsMarketValue\":\"0.00\"", body)
+ Assert.Contains("\"portfolioValue\":\"0.00\"", body)
+ Assert.Contains("\"status\":\"unavailable\"", body)
+ Assert.Contains("\"quantity\":\"100.00000000\"", body)
+ Assert.Contains("\"unavailablePositions\":1", body)
+
+ [<Fact>]
+ member _.``valuation of a missing fund answers 404``() =
+ let status, body = getValuation Map.empty Map.empty (Guid.NewGuid())
+ Assert.Equal(404, status)
+ Assert.Contains("FUND_NOT_FOUND", body)
diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs
index 3a9ec61..d8bb472 100644
--- a/tests/FundLab.Web.Tests/BoundaryTests.fs
+++ b/tests/FundLab.Web.Tests/BoundaryTests.fs
@@ -710,6 +710,75 @@ module ConfirmationBoundaryTests =
Assert.False(updated.bondPositionsInFlight)
[<Fact>]
+ let ``valuation completion stores totals and per-position marks`` () =
+ let fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"
+
+ let model =
+ { init () with
+ createdFund = Some(FundBoundaryTests.sampleFund fundId)
+ valuationReadSeq = 1
+ valuationInFlight = true }
+
+ let raw : RawFundValuation =
+ {
+ fundId = fundId
+ currency = "CNY"
+ cash = "10000.00"
+ positionsMarketValue = "126320.68"
+ portfolioValue = "136320.68"
+ pricedPositions = 1
+ unavailablePositions = 1
+ positions =
+ [|
+ {
+ instrumentCode = "600519"
+ name = box "贵州茅台"
+ assetClass = "stock"
+ quantity = "100.00000000"
+ price = box "1252.57"
+ priceSource = box "live"
+ marketValue = box "125257.00"
+ status = "priced"
+ }
+ {
+ instrumentCode = "110075"
+ name = box "南航转债"
+ assetClass = "bond"
+ quantity = "10.00000000"
+ price = null
+ priceSource = null
+ marketValue = null
+ status = "unavailable"
+ }
+ |]
+ }
+
+ let updated, _ = update (ValuationReadCompleted(1, fundId, raw)) model
+
+ let valuation = updated.valuation.Value
+ Assert.Equal("136320.68", valuation.portfolioValue)
+ Assert.Equal(1, valuation.unavailablePositions)
+ Assert.Equal(2, valuation.positions.Length)
+ Assert.Equal("unavailable", valuation.positions[1].status)
+ Assert.False(updated.valuationInFlight)
+
+ [<Fact>]
+ let ``valuation failure clears totals and surfaces a message`` () =
+ let fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"
+
+ let model =
+ { init () with
+ createdFund = Some(FundBoundaryTests.sampleFund fundId)
+ valuationReadSeq = 1
+ valuationInFlight = true }
+
+ let updated, _ = update (ValuationReadFailed(1, fundId, "collector exited with code 2")) model
+
+ Assert.Equal(None, updated.valuation)
+ Assert.Contains("组合估值不可用", updated.valuationMessage |> Option.defaultValue "")
+ Assert.False(updated.valuationInFlight)
+
+ [<Fact>]
let ``order decode keeps decimal strings and decodes option fields`` () =
let confirmed = Api.decodeOrder (sampleConfirmedRaw ())
let pending = Api.decodeOrder (samplePendingRaw ())