summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/MarketData.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 01:40:10 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 01:40:10 +0800
commitbe1afd248e4cc9b03a1e21090602a945af6988b2 (patch)
treed31bd6e6c1736ab4ad784b52ebc5010ade074439 /src/FundLab.Api/MarketData.fs
parent2ada70d6467aec11b45328112d598454ca50f2d6 (diff)
downloadfund-lab-be1afd248e4cc9b03a1e21090602a945af6988b2.tar.gz
feat(app): 接入 AKShare 行情采集、净值查询与前端净值曲线
[变更性质] - 本提交完成 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 的现有修改。
Diffstat (limited to 'src/FundLab.Api/MarketData.fs')
-rw-r--r--src/FundLab.Api/MarketData.fs294
1 files changed, 294 insertions, 0 deletions
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<unit, string> = Ok ()
+ member _.Combine(first: Result<unit, string>, 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<JsonElement>
+
+ 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<MarketDataSearchPayload, string> =
+ 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<MarketDataNavPayload, string> =
+ 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"