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 reservedCash: string status: string } type CreateOrderPayload = { idempotencyKey: string fundCode: string amount: string feeAmount: string } type RawOrder = { id: string fundCode: string amount: string feeAmount: string reservedTotal: string status: string submittedAt: string tradeDate: string pendingReason: obj confirmedAt: obj confirmedNav: obj confirmedNavDate: obj confirmedUnits: obj confirmedInvestedCash: obj confirmedResidualCash: obj } type RawPosition = { instrumentCode: string units: string reservedUnits: string costCash: string lastConfirmedAt: string valuationNav: obj valuationNavDate: obj valuationCollectedAt: obj } type RawRedemption = { id: string instrumentCode: string units: string feeAmount: string status: string submittedAt: string tradeDate: string pendingReason: obj confirmedAt: obj confirmedNav: obj confirmedNavDate: obj confirmedProceeds: obj confirmedCostReleased: obj } type RawPositions = { availableCash: string reservedCash: string positions: RawPosition array } type CreateAttempt = { idempotencyKey: string name: string cash: string } type OrderAttempt = { idempotencyKey: string fundCode: string amount: string feeAmount: string } type ConfirmAttempt = { idempotencyKey: string orderId: string } type RedemptionAttempt = { idempotencyKey: string instrumentCode: string units: string feeAmount: string } type RedemptionConfirmAttempt = { idempotencyKey: string orderId: string } type CreateRedemptionPayload = { idempotencyKey: string instrumentCode: string units: string feeAmount: string } type OrderDetail = { id: string fundCode: string amount: string feeAmount: string reservedTotal: string status: string submittedAt: string tradeDate: string pendingReason: string option confirmedAt: string option confirmedNav: string option confirmedNavDate: string option confirmedUnits: string option confirmedInvestedCash: string option confirmedResidualCash: string option } type Position = { instrumentCode: string units: string reservedUnits: string costCash: string lastConfirmedAt: string valuationNav: string option valuationNavDate: string option } type RedemptionDetail = { id: string instrumentCode: string units: string feeAmount: string status: string submittedAt: string tradeDate: string pendingReason: string option confirmedAt: string option confirmedNav: string option confirmedNavDate: string option confirmedProceeds: string option confirmedCostReleased: string option } type Positions = { availableCash: string reservedCash: string positions: Position list } 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 createOrder (token: string) (fundId: string) (payload: CreateOrderPayload) : JS.Promise = jsNative [] let getOrders (token: string) (fundId: string) : JS.Promise = jsNative [] let confirmOrder (token: string) (fundId: string) (orderId: string) (idempotencyKey: string) : JS.Promise = jsNative [] let getPositions (token: string) (fundId: string) : JS.Promise = jsNative [] let createRedemption (token: string) (fundId: string) (payload: CreateRedemptionPayload) : JS.Promise = jsNative [] let getRedemptions (token: string) (fundId: string) : JS.Promise = jsNative [] let confirmRedemption (token: string) (fundId: string) (orderId: string) (idempotencyKey: string) : JS.Promise = jsNative let decodeOptionalText (raw: obj) : string option = if isNull raw then None else match raw with | :? string as value -> Some value | _ -> 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 let decodeOrder (raw: RawOrder) : OrderDetail = { id = raw.id fundCode = raw.fundCode amount = raw.amount feeAmount = raw.feeAmount reservedTotal = raw.reservedTotal status = raw.status submittedAt = raw.submittedAt tradeDate = raw.tradeDate pendingReason = decodeOptionalText raw.pendingReason confirmedAt = decodeOptionalText raw.confirmedAt confirmedNav = decodeOptionalText raw.confirmedNav confirmedNavDate = decodeOptionalText raw.confirmedNavDate confirmedUnits = decodeOptionalText raw.confirmedUnits confirmedInvestedCash = decodeOptionalText raw.confirmedInvestedCash confirmedResidualCash = decodeOptionalText raw.confirmedResidualCash } let decodePosition (raw: RawPosition) : Position = { instrumentCode = raw.instrumentCode units = raw.units reservedUnits = raw.reservedUnits costCash = raw.costCash lastConfirmedAt = raw.lastConfirmedAt valuationNav = decodeOptionalText raw.valuationNav valuationNavDate = decodeOptionalText raw.valuationNavDate } let decodePositions (raw: RawPositions) : Positions = { availableCash = raw.availableCash reservedCash = raw.reservedCash positions = raw.positions |> Array.map decodePosition |> List.ofArray } let decodeRedemption (raw: RawRedemption) : RedemptionDetail = { id = raw.id instrumentCode = raw.instrumentCode units = raw.units feeAmount = raw.feeAmount status = raw.status submittedAt = raw.submittedAt tradeDate = raw.tradeDate pendingReason = decodeOptionalText raw.pendingReason confirmedAt = decodeOptionalText raw.confirmedAt confirmedNav = decodeOptionalText raw.confirmedNav confirmedNavDate = decodeOptionalText raw.confirmedNavDate confirmedProceeds = decodeOptionalText raw.confirmedProceeds confirmedCostReleased = decodeOptionalText raw.confirmedCostReleased } 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 orderCode: string orderAmount: string orderFee: string orderCreateSeq: int orderReadSeq: int orderInFlight: bool lastOrderAttempt: OrderAttempt option orders: OrderDetail list confirmSeq: int confirmInFlight: bool lastConfirmAttempt: ConfirmAttempt option positions: Positions option positionsSeq: int positionsInFlight: bool redemptionCode: string redemptionUnits: string redemptionFee: string redemptionCreateSeq: int redemptionReadSeq: int redemptionInFlight: bool lastRedemptionAttempt: RedemptionAttempt option redemptions: RedemptionDetail list redemptionConfirmSeq: int redemptionConfirmInFlight: bool lastRedemptionConfirmAttempt: RedemptionConfirmAttempt 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 | OrderCodeChanged of string | OrderAmountChanged of string | OrderFeeChanged of string | OrderCreateRequested | OrderCreateCompleted of requestId: int * fundId: string * order: RawOrder | OrderCreateFailed of requestId: int * fundId: string * message: string | OrdersReadRequested | OrdersReadCompleted of requestId: int * orders: RawOrder array | OrdersReadFailed of requestId: int * message: string | OrderConfirmRequested of orderId: string | OrderConfirmCompleted of requestId: int * orderId: string * order: RawOrder | OrderConfirmFailed of requestId: int * orderId: string * message: string | PositionsReadRequested | PositionsReadCompleted of requestId: int * positions: RawPositions | PositionsReadFailed of requestId: int * message: string | RedemptionCodeChanged of string | RedemptionUnitsChanged of string | RedemptionFeeChanged of string | RedemptionCreateRequested | RedemptionCreateCompleted of requestId: int * fundId: string * order: RawRedemption | RedemptionCreateFailed of requestId: int * fundId: string * message: string | RedemptionsReadRequested | RedemptionsReadCompleted of requestId: int * orders: RawRedemption array | RedemptionsReadFailed of requestId: int * message: string | RedemptionConfirmRequested of orderId: string | RedemptionConfirmCompleted of requestId: int * orderId: string * order: RawRedemption | RedemptionConfirmFailed of requestId: int * orderId: string * 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 isPositiveCash (text: string) = match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with | true, value -> value > 0M | false, _ -> false let isNonNegativeCash (text: string) = match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with | true, value -> value >= 0M | false, _ -> false 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 resolveOrderKey (lastAttempt: OrderAttempt option) (code: string) (amount: string) (fee: string) = match lastAttempt with | Some attempt when attempt.fundCode = code && attempt.amount = amount && attempt.feeAmount = fee -> 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 orderCode = "" orderAmount = "" orderFee = "" orderCreateSeq = 0 orderReadSeq = 0 orderInFlight = false lastOrderAttempt = None orders = [] confirmSeq = 0 confirmInFlight = false lastConfirmAttempt = None positions = None positionsSeq = 0 positionsInFlight = false redemptionCode = "" redemptionUnits = "" redemptionFee = "" redemptionCreateSeq = 0 redemptionReadSeq = 0 redemptionInFlight = false lastRedemptionAttempt = None redemptions = [] redemptionConfirmSeq = 0 redemptionConfirmInFlight = false lastRedemptionConfirmAttempt = 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 private createOrderCommand token fundId payload requestId = Cmd.OfPromise.either (fun () -> Api.createOrder token fundId payload) () (fun order -> OrderCreateCompleted(requestId, fundId, order)) (fun error -> OrderCreateFailed(requestId, fundId, errorText error)) let private readOrdersCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getOrders token fundId) () (fun orders -> OrdersReadCompleted(requestId, orders)) (fun error -> OrdersReadFailed(requestId, errorText error)) let private confirmOrderCommand token fundId orderId idempotencyKey requestId = Cmd.OfPromise.either (fun () -> Api.confirmOrder token fundId orderId idempotencyKey) () (fun order -> OrderConfirmCompleted(requestId, orderId, order)) (fun error -> OrderConfirmFailed(requestId, orderId, errorText error)) let private readPositionsCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getPositions token fundId) () (fun positions -> PositionsReadCompleted(requestId, positions)) (fun error -> PositionsReadFailed(requestId, errorText error)) let private createRedemptionCommand token fundId payload requestId = Cmd.OfPromise.either (fun () -> Api.createRedemption token fundId payload) () (fun order -> RedemptionCreateCompleted(requestId, fundId, order)) (fun error -> RedemptionCreateFailed(requestId, fundId, errorText error)) let private readRedemptionsCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getRedemptions token fundId) () (fun orders -> RedemptionsReadCompleted(requestId, orders)) (fun error -> RedemptionsReadFailed(requestId, errorText error)) let private confirmRedemptionCommand token fundId orderId idempotencyKey requestId = Cmd.OfPromise.either (fun () -> Api.confirmRedemption token fundId orderId idempotencyKey) () (fun order -> RedemptionConfirmCompleted(requestId, orderId, order)) (fun error -> RedemptionConfirmFailed(requestId, orderId, 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 orderCode = "" orderAmount = "" orderFee = "" orderCreateSeq = model.orderCreateSeq + 1 orderReadSeq = model.orderReadSeq + 1 orderInFlight = false lastOrderAttempt = None orders = [] confirmSeq = model.confirmSeq + 1 confirmInFlight = false lastConfirmAttempt = None positions = None positionsSeq = model.positionsSeq + 1 positionsInFlight = false redemptionCode = "" redemptionUnits = "" redemptionFee = "" redemptionCreateSeq = model.redemptionCreateSeq + 1 redemptionReadSeq = model.redemptionReadSeq + 1 redemptionInFlight = false lastRedemptionAttempt = None redemptions = [] redemptionConfirmSeq = model.redemptionConfirmSeq + 1 redemptionConfirmInFlight = false lastRedemptionConfirmAttempt = 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 orderCreateSeq = model.orderCreateSeq + 1 orderReadSeq = model.orderReadSeq + 1 orderInFlight = false lastOrderAttempt = None orders = [] confirmSeq = model.confirmSeq + 1 confirmInFlight = false lastConfirmAttempt = None positions = None positionsSeq = model.positionsSeq + 1 positionsInFlight = false redemptionCode = "" redemptionUnits = "" redemptionFee = "" redemptionCreateSeq = model.redemptionCreateSeq + 1 redemptionReadSeq = model.redemptionReadSeq + 1 redemptionInFlight = false lastRedemptionAttempt = None redemptions = [] redemptionConfirmSeq = model.redemptionConfirmSeq + 1 redemptionConfirmInFlight = false lastRedemptionConfirmAttempt = None error = None }, Cmd.ofMsg OrdersReadRequested 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 | OrderCodeChanged value -> { model with orderCode = value; error = None; lastOrderAttempt = None }, Cmd.none | OrderAmountChanged value -> { model with orderAmount = value; error = None; lastOrderAttempt = None }, Cmd.none | OrderFeeChanged value -> { model with orderFee = value; error = None; lastOrderAttempt = None }, Cmd.none | OrderCreateRequested -> let code = model.orderCode.Trim() let amount = model.orderAmount.Trim() let fee = model.orderFee.Trim() if String.IsNullOrWhiteSpace model.token then { model with error = Some "请输入 API token" }, Cmd.none elif model.createdFund.IsNone then { model with error = Some "请先创建一个基金" }, Cmd.none elif code = "" then { model with error = Some "请输入基金代码" }, Cmd.none elif not (isValidCashText amount) || not (isPositiveCash amount) then { model with error = Some "认购金额必须是大于零的两位小数金额,例如 100.00" }, Cmd.none elif not (isValidCashText fee) || not (isNonNegativeCash fee) then { model with error = Some "认购手续费必须是不小于零的两位小数金额,例如 0.00" }, Cmd.none elif model.orderInFlight then model, Cmd.none else let requestId = model.orderCreateSeq + 1 let idempotencyKey = resolveOrderKey model.lastOrderAttempt code amount fee { model with orderCode = code orderAmount = amount orderFee = fee orderCreateSeq = requestId orderInFlight = true lastOrderAttempt = Some { idempotencyKey = idempotencyKey fundCode = code amount = amount feeAmount = fee } error = None }, createOrderCommand model.token model.createdFund.Value.id { idempotencyKey = idempotencyKey fundCode = code amount = amount feeAmount = fee } requestId | OrderCreateCompleted (requestId, fundId, order) -> if requestId = model.orderCreateSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with orderInFlight = false lastOrderAttempt = None error = None }, Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg OrdersReadRequested ] else model, Cmd.none | OrderCreateFailed (requestId, fundId, message) -> if requestId = model.orderCreateSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with orderInFlight = false; error = Some message }, Cmd.none else model, Cmd.none | OrdersReadRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> let requestId = model.orderReadSeq + 1 { model with orderReadSeq = requestId; error = None }, readOrdersCommand model.token fund.id requestId | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none | None -> model, Cmd.none | OrdersReadCompleted (requestId, orders) -> if requestId = model.orderReadSeq then { model with orders = orders |> Array.map Api.decodeOrder |> Array.toList error = None }, Cmd.none else model, Cmd.none | OrdersReadFailed (requestId, message) -> if requestId = model.orderReadSeq then { model with error = Some message }, Cmd.none else model, Cmd.none | OrderConfirmRequested orderId -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.confirmInFlight -> let requestId = model.confirmSeq + 1 let idempotencyKey = Guid.NewGuid().ToString("N") { model with confirmSeq = requestId confirmInFlight = true lastConfirmAttempt = Some { idempotencyKey = idempotencyKey; orderId = orderId } error = None }, confirmOrderCommand model.token fund.id orderId idempotencyKey requestId | Some _ when model.confirmInFlight -> model, Cmd.none | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none | None -> model, Cmd.none | OrderConfirmCompleted (requestId, orderId, raw) -> if requestId = model.confirmSeq && (model.lastConfirmAttempt |> Option.exists (fun attempt -> attempt.orderId = orderId)) then let confirmed = Api.decodeOrder raw let orders = model.orders |> List.map (fun order -> if order.id = confirmed.id then confirmed else order) { model with confirmInFlight = false; lastConfirmAttempt = None; orders = orders; error = None }, Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg PositionsReadRequested ] else model, Cmd.none | OrderConfirmFailed (requestId, orderId, message) -> if requestId = model.confirmSeq && (model.lastConfirmAttempt |> Option.exists (fun attempt -> attempt.orderId = orderId)) then { model with confirmInFlight = false; lastConfirmAttempt = None; error = Some message }, Cmd.ofMsg OrdersReadRequested else model, Cmd.none | PositionsReadRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.positionsInFlight -> let requestId = model.positionsSeq + 1 { model with positionsSeq = requestId; positionsInFlight = true; error = None }, readPositionsCommand model.token fund.id requestId | Some _ -> model, Cmd.none | None -> model, Cmd.none | PositionsReadCompleted (requestId, raw) -> if requestId = model.positionsSeq then { model with positions = Some(Api.decodePositions raw) positionsInFlight = false error = None }, Cmd.none else model, Cmd.none | PositionsReadFailed (requestId, message) -> if requestId = model.positionsSeq then { model with positionsInFlight = false; error = Some message }, Cmd.none else model, Cmd.none | RedemptionCodeChanged value -> { model with redemptionCode = value; error = None; lastRedemptionAttempt = None }, Cmd.none | RedemptionUnitsChanged value -> { model with redemptionUnits = value; error = None; lastRedemptionAttempt = None }, Cmd.none | RedemptionFeeChanged value -> { model with redemptionFee = value; error = None; lastRedemptionAttempt = None }, Cmd.none | RedemptionCreateRequested -> let code = model.redemptionCode.Trim() let units = model.redemptionUnits.Trim() let fee = model.redemptionFee.Trim() if String.IsNullOrWhiteSpace model.token then { model with error = Some "请输入 API token" }, Cmd.none elif model.createdFund.IsNone then { model with error = Some "请先创建一个基金" }, Cmd.none elif code = "" then { model with error = Some "请输入基金代码" }, Cmd.none elif Decimal.TryParse(units, NumberStyles.Float, CultureInfo.InvariantCulture) |> fst |> not then { model with error = Some "赎回份额必须是合法的八位小数份额数,例如 100.00000000" }, Cmd.none elif Decimal.Parse(units, NumberStyles.Float, CultureInfo.InvariantCulture) <= 0m then { model with error = Some "赎回份额必须是大于零的八位小数份额数,例如 100.00000000" }, Cmd.none elif not (isValidCashText fee) || not (isNonNegativeCash fee) then { model with error = Some "赎回手续费必须是不小于零的两位小数金额,例如 0.00" }, Cmd.none elif model.redemptionInFlight then model, Cmd.none else let requestId = model.redemptionCreateSeq + 1 let idempotencyKey = match model.lastRedemptionAttempt with | Some attempt when attempt.instrumentCode = code && attempt.units = units && attempt.feeAmount = fee -> attempt.idempotencyKey | _ -> Guid.NewGuid().ToString("N") { model with redemptionCode = code redemptionUnits = units redemptionFee = fee redemptionCreateSeq = requestId redemptionInFlight = true lastRedemptionAttempt = Some { idempotencyKey = idempotencyKey instrumentCode = code units = units feeAmount = fee } error = None }, createRedemptionCommand model.token model.createdFund.Value.id { idempotencyKey = idempotencyKey instrumentCode = code units = units feeAmount = fee } requestId | RedemptionCreateCompleted (requestId, fundId, order) -> if requestId = model.redemptionCreateSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with redemptionInFlight = false lastRedemptionAttempt = None error = None }, Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg RedemptionsReadRequested; Cmd.ofMsg PositionsReadRequested ] else model, Cmd.none | RedemptionCreateFailed (requestId, fundId, message) -> if requestId = model.redemptionCreateSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with redemptionInFlight = false; error = Some message }, Cmd.none else model, Cmd.none | RedemptionsReadRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> let requestId = model.redemptionReadSeq + 1 { model with redemptionReadSeq = requestId; error = None }, readRedemptionsCommand model.token fund.id requestId | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none | None -> model, Cmd.none | RedemptionsReadCompleted (requestId, orders) -> if requestId = model.redemptionReadSeq then { model with redemptions = orders |> Array.map Api.decodeRedemption |> Array.toList error = None }, Cmd.none else model, Cmd.none | RedemptionsReadFailed (requestId, message) -> if requestId = model.redemptionReadSeq then { model with error = Some message }, Cmd.none else model, Cmd.none | RedemptionConfirmRequested orderId -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.redemptionConfirmInFlight -> let requestId = model.redemptionConfirmSeq + 1 let idempotencyKey = Guid.NewGuid().ToString("N") { model with redemptionConfirmSeq = requestId redemptionConfirmInFlight = true lastRedemptionConfirmAttempt = Some { idempotencyKey = idempotencyKey; orderId = orderId } error = None }, confirmRedemptionCommand model.token fund.id orderId idempotencyKey requestId | Some _ when model.redemptionConfirmInFlight -> model, Cmd.none | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none | None -> model, Cmd.none | RedemptionConfirmCompleted (requestId, orderId, raw) -> if requestId = model.redemptionConfirmSeq && (model.lastRedemptionConfirmAttempt |> Option.exists (fun attempt -> attempt.orderId = orderId)) then let confirmed = Api.decodeRedemption raw let redemptions = model.redemptions |> List.map (fun order -> if order.id = confirmed.id then confirmed else order) { model with redemptionConfirmInFlight = false; lastRedemptionConfirmAttempt = None; redemptions = redemptions; error = None }, Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg PositionsReadRequested ] else model, Cmd.none | RedemptionConfirmFailed (requestId, orderId, message) -> if requestId = model.redemptionConfirmSeq && (model.lastRedemptionConfirmAttempt |> Option.exists (fun attempt -> attempt.orderId = orderId)) then { model with redemptionConfirmInFlight = false; lastRedemptionConfirmAttempt = None; error = Some message }, Cmd.ofMsg RedemptionsReadRequested 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.reservedCash 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 private orderStatusText (status: string) = if status = "submitted" then "已提交 · 待确认" elif status = "pending_nav" then "等待净值" elif status = "confirmed" then "已确认" else status let private orderRow (order: OrderDetail) model dispatch = let confirmButton = if order.status = "submitted" || order.status = "pending_nav" then Html.button [ prop.className "secondary-action order-confirm-action" prop.disabled model.confirmInFlight prop.onClick (fun _ -> dispatch (OrderConfirmRequested order.id)) prop.text (if model.confirmInFlight then "确认中..." else "确认净值") ] else Html.none let detailText = match order.status with | "confirmed" -> [ sprintf "确认净值 %s" (order.confirmedNav |> Option.defaultValue "—") sprintf "份额 %s" (order.confirmedUnits |> Option.defaultValue "—") sprintf "净值日期 %s" (order.confirmedNavDate |> Option.defaultValue "—") sprintf "成交金额 %s" (order.confirmedInvestedCash |> Option.defaultValue "—") sprintf "退回现金 %s" (order.confirmedResidualCash |> Option.defaultValue "—") ] |> String.concat " · " | "pending_nav" -> "等待净值 · " + (order.pendingReason |> Option.defaultValue "净值尚未公布") | _ -> "已冻结现金;净值确认后按交易日成交。" Html.div [ prop.className "order-row" prop.children [ Html.span [ prop.className "order-code"; prop.text order.fundCode ] Html.span [ prop.className "order-cell"; prop.text (sprintf "金额 %s" order.amount) ] Html.span [ prop.className "order-cell"; prop.text (sprintf "手续费 %s" order.feeAmount) ] Html.span [ prop.className "order-cell"; prop.text (sprintf "预留合计 %s" order.reservedTotal) ] Html.span [ prop.className "order-cell"; prop.text (sprintf "交易日 %s" order.tradeDate) ] Html.span [ prop.className "order-status"; prop.text (orderStatusText order.status) ] Html.span [ prop.className "order-detail"; prop.text detailText ] confirmButton Html.span [ prop.className "order-cell"; prop.text order.submittedAt ] ] ] let private subscribePanel model dispatch = Html.section [ prop.className "panel subscribe-panel" prop.children [ Html.div [ prop.className "section-heading" prop.children [ Html.div [ Html.p [ prop.className "eyebrow"; prop.text "04 / SUBSCRIBE" ] Html.h2 "认购一笔基金(模拟)" ] Html.span [ prop.className "section-note"; prop.text "Pending · no execution" ] ] ] 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 order-code-input" prop.placeholder "六位基金代码" prop.value model.orderCode prop.onChange (fun value -> dispatch (OrderCodeChanged value)) ] ] ] Html.label [ prop.className "field-label" prop.children [ Html.span "认购金额(元)" Html.input [ prop.className "text-input order-amount-input" prop.placeholder "例如 100.00" prop.value model.orderAmount prop.onChange (fun value -> dispatch (OrderAmountChanged value)) ] ] ] Html.label [ prop.className "field-label" prop.children [ Html.span "认购手续费(元)" Html.input [ prop.className "text-input order-fee-input" prop.placeholder "0.00 或 1.50" prop.value model.orderFee prop.onChange (fun value -> dispatch (OrderFeeChanged value)) ] ] ] Html.button [ prop.className "primary-action order-submit-action" prop.disabled model.orderInFlight prop.onClick (fun _ -> dispatch OrderCreateRequested) prop.text ((if model.orderInFlight then "下单中..." else "认购下单"): string) ] ] ] Html.p [ prop.className "hint" prop.text "金额与手续费按两位小数字符串提交,手续费允许 0.00;下单即冻结现金,订单保持已提交 · 待确认,不产生任何份额或收益。" ] Html.div [ prop.className "pending-orders" prop.children [ if List.isEmpty model.orders then Html.p [ prop.className "hint"; prop.text "暂无待确认订单" ] else yield! (model.orders |> List.map (fun order -> orderRow order model dispatch)) ] ] Html.div [ prop.className "panel-actions" prop.children [ Html.button [ prop.className "secondary-action orders-refresh-action" prop.onClick (fun _ -> dispatch OrdersReadRequested) prop.text "刷新订单" ] Html.button [ prop.className "secondary-action positions-refresh-action" prop.disabled model.positionsInFlight prop.onClick (fun _ -> dispatch PositionsReadRequested) prop.text ((if model.positionsInFlight then "读取中..." else "刷新持仓"): string) ] ] ] ] ] let private positionRow (position: Position) = let valuation = match position.valuationNav, position.valuationNavDate with | Some nav, Some navDate -> sprintf "%s(%s)" nav navDate | _ -> "估值待更新" let frozen = if position.reservedUnits <> "0.00000000" then Html.span [ prop.className "order-cell"; prop.text (sprintf "冻结份额 %s" position.reservedUnits) ] else Html.none Html.div [ prop.className "order-row position-row" prop.children [ Html.span [ prop.className "order-code"; prop.text position.instrumentCode ] Html.span [ prop.className "order-cell"; prop.text (sprintf "份额 %s" position.units) ] frozen Html.span [ prop.className "order-cell"; prop.text (sprintf "成本 %s" position.costCash) ] Html.span [ prop.className "order-cell"; prop.text (sprintf "最新估值净值 %s" valuation) ] Html.span [ prop.className "order-cell"; prop.text (sprintf "最近确认 %s" position.lastConfirmedAt) ] ] ] let private positionsPanel model dispatch = Html.section [ prop.className "panel positions-panel" prop.children [ Html.div [ prop.className "section-heading" prop.children [ Html.div [ Html.p [ prop.className "eyebrow"; prop.text "05 / HOLDINGS" ] Html.h2 "确认后持仓" ] Html.span [ prop.className "section-note"; prop.text "Positions · confirmed units" ] ] ] match model.createdFund, model.positions with | Some _, Some positions -> Html.div [ prop.className "positions-summary" prop.children [ fundSummaryRow "可用现金" positions.availableCash fundSummaryRow "保留现金" positions.reservedCash ] ] if List.isEmpty positions.positions then Html.p [ prop.className "hint"; prop.text "暂无持仓,确认认购后这里会展示份额与估值。" ] else yield! (positions.positions |> List.map positionRow) | Some _, None -> Html.p [ prop.className "hint"; prop.text "持仓尚未读取,点击「刷新持仓」获取确认后份额。" ] | None, _ -> Html.p [ prop.className "hint"; prop.text "创建基金后,这里会展示确认后的持仓与现金。" ] Html.div [ prop.className "panel-actions" prop.children [ Html.button [ prop.className "secondary-action positions-read-action" prop.disabled (model.positionsInFlight || model.createdFund.IsNone) prop.onClick (fun _ -> dispatch PositionsReadRequested) prop.text ((if model.positionsInFlight then "读取中..." else "刷新持仓"): string) ] ] ] ] ] let private redemptionStatusText (status: string) = if status = "submitted" then "已提交 · 待确认" elif status = "pending_nav" then "等待净值" elif status = "confirmed" then "已确认" else status let private redemptionRow (order: RedemptionDetail) model dispatch = let confirmButton = if order.status = "submitted" || order.status = "pending_nav" then Html.button [ prop.className "secondary-action redemption-confirm-action" prop.disabled model.redemptionConfirmInFlight prop.onClick (fun _ -> dispatch (RedemptionConfirmRequested order.id)) prop.text (if model.redemptionConfirmInFlight then "确认中..." else "确认赎回") ] else Html.none let detailText = match order.status with | "confirmed" -> [ sprintf "确认净值 %s" (order.confirmedNav |> Option.defaultValue "—") sprintf "赎回到账 %s" (order.confirmedProceeds |> Option.defaultValue "—") sprintf "核销成本 %s" (order.confirmedCostReleased |> Option.defaultValue "—") sprintf "净值日期 %s" (order.confirmedNavDate |> Option.defaultValue "—") ] |> String.concat " · " | "pending_nav" -> "等待净值 · " + (order.pendingReason |> Option.defaultValue "净值尚未公布") | _ -> "已冻结份额;净值确认后按交易日结算到账。" Html.div [ prop.className "order-row" prop.children [ Html.span [ prop.className "order-code"; prop.text order.instrumentCode ] Html.span [ prop.className "order-cell"; prop.text (sprintf "份额 %s" order.units) ] Html.span [ prop.className "order-cell"; prop.text (sprintf "手续费 %s" order.feeAmount) ] Html.span [ prop.className "order-cell"; prop.text (sprintf "交易日 %s" order.tradeDate) ] Html.span [ prop.className "order-status"; prop.text (redemptionStatusText order.status) ] Html.span [ prop.className "order-detail"; prop.text detailText ] confirmButton Html.span [ prop.className "order-cell"; prop.text order.submittedAt ] ] ] let private redeemPanel model dispatch = Html.section [ prop.className "panel redeem-panel" prop.children [ Html.div [ prop.className "section-heading" prop.children [ Html.div [ Html.p [ prop.className "eyebrow"; prop.text "06 / REDEEM" ] Html.h2 "赎回已确认持仓(模拟)" ] Html.span [ prop.className "section-note"; prop.text "Pending · units frozen" ] ] ] 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 redemption-code-input" prop.placeholder "六位基金代码" prop.value model.redemptionCode prop.onChange (fun value -> dispatch (RedemptionCodeChanged value)) ] ] ] Html.label [ prop.className "field-label" prop.children [ Html.span "赎回份额" Html.input [ prop.className "text-input redemption-units-input" prop.placeholder "例如 100.00000000" prop.value model.redemptionUnits prop.onChange (fun value -> dispatch (RedemptionUnitsChanged value)) ] ] ] Html.label [ prop.className "field-label" prop.children [ Html.span "赎回手续费(元)" Html.input [ prop.className "text-input redemption-fee-input" prop.placeholder "0.00 或 1.50" prop.value model.redemptionFee prop.onChange (fun value -> dispatch (RedemptionFeeChanged value)) ] ] ] Html.button [ prop.className "primary-action redemption-submit-action" prop.disabled model.redemptionInFlight prop.onClick (fun _ -> dispatch RedemptionCreateRequested) prop.text ((if model.redemptionInFlight then "赎回中..." else "提交赎回"): string) ] ] ] Html.p [ prop.className "hint" prop.text "份额与手续费按小数字符串提交;提交即冻结对应份额,确认前不产生任何现金变化。" ] Html.div [ prop.className "pending-redemptions" prop.children [ if List.isEmpty model.redemptions then Html.p [ prop.className "hint"; prop.text "暂无赎回订单" ] else yield! (model.redemptions |> List.map (fun order -> redemptionRow order model dispatch)) ] ] Html.div [ prop.className "panel-actions" prop.children [ Html.button [ prop.className "secondary-action redemptions-refresh-action" prop.onClick (fun _ -> dispatch RedemptionsReadRequested) 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 / 3D-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 subscribePanel model dispatch positionsPanel model dispatch redeemPanel model dispatch Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ & SUBSCRIBE" ] ] ] let start () = Program.mkProgram (fun () -> init (), Cmd.none) update view |> Program.withReactSynchronous "fund-lab-root" |> Program.run