summaryrefslogtreecommitdiff
path: root/src/FundLab.Web
diff options
context:
space:
mode:
Diffstat (limited to 'src/FundLab.Web')
-rw-r--r--src/FundLab.Web/App.fs126
-rw-r--r--src/FundLab.Web/src/api.js12
-rw-r--r--src/FundLab.Web/src/styles.css11
3 files changed, 148 insertions, 1 deletions
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index fe16411..c150596 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -453,6 +453,20 @@ type RawStockPositions =
positions: RawStockPosition array
}
+type RawStockSell =
+ {
+ id: string
+ fundId: string
+ instrumentCode: string
+ stockName: obj
+ quantity: string
+ price: string
+ feeAmount: string
+ proceeds: string
+ executedAt: string
+ isSynthetic: bool
+ }
+
type RawBondTrade =
{
id: string
@@ -616,6 +630,20 @@ type CreateStockTradePayload =
quantity: string
}
+type CreateStockSellPayload =
+ {
+ idempotencyKey: string
+ instrumentCode: string
+ quantity: string
+ }
+
+type StockSellAttempt =
+ {
+ idempotencyKey: string
+ instrumentCode: string
+ quantity: string
+ }
+
type CreateBondTradePayload =
{
idempotencyKey: string
@@ -1010,6 +1038,9 @@ module Api =
[<Import("getStockPositions", "./src/api.js")>]
let getStockPositions (token: string) (fundId: string) : JS.Promise<RawStockPositions> = jsNative
+ [<Import("createStockSell", "./src/api.js")>]
+ let createStockSell (token: string) (fundId: string) (payload: CreateStockSellPayload) : JS.Promise<RawStockSell> = jsNative
+
[<Import("createBondTrade", "./src/api.js")>]
let createBondTrade (token: string) (fundId: string) (payload: CreateBondTradePayload) : JS.Promise<RawBondTrade> = jsNative
@@ -1504,6 +1535,11 @@ type Model =
stockPositionsInFlight: bool
stockPositions: StockPositionView list
stockPositionsMessage: string option
+ stockSellQuantities: Map<string, string>
+ stockSellAttempts: Map<string, StockSellAttempt>
+ stockSellSeq: int
+ stockSellInFlight: bool
+ stockSellMessage: string option
bondTradeQuantity: string
bondTradeSeq: int
bondTradeInFlight: bool
@@ -1646,6 +1682,10 @@ type Msg =
| StockPositionsReadRequested
| StockPositionsReadCompleted of requestId: int * fundId: string * positions: RawStockPositions
| StockPositionsReadFailed of requestId: int * fundId: string * message: string
+ | StockSellQuantityChanged of code: string * value: string
+ | StockSellRequested of code: string
+ | StockSellCompleted of requestId: int * fundId: string * code: string * sell: RawStockSell
+ | StockSellFailed of requestId: int * fundId: string * code: string * message: string
| BondTradeQuantityChanged of string
| BondTradeRequested
| BondTradeCompleted of requestId: int * fundId: string * trade: RawBondTrade
@@ -1815,6 +1855,11 @@ let init () =
stockPositionsInFlight = false
stockPositions = []
stockPositionsMessage = None
+ stockSellQuantities = Map.empty
+ stockSellAttempts = Map.empty
+ stockSellSeq = 0
+ stockSellInFlight = false
+ stockSellMessage = None
bondTradeQuantity = ""
bondTradeSeq = 0
bondTradeInFlight = false
@@ -2072,6 +2117,13 @@ let private readStockPositionsCommand token fundId requestId =
(fun positions -> StockPositionsReadCompleted(requestId, fundId, positions))
(fun error -> StockPositionsReadFailed(requestId, fundId, errorText error))
+let private createStockSellCommand token fundId payload requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.createStockSell token fundId payload)
+ ()
+ (fun sell -> StockSellCompleted(requestId, fundId, payload.instrumentCode, sell))
+ (fun error -> StockSellFailed(requestId, fundId, payload.instrumentCode, errorText error))
+
let private createBondTradeCommand token fundId payload requestId =
Cmd.OfPromise.either
(fun () -> Api.createBondTrade token fundId payload)
@@ -3616,6 +3668,61 @@ let update message model =
Cmd.none
else
model, Cmd.none
+ | StockSellQuantityChanged (code, value) ->
+ { model with stockSellQuantities = Map.add code value model.stockSellQuantities }, Cmd.none
+ | StockSellRequested code ->
+ match model.createdFund with
+ | Some fund when not (String.IsNullOrWhiteSpace model.token) ->
+ let quantity = (model.stockSellQuantities |> Map.tryFind code |> Option.defaultValue "").Trim()
+
+ if not (isValidQuantityText quantity) then
+ { model with stockSellMessage = Some "卖出数量必须是大于零的整数股数,例如 100" }, Cmd.none
+ else
+ let requestId = model.stockSellSeq + 1
+
+ let idempotencyKey =
+ match model.stockSellAttempts |> Map.tryFind code with
+ | Some attempt when attempt.quantity = quantity -> attempt.idempotencyKey
+ | _ -> Guid.NewGuid().ToString("N")
+
+ let payload : CreateStockSellPayload =
+ { idempotencyKey = idempotencyKey; instrumentCode = code; quantity = quantity }
+
+ let attempt : StockSellAttempt =
+ { idempotencyKey = idempotencyKey; instrumentCode = code; quantity = quantity }
+
+ {
+ model with
+ stockSellSeq = requestId
+ stockSellInFlight = true
+ stockSellMessage = None
+ stockSellAttempts = Map.add code attempt model.stockSellAttempts
+ },
+ createStockSellCommand model.token fund.id payload requestId
+ | _ when String.IsNullOrWhiteSpace model.token ->
+ { model with stockSellMessage = Some "请输入 API token" }, Cmd.none
+ | _ ->
+ { model with stockSellMessage = Some "请先创建一个基金" }, Cmd.none
+ | StockSellCompleted (requestId, fundId, code, sell) ->
+ if requestId = model.stockSellSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ {
+ model with
+ stockSellInFlight = false
+ stockSellMessage =
+ Some(sprintf "已卖出 %s %s 股:成交价 %s,回收现金 %s" code sell.quantity sell.price sell.proceeds)
+ stockSellQuantities = Map.remove code model.stockSellQuantities
+ stockSellAttempts = Map.remove code model.stockSellAttempts
+ },
+ Cmd.batch [ Cmd.ofMsg StockPositionsReadRequested; Cmd.ofMsg ValuationReadRequested ]
+ else
+ model, Cmd.none
+ | StockSellFailed (requestId, fundId, code, message) ->
+ if requestId = model.stockSellSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ { model with stockSellInFlight = false; stockSellMessage = Some(sprintf "卖出失败 — %s" message) }, Cmd.none
+ else
+ model, Cmd.none
| BondTradeQuantityChanged value -> { model with bondTradeQuantity = value }, Cmd.none
| BondTradeRequested ->
let code = model.bondCode.Trim()
@@ -5788,6 +5895,8 @@ let private stocksPanel model dispatch =
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 "卖出数量" ]
+ Html.span [ prop.className "fund-detail-label"; prop.text "操作" ]
]
]
:: (model.stockPositions
@@ -5799,6 +5908,18 @@ let private stocksPanel model dispatch =
Html.span [ prop.className "fund-detail-value"; prop.text (position.stockName |> 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.input [
+ prop.className "text-input stock-sell-quantity-input"
+ prop.placeholder "卖出数量"
+ prop.value (model.stockSellQuantities |> Map.tryFind position.instrumentCode |> Option.defaultValue "")
+ prop.onChange (fun value -> dispatch (StockSellQuantityChanged(position.instrumentCode, value)))
+ ]
+ Html.button [
+ prop.className "secondary-action stock-sell-action"
+ prop.disabled model.stockSellInFlight
+ prop.onClick (fun _ -> dispatch (StockSellRequested position.instrumentCode))
+ prop.text ((if model.stockSellInFlight then "卖出中..." else "卖出"): string)
+ ]
]
]))
)
@@ -5808,6 +5929,11 @@ let private stocksPanel model dispatch =
prop.hidden (model.stockPositionsMessage.IsNone)
prop.text (model.stockPositionsMessage |> Option.defaultValue "")
]
+ Html.p [
+ prop.className "stock-sell-message"
+ prop.hidden (model.stockSellMessage.IsNone)
+ prop.text (model.stockSellMessage |> Option.defaultValue "")
+ ]
Html.div [
prop.className "fund-valuation"
prop.children [
diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js
index eaa8978..3947688 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -159,6 +159,18 @@ export function createStockTrade(token, fundId, payload) {
});
}
+export function createStockSell(token, fundId, payload) {
+ const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"quantity":${JSON.stringify(payload.quantity)}}`;
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}/stock-sells`, token, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": payload.idempotencyKey
+ },
+ body
+ });
+}
+
export function getStockPositions(token, fundId) {
return requestJson(`/api/funds/${encodeURIComponent(fundId)}/stock-positions`, token);
}
diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css
index dbd53ed..8aee2d2 100644
--- a/src/FundLab.Web/src/styles.css
+++ b/src/FundLab.Web/src/styles.css
@@ -648,13 +648,22 @@ h2 {
.stock-positions-row {
display: grid;
- grid-template-columns: 1fr 1.4fr 1fr 1fr;
+ grid-template-columns: 1fr 1.4fr 1fr 1fr 1.1fr 0.9fr;
gap: 8px;
align-items: center;
padding: 4px 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
+.stock-sell-quantity-input {
+ min-width: 0;
+}
+
+.stock-sell-message {
+ color: #0f766e;
+ font-size: 12px;
+}
+
.returns-pending {
color: #b45309;
font-size: 12px;