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 } type CreateFundPayload = { idempotencyKey: string name: string initialCash: string initialUnitNav: string isSynthetic: bool } type FundSummary = { id: string name: string currency: string initialCash: string initialUnitNav: string isSynthetic: bool availableCash: string status: string } type CreateAttempt = { idempotencyKey: string name: string cash: string } module Api = [] let searchInstruments (token: string) (query: string) : JS.Promise = jsNative [] let getNav (token: string) (code: string) : JS.Promise = jsNative [] let refreshNav (token: string) (code: string) : JS.Promise = jsNative [] let createFund (token: string) (payload: CreateFundPayload) : JS.Promise = jsNative [] let getFund (token: string) (fundId: string) : JS.Promise = jsNative let decodeOptionalText (raw: obj) : string option = if isNull raw then None else let envelope = unbox 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 = { token: string query: string searchSeq: int navSeq: int searchResults: Instrument list selected: Instrument option observations: NavObservation list searchInFlight: bool navInFlight: bool fundName: string fundCash: string fundSeq: int fundInFlight: bool createdFund: FundSummary option lastCreateAttempt: CreateAttempt option error: string option } type Msg = | 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 | FundNameChanged of string | FundCashChanged of string | FundCreateRequested | FundCreateCompleted of requestId: int * fund: FundSummary | FundCreateFailed of requestId: int * message: string | FundReadRequested | FundReadCompleted of requestId: int * fund: FundSummary | FundReadFailed of requestId: int * message: string let defaultInitialUnitNav = "1.00000000" let private cashPattern = Text.RegularExpressions.Regex("^[0-9]{1,18}(\.[0-9]{1,2})?$", Text.RegularExpressions.RegexOptions.Compiled) let isValidCashText (text: string) = cashPattern.IsMatch text let resolveCreateKey (lastAttempt: CreateAttempt option) (name: string) (cash: string) = match lastAttempt with | Some attempt when attempt.name = name && attempt.cash = cash -> attempt.idempotencyKey | _ -> Guid.NewGuid().ToString("N") let init () = { token = "" query = "" searchSeq = 0 navSeq = 0 searchResults = [] selected = None observations = [] searchInFlight = false navInFlight = false fundName = "" fundCash = "" fundSeq = 0 fundInFlight = false createdFund = None lastCreateAttempt = None 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 private createFundCommand token payload requestId = Cmd.OfPromise.either (fun () -> Api.createFund token payload) () (fun fund -> FundCreateCompleted(requestId, fund)) (fun error -> FundCreateFailed(requestId, errorText error)) let private readFundCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getFund token fundId) () (fun fund -> FundReadCompleted(requestId, fund)) (fun error -> FundReadFailed(requestId, errorText error)) let update message model = match message with | TokenChanged token -> { model with token = token searchSeq = model.searchSeq + 1 navSeq = model.navSeq + 1 searchResults = [] selected = None observations = [] searchInFlight = false navInFlight = false fundName = "" fundCash = "" fundSeq = model.fundSeq + 1 fundInFlight = false createdFund = None lastCreateAttempt = None 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 | FundNameChanged value -> { model with fundName = value; error = None; lastCreateAttempt = None }, Cmd.none | FundCashChanged value -> { model with fundCash = value; error = None; lastCreateAttempt = None }, Cmd.none | FundCreateRequested -> let name = model.fundName.Trim() let cash = model.fundCash.Trim() if String.IsNullOrWhiteSpace model.token then { model with error = Some "请输入 API token" }, Cmd.none elif name = "" then { model with error = Some "请输入基金名称" }, Cmd.none elif not (isValidCashText cash) then { model with error = Some "初始现金必须是合法金额,例如 10000.00" }, Cmd.none elif model.fundInFlight then model, Cmd.none else let requestId = model.fundSeq + 1 let idempotencyKey = resolveCreateKey model.lastCreateAttempt name cash { model with fundName = name fundCash = cash fundSeq = requestId fundInFlight = true lastCreateAttempt = Some { idempotencyKey = idempotencyKey; name = name; cash = cash } error = None }, createFundCommand model.token { idempotencyKey = idempotencyKey name = name initialCash = cash initialUnitNav = defaultInitialUnitNav isSynthetic = false } requestId | FundCreateCompleted (requestId, fund) -> if requestId = model.fundSeq then { model with createdFund = Some fund fundInFlight = false lastCreateAttempt = None error = None }, Cmd.none else model, Cmd.none | FundCreateFailed (requestId, message) -> if requestId = model.fundSeq then { model with fundInFlight = false; error = Some message }, Cmd.none else model, Cmd.none | FundReadRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> if model.fundInFlight then model, Cmd.none else let requestId = model.fundSeq + 1 { model with fundSeq = requestId; fundInFlight = true; error = None }, readFundCommand model.token fund.id requestId | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none | None -> { model with error = Some "请先创建一个基金" }, Cmd.none | FundReadCompleted (requestId, fund) -> if requestId = model.fundSeq then { model with createdFund = Some fund; fundInFlight = false; error = None }, Cmd.none else model, Cmd.none | FundReadFailed (requestId, message) -> if requestId = model.fundSeq then { model with fundInFlight = 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 private fundSummaryRow (label: string) (value: string) = Html.div [ prop.className "fund-detail-row" prop.children [ Html.span [ prop.className "fund-detail-label"; prop.text label ] Html.span [ prop.className "fund-detail-value"; prop.text value ] ] ] let private fundPanel model dispatch = Html.section [ prop.className "panel fund-panel" prop.children [ Html.div [ prop.className "section-heading" prop.children [ Html.div [ Html.p [ prop.className "eyebrow"; prop.text "03 / CREATE" ] Html.h2 "创建一笔真实基金" ] Html.span [ prop.className "section-note"; prop.text "PostgreSQL · idempotent" ] ] ] Html.div [ prop.className "fund-form-row" prop.children [ Html.label [ prop.className "field-label" prop.children [ Html.span "基金名称" Html.input [ prop.className "text-input fund-name-input" prop.placeholder "例如 稳健一号" prop.value model.fundName prop.onChange (fun value -> dispatch (FundNameChanged value)) ] ] ] Html.label [ prop.className "field-label" prop.children [ Html.span "初始现金(元)" Html.input [ prop.className "text-input fund-cash-input" prop.placeholder "例如 10000.00" prop.value model.fundCash prop.onChange (fun value -> dispatch (FundCashChanged value)) ] ] ] Html.button [ prop.className "primary-action fund-create-action" prop.disabled model.fundInFlight prop.onClick (fun _ -> dispatch FundCreateRequested) prop.text ((if model.fundInFlight then "创建中..." else "创建基金"): string) ] ] ] Html.p [ prop.className "hint" prop.text "初始单位净值固定为 1.00000000;金额以 decimal 字符串提交,不经过浮点运算。" ] match model.createdFund with | Some fund -> Html.div [ prop.className "fund-summary" prop.children [ fundSummaryRow "基金ID" fund.id fundSummaryRow "名称" fund.name fundSummaryRow "币种" fund.currency fundSummaryRow "初始现金" fund.initialCash fundSummaryRow "可用现金" fund.availableCash fundSummaryRow "初始单位净值" fund.initialUnitNav fundSummaryRow "份额" "0(新基金尚未买入持仓)" fundSummaryRow "状态" fund.status fundSummaryRow "数据来源" (if fund.isSynthetic then "合成(测试标记)" else "真实建档") Html.div [ prop.className "panel-actions" prop.children [ Html.button [ prop.className "secondary-action fund-read-action" prop.disabled model.fundInFlight prop.onClick (fun _ -> dispatch FundReadRequested) prop.text ((if model.fundInFlight then "读取中..." else "重新读取"): string) ] ] ] ] ] | None -> Html.p [ prop.className "hint" prop.text "创建成功后,这里会展示服务端返回的基金档案,可通过重新读取复核持久化结果。" ] ] ] let view model dispatch = Html.main [ prop.className "app-shell" prop.children [ 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 "LEDGER / 3C-1" ] ] ] 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 fundPanel model dispatch Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ" ] ] ] let start () = Program.mkProgram (fun () -> init (), Cmd.none) update view |> Program.withReactSynchronous "fund-lab-root" |> Program.run