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.fs215
1 files changed, 214 insertions, 1 deletions
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index 5e891ae..56422ed 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -503,6 +503,47 @@ type RawBondPositions =
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
@@ -1063,6 +1104,12 @@ module Api =
[<Import("getBondPositions", "./src/api.js")>]
let getBondPositions (token: string) (fundId: string) : JS.Promise<RawBondPositions> = jsNative
+ [<Import("createBondSell", "./src/api.js")>]
+ let createBondSell (token: string) (fundId: string) (payload: CreateBondTradePayload) : JS.Promise<RawBondSell> = jsNative
+
+ [<Import("getBondCalendar", "./src/api.js")>]
+ let getBondCalendar (token: string) (fundId: string) : JS.Promise<RawBondCalendar> = jsNative
+
[<Import("getFundValuation", "./src/api.js")>]
let getFundValuation (token: string) (fundId: string) : JS.Promise<RawFundValuation> = jsNative
@@ -1574,6 +1621,14 @@ type Model =
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
@@ -1717,6 +1772,13 @@ type Msg =
| 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
@@ -1894,6 +1956,14 @@ let init () =
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
@@ -2162,6 +2232,20 @@ let private readBondPositionsCommand token fundId requestId =
(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)
@@ -3831,7 +3915,7 @@ let update message model =
bondPositionsInFlight = false
bondPositionsMessage = None
},
- Cmd.none
+ Cmd.ofMsg BondCalendarReadRequested
else
model, Cmd.none
| BondPositionsReadFailed (requestId, fundId, message) ->
@@ -3846,6 +3930,95 @@ let update message model =
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) ->
@@ -5757,6 +5930,29 @@ let private bondsPanel model dispatch =
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 [
@@ -5791,6 +5987,23 @@ let private bondsPanel model dispatch =
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 ]))
+ )
+ ]
]
]