summaryrefslogtreecommitdiff
path: root/src/FundLab.Web/App.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.Web/App.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.Web/App.fs')
-rw-r--r--src/FundLab.Web/App.fs607
1 files changed, 593 insertions, 14 deletions
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index 630a8e9..0ee4d56 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -1,41 +1,620 @@
module FundLab.Web
+open System
+open System.Globalization
open Elmish
open Elmish.React
open Feliz
+open Fable.Core
+
+type Instrument =
+ {
+ code: string
+ name: string
+ fundType: string option
+ }
+
+type NavObservation =
+ {
+ navDate: string
+ nav: string
+ dailyReturn: string option
+ }
+
+type ChartPoint =
+ {
+ x: float
+ y: float
+ navDate: string
+ nav: string
+ }
+
+module Chart =
+ let private invariant = CultureInfo.InvariantCulture
+
+ let private navValue (observation: NavObservation) =
+ Decimal.Parse(observation.nav, NumberStyles.Float, invariant)
+
+ let chartPoints (observations: NavObservation list) =
+ let ordered: NavObservation list = observations |> List.sortBy (fun observation -> observation.navDate)
+ let values = ordered |> List.map navValue
+
+ match values with
+ | [] -> []
+ | _ ->
+ let minimum = List.min values
+ let maximum = List.max values
+ let range = maximum - minimum
+ let count = List.length ordered
+
+ ordered
+ |> List.mapi (fun index observation ->
+ let value = navValue observation
+ let x =
+ if count = 1 then
+ 0.5
+ else
+ float index / float (count - 1)
+
+ let y =
+ if range = 0M then
+ 0.5
+ else
+ float ((value - minimum) / range)
+
+ {
+ x = x
+ y = y
+ navDate = observation.navDate
+ nav = observation.nav
+ })
+
+type RawOptionalText =
+ {
+ case: string
+ fields: string array
+ }
+
+type RawInstrument =
+ {
+ code: string
+ name: string
+ fundType: obj
+ }
+
+type RawSearchResponse =
+ {
+ instruments: RawInstrument array
+ }
+
+type RawObservation =
+ {
+ navDate: string
+ nav: string
+ dailyReturn: obj
+ }
+
+type RawNavResponse =
+ {
+ observations: RawObservation array
+ }
+
+module Api =
+ [<Import("searchInstruments", "./src/api.js")>]
+ let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative
+
+ [<Import("getNav", "./src/api.js")>]
+ let getNav (token: string) (code: string) : JS.Promise<RawNavResponse> = jsNative
+
+ [<Import("refreshNav", "./src/api.js")>]
+ let refreshNav (token: string) (code: string) : JS.Promise<RawNavResponse> = jsNative
+
+ let decodeOptionalText (raw: obj) : string option =
+ if isNull raw then
+ None
+ else
+ let envelope = unbox<RawOptionalText> raw
+
+ if isNull (box envelope.fields) || envelope.fields.Length = 0 then
+ None
+ else
+ Some envelope.fields[0]
+
+ let decodeInstrument (raw: RawInstrument) : Instrument =
+ {
+ code = raw.code
+ name = raw.name
+ fundType = decodeOptionalText raw.fundType
+ }
+
+ let decodeSearch (raw: RawSearchResponse) : Instrument list =
+ raw.instruments |> Array.map decodeInstrument |> List.ofArray
+
+ let decodeObservation (raw: RawObservation) : NavObservation =
+ {
+ navDate = raw.navDate
+ nav = raw.nav
+ dailyReturn = decodeOptionalText raw.dailyReturn
+ }
+
+ let decodeNav (raw: RawNavResponse) : NavObservation list =
+ raw.observations |> Array.map decodeObservation |> List.ofArray
type Model =
{
- hasFund: bool
+ token: string
+ query: string
+ searchSeq: int
+ navSeq: int
+ searchResults: Instrument list
+ selected: Instrument option
+ observations: NavObservation list
+ searchInFlight: bool
+ navInFlight: bool
+ error: string option
}
type Msg =
- | CreateFundRequested
+ | TokenChanged of string
+ | QueryChanged of string
+ | SearchRequested
+ | SearchCompleted of requestId: int * instruments: Instrument list
+ | SearchFailed of requestId: int * message: string
+ | InstrumentSelected of Instrument
+ | LoadNavRequested
+ | RefreshNavRequested
+ | NavCompleted of requestId: int * observations: NavObservation list
+ | NavFailed of requestId: int * message: string
let init () =
- { hasFund = false }
+ {
+ token = ""
+ query = ""
+ searchSeq = 0
+ navSeq = 0
+ searchResults = []
+ selected = None
+ observations = []
+ searchInFlight = false
+ navInFlight = false
+ error = None
+ }
+
+let private errorText (error: exn) =
+ if String.IsNullOrWhiteSpace error.Message then
+ "请求失败"
+ else
+ error.Message
+
+let private searchCommand token query requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.searchInstruments token query)
+ ()
+ (fun raw -> SearchCompleted(requestId, Api.decodeSearch raw))
+ (fun error -> SearchFailed(requestId, errorText error))
+
+let private navCommand token code requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.getNav token code)
+ ()
+ (fun raw -> NavCompleted(requestId, Api.decodeNav raw))
+ (fun error -> NavFailed(requestId, errorText error))
+
+let private refreshCommand token code requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.refreshNav token code)
+ ()
+ (fun raw -> NavCompleted(requestId, Api.decodeNav raw))
+ (fun error -> NavFailed(requestId, errorText error))
let update message model =
match message with
- | CreateFundRequested -> model
+ | TokenChanged token ->
+ {
+ model with
+ token = token
+ searchSeq = model.searchSeq + 1
+ navSeq = model.navSeq + 1
+ searchResults = []
+ selected = None
+ observations = []
+ searchInFlight = false
+ navInFlight = false
+ error = None
+ },
+ Cmd.none
+ | QueryChanged query ->
+ { model with query = query; error = None }, Cmd.none
+ | SearchRequested ->
+ let query = model.query.Trim()
+
+ if String.IsNullOrWhiteSpace model.token then
+ { model with error = Some "请输入 API token" }, Cmd.none
+ elif String.IsNullOrWhiteSpace query then
+ { model with error = Some "请输入基金名称或六位代码" }, Cmd.none
+ else
+ let requestId = model.searchSeq + 1
+
+ {
+ model with
+ query = query
+ searchSeq = requestId
+ searchInFlight = true
+ error = None
+ },
+ searchCommand model.token query requestId
+ | SearchCompleted (requestId, instruments) ->
+ if requestId = model.searchSeq then
+ {
+ model with
+ searchResults = instruments
+ searchInFlight = false
+ error = None
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | SearchFailed (requestId, message) ->
+ if requestId = model.searchSeq then
+ { model with searchInFlight = false; error = Some message }, Cmd.none
+ else
+ model, Cmd.none
+ | InstrumentSelected instrument ->
+ if String.IsNullOrWhiteSpace model.token then
+ { model with error = Some "请输入 API token" }, Cmd.none
+ else
+ let requestId = model.navSeq + 1
+
+ {
+ model with
+ selected = Some instrument
+ observations = []
+ navSeq = requestId
+ navInFlight = true
+ error = None
+ },
+ navCommand model.token instrument.code requestId
+ | LoadNavRequested ->
+ match model.selected with
+ | Some instrument when not (String.IsNullOrWhiteSpace model.token) ->
+ let requestId = model.navSeq + 1
+
+ {
+ model with
+ navSeq = requestId
+ navInFlight = true
+ error = None
+ },
+ navCommand model.token instrument.code requestId
+ | Some _ ->
+ { model with error = Some "请输入 API token" }, Cmd.none
+ | None ->
+ { model with error = Some "请先选择一个基金" }, Cmd.none
+ | RefreshNavRequested ->
+ match model.selected with
+ | Some instrument when not (String.IsNullOrWhiteSpace model.token) ->
+ let requestId = model.navSeq + 1
+
+ {
+ model with
+ navSeq = requestId
+ navInFlight = true
+ error = None
+ },
+ refreshCommand model.token instrument.code requestId
+ | Some _ ->
+ { model with error = Some "请输入 API token" }, Cmd.none
+ | None ->
+ { model with error = Some "请先选择一个基金" }, Cmd.none
+ | NavCompleted (requestId, observations) ->
+ if requestId = model.navSeq then
+ {
+ model with
+ observations = observations |> List.sortBy (fun observation -> observation.navDate)
+ navInFlight = false
+ error = None
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | NavFailed (requestId, message) ->
+ if requestId = model.navSeq then
+ { model with navInFlight = false; error = Some message }, Cmd.none
+ else
+ model, Cmd.none
+
+let private navText (text: string) =
+ match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with
+ | true, value -> value.ToString("0.00000000", CultureInfo.InvariantCulture)
+ | false, _ -> text
+
+let private latestStats (observations: NavObservation list) =
+ let ordered: NavObservation list = observations |> List.sortBy (fun observation -> observation.navDate)
+
+ match ordered with
+ | [] -> None
+ | ordered ->
+ let first = List.head ordered
+ let latest = List.last ordered
+
+ Some(
+ navText latest.nav,
+ latest.dailyReturn |> Option.map (fun value -> value + "%"),
+ first.navDate,
+ latest.navDate
+ )
+
+let private chartView (observations: NavObservation list) =
+ let points = Chart.chartPoints observations
+
+ if List.isEmpty points then
+ Html.div [ prop.className "chart-empty"; prop.text "选择基金后加载历史净值" ]
+ else
+ let width = 960.0
+ let height = 320.0
+ let padding = 28.0
+ let chartWidth = width - padding * 2.0
+ let chartHeight = height - padding * 2.0
+
+ let coordinates =
+ points
+ |> List.map (fun point ->
+ padding + point.x * chartWidth,
+ padding + (1.0 - point.y) * chartHeight)
+
+ let gridLines =
+ [ 0 .. 4 ]
+ |> List.map (fun index ->
+ let y = padding + float index / 4.0 * chartHeight
+
+ Svg.line [
+ svg.x1 padding
+ svg.y1 y
+ svg.x2 (width - padding)
+ svg.y2 y
+ svg.stroke "#e2e8f0"
+ svg.strokeWidth 1
+ ])
+
+ let markers =
+ coordinates
+ |> List.mapi (fun index (x, y) ->
+ Svg.circle [
+ svg.cx x
+ svg.cy y
+ svg.r 3.5
+ svg.fill "#f59e0b"
+ ])
+
+ Html.div [
+ prop.className "chart-wrap"
+ prop.children [
+ Svg.svg [
+ svg.className "nav-chart"
+ svg.viewBox (0, 0, 960, 320)
+ svg.children [
+ yield! gridLines
+ Svg.polyline [
+ svg.points coordinates
+ svg.fill "none"
+ svg.stroke "#1e40af"
+ svg.strokeWidth 3
+ ]
+ yield! markers
+ ]
+ ]
+ Html.div [
+ prop.className "chart-axis"
+ prop.children [
+ Html.span [ prop.text (points |> List.head |> fun point -> point.navDate) ]
+ Html.span [ prop.text (points |> List.last |> fun point -> point.navDate) ]
+ ]
+ ]
+ ]
+ ]
+
+let private resultRow instrument dispatch =
+ Html.button [
+ prop.className "result-row"
+ prop.onClick (fun _ -> dispatch (InstrumentSelected instrument))
+ prop.children [
+ Html.span [ prop.className "result-code"; prop.text instrument.code ]
+ Html.span [ prop.className "result-name"; prop.text instrument.name ]
+ Html.span [ prop.className "result-type"; prop.text (instrument.fundType |> Option.defaultValue "基金") ]
+ ]
+ ]
+
+let private searchPanel model dispatch =
+ Html.section [
+ prop.className "panel search-panel"
+ prop.children [
+ Html.div [
+ prop.className "section-heading"
+ prop.children [
+ Html.div [
+ Html.p [ prop.className "eyebrow"; prop.text "01 / FIND" ]
+ Html.h2 "先锁定真实代码"
+ ]
+ Html.span [ prop.className "section-note"; prop.text "AKShare · exact match" ]
+ ]
+ ]
+ Html.div [
+ prop.className "search-row"
+ prop.children [
+ Html.input [
+ prop.className "text-input search-input"
+ prop.placeholder "输入基金名称或六位代码"
+ prop.value model.query
+ prop.onChange (fun value -> dispatch (QueryChanged value))
+ ]
+ Html.button [
+ prop.className "primary-action"
+ prop.disabled model.searchInFlight
+ prop.onClick (fun _ -> dispatch SearchRequested)
+ prop.text (if model.searchInFlight then "查询中..." else "搜索")
+ ]
+ ]
+ ]
+ if List.isEmpty model.searchResults then
+ Html.p [ prop.className "hint"; prop.text "搜索结果会保留来源与代码,避免名称歧义。" ]
+ else
+ Html.div [
+ prop.className "result-list"
+ prop.children (model.searchResults |> List.map (fun instrument -> resultRow instrument dispatch))
+ ]
+ ]
+ ]
+
+let private navPanel model dispatch =
+ let selectedName =
+ model.selected
+ |> Option.map (fun instrument -> instrument.name)
+ |> Option.defaultValue "尚未选择基金"
+
+ Html.section [
+ prop.className "panel nav-panel"
+ prop.children [
+ Html.div [
+ prop.className "section-heading"
+ prop.children [
+ Html.div [
+ Html.p [ prop.className "eyebrow"; prop.text "02 / TRACE" ]
+ Html.h2 selectedName
+ ]
+ match model.selected with
+ | Some instrument ->
+ Html.div [
+ prop.className "instrument-meta"
+ prop.children [
+ Html.span [ prop.className "code-chip"; prop.text instrument.code ]
+ Html.span [ prop.className "section-note"; prop.text (instrument.fundType |> Option.defaultValue "基金") ]
+ ]
+ ]
+ | None -> Html.none
+ ]
+ ]
+ match latestStats model.observations with
+ | None ->
+ Html.div [
+ prop.className "chart-placeholder"
+ prop.children [
+ Html.div [ prop.className "placeholder-line" ]
+ Html.p "历史净值会在这里形成一条可追溯的曲线"
+ Html.span "数据来自已持久化的日频观察值"
+ ]
+ ]
+ | Some(latestNav, dailyReturn, fromDate, toDate) ->
+ Html.div [
+ prop.className "nav-content"
+ prop.children [
+ Html.div [
+ prop.className "metric-strip"
+ prop.children [
+ Html.div [
+ prop.className "metric"
+ prop.children [
+ Html.span "最新单位净值"
+ Html.strong latestNav
+ ]
+ ]
+ Html.div [
+ prop.className "metric"
+ prop.children [
+ Html.span "最新日收益"
+ Html.strong (dailyReturn |> Option.defaultValue "暂无")
+ ]
+ ]
+ Html.div [
+ prop.className "metric"
+ prop.children [
+ Html.span "观察区间"
+ Html.strong (sprintf "%s — %s" fromDate toDate)
+ ]
+ ]
+ ]
+ ]
+ chartView model.observations
+ ]
+ ]
+ Html.div [
+ prop.className "panel-actions"
+ prop.children [
+ Html.button [
+ prop.className "secondary-action"
+ prop.disabled (model.navInFlight || model.selected.IsNone)
+ prop.onClick (fun _ -> dispatch LoadNavRequested)
+ prop.text "重新读取"
+ ]
+ Html.button [
+ prop.className "primary-action"
+ prop.disabled (model.navInFlight || model.selected.IsNone)
+ prop.onClick (fun _ -> dispatch RefreshNavRequested)
+ prop.text (if model.navInFlight then "同步中..." else "刷新 AKShare")
+ ]
+ ]
+ ]
+ ]
+ ]
let view model dispatch =
Html.main [
- prop.className "empty-state"
+ prop.className "app-shell"
prop.children [
- Html.p [ prop.className "eyebrow"; prop.text "FUND LAB" ]
- Html.h1 "尚未创建基金"
- Html.p "先创建一支人民币模拟基金,再选择基金代码和持仓金额。"
- Html.p (if model.hasFund then "已创建基金" else "尚未选择投资")
- Html.button [
- prop.className "primary-action"
- prop.text "创建基金"
- prop.onClick (fun _ -> dispatch CreateFundRequested)
+ Html.header [
+ prop.className "topbar"
+ prop.children [
+ Html.div [
+ prop.className "brand"
+ prop.children [
+ Html.span [ prop.className "brand-mark"; prop.text "F" ]
+ Html.span "FUND LAB"
+ ]
+ ]
+ Html.span [ prop.className "topbar-status"; prop.text "MARKET DATA / 3B" ]
+ ]
+ ]
+ Html.div [
+ prop.className "hero"
+ prop.children [
+ Html.div [
+ prop.className "hero-copy"
+ prop.children [
+ Html.p [ prop.className "eyebrow"; prop.text "人民币模拟基金 · 观察台" ]
+ Html.h1 "把净值曲线,先看清楚。"
+ Html.p "从真实基金代码开始,读取、校验并追踪每日单位净值。这里不下单,只保留可复核的数据。"
+ ]
+ ]
+ Html.div [
+ prop.className "token-card"
+ prop.children [
+ Html.label [
+ prop.className "field-label"
+ prop.children [
+ Html.span "API TOKEN"
+ Html.input [
+ prop.className "text-input token-input"
+ prop.type' "password"
+ prop.placeholder "仅保存在当前页面内存"
+ prop.value model.token
+ prop.onChange (fun value -> dispatch (TokenChanged value))
+ ]
+ ]
+ ]
+ Html.p [ prop.className "token-note"; prop.text "请求通过 Bearer header 发送,不写入构建产物。" ]
+ ]
+ ]
+ ]
]
+ match model.error with
+ | Some message -> Html.div [ prop.className "error-banner"; prop.text message ]
+ | None -> Html.none
+ searchPanel model dispatch
+ navPanel model dispatch
+ Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / MODE · READ-ONLY" ]
]
]
let start () =
- Program.mkSimple init update view
+ Program.mkProgram (fun () -> init (), Cmd.none) update view
|> Program.withReactSynchronous "fund-lab-root"
|> Program.run