diff options
Diffstat (limited to 'src/FundLab.Web/App.fs')
| -rw-r--r-- | src/FundLab.Web/App.fs | 298 |
1 files changed, 294 insertions, 4 deletions
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 43c438f..5a89b5b 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -117,9 +117,29 @@ type FundSummary = 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 + } + type CreateAttempt = { idempotencyKey: string @@ -127,6 +147,14 @@ type CreateAttempt = cash: string } +type OrderAttempt = + { + idempotencyKey: string + fundCode: string + amount: string + feeAmount: string + } + module Api = [<Import("searchInstruments", "./src/api.js")>] let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative @@ -143,6 +171,12 @@ module Api = [<Import("getFund", "./src/api.js")>] let getFund (token: string) (fundId: string) : JS.Promise<FundSummary> = jsNative + [<Import("createOrder", "./src/api.js")>] + let createOrder (token: string) (fundId: string) (payload: CreateOrderPayload) : JS.Promise<RawOrder> = jsNative + + [<Import("getOrders", "./src/api.js")>] + let getOrders (token: string) (fundId: string) : JS.Promise<RawOrder array> = jsNative + let decodeOptionalText (raw: obj) : string option = if isNull raw then None @@ -191,6 +225,13 @@ type Model = fundInFlight: bool createdFund: FundSummary option lastCreateAttempt: CreateAttempt option + orderCode: string + orderAmount: string + orderFee: string + orderSeq: int + orderInFlight: bool + lastOrderAttempt: OrderAttempt option + pendingOrders: RawOrder list error: string option } @@ -213,6 +254,15 @@ type Msg = | 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 * order: RawOrder + | OrderCreateFailed of requestId: int * message: string + | OrdersReadRequested + | OrdersReadCompleted of requestId: int * orders: RawOrder array + | OrdersReadFailed of requestId: int * message: string let defaultInitialUnitNav = "1.00000000" @@ -221,11 +271,22 @@ let private cashPattern = 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 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 = "" @@ -243,6 +304,13 @@ let init () = fundInFlight = false createdFund = None lastCreateAttempt = None + orderCode = "" + orderAmount = "" + orderFee = "" + orderSeq = 0 + orderInFlight = false + lastOrderAttempt = None + pendingOrders = [] error = None } @@ -287,6 +355,20 @@ let private readFundCommand token fundId requestId = (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, order)) + (fun error -> OrderCreateFailed(requestId, 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 update message model = match message with | TokenChanged token -> @@ -306,6 +388,13 @@ let update message model = fundInFlight = false createdFund = None lastCreateAttempt = None + orderCode = "" + orderAmount = "" + orderFee = "" + orderSeq = model.orderSeq + 1 + orderInFlight = false + lastOrderAttempt = None + pendingOrders = [] error = None }, Cmd.none @@ -456,7 +545,7 @@ let update message model = lastCreateAttempt = None error = None }, - Cmd.none + Cmd.ofMsg OrdersReadRequested else model, Cmd.none | FundCreateFailed (requestId, message) -> @@ -488,6 +577,102 @@ let update message model = { 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 (isPositiveCash fee) then + { model with error = Some "认购手续费必须是大于零的两位小数金额,例如 1.50" }, Cmd.none + elif model.orderInFlight then + model, Cmd.none + else + let requestId = model.orderSeq + 1 + let idempotencyKey = resolveOrderKey model.lastOrderAttempt code amount fee + + { + model with + orderCode = code + orderAmount = amount + orderFee = fee + orderSeq = 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, order) -> + if requestId = model.orderSeq then + { + model with + orderInFlight = false + lastOrderAttempt = None + error = None + }, + Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg OrdersReadRequested ] + else + model, Cmd.none + | OrderCreateFailed (requestId, message) -> + if requestId = model.orderSeq 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.orderSeq + 1 + + { model with orderSeq = 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.orderSeq then + { + model with + pendingOrders = orders |> Array.toList + error = None + }, + Cmd.none + else + model, Cmd.none + | OrdersReadFailed (requestId, message) -> + if requestId = model.orderSeq then + { model with error = Some message }, Cmd.none + else + model, Cmd.none let private navText (text: string) = match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with @@ -794,6 +979,7 @@ let private fundPanel model dispatch = fundSummaryRow "币种" fund.currency fundSummaryRow "初始现金" fund.initialCash fundSummaryRow "可用现金" fund.availableCash + fundSummaryRow "保留现金" fund.reservedCash fundSummaryRow "初始单位净值" fund.initialUnitNav fundSummaryRow "份额" "0(新基金尚未买入持仓)" fundSummaryRow "状态" fund.status @@ -819,6 +1005,109 @@ let private fundPanel model dispatch = ] ] +let private orderStatusText (status: string) = + if status = "submitted" then "已提交 · 待确认" else status + +let private orderRow (order: RawOrder) = + 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-status"; prop.text (orderStatusText order.status) ] + 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 "例如 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 "金额与手续费按两位小数字符串提交;下单即冻结现金,订单保持已提交 · 待确认,不产生任何份额或收益。" + ] + Html.div [ + prop.className "pending-orders" + prop.children [ + if List.isEmpty model.pendingOrders then + Html.p [ prop.className "hint"; prop.text "暂无待确认订单" ] + else + yield! (model.pendingOrders |> List.map orderRow) + ] + ] + Html.div [ + prop.className "panel-actions" + prop.children [ + Html.button [ + prop.className "secondary-action orders-refresh-action" + prop.onClick (fun _ -> dispatch OrdersReadRequested) + prop.text "刷新订单" + ] + ] + ] + ] + ] + let view model dispatch = Html.main [ prop.className "app-shell" @@ -833,7 +1122,7 @@ let view model dispatch = Html.span "FUND LAB" ] ] - Html.span [ prop.className "topbar-status"; prop.text "LEDGER / 3C-1" ] + Html.span [ prop.className "topbar-status"; prop.text "LEDGER / 3D-1" ] ] ] Html.div [ @@ -844,7 +1133,7 @@ let view model dispatch = prop.children [ Html.p [ prop.className "eyebrow"; prop.text "人民币模拟基金 · 观察台" ] Html.h1 "把净值曲线,先看清楚。" - Html.p "从真实基金代码开始,读取、校验并追踪每日单位净值。这里不下单,只保留可复核的数据。" + Html.p "从真实基金代码开始,读取、校验并追踪每日单位净值。认购只生成待确认订单并冻结现金,不执行真实交易。" ] ] Html.div [ @@ -874,7 +1163,8 @@ let view model dispatch = searchPanel model dispatch navPanel model dispatch fundPanel model dispatch - Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ" ] + subscribePanel model dispatch + Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ & SUBSCRIBE" ] ] ] |
