diff options
Diffstat (limited to 'src/FundLab.Web')
| -rw-r--r-- | src/FundLab.Web/App.fs | 215 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 16 |
2 files changed, 230 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 ])) + ) + ] ] ] diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index 3947688..3136371 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -191,6 +191,22 @@ export function getBondPositions(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}/bond-positions`, token); } +export function createBondSell(token, fundId, payload) { + const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"quantity":${JSON.stringify(payload.quantity)}}`; + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/bond-sells`, token, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": payload.idempotencyKey + }, + body + }); +} + +export function getBondCalendar(token, fundId) { + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/bond-calendar`, token); +} + export function getFundValuation(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}/valuation`, token); } |
