summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--qa/driver/browser-test.js67
-rwxr-xr-xqa/stub/fund-lab-python11
-rw-r--r--src/FundLab.Api/App.fs52
-rw-r--r--src/FundLab.Api/FundLab.Api.fsproj1
-rw-r--r--src/FundLab.Api/MarketData.fs69
-rw-r--r--src/FundLab.Api/MarketDataService.fs4
-rw-r--r--src/FundLab.Api/Program.fs4
-rw-r--r--src/FundLab.Api/StockDailyProbe.fs80
-rw-r--r--src/FundLab.Api/akshare_collector.py88
-rw-r--r--src/FundLab.Web/App.fs120
-rw-r--r--src/FundLab.Web/src/api.js4
-rw-r--r--src/FundLab.Web/src/styles.css5
-rw-r--r--tests/FundLab.Api.Tests/BondQuoteProbeTests.fs6
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/NavDateProbeTests.fs10
-rw-r--r--tests/FundLab.Api.Tests/NavSeriesProbeTests.fs3
-rw-r--r--tests/FundLab.Api.Tests/StockDailyProbeTests.fs198
-rw-r--r--tests/FundLab.Api.Tests/StockQuoteProbeTests.fs6
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs33
19 files changed, 751 insertions, 11 deletions
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js
index 424872a..401cb78 100644
--- a/qa/driver/browser-test.js
+++ b/qa/driver/browser-test.js
@@ -36,6 +36,7 @@ let lastNavDatesResponse = null;
let lastNavSeriesResponse = null;
let lastBondQuoteResponse = null;
let lastStockQuoteResponse = null;
+let lastStockDailyResponse = null;
function check(name, ok, detail) {
results.push({ name, ok, detail: detail || "" });
@@ -195,6 +196,11 @@ async function summaryLabelExists(page, label, name) {
lastStockQuoteResponse = await r.json();
} catch {}
}
+ if (r.url().includes("/market/stock-daily") && r.request().method() === "GET" && r.status() < 400) {
+ try {
+ lastStockDailyResponse = 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());
}
@@ -248,6 +254,7 @@ async function summaryLabelExists(page, label, name) {
await rebalanceScenario(page);
await bondsScenario(page);
await stocksScenario(page);
+ await stockDailyScenario(page);
} finally {
check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | "));
await browser.close();
@@ -764,6 +771,66 @@ async function stocksScenario(page) {
await page.screenshot({ path: SHOTS + "/22-stocks.png" });
}
+async function stockDailyScenario(page) {
+ // S3: 股票日线历史走真实 API(QA 环境由桩采集器提供,非前端 mock)。
+ await page.waitForSelector(".stocks-panel", { timeout: 10000 });
+
+ lastStockDailyResponse = null;
+ await page.fill(".stock-code-input", "600519");
+ await page.click(".stock-daily-action");
+ await page.waitForSelector(".stock-daily-row", { timeout: 10000 });
+
+ const rows = await page.locator(".stock-daily-row").count();
+ const daily = lastStockDailyResponse || {};
+ const observations = Array.isArray(daily.observations) ? daily.observations : [];
+ const hasClose = observations.every((item) => typeof item.close === "string" && item.close.length > 0);
+
+ check(
+ "S3a 真实可用股票日线显示日期行且数量>=3",
+ daily.code === "600519" && observations.length >= 3 && hasClose && rows >= 3,
+ `code=${daily.code} observations=${observations.length} rows=${rows}`
+ );
+
+ const token = process.env.QA_TOKEN || "qa-token";
+
+ await page.fill(".stock-code-input", "999999");
+ await page.click(".stock-daily-action");
+ await page.waitForFunction(
+ () => {
+ const element = document.querySelector(".stock-daily-unavailable");
+ return element && !element.hidden && element.textContent.includes("股票日线不可用");
+ },
+ { timeout: 10000 }
+ );
+
+ const unavailableText = ((await page.textContent(".stock-daily-unavailable")) || "").trim();
+ const rowsGone = (await page.locator(".stock-daily-row").count()) === 0;
+
+ const failure = await page.evaluate(async (token) => {
+ const response = await fetch("/api/market/stock-daily?code=999999&days=5", {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ let body = null;
+ try {
+ body = await response.json();
+ } catch {}
+ return { status: response.status, body };
+ }, token);
+
+ check(
+ "S3b 无有效日线时显示不可用提示且无 0/空表占位",
+ unavailableText.includes("股票日线不可用") &&
+ rowsGone &&
+ !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 + "/23-stock-daily.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 5c6daa3..c9ce981 100755
--- a/qa/stub/fund-lab-python
+++ b/qa/stub/fund-lab-python
@@ -53,6 +53,17 @@ case "$OP" in
exit 2
fi
;;
+ stock-daily)
+ # Synthetic daily bars mirror the live AKShare A-share history contract
+ # (>= 3 rows); unknown codes fail like the collector's "no valid daily
+ # history" error (-> API 503).
+ if [ "$CODE" = "600519" ]; then
+ printf '{"schema_version":"fund-lab.akshare.v1","operation":"stock-daily","source":"stub-synthetic","source_revision":"stub-1","_synthetic":true,"collected_at":"%s","instrument":{"code":"%s"},"observations":[{"date":"2026-09-17","close":"1266.98","volume":"1755380.0","amount":"2217338283.0"},{"date":"2026-09-18","close":"1257.12","volume":"2489087.0","amount":"3135849108.0"},{"date":"2026-09-21","close":"1252.57","volume":"2501689.0","amount":"3135910045.0"}]}\n' "$TS" "$CODE"
+ else
+ echo "AKShare collector failed: no valid daily history for $CODE" >&2
+ exit 2
+ fi
+ ;;
*)
echo "unknown operation: $OP" >&2
exit 2
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index 9565485..4313329 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -358,6 +358,7 @@ type MarketProbes =
NavSeries: INavSeriesProbe
BondQuotes: IBondQuoteProbe
StockQuotes: IStockQuoteProbe
+ StockDaily: IStockDailyProbe
}
type BondQuoteApiResponse =
@@ -376,6 +377,20 @@ type StockQuoteApiResponse =
currency: string
}
+type StockDailyObservationApiResponse =
+ {
+ date: string
+ close: string
+ volume: string option
+ amount: string option
+ }
+
+type StockDailyApiResponse =
+ {
+ code: string
+ observations: StockDailyObservationApiResponse list
+ }
+
module App =
let addOptionFriendlyJson (services: IServiceCollection) =
let settings =
@@ -1630,7 +1645,7 @@ module App =
lastSeenAt = timestampText observation.LastSeenAt
}
- let private marketDataNavResponse code observations =
+ let private marketDataNavResponse code (observations: MarketDataNavRecord list) : MarketDataNavApiResponse =
{
code = code
observations = observations |> List.map marketDataObservationResponse
@@ -1753,12 +1768,47 @@ module App =
ctx
| Error failure -> marketDataError failure next ctx
+ let private stockDailyObservationResponse (observation: StockDailyObservation) : StockDailyObservationApiResponse =
+ {
+ date = dateText observation.BarDate
+ close = decimalText observation.Close
+ volume = observation.Volume |> Option.map decimalText
+ amount = observation.Amount |> Option.map decimalText
+ }
+
+ let private getStockDaily (probe: IStockDailyProbe) : HttpHandler =
+ fun next ctx ->
+ let code = ctx.Request.Query["code"].ToString()
+ let daysText = ctx.Request.Query["days"].ToString()
+
+ let days =
+ if String.IsNullOrWhiteSpace daysText then
+ Ok 5
+ else
+ match Int32.TryParse(daysText, NumberStyles.Integer, invariant) with
+ | true, value when value >= 1 && value <= 30 -> Ok value
+ | _ -> Error "days must be an integer between 1 and 30"
+
+ match days with
+ | Error message -> marketDataError (InvalidMarketDataRequest message) next ctx
+ | Ok value ->
+ match probe.RecentDaily(code, value, ctx.RequestAborted) with
+ | Ok observations ->
+ json
+ ({ code = code.Trim()
+ observations = observations |> List.map stockDailyObservationResponse }
+ : StockDailyApiResponse)
+ next
+ ctx
+ | Error failure -> marketDataError failure next ctx
+
let private marketProbeRoutes (probes: MarketProbes) =
[
GET >=> route "/market/nav-dates" >=> getMarketNavDates probes.NavDates
GET >=> route "/market/nav-series" >=> getMarketNavSeries probes.NavSeries
GET >=> route "/market/bond-quote" >=> getBondQuote probes.BondQuotes
GET >=> route "/market/stock-quote" >=> getStockQuote probes.StockQuotes
+ GET >=> route "/market/stock-daily" >=> getStockDaily probes.StockDaily
]
let private createApplicationInternal
diff --git a/src/FundLab.Api/FundLab.Api.fsproj b/src/FundLab.Api/FundLab.Api.fsproj
index 62c6324..2703e4b 100644
--- a/src/FundLab.Api/FundLab.Api.fsproj
+++ b/src/FundLab.Api/FundLab.Api.fsproj
@@ -22,6 +22,7 @@
<Compile Include="NavSeriesProbe.fs" />
<Compile Include="BondQuoteProbe.fs" />
<Compile Include="StockQuoteProbe.fs" />
+ <Compile Include="StockDailyProbe.fs" />
<Compile Include="App.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
diff --git a/src/FundLab.Api/MarketData.fs b/src/FundLab.Api/MarketData.fs
index 87ef145..82da1c9 100644
--- a/src/FundLab.Api/MarketData.fs
+++ b/src/FundLab.Api/MarketData.fs
@@ -59,6 +59,23 @@ type MarketDataStockQuotePayload =
Currency: string
}
+type MarketDataStockDailyBar =
+ {
+ BarDate: DateOnly
+ Close: decimal
+ Volume: decimal option
+ Amount: decimal option
+ }
+
+type MarketDataStockDailyPayload =
+ {
+ Source: string
+ SourceRevision: string
+ CollectedAt: DateTimeOffset
+ Code: string
+ Bars: MarketDataStockDailyBar list
+ }
+
type MarketDataInstrumentRecord =
{
Code: string
@@ -398,3 +415,55 @@ module MarketData =
}
with
| :? JsonException -> Error "payload must be valid JSON"
+
+ let private parseStockDailyBar root =
+ result {
+ let! dateText = requiredString root "date"
+ let! date = parseDate dateText
+ let! closeProperty = requiredProperty root "close"
+ let! close = parseDecimal "close" closeProperty
+ let! volumeProperty = requiredProperty root "volume"
+ let! volume = optionalDecimal "volume" volumeProperty
+ let! amountProperty = requiredProperty root "amount"
+ let! amount = optionalDecimal "amount" amountProperty
+
+ return
+ {
+ BarDate = date
+ Close = close
+ Volume = volume
+ Amount = amount
+ }
+ }
+
+ let parseStockDailyPayload (json: string) : Result<MarketDataStockDailyPayload, string> =
+ try
+ use document = JsonDocument.Parse(json)
+ let root = document.RootElement
+
+ result {
+ let! source, sourceRevision, collectedAt = validateEnvelope root "stock-daily"
+ let! instrumentProperty = requiredProperty root "instrument"
+ let! codeProperty = requiredString instrumentProperty "code"
+
+ if not (isFundCode codeProperty) then
+ return! Error "stock code must contain exactly six digits"
+
+ let! observationsProperty = requiredProperty root "observations"
+ let! barsProperty = requiredProperty root "observations"
+ let! bars = parseArray parseStockDailyBar barsProperty
+
+ if List.isEmpty bars then
+ return! Error "observations must not be empty"
+
+ return
+ {
+ Source = source
+ SourceRevision = sourceRevision
+ CollectedAt = collectedAt
+ Code = codeProperty
+ Bars = bars
+ }
+ }
+ with
+ | :? JsonException -> Error "payload must be valid JSON"
diff --git a/src/FundLab.Api/MarketDataService.fs b/src/FundLab.Api/MarketDataService.fs
index 7460bad..ee3aa67 100644
--- a/src/FundLab.Api/MarketDataService.fs
+++ b/src/FundLab.Api/MarketDataService.fs
@@ -20,6 +20,7 @@ type IMarketDataCollector =
abstract FetchNav: code: string * CancellationToken -> Result<string, string>
abstract FetchBondQuote: code: string * CancellationToken -> Result<string, string>
abstract FetchStockQuote: code: string * CancellationToken -> Result<string, string>
+ abstract FetchStockDaily: code: string * days: int * CancellationToken -> Result<string, string>
type IMarketDataService =
abstract Search: query: string * CancellationToken -> Result<MarketDataSearchPayload, MarketDataFailure>
@@ -211,6 +212,9 @@ type ProcessMarketDataCollector(pythonExecutable: string, scriptPath: string, py
member _.FetchStockQuote(code: string, token: CancellationToken) =
execute token [ "--operation"; "stock-quote"; "--code"; code ]
+ member _.FetchStockDaily(code: string, days: int, token: CancellationToken) =
+ execute token [ "--operation"; "stock-daily"; "--code"; code; "--days"; string days ]
+
type MarketDataService(repository: FundRepository, collector: IMarketDataCollector) =
let codePattern = Text.RegularExpressions.Regex("^[0-9]{6}$", Text.RegularExpressions.RegexOptions.Compiled)
diff --git a/src/FundLab.Api/Program.fs b/src/FundLab.Api/Program.fs
index 410583c..768fb26 100644
--- a/src/FundLab.Api/Program.fs
+++ b/src/FundLab.Api/Program.fs
@@ -23,12 +23,14 @@ let main argv =
let navSeriesProbe = AkshareNavSeriesProbe(collector) :> INavSeriesProbe
let bondQuoteProbe = AkshareBondQuoteProbe(collector) :> IBondQuoteProbe
let stockQuoteProbe = AkshareStockQuoteProbe(collector) :> IStockQuoteProbe
+ let stockDailyProbe = AkshareStockDailyProbe(collector) :> IStockDailyProbe
let probes =
{ NavDates = navDateProbe
NavSeries = navSeriesProbe
BondQuotes = bondQuoteProbe
- StockQuotes = stockQuoteProbe }
+ StockQuotes = stockQuoteProbe
+ StockDaily = stockDailyProbe }
let app = builder.Build()
app.UseGiraffe(App.createApplicationWithMarketDataAndProbes repository marketData probes)
diff --git a/src/FundLab.Api/StockDailyProbe.fs b/src/FundLab.Api/StockDailyProbe.fs
new file mode 100644
index 0000000..8099267
--- /dev/null
+++ b/src/FundLab.Api/StockDailyProbe.fs
@@ -0,0 +1,80 @@
+namespace FundLab.Api
+
+open System
+open System.Collections.Concurrent
+open System.Threading
+
+/// One daily close of a stock's history.
+type StockDailyObservation =
+ {
+ BarDate: DateOnly
+ Close: decimal
+ Volume: decimal option
+ Amount: decimal option
+ }
+
+/// Read-only probe that returns recent daily history for a stock code.
+///
+/// Like the other market probes there is no synthetic fallback: a failure is
+/// surfaced as a MarketDataFailure so the API answers 503 with the reason.
+type IStockDailyProbe =
+ abstract RecentDaily: code: string * days: int * CancellationToken -> Result<StockDailyObservation list, MarketDataFailure>
+
+/// AKShare-backed stock-daily probe. Successful lookups are memoised per code
+/// and requested window; failures are never cached. A payload whose instrument
+/// code disagrees with the request is rejected outright.
+type AkshareStockDailyProbe(collector: IMarketDataCollector) =
+ let cache = ConcurrentDictionary<string, StockDailyObservation list>(StringComparer.Ordinal)
+
+ let isStockCode (value: string) =
+ not (String.IsNullOrWhiteSpace value)
+ && value.Length = 6
+ && value |> Seq.forall Char.IsDigit
+
+ let clip days =
+ if days < 1 then 1
+ elif days > 30 then 30
+ else days
+
+ member _.Clear() = cache.Clear()
+
+ interface IStockDailyProbe with
+ member _.RecentDaily(code, days, token) =
+ let normalized = if isNull code then "" else code.Trim()
+
+ if not (isStockCode normalized) then
+ Error(InvalidMarketDataRequest "stock code must contain exactly six digits")
+ else
+ let bounded = clip days
+ let cacheKey = sprintf "%s|%d" normalized bounded
+
+ match cache.TryGetValue cacheKey with
+ | true, cached -> Ok cached
+ | _ ->
+ match collector.FetchStockDaily(normalized, bounded, token) with
+ | Error message -> Error(MarketDataCollectorUnavailable message)
+ | Ok json ->
+ match MarketData.parseStockDailyPayload json with
+ | Error message -> Error(InvalidMarketDataPayload message)
+ | Ok payload when not (String.Equals(payload.Code, normalized, StringComparison.Ordinal)) ->
+ Error(
+ InvalidMarketDataPayload(
+ sprintf "payload code '%s' does not match requested code '%s'" payload.Code normalized
+ )
+ )
+ | Ok payload ->
+ let observations =
+ payload.Bars
+ |> List.filter (fun bar -> bar.Close > 0m)
+ |> List.map (fun bar ->
+ {
+ BarDate = bar.BarDate
+ Close = bar.Close
+ Volume = bar.Volume
+ Amount = bar.Amount
+ })
+ |> List.distinctBy (fun bar -> bar.BarDate)
+ |> List.sortBy (fun bar -> bar.BarDate)
+
+ cache.[cacheKey] <- observations
+ Ok observations
diff --git a/src/FundLab.Api/akshare_collector.py b/src/FundLab.Api/akshare_collector.py
index 62632bf..f9a0f06 100644
--- a/src/FundLab.Api/akshare_collector.py
+++ b/src/FundLab.Api/akshare_collector.py
@@ -269,6 +269,87 @@ def stock_from_snapshot(code):
return None
+def stock_daily_observations(frame, date_key, close_key, volume_key, amount_key):
+ rows = []
+
+ for _, row in frame.iterrows():
+ row_date = date_text(row.get(date_key))
+ close = decimal_text(row.get(close_key))
+ if row_date is None or close is None or Decimal(close) <= 0:
+ continue
+
+ rows.append(
+ {
+ "date": row_date,
+ "close": close,
+ "volume": decimal_text(row.get(volume_key)),
+ "amount": decimal_text(row.get(amount_key)),
+ }
+ )
+
+ rows.sort(key=lambda item: item["date"])
+ return rows
+
+
+def stock_market_symbol(code):
+ if code[0] in ("6", "9"):
+ return f"sh{code}"
+ if code[0] in ("0", "3", "2"):
+ return f"sz{code}"
+ if code[0] in ("4", "8"):
+ return f"bj{code}"
+ return f"sh{code}"
+
+
+def stock_daily(code, days_text):
+ code = text(code)
+ if code is None or not re.fullmatch(r"\d{6}", code):
+ raise ValueError("stock code must contain exactly six digits")
+
+ if days_text is None:
+ days = 5
+ else:
+ try:
+ days = int(days_text)
+ except (TypeError, ValueError):
+ raise ValueError("days must be an integer between 1 and 30")
+
+ if days < 1 or days > 30:
+ raise ValueError("days must be an integer between 1 and 30")
+
+ symbol = stock_market_symbol(code)
+ providers = [
+ ((ak.stock_zh_a_hist, {"symbol": code, "period": "daily", "adjust": ""}), "日期", "收盘", "成交量", "成交额"),
+ ((ak.stock_zh_a_daily, {"symbol": symbol, "adjust": ""}), "date", "close", "volume", "amount"),
+ ((ak.stock_zh_a_hist_tx, {"symbol": symbol}), "date", "close", "volume", "amount"),
+ ]
+
+ for (fetch, kwargs), date_key, close_key, volume_key, amount_key in providers:
+ try:
+ frame = fetch(**kwargs)
+ except Exception:
+ continue
+
+ if frame is None or frame.empty:
+ continue
+
+ observations = stock_daily_observations(frame, date_key, close_key, volume_key, amount_key)
+ if not observations:
+ continue
+
+ return {
+ "schema_version": SCHEMA_VERSION,
+ "operation": "stock-daily",
+ "source": "akshare",
+ "source_revision": source_revision(),
+ "collected_at": collected_at(),
+ "instrument": {"code": code},
+ "observations": observations[-days:],
+ }
+
+ raise ValueError(f"no valid daily history for {code}")
+
+
def stock_quote(code):
code = text(code)
if code is None or not re.fullmatch(r"\d{6}", code):
@@ -305,9 +386,10 @@ def stock_quote(code):
def main():
parser = argparse.ArgumentParser()
- parser.add_argument("--operation", choices=("search", "nav", "bond-quote", "stock-quote"), required=True)
+ parser.add_argument("--operation", choices=("search", "nav", "bond-quote", "stock-quote", "stock-daily"), required=True)
parser.add_argument("--query")
parser.add_argument("--code")
+ parser.add_argument("--days")
args = parser.parse_args()
try:
@@ -317,8 +399,10 @@ def main():
payload = nav(args.code)
elif args.operation == "bond-quote":
payload = bond_quote(args.code)
- else:
+ elif args.operation == "stock-quote":
payload = stock_quote(args.code)
+ else:
+ payload = stock_daily(args.code, args.days)
json.dump(payload, sys.stdout, ensure_ascii=False, separators=(",", ":"))
sys.stdout.write("\n")
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index 9b4bed9..7a0e56c 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -408,6 +408,20 @@ type RawStockQuote =
currency: string
}
+type RawStockDailyObservation =
+ {
+ date: string
+ close: string
+ volume: obj
+ amount: obj
+ }
+
+type RawStockDaily =
+ {
+ code: string
+ observations: RawStockDailyObservation array
+ }
+
type CreateAttempt =
{
idempotencyKey: string
@@ -724,6 +738,14 @@ type StockQuoteView =
currency: string
}
+type StockDailyBar =
+ {
+ date: string
+ close: string
+ volume: string option
+ amount: string option
+ }
+
module Api =
[<Import("searchInstruments", "./src/api.js")>]
let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative
@@ -779,6 +801,9 @@ module Api =
[<Import("getStockQuote", "./src/api.js")>]
let getStockQuote (token: string) (code: string) : JS.Promise<RawStockQuote> = jsNative
+ [<Import("getStockDaily", "./src/api.js")>]
+ let getStockDaily (token: string) (code: string) (days: int) : JS.Promise<RawStockDaily> = jsNative
+
[<Import("createCapitalDeposit", "./src/api.js")>]
let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative
@@ -1067,6 +1092,14 @@ module Api =
currency = raw.currency
}
+ let decodeStockDailyBar (raw: RawStockDailyObservation) : StockDailyBar =
+ {
+ date = raw.date
+ close = raw.close
+ volume = decodeOptionalText raw.volume
+ amount = decodeOptionalText raw.amount
+ }
+
type Model =
{
token: string
@@ -1160,6 +1193,10 @@ type Model =
stockQuoteInFlight: bool
stockQuote: StockQuoteView option
stockQuoteMessage: string option
+ stockDailySeq: int
+ stockDailyInFlight: bool
+ stockDaily: StockDailyBar list
+ stockDailyMessage: string option
returnsSelectedDate: string option
planReadSeq: int
planInFlight: bool
@@ -1268,6 +1305,9 @@ type Msg =
| StockQuoteRequested
| StockQuoteCompleted of requestId: int * quote: RawStockQuote
| StockQuoteFailed of requestId: int * message: string
+ | StockDailyRequested
+ | StockDailyCompleted of requestId: int * daily: RawStockDaily
+ | StockDailyFailed of requestId: int * message: string
| ReturnsDateChanged of string
| InvestmentPlansReadRequested
| InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array
@@ -1397,6 +1437,10 @@ let init () =
stockQuoteInFlight = false
stockQuote = None
stockQuoteMessage = None
+ stockDailySeq = 0
+ stockDailyInFlight = false
+ stockDaily = []
+ stockDailyMessage = None
returnsSelectedDate = None
planReadSeq = 0
planInFlight = false
@@ -1595,6 +1639,13 @@ let private readStockQuoteCommand token code requestId =
(fun quote -> StockQuoteCompleted(requestId, quote))
(fun error -> StockQuoteFailed(requestId, errorText error))
+let private readStockDailyCommand token code days requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.getStockDaily token code days)
+ ()
+ (fun daily -> StockDailyCompleted(requestId, daily))
+ (fun error -> StockDailyFailed(requestId, errorText error))
+
let private readInvestmentPlansCommand token fundId requestId =
Cmd.OfPromise.either
(fun () -> Api.getInvestmentPlans token fundId)
@@ -2909,6 +2960,48 @@ let update message model =
Cmd.none
else
model, Cmd.none
+ | StockDailyRequested ->
+ let code = model.stockCode.Trim()
+
+ if String.IsNullOrWhiteSpace model.token then
+ { model with stockDailyMessage = Some "请输入 API token"; stockDaily = [] }, Cmd.none
+ elif String.IsNullOrWhiteSpace code then
+ { model with stockDailyMessage = Some "请输入六位股票代码"; stockDaily = [] }, Cmd.none
+ else
+ let requestId = model.stockDailySeq + 1
+
+ {
+ model with
+ stockDailySeq = requestId
+ stockDailyInFlight = true
+ stockDailyMessage = None
+ stockDaily = []
+ },
+ readStockDailyCommand model.token code 5 requestId
+ | StockDailyCompleted (requestId, daily) ->
+ if requestId = model.stockDailySeq then
+ let rows = daily.observations |> Array.toList |> List.map Api.decodeStockDailyBar
+
+ {
+ model with
+ stockDaily = rows
+ stockDailyMessage = (if List.isEmpty rows then Some "股票日线不可用 — 暂无数据" else None)
+ stockDailyInFlight = false
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | StockDailyFailed (requestId, message) ->
+ if requestId = model.stockDailySeq then
+ {
+ model with
+ stockDaily = []
+ stockDailyMessage = Some(sprintf "股票日线不可用 — %s" message)
+ stockDailyInFlight = false
+ },
+ Cmd.none
+ else
+ model, Cmd.none
| ReturnsDateChanged value ->
{ model with returnsSelectedDate = (if String.IsNullOrWhiteSpace value then None else Some value) }, Cmd.none
| InvestmentPlansReadRequested ->
@@ -4709,6 +4802,12 @@ let private stocksPanel model dispatch =
prop.onClick (fun _ -> dispatch StockQuoteRequested)
prop.text ((if model.stockQuoteInFlight then "查询中..." else "查询行情"): string)
]
+ Html.button [
+ prop.className "secondary-action stock-daily-action"
+ prop.disabled model.stockDailyInFlight
+ prop.onClick (fun _ -> dispatch StockDailyRequested)
+ prop.text ((if model.stockDailyInFlight then "读取中..." else "查询近5日日线"): string)
+ ]
]
]
Html.p [
@@ -4733,6 +4832,27 @@ let private stocksPanel model dispatch =
]
| None ->
Html.p [ prop.className "hint"; prop.text "查询后显示名称/价格/币种;缺失字段以「—」表示。" ]
+ Html.div [
+ prop.className "stock-daily"
+ prop.hidden (List.isEmpty model.stockDaily)
+ prop.children (
+ Html.p [ prop.className "returns-subheading"; prop.text "近 5 个交易日收盘" ]
+ :: (model.stockDaily
+ |> List.map (fun bar ->
+ Html.div [
+ prop.className "order-row stock-daily-row"
+ prop.children [
+ Html.span [ prop.className "fund-detail-label"; prop.text bar.date ]
+ Html.span [ prop.className "fund-detail-value"; prop.text bar.close ]
+ ]
+ ]))
+ )
+ ]
+ Html.p [
+ prop.className "stock-daily-unavailable"
+ prop.hidden (model.stockDailyMessage.IsNone)
+ prop.text (model.stockDailyMessage |> Option.defaultValue "")
+ ]
]
]
diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js
index 7b7e1b9..304c79f 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -143,6 +143,10 @@ export function getStockQuote(token, code) {
return requestJson(`/api/market/stock-quote?code=${encodeURIComponent(code)}`, token);
}
+export function getStockDaily(token, code, days) {
+ return requestJson(`/api/market/stock-daily?code=${encodeURIComponent(code)}&days=${days}`, 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 17326b5..c0ac4a4 100644
--- a/src/FundLab.Web/src/styles.css
+++ b/src/FundLab.Web/src/styles.css
@@ -594,6 +594,11 @@ h2 {
font-size: 12px;
}
+.stock-daily-unavailable {
+ color: #b45309;
+ font-size: 12px;
+}
+
.returns-pending {
color: #b45309;
font-size: 12px;
diff --git a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs
index c9821dc..bed590a 100644
--- a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs
+++ b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs
@@ -24,7 +24,8 @@ module BondQuoteProbeSupport =
member _.Search(_, _) = Ok ""
member _.FetchNav(_, _) = Ok ""
member _.FetchBondQuote(_, _) = fetch ()
- member _.FetchStockQuote(_, _) = Ok "" }
+ member _.FetchStockQuote(_, _) = Ok ""
+ member _.FetchStockDaily(_, _, _) = Ok "" }
let stubProbe (getQuote: string * CancellationToken -> Result<BondQuote, MarketDataFailure>) =
{ new IBondQuoteProbe with
@@ -40,7 +41,8 @@ type BondQuoteProbeTests(fixture: PostgresFixture) =
{ NavDates = NavDateProbeSupport.stubNavDateProbe
NavSeries = NavDateProbeSupport.stubNavSeriesProbe
BondQuotes = probe
- StockQuotes = NavDateProbeSupport.stubStockQuoteProbe }
+ StockQuotes = NavDateProbeSupport.stubStockQuoteProbe
+ StockDaily = NavDateProbeSupport.stubStockDailyProbe }
[<Fact>]
member _.``probe parses name, price and maturity date``() =
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index 755cc1d..83c96bc 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -33,6 +33,7 @@
<Compile Include="NavSeriesProbeTests.fs" />
<Compile Include="BondQuoteProbeTests.fs" />
<Compile Include="StockQuoteProbeTests.fs" />
+ <Compile Include="StockDailyProbeTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
diff --git a/tests/FundLab.Api.Tests/NavDateProbeTests.fs b/tests/FundLab.Api.Tests/NavDateProbeTests.fs
index cc513d7..12ff84a 100644
--- a/tests/FundLab.Api.Tests/NavDateProbeTests.fs
+++ b/tests/FundLab.Api.Tests/NavDateProbeTests.fs
@@ -29,7 +29,8 @@ module NavDateProbeSupport =
member _.Search(_, _) = Ok ""
member _.FetchNav(_, _) = fetch ()
member _.FetchBondQuote(_, _) = Ok ""
- member _.FetchStockQuote(_, _) = Ok "" }
+ member _.FetchStockQuote(_, _) = Ok ""
+ member _.FetchStockDaily(_, _, _) = Ok "" }
let stubBondQuoteProbe =
{ new IBondQuoteProbe with
@@ -39,6 +40,10 @@ module NavDateProbeSupport =
{ new IStockQuoteProbe with
member _.GetQuote(_, _) = Error(InvalidMarketDataRequest "stock probe not configured for this test") }
+ let stubStockDailyProbe =
+ { new IStockDailyProbe with
+ member _.RecentDaily(_, _, _) = Error(InvalidMarketDataRequest "stock daily probe not configured for this test") }
+
let stubNavDateProbe =
{ new INavDateProbe with
member _.RecentNavDates(_, _, _) = Ok [] }
@@ -99,7 +104,8 @@ type NavDateProbeTests(fixture: PostgresFixture) =
{ NavDates = probe
NavSeries = stubSeriesProbe
BondQuotes = NavDateProbeSupport.stubBondQuoteProbe
- StockQuotes = NavDateProbeSupport.stubStockQuoteProbe }
+ StockQuotes = NavDateProbeSupport.stubStockQuoteProbe
+ StockDaily = NavDateProbeSupport.stubStockDailyProbe }
[<Fact>]
member _.``probe parses, dedupes, sorts descending and truncates``() =
diff --git a/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs b/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs
index 81d1733..19a6a50 100644
--- a/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs
+++ b/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs
@@ -41,7 +41,8 @@ type NavSeriesProbeTests(fixture: PostgresFixture) =
{ NavDates = stubDateProbe
NavSeries = probe
BondQuotes = NavDateProbeSupport.stubBondQuoteProbe
- StockQuotes = NavDateProbeSupport.stubStockQuoteProbe }
+ StockQuotes = NavDateProbeSupport.stubStockQuoteProbe
+ StockDaily = NavDateProbeSupport.stubStockDailyProbe }
[<Fact>]
member _.``series parses points descending, keeps accumulated nav, drops zero navs and truncates``() =
diff --git a/tests/FundLab.Api.Tests/StockDailyProbeTests.fs b/tests/FundLab.Api.Tests/StockDailyProbeTests.fs
new file mode 100644
index 0000000..d8f8ee4
--- /dev/null
+++ b/tests/FundLab.Api.Tests/StockDailyProbeTests.fs
@@ -0,0 +1,198 @@
+namespace FundLab.Api.Tests
+
+open System
+open System.Threading
+open Xunit
+open FundLab.Api
+
+module StockDailyProbeSupport =
+ let payload (code: string) (observations: (string * string * string option * string option) list) =
+ let body =
+ observations
+ |> List.map (fun (date, close, volume, amount) ->
+ let optionalText =
+ function
+ | Some value -> sprintf "\"%s\"" value
+ | None -> "null"
+
+ sprintf
+ "{\"date\":\"%s\",\"close\":\"%s\",\"volume\":%s,\"amount\":%s}"
+ date
+ close
+ (optionalText volume)
+ (optionalText amount))
+ |> String.concat ","
+
+ sprintf
+ "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"stock-daily\",\"source\":\"akshare\",\"source_revision\":\"akshare-test/eastmoney\",\"collected_at\":\"2026-09-21T08:00:00Z\",\"instrument\":{\"code\":\"%s\"},\"observations\":[%s]}"
+ code
+ body
+
+ let collectorReturning (fetch: unit -> Result<string, string>) =
+ { new IMarketDataCollector with
+ member _.Search(_, _) = Ok ""
+ member _.FetchNav(_, _) = Ok ""
+ member _.FetchBondQuote(_, _) = Ok ""
+ member _.FetchStockQuote(_, _) = Ok ""
+ member _.FetchStockDaily(_, _, _) = fetch () }
+
+ let stubProbe (recent: string * int * CancellationToken -> Result<StockDailyObservation list, MarketDataFailure>) =
+ { new IStockDailyProbe with
+ member _.RecentDaily(code, days, token) = recent (code, days, token) }
+
+[<Collection("postgres")>]
+type StockDailyProbeTests(fixture: PostgresFixture) =
+ let repository () = FundRepository(fixture.ConnectionString)
+
+ let appWithDaily (probe: IStockDailyProbe) =
+ App.createApplicationWithProbes
+ (repository ())
+ { NavDates = NavDateProbeSupport.stubNavDateProbe
+ NavSeries = NavDateProbeSupport.stubNavSeriesProbe
+ BondQuotes = NavDateProbeSupport.stubBondQuoteProbe
+ StockQuotes = NavDateProbeSupport.stubStockQuoteProbe
+ StockDaily = probe }
+
+ [<Fact>]
+ member _.``probe decodes observations ascending and de-duplicates dates``() =
+ let json =
+ StockDailyProbeSupport.payload
+ "600519"
+ [
+ "2026-09-21", "1252.57", Some "2501689.0", Some "3135910045.0"
+ "2026-09-17", "1266.98", Some "1755380.0", Some "2217338283.0"
+ "2026-09-18", "1257.12", Some "2489087.0", Some "3135849108.0"
+ "2026-09-17", "1266.98", Some "1755380.0", Some "2217338283.0"
+ ]
+
+ let probe =
+ AkshareStockDailyProbe(StockDailyProbeSupport.collectorReturning (fun () -> Ok json)) :> IStockDailyProbe
+
+ match probe.RecentDaily("600519", 5, CancellationToken.None) with
+ | Ok observations ->
+ Assert.Equal<DateOnly list>(
+ [ DateOnly(2026, 9, 17); DateOnly(2026, 9, 18); DateOnly(2026, 9, 21) ],
+ observations |> List.map (fun observation -> observation.BarDate)
+ )
+
+ Assert.Equal(3, List.length observations)
+ Assert.Equal<decimal option>(Some 3135910045.0m, (observations |> List.last).Amount)
+ | Error failure -> failwithf "expected daily observations, got %A" failure
+
+ [<Fact>]
+ member _.``probe rejects a payload whose code disagrees with the request``() =
+ let json = StockDailyProbeSupport.payload "000001" [ "2026-09-21", "1252.57", None, None ]
+
+ let probe =
+ AkshareStockDailyProbe(StockDailyProbeSupport.collectorReturning (fun () -> Ok json)) :> IStockDailyProbe
+
+ match probe.RecentDaily("600519", 5, CancellationToken.None) with
+ | Error(InvalidMarketDataPayload message) -> Assert.Contains("does not match requested code", message)
+ | other -> failwithf "expected InvalidMarketDataPayload, got %A" other
+
+ [<Fact>]
+ member _.``probe rejects a malformed stock code without touching the collector``() =
+ let probe =
+ AkshareStockDailyProbe(StockDailyProbeSupport.collectorReturning (fun () -> failwith "collector should not be called"))
+ :> IStockDailyProbe
+
+ match probe.RecentDaily("abc", 5, CancellationToken.None) with
+ | Error(InvalidMarketDataRequest message) -> Assert.Contains("six digits", message)
+ | other -> failwithf "expected InvalidMarketDataRequest, got %A" other
+
+ [<Fact>]
+ member _.``probe surfaces a no valid history collector failure``() =
+ let probe =
+ AkshareStockDailyProbe(
+ StockDailyProbeSupport.collectorReturning (fun () -> Error "AKShare collector failed: no valid daily history for 999999")
+ )
+ :> IStockDailyProbe
+
+ match probe.RecentDaily("999999", 5, CancellationToken.None) with
+ | Error(MarketDataCollectorUnavailable message) -> Assert.Contains("no valid daily history for 999999", message)
+ | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other
+
+ [<Fact>]
+ member _.``probe maps malformed payloads to an invalid payload reason``() =
+ let probe =
+ AkshareStockDailyProbe(StockDailyProbeSupport.collectorReturning (fun () -> Ok "{not-json")) :> IStockDailyProbe
+
+ match probe.RecentDaily("600519", 5, CancellationToken.None) with
+ | Error(InvalidMarketDataPayload _) -> ()
+ | other -> failwithf "expected InvalidMarketDataPayload, got %A" other
+
+ [<Fact>]
+ member _.``market stock-daily endpoint returns the probe observations``() =
+ let probe =
+ StockDailyProbeSupport.stubProbe (fun (code, days, _) ->
+ Assert.Equal("600519", code)
+ Assert.Equal(5, days)
+
+ Ok
+ [
+ { BarDate = DateOnly(2026, 9, 18); Close = 1257.12m; Volume = Some 2489087m; Amount = Some 3135849108m }
+ { BarDate = DateOnly(2026, 9, 21); Close = 1252.57m; Volume = Some 2501689m; Amount = Some 3135910045m }
+ ])
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithDaily probe)
+ "GET"
+ "/api/market/stock-daily?code=600519&days=5"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"code\":\"600519\"", body)
+ Assert.Contains("\"date\":\"2026-09-21\"", body)
+ Assert.Contains("1252.57", body)
+
+ [<Fact>]
+ member _.``market stock-daily endpoint answers 503 when the probe fails``() =
+ let probe =
+ StockDailyProbeSupport.stubProbe (fun _ ->
+ Error(MarketDataCollectorUnavailable "AKShare collector failed: no valid daily history for 999999"))
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithDaily probe)
+ "GET"
+ "/api/market/stock-daily?code=999999&days=5"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(503, status)
+ Assert.Contains("MARKET_DATA_UNAVAILABLE", body)
+
+ [<Fact>]
+ member _.``market stock-daily endpoint rejects a malformed code with 400``() =
+ let probe =
+ StockDailyProbeSupport.stubProbe (fun _ ->
+ Error(InvalidMarketDataRequest "stock code must contain exactly six digits"))
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithDaily probe)
+ "GET"
+ "/api/market/stock-daily?code=abc&days=5"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(400, status)
+ Assert.Contains("INVALID_MARKET_DATA_REQUEST", body)
+
+ [<Fact>]
+ member _.``market stock-daily endpoint rejects an out-of-range days with 400``() =
+ let probe =
+ StockDailyProbeSupport.stubProbe (fun _ -> failwith "probe should not be called for invalid days")
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithDaily probe)
+ "GET"
+ "/api/market/stock-daily?code=600519&days=999"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(400, status)
+ Assert.Contains("days must be an integer between 1 and 30", body)
diff --git a/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs b/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs
index e29c666..2cad557 100644
--- a/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs
+++ b/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs
@@ -22,7 +22,8 @@ module StockQuoteProbeSupport =
member _.Search(_, _) = Ok ""
member _.FetchNav(_, _) = Ok ""
member _.FetchBondQuote(_, _) = Ok ""
- member _.FetchStockQuote(_, _) = fetch () }
+ member _.FetchStockQuote(_, _) = fetch ()
+ member _.FetchStockDaily(_, _, _) = Ok "" }
let stubProbe (getQuote: string * CancellationToken -> Result<StockQuote, MarketDataFailure>) =
{ new IStockQuoteProbe with
@@ -38,7 +39,8 @@ type StockQuoteProbeTests(fixture: PostgresFixture) =
{ NavDates = NavDateProbeSupport.stubNavDateProbe
NavSeries = NavDateProbeSupport.stubNavSeriesProbe
BondQuotes = NavDateProbeSupport.stubBondQuoteProbe
- StockQuotes = probe }
+ StockQuotes = probe
+ StockDaily = NavDateProbeSupport.stubStockDailyProbe }
[<Fact>]
member _.``probe parses name, price and currency``() =
diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs
index 3001273..4714632 100644
--- a/tests/FundLab.Web.Tests/BoundaryTests.fs
+++ b/tests/FundLab.Web.Tests/BoundaryTests.fs
@@ -522,6 +522,39 @@ module ConfirmationBoundaryTests =
Assert.Contains("无效代码", updated.stockQuoteMessage |> Option.defaultValue "")
[<Fact>]
+ let ``stock daily completion stores ascending rows`` () =
+ let model = init ()
+
+ let raw : RawStockDaily =
+ {
+ code = "600519"
+ observations =
+ [|
+ { date = "2026-09-17"; close = "1266.98"; volume = null; amount = null }
+ { date = "2026-09-18"; close = "1257.12"; volume = null; amount = null }
+ { date = "2026-09-21"; close = "1252.57"; volume = null; amount = null }
+ |]
+ }
+
+ let updated, _ = update (StockDailyCompleted(model.stockDailySeq, raw)) model
+
+ Assert.Equal(3, updated.stockDaily.Length)
+ Assert.Equal("2026-09-17", updated.stockDaily[0].date)
+ Assert.Equal("1252.57", updated.stockDaily[2].close)
+ Assert.Equal(None, updated.stockDailyMessage)
+
+ [<Fact>]
+ let ``stock daily failure shows an unavailable message without rows`` () =
+ let model = { init () with stockDailyInFlight = true }
+
+ let updated, _ =
+ update (StockDailyFailed(model.stockDailySeq, "AKShare collector failed: no valid daily history for 999999")) model
+
+ Assert.Empty updated.stockDaily
+ Assert.Contains("股票日线不可用", updated.stockDailyMessage |> Option.defaultValue "")
+ Assert.False(updated.stockDailyInFlight)
+
+ [<Fact>]
let ``order decode keeps decimal strings and decodes option fields`` () =
let confirmed = Api.decodeOrder (sampleConfirmedRaw ())
let pending = Api.decodeOrder (samplePendingRaw ())