summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/FundLab.Api/App.fs30
-rw-r--r--src/FundLab.Web/App.fs349
-rw-r--r--src/FundLab.Web/src/api.js19
-rw-r--r--src/FundLab.Web/src/styles.css10
4 files changed, 366 insertions, 42 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index 839a641..97787aa 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -57,19 +57,6 @@ type FundApiResponse =
status: string
}
-type SubscriptionOrderApiResponse =
- {
- id: Guid
- fundId: Guid
- fundCode: string
- amount: string
- feeAmount: string
- reservedTotal: string
- status: string
- submittedAt: string
- isSynthetic: bool
- }
-
type ConfirmedQuoteEvidenceResponse =
{
navDate: string
@@ -198,20 +185,7 @@ module App =
status = fund.Status
}
- let private orderResponse (order: SubscriptionOrderRecord) : SubscriptionOrderApiResponse =
- {
- id = order.Id
- fundId = order.FundId
- fundCode = order.FundCode
- amount = cashText order.Amount
- feeAmount = cashText order.FeeAmount
- reservedTotal = cashText order.ReservedTotal
- status = order.Status
- submittedAt = timestampText order.SubmittedAt
- isSynthetic = order.IsSynthetic
- }
-
- let private confirmationResponse (order: SubscriptionOrderRecord) : SubscriptionOrderDetailResponse =
+ let private orderResponse (order: SubscriptionOrderRecord) : SubscriptionOrderDetailResponse =
let quoteEvidence =
order.ConfirmedQuote
|> Option.map (fun quote ->
@@ -461,7 +435,7 @@ module App =
match repository.ConfirmSubscriptionOrder(idempotencyKey, fundId, orderId) with
| OrderConfirmed order
| ConfirmReplayed order
- | ConfirmPendingNav order -> json (confirmationResponse order) next ctx
+ | ConfirmPendingNav order -> json (orderResponse order) next ctx
| ConfirmIdempotencyConflict ->
errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request" next ctx
| ConfirmAlreadyConfirmed ->
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index 51113a0..56b5a0c 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -138,6 +138,32 @@ type RawOrder =
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
+ costCash: string
+ lastConfirmedAt: string
+ valuationNav: obj
+ valuationNavDate: obj
+ valuationCollectedAt: obj
+ }
+
+type RawPositions =
+ {
+ availableCash: string
+ reservedCash: string
+ positions: RawPosition array
}
type CreateAttempt =
@@ -155,6 +181,48 @@ type OrderAttempt =
feeAmount: string
}
+type ConfirmAttempt =
+ {
+ idempotencyKey: string
+ orderId: string
+ }
+
+type OrderDetail =
+ {
+ id: string
+ fundCode: string
+ amount: string
+ feeAmount: string
+ reservedTotal: string
+ status: string
+ submittedAt: string
+ tradeDate: string
+ pendingReason: string option
+ confirmedAt: string option
+ confirmedNav: string option
+ confirmedNavDate: string option
+ confirmedUnits: string option
+ confirmedInvestedCash: string option
+ confirmedResidualCash: string option
+ }
+
+type Position =
+ {
+ instrumentCode: string
+ units: string
+ costCash: string
+ lastConfirmedAt: string
+ valuationNav: string option
+ valuationNavDate: string option
+ }
+
+type Positions =
+ {
+ availableCash: string
+ reservedCash: string
+ positions: Position list
+ }
+
module Api =
[<Import("searchInstruments", "./src/api.js")>]
let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative
@@ -177,16 +245,25 @@ module Api =
[<Import("getOrders", "./src/api.js")>]
let getOrders (token: string) (fundId: string) : JS.Promise<RawOrder array> = jsNative
+ [<Import("confirmOrder", "./src/api.js")>]
+ let confirmOrder (token: string) (fundId: string) (orderId: string) (idempotencyKey: string) : JS.Promise<RawOrder> = jsNative
+
+ [<Import("getPositions", "./src/api.js")>]
+ let getPositions (token: string) (fundId: string) : JS.Promise<RawPositions> = jsNative
+
let decodeOptionalText (raw: obj) : string option =
if isNull raw then
None
else
- let envelope = unbox<RawOptionalText> raw
+ match raw with
+ | :? string as value -> Some value
+ | _ ->
+ let envelope = unbox<RawOptionalText> raw
- if isNull (box envelope.fields) || envelope.fields.Length = 0 then
- None
- else
- Some envelope.fields[0]
+ if isNull (box envelope.fields) || envelope.fields.Length = 0 then
+ None
+ else
+ Some envelope.fields[0]
let decodeInstrument (raw: RawInstrument) : Instrument =
{
@@ -208,6 +285,42 @@ module Api =
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
+ 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
+ }
+
type Model =
{
token: string
@@ -232,7 +345,13 @@ type Model =
orderReadSeq: int
orderInFlight: bool
lastOrderAttempt: OrderAttempt option
- pendingOrders: RawOrder list
+ orders: OrderDetail list
+ confirmSeq: int
+ confirmInFlight: bool
+ lastConfirmAttempt: ConfirmAttempt option
+ positions: Positions option
+ positionsSeq: int
+ positionsInFlight: bool
error: string option
}
@@ -264,6 +383,12 @@ type Msg =
| 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
let defaultInitialUnitNav = "1.00000000"
@@ -317,7 +442,13 @@ let init () =
orderReadSeq = 0
orderInFlight = false
lastOrderAttempt = None
- pendingOrders = []
+ orders = []
+ confirmSeq = 0
+ confirmInFlight = false
+ lastConfirmAttempt = None
+ positions = None
+ positionsSeq = 0
+ positionsInFlight = false
error = None
}
@@ -376,6 +507,20 @@ let private readOrdersCommand token fundId requestId =
(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 update message model =
match message with
| TokenChanged token ->
@@ -402,7 +547,13 @@ let update message model =
orderReadSeq = model.orderReadSeq + 1
orderInFlight = false
lastOrderAttempt = None
- pendingOrders = []
+ orders = []
+ confirmSeq = model.confirmSeq + 1
+ confirmInFlight = false
+ lastConfirmAttempt = None
+ positions = None
+ positionsSeq = model.positionsSeq + 1
+ positionsInFlight = false
error = None
},
Cmd.none
@@ -555,7 +706,13 @@ let update message model =
orderReadSeq = model.orderReadSeq + 1
orderInFlight = false
lastOrderAttempt = None
- pendingOrders = []
+ orders = []
+ confirmSeq = model.confirmSeq + 1
+ confirmInFlight = false
+ lastConfirmAttempt = None
+ positions = None
+ positionsSeq = model.positionsSeq + 1
+ positionsInFlight = false
error = None
},
Cmd.ofMsg OrdersReadRequested
@@ -677,7 +834,7 @@ let update message model =
if requestId = model.orderReadSeq then
{
model with
- pendingOrders = orders |> Array.toList
+ orders = orders |> Array.map Api.decodeOrder |> Array.toList
error = None
},
Cmd.none
@@ -688,6 +845,68 @@ let update message model =
{ model with error = Some message }, Cmd.none
else
model, Cmd.none
+ | OrderConfirmRequested orderId ->
+ match model.createdFund with
+ | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.confirmInFlight ->
+ let requestId = model.confirmSeq + 1
+ let idempotencyKey = Guid.NewGuid().ToString("N")
+
+ {
+ model with
+ confirmSeq = requestId
+ confirmInFlight = true
+ lastConfirmAttempt = Some { idempotencyKey = idempotencyKey; orderId = orderId }
+ error = None
+ },
+ confirmOrderCommand model.token fund.id orderId idempotencyKey requestId
+ | Some _ when model.confirmInFlight -> model, Cmd.none
+ | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none
+ | None -> model, Cmd.none
+ | OrderConfirmCompleted (requestId, orderId, raw) ->
+ if requestId = model.confirmSeq
+ && (model.lastConfirmAttempt |> Option.exists (fun attempt -> attempt.orderId = orderId)) then
+ let confirmed = Api.decodeOrder raw
+
+ let orders =
+ model.orders
+ |> List.map (fun order -> if order.id = confirmed.id then confirmed else order)
+
+ { model with confirmInFlight = false; lastConfirmAttempt = None; orders = orders; error = None },
+ Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg PositionsReadRequested ]
+ else
+ model, Cmd.none
+ | OrderConfirmFailed (requestId, orderId, message) ->
+ if requestId = model.confirmSeq
+ && (model.lastConfirmAttempt |> Option.exists (fun attempt -> attempt.orderId = orderId)) then
+ { model with confirmInFlight = false; lastConfirmAttempt = None; error = Some message },
+ Cmd.ofMsg OrdersReadRequested
+ else
+ model, Cmd.none
+ | PositionsReadRequested ->
+ match model.createdFund with
+ | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.positionsInFlight ->
+ let requestId = model.positionsSeq + 1
+
+ { model with positionsSeq = requestId; positionsInFlight = true; error = None },
+ readPositionsCommand model.token fund.id requestId
+ | Some _ -> model, Cmd.none
+ | None -> model, Cmd.none
+ | PositionsReadCompleted (requestId, raw) ->
+ if requestId = model.positionsSeq then
+ {
+ model with
+ positions = Some(Api.decodePositions raw)
+ positionsInFlight = false
+ error = None
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | PositionsReadFailed (requestId, message) ->
+ if requestId = model.positionsSeq then
+ { model with positionsInFlight = false; error = Some message }, Cmd.none
+ else
+ model, Cmd.none
let private navText (text: string) =
match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with
@@ -1021,9 +1240,38 @@ let private fundPanel model dispatch =
]
let private orderStatusText (status: string) =
- if status = "submitted" then "已提交 · 待确认" else status
+ 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 "净值尚未公布")
+ | _ -> "已冻结现金;净值确认后按交易日成交。"
-let private orderRow (order: RawOrder) =
Html.div [
prop.className "order-row"
prop.children [
@@ -1031,7 +1279,10 @@ let private orderRow (order: RawOrder) =
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 ]
]
]
@@ -1104,10 +1355,10 @@ let private subscribePanel model dispatch =
Html.div [
prop.className "pending-orders"
prop.children [
- if List.isEmpty model.pendingOrders then
+ if List.isEmpty model.orders then
Html.p [ prop.className "hint"; prop.text "暂无待确认订单" ]
else
- yield! (model.pendingOrders |> List.map orderRow)
+ yield! (model.orders |> List.map (fun order -> orderRow order model dispatch))
]
]
Html.div [
@@ -1118,6 +1369,75 @@ let private subscribePanel model dispatch =
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
+ | _ -> "估值待更新"
+
+ 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) ]
+ 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)
+ ]
]
]
]
@@ -1179,6 +1499,7 @@ let view model dispatch =
navPanel model dispatch
fundPanel model dispatch
subscribePanel model dispatch
+ positionsPanel model dispatch
Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ & SUBSCRIBE" ]
]
]
diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js
index 7b4f738..bda8536 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -68,3 +68,22 @@ export function createOrder(token, fundId, payload) {
export function getOrders(token, fundId) {
return requestJson(`/api/funds/${encodeURIComponent(fundId)}/orders`, token);
}
+
+export function confirmOrder(token, fundId, orderId, idempotencyKey) {
+ return requestJson(
+ `/api/funds/${encodeURIComponent(fundId)}/orders/${encodeURIComponent(orderId)}/confirm`,
+ token,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": idempotencyKey
+ },
+ body: "{}"
+ }
+ );
+}
+
+export function getPositions(token, fundId) {
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}/positions`, token);
+}
diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css
index 58d340d..bd072a5 100644
--- a/src/FundLab.Web/src/styles.css
+++ b/src/FundLab.Web/src/styles.css
@@ -516,6 +516,16 @@ h2 {
font-size: 11px;
}
+.order-detail {
+ flex-basis: 100%;
+ color: #64748b;
+ font-size: 11px;
+}
+
+.position-row {
+ background: #f8fafc;
+}
+
.footer-note {
padding: 16px 0 34px;
font-family: "SFMono-Regular", Consolas, monospace;