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 RawRebalanceExecutionRecord = { planId: string runDate: string instrumentCode: string action: string amount: string status: string orderId: obj pendingReason: obj executedAt: string } type RawRebalanceWeightRow = { instrumentCode: string targetPercent: string currentValue: string currentPercent: string action: string deltaAmount: string deltaUnits: obj } type RawRebalancePreview = { planId: string runDate: string availableCash: string equity: string rows: RawRebalanceWeightRow array } type RawSipPlan = { id: string instrumentCode: string amount: string frequency: string status: string anchorDate: string nextTradeDate: string lastExecutionStatus: obj lastExecutionDate: obj } type RawInvestmentPlan = { id: string fundId: string instrumentCode: string amount: string frequency: string status: string anchorDate: string nextRunDate: string lastRunStatus: obj lastRunDate: obj isSynthetic: bool createdAt: string } type RawInvestmentPlanRunOutcome = { runDate: string status: string orderId: obj pendingReason: obj } type RawInvestmentPlanRunPlan = { planId: string instrumentCode: string amount: string frequency: string runs: RawInvestmentPlanRunOutcome array nextRunDate: string } type RawInvestmentPlanRun = { fundId: string processingDate: string plans: RawInvestmentPlanRunPlan array } 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 RawMarketNavDates = { code: string dates: string array } type RawMarketNavSeriesPoint = { navDate: string nav: string accumulatedNav: obj } type RawMarketNavSeries = { code: string points: RawMarketNavSeriesPoint array } type RawBondQuote = { code: string name: obj price: obj cleanPrice: obj accruedInterest: obj date: obj maturityDate: obj parValue: obj valueDate: obj couponRate: obj rating: obj dataStatus: obj accruedInterestComputed: obj dirtyPrice: obj valuationDate: obj } type RawStockQuote = { code: string name: obj price: obj currency: string } type RawStockDailyObservation = { date: string close: string volume: obj amount: obj } type RawStockDaily = { code: string observations: RawStockDailyObservation array } type RawStockTrade = { id: string fundId: string instrumentCode: string stockName: obj quantity: string price: string costCash: string executedAt: string isSynthetic: bool } type RawStockPosition = { instrumentCode: string stockName: obj quantity: string costCash: string lastTradedAt: string } type RawStockPositions = { fundId: string positions: RawStockPosition array } type RawStockSell = { id: string fundId: string instrumentCode: string stockName: obj quantity: string price: string feeAmount: string proceeds: string executedAt: string isSynthetic: bool } type RawBondTrade = { id: string fundId: string instrumentCode: string bondName: obj quantity: string price: string costCash: string executedAt: string isSynthetic: bool } type RawBondPosition = { instrumentCode: string bondName: obj quantity: string costCash: string lastTradedAt: string } type RawBondPositions = { fundId: string positions: RawBondPosition array } type RawBondSell = { id: string fundId: string instrumentCode: string bondName: obj quantity: string price: string cleanPrice: string accruedInterest: string parValue: string settlementDate: string tradeDate: string feeAmount: string proceeds: string costReleased: string realizedPnl: string executedAt: string isSynthetic: bool } type RawBondCalendarEntry = { instrumentCode: string bondName: obj eventType: string eventDate: string quantity: string source: string amount: obj note: obj } type RawBondCalendar = { fundId: string fromDate: string toDate: string entries: RawBondCalendarEntry array } type RawValuationPosition = { instrumentCode: string name: obj assetClass: string quantity: string price: obj priceSource: obj marketValue: obj status: string } type RawFundValuation = { fundId: string currency: string cash: string positionsMarketValue: string portfolioValue: string pricedPositions: int unavailablePositions: int positions: RawValuationPosition array } type RawCashLedgerEvent = { source: string eventDate: string amount: string referenceType: string referenceId: string note: obj } type RawCashLedgerSource = { source: string netAmount: string eventCount: int } type RawCashReconciliation = { fundId: string openingCash: string netInflow: string closingCash: string ledgerBalance: string difference: string sources: RawCashLedgerSource array events: RawCashLedgerEvent array } type RawMarketRefreshTarget = { instrumentCode: string assetClass: string snapshotDate: string price: string } type RawMarketRefreshFailure = { instrumentCode: string assetClass: string reason: string } type RawMarketRefresh = { fundId: string asOfDate: string refreshed: RawMarketRefreshTarget array failures: RawMarketRefreshFailure 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 CreateStockTradePayload = { idempotencyKey: string instrumentCode: string quantity: string } type CreateStockSellPayload = { idempotencyKey: string instrumentCode: string quantity: string } type StockSellAttempt = { idempotencyKey: string instrumentCode: string quantity: string } type CreateBondTradePayload = { idempotencyKey: string instrumentCode: string quantity: string } type SipPlan = { id: string instrumentCode: string amount: string frequency: string status: string anchorDate: string nextTradeDate: string lastExecutionStatus: string option lastExecutionDate: string option } type InvestmentPlan = { id: string instrumentCode: string amount: string frequency: string status: string anchorDate: string nextRunDate: string lastRunStatus: string option lastRunDate: string option } type InvestmentPlanRunOutcome = { runDate: string status: string orderId: string option pendingReason: string option } type InvestmentPlanRunPlan = { planId: string instrumentCode: string amount: string frequency: string runs: InvestmentPlanRunOutcome list nextRunDate: string } type InvestmentPlanRun = { fundId: string processingDate: string plans: InvestmentPlanRunPlan list } 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 RebalanceTargetDraft = { code: string percent: string } type RebalanceExecutionRecord = { planId: string runDate: string instrumentCode: string action: string amount: string status: string orderId: string option pendingReason: string option executedAt: string } type RebalanceWeightRow = { instrumentCode: string targetPercent: string currentValue: string currentPercent: string action: string deltaAmount: string deltaUnits: string option } type RebalancePreview = { planId: string runDate: string availableCash: string equity: string rows: RebalanceWeightRow list } 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 } type MarketNavSeriesPoint = { navDate: string nav: string accumulatedNav: string option } type BondQuoteView = { code: string name: string option price: string option cleanPrice: string option accruedInterest: string option quoteDate: string option maturityDate: string option parValue: string option valueDate: string option couponRate: string option rating: string option dataStatus: string option accruedInterestComputed: string option dirtyPrice: string option valuationDate: string option } type StockQuoteView = { code: string name: string option price: string option currency: string } type StockDailyBar = { date: string close: string volume: string option amount: string option } type StockTradeView = { id: string instrumentCode: string stockName: string option quantity: string price: string costCash: string executedAt: string } type StockPositionView = { instrumentCode: string stockName: string option quantity: string costCash: string lastTradedAt: string } type StockTradeAttempt = { idempotencyKey: string instrumentCode: string quantity: string } type BondTradeView = { id: string instrumentCode: string bondName: string option quantity: string price: string costCash: string executedAt: string } type BondPositionView = { instrumentCode: string bondName: string option quantity: string costCash: string lastTradedAt: string } type ValuationPositionView = { instrumentCode: string name: string option assetClass: string quantity: string price: string option priceSource: string option marketValue: string option status: string } type FundValuationView = { currency: string cash: string positionsMarketValue: string portfolioValue: string pricedPositions: int unavailablePositions: int positions: ValuationPositionView list } type CashLedgerEventView = { source: string eventDate: string amount: string referenceType: string referenceId: string note: string option } type CashLedgerSourceView = { source: string netAmount: string eventCount: int } type CashReconciliationView = { openingCash: string netInflow: string closingCash: string ledgerBalance: string difference: string sources: CashLedgerSourceView list events: CashLedgerEventView list } type BondTradeAttempt = { idempotencyKey: string instrumentCode: string quantity: string } module Api = [] let searchInstruments (token: string) (query: string) : JS.Promise = jsNative [] let getNav (token: string) (code: string) : JS.Promise = jsNative [] let refreshNav (token: string) (code: string) : JS.Promise = jsNative [] let createFund (token: string) (payload: CreateFundPayload) : JS.Promise = jsNative [] let getFund (token: string) (fundId: string) : JS.Promise = jsNative [] let 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 getMarketNavDates (token: string) (code: string) : JS.Promise = jsNative [] let getMarketNavSeries (token: string) (code: string) : JS.Promise = jsNative [] let getBondQuote (token: string) (code: string) : JS.Promise = jsNative [] let getStockQuote (token: string) (code: string) : JS.Promise = jsNative [] let getStockDaily (token: string) (code: string) (days: int) : JS.Promise = jsNative [] let createStockTrade (token: string) (fundId: string) (payload: CreateStockTradePayload) : JS.Promise = jsNative [] let getStockPositions (token: string) (fundId: string) : JS.Promise = jsNative [] let createStockSell (token: string) (fundId: string) (payload: CreateStockSellPayload) : JS.Promise = jsNative [] let createBondTrade (token: string) (fundId: string) (payload: CreateBondTradePayload) : JS.Promise = jsNative [] let getBondPositions (token: string) (fundId: string) : JS.Promise = jsNative [] let createBondSell (token: string) (fundId: string) (payload: CreateBondTradePayload) : JS.Promise = jsNative [] let getBondCalendar (token: string) (fundId: string) : JS.Promise = jsNative [] let getFundValuation (token: string) (fundId: string) : JS.Promise = jsNative [] let getCashReconciliation (token: string) (fundId: string) : JS.Promise = jsNative [] let refreshFundMarketData (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 archiveRebalancePlan (token: string) (fundId: string) (planId: string) : JS.Promise = jsNative [] let executeRebalancePlan (token: string) (fundId: string) (planId: string) : JS.Promise = jsNative [] let previewRebalancePlan (token: string) (fundId: string) (planId: string) : JS.Promise = jsNative [] let getRebalanceExecutions (token: string) (fundId: 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 pauseSipPlan (token: string) (fundId: string) (planId: string) : JS.Promise = jsNative [] let resumeSipPlan (token: string) (fundId: string) (planId: string) : JS.Promise = jsNative [] let getInvestmentPlans (token: string) (fundId: string) : JS.Promise = jsNative [] let runInvestmentPlans (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 decodeRebalanceExecutionRecord (raw: RawRebalanceExecutionRecord) : RebalanceExecutionRecord = { planId = raw.planId runDate = raw.runDate instrumentCode = raw.instrumentCode action = raw.action amount = raw.amount status = raw.status orderId = decodeOptionalText raw.orderId pendingReason = decodeOptionalText raw.pendingReason executedAt = raw.executedAt } let decodeRebalanceWeightRow (raw: RawRebalanceWeightRow) : RebalanceWeightRow = { instrumentCode = raw.instrumentCode targetPercent = raw.targetPercent currentValue = raw.currentValue currentPercent = raw.currentPercent action = raw.action deltaAmount = raw.deltaAmount deltaUnits = decodeOptionalText raw.deltaUnits } let decodeRebalancePreview (raw: RawRebalancePreview) : RebalancePreview = { planId = raw.planId runDate = raw.runDate availableCash = raw.availableCash equity = raw.equity rows = raw.rows |> Array.map decodeRebalanceWeightRow |> Array.toList } 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 decodeInvestmentPlan (raw: RawInvestmentPlan) : InvestmentPlan = { id = raw.id instrumentCode = raw.instrumentCode amount = raw.amount frequency = raw.frequency status = raw.status anchorDate = raw.anchorDate nextRunDate = raw.nextRunDate lastRunStatus = decodeOptionalText raw.lastRunStatus lastRunDate = decodeOptionalText raw.lastRunDate } let decodeInvestmentPlanRunOutcome (raw: RawInvestmentPlanRunOutcome) : InvestmentPlanRunOutcome = { runDate = raw.runDate status = raw.status orderId = decodeOptionalText raw.orderId pendingReason = decodeOptionalText raw.pendingReason } let decodeInvestmentPlanRunPlan (raw: RawInvestmentPlanRunPlan) : InvestmentPlanRunPlan = { planId = raw.planId instrumentCode = raw.instrumentCode amount = raw.amount frequency = raw.frequency runs = raw.runs |> Array.map decodeInvestmentPlanRunOutcome |> Array.toList nextRunDate = raw.nextRunDate } let decodeInvestmentPlanRun (raw: RawInvestmentPlanRun) : InvestmentPlanRun = { fundId = raw.fundId processingDate = raw.processingDate plans = raw.plans |> Array.map decodeInvestmentPlanRunPlan |> Array.toList } 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 } let decodeNavSeriesPoint (raw: RawMarketNavSeriesPoint) : MarketNavSeriesPoint = { navDate = raw.navDate nav = raw.nav accumulatedNav = decodeOptionalText raw.accumulatedNav } let decodeBondQuote (raw: RawBondQuote) : BondQuoteView = { code = raw.code name = decodeOptionalText raw.name price = decodeOptionalText raw.price cleanPrice = decodeOptionalText raw.cleanPrice accruedInterest = decodeOptionalText raw.accruedInterest quoteDate = decodeOptionalText raw.date maturityDate = decodeOptionalText raw.maturityDate parValue = decodeOptionalText raw.parValue valueDate = decodeOptionalText raw.valueDate couponRate = decodeOptionalText raw.couponRate rating = decodeOptionalText raw.rating dataStatus = decodeOptionalText raw.dataStatus accruedInterestComputed = decodeOptionalText raw.accruedInterestComputed dirtyPrice = decodeOptionalText raw.dirtyPrice valuationDate = decodeOptionalText raw.valuationDate } let decodeStockQuote (raw: RawStockQuote) : StockQuoteView = { code = raw.code name = decodeOptionalText raw.name price = decodeOptionalText raw.price currency = raw.currency } let decodeStockDailyBar (raw: RawStockDailyObservation) : StockDailyBar = { date = raw.date close = raw.close volume = decodeOptionalText raw.volume amount = decodeOptionalText raw.amount } let decodeStockTrade (raw: RawStockTrade) : StockTradeView = { id = raw.id instrumentCode = raw.instrumentCode stockName = decodeOptionalText raw.stockName quantity = raw.quantity price = raw.price costCash = raw.costCash executedAt = raw.executedAt } let decodeStockPosition (raw: RawStockPosition) : StockPositionView = { instrumentCode = raw.instrumentCode stockName = decodeOptionalText raw.stockName quantity = raw.quantity costCash = raw.costCash lastTradedAt = raw.lastTradedAt } let decodeBondTrade (raw: RawBondTrade) : BondTradeView = { id = raw.id instrumentCode = raw.instrumentCode bondName = decodeOptionalText raw.bondName quantity = raw.quantity price = raw.price costCash = raw.costCash executedAt = raw.executedAt } let decodeBondPosition (raw: RawBondPosition) : BondPositionView = { instrumentCode = raw.instrumentCode bondName = decodeOptionalText raw.bondName quantity = raw.quantity costCash = raw.costCash lastTradedAt = raw.lastTradedAt } let decodeValuationPosition (raw: RawValuationPosition) : ValuationPositionView = { instrumentCode = raw.instrumentCode name = decodeOptionalText raw.name assetClass = raw.assetClass quantity = raw.quantity price = decodeOptionalText raw.price priceSource = decodeOptionalText raw.priceSource marketValue = decodeOptionalText raw.marketValue status = raw.status } let decodeFundValuation (raw: RawFundValuation) : FundValuationView = { currency = raw.currency cash = raw.cash positionsMarketValue = raw.positionsMarketValue portfolioValue = raw.portfolioValue pricedPositions = raw.pricedPositions unavailablePositions = raw.unavailablePositions positions = raw.positions |> Array.toList |> List.map decodeValuationPosition } let decodeCashLedgerEvent (raw: RawCashLedgerEvent) : CashLedgerEventView = { source = raw.source eventDate = raw.eventDate amount = raw.amount referenceType = raw.referenceType referenceId = raw.referenceId note = Option.ofObj raw.note |> Option.map string } let decodeCashLedgerSource (raw: RawCashLedgerSource) : CashLedgerSourceView = { source = raw.source netAmount = raw.netAmount eventCount = raw.eventCount } let decodeCashReconciliation (raw: RawCashReconciliation) : CashReconciliationView = { openingCash = raw.openingCash netInflow = raw.netInflow closingCash = raw.closingCash ledgerBalance = raw.ledgerBalance difference = raw.difference sources = raw.sources |> Array.toList |> List.map decodeCashLedgerSource events = raw.events |> Array.toList |> List.map decodeCashLedgerEvent } 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 sipStatusSeq: int sipStatusInFlight: bool lastSipAttempt: SipAttempt option sipPlans: SipPlan list rebalTargets: RebalanceTargetDraft list rebalCreateSeq: int rebalReadSeq: int rebalArchiveSeq: int rebalArchiveInFlight: bool rebalInFlight: bool rebalExecuteSeq: int rebalExecuteInFlight: bool rebalPlans: RebalancePlan list rebalResult: RebalanceOutcome list option rebalPreviewSeq: int rebalPreviewInFlight: bool rebalPreview: RebalancePreview option rebalExecutionsReadSeq: int rebalExecutionsInFlight: bool rebalExecutions: RebalanceExecutionRecord list dividendReadSeq: int dividendInFlight: bool dividends: DividendRecord list returnsReadSeq: int returnsInFlight: bool returns: FundReturns option navDatesReadSeq: int navDatesInFlight: bool navDates: string list navDatesAvailable: bool navSeriesReadSeq: int navSeriesInFlight: bool navSeries: MarketNavSeriesPoint list navSeriesAvailable: bool bondCode: string bondQuoteSeq: int bondQuoteInFlight: bool bondQuote: BondQuoteView option bondQuoteMessage: string option stockCode: string stockQuoteSeq: int stockQuoteInFlight: bool stockQuote: StockQuoteView option stockQuoteMessage: string option stockDailySeq: int stockDailyInFlight: bool stockDaily: StockDailyBar list stockDailyMessage: string option stockTradeQuantity: string stockTradeSeq: int stockTradeInFlight: bool lastStockTrade: StockTradeView option lastStockTradeAttempt: StockTradeAttempt option stockTradeMessage: string option stockPositionsReadSeq: int stockPositionsInFlight: bool stockPositions: StockPositionView list stockPositionsMessage: string option stockSellQuantities: Map stockSellAttempts: Map stockSellSeq: int stockSellInFlight: bool stockSellMessage: string option bondTradeQuantity: string bondTradeSeq: int bondTradeInFlight: bool lastBondTrade: BondTradeView option lastBondTradeAttempt: BondTradeAttempt option bondTradeMessage: string option bondPositionsReadSeq: int bondPositionsInFlight: bool bondPositions: BondPositionView list bondPositionsMessage: string option bondSellQuantity: string bondSellSeq: int bondSellInFlight: bool bondSellMessage: string option bondCalendarSeq: int bondCalendarInFlight: bool bondCalendarEntries: string list bondCalendarMessage: string option valuationReadSeq: int valuationInFlight: bool valuation: FundValuationView option valuationMessage: string option cashLedgerReadSeq: int cashLedgerInFlight: bool cashReconciliation: CashReconciliationView option cashLedgerMessage: string option cashLedgerFilter: string marketRefreshSeq: int marketRefreshInFlight: bool marketRefreshMessage: string option returnsSelectedDate: string option planReadSeq: int planInFlight: bool planRunSeq: int planRunInFlight: bool investmentPlans: InvestmentPlan list lastPlanRun: InvestmentPlanRun 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 | SipPlanPauseRequested of planId: string | SipPlanResumeRequested of planId: string | SipPlanStatusChanged of requestId: int * plan: RawSipPlan | SipPlanStatusChangeFailed of requestId: int * message: string | RebalTargetCodeChanged of index: int * value: string | RebalTargetPercentChanged of index: int * value: string | RebalTargetAdded | RebalTargetRemoved of index: int | 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 | RebalancePlanArchiveRequested of planId: string | RebalancePlanArchived of requestId: int * plan: RawRebalancePlan | RebalancePlanArchiveFailed of requestId: int * message: string | RebalancePreviewRequested of planId: string | RebalancePreviewCompleted of requestId: int * preview: RawRebalancePreview | RebalancePreviewFailed of requestId: int * message: string | RebalanceExecuteRequested of planId: string | RebalanceExecuted of requestId: int * result: RawRebalanceExecution | RebalanceExecuteFailed of requestId: int * message: string | RebalanceExecutionsReadRequested | RebalanceExecutionsReadCompleted of requestId: int * records: RawRebalanceExecutionRecord array | RebalanceExecutionsReadFailed 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 | NavDatesReadRequested | NavDatesReadCompleted of requestId: int * dates: RawMarketNavDates | NavDatesReadFailed of requestId: int * message: string | NavSeriesReadRequested | NavSeriesReadCompleted of requestId: int * series: RawMarketNavSeries | NavSeriesReadFailed of requestId: int * message: string | BondCodeChanged of string | BondQuoteRequested | BondQuoteCompleted of requestId: int * quote: RawBondQuote | BondQuoteFailed of requestId: int * message: string | StockCodeChanged of string | StockQuoteRequested | StockQuoteCompleted of requestId: int * quote: RawStockQuote | StockQuoteFailed of requestId: int * message: string | StockDailyRequested | StockDailyCompleted of requestId: int * daily: RawStockDaily | StockDailyFailed of requestId: int * message: string | StockTradeQuantityChanged of string | StockTradeRequested | StockTradeCompleted of requestId: int * fundId: string * trade: RawStockTrade | StockTradeFailed of requestId: int * fundId: string * message: string | StockPositionsReadRequested | StockPositionsReadCompleted of requestId: int * fundId: string * positions: RawStockPositions | StockPositionsReadFailed of requestId: int * fundId: string * message: string | StockSellQuantityChanged of code: string * value: string | StockSellRequested of code: string | StockSellCompleted of requestId: int * fundId: string * code: string * sell: RawStockSell | StockSellFailed of requestId: int * fundId: string * code: string * message: string | BondTradeQuantityChanged of string | BondTradeRequested | BondTradeCompleted of requestId: int * fundId: string * trade: RawBondTrade | BondTradeFailed of requestId: int * fundId: string * message: string | BondPositionsReadRequested | BondPositionsReadCompleted of requestId: int * fundId: string * positions: RawBondPositions | BondPositionsReadFailed of requestId: int * fundId: string * message: string | BondSellQuantityChanged of string | BondSellRequested | BondSellCompleted of requestId: int * fundId: string * sell: RawBondSell | BondSellFailed of requestId: int * fundId: string * message: string | BondCalendarReadRequested | BondCalendarReadCompleted of requestId: int * fundId: string * calendar: RawBondCalendar | BondCalendarReadFailed of requestId: int * fundId: string * message: string | ValuationReadRequested | ValuationReadCompleted of requestId: int * fundId: string * valuation: RawFundValuation | ValuationReadFailed of requestId: int * fundId: string * message: string | CashLedgerReadRequested | CashLedgerReadCompleted of requestId: int * fundId: string * reconciliation: RawCashReconciliation | CashLedgerReadFailed of requestId: int * fundId: string * message: string | CashLedgerFilterChanged of string | MarketRefreshRequested | MarketRefreshCompleted of requestId: int * fundId: string * response: RawMarketRefresh | MarketRefreshFailed of requestId: int * fundId: string * message: string | ReturnsDateChanged of string | InvestmentPlansReadRequested | InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array | InvestmentPlansReadFailed of requestId: int * message: string | InvestmentPlansRunRequested | InvestmentPlansRunCompleted of requestId: int * fundId: string * run: RawInvestmentPlanRun | InvestmentPlansRunFailed of requestId: int * fundId: 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 private quantityPattern = Text.RegularExpressions.Regex("^[0-9]{1,18}$", Text.RegularExpressions.RegexOptions.Compiled) let isValidQuantityText (text: string) = quantityPattern.IsMatch text && 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 sipStatusSeq = 0 sipStatusInFlight = false lastSipAttempt = None sipPlans = [] rebalTargets = [ { code = ""; percent = "" }; { code = ""; percent = "" } ] rebalCreateSeq = 0 rebalReadSeq = 0 rebalArchiveSeq = 0 rebalArchiveInFlight = false rebalInFlight = false rebalExecuteSeq = 0 rebalExecuteInFlight = false rebalPlans = [] rebalResult = None rebalPreviewSeq = 0 rebalPreviewInFlight = false rebalPreview = None rebalExecutionsReadSeq = 0 rebalExecutionsInFlight = false rebalExecutions = [] dividendReadSeq = 0 dividendInFlight = false dividends = [] returnsReadSeq = 0 returnsInFlight = false returns = None navDatesReadSeq = 0 navDatesInFlight = false navDates = [] navDatesAvailable = false navSeriesReadSeq = 0 navSeriesInFlight = false navSeries = [] navSeriesAvailable = false bondCode = "" bondQuoteSeq = 0 bondQuoteInFlight = false bondQuote = None bondQuoteMessage = None stockCode = "" stockQuoteSeq = 0 stockQuoteInFlight = false stockQuote = None stockQuoteMessage = None stockDailySeq = 0 stockDailyInFlight = false stockDaily = [] stockDailyMessage = None stockTradeQuantity = "" stockTradeSeq = 0 stockTradeInFlight = false lastStockTrade = None lastStockTradeAttempt = None stockTradeMessage = None stockPositionsReadSeq = 0 stockPositionsInFlight = false stockPositions = [] stockPositionsMessage = None stockSellQuantities = Map.empty stockSellAttempts = Map.empty stockSellSeq = 0 stockSellInFlight = false stockSellMessage = None bondTradeQuantity = "" bondTradeSeq = 0 bondTradeInFlight = false lastBondTrade = None lastBondTradeAttempt = None bondTradeMessage = None bondPositionsReadSeq = 0 bondPositionsInFlight = false bondPositions = [] bondPositionsMessage = None bondSellQuantity = "" bondSellSeq = 0 bondSellInFlight = false bondSellMessage = None bondCalendarSeq = 0 bondCalendarInFlight = false bondCalendarEntries = [] bondCalendarMessage = None valuationReadSeq = 0 valuationInFlight = false valuation = None valuationMessage = None cashLedgerReadSeq = 0 cashLedgerInFlight = false cashReconciliation = None cashLedgerMessage = None cashLedgerFilter = "" marketRefreshSeq = 0 marketRefreshInFlight = false marketRefreshMessage = None returnsSelectedDate = None planReadSeq = 0 planInFlight = false planRunSeq = 0 planRunInFlight = false investmentPlans = [] lastPlanRun = 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 pauseSipPlanCommand token fundId planId requestId = Cmd.OfPromise.either (fun () -> Api.pauseSipPlan token fundId planId) () (fun plan -> SipPlanStatusChanged(requestId, plan)) (fun error -> SipPlanStatusChangeFailed(requestId, errorText error)) let private resumeSipPlanCommand token fundId planId requestId = Cmd.OfPromise.either (fun () -> Api.resumeSipPlan token fundId planId) () (fun plan -> SipPlanStatusChanged(requestId, plan)) (fun error -> SipPlanStatusChangeFailed(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 archiveRebalancePlanCommand token fundId planId requestId = Cmd.OfPromise.either (fun () -> Api.archiveRebalancePlan token fundId planId) () (fun plan -> RebalancePlanArchived(requestId, plan)) (fun error -> RebalancePlanArchiveFailed(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 previewRebalanceCommand token fundId planId requestId = Cmd.OfPromise.either (fun () -> Api.previewRebalancePlan token fundId planId) () (fun preview -> RebalancePreviewCompleted(requestId, preview)) (fun error -> RebalancePreviewFailed(requestId, errorText error)) let private readRebalanceExecutionsCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getRebalanceExecutions token fundId) () (fun records -> RebalanceExecutionsReadCompleted(requestId, records)) (fun error -> RebalanceExecutionsReadFailed(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 private readNavDatesCommand token code requestId = Cmd.OfPromise.either (fun () -> Api.getMarketNavDates token code) () (fun dates -> NavDatesReadCompleted(requestId, dates)) (fun error -> NavDatesReadFailed(requestId, errorText error)) let private readNavSeriesCommand token code requestId = Cmd.OfPromise.either (fun () -> Api.getMarketNavSeries token code) () (fun series -> NavSeriesReadCompleted(requestId, series)) (fun error -> NavSeriesReadFailed(requestId, errorText error)) let private readBondQuoteCommand token code requestId = Cmd.OfPromise.either (fun () -> Api.getBondQuote token code) () (fun quote -> BondQuoteCompleted(requestId, quote)) (fun error -> BondQuoteFailed(requestId, errorText error)) let private readStockQuoteCommand token code requestId = Cmd.OfPromise.either (fun () -> Api.getStockQuote token code) () (fun quote -> StockQuoteCompleted(requestId, quote)) (fun error -> StockQuoteFailed(requestId, errorText error)) let private readStockDailyCommand token code days requestId = Cmd.OfPromise.either (fun () -> Api.getStockDaily token code days) () (fun daily -> StockDailyCompleted(requestId, daily)) (fun error -> StockDailyFailed(requestId, errorText error)) let private createStockTradeCommand token fundId payload requestId = Cmd.OfPromise.either (fun () -> Api.createStockTrade token fundId payload) () (fun trade -> StockTradeCompleted(requestId, fundId, trade)) (fun error -> StockTradeFailed(requestId, fundId, errorText error)) let private readStockPositionsCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getStockPositions token fundId) () (fun positions -> StockPositionsReadCompleted(requestId, fundId, positions)) (fun error -> StockPositionsReadFailed(requestId, fundId, errorText error)) let private createStockSellCommand token fundId payload requestId = Cmd.OfPromise.either (fun () -> Api.createStockSell token fundId payload) () (fun sell -> StockSellCompleted(requestId, fundId, payload.instrumentCode, sell)) (fun error -> StockSellFailed(requestId, fundId, payload.instrumentCode, errorText error)) let private createBondTradeCommand token fundId payload requestId = Cmd.OfPromise.either (fun () -> Api.createBondTrade token fundId payload) () (fun trade -> BondTradeCompleted(requestId, fundId, trade)) (fun error -> BondTradeFailed(requestId, fundId, errorText error)) let private readBondPositionsCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getBondPositions token fundId) () (fun positions -> BondPositionsReadCompleted(requestId, fundId, positions)) (fun error -> BondPositionsReadFailed(requestId, fundId, errorText error)) let private createBondSellCommand token fundId payload requestId = Cmd.OfPromise.either (fun () -> Api.createBondSell token fundId payload) () (fun sell -> BondSellCompleted(requestId, fundId, sell)) (fun error -> BondSellFailed(requestId, fundId, errorText error)) let private readBondCalendarCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getBondCalendar token fundId) () (fun calendar -> BondCalendarReadCompleted(requestId, fundId, calendar)) (fun error -> BondCalendarReadFailed(requestId, fundId, errorText error)) let private readValuationCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getFundValuation token fundId) () (fun valuation -> ValuationReadCompleted(requestId, fundId, valuation)) (fun error -> ValuationReadFailed(requestId, fundId, errorText error)) let private refreshMarketDataCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.refreshFundMarketData token fundId) () (fun response -> MarketRefreshCompleted(requestId, fundId, response)) (fun error -> MarketRefreshFailed(requestId, fundId, errorText error)) let private readCashLedgerCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getCashReconciliation token fundId) () (fun reconciliation -> CashLedgerReadCompleted(requestId, fundId, reconciliation)) (fun error -> CashLedgerReadFailed(requestId, fundId, errorText error)) let private readInvestmentPlansCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getInvestmentPlans token fundId) () (fun plans -> InvestmentPlansReadCompleted(requestId, plans)) (fun error -> InvestmentPlansReadFailed(requestId, errorText error)) let private runInvestmentPlansCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.runInvestmentPlans token fundId) () (fun run -> InvestmentPlansRunCompleted(requestId, fundId, run)) (fun error -> InvestmentPlansRunFailed(requestId, fundId, errorText error)) let private dispatchSipStatusChange model planId pause = match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.sipStatusInFlight -> let requestId = model.sipStatusSeq + 1 { model with sipStatusSeq = requestId; sipStatusInFlight = true; error = None }, (if pause then pauseSipPlanCommand model.token fund.id planId requestId else resumeSipPlanCommand model.token fund.id planId requestId) | _ -> model, Cmd.none 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 = [] rebalTargets = [ { code = ""; percent = "" }; { code = ""; percent = "" } ] rebalCreateSeq = model.rebalCreateSeq + 1 rebalReadSeq = model.rebalReadSeq + 1 rebalInFlight = false rebalExecuteSeq = model.rebalExecuteSeq + 1 rebalExecuteInFlight = false rebalPlans = [] rebalResult = None rebalPreviewSeq = model.rebalPreviewSeq + 1 rebalPreviewInFlight = false rebalPreview = None rebalExecutionsReadSeq = model.rebalExecutionsReadSeq + 1 rebalExecutionsInFlight = false rebalExecutions = [] dividendReadSeq = model.dividendReadSeq + 1 dividendInFlight = false dividends = [] returnsReadSeq = model.returnsReadSeq + 1 returnsInFlight = false returns = None navDatesReadSeq = model.navDatesReadSeq + 1 navDatesInFlight = false navDates = [] navDatesAvailable = false navSeriesReadSeq = model.navSeriesReadSeq + 1 navSeriesInFlight = false navSeries = [] navSeriesAvailable = false returnsSelectedDate = None planReadSeq = model.planReadSeq + 1 planInFlight = false planRunSeq = model.planRunSeq + 1 planRunInFlight = false investmentPlans = [] lastPlanRun = 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 = [] rebalTargets = [ { code = ""; percent = "" }; { code = ""; percent = "" } ] rebalCreateSeq = model.rebalCreateSeq + 1 rebalReadSeq = model.rebalReadSeq + 1 rebalInFlight = false rebalExecuteSeq = model.rebalExecuteSeq + 1 rebalExecuteInFlight = false rebalPlans = [] rebalResult = None rebalPreviewSeq = model.rebalPreviewSeq + 1 rebalPreviewInFlight = false rebalPreview = None rebalExecutionsReadSeq = model.rebalExecutionsReadSeq + 1 rebalExecutionsInFlight = false rebalExecutions = [] dividendReadSeq = model.dividendReadSeq + 1 dividendInFlight = false dividends = [] returnsReadSeq = model.returnsReadSeq + 1 returnsInFlight = false returns = None navDatesReadSeq = model.navDatesReadSeq + 1 navDatesInFlight = false navDates = [] navDatesAvailable = false navSeriesReadSeq = model.navSeriesReadSeq + 1 navSeriesInFlight = false navSeries = [] navSeriesAvailable = false returnsSelectedDate = None planReadSeq = model.planReadSeq + 1 planInFlight = false planRunSeq = model.planRunSeq + 1 planRunInFlight = false investmentPlans = [] lastPlanRun = None error = None }, Cmd.batch [ Cmd.ofMsg OrdersReadRequested; Cmd.ofMsg ReturnsReadRequested; Cmd.ofMsg InvestmentPlansReadRequested ] 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; Cmd.ofMsg CashLedgerReadRequested ] 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 | SipPlanPauseRequested planId -> dispatchSipStatusChange model planId true | SipPlanResumeRequested planId -> dispatchSipStatusChange model planId false | SipPlanStatusChanged (requestId, plan) -> if requestId = model.sipStatusSeq then let decoded = Api.decodeSipPlan plan { model with sipStatusInFlight = false sipPlans = model.sipPlans |> List.map (fun existing -> if existing.id = decoded.id then decoded else existing) error = None }, Cmd.none else model, Cmd.none | SipPlanStatusChangeFailed (requestId, message) -> if requestId = model.sipStatusSeq then { model with sipStatusInFlight = false; error = Some message }, Cmd.none else model, Cmd.none | RebalTargetCodeChanged (index, value) -> { model with rebalTargets = model.rebalTargets |> List.mapi (fun i draft -> if i = index then { draft with code = value } else draft) error = None }, Cmd.none | RebalTargetPercentChanged (index, value) -> { model with rebalTargets = model.rebalTargets |> List.mapi (fun i draft -> if i = index then { draft with percent = value } else draft) error = None }, Cmd.none | RebalTargetAdded -> { model with rebalTargets = model.rebalTargets @ [ { code = ""; percent = "" } ]; error = None }, Cmd.none | RebalTargetRemoved index -> { model with rebalTargets = model.rebalTargets |> List.mapi (fun i draft -> i, draft) |> List.filter (fun (i, _) -> i <> index) |> List.map snd error = None }, Cmd.none | RebalancePlanCreateRequested -> let participating = model.rebalTargets |> List.map (fun draft -> draft.code.Trim(), draft.percent.Trim()) |> List.filter (fun (code, _) -> code <> "") 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 participating.IsEmpty then { model with error = Some "请输入至少一个目标基金代码" }, Cmd.none elif participating |> List.exists (fun (_, percent) -> not (isValidCashText percent) || not (isNonNegativeCash percent)) then { model with error = Some "目标比例必须是不小于 0 的两位小数百分数,例如 60.00" }, Cmd.none else let total = participating |> List.sumBy (fun (_, percent) -> Decimal.Parse(percent, NumberStyles.Float, CultureInfo.InvariantCulture)) if total <> 100m then { model with error = Some "目标比例合计必须等于 100" }, Cmd.none elif model.rebalInFlight then model, Cmd.none else let requestId = model.rebalCreateSeq + 1 { model with rebalCreateSeq = requestId rebalInFlight = true rebalResult = None rebalPreview = None error = None }, createRebalancePlanCommand model.token model.createdFund.Value.id { idempotencyKey = Guid.NewGuid().ToString("N") targets = participating |> List.map (fun (code, percent) -> { instrumentCode = code; targetPercent = percent }) |> List.toArray } 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 | RebalancePlanArchiveRequested planId -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.rebalArchiveInFlight -> let requestId = model.rebalArchiveSeq + 1 { model with rebalArchiveSeq = requestId; rebalArchiveInFlight = true; error = None }, archiveRebalancePlanCommand model.token fund.id planId requestId | _ -> model, Cmd.none | RebalancePlanArchived (requestId, plan) -> if requestId = model.rebalArchiveSeq then let decoded = Api.decodeRebalancePlan plan { model with rebalArchiveInFlight = false rebalPlans = model.rebalPlans |> List.map (fun existing -> if existing.id = decoded.id then decoded else existing) error = None }, Cmd.none else model, Cmd.none | RebalancePlanArchiveFailed (requestId, message) -> if requestId = model.rebalArchiveSeq then { model with rebalArchiveInFlight = false; error = Some message }, Cmd.none else model, Cmd.none | RebalancePreviewRequested planId -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.rebalPreviewInFlight -> let requestId = model.rebalPreviewSeq + 1 { model with rebalPreviewSeq = requestId rebalPreviewInFlight = true rebalPreview = None error = None }, previewRebalanceCommand model.token fund.id planId requestId | Some _ when model.rebalPreviewInFlight -> model, Cmd.none | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none | None -> model, Cmd.none | RebalancePreviewCompleted (requestId, raw) -> if requestId = model.rebalPreviewSeq then { model with rebalPreviewInFlight = false rebalPreview = Some(Api.decodeRebalancePreview raw) error = None }, Cmd.none else model, Cmd.none | RebalancePreviewFailed (requestId, message) -> if requestId = model.rebalPreviewSeq then { model with rebalPreviewInFlight = false; 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 rebalPreview = None 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 Cmd.ofMsg RebalanceExecutionsReadRequested ] 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 | RebalanceExecutionsReadRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> let requestId = model.rebalExecutionsReadSeq + 1 { model with rebalExecutionsReadSeq = requestId; rebalExecutionsInFlight = true }, readRebalanceExecutionsCommand model.token fund.id requestId | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none | None -> model, Cmd.none | RebalanceExecutionsReadCompleted (requestId, records) -> if requestId = model.rebalExecutionsReadSeq then { model with rebalExecutionsInFlight = false rebalExecutions = records |> Array.map Api.decodeRebalanceExecutionRecord |> Array.toList error = None }, Cmd.none else model, Cmd.none | RebalanceExecutionsReadFailed (requestId, message) -> if requestId = model.rebalExecutionsReadSeq then { model with rebalExecutionsInFlight = 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 }, Cmd.batch [ readReturnsCommand model.token fund.id requestId Cmd.ofMsg NavDatesReadRequested Cmd.ofMsg NavSeriesReadRequested ] | 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 | NavDatesReadRequested -> match model.createdFund with | Some _ when String.IsNullOrWhiteSpace model.token -> { model with error = Some "请输入 API token" }, Cmd.none | Some _ -> let heldCode = model.positions |> Option.bind (fun positions -> positions.positions |> List.tryHead) |> Option.map (fun position -> position.instrumentCode) match heldCode with | Some code -> let requestId = model.navDatesReadSeq + 1 { model with navDatesReadSeq = requestId navDatesInFlight = true }, readNavDatesCommand model.token code requestId | None -> { model with navDates = [] navDatesAvailable = false navDatesInFlight = false }, Cmd.none | None -> model, Cmd.none | NavDatesReadCompleted (requestId, dates) -> if requestId = model.navDatesReadSeq then let resolved = dates.dates |> Array.toList { model with navDates = resolved navDatesAvailable = not (List.isEmpty resolved) navDatesInFlight = false }, Cmd.none else model, Cmd.none | NavDatesReadFailed (requestId, _) -> if requestId = model.navDatesReadSeq then { model with navDates = [] navDatesAvailable = false navDatesInFlight = false }, Cmd.none else model, Cmd.none | NavSeriesReadRequested -> match model.createdFund with | Some _ when String.IsNullOrWhiteSpace model.token -> { model with error = Some "请输入 API token" }, Cmd.none | Some _ -> let heldCode = model.positions |> Option.bind (fun positions -> positions.positions |> List.tryHead) |> Option.map (fun position -> position.instrumentCode) match heldCode with | Some code -> let requestId = model.navSeriesReadSeq + 1 { model with navSeriesReadSeq = requestId navSeriesInFlight = true }, readNavSeriesCommand model.token code requestId | None -> { model with navSeries = [] navSeriesAvailable = false navSeriesInFlight = false }, Cmd.none | None -> model, Cmd.none | NavSeriesReadCompleted (requestId, series) -> if requestId = model.navSeriesReadSeq then let resolved = series.points |> Array.toList |> List.map Api.decodeNavSeriesPoint { model with navSeries = resolved navSeriesAvailable = not (List.isEmpty resolved) navSeriesInFlight = false }, Cmd.none else model, Cmd.none | NavSeriesReadFailed (requestId, _) -> if requestId = model.navSeriesReadSeq then { model with navSeries = [] navSeriesAvailable = false navSeriesInFlight = false }, Cmd.none else model, Cmd.none | BondCodeChanged value -> { model with bondCode = value }, Cmd.none | BondQuoteRequested -> let code = model.bondCode.Trim() if String.IsNullOrWhiteSpace model.token then { model with bondQuoteMessage = Some "请输入 API token"; bondQuote = None }, Cmd.none elif String.IsNullOrWhiteSpace code then { model with bondQuoteMessage = Some "请输入六位债券代码"; bondQuote = None }, Cmd.none else let requestId = model.bondQuoteSeq + 1 { model with bondQuoteSeq = requestId bondQuoteInFlight = true bondQuoteMessage = None bondQuote = None }, readBondQuoteCommand model.token code requestId | BondQuoteCompleted (requestId, quote) -> if requestId = model.bondQuoteSeq then { model with bondQuote = Some(Api.decodeBondQuote quote) bondQuoteMessage = None bondQuoteInFlight = false }, Cmd.none else model, Cmd.none | BondQuoteFailed (requestId, message) -> if requestId = model.bondQuoteSeq then { model with bondQuote = None bondQuoteMessage = Some(sprintf "债券行情不可用 — %s" message) bondQuoteInFlight = false }, Cmd.none else model, Cmd.none | StockCodeChanged value -> { model with stockCode = value }, Cmd.none | StockQuoteRequested -> let code = model.stockCode.Trim() if String.IsNullOrWhiteSpace model.token then { model with stockQuoteMessage = Some "请输入 API token"; stockQuote = None }, Cmd.none elif String.IsNullOrWhiteSpace code then { model with stockQuoteMessage = Some "请输入六位股票代码"; stockQuote = None }, Cmd.none else let requestId = model.stockQuoteSeq + 1 { model with stockQuoteSeq = requestId stockQuoteInFlight = true stockQuoteMessage = None stockQuote = None }, readStockQuoteCommand model.token code requestId | StockQuoteCompleted (requestId, quote) -> if requestId = model.stockQuoteSeq then { model with stockQuote = Some(Api.decodeStockQuote quote) stockQuoteMessage = None stockQuoteInFlight = false }, Cmd.none else model, Cmd.none | StockQuoteFailed (requestId, message) -> if requestId = model.stockQuoteSeq then let text = if message.Contains("six digits") then "无效代码 — 请输入六位股票代码" else sprintf "股票行情不可用 — %s" message { model with stockQuote = None stockQuoteMessage = Some text stockQuoteInFlight = false }, Cmd.none else model, Cmd.none | StockDailyRequested -> let code = model.stockCode.Trim() if String.IsNullOrWhiteSpace model.token then { model with stockDailyMessage = Some "请输入 API token"; stockDaily = [] }, Cmd.none elif String.IsNullOrWhiteSpace code then { model with stockDailyMessage = Some "请输入六位股票代码"; stockDaily = [] }, Cmd.none else let requestId = model.stockDailySeq + 1 { model with stockDailySeq = requestId stockDailyInFlight = true stockDailyMessage = None stockDaily = [] }, readStockDailyCommand model.token code 5 requestId | StockDailyCompleted (requestId, daily) -> if requestId = model.stockDailySeq then let rows = daily.observations |> Array.toList |> List.map Api.decodeStockDailyBar { model with stockDaily = rows stockDailyMessage = (if List.isEmpty rows then Some "股票日线不可用 — 暂无数据" else None) stockDailyInFlight = false }, Cmd.none else model, Cmd.none | StockDailyFailed (requestId, message) -> if requestId = model.stockDailySeq then { model with stockDaily = [] stockDailyMessage = Some(sprintf "股票日线不可用 — %s" message) stockDailyInFlight = false }, Cmd.none else model, Cmd.none | StockTradeQuantityChanged value -> { model with stockTradeQuantity = value }, Cmd.none | StockTradeRequested -> let code = model.stockCode.Trim() let quantity = model.stockTradeQuantity.Trim() if String.IsNullOrWhiteSpace model.token then { model with stockTradeMessage = Some "请输入 API token" }, Cmd.none elif model.createdFund.IsNone then { model with stockTradeMessage = Some "请先创建一个基金" }, Cmd.none elif code.Length <> 6 || not (code |> Seq.forall Char.IsDigit) then { model with stockTradeMessage = Some "无效代码 — 请输入六位股票代码" }, Cmd.none elif not (isValidQuantityText quantity) then { model with stockTradeMessage = Some "数量必须是大于零的整数股数,例如 100" }, Cmd.none elif model.stockTradeInFlight then model, Cmd.none else let requestId = model.stockTradeSeq + 1 let idempotencyKey = match model.lastStockTradeAttempt with | Some attempt when attempt.instrumentCode = code && attempt.quantity = quantity -> attempt.idempotencyKey | _ -> Guid.NewGuid().ToString("N") { model with stockCode = code stockTradeQuantity = quantity stockTradeSeq = requestId stockTradeInFlight = true lastStockTradeAttempt = Some { idempotencyKey = idempotencyKey instrumentCode = code quantity = quantity } stockTradeMessage = None }, createStockTradeCommand model.token model.createdFund.Value.id { idempotencyKey = idempotencyKey; instrumentCode = code; quantity = quantity } requestId | StockTradeCompleted (requestId, fundId, trade) -> if requestId = model.stockTradeSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with stockTradeInFlight = false lastStockTrade = Some(Api.decodeStockTrade trade) lastStockTradeAttempt = None stockTradeMessage = None }, Cmd.ofMsg StockPositionsReadRequested else model, Cmd.none | StockTradeFailed (requestId, fundId, message) -> if requestId = model.stockTradeSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with stockTradeInFlight = false; stockTradeMessage = Some(sprintf "买入失败 — %s" message) }, Cmd.none else model, Cmd.none | StockPositionsReadRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> let requestId = model.stockPositionsReadSeq + 1 { model with stockPositionsReadSeq = requestId stockPositionsInFlight = true stockPositionsMessage = None }, readStockPositionsCommand model.token fund.id requestId | _ -> model, Cmd.none | StockPositionsReadCompleted (requestId, fundId, positions) -> if requestId = model.stockPositionsReadSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with stockPositions = positions.positions |> Array.toList |> List.map Api.decodeStockPosition stockPositionsInFlight = false stockPositionsMessage = None }, Cmd.none else model, Cmd.none | StockPositionsReadFailed (requestId, fundId, message) -> if requestId = model.stockPositionsReadSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with stockPositions = [] stockPositionsInFlight = false stockPositionsMessage = Some(sprintf "持仓不可用 — %s" message) }, Cmd.none else model, Cmd.none | StockSellQuantityChanged (code, value) -> { model with stockSellQuantities = Map.add code value model.stockSellQuantities }, Cmd.none | StockSellRequested code -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> let quantity = (model.stockSellQuantities |> Map.tryFind code |> Option.defaultValue "").Trim() if not (isValidQuantityText quantity) then { model with stockSellMessage = Some "卖出数量必须是大于零的整数股数,例如 100" }, Cmd.none else let requestId = model.stockSellSeq + 1 let idempotencyKey = match model.stockSellAttempts |> Map.tryFind code with | Some attempt when attempt.quantity = quantity -> attempt.idempotencyKey | _ -> Guid.NewGuid().ToString("N") let payload : CreateStockSellPayload = { idempotencyKey = idempotencyKey; instrumentCode = code; quantity = quantity } let attempt : StockSellAttempt = { idempotencyKey = idempotencyKey; instrumentCode = code; quantity = quantity } { model with stockSellSeq = requestId stockSellInFlight = true stockSellMessage = None stockSellAttempts = Map.add code attempt model.stockSellAttempts }, createStockSellCommand model.token fund.id payload requestId | _ when String.IsNullOrWhiteSpace model.token -> { model with stockSellMessage = Some "请输入 API token" }, Cmd.none | _ -> { model with stockSellMessage = Some "请先创建一个基金" }, Cmd.none | StockSellCompleted (requestId, fundId, code, sell) -> if requestId = model.stockSellSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with stockSellInFlight = false stockSellMessage = Some(sprintf "已卖出 %s %s 股:成交价 %s,回收现金 %s" code sell.quantity sell.price sell.proceeds) stockSellQuantities = Map.remove code model.stockSellQuantities stockSellAttempts = Map.remove code model.stockSellAttempts }, Cmd.batch [ Cmd.ofMsg StockPositionsReadRequested; Cmd.ofMsg ValuationReadRequested; Cmd.ofMsg CashLedgerReadRequested ] else model, Cmd.none | StockSellFailed (requestId, fundId, code, message) -> if requestId = model.stockSellSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with stockSellInFlight = false; stockSellMessage = Some(sprintf "卖出失败 — %s" message) }, Cmd.none else model, Cmd.none | BondTradeQuantityChanged value -> { model with bondTradeQuantity = value }, Cmd.none | BondTradeRequested -> let code = model.bondCode.Trim() let quantity = model.bondTradeQuantity.Trim() if String.IsNullOrWhiteSpace model.token then { model with bondTradeMessage = Some "请输入 API token" }, Cmd.none elif model.createdFund.IsNone then { model with bondTradeMessage = Some "请先创建一个基金" }, Cmd.none elif code.Length <> 6 || not (code |> Seq.forall Char.IsDigit) then { model with bondTradeMessage = Some "无效代码 — 请输入六位债券代码" }, Cmd.none elif not (isValidQuantityText quantity) then { model with bondTradeMessage = Some "数量必须是大于零的整数张数,例如 10" }, Cmd.none elif model.bondTradeInFlight then model, Cmd.none else let requestId = model.bondTradeSeq + 1 let idempotencyKey = match model.lastBondTradeAttempt with | Some attempt when attempt.instrumentCode = code && attempt.quantity = quantity -> attempt.idempotencyKey | _ -> Guid.NewGuid().ToString("N") { model with bondCode = code bondTradeQuantity = quantity bondTradeSeq = requestId bondTradeInFlight = true lastBondTradeAttempt = Some { idempotencyKey = idempotencyKey instrumentCode = code quantity = quantity } bondTradeMessage = None }, createBondTradeCommand model.token model.createdFund.Value.id { idempotencyKey = idempotencyKey; instrumentCode = code; quantity = quantity } requestId | BondTradeCompleted (requestId, fundId, trade) -> if requestId = model.bondTradeSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with bondTradeInFlight = false lastBondTrade = Some(Api.decodeBondTrade trade) lastBondTradeAttempt = None bondTradeMessage = None }, Cmd.ofMsg BondPositionsReadRequested else model, Cmd.none | BondTradeFailed (requestId, fundId, message) -> if requestId = model.bondTradeSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with bondTradeInFlight = false; bondTradeMessage = Some(sprintf "买入失败 — %s" message) }, Cmd.none else model, Cmd.none | BondPositionsReadRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> let requestId = model.bondPositionsReadSeq + 1 { model with bondPositionsReadSeq = requestId bondPositionsInFlight = true bondPositionsMessage = None }, readBondPositionsCommand model.token fund.id requestId | _ -> model, Cmd.none | BondPositionsReadCompleted (requestId, fundId, positions) -> if requestId = model.bondPositionsReadSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with bondPositions = positions.positions |> Array.toList |> List.map Api.decodeBondPosition bondPositionsInFlight = false bondPositionsMessage = None }, Cmd.ofMsg BondCalendarReadRequested else model, Cmd.none | BondPositionsReadFailed (requestId, fundId, message) -> if requestId = model.bondPositionsReadSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with bondPositions = [] bondPositionsInFlight = false bondPositionsMessage = Some(sprintf "持仓不可用 — %s" message) }, Cmd.none else model, Cmd.none | BondSellQuantityChanged value -> { model with bondSellQuantity = value }, Cmd.none | BondSellRequested -> let code = model.bondCode.Trim() let quantity = model.bondSellQuantity.Trim() if String.IsNullOrWhiteSpace model.token then { model with bondSellMessage = Some "请输入 API token" }, Cmd.none elif model.createdFund.IsNone then { model with bondSellMessage = Some "请先创建一个基金" }, Cmd.none elif code.Length <> 6 || not (code |> Seq.forall Char.IsDigit) then { model with bondSellMessage = Some "无效代码 — 请输入六位债券代码" }, Cmd.none elif not (isValidQuantityText quantity) then { model with bondSellMessage = Some "数量必须是大于零的整数张数,例如 10" }, Cmd.none elif model.bondSellInFlight then model, Cmd.none else let requestId = model.bondSellSeq + 1 let idempotencyKey = Guid.NewGuid().ToString("N") { model with bondSellQuantity = quantity bondSellSeq = requestId bondSellInFlight = true bondSellMessage = None }, createBondSellCommand model.token model.createdFund.Value.id { idempotencyKey = idempotencyKey; instrumentCode = code; quantity = quantity } requestId | BondSellCompleted (requestId, fundId, sell) -> if requestId = model.bondSellSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with bondSellInFlight = false bondSellMessage = Some(sprintf "已卖出 %s %s 张:回收现金 %s,已实现盈亏 %s" sell.instrumentCode sell.quantity sell.proceeds sell.realizedPnl) }, Cmd.ofMsg BondPositionsReadRequested else model, Cmd.none | BondSellFailed (requestId, fundId, message) -> if requestId = model.bondSellSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with bondSellInFlight = false; bondSellMessage = Some(sprintf "卖出失败 — %s" message) }, Cmd.none else model, Cmd.none | BondCalendarReadRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> let requestId = model.bondCalendarSeq + 1 { model with bondCalendarSeq = requestId bondCalendarInFlight = true bondCalendarMessage = None }, readBondCalendarCommand model.token fund.id requestId | _ -> model, Cmd.none | BondCalendarReadCompleted (requestId, fundId, calendar) -> if requestId = model.bondCalendarSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then let formatEntry (entry: RawBondCalendarEntry) = let amountText = Api.decodeOptionalText entry.amount |> Option.map (fun value -> sprintf " %s" value) |> Option.defaultValue "" sprintf "%s %s %s (%s)%s" entry.eventDate entry.eventType entry.instrumentCode entry.source amountText { model with bondCalendarInFlight = false bondCalendarEntries = calendar.entries |> Array.toList |> List.map formatEntry bondCalendarMessage = None }, Cmd.none else model, Cmd.none | BondCalendarReadFailed (requestId, fundId, message) -> if requestId = model.bondCalendarSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with bondCalendarInFlight = false; bondCalendarEntries = []; bondCalendarMessage = Some(sprintf "债券日历不可用 — %s" message) }, Cmd.none else model, Cmd.none | ValuationReadRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> let requestId = model.valuationReadSeq + 1 { model with valuationReadSeq = requestId valuationInFlight = true valuationMessage = None }, readValuationCommand model.token fund.id requestId | _ -> model, Cmd.none | ValuationReadCompleted (requestId, fundId, valuation) -> if requestId = model.valuationReadSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with valuation = Some(Api.decodeFundValuation valuation) valuationInFlight = false valuationMessage = None }, Cmd.none else model, Cmd.none | ValuationReadFailed (requestId, fundId, message) -> if requestId = model.valuationReadSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with valuation = None valuationInFlight = false valuationMessage = Some(sprintf "组合估值不可用 — %s" message) }, Cmd.none else model, Cmd.none | CashLedgerReadRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> let requestId = model.cashLedgerReadSeq + 1 { model with cashLedgerReadSeq = requestId cashLedgerInFlight = true cashLedgerMessage = None }, readCashLedgerCommand model.token fund.id requestId | _ -> model, Cmd.none | CashLedgerReadCompleted (requestId, fundId, reconciliation) -> if requestId = model.cashLedgerReadSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with cashReconciliation = Some(Api.decodeCashReconciliation reconciliation) cashLedgerInFlight = false cashLedgerMessage = None }, Cmd.none else model, Cmd.none | CashLedgerReadFailed (requestId, fundId, message) -> if requestId = model.cashLedgerReadSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with cashReconciliation = None cashLedgerInFlight = false cashLedgerMessage = Some(sprintf "现金账不可用 — %s" message) }, Cmd.none else model, Cmd.none | CashLedgerFilterChanged value -> { model with cashLedgerFilter = value }, Cmd.none | MarketRefreshRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> let requestId = model.marketRefreshSeq + 1 { model with marketRefreshSeq = requestId marketRefreshInFlight = true marketRefreshMessage = None }, refreshMarketDataCommand model.token fund.id requestId | _ -> model, Cmd.none | MarketRefreshCompleted (requestId, fundId, response) -> if requestId = model.marketRefreshSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with marketRefreshInFlight = false marketRefreshMessage = Some(sprintf "行情刷新完成(%s):更新 %d 项,失败 %d 项" response.asOfDate response.refreshed.Length response.failures.Length) }, Cmd.ofMsg ValuationReadRequested else model, Cmd.none | MarketRefreshFailed (requestId, fundId, message) -> if requestId = model.marketRefreshSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with marketRefreshInFlight = false marketRefreshMessage = Some(sprintf "行情刷新失败 — %s" message) }, Cmd.none else model, Cmd.none | ReturnsDateChanged value -> { model with returnsSelectedDate = (if String.IsNullOrWhiteSpace value then None else Some value) }, Cmd.none | InvestmentPlansReadRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> let requestId = model.planReadSeq + 1 { model with planReadSeq = requestId planInFlight = true error = None }, readInvestmentPlansCommand model.token fund.id requestId | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none | None -> model, Cmd.none | InvestmentPlansReadCompleted (requestId, plans) -> if requestId = model.planReadSeq then { model with investmentPlans = plans |> Array.map Api.decodeInvestmentPlan |> Array.toList planInFlight = false error = None }, Cmd.none else model, Cmd.none | InvestmentPlansReadFailed (requestId, message) -> if requestId = model.planReadSeq then { model with planInFlight = false; error = Some message }, Cmd.none else model, Cmd.none | InvestmentPlansRunRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> if model.planRunInFlight then model, Cmd.none else let requestId = model.planRunSeq + 1 { model with planRunSeq = requestId planRunInFlight = true error = None }, runInvestmentPlansCommand model.token fund.id requestId | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none | None -> model, Cmd.none | InvestmentPlansRunCompleted (requestId, fundId, run) -> if requestId = model.planRunSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then let decoded = Api.decodeInvestmentPlanRun run { model with planRunInFlight = false lastPlanRun = Some decoded error = None }, Cmd.batch [ Cmd.ofMsg InvestmentPlansReadRequested Cmd.ofMsg FundReadRequested Cmd.ofMsg PositionsReadRequested Cmd.ofMsg ReturnsReadRequested ] else model, Cmd.none | InvestmentPlansRunFailed (requestId, fundId, message) -> if requestId = model.planRunSeq && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then { model with planRunInFlight = 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 dispatch (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 "进行中" elif plan.status = "paused" then "已暂停" else plan.status ) ] Html.span [ prop.className "order-cell sip-plan-actions" prop.children [ if plan.status = "paused" then Html.button [ prop.className "secondary-action sip-resume-action" prop.onClick (fun _ -> dispatch (SipPlanResumeRequested plan.id)) prop.text "恢复" ] else Html.button [ prop.className "secondary-action sip-pause-action" prop.onClick (fun _ -> dispatch (SipPlanPauseRequested plan.id)) prop.text "暂停" ] ] ] ] ] 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 dispatch)) ] ] 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 rebalanceActionText (action: string) = if action = "buy" then "买入" elif action = "sell" then "卖出" elif action = "hold" then "持有" else action let private rebalanceTargetDraftRow (index: int) (draft: RebalanceTargetDraft) dispatch = Html.div [ prop.className "fund-form-row rebalance-target-row" prop.children [ Html.label [ prop.className "field-label" prop.children [ Html.span (sprintf "目标基金 %d" (index + 1)) Html.input [ prop.className "text-input rebalance-target-code-input" prop.placeholder "六位基金代码(留空则不参与)" prop.value draft.code prop.onChange (fun value -> dispatch (RebalTargetCodeChanged(index, value))) ] ] ] Html.label [ prop.className "field-label" prop.children [ Html.span "目标比例" Html.input [ prop.className "text-input rebalance-target-percent-input" prop.placeholder "例如 60.00" prop.value draft.percent prop.onChange (fun value -> dispatch (RebalTargetPercentChanged(index, value))) ] ] ] Html.button [ prop.className "secondary-action rebalance-target-remove-action" prop.onClick (fun _ -> dispatch (RebalTargetRemoved index)) prop.text "删除" ] ] ] let private rebalanceWeightRow (row: RebalanceWeightRow) = Html.div [ prop.className "order-row rebalance-preview-row" prop.children [ Html.span [ prop.className "order-code"; prop.text row.instrumentCode ] Html.span [ prop.className "order-cell" prop.text (sprintf "目标 %s%% / 当前 %s%%" row.targetPercent row.currentPercent) ] Html.span [ prop.className "order-cell" prop.text ( match row.deltaUnits with | Some units -> sprintf "%s %s 元 / %s 份" (rebalanceActionText row.action) row.deltaAmount units | None -> sprintf "%s %s 元" (rebalanceActionText row.action) row.deltaAmount ) ] ] ] let private rebalanceExecutionRow (record: RebalanceExecutionRecord) = let statusText = if record.status = "succeeded" then "已确认" elif record.status = "pending_nav" then "待净值" elif record.status = "insufficient_cash" then "现金不足" elif record.status = "insufficient_units" then "持仓不足" elif record.status = "failed" then "失败" elif record.status.StartsWith("skipped_") then "已跳过" else record.status Html.div [ prop.className "order-row rebalance-execution-row" prop.children [ Html.span [ prop.className "order-code"; prop.text record.instrumentCode ] Html.span [ prop.className "order-cell" prop.text (sprintf "%s %s %s 元" record.runDate (rebalanceActionText record.action) record.amount) ] Html.span [ prop.className "order-status"; prop.text statusText ] match record.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 "12 / REBALANCE" ] Html.h2 "组合再平衡" ] Html.span [ prop.className "section-note"; prop.text "Rebalance - target weights & execution" ] ] ] Html.div [ prop.className "rebalance-targets" prop.children [ yield! (model.rebalTargets |> List.mapi (fun index draft -> rebalanceTargetDraftRow index draft dispatch)) Html.div [ prop.className "fund-form-row" prop.children [ Html.button [ prop.className "secondary-action rebalance-target-add-action" prop.onClick (fun _ -> dispatch RebalTargetAdded) prop.text "添加目标" ] 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 rebalance-plan-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 " / ") ] Html.span [ prop.className "order-status" prop.text (if plan.status = "archived" then "已归档" else "进行中") ] (if plan.status = "active" then Html.span [ prop.className "order-actions" prop.children [ Html.button [ prop.className "secondary-action rebalance-preview-action" prop.disabled model.rebalPreviewInFlight prop.onClick (fun _ -> dispatch (RebalancePreviewRequested plan.id)) prop.text ((if model.rebalPreviewInFlight then "计算中..." else "预览差额"): string) ] 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) ] Html.button [ prop.className "secondary-action rebalance-archive-action" prop.disabled model.rebalArchiveInFlight prop.onClick (fun _ -> dispatch (RebalancePlanArchiveRequested plan.id)) prop.text "归档" ] ] ] else Html.none) ] ])) ] ] match model.rebalPreview with | Some preview -> let hasDrift = preview.rows |> List.exists (fun row -> row.action <> "hold") Html.div [ prop.className "rebalance-preview" prop.children [ Html.p [ prop.className "hint" prop.text ( sprintf "估值快照 %s:权益 %s 元 / 可用现金 %s 元%s" preview.runDate preview.equity preview.availableCash (if hasDrift then "" else ",当前权重已在容差内") ) ] yield! (preview.rows |> List.map rebalanceWeightRow) ] ] | None -> Html.none match model.rebalResult with | Some outcomes -> Html.div [ prop.className "rebalance-result" prop.children [ Html.p [ prop.className "hint"; prop.text "最近一次执行:" ] if List.isEmpty outcomes then Html.p [ prop.className "hint" prop.text "当前权重已在容差内,本次未产生任何交易" ] else yield! (outcomes |> List.map rebalanceOutcomeRow) ] ] | None -> Html.none Html.div [ prop.className "rebalance-executions" prop.children [ Html.p [ prop.className "hint"; prop.text "执行记录:" ] if List.isEmpty model.rebalExecutions then Html.p [ prop.className "hint"; prop.text "暂无执行记录" ] else yield! (model.rebalExecutions |> List.map rebalanceExecutionRow) ] ] 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 "刷新计划" ] Html.button [ prop.className "secondary-action rebalance-executions-refresh-action" prop.disabled model.rebalExecutionsInFlight prop.onClick (fun _ -> dispatch RebalanceExecutionsReadRequested) prop.text ((if model.rebalExecutionsInFlight then "刷新中..." else "刷新执行记录"): string) ] ] ] ] ] 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 marketNavSeriesView (points: MarketNavSeriesPoint list) = let ordered = points |> List.sortBy (fun point -> point.navDate) let dates = ordered |> List.map (fun point -> point.navDate) let navSeries = ordered |> List.map (fun point -> point.navDate, point.nav) let navPoints = Chart.seriesPoints dates navSeries let empty = Html.div [ prop.className "chart-empty returns-nav-series-empty" prop.hidden (not (List.isEmpty navPoints)) prop.text "暂无真实净值数据" ] let chart = if List.isEmpty navPoints then Html.none 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 ]) Html.div [ prop.className "chart-wrap returns-nav-series-chart" prop.children [ Svg.svg [ svg.className "nav-chart" svg.viewBox (0, 0, 960, 320) svg.children [ yield! gridLines yield Svg.polyline [ svg.points (coordinates navPoints) svg.fill "none" svg.stroke "#f59e0b" svg.strokeWidth 3 ] yield! (coordinates navPoints |> List.map (fun (x, y) -> Svg.circle [ svg.className "nav-series-marker" svg.cx x svg.cy y svg.r 3.5 svg.fill "#f59e0b" ])) ] ] Html.div [ prop.className "chart-axis" prop.children [ Html.span [ prop.text (ordered |> List.head |> fun point -> point.navDate) ] Html.span [ prop.text (ordered |> List.last |> fun point -> point.navDate) ] ] ] ] ] Html.div [ prop.className "returns-nav-series" prop.custom ("data-nav-series-points", string (List.length navPoints)) prop.children [ empty; chart ] ] let private returnsPanel model dispatch = let visiblePoints = match model.returns with | None -> [] | Some returns -> match model.returnsSelectedDate with | Some selected -> returns.points |> List.filter (fun point -> point.date <= selected) | None -> returns.points let latest (selector: ReturnsPoint -> string option) = visiblePoints |> 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" ] ] ] Html.div [ prop.className "returns-date-picker" prop.custom ( "data-nav-dates-state", (if model.navDatesAvailable then "available" else "empty") ) prop.children [ Html.label [ prop.className "field-label" prop.children [ Html.span "真实净值日期" Html.select [ prop.className "text-input returns-date-input" prop.disabled (not model.navDatesAvailable) prop.value (defaultArg model.returnsSelectedDate "") prop.onChange (fun value -> dispatch (ReturnsDateChanged value)) prop.children [ Html.option [ prop.value ""; prop.text "最新" ] yield! (model.navDates |> List.map (fun date -> Html.option [ prop.value date; prop.text date ])) ] ] ] ] Html.p [ prop.className "returns-date-empty" prop.hidden model.navDatesAvailable prop.text "暂无真实净值数据" ] ] ] 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 visiblePoints Html.p [ prop.className "returns-note" prop.text "蓝线 总资产 / 橙线 单位净值(各自归一化);缺少净值的数据点不连线,也不补零。" ] Html.p [ prop.className "returns-subheading" prop.text "持仓基金真实净值序列(AKShare 多日)" ] marketNavSeriesView model.navSeries 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 private investmentPlanFrequencyText (frequency: string) = if frequency = "daily" then "每日" elif frequency = "weekly" then "每周" elif frequency = "monthly" then "每月" else frequency let private investmentPlanRunStatusText (status: string) = if status = "succeeded" then "已执行" elif status = "pending_nav" then "待净值" elif status = "insufficient_cash" then "现金不足" elif status = "failed" then "失败" else status let private investmentPlanRow (plan: InvestmentPlan) = 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" (investmentPlanFrequencyText 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.nextRunDate) ] let lastRun = match plan.lastRunStatus with | Some status -> sprintf "最近执行 %s(%s)" (investmentPlanRunStatusText status) (plan.lastRunDate |> Option.defaultValue "—") | None -> "最近执行 暂无" Html.span [ prop.className "order-cell"; prop.text lastRun ] Html.span [ prop.className "order-status"; prop.text (if plan.status = "active" then "进行中" else plan.status) ] ] ] let private investmentPlanRunRow (plan: InvestmentPlanRunPlan) = 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" (investmentPlanFrequencyText plan.frequency)) ] let runs = if List.isEmpty plan.runs then "本次无到期扣款" else plan.runs |> List.map (fun run -> sprintf "%s %s" run.runDate (investmentPlanRunStatusText run.status)) |> String.concat "、" Html.span [ prop.className "order-cell"; prop.text runs ] Html.span [ prop.className "order-cell"; prop.text (sprintf "下次执行 %s" plan.nextRunDate) ] ] ] let private investmentPlansPanel model dispatch = Html.section [ prop.className "panel plans-panel" prop.children [ Html.div [ prop.className "section-heading" prop.children [ Html.div [ Html.p [ prop.className "eyebrow"; prop.text "11 / PLANS" ] Html.h2 "定投计划执行" ] Html.span [ prop.className "section-note"; prop.text "scheduled contribution" ] ] ] Html.p [ prop.className "hint" prop.text "执行到期计划:按当日净值扣款买入,重复执行不产生第二笔;存款与买入都不计入收益。" ] Html.div [ prop.className "sip-plans plans-list" prop.children [ if List.isEmpty model.investmentPlans then Html.p [ prop.className "hint"; prop.text "暂无定投计划" ] else yield! (model.investmentPlans |> List.map investmentPlanRow) ] ] match model.lastPlanRun with | Some run -> Html.div [ prop.className "plans-run-result" prop.children [ Html.p [ prop.className "hint"; prop.text (sprintf "最近执行处理日 %s" run.processingDate) ] if List.isEmpty run.plans then Html.p [ prop.className "hint"; prop.text "本次没有可执行的计划" ] else yield! (run.plans |> List.map investmentPlanRunRow) ] ] | None -> Html.none Html.div [ prop.className "panel-actions" prop.children [ Html.button [ prop.className "primary-action plans-run-action" prop.disabled model.planRunInFlight prop.onClick (fun _ -> dispatch InvestmentPlansRunRequested) prop.text ((if model.planRunInFlight then "执行中..." else "执行到期计划"): string) ] Html.button [ prop.className "secondary-action plans-refresh-action" prop.disabled model.planInFlight prop.onClick (fun _ -> dispatch InvestmentPlansReadRequested) prop.text ((if model.planInFlight then "读取中..." else "刷新计划"): string) ] ] ] ] ] let private bondQuoteMetric (label: string) (value: string option) = Html.div [ prop.className "metric" prop.children [ Html.span label Html.strong (value |> Option.defaultValue "—") ] ] let private bondsPanel model dispatch = Html.section [ prop.className "panel bonds-panel" prop.children [ Html.div [ prop.className "section-heading" prop.children [ Html.div [ Html.p [ prop.className "eyebrow"; prop.text "13 / BONDS" ] Html.h2 "债券行情探针" ] Html.span [ prop.className "section-note"; prop.text "AKShare · convertible spot" ] ] ] Html.p [ prop.className "bonds-note" prop.text "数据来源实时 AKShare 可转债行情,仅查询单一债券代码,不做本地模拟。" ] Html.div [ prop.className "search-row" prop.children [ Html.input [ prop.className "text-input bond-code-input" prop.placeholder "输入六位债券代码,如 110075" prop.value model.bondCode prop.onChange (fun value -> dispatch (BondCodeChanged value)) ] Html.button [ prop.className "primary-action bond-quote-action" prop.disabled model.bondQuoteInFlight prop.onClick (fun _ -> dispatch BondQuoteRequested) prop.text ((if model.bondQuoteInFlight then "查询中..." else "查询行情"): string) ] ] ] Html.p [ prop.className "bond-quote-unavailable" prop.hidden (model.bondQuoteMessage.IsNone) prop.text (model.bondQuoteMessage |> Option.defaultValue "") ] match model.bondQuote with | Some quote -> Html.div [ prop.className "nav-content bond-quote-result" prop.children [ Html.div [ prop.className "metric-strip" prop.children [ bondQuoteMetric "名称" quote.name bondQuoteMetric "净价" quote.cleanPrice bondQuoteMetric "应计利息" quote.accruedInterestComputed bondQuoteMetric "全价" quote.dirtyPrice bondQuoteMetric "票息率" quote.couponRate bondQuoteMetric "面值" quote.parValue bondQuoteMetric "起息日" quote.valueDate bondQuoteMetric "到期日" quote.maturityDate bondQuoteMetric "评级" quote.rating bondQuoteMetric "估值日期" quote.valuationDate bondQuoteMetric "数据质量" quote.dataStatus ] ] ] ] | None -> Html.p [ prop.className "hint"; prop.text "查询后显示名称/价格/到期日;缺失字段以「—」表示,不补零。" ] Html.div [ prop.className "bond-trade" prop.children [ Html.p [ prop.className "returns-subheading"; prop.text "买入(以实时行情价成交)" ] Html.div [ prop.className "search-row" prop.children [ Html.input [ prop.className "text-input bond-trade-quantity-input" prop.placeholder "买入数量(整数张),如 10" prop.value model.bondTradeQuantity prop.onChange (fun value -> dispatch (BondTradeQuantityChanged value)) ] Html.button [ prop.className "primary-action bond-trade-action" prop.disabled model.bondTradeInFlight prop.onClick (fun _ -> dispatch BondTradeRequested) prop.text ((if model.bondTradeInFlight then "买入中..." else "买入"): string) ] Html.button [ prop.className "secondary-action bond-positions-refresh-action" prop.disabled model.bondPositionsInFlight prop.onClick (fun _ -> dispatch BondPositionsReadRequested) prop.text ((if model.bondPositionsInFlight then "读取中..." else "刷新持仓"): string) ] ] ] Html.p [ prop.className "bond-trade-unavailable" prop.hidden (model.bondTradeMessage.IsNone) prop.text (model.bondTradeMessage |> Option.defaultValue "") ] Html.p [ prop.className "returns-subheading"; prop.text "卖出(净价/全价口径一致,应计利息随全价吐回)" ] Html.div [ prop.className "search-row" prop.children [ Html.input [ prop.className "text-input bond-sell-quantity-input" prop.placeholder "卖出数量(整数张),如 10" prop.value model.bondSellQuantity prop.onChange (fun value -> dispatch (BondSellQuantityChanged value)) ] Html.button [ prop.className "primary-action bond-sell-action" prop.disabled model.bondSellInFlight prop.onClick (fun _ -> dispatch BondSellRequested) prop.text ((if model.bondSellInFlight then "卖出中..." else "卖出"): string) ] ] ] Html.p [ prop.className "bond-sell-unavailable" prop.hidden (model.bondSellMessage.IsNone) prop.text (model.bondSellMessage |> Option.defaultValue "") ] ] ] Html.div [ prop.className "bond-positions" prop.hidden (List.isEmpty model.bondPositions) prop.children ( Html.p [ prop.className "returns-subheading"; prop.text "债券持仓" ] :: Html.div [ prop.className "bond-positions-row bond-positions-header" prop.children [ Html.span [ prop.className "fund-detail-label"; prop.text "代码" ] Html.span [ prop.className "fund-detail-label"; prop.text "名称" ] Html.span [ prop.className "fund-detail-label"; prop.text "数量" ] Html.span [ prop.className "fund-detail-label"; prop.text "成本" ] ] ] :: (model.bondPositions |> List.map (fun position -> Html.div [ prop.className "bond-positions-row bond-position-row" prop.children [ Html.span [ prop.className "fund-detail-value"; prop.text position.instrumentCode ] Html.span [ prop.className "fund-detail-value"; prop.text (position.bondName |> Option.defaultValue "—") ] Html.span [ prop.className "fund-detail-value"; prop.text position.quantity ] Html.span [ prop.className "fund-detail-value"; prop.text position.costCash ] ] ])) ) ] Html.p [ prop.className "bond-positions-unavailable" prop.hidden (model.bondPositionsMessage.IsNone) prop.text (model.bondPositionsMessage |> Option.defaultValue "") ] Html.div [ prop.className "bond-calendar" prop.children ( Html.p [ prop.className "returns-subheading"; prop.text "持仓到期/付息日历(已实现口径)" ] :: (if List.isEmpty model.bondCalendarEntries then [ Html.p [ prop.className "bond-calendar-empty" prop.text (model.bondCalendarMessage |> Option.defaultValue "暂无到期或付息事件") ] ] else model.bondCalendarEntries |> List.map (fun entry -> Html.p [ prop.className "bond-calendar-row"; prop.text entry ])) ) ] ] ] let private stocksPanel model dispatch = Html.section [ prop.className "panel stocks-panel" prop.children [ Html.div [ prop.className "section-heading" prop.children [ Html.div [ Html.p [ prop.className "eyebrow"; prop.text "14 / STOCKS" ] Html.h2 "股票行情探针" ] Html.span [ prop.className "section-note"; prop.text "AKShare · A-share spot" ] ] ] Html.p [ prop.className "bonds-note" prop.text "数据来源实时 AKShare A 股行情,仅查询单一六位代码,不做本地模拟,不补零。" ] Html.div [ prop.className "search-row" prop.children [ Html.input [ prop.className "text-input stock-code-input" prop.placeholder "输入六位股票代码,如 600519" prop.value model.stockCode prop.onChange (fun value -> dispatch (StockCodeChanged value)) ] Html.button [ prop.className "primary-action stock-quote-action" prop.disabled model.stockQuoteInFlight prop.onClick (fun _ -> dispatch StockQuoteRequested) prop.text ((if model.stockQuoteInFlight then "查询中..." else "查询行情"): string) ] Html.button [ prop.className "secondary-action stock-daily-action" prop.disabled model.stockDailyInFlight prop.onClick (fun _ -> dispatch StockDailyRequested) prop.text ((if model.stockDailyInFlight then "读取中..." else "查询近5日日线"): string) ] ] ] Html.p [ prop.className "stock-quote-unavailable" prop.hidden (model.stockQuoteMessage.IsNone) prop.text (model.stockQuoteMessage |> Option.defaultValue "") ] match model.stockQuote with | Some quote -> Html.div [ prop.className "nav-content stock-quote-result" prop.children [ Html.div [ prop.className "metric-strip" prop.children [ bondQuoteMetric "名称" quote.name bondQuoteMetric "价格" quote.price bondQuoteMetric "币种" (Some quote.currency) ] ] ] ] | None -> Html.p [ prop.className "hint"; prop.text "查询后显示名称/价格/币种;缺失字段以「—」表示。" ] Html.div [ prop.className "stock-daily" prop.hidden (List.isEmpty model.stockDaily) prop.children ( Html.p [ prop.className "returns-subheading"; prop.text "近 5 个交易日收盘" ] :: (model.stockDaily |> List.map (fun bar -> Html.div [ prop.className "order-row stock-daily-row" prop.children [ Html.span [ prop.className "fund-detail-label"; prop.text bar.date ] Html.span [ prop.className "fund-detail-value"; prop.text bar.close ] ] ])) ) ] Html.p [ prop.className "stock-daily-unavailable" prop.hidden (model.stockDailyMessage.IsNone) prop.text (model.stockDailyMessage |> Option.defaultValue "") ] Html.div [ prop.className "stock-trade" prop.children [ Html.p [ prop.className "returns-subheading"; prop.text "买入(以实时行情价成交)" ] Html.div [ prop.className "search-row" prop.children [ Html.input [ prop.className "text-input stock-trade-quantity-input" prop.placeholder "买入数量(整数股),如 100" prop.value model.stockTradeQuantity prop.onChange (fun value -> dispatch (StockTradeQuantityChanged value)) ] Html.button [ prop.className "primary-action stock-trade-action" prop.disabled model.stockTradeInFlight prop.onClick (fun _ -> dispatch StockTradeRequested) prop.text ((if model.stockTradeInFlight then "买入中..." else "买入"): string) ] Html.button [ prop.className "secondary-action stock-positions-refresh-action" prop.disabled model.stockPositionsInFlight prop.onClick (fun _ -> dispatch StockPositionsReadRequested) prop.text ((if model.stockPositionsInFlight then "读取中..." else "刷新持仓"): string) ] ] ] Html.p [ prop.className "stock-trade-unavailable" prop.hidden (model.stockTradeMessage.IsNone) prop.text (model.stockTradeMessage |> Option.defaultValue "") ] ] ] Html.div [ prop.className "stock-positions" prop.hidden (List.isEmpty model.stockPositions) prop.children ( Html.p [ prop.className "returns-subheading"; prop.text "股票持仓" ] :: Html.div [ prop.className "stock-positions-row stock-positions-header" prop.children [ Html.span [ prop.className "fund-detail-label"; prop.text "代码" ] Html.span [ prop.className "fund-detail-label"; prop.text "名称" ] Html.span [ prop.className "fund-detail-label"; prop.text "数量" ] Html.span [ prop.className "fund-detail-label"; prop.text "成本" ] Html.span [ prop.className "fund-detail-label"; prop.text "卖出数量" ] Html.span [ prop.className "fund-detail-label"; prop.text "操作" ] ] ] :: (model.stockPositions |> List.map (fun position -> Html.div [ prop.className "stock-positions-row stock-position-row" prop.children [ Html.span [ prop.className "fund-detail-value"; prop.text position.instrumentCode ] Html.span [ prop.className "fund-detail-value"; prop.text (position.stockName |> Option.defaultValue "—") ] Html.span [ prop.className "fund-detail-value"; prop.text position.quantity ] Html.span [ prop.className "fund-detail-value"; prop.text position.costCash ] Html.input [ prop.className "text-input stock-sell-quantity-input" prop.placeholder "卖出数量" prop.value (model.stockSellQuantities |> Map.tryFind position.instrumentCode |> Option.defaultValue "") prop.onChange (fun value -> dispatch (StockSellQuantityChanged(position.instrumentCode, value))) ] Html.button [ prop.className "secondary-action stock-sell-action" prop.disabled model.stockSellInFlight prop.onClick (fun _ -> dispatch (StockSellRequested position.instrumentCode)) prop.text ((if model.stockSellInFlight then "卖出中..." else "卖出"): string) ] ] ])) ) ] Html.p [ prop.className "stock-positions-unavailable" prop.hidden (model.stockPositionsMessage.IsNone) prop.text (model.stockPositionsMessage |> Option.defaultValue "") ] Html.p [ prop.className "stock-sell-message" prop.hidden (model.stockSellMessage.IsNone) prop.text (model.stockSellMessage |> Option.defaultValue "") ] Html.div [ prop.className "fund-valuation" prop.children [ Html.p [ prop.className "returns-subheading"; prop.text "组合估值" ] Html.div [ prop.className "search-row" prop.children [ Html.button [ prop.className "secondary-action valuation-refresh-action" prop.disabled model.valuationInFlight prop.onClick (fun _ -> dispatch ValuationReadRequested) prop.text ((if model.valuationInFlight then "估值中..." else "刷新估值"): string) ] Html.button [ prop.className "secondary-action market-refresh-action" prop.disabled model.marketRefreshInFlight prop.onClick (fun _ -> dispatch MarketRefreshRequested) prop.text ((if model.marketRefreshInFlight then "刷新中..." else "刷新行情"): string) ] ] ] Html.p [ prop.className "market-refresh-message" prop.hidden (model.marketRefreshMessage.IsNone) prop.text (model.marketRefreshMessage |> Option.defaultValue "") ] match model.valuation with | Some valuation -> Html.div [ prop.className "valuation-content" prop.children ( [ Html.div [ prop.className "metric-strip valuation-metrics" prop.children [ bondQuoteMetric "现金" (Some valuation.cash) bondQuoteMetric "持仓市值" (Some valuation.positionsMarketValue) bondQuoteMetric "组合合计" (Some valuation.portfolioValue) bondQuoteMetric "已定价/缺失" (Some(sprintf "%d / %d" valuation.pricedPositions valuation.unavailablePositions)) ] ] ] @ (valuation.positions |> List.map (fun position -> Html.div [ prop.className ( if position.status = "unavailable" then "valuation-row valuation-position-row valuation-unavailable" else "valuation-row valuation-position-row" ) prop.children [ Html.span [ prop.className "fund-detail-label"; prop.text position.instrumentCode ] Html.span [ prop.className "fund-detail-value"; prop.text (position.name |> Option.defaultValue "—") ] Html.span [ prop.className "fund-detail-value"; prop.text position.quantity ] Html.span [ prop.className "fund-detail-value" prop.text (position.marketValue |> Option.defaultValue "缺失") ] Html.span [ prop.className "fund-detail-value"; prop.text (position.priceSource |> Option.defaultValue "—") ] ] ])) ) ] | None -> Html.p [ prop.className "hint"; prop.text "刷新后显示现金/持仓市值/组合合计;缺失行情标的不补零,标记为缺失。" ] Html.p [ prop.className "valuation-unavailable" prop.hidden (model.valuationMessage.IsNone) prop.text (model.valuationMessage |> Option.defaultValue "") ] ] ] Html.div [ prop.className "fund-cash-ledger" prop.children [ Html.p [ prop.className "returns-subheading"; prop.text "现金账对账" ] Html.div [ prop.className "search-row" prop.children [ Html.button [ prop.className "secondary-action cash-ledger-refresh-action" prop.disabled model.cashLedgerInFlight prop.onClick (fun _ -> dispatch CashLedgerReadRequested) prop.text ((if model.cashLedgerInFlight then "加载中..." else "刷新现金账"): string) ] Html.select [ prop.className "cash-ledger-filter" prop.value model.cashLedgerFilter prop.onChange (fun (value: string) -> dispatch (CashLedgerFilterChanged value)) prop.children ( [ Html.option [ prop.value ""; prop.text "全部来源" ] ] @ (match model.cashReconciliation with | Some reconciliation -> reconciliation.sources |> List.map (fun source -> Html.option [ prop.value source.source; prop.text source.source ]) | None -> []) ) ] ] ] match model.cashReconciliation with | Some reconciliation -> let filteredEvents = if model.cashLedgerFilter = "" then reconciliation.events else reconciliation.events |> List.filter (fun event -> event.source = model.cashLedgerFilter) Html.div [ prop.className "cash-ledger-content" prop.children ( [ Html.div [ prop.className "metric-strip cash-ledger-metrics" prop.children [ bondQuoteMetric "期初现金" (Some reconciliation.openingCash) bondQuoteMetric "净流入" (Some reconciliation.netInflow) bondQuoteMetric "期末现金" (Some reconciliation.closingCash) bondQuoteMetric "账实差异" (Some reconciliation.difference) ] ] Html.div [ prop.className "cash-ledger-sources" prop.children ( reconciliation.sources |> List.map (fun source -> Html.div [ prop.className "cash-ledger-source-row" prop.children [ Html.span [ prop.className "fund-detail-label"; prop.text source.source ] Html.span [ prop.className "fund-detail-value"; prop.text source.netAmount ] Html.span [ prop.className "fund-detail-value"; prop.text (sprintf "%d 笔" source.eventCount) ] ] ]) ) ] Html.div [ prop.className "cash-ledger-events" prop.children ( filteredEvents |> List.map (fun event -> Html.div [ prop.className "cash-ledger-event-row" prop.children [ Html.span [ prop.className "fund-detail-value"; prop.text event.eventDate ] Html.span [ prop.className "fund-detail-label"; prop.text event.source ] Html.span [ prop.className "fund-detail-value"; prop.text event.amount ] Html.span [ prop.className "fund-detail-value"; prop.text (event.note |> Option.defaultValue "—") ] ] ]) ) ] ] ) ] | None -> Html.p [ prop.className "hint"; prop.text "刷新后显示按来源分组的现金流水与账实对账;期末现金 = 期初 + 净流入,差异应为 0。" ] Html.p [ prop.className "cash-ledger-unavailable" prop.hidden (model.cashLedgerMessage.IsNone) prop.text (model.cashLedgerMessage |> Option.defaultValue "") ] ] ] ] ] 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 dividendPanel model dispatch returnsPanel model dispatch investmentPlansPanel model dispatch rebalancePanel model dispatch bondsPanel model dispatch stocksPanel 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