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 } type SeriesPoint = { x: float y: float label: string value: 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 }) /// Normalizes one named series over a shared, already ordered date axis. Dates /// without a value are omitted (a gap, never a zero). `x` is the position on the /// full axis so two series stay aligned; `y` is normalized within the series. let seriesPoints (dates: string list) (values: (string * string) list) : SeriesPoint list = let parsed = values |> List.choose (fun (date, text) -> match Decimal.TryParse(text, NumberStyles.Float, invariant) with | true, value -> Some(date, value, text) | false, _ -> None) match parsed with | [] -> [] | _ -> let numbers = parsed |> List.map (fun (_, value, _) -> value) let minimum = List.min numbers let maximum = List.max numbers let range = maximum - minimum let count = List.length dates let lookup = parsed |> List.map (fun (date, value, text) -> date, (value, text)) |> Map.ofList dates |> List.mapi (fun index date -> match Map.tryFind date lookup with | None -> None | Some(value, text) -> 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) Some { x = x; y = y; label = date; value = text }) |> List.choose id 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 RawRebalanceTarget = { instrumentCode: string targetPercent: string } type RawRebalanceOutcome = { instrumentCode: string action: string amount: string status: string orderId: obj pendingReason: obj } type RawRebalanceExecution = { planId: string runDate: string outcomes: RawRebalanceOutcome array } type RawSipPlan = { id: string instrumentCode: string amount: string frequency: string status: string anchorDate: string nextTradeDate: string lastExecutionStatus: obj lastExecutionDate: 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 RawDividend = { id: string fundId: string instrumentCode: string navDate: string dps: string mode: string sourceKey: string status: string grossCash: obj creditedUnits: obj creditedInvested: obj orderId: obj pendingReason: obj isSynthetic: bool createdAt: string } type RawPositions = { availableCash: string reservedCash: string positions: RawPosition array } type RawReturnsPoint = { date: string pending: bool totalAssets: obj unitNav: obj cash: string reservedCash: string holdingsValue: obj cumulativeReturn: obj netExternalFlow: string } type RawReturns = { fundId: string pending: bool dataUpdatedAt: obj points: RawReturnsPoint 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 CapitalAttempt = { idempotencyKey: string amount: string } type CreateCapitalPayload = { idempotencyKey: string amount: string note: string } type RawRebalancePlan = { id: string fundId: string targets: RawRebalanceTarget array status: string createdAt: string } type SipAttempt = { idempotencyKey: string instrumentCode: string amount: string frequency: string } type CreateSipPayload = { idempotencyKey: string instrumentCode: string amount: string frequency: string } type SipPlan = { id: string instrumentCode: string amount: string frequency: string status: string anchorDate: string nextTradeDate: string lastExecutionStatus: string option lastExecutionDate: string option } type RebalanceTarget = { instrumentCode: string targetPercent: string } type RebalanceOutcome = { instrumentCode: string action: string amount: string status: string orderId: string option pendingReason: string option } type RebalancePlan = { id: string targets: RebalanceTarget list status: string createdAt: string } type CreateRebalancePayload = { idempotencyKey: string targets: RebalanceTarget array } 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 DividendRecord = { id: string instrumentCode: string navDate: string dps: string mode: string sourceKey: string status: string grossCash: string option creditedUnits: string option creditedInvested: string option orderId: string option pendingReason: string option isSynthetic: bool createdAt: string } type Positions = { availableCash: string reservedCash: string positions: Position list } type ReturnsPoint = { date: string pending: bool totalAssets: string option unitNav: string option cash: string reservedCash: string holdingsValue: string option cumulativeReturn: string option netExternalFlow: string } type FundReturns = { pending: bool dataUpdatedAt: string option points: ReturnsPoint 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 getDividends (token: string) (fundId: string) : JS.Promise = jsNative [] let getReturns (token: string) (fundId: string) : JS.Promise = jsNative [] let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise = jsNative [] let createRebalancePlan (token: string) (fundId: string) (payload: CreateRebalancePayload) : JS.Promise = jsNative [] let getRebalancePlans (token: string) (fundId: string) : JS.Promise = jsNative [] let executeRebalancePlan (token: string) (fundId: string) (planId: string) : JS.Promise = jsNative [] let createSipPlan (token: string) (fundId: string) (payload: CreateSipPayload) : JS.Promise = jsNative [] let getSipPlans (token: string) (fundId: 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 decodeRebalancePlan (raw: RawRebalancePlan) : RebalancePlan = { id = raw.id targets = raw.targets |> Array.map (fun target -> { instrumentCode = target.instrumentCode targetPercent = target.targetPercent }) |> Array.toList status = raw.status createdAt = raw.createdAt } let decodeRebalanceOutcome (raw: RawRebalanceOutcome) : RebalanceOutcome = { instrumentCode = raw.instrumentCode action = raw.action amount = raw.amount status = raw.status orderId = decodeOptionalText raw.orderId pendingReason = decodeOptionalText raw.pendingReason } let decodeSipPlan (raw: RawSipPlan) : SipPlan = { id = raw.id instrumentCode = raw.instrumentCode amount = raw.amount frequency = raw.frequency status = raw.status anchorDate = raw.anchorDate nextTradeDate = raw.nextTradeDate lastExecutionStatus = decodeOptionalText raw.lastExecutionStatus lastExecutionDate = decodeOptionalText raw.lastExecutionDate } 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 } let decodeDividend (raw: RawDividend) : DividendRecord = { id = raw.id instrumentCode = raw.instrumentCode navDate = raw.navDate dps = raw.dps mode = raw.mode sourceKey = raw.sourceKey status = raw.status grossCash = decodeOptionalText raw.grossCash creditedUnits = decodeOptionalText raw.creditedUnits creditedInvested = decodeOptionalText raw.creditedInvested orderId = decodeOptionalText raw.orderId pendingReason = decodeOptionalText raw.pendingReason isSynthetic = raw.isSynthetic createdAt = raw.createdAt } let decodeReturnsPoint (raw: RawReturnsPoint) : ReturnsPoint = { date = raw.date pending = raw.pending totalAssets = decodeOptionalText raw.totalAssets unitNav = decodeOptionalText raw.unitNav cash = raw.cash reservedCash = raw.reservedCash holdingsValue = decodeOptionalText raw.holdingsValue cumulativeReturn = decodeOptionalText raw.cumulativeReturn netExternalFlow = raw.netExternalFlow } let decodeReturns (raw: RawReturns) : FundReturns = { pending = raw.pending dataUpdatedAt = decodeOptionalText raw.dataUpdatedAt points = raw.points |> Array.map decodeReturnsPoint |> Array.toList } 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 capitalAmount: string capitalSeq: int capitalInFlight: bool lastCapitalAttempt: CapitalAttempt option capitalNote: string option sipCode: string sipAmount: string sipFrequency: string sipCreateSeq: int sipReadSeq: int sipInFlight: bool lastSipAttempt: SipAttempt option sipPlans: SipPlan list rebalCode1: string rebalPercent1: string rebalCode2: string rebalPercent2: string rebalCreateSeq: int rebalReadSeq: int rebalInFlight: bool rebalExecuteSeq: int rebalExecuteInFlight: bool rebalPlans: RebalancePlan list rebalResult: RebalanceOutcome list option dividendReadSeq: int dividendInFlight: bool dividends: DividendRecord list returnsReadSeq: int returnsInFlight: bool returns: FundReturns 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 | CapitalAmountChanged of string | CapitalDepositRequested | CapitalDepositCompleted of requestId: int * amount: string | CapitalDepositFailed of requestId: int * message: string | SipCodeChanged of string | SipAmountChanged of string | SipFrequencyChanged of string | SipPlanCreateRequested | SipPlanCreateCompleted of requestId: int * fundId: string * plan: RawSipPlan | SipPlanCreateFailed of requestId: int * fundId: string * message: string | SipPlansReadRequested | SipPlansReadCompleted of requestId: int * plans: RawSipPlan array | SipPlansReadFailed of requestId: int * message: string | RebalCode1Changed of string | RebalPercent1Changed of string | RebalCode2Changed of string | RebalPercent2Changed of string | RebalancePlanCreateRequested | RebalancePlanCreateCompleted of requestId: int * fundId: string * plan: RawRebalancePlan | RebalancePlanCreateFailed of requestId: int * fundId: string * message: string | RebalancePlansReadRequested | RebalancePlansReadCompleted of requestId: int * plans: RawRebalancePlan array | RebalancePlansReadFailed of requestId: int * message: string | RebalanceExecuteRequested of planId: string | RebalanceExecuted of requestId: int * result: RawRebalanceExecution | RebalanceExecuteFailed of requestId: int * message: string | DividendsReadRequested | DividendsReadCompleted of requestId: int * records: RawDividend array | DividendsReadFailed of requestId: int * message: string | ReturnsReadRequested | ReturnsReadCompleted of requestId: int * returns: RawReturns | ReturnsReadFailed 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 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 capitalAmount = "" capitalSeq = 0 capitalInFlight = false lastCapitalAttempt = None capitalNote = None sipCode = "" sipAmount = "" sipFrequency = "weekly" sipCreateSeq = 0 sipReadSeq = 0 sipInFlight = false lastSipAttempt = None sipPlans = [] rebalCode1 = "" rebalPercent1 = "" rebalCode2 = "" rebalPercent2 = "" rebalCreateSeq = 0 rebalReadSeq = 0 rebalInFlight = false rebalExecuteSeq = 0 rebalExecuteInFlight = false rebalPlans = [] rebalResult = None dividendReadSeq = 0 dividendInFlight = false dividends = [] returnsReadSeq = 0 returnsInFlight = false returns = 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 private createCapitalDepositCommand token fundId payload requestId = Cmd.OfPromise.either (fun () -> Api.createCapitalDeposit token fundId payload) () (fun _ -> CapitalDepositCompleted(requestId, payload.amount)) (fun error -> CapitalDepositFailed(requestId, errorText error)) let private createSipPlanCommand token fundId payload requestId = Cmd.OfPromise.either (fun () -> Api.createSipPlan token fundId payload) () (fun plan -> SipPlanCreateCompleted(requestId, fundId, plan)) (fun error -> SipPlanCreateFailed(requestId, fundId, errorText error)) let private readSipPlansCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getSipPlans token fundId) () (fun plans -> SipPlansReadCompleted(requestId, plans)) (fun error -> SipPlansReadFailed(requestId, errorText error)) let private createRebalancePlanCommand token fundId payload requestId = Cmd.OfPromise.either (fun () -> Api.createRebalancePlan token fundId payload) () (fun plan -> RebalancePlanCreateCompleted(requestId, fundId, plan)) (fun error -> RebalancePlanCreateFailed(requestId, fundId, errorText error)) let private readRebalancePlansCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getRebalancePlans token fundId) () (fun plans -> RebalancePlansReadCompleted(requestId, plans)) (fun error -> RebalancePlansReadFailed(requestId, errorText error)) let private executeRebalanceCommand token fundId planId requestId = Cmd.OfPromise.either (fun () -> Api.executeRebalancePlan token fundId planId) () (fun result -> RebalanceExecuted(requestId, result)) (fun error -> RebalanceExecuteFailed(requestId, errorText error)) let private readDividendsCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getDividends token fundId) () (fun records -> DividendsReadCompleted(requestId, records)) (fun error -> DividendsReadFailed(requestId, errorText error)) let private readReturnsCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getReturns token fundId) () (fun returns -> ReturnsReadCompleted(requestId, returns)) (fun error -> ReturnsReadFailed(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 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 capitalAmount = "" capitalSeq = model.capitalSeq + 1 capitalInFlight = false lastCapitalAttempt = None capitalNote = None sipCode = "" sipAmount = "" sipFrequency = "weekly" sipCreateSeq = model.sipCreateSeq + 1 sipReadSeq = model.sipReadSeq + 1 sipInFlight = false lastSipAttempt = None sipPlans = [] rebalCode1 = "" rebalPercent1 = "" rebalCode2 = "" rebalPercent2 = "" rebalCreateSeq = model.rebalCreateSeq + 1 rebalReadSeq = model.rebalReadSeq + 1 rebalInFlight = false rebalExecuteSeq = model.rebalExecuteSeq + 1 rebalExecuteInFlight = false rebalPlans = [] rebalResult = None dividendReadSeq = model.dividendReadSeq + 1 dividendInFlight = false dividends = [] returnsReadSeq = model.returnsReadSeq + 1 returnsInFlight = false returns = 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 capitalAmount = "" capitalSeq = model.capitalSeq + 1 capitalInFlight = false lastCapitalAttempt = None capitalNote = None sipCode = "" sipAmount = "" sipFrequency = "weekly" sipCreateSeq = model.sipCreateSeq + 1 sipReadSeq = model.sipReadSeq + 1 sipInFlight = false lastSipAttempt = None sipPlans = [] rebalCode1 = "" rebalPercent1 = "" rebalCode2 = "" rebalPercent2 = "" rebalCreateSeq = model.rebalCreateSeq + 1 rebalReadSeq = model.rebalReadSeq + 1 rebalInFlight = false rebalExecuteSeq = model.rebalExecuteSeq + 1 rebalExecuteInFlight = false rebalPlans = [] rebalResult = None dividendReadSeq = model.dividendReadSeq + 1 dividendInFlight = false dividends = [] returnsReadSeq = model.returnsReadSeq + 1 returnsInFlight = false returns = None error = None }, Cmd.batch [ Cmd.ofMsg OrdersReadRequested; Cmd.ofMsg ReturnsReadRequested ] 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; Cmd.ofMsg ReturnsReadRequested ] 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; Cmd.ofMsg ReturnsReadRequested ] 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; Cmd.ofMsg ReturnsReadRequested ] 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 | CapitalAmountChanged value -> { model with capitalAmount = value; error = None; lastCapitalAttempt = None }, Cmd.none | CapitalDepositRequested -> let amount = model.capitalAmount.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 not (isValidCashText amount) || not (isPositiveCash amount) then { model with error = Some "追加金额必须是大于零的两位小数金额,例如 1000.00" }, Cmd.none elif model.capitalInFlight then model, Cmd.none else let requestId = model.capitalSeq + 1 let idempotencyKey = match model.lastCapitalAttempt with | Some attempt when attempt.amount = amount -> attempt.idempotencyKey | _ -> Guid.NewGuid().ToString("N") { model with capitalAmount = amount capitalSeq = requestId capitalInFlight = true lastCapitalAttempt = Some { idempotencyKey = idempotencyKey; amount = amount } error = None }, createCapitalDepositCommand model.token model.createdFund.Value.id { idempotencyKey = idempotencyKey amount = amount note = "追加资金" } requestId | CapitalDepositCompleted (requestId, amount) -> if requestId = model.capitalSeq then { model with capitalInFlight = false capitalNote = Some(sprintf "追加成功 + %s(现金余额已刷新)" amount) error = None }, Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg ReturnsReadRequested ] else model, Cmd.none | CapitalDepositFailed (requestId, message) -> if requestId = model.capitalSeq then { model with capitalInFlight = false; error = Some message }, Cmd.none else model, Cmd.none | SipCodeChanged value -> { model with sipCode = value; error = None; lastSipAttempt = None }, Cmd.none | SipAmountChanged value -> { model with sipAmount = value; error = None; lastSipAttempt = None }, Cmd.none | SipFrequencyChanged value -> { model with sipFrequency = value; error = None; lastSipAttempt = None }, Cmd.none | SipPlanCreateRequested -> let code = model.sipCode.Trim() let amount = model.sipAmount.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 "定投金额必须是大于零的两位小数金额,例如 200.00" }, Cmd.none elif model.sipInFlight then model, Cmd.none else let requestId = model.sipCreateSeq + 1 let idempotencyKey = match model.lastSipAttempt with | Some attempt when attempt.instrumentCode = code && attempt.amount = amount && attempt.frequency = model.sipFrequency -> attempt.idempotencyKey | _ -> Guid.NewGuid().ToString("N") { model with sipCode = code sipAmount = amount sipCreateSeq = requestId sipInFlight = true lastSipAttempt = Some { idempotencyKey = idempotencyKey instrumentCode = code amount = amount frequency = model.sipFrequency } error = None }, createSipPlanCommand model.token model.createdFund.Value.id { idempotencyKey = idempotencyKey instrumentCode = code amount = amount frequency = model.sipFrequency } requestId | SipPlanCreateCompleted (requestId, fundId, plan) -> if requestId = model.sipCreateSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with sipInFlight = false lastSipAttempt = None error = None }, Cmd.ofMsg SipPlansReadRequested else model, Cmd.none | SipPlanCreateFailed (requestId, fundId, message) -> if requestId = model.sipCreateSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with sipInFlight = false; error = Some message }, Cmd.none else model, Cmd.none | SipPlansReadRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> let requestId = model.sipReadSeq + 1 { model with sipReadSeq = requestId; error = None }, readSipPlansCommand model.token fund.id requestId | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none | None -> model, Cmd.none | SipPlansReadCompleted (requestId, plans) -> if requestId = model.sipReadSeq then { model with sipPlans = plans |> Array.map Api.decodeSipPlan |> Array.toList error = None }, Cmd.none else model, Cmd.none | SipPlansReadFailed (requestId, message) -> if requestId = model.sipReadSeq then { model with error = Some message }, Cmd.none else model, Cmd.none | RebalCode1Changed value -> { model with rebalCode1 = value; error = None }, Cmd.none | RebalPercent1Changed value -> { model with rebalPercent1 = value; error = None }, Cmd.none | RebalCode2Changed value -> { model with rebalCode2 = value; error = None }, Cmd.none | RebalPercent2Changed value -> { model with rebalPercent2 = value; error = None }, Cmd.none | RebalancePlanCreateRequested -> let code1 = model.rebalCode1.Trim() let percent1 = model.rebalPercent1.Trim() let code2 = model.rebalCode2.Trim() let percent2 = model.rebalPercent2.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 code1 = "" || code2 = "" then { model with error = Some "请输入两个目标基金代码" }, Cmd.none elif not (isValidCashText percent1) || not (isValidCashText percent2) || not (isNonNegativeCash percent1) || not (isNonNegativeCash percent2) then { model with error = Some "目标比例必须是不小于 0 的两位小数百分数,例如 60.00" }, Cmd.none else let percent1Value = Decimal.Parse(model.rebalPercent1.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture) let percent2Value = Decimal.Parse(model.rebalPercent2.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture) if percent1Value + percent2Value <> 100m then { model with error = Some "目标比例合计必须等于 100" }, Cmd.none elif model.rebalInFlight then model, Cmd.none else let requestId = model.rebalCreateSeq + 1 let idempotencyKey = Guid.NewGuid().ToString("N") { model with rebalCreateSeq = requestId rebalInFlight = true rebalResult = None error = None }, createRebalancePlanCommand model.token model.createdFund.Value.id { idempotencyKey = idempotencyKey targets = [| { instrumentCode = code1; targetPercent = model.rebalPercent1.Trim() } { instrumentCode = code2; targetPercent = model.rebalPercent2.Trim() } |] } requestId | RebalancePlanCreateCompleted (requestId, fundId, plan) -> if requestId = model.rebalCreateSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with rebalInFlight = false rebalResult = None error = None }, Cmd.ofMsg RebalancePlansReadRequested else model, Cmd.none | RebalancePlanCreateFailed (requestId, fundId, message) -> if requestId = model.rebalCreateSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with rebalInFlight = false; error = Some message }, Cmd.none else model, Cmd.none | RebalancePlansReadRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> let requestId = model.rebalReadSeq + 1 { model with rebalReadSeq = requestId }, readRebalancePlansCommand model.token fund.id requestId | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none | None -> model, Cmd.none | RebalancePlansReadCompleted (requestId, plans) -> if requestId = model.rebalReadSeq then { model with rebalPlans = plans |> Array.map Api.decodeRebalancePlan |> Array.toList error = None }, Cmd.none else model, Cmd.none | RebalancePlansReadFailed (requestId, message) -> if requestId = model.rebalReadSeq then { model with error = Some message }, Cmd.none else model, Cmd.none | RebalanceExecuteRequested planId -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.rebalExecuteInFlight -> let requestId = model.rebalExecuteSeq + 1 { model with rebalExecuteSeq = requestId rebalExecuteInFlight = true rebalResult = None error = None }, executeRebalanceCommand model.token fund.id planId requestId | Some _ when model.rebalExecuteInFlight -> model, Cmd.none | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none | None -> model, Cmd.none | RebalanceExecuted (requestId, raw) -> if requestId = model.rebalExecuteSeq then { model with rebalExecuteInFlight = false rebalResult = Some (raw.outcomes |> Array.map (fun outcome -> { instrumentCode = outcome.instrumentCode action = outcome.action amount = outcome.amount status = outcome.status orderId = Api.decodeOptionalText outcome.orderId pendingReason = Api.decodeOptionalText outcome.pendingReason }) |> Array.toList) error = None }, Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg OrdersReadRequested; Cmd.ofMsg PositionsReadRequested ] else model, Cmd.none | RebalanceExecuteFailed (requestId, message) -> if requestId = model.rebalExecuteSeq then { model with rebalExecuteInFlight = false; error = Some message }, Cmd.none else model, Cmd.none | DividendsReadRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> let requestId = model.dividendReadSeq + 1 { model with dividendReadSeq = requestId dividendInFlight = true error = None }, readDividendsCommand model.token fund.id requestId | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none | None -> model, Cmd.none | DividendsReadCompleted (requestId, records) -> if requestId = model.dividendReadSeq then { model with dividends = records |> Array.map Api.decodeDividend |> Array.toList dividendInFlight = false error = None }, Cmd.none else model, Cmd.none | DividendsReadFailed (requestId, message) -> if requestId = model.dividendReadSeq then { model with dividendInFlight = false; error = Some message }, Cmd.none else model, Cmd.none | ReturnsReadRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> let requestId = model.returnsReadSeq + 1 { model with returnsReadSeq = requestId returnsInFlight = true error = None }, readReturnsCommand model.token fund.id requestId | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none | None -> model, Cmd.none | ReturnsReadCompleted (requestId, returns) -> if requestId = model.returnsReadSeq then { model with returns = Some(Api.decodeReturns returns) returnsInFlight = false error = None }, Cmd.none else model, Cmd.none | ReturnsReadFailed (requestId, message) -> if requestId = model.returnsReadSeq then { model with returnsInFlight = 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.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 "创建成功后,这里会展示服务端返回的基金档案,可通过重新读取复核持久化结果。" ] match model.createdFund with | Some _ -> Html.div [ prop.className "capital-block" prop.children [ Html.label [ prop.className "field-label" prop.children [ Html.span "追加资金(元)" Html.input [ prop.className "text-input capital-amount-input" prop.placeholder "例如 1000.00" prop.value model.capitalAmount prop.onChange (fun value -> dispatch (CapitalAmountChanged value)) ] ] ] Html.button [ prop.className "primary-action capital-deposit-action" prop.disabled model.capitalInFlight prop.onClick (fun _ -> dispatch CapitalDepositRequested) prop.text ((if model.capitalInFlight then "追加中..." else "追加资金"): string) ] ] ] Html.p [ prop.className "hint capital-result" prop.text (match model.capitalNote with | Some note -> note | None -> "追加资金直接计入可用现金,不产生份额、不分摊成本、不计入已实现收益。") ] | None -> Html.none ] ] 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 private sipFrequencyText (frequency: string) = if frequency = "weekly" then "每周" elif frequency = "biweekly" then "每两周" elif frequency = "monthly" then "每月" else frequency let private sipPlanRow (plan: SipPlan) = Html.div [ prop.className "order-row" prop.children [ Html.span [ prop.className "order-code"; prop.text plan.instrumentCode ] Html.span [ prop.className "order-cell"; prop.text (sprintf "金额 %s" plan.amount) ] Html.span [ prop.className "order-cell"; prop.text (sprintf "频率 %s" (sipFrequencyText plan.frequency)) ] Html.span [ prop.className "order-cell"; prop.text (sprintf "起投日 %s" plan.anchorDate) ] Html.span [ prop.className "order-cell"; prop.text (sprintf "下次扣款 %s" plan.nextTradeDate) ] let lastExecution = match plan.lastExecutionStatus with | Some status -> let label = if status = "succeeded" then "已执行" elif status = "pending_nav" then "待净值" elif status = "insufficient_cash" then "现金不足" elif status = "failed" then "失败" else status sprintf "最近执行 %s(%s)" label (plan.lastExecutionDate |> Option.defaultValue "—") | None -> "最近执行 暂无" Html.span [ prop.className "order-cell"; prop.text lastExecution ] Html.span [ prop.className "order-status"; prop.text (if plan.status = "active" then "进行中" else plan.status) ] ] ] let private sipPanel model dispatch = Html.section [ prop.className "panel sip-panel" prop.children [ Html.div [ prop.className "section-heading" prop.children [ Html.div [ Html.p [ prop.className "eyebrow"; prop.text "07 / SIP" ] Html.h2 "定期定额投资计划" ] Html.span [ prop.className "section-note"; prop.text "SIP · schedule only" ] ] ] 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 sip-code-input" prop.placeholder "六位基金代码" prop.value model.sipCode prop.onChange (fun value -> dispatch (SipCodeChanged value)) ] ] ] Html.label [ prop.className "field-label" prop.children [ Html.span "每期金额(元)" Html.input [ prop.className "text-input sip-amount-input" prop.placeholder "例如 200.00" prop.value model.sipAmount prop.onChange (fun value -> dispatch (SipAmountChanged value)) ] ] ] Html.label [ prop.className "field-label" prop.children [ Html.span "频率" Html.select [ prop.className "text-input sip-frequency-input" prop.value model.sipFrequency prop.onChange (fun value -> dispatch (SipFrequencyChanged value)) prop.children [ Html.option [ prop.value "weekly"; prop.text "每周" ] Html.option [ prop.value "biweekly"; prop.text "每两周" ] Html.option [ prop.value "monthly"; prop.text "每月" ] ] ] ] ] Html.button [ prop.className "primary-action sip-submit-action" prop.disabled model.sipInFlight prop.onClick (fun _ -> dispatch SipPlanCreateRequested) prop.text ((if model.sipInFlight then "创建中..." else "创建定投计划"): string) ] ] ] Html.p [ prop.className "hint" prop.text "计划只登记扣款节奏与金额;到期执行将在后续接入,当前不扣款、不动份额与净值。" ] Html.div [ prop.className "sip-plans" prop.children [ if List.isEmpty model.sipPlans then Html.p [ prop.className "hint"; prop.text "暂无定投计划" ] else yield! (model.sipPlans |> List.map sipPlanRow) ] ] Html.div [ prop.className "panel-actions" prop.children [ Html.button [ prop.className "secondary-action sip-plans-refresh-action" prop.onClick (fun _ -> dispatch SipPlansReadRequested) prop.text "刷新计划" ] ] ] ] ] let private rebalanceOutcomeRow (outcome: RebalanceOutcome) = let statusText = if outcome.status = "succeeded" then "已确认" elif outcome.status = "pending_nav" then "待净值" elif outcome.status = "insufficient_cash" then "现金不足" elif outcome.status = "insufficient_units" then "持仓不足" elif outcome.status = "failed" then "失败" elif outcome.status.StartsWith("skipped_") then "已跳过" else outcome.status let actionText = if outcome.action = "buy" then "买入" elif outcome.action = "sell" then "卖出" else outcome.action Html.div [ prop.className "order-row" prop.children [ Html.span [ prop.className "order-code"; prop.text outcome.instrumentCode ] Html.span [ prop.className "order-cell"; prop.text (sprintf "%s %s 元" actionText outcome.amount) ] Html.span [ prop.className "order-status"; prop.text statusText ] match outcome.pendingReason with | Some reason -> Html.span [ prop.className "order-detail"; prop.text reason ] | None -> Html.none ] ] let private rebalancePanel model dispatch = Html.section [ prop.className "panel rebalance-panel" prop.children [ Html.div [ prop.className "section-heading" prop.children [ Html.div [ Html.p [ prop.className "eyebrow"; prop.text "08 / REBALANCE" ] Html.h2 "组合再平衡" ] Html.span [ prop.className "section-note"; prop.text "Rebalance - manual run" ] ] ] Html.div [ prop.className "fund-form-row" prop.children [ Html.label [ prop.className "field-label" prop.children [ Html.span "目标基金 1" Html.input [ prop.className "text-input rebalance-code1-input" prop.placeholder "六位基金代码" prop.value model.rebalCode1 prop.onChange (fun value -> dispatch (RebalCode1Changed value)) ] ] ] Html.label [ prop.className "field-label" prop.children [ Html.span "目标比例" Html.input [ prop.className "text-input rebalance-percent1-input" prop.placeholder "例如 60.00" prop.value model.rebalPercent1 prop.onChange (fun value -> dispatch (RebalPercent1Changed value)) ] ] ] Html.label [ prop.className "field-label" prop.children [ Html.span "目标基金 2" Html.input [ prop.className "text-input rebalance-code2-input" prop.placeholder "六位基金代码" prop.value model.rebalCode2 prop.onChange (fun value -> dispatch (RebalCode2Changed value)) ] ] ] Html.label [ prop.className "field-label" prop.children [ Html.span "目标比例" Html.input [ prop.className "text-input rebalance-percent2-input" prop.placeholder "例如 40.00" prop.value model.rebalPercent2 prop.onChange (fun value -> dispatch (RebalPercent2Changed value)) ] ] ] Html.button [ prop.className "primary-action rebalance-submit-action" prop.disabled model.rebalInFlight prop.onClick (fun _ -> dispatch RebalancePlanCreateRequested) prop.text ((if model.rebalInFlight then "创建中..." else "创建计划"): string) ] ] ] Html.p [ prop.className "hint" prop.text "两个目标比例合计必须为 100;执行时按估值快照计算差额,经与手工单相同的下单与确认管线成交。" ] Html.div [ prop.className "rebalance-plans" prop.children [ if List.isEmpty model.rebalPlans then Html.p [ prop.className "hint"; prop.text "暂无再平衡计划" ] else yield! (model.rebalPlans |> List.map (fun plan -> Html.div [ prop.className "order-row" prop.children [ Html.span [ prop.className "order-cell" prop.text (plan.targets |> List.map (fun target -> sprintf "%s %s%%" target.instrumentCode target.targetPercent) |> String.concat " / ") ] (if plan.status = "active" then Html.button [ prop.className "secondary-action rebalance-execute-action" prop.disabled model.rebalExecuteInFlight prop.onClick (fun _ -> dispatch (RebalanceExecuteRequested plan.id)) prop.text ((if model.rebalExecuteInFlight then "执行中..." else "执行再平衡"): string) ] else Html.none) ] ])) ] ] match model.rebalResult with | Some outcomes -> Html.div [ prop.className "rebalance-result" prop.children [ Html.p [ prop.className "hint"; prop.text "最近一次执行:" ] yield! (outcomes |> List.map rebalanceOutcomeRow) ] ] | None -> Html.none Html.div [ prop.className "panel-actions" prop.children [ Html.button [ prop.className "secondary-action rebalance-plans-refresh-action" prop.onClick (fun _ -> dispatch RebalancePlansReadRequested) prop.text "刷新计划" ] ] ] ] ] let private dividendModeText (mode: string) = match mode with | "reinvest" -> "红利再投资" | _ -> "现金" let private dividendStatusText (status: string) = match status with | "cash_credited" | "succeeded" -> "已入账" | "pending_nav" -> "待净值" | other -> other let private dividendPanel model dispatch = Html.section [ prop.className "panel dividend-panel" prop.children [ Html.div [ prop.className "section-heading" prop.children [ Html.div [ Html.p [ prop.className "eyebrow"; prop.text "09 / DIVIDEND" ] Html.h2 "分红记录" ] Html.span [ prop.className "section-note"; prop.text "Dividends - booked history" ] ] ] Html.div [ prop.className "dividend-history" prop.children [ if List.isEmpty model.dividends then Html.p [ prop.className "hint"; prop.text "暂无分红记录" ] else yield! (model.dividends |> List.map (fun record -> Html.div [ prop.className "order-row dividend-row" prop.children [ Html.span [ prop.className "order-code"; prop.text record.instrumentCode ] Html.span [ prop.className "order-cell" prop.text (sprintf "持仓日期 %s" record.navDate) ] Html.span [ prop.className "order-cell" prop.text (sprintf "类型 %s" (dividendModeText record.mode)) ] Html.span [ prop.className "order-cell" prop.text (sprintf "每份分红 %s" record.dps) ] match record.grossCash with | Some cash -> Html.span [ prop.className "order-cell" prop.text (sprintf "入账现金 %s" cash) ] | None -> Html.none match record.creditedUnits with | Some units -> Html.span [ prop.className "order-cell" prop.text (sprintf "入账份额 %s" units) ] | None -> Html.none Html.span [ prop.className "order-status"; prop.text (dividendStatusText record.status) ] Html.span [ prop.className "order-cell dividend-key" prop.title record.sourceKey prop.text record.sourceKey ] ] ])) ] ] Html.div [ prop.className "panel-actions" prop.children [ Html.button [ prop.className "secondary-action dividend-refresh-action" prop.disabled model.dividendInFlight prop.onClick (fun _ -> dispatch DividendsReadRequested) prop.text ((if model.dividendInFlight then "读取中..." else "刷新分红记录"): string) ] ] ] ] ] let private moneyText (text: string) = match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with | true, value -> value.ToString("0.00", CultureInfo.InvariantCulture) | false, _ -> text let private shortTimestamp (text: string) = if String.IsNullOrWhiteSpace text then "暂无" else let normalized = text.Replace("T", " ") if normalized.Length >= 16 then normalized.Substring(0, 16) else normalized let private returnsChartView (points: ReturnsPoint list) = let dates = points |> List.map (fun point -> point.date) let totalSeries = points |> List.choose (fun point -> point.totalAssets |> Option.map (fun value -> point.date, value)) let navSeries = points |> List.choose (fun point -> point.unitNav |> Option.map (fun value -> point.date, value)) let totalPoints = Chart.seriesPoints dates totalSeries let navPoints = Chart.seriesPoints dates navSeries if List.isEmpty totalPoints && List.isEmpty navPoints 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 (series: SeriesPoint list) = series |> 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 color (series: SeriesPoint list) = coordinates series |> List.map (fun (x, y) -> Svg.circle [ svg.cx x svg.cy y svg.r 3.5 svg.fill color ]) Html.div [ prop.className "chart-wrap returns-chart" prop.children [ Svg.svg [ svg.className "nav-chart" svg.viewBox (0, 0, 960, 320) svg.children [ yield! gridLines if List.length totalPoints >= 2 then yield Svg.polyline [ svg.points (coordinates totalPoints) svg.fill "none" svg.stroke "#1e40af" svg.strokeWidth 3 ] if List.length navPoints >= 2 then yield Svg.polyline [ svg.points (coordinates navPoints) svg.fill "none" svg.stroke "#f59e0b" svg.strokeWidth 3 ] yield! markers "#1e40af" totalPoints yield! markers "#f59e0b" navPoints ] ] Html.div [ prop.className "chart-axis" prop.children [ Html.span [ prop.text (points |> List.head |> fun point -> point.date) ] Html.span [ prop.text (points |> List.last |> fun point -> point.date) ] ] ] ] ] let private returnsPanel model dispatch = let latest (selector: ReturnsPoint -> string option) = model.returns |> Option.bind (fun returns -> returns.points |> List.rev |> List.tryPick selector) let totalAssets = latest (fun point -> point.totalAssets) let cumulativeReturn = latest (fun point -> point.cumulativeReturn) let updatedAt = model.returns |> Option.bind (fun returns -> returns.dataUpdatedAt) Html.section [ prop.className "panel returns-panel" prop.children [ Html.div [ prop.className "section-heading" prop.children [ Html.div [ Html.p [ prop.className "eyebrow"; prop.text "10 / RETURNS" ] Html.h2 "收益与资产曲线" ] Html.span [ prop.className "section-note"; prop.text "Returns - cash-flow adjusted" ] ] ] match model.returns with | None -> Html.p [ prop.className "hint"; prop.text "创建基金后可读取收益曲线。" ] | Some returns when List.isEmpty returns.points -> Html.p [ prop.className "hint"; prop.text "暂无收益数据" ] | Some returns -> 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 (totalAssets |> Option.map moneyText |> Option.defaultValue "暂无") ] ] Html.div [ prop.className "metric" prop.children [ Html.span "累计收益" Html.strong (cumulativeReturn |> Option.map moneyText |> Option.defaultValue "暂无") ] ] Html.div [ prop.className "metric" prop.children [ Html.span "数据更新时间" Html.strong (updatedAt |> Option.map shortTimestamp |> Option.defaultValue "暂无") ] ] ] ] returnsChartView returns.points Html.p [ prop.className "returns-note" prop.text "蓝线 总资产 / 橙线 单位净值(各自归一化);缺少净值的数据点不连线,也不补零。" ] if returns.pending then Html.p [ prop.className "returns-pending" prop.text "部分持有基金缺少估值日净值,相关日期标记为待更新。" ] ] ] Html.div [ prop.className "panel-actions" prop.children [ Html.button [ prop.className "secondary-action returns-refresh-action" prop.disabled model.returnsInFlight prop.onClick (fun _ -> dispatch ReturnsReadRequested) prop.text ((if model.returnsInFlight then "读取中..." else "刷新收益曲线"): string) ] ] ] ] ] 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 sipPanel model dispatch rebalancePanel model dispatch dividendPanel model dispatch returnsPanel 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