summaryrefslogtreecommitdiff
path: root/src/FundLab.Web/App.fs
diff options
context:
space:
mode:
Diffstat (limited to 'src/FundLab.Web/App.fs')
-rw-r--r--src/FundLab.Web/App.fs298
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" ]
]
]