From be1afd248e4cc9b03a1e21090602a945af6988b2 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 01:40:10 +0800 Subject: feat(app): 接入 AKShare 行情采集、净值查询与前端净值曲线 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [变更性质] - 本提交完成 3b 行情闭环:真实 AKShare 搜索与净值采集、PostgreSQL 持久化、鉴权 API 与 Fable 前端净值曲线展示。 [新增功能] - API 新增 /api/instruments/search、/api/instruments/{code}/nav 与 nav/refresh 接口,经 Bearer 鉴权调用 AKShare Python 采集器并幂等落库。 - 前端提供基金搜索、精确代码选择、历史净值刷新/重读与 SVG 折线图,token 变更即清空私有结果并失效在途请求。 [实现方案] - MarketData 以 requiredProperty 校验采集器 payload,净值观测按 (code, nav_date) 幂等 upsert 并保留来源与哈希。 - 前端以 requestId 序列守卫 SearchCompleted/SearchFailed/NavCompleted/NavFailed,TokenChanged 同时递增 searchSeq/navSeq 拒绝过期响应;边界解码兼容 F# option 的 {"case":"Some"} 线格式。 [影响范围] - 新增 FundLab.Web.Tests(边界解码、序列失效、图表纯函数共 8 项),扩展 API 测试至 20 项;vite dev 代理 /api 至本地 API。 - 真实 AKShare 端到端依赖本机 akshare 环境,由负责人另行验收;本提交不包含 docs/overnight-progress.md 的现有修改。 --- src/FundLab.Api/MarketData.fs | 294 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 src/FundLab.Api/MarketData.fs (limited to 'src/FundLab.Api/MarketData.fs') diff --git a/src/FundLab.Api/MarketData.fs b/src/FundLab.Api/MarketData.fs new file mode 100644 index 0000000..e396a7d --- /dev/null +++ b/src/FundLab.Api/MarketData.fs @@ -0,0 +1,294 @@ +namespace FundLab.Api + +open System +open System.Globalization +open System.Text.Json + +type MarketDataInstrument = + { + Code: string + Name: string + FundType: string option + } + +type MarketDataObservation = + { + NavDate: DateOnly + PublishedAt: DateTimeOffset option + Nav: decimal + AccumulatedNav: decimal option + DailyReturn: decimal option + } + +type MarketDataSearchPayload = + { + Source: string + SourceRevision: string + CollectedAt: DateTimeOffset + Instruments: MarketDataInstrument list + } + +type MarketDataNavPayload = + { + Source: string + SourceRevision: string + CollectedAt: DateTimeOffset + Code: string + Observations: MarketDataObservation list + } + +type MarketDataInstrumentRecord = + { + Code: string + Name: string + FundType: string option + Source: string + SourceRevision: string + SourceCollectedAt: DateTimeOffset + SourcePayloadHash: string + FirstSeenAt: DateTimeOffset + LastSeenAt: DateTimeOffset + } + +type MarketDataNavRecord = + { + Code: string + NavDate: DateOnly + PublishedAt: DateTimeOffset option + Nav: decimal + AccumulatedNav: decimal option + DailyReturn: decimal option + Source: string + SourceRevision: string + SourceCollectedAt: DateTimeOffset + SourcePayloadHash: string + FirstSeenAt: DateTimeOffset + LastSeenAt: DateTimeOffset + } + +module MarketData = + type private ResultBuilder() = + member _.Bind(value, binder) = Result.bind binder value + member _.Return(value) = Ok value + member _.ReturnFrom(value) = value + member _.Zero() : Result = Ok () + member _.Combine(first: Result, second: unit -> Result<'a, string>) = + match first with + | Ok () -> second () + | Error message -> Error message + member _.Delay(generator: unit -> Result<'a, string>) = generator + member _.Run(generator: unit -> Result<'a, string>) = generator () + + let private result = ResultBuilder() + let private invariant = CultureInfo.InvariantCulture + + let private requiredProperty (root: JsonElement) (name: string) = + let mutable property = Unchecked.defaultof + + if root.ValueKind <> JsonValueKind.Object then + Error "payload root must be a JSON object" + elif root.TryGetProperty(name, &property) then + Ok property + else + Error(sprintf "payload property '%s' is required" name) + + let private requiredString (root: JsonElement) name = + requiredProperty root name + |> Result.bind (fun property -> + if property.ValueKind = JsonValueKind.String then + let value = property.GetString() + + if String.IsNullOrWhiteSpace value then + Error(sprintf "payload property '%s' must not be empty" name) + else + Ok value + else + Error(sprintf "payload property '%s' must be a string" name)) + + let private optionalString (root: JsonElement) name = + requiredProperty root name + |> Result.bind (fun property -> + if property.ValueKind = JsonValueKind.Null then + Ok None + elif property.ValueKind = JsonValueKind.String then + let value = property.GetString() + + if String.IsNullOrWhiteSpace value then + Ok None + else + Ok(Some value) + else + Error(sprintf "payload property '%s' must be null or a string" name)) + + let private validateEnvelope root operation = + result { + let! schemaVersion = requiredString root "schema_version" + let! actualOperation = requiredString root "operation" + let! source = requiredString root "source" + let! sourceRevision = requiredString root "source_revision" + let! collectedAtText = requiredString root "collected_at" + + if schemaVersion <> "fund-lab.akshare.v1" then + return! Error(sprintf "unsupported schema_version '%s'" schemaVersion) + + if actualOperation <> operation then + return! Error(sprintf "payload operation must be '%s'" operation) + + match DateTimeOffset.TryParse(collectedAtText, invariant, DateTimeStyles.RoundtripKind) with + | true, collectedAt -> + return source, sourceRevision, collectedAt + | false, _ -> + return! Error "collected_at must be an ISO-8601 timestamp" + } + + let private isFundCode (value: string) = + value.Length = 6 && value |> Seq.forall Char.IsDigit + + let private parseFundCode root = + requiredString root "code" + |> Result.bind (fun code -> + if isFundCode code then + Ok code + else + Error "fund code must contain exactly six digits") + + let private parseDate text = + let mutable date = DateOnly.MinValue + + if DateOnly.TryParseExact(text, "yyyy-MM-dd", invariant, DateTimeStyles.None, &date) then + Ok date + else + Error "nav_date must be an ISO date" + + let private parseDecimal label (property: JsonElement) = + if property.ValueKind <> JsonValueKind.String then + Error(sprintf "%s must be a decimal string" label) + else + let text = property.GetString() + + if not (String.IsNullOrWhiteSpace text) then + match Decimal.TryParse(text, NumberStyles.AllowLeadingSign ||| NumberStyles.AllowDecimalPoint, invariant) with + | true, value -> Ok value + | false, _ -> Error(sprintf "%s must be a decimal string" label) + else + Error(sprintf "%s must be a decimal string" label) + + let private optionalDecimal label (property: JsonElement) = + if property.ValueKind = JsonValueKind.Null then + Ok None + else + parseDecimal label property |> Result.map Some + + let private optionalPublishedAt (property: JsonElement) = + if property.ValueKind = JsonValueKind.Null then + Ok None + elif property.ValueKind <> JsonValueKind.String then + Error "published_at must be null or an ISO-8601 timestamp" + else + let text = property.GetString() + + if String.IsNullOrWhiteSpace text then + Error "published_at must be null or an ISO-8601 timestamp" + else + match DateTimeOffset.TryParse(text, invariant, DateTimeStyles.RoundtripKind) with + | true, value -> Ok(Some value) + | false, _ -> Error "published_at must be null or an ISO-8601 timestamp" + + let private parseArray parser (property: JsonElement) = + if property.ValueKind <> JsonValueKind.Array then + Error "payload collection must be a JSON array" + else + property.EnumerateArray() + |> Seq.fold + (fun state item -> + match state, parser item with + | Ok values, Ok value -> Ok(value :: values) + | Error message, _ -> Error message + | _, Error message -> Error message) + (Ok []) + |> Result.map List.rev + + let private parseSearchInstrument root = + result { + let! code = parseFundCode root + let! name = requiredString root "name" + let! fundType = optionalString root "fund_type" + + return + { + Code = code + Name = name + FundType = fundType + } + } + + let private parseObservation root = + result { + let! navDateText = requiredString root "nav_date" + let! navDate = parseDate navDateText + let! publishedAtProperty = requiredProperty root "published_at" + let! publishedAt = optionalPublishedAt publishedAtProperty + let! navProperty = requiredProperty root "nav" + let! nav = parseDecimal "nav" navProperty + let! accumulatedNavProperty = requiredProperty root "accumulated_nav" + let! accumulatedNav = optionalDecimal "accumulated_nav" accumulatedNavProperty + let! dailyReturnProperty = requiredProperty root "daily_return" + let! dailyReturn = optionalDecimal "daily_return" dailyReturnProperty + + return + { + NavDate = navDate + PublishedAt = publishedAt + Nav = nav + AccumulatedNav = accumulatedNav + DailyReturn = dailyReturn + } + } + + let parseSearchPayload (json: string) : Result = + try + use document = JsonDocument.Parse(json) + let root = document.RootElement + + result { + let! source, sourceRevision, collectedAt = validateEnvelope root "search" + let! instrumentsProperty = requiredProperty root "instruments" + let! instruments = parseArray parseSearchInstrument instrumentsProperty + + return + { + Source = source + SourceRevision = sourceRevision + CollectedAt = collectedAt + Instruments = instruments + } + } + with + | :? JsonException -> Error "payload must be valid JSON" + + let parseNavPayload (json: string) : Result = + try + use document = JsonDocument.Parse(json) + let root = document.RootElement + + result { + let! source, sourceRevision, collectedAt = validateEnvelope root "nav" + let! instrumentProperty = requiredProperty root "instrument" + let! code = parseFundCode instrumentProperty + let! observationsProperty = requiredProperty root "observations" + let! observations = parseArray parseObservation observationsProperty + + if List.isEmpty observations then + return! Error "observations must not be empty" + + return + { + Source = source + SourceRevision = sourceRevision + CollectedAt = collectedAt + Code = code + Observations = observations + } + } + with + | :? JsonException -> Error "payload must be valid JSON" -- cgit v1.2.3