summaryrefslogtreecommitdiff
path: root/src/FundLab.Web/App.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 04:45:10 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 04:45:10 +0800
commit0183b86f18d9897ff9487e6d864898954397991d (patch)
tree245c8f197f5803802dd630f278e9f2a08affa4ec /src/FundLab.Web/App.fs
parentb6008d67232756d743699472865025159f6ec533 (diff)
downloadfund-lab-0183b86f18d9897ff9487e6d864898954397991d.tar.gz
Add bond buy to holdings minimal vertical slice (3d-22)
Diffstat (limited to 'src/FundLab.Web/App.fs')
-rw-r--r--src/FundLab.Web/App.fs294
1 files changed, 294 insertions, 0 deletions
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index 772b97e..8b8f3b9 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -453,6 +453,34 @@ type RawStockPositions =
positions: RawStockPosition array
}
+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 CreateAttempt =
{
idempotencyKey: string
@@ -541,6 +569,13 @@ type CreateStockTradePayload =
quantity: string
}
+type CreateBondTradePayload =
+ {
+ idempotencyKey: string
+ instrumentCode: string
+ quantity: string
+ }
+
type SipPlan =
{
id: string
@@ -814,6 +849,33 @@ type StockTradeAttempt =
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 BondTradeAttempt =
+ {
+ idempotencyKey: string
+ instrumentCode: string
+ quantity: string
+ }
+
module Api =
[<Import("searchInstruments", "./src/api.js")>]
let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative
@@ -878,6 +940,12 @@ module Api =
[<Import("getStockPositions", "./src/api.js")>]
let getStockPositions (token: string) (fundId: string) : JS.Promise<RawStockPositions> = jsNative
+ [<Import("createBondTrade", "./src/api.js")>]
+ let createBondTrade (token: string) (fundId: string) (payload: CreateBondTradePayload) : JS.Promise<RawBondTrade> = jsNative
+
+ [<Import("getBondPositions", "./src/api.js")>]
+ let getBondPositions (token: string) (fundId: string) : JS.Promise<RawBondPositions> = jsNative
+
[<Import("createCapitalDeposit", "./src/api.js")>]
let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative
@@ -1197,6 +1265,26 @@ module Api =
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
+ }
+
type Model =
{
token: string
@@ -1304,6 +1392,16 @@ type Model =
stockPositionsInFlight: bool
stockPositions: StockPositionView list
stockPositionsMessage: 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
returnsSelectedDate: string option
planReadSeq: int
planInFlight: bool
@@ -1422,6 +1520,13 @@ type Msg =
| StockPositionsReadRequested
| StockPositionsReadCompleted of requestId: int * fundId: string * positions: RawStockPositions
| StockPositionsReadFailed of requestId: int * fundId: 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
| ReturnsDateChanged of string
| InvestmentPlansReadRequested
| InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array
@@ -1574,6 +1679,16 @@ let init () =
stockPositionsInFlight = false
stockPositions = []
stockPositionsMessage = None
+ bondTradeQuantity = ""
+ bondTradeSeq = 0
+ bondTradeInFlight = false
+ lastBondTrade = None
+ lastBondTradeAttempt = None
+ bondTradeMessage = None
+ bondPositionsReadSeq = 0
+ bondPositionsInFlight = false
+ bondPositions = []
+ bondPositionsMessage = None
returnsSelectedDate = None
planReadSeq = 0
planInFlight = false
@@ -1793,6 +1908,20 @@ let private readStockPositionsCommand token fundId requestId =
(fun positions -> StockPositionsReadCompleted(requestId, fundId, positions))
(fun error -> StockPositionsReadFailed(requestId, fundId, 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 readInvestmentPlansCommand token fundId requestId =
Cmd.OfPromise.either
(fun () -> Api.getInvestmentPlans token fundId)
@@ -3248,6 +3377,105 @@ let update message model =
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.none
+ 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
| ReturnsDateChanged value ->
{ model with returnsSelectedDate = (if String.IsNullOrWhiteSpace value then None else Some value) }, Cmd.none
| InvestmentPlansReadRequested ->
@@ -5015,6 +5243,72 @@ let private bondsPanel model dispatch =
]
| 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.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 "")
+ ]
]
]