summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--qa/driver/browser-test.js70
-rwxr-xr-xqa/stub/fund-lab-python11
-rw-r--r--src/FundLab.Web/App.fs156
-rw-r--r--src/FundLab.Web/src/api.js4
-rw-r--r--src/FundLab.Web/src/styles.css5
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs34
6 files changed, 280 insertions, 0 deletions
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js
index dddc562..424872a 100644
--- a/qa/driver/browser-test.js
+++ b/qa/driver/browser-test.js
@@ -35,6 +35,7 @@ let lastRebalanceExecutionsResponse = null;
let lastNavDatesResponse = null;
let lastNavSeriesResponse = null;
let lastBondQuoteResponse = null;
+let lastStockQuoteResponse = null;
function check(name, ok, detail) {
results.push({ name, ok, detail: detail || "" });
@@ -189,6 +190,11 @@ async function summaryLabelExists(page, label, name) {
lastBondQuoteResponse = await r.json();
} catch {}
}
+ if (r.url().includes("/market/stock-quote") && r.request().method() === "GET" && r.status() < 400) {
+ try {
+ lastStockQuoteResponse = 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());
}
@@ -241,6 +247,7 @@ async function summaryLabelExists(page, label, name) {
await plansScenario(page);
await rebalanceScenario(page);
await bondsScenario(page);
+ await stocksScenario(page);
} finally {
check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | "));
await browser.close();
@@ -694,6 +701,69 @@ async function bondsScenario(page) {
await page.screenshot({ path: SHOTS + "/21-bonds.png" });
}
+async function stocksScenario(page) {
+ // S 系列: 股票行情探针面板走真实 API(QA 环境由桩采集器提供,非前端 mock)。
+ await page.waitForSelector(".stocks-panel", { timeout: 10000 });
+
+ lastStockQuoteResponse = null;
+ await page.fill(".stock-code-input", "600519");
+ await page.click(".stock-quote-action");
+ await page.waitForSelector(".stock-quote-result", { timeout: 10000 });
+
+ const resultText = ((await page.textContent(".stock-quote-result")) || "").trim();
+ const quote = lastStockQuoteResponse || {};
+
+ check(
+ "S1 真实可用股票返回名称与价格字符串",
+ quote.code === "600519" &&
+ typeof quote.name === "string" &&
+ quote.name.includes("贵州茅台") &&
+ typeof quote.price === "string" &&
+ quote.price.length > 0 &&
+ resultText.includes("贵州茅台") &&
+ resultText.includes(quote.price),
+ `quote=${JSON.stringify(quote).slice(0, 160)} result=${resultText.slice(0, 160)}`
+ );
+
+ await page.fill(".stock-code-input", "999999");
+ await page.click(".stock-quote-action");
+ await page.waitForFunction(
+ () => {
+ const element = document.querySelector(".stock-quote-unavailable");
+ return element && !element.hidden && element.textContent.includes("股票行情不可用");
+ },
+ { timeout: 10000 }
+ );
+
+ const unavailableText = ((await page.textContent(".stock-quote-unavailable")) || "").trim();
+ const resultGone = (await page.locator(".stock-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/stock-quote?code=999999", {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ let body = null;
+ try {
+ body = await response.json();
+ } catch {}
+ return { status: response.status, body };
+ }, token);
+
+ check(
+ "S2 无有效报价时显示不可用提示且不显示 0 占位",
+ unavailableText.includes("股票行情不可用") &&
+ resultGone &&
+ !unavailableText.includes("0.00") &&
+ failure.status === 503 &&
+ failure.body &&
+ failure.body.error === "MARKET_DATA_UNAVAILABLE",
+ `message=${unavailableText.slice(0, 200)} status=${failure.status}`
+ );
+
+ await page.screenshot({ path: SHOTS + "/22-stocks.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 9a171bc..5c6daa3 100755
--- a/qa/stub/fund-lab-python
+++ b/qa/stub/fund-lab-python
@@ -42,6 +42,17 @@ case "$OP" in
exit 2
fi
;;
+ stock-quote)
+ # Synthetic rows mirror the live AKShare A-share 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" = "600519" ]; then
+ printf '{"schema_version":"fund-lab.akshare.v1","operation":"stock-quote","source":"stub-synthetic","source_revision":"stub-1","_synthetic":true,"collected_at":"%s","instrument":{"code":"%s"},"quote":{"name":"贵州茅台","price":"1252.57","currency":"CNY"}}\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 ca0c61c..9b4bed9 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -400,6 +400,14 @@ type RawBondQuote =
maturityDate: obj
}
+type RawStockQuote =
+ {
+ code: string
+ name: obj
+ price: obj
+ currency: string
+ }
+
type CreateAttempt =
{
idempotencyKey: string
@@ -708,6 +716,14 @@ type BondQuoteView =
maturityDate: string option
}
+type StockQuoteView =
+ {
+ code: string
+ name: string option
+ price: string option
+ currency: string
+ }
+
module Api =
[<Import("searchInstruments", "./src/api.js")>]
let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative
@@ -760,6 +776,9 @@ module Api =
[<Import("getBondQuote", "./src/api.js")>]
let getBondQuote (token: string) (code: string) : JS.Promise<RawBondQuote> = jsNative
+ [<Import("getStockQuote", "./src/api.js")>]
+ let getStockQuote (token: string) (code: string) : JS.Promise<RawStockQuote> = jsNative
+
[<Import("createCapitalDeposit", "./src/api.js")>]
let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative
@@ -1040,6 +1059,14 @@ module Api =
maturityDate = decodeOptionalText raw.maturityDate
}
+ let decodeStockQuote (raw: RawStockQuote) : StockQuoteView =
+ {
+ code = raw.code
+ name = decodeOptionalText raw.name
+ price = decodeOptionalText raw.price
+ currency = raw.currency
+ }
+
type Model =
{
token: string
@@ -1128,6 +1155,11 @@ type Model =
bondQuoteInFlight: bool
bondQuote: BondQuoteView option
bondQuoteMessage: string option
+ stockCode: string
+ stockQuoteSeq: int
+ stockQuoteInFlight: bool
+ stockQuote: StockQuoteView option
+ stockQuoteMessage: string option
returnsSelectedDate: string option
planReadSeq: int
planInFlight: bool
@@ -1232,6 +1264,10 @@ type Msg =
| BondQuoteRequested
| BondQuoteCompleted of requestId: int * quote: RawBondQuote
| BondQuoteFailed of requestId: int * message: string
+ | StockCodeChanged of string
+ | StockQuoteRequested
+ | StockQuoteCompleted of requestId: int * quote: RawStockQuote
+ | StockQuoteFailed of requestId: int * message: string
| ReturnsDateChanged of string
| InvestmentPlansReadRequested
| InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array
@@ -1356,6 +1392,11 @@ let init () =
bondQuoteInFlight = false
bondQuote = None
bondQuoteMessage = None
+ stockCode = ""
+ stockQuoteSeq = 0
+ stockQuoteInFlight = false
+ stockQuote = None
+ stockQuoteMessage = None
returnsSelectedDate = None
planReadSeq = 0
planInFlight = false
@@ -1547,6 +1588,13 @@ let private readBondQuoteCommand token code requestId =
(fun quote -> BondQuoteCompleted(requestId, quote))
(fun error -> BondQuoteFailed(requestId, errorText error))
+let private readStockQuoteCommand token code requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.getStockQuote token code)
+ ()
+ (fun quote -> StockQuoteCompleted(requestId, quote))
+ (fun error -> StockQuoteFailed(requestId, errorText error))
+
let private readInvestmentPlansCommand token fundId requestId =
Cmd.OfPromise.either
(fun () -> Api.getInvestmentPlans token fundId)
@@ -2814,6 +2862,53 @@ let update message model =
Cmd.none
else
model, Cmd.none
+ | StockCodeChanged value -> { model with stockCode = value }, Cmd.none
+ | StockQuoteRequested ->
+ let code = model.stockCode.Trim()
+
+ if String.IsNullOrWhiteSpace model.token then
+ { model with stockQuoteMessage = Some "请输入 API token"; stockQuote = None }, Cmd.none
+ elif String.IsNullOrWhiteSpace code then
+ { model with stockQuoteMessage = Some "请输入六位股票代码"; stockQuote = None }, Cmd.none
+ else
+ let requestId = model.stockQuoteSeq + 1
+
+ {
+ model with
+ stockQuoteSeq = requestId
+ stockQuoteInFlight = true
+ stockQuoteMessage = None
+ stockQuote = None
+ },
+ readStockQuoteCommand model.token code requestId
+ | StockQuoteCompleted (requestId, quote) ->
+ if requestId = model.stockQuoteSeq then
+ {
+ model with
+ stockQuote = Some(Api.decodeStockQuote quote)
+ stockQuoteMessage = None
+ stockQuoteInFlight = false
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | StockQuoteFailed (requestId, message) ->
+ if requestId = model.stockQuoteSeq then
+ let text =
+ if message.Contains("six digits") then
+ "无效代码 — 请输入六位股票代码"
+ else
+ sprintf "股票行情不可用 — %s" message
+
+ {
+ model with
+ stockQuote = None
+ stockQuoteMessage = Some text
+ stockQuoteInFlight = false
+ },
+ Cmd.none
+ else
+ model, Cmd.none
| ReturnsDateChanged value ->
{ model with returnsSelectedDate = (if String.IsNullOrWhiteSpace value then None else Some value) }, Cmd.none
| InvestmentPlansReadRequested ->
@@ -4581,6 +4676,66 @@ let private bondsPanel model dispatch =
]
]
+let private stocksPanel model dispatch =
+ Html.section [
+ prop.className "panel stocks-panel"
+ prop.children [
+ Html.div [
+ prop.className "section-heading"
+ prop.children [
+ Html.div [
+ Html.p [ prop.className "eyebrow"; prop.text "14 / STOCKS" ]
+ Html.h2 "股票行情探针"
+ ]
+ Html.span [ prop.className "section-note"; prop.text "AKShare · A-share spot" ]
+ ]
+ ]
+ Html.p [
+ prop.className "bonds-note"
+ prop.text "数据来源实时 AKShare A 股行情,仅查询单一六位代码,不做本地模拟,不补零。"
+ ]
+ Html.div [
+ prop.className "search-row"
+ prop.children [
+ Html.input [
+ prop.className "text-input stock-code-input"
+ prop.placeholder "输入六位股票代码,如 600519"
+ prop.value model.stockCode
+ prop.onChange (fun value -> dispatch (StockCodeChanged value))
+ ]
+ Html.button [
+ prop.className "primary-action stock-quote-action"
+ prop.disabled model.stockQuoteInFlight
+ prop.onClick (fun _ -> dispatch StockQuoteRequested)
+ prop.text ((if model.stockQuoteInFlight then "查询中..." else "查询行情"): string)
+ ]
+ ]
+ ]
+ Html.p [
+ prop.className "stock-quote-unavailable"
+ prop.hidden (model.stockQuoteMessage.IsNone)
+ prop.text (model.stockQuoteMessage |> Option.defaultValue "")
+ ]
+ match model.stockQuote with
+ | Some quote ->
+ Html.div [
+ prop.className "nav-content stock-quote-result"
+ prop.children [
+ Html.div [
+ prop.className "metric-strip"
+ prop.children [
+ bondQuoteMetric "名称" quote.name
+ bondQuoteMetric "价格" quote.price
+ bondQuoteMetric "币种" (Some quote.currency)
+ ]
+ ]
+ ]
+ ]
+ | None ->
+ Html.p [ prop.className "hint"; prop.text "查询后显示名称/价格/币种;缺失字段以「—」表示。" ]
+ ]
+ ]
+
let view model dispatch =
Html.main [
prop.className "app-shell"
@@ -4645,6 +4800,7 @@ let view model dispatch =
investmentPlansPanel model dispatch
rebalancePanel model dispatch
bondsPanel model dispatch
+ stocksPanel 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 ed49552..7b7e1b9 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -139,6 +139,10 @@ export function getBondQuote(token, code) {
return requestJson(`/api/market/bond-quote?code=${encodeURIComponent(code)}`, token);
}
+export function getStockQuote(token, code) {
+ return requestJson(`/api/market/stock-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 0f67e97..17326b5 100644
--- a/src/FundLab.Web/src/styles.css
+++ b/src/FundLab.Web/src/styles.css
@@ -589,6 +589,11 @@ h2 {
font-size: 12px;
}
+.stock-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 4f2137a..3001273 100644
--- a/tests/FundLab.Web.Tests/BoundaryTests.fs
+++ b/tests/FundLab.Web.Tests/BoundaryTests.fs
@@ -488,6 +488,40 @@ module ConfirmationBoundaryTests =
Assert.False(updated.bondQuoteInFlight)
[<Fact>]
+ let ``stock quote completion stores decoded quote and clears the message`` () =
+ let model = init ()
+
+ let raw : RawStockQuote =
+ { code = "600519"; name = box "贵州茅台"; price = box "1252.57"; currency = "CNY" }
+
+ let updated, _ = update (StockQuoteCompleted(model.stockQuoteSeq, raw)) model
+
+ Assert.Equal(Some "贵州茅台", updated.stockQuote |> Option.bind (fun quote -> quote.name))
+ Assert.Equal(Some "1252.57", updated.stockQuote |> Option.bind (fun quote -> quote.price))
+ Assert.Equal(Some "CNY", updated.stockQuote |> Option.map (fun quote -> quote.currency))
+ Assert.Equal(None, updated.stockQuoteMessage)
+ Assert.False(updated.stockQuoteInFlight)
+
+ [<Fact>]
+ let ``stock quote failure shows an unavailable message without a quote`` () =
+ let model = { init () with stockQuoteInFlight = true }
+
+ let updated, _ = update (StockQuoteFailed(model.stockQuoteSeq, "collector exited with code 2")) model
+
+ Assert.Equal(None, updated.stockQuote)
+ Assert.Contains("股票行情不可用", updated.stockQuoteMessage |> Option.defaultValue "")
+ Assert.False(updated.stockQuoteInFlight)
+
+ [<Fact>]
+ let ``stock quote invalid code shows an invalid code message`` () =
+ let model = { init () with stockQuoteInFlight = true }
+
+ let updated, _ =
+ update (StockQuoteFailed(model.stockQuoteSeq, "stock code must contain exactly six digits")) model
+
+ Assert.Contains("无效代码", updated.stockQuoteMessage |> Option.defaultValue "")
+
+ [<Fact>]
let ``order decode keeps decimal strings and decodes option fields`` () =
let confirmed = Api.decodeOrder (sampleConfirmedRaw ())
let pending = Api.decodeOrder (samplePendingRaw ())