summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 01:54:55 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 01:54:55 +0800
commit0d1af831c10683bb1e5a5853e5924c5ddfb1310d (patch)
tree638c7a94fb213926aebaa443761313ddf7dcd499
parentaa4dba4f70749b459c3922ce68610c4d78e644e6 (diff)
downloadfund-lab-0d1af831c10683bb1e5a5853e5924c5ddfb1310d.tar.gz
Add bonds probe panel with live bond quote flow (3d-16)
-rw-r--r--qa/driver/browser-test.js70
-rwxr-xr-xqa/stub/fund-lab-python11
-rw-r--r--src/FundLab.Web/App.fs159
-rw-r--r--src/FundLab.Web/src/api.js4
-rw-r--r--src/FundLab.Web/src/styles.css11
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs29
6 files changed, 284 insertions, 0 deletions
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js
index a52fa03..dddc562 100644
--- a/qa/driver/browser-test.js
+++ b/qa/driver/browser-test.js
@@ -34,6 +34,7 @@ let lastRebalanceExecuteResponse = null;
let lastRebalanceExecutionsResponse = null;
let lastNavDatesResponse = null;
let lastNavSeriesResponse = null;
+let lastBondQuoteResponse = null;
function check(name, ok, detail) {
results.push({ name, ok, detail: detail || "" });
@@ -183,6 +184,11 @@ async function summaryLabelExists(page, label, name) {
lastNavSeriesResponse = await r.json();
} catch {}
}
+ if (r.url().includes("/market/bond-quote") && r.request().method() === "GET" && r.status() < 400) {
+ try {
+ lastBondQuoteResponse = 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())) {
consoleErrors.push("resource " + r.status() + ": " + r.url());
}
@@ -234,6 +240,7 @@ async function summaryLabelExists(page, label, name) {
await navSeriesScenario(page);
await plansScenario(page);
await rebalanceScenario(page);
+ await bondsScenario(page);
} finally {
check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | "));
await browser.close();
@@ -624,6 +631,69 @@ async function navSeriesScenario(page) {
await page.screenshot({ path: SHOTS + "/20-nav-series.png" });
}
+async function bondsScenario(page) {
+ // B 系列: 债券行情探针面板走真实 API(QA 环境由桩采集器提供,非前端 mock)。
+ await page.waitForSelector(".bonds-panel", { timeout: 10000 });
+
+ lastBondQuoteResponse = null;
+ await page.fill(".bond-code-input", "110075");
+ await page.click(".bond-quote-action");
+ await page.waitForSelector(".bond-quote-result", { timeout: 10000 });
+
+ const resultText = ((await page.textContent(".bond-quote-result")) || "").trim();
+ const quote = lastBondQuoteResponse || {};
+
+ check(
+ "B1 真实可用券返回名称与价格字符串",
+ quote.code === "110075" &&
+ typeof quote.name === "string" &&
+ quote.name.length > 0 &&
+ typeof quote.price === "string" &&
+ quote.price.length > 0 &&
+ resultText.includes(quote.name) &&
+ resultText.includes(quote.price),
+ `quote=${JSON.stringify(quote).slice(0, 160)} result=${resultText.slice(0, 160)}`
+ );
+
+ await page.fill(".bond-code-input", "019547");
+ await page.click(".bond-quote-action");
+ await page.waitForFunction(
+ () => {
+ const element = document.querySelector(".bond-quote-unavailable");
+ return element && !element.hidden && element.textContent.includes("不可用");
+ },
+ { timeout: 10000 }
+ );
+
+ const unavailableText = ((await page.textContent(".bond-quote-unavailable")) || "").trim();
+ const resultGone = (await page.locator(".bond-quote-result").count()) === 0;
+
+ const token = process.env.QA_TOKEN || "qa-token";
+
+ const failure = await page.evaluate(async (token) => {
+ const response = await fetch("/api/market/bond-quote?code=019547", {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ let body = null;
+ try {
+ body = await response.json();
+ } catch {}
+ return { status: response.status, body };
+ }, token);
+
+ check(
+ "B2 无有效报价时显示不可用提示且不补零",
+ unavailableText.includes("不可用") &&
+ resultGone &&
+ failure.status === 503 &&
+ failure.body &&
+ failure.body.error === "MARKET_DATA_UNAVAILABLE",
+ `message=${unavailableText.slice(0, 200)} status=${failure.status}`
+ );
+
+ await page.screenshot({ path: SHOTS + "/21-bonds.png" });
+}
+
async function postInvestmentPlanViaApi(page, fundId, token) {
return page.evaluate(
async ({ fundId, token }) => {
diff --git a/qa/stub/fund-lab-python b/qa/stub/fund-lab-python
index cefcecd..9a171bc 100755
--- a/qa/stub/fund-lab-python
+++ b/qa/stub/fund-lab-python
@@ -31,6 +31,17 @@ case "$OP" in
fi
printf '{"schema_version":"fund-lab.akshare.v1","operation":"nav","source":"stub-synthetic","source_revision":"stub-1","_synthetic":true,"collected_at":"%s","instrument":{"code":"%s"},"observations":[{"nav_date":"2026-09-15","published_at":null,"nav":"1.1000","accumulated_nav":null,"daily_return":null},{"nav_date":"2026-09-16","published_at":null,"nav":"1.1205","accumulated_nav":null,"daily_return":"1.86"},{"nav_date":"2026-09-17","published_at":null,"nav":"1.1180","accumulated_nav":null,"daily_return":"-0.22"},{"nav_date":"2026-09-18","published_at":null,"nav":"1.1500","accumulated_nav":null,"daily_return":"2.86"},{"nav_date":"2026-09-21","published_at":null,"nav":"1.1902","accumulated_nav":null,"daily_return":"3.49"}]}\n' "$TS" "$CODE"
;;
+ bond-quote)
+ # Synthetic rows mirror the live AKShare convertible-bond spot contract:
+ # a known live code answers with a name/price, unknown codes fail like the
+ # "no valid quote" collector error (-> API 503).
+ if [ "$CODE" = "110075" ]; then
+ printf '{"schema_version":"fund-lab.akshare.v1","operation":"bond-quote","source":"stub-synthetic","source_revision":"stub-1","_synthetic":true,"collected_at":"%s","instrument":{"code":"%s"},"quote":{"name":"南航转债","price":"106.368","maturity_date":"2026-10-15"}}\n' "$TS" "$CODE"
+ else
+ echo "AKShare collector failed: no valid quote for $CODE" >&2
+ exit 2
+ fi
+ ;;
*)
echo "unknown operation: $OP" >&2
exit 2
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index 1552e15..ca0c61c 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -392,6 +392,14 @@ type RawMarketNavSeries =
points: RawMarketNavSeriesPoint array
}
+type RawBondQuote =
+ {
+ code: string
+ name: obj
+ price: obj
+ maturityDate: obj
+ }
+
type CreateAttempt =
{
idempotencyKey: string
@@ -692,6 +700,14 @@ type MarketNavSeriesPoint =
accumulatedNav: string option
}
+type BondQuoteView =
+ {
+ code: string
+ name: string option
+ price: string option
+ maturityDate: string option
+ }
+
module Api =
[<Import("searchInstruments", "./src/api.js")>]
let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative
@@ -741,6 +757,9 @@ module Api =
[<Import("getMarketNavSeries", "./src/api.js")>]
let getMarketNavSeries (token: string) (code: string) : JS.Promise<RawMarketNavSeries> = jsNative
+ [<Import("getBondQuote", "./src/api.js")>]
+ let getBondQuote (token: string) (code: string) : JS.Promise<RawBondQuote> = jsNative
+
[<Import("createCapitalDeposit", "./src/api.js")>]
let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative
@@ -1013,6 +1032,14 @@ module Api =
accumulatedNav = decodeOptionalText raw.accumulatedNav
}
+ let decodeBondQuote (raw: RawBondQuote) : BondQuoteView =
+ {
+ code = raw.code
+ name = decodeOptionalText raw.name
+ price = decodeOptionalText raw.price
+ maturityDate = decodeOptionalText raw.maturityDate
+ }
+
type Model =
{
token: string
@@ -1096,6 +1123,11 @@ type Model =
navSeriesInFlight: bool
navSeries: MarketNavSeriesPoint list
navSeriesAvailable: bool
+ bondCode: string
+ bondQuoteSeq: int
+ bondQuoteInFlight: bool
+ bondQuote: BondQuoteView option
+ bondQuoteMessage: string option
returnsSelectedDate: string option
planReadSeq: int
planInFlight: bool
@@ -1196,6 +1228,10 @@ type Msg =
| NavSeriesReadRequested
| NavSeriesReadCompleted of requestId: int * series: RawMarketNavSeries
| NavSeriesReadFailed of requestId: int * message: string
+ | BondCodeChanged of string
+ | BondQuoteRequested
+ | BondQuoteCompleted of requestId: int * quote: RawBondQuote
+ | BondQuoteFailed of requestId: int * message: string
| ReturnsDateChanged of string
| InvestmentPlansReadRequested
| InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array
@@ -1315,6 +1351,11 @@ let init () =
navSeriesInFlight = false
navSeries = []
navSeriesAvailable = false
+ bondCode = ""
+ bondQuoteSeq = 0
+ bondQuoteInFlight = false
+ bondQuote = None
+ bondQuoteMessage = None
returnsSelectedDate = None
planReadSeq = 0
planInFlight = false
@@ -1499,6 +1540,13 @@ let private readNavSeriesCommand token code requestId =
(fun series -> NavSeriesReadCompleted(requestId, series))
(fun error -> NavSeriesReadFailed(requestId, errorText error))
+let private readBondQuoteCommand token code requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.getBondQuote token code)
+ ()
+ (fun quote -> BondQuoteCompleted(requestId, quote))
+ (fun error -> BondQuoteFailed(requestId, errorText error))
+
let private readInvestmentPlansCommand token fundId requestId =
Cmd.OfPromise.either
(fun () -> Api.getInvestmentPlans token fundId)
@@ -2725,6 +2773,47 @@ let update message model =
Cmd.none
else
model, Cmd.none
+ | BondCodeChanged value -> { model with bondCode = value }, Cmd.none
+ | BondQuoteRequested ->
+ let code = model.bondCode.Trim()
+
+ if String.IsNullOrWhiteSpace model.token then
+ { model with bondQuoteMessage = Some "请输入 API token"; bondQuote = None }, Cmd.none
+ elif String.IsNullOrWhiteSpace code then
+ { model with bondQuoteMessage = Some "请输入六位债券代码"; bondQuote = None }, Cmd.none
+ else
+ let requestId = model.bondQuoteSeq + 1
+
+ {
+ model with
+ bondQuoteSeq = requestId
+ bondQuoteInFlight = true
+ bondQuoteMessage = None
+ bondQuote = None
+ },
+ readBondQuoteCommand model.token code requestId
+ | BondQuoteCompleted (requestId, quote) ->
+ if requestId = model.bondQuoteSeq then
+ {
+ model with
+ bondQuote = Some(Api.decodeBondQuote quote)
+ bondQuoteMessage = None
+ bondQuoteInFlight = false
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | BondQuoteFailed (requestId, message) ->
+ if requestId = model.bondQuoteSeq then
+ {
+ model with
+ bondQuote = None
+ bondQuoteMessage = Some(sprintf "债券行情不可用 — %s" message)
+ bondQuoteInFlight = false
+ },
+ Cmd.none
+ else
+ model, Cmd.none
| ReturnsDateChanged value ->
{ model with returnsSelectedDate = (if String.IsNullOrWhiteSpace value then None else Some value) }, Cmd.none
| InvestmentPlansReadRequested ->
@@ -4423,6 +4512,75 @@ let private investmentPlansPanel model dispatch =
]
]
+let private bondQuoteMetric (label: string) (value: string option) =
+ Html.div [
+ prop.className "metric"
+ prop.children [
+ Html.span label
+ Html.strong (value |> Option.defaultValue "—")
+ ]
+ ]
+
+let private bondsPanel model dispatch =
+ Html.section [
+ prop.className "panel bonds-panel"
+ prop.children [
+ Html.div [
+ prop.className "section-heading"
+ prop.children [
+ Html.div [
+ Html.p [ prop.className "eyebrow"; prop.text "13 / BONDS" ]
+ Html.h2 "债券行情探针"
+ ]
+ Html.span [ prop.className "section-note"; prop.text "AKShare · convertible spot" ]
+ ]
+ ]
+ Html.p [
+ prop.className "bonds-note"
+ prop.text "数据来源实时 AKShare 可转债行情,仅查询单一债券代码,不做本地模拟。"
+ ]
+ Html.div [
+ prop.className "search-row"
+ prop.children [
+ Html.input [
+ prop.className "text-input bond-code-input"
+ prop.placeholder "输入六位债券代码,如 110075"
+ prop.value model.bondCode
+ prop.onChange (fun value -> dispatch (BondCodeChanged value))
+ ]
+ Html.button [
+ prop.className "primary-action bond-quote-action"
+ prop.disabled model.bondQuoteInFlight
+ prop.onClick (fun _ -> dispatch BondQuoteRequested)
+ prop.text ((if model.bondQuoteInFlight then "查询中..." else "查询行情"): string)
+ ]
+ ]
+ ]
+ Html.p [
+ prop.className "bond-quote-unavailable"
+ prop.hidden (model.bondQuoteMessage.IsNone)
+ prop.text (model.bondQuoteMessage |> Option.defaultValue "")
+ ]
+ match model.bondQuote with
+ | Some quote ->
+ Html.div [
+ prop.className "nav-content bond-quote-result"
+ prop.children [
+ Html.div [
+ prop.className "metric-strip"
+ prop.children [
+ bondQuoteMetric "名称" quote.name
+ bondQuoteMetric "价格" quote.price
+ bondQuoteMetric "到期日" quote.maturityDate
+ ]
+ ]
+ ]
+ ]
+ | None ->
+ Html.p [ prop.className "hint"; prop.text "查询后显示名称/价格/到期日;缺失字段以「—」表示,不补零。" ]
+ ]
+ ]
+
let view model dispatch =
Html.main [
prop.className "app-shell"
@@ -4486,6 +4644,7 @@ let view model dispatch =
returnsPanel model dispatch
investmentPlansPanel model dispatch
rebalancePanel model dispatch
+ bondsPanel model dispatch
Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ & SUBSCRIBE" ]
]
]
diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js
index c94a9e0..ed49552 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -135,6 +135,10 @@ export function getMarketNavSeries(token, code) {
return requestJson(`/api/market/nav-series?code=${encodeURIComponent(code)}&limit=30`, token);
}
+export function getBondQuote(token, code) {
+ return requestJson(`/api/market/bond-quote?code=${encodeURIComponent(code)}`, 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 b994a80..0f67e97 100644
--- a/src/FundLab.Web/src/styles.css
+++ b/src/FundLab.Web/src/styles.css
@@ -578,6 +578,17 @@ h2 {
font-size: 12px;
}
+.bonds-note {
+ color: #64748b;
+ font-size: 12px;
+ margin-bottom: 8px;
+}
+
+.bond-quote-unavailable {
+ color: #b45309;
+ font-size: 12px;
+}
+
.returns-pending {
color: #b45309;
font-size: 12px;
diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs
index 6b4eef8..4f2137a 100644
--- a/tests/FundLab.Web.Tests/BoundaryTests.fs
+++ b/tests/FundLab.Web.Tests/BoundaryTests.fs
@@ -459,6 +459,35 @@ module ConfirmationBoundaryTests =
Assert.False(updated.navSeriesInFlight)
[<Fact>]
+ let ``bond quote completion stores decoded quote and clears the message`` () =
+ let model = init ()
+
+ let raw : RawBondQuote =
+ { code = "110075"; name = box "南航转债"; price = box "106.368"; maturityDate = null }
+
+ let updated, _ = update (BondQuoteCompleted(model.bondQuoteSeq, raw)) model
+
+ Assert.Equal(Some "南航转债", updated.bondQuote |> Option.bind (fun quote -> quote.name))
+ Assert.Equal(Some "106.368", updated.bondQuote |> Option.bind (fun quote -> quote.price))
+ Assert.Equal(None, updated.bondQuote |> Option.bind (fun quote -> quote.maturityDate))
+ Assert.Equal(None, updated.bondQuoteMessage)
+ Assert.False(updated.bondQuoteInFlight)
+
+ [<Fact>]
+ let ``bond quote failure shows an unavailable message without a quote`` () =
+ let model = { init () with bondQuoteInFlight = true }
+
+ let updated, _ =
+ update (BondQuoteFailed(model.bondQuoteSeq, "AKShare collector failed: no valid quote for 019547")) model
+
+ Assert.Equal(None, updated.bondQuote)
+
+ let message = updated.bondQuoteMessage |> Option.defaultValue ""
+ Assert.Contains("不可用", message)
+ Assert.Contains("no valid quote for 019547", message)
+ Assert.False(updated.bondQuoteInFlight)
+
+ [<Fact>]
let ``order decode keeps decimal strings and decodes option fields`` () =
let confirmed = Api.decodeOrder (sampleConfirmedRaw ())
let pending = Api.decodeOrder (samplePendingRaw ())