diff options
| -rw-r--r-- | qa/driver/browser-test.js | 53 | ||||
| -rw-r--r-- | src/FundLab.Api/App.fs | 124 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 187 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 4 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 18 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/FundValuationTests.fs | 178 | ||||
| -rw-r--r-- | tests/FundLab.Web.Tests/BoundaryTests.fs | 69 |
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 ()) |
