diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 04:29:42 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 04:29:42 +0800 |
| commit | b6008d67232756d743699472865025159f6ec533 (patch) | |
| tree | 739cc45c5e9677a08c03cebd0d52a8b773984754 /src/FundLab.Web | |
| parent | b5a80f69cb2e2793e5134e23a0eb3e72cdbdbe0b (diff) | |
| download | fund-lab-b6008d67232756d743699472865025159f6ec533.tar.gz | |
Add stock buy to holdings minimal vertical slice (3d-21)
Diffstat (limited to 'src/FundLab.Web')
| -rw-r--r-- | src/FundLab.Web/App.fs | 303 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 16 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 19 |
3 files changed, 338 insertions, 0 deletions
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index d18cff9..772b97e 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -425,6 +425,34 @@ type RawStockDaily = observations: RawStockDailyObservation array } +type RawStockTrade = + { + id: string + fundId: string + instrumentCode: string + stockName: obj + quantity: string + price: string + costCash: string + executedAt: string + isSynthetic: bool + } + +type RawStockPosition = + { + instrumentCode: string + stockName: obj + quantity: string + costCash: string + lastTradedAt: string + } + +type RawStockPositions = + { + fundId: string + positions: RawStockPosition array + } + type CreateAttempt = { idempotencyKey: string @@ -506,6 +534,13 @@ type CreateSipPayload = frequency: string } +type CreateStockTradePayload = + { + idempotencyKey: string + instrumentCode: string + quantity: string + } + type SipPlan = { id: string @@ -752,6 +787,33 @@ type StockDailyBar = amount: string option } +type StockTradeView = + { + id: string + instrumentCode: string + stockName: string option + quantity: string + price: string + costCash: string + executedAt: string + } + +type StockPositionView = + { + instrumentCode: string + stockName: string option + quantity: string + costCash: string + lastTradedAt: string + } + +type StockTradeAttempt = + { + idempotencyKey: string + instrumentCode: string + quantity: string + } + module Api = [<Import("searchInstruments", "./src/api.js")>] let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative @@ -810,6 +872,12 @@ module Api = [<Import("getStockDaily", "./src/api.js")>] let getStockDaily (token: string) (code: string) (days: int) : JS.Promise<RawStockDaily> = jsNative + [<Import("createStockTrade", "./src/api.js")>] + let createStockTrade (token: string) (fundId: string) (payload: CreateStockTradePayload) : JS.Promise<RawStockTrade> = jsNative + + [<Import("getStockPositions", "./src/api.js")>] + let getStockPositions (token: string) (fundId: string) : JS.Promise<RawStockPositions> = jsNative + [<Import("createCapitalDeposit", "./src/api.js")>] let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative @@ -1109,6 +1177,26 @@ module Api = amount = decodeOptionalText raw.amount } + let decodeStockTrade (raw: RawStockTrade) : StockTradeView = + { + id = raw.id + instrumentCode = raw.instrumentCode + stockName = decodeOptionalText raw.stockName + quantity = raw.quantity + price = raw.price + costCash = raw.costCash + executedAt = raw.executedAt + } + + let decodeStockPosition (raw: RawStockPosition) : StockPositionView = + { + instrumentCode = raw.instrumentCode + stockName = decodeOptionalText raw.stockName + quantity = raw.quantity + costCash = raw.costCash + lastTradedAt = raw.lastTradedAt + } + type Model = { token: string @@ -1206,6 +1294,16 @@ type Model = stockDailyInFlight: bool stockDaily: StockDailyBar list stockDailyMessage: string option + stockTradeQuantity: string + stockTradeSeq: int + stockTradeInFlight: bool + lastStockTrade: StockTradeView option + lastStockTradeAttempt: StockTradeAttempt option + stockTradeMessage: string option + stockPositionsReadSeq: int + stockPositionsInFlight: bool + stockPositions: StockPositionView list + stockPositionsMessage: string option returnsSelectedDate: string option planReadSeq: int planInFlight: bool @@ -1317,6 +1415,13 @@ type Msg = | StockDailyRequested | StockDailyCompleted of requestId: int * daily: RawStockDaily | StockDailyFailed of requestId: int * message: string + | StockTradeQuantityChanged of string + | StockTradeRequested + | StockTradeCompleted of requestId: int * fundId: string * trade: RawStockTrade + | StockTradeFailed of requestId: int * fundId: string * message: string + | StockPositionsReadRequested + | StockPositionsReadCompleted of requestId: int * fundId: string * positions: RawStockPositions + | StockPositionsReadFailed of requestId: int * fundId: string * message: string | ReturnsDateChanged of string | InvestmentPlansReadRequested | InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array @@ -1342,6 +1447,15 @@ let isNonNegativeCash (text: string) = | true, value -> value >= 0M | false, _ -> false +let private quantityPattern = + Text.RegularExpressions.Regex("^[0-9]{1,18}$", Text.RegularExpressions.RegexOptions.Compiled) + +let isValidQuantityText (text: string) = + quantityPattern.IsMatch text + && match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with + | true, value -> value > 0M + | false, _ -> false + let resolveCreateKey (lastAttempt: CreateAttempt option) (name: string) (cash: string) = match lastAttempt with | Some attempt when attempt.name = name && attempt.cash = cash -> attempt.idempotencyKey @@ -1450,6 +1564,16 @@ let init () = stockDailyInFlight = false stockDaily = [] stockDailyMessage = None + stockTradeQuantity = "" + stockTradeSeq = 0 + stockTradeInFlight = false + lastStockTrade = None + lastStockTradeAttempt = None + stockTradeMessage = None + stockPositionsReadSeq = 0 + stockPositionsInFlight = false + stockPositions = [] + stockPositionsMessage = None returnsSelectedDate = None planReadSeq = 0 planInFlight = false @@ -1655,6 +1779,20 @@ let private readStockDailyCommand token code days requestId = (fun daily -> StockDailyCompleted(requestId, daily)) (fun error -> StockDailyFailed(requestId, errorText error)) +let private createStockTradeCommand token fundId payload requestId = + Cmd.OfPromise.either + (fun () -> Api.createStockTrade token fundId payload) + () + (fun trade -> StockTradeCompleted(requestId, fundId, trade)) + (fun error -> StockTradeFailed(requestId, fundId, errorText error)) + +let private readStockPositionsCommand token fundId requestId = + Cmd.OfPromise.either + (fun () -> Api.getStockPositions token fundId) + () + (fun positions -> StockPositionsReadCompleted(requestId, fundId, positions)) + (fun error -> StockPositionsReadFailed(requestId, fundId, errorText error)) + let private readInvestmentPlansCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getInvestmentPlans token fundId) @@ -3011,6 +3149,105 @@ let update message model = Cmd.none else model, Cmd.none + | StockTradeQuantityChanged value -> { model with stockTradeQuantity = value }, Cmd.none + | StockTradeRequested -> + let code = model.stockCode.Trim() + let quantity = model.stockTradeQuantity.Trim() + + if String.IsNullOrWhiteSpace model.token then + { model with stockTradeMessage = Some "请输入 API token" }, Cmd.none + elif model.createdFund.IsNone then + { model with stockTradeMessage = Some "请先创建一个基金" }, Cmd.none + elif code.Length <> 6 || not (code |> Seq.forall Char.IsDigit) then + { model with stockTradeMessage = Some "无效代码 — 请输入六位股票代码" }, Cmd.none + elif not (isValidQuantityText quantity) then + { model with stockTradeMessage = Some "数量必须是大于零的整数股数,例如 100" }, Cmd.none + elif model.stockTradeInFlight then + model, Cmd.none + else + let requestId = model.stockTradeSeq + 1 + + let idempotencyKey = + match model.lastStockTradeAttempt with + | Some attempt when attempt.instrumentCode = code && attempt.quantity = quantity -> attempt.idempotencyKey + | _ -> Guid.NewGuid().ToString("N") + + { + model with + stockCode = code + stockTradeQuantity = quantity + stockTradeSeq = requestId + stockTradeInFlight = true + lastStockTradeAttempt = + Some + { + idempotencyKey = idempotencyKey + instrumentCode = code + quantity = quantity + } + stockTradeMessage = None + }, + createStockTradeCommand + model.token + model.createdFund.Value.id + { idempotencyKey = idempotencyKey; instrumentCode = code; quantity = quantity } + requestId + | StockTradeCompleted (requestId, fundId, trade) -> + if requestId = model.stockTradeSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + stockTradeInFlight = false + lastStockTrade = Some(Api.decodeStockTrade trade) + lastStockTradeAttempt = None + stockTradeMessage = None + }, + Cmd.ofMsg StockPositionsReadRequested + else + model, Cmd.none + | StockTradeFailed (requestId, fundId, message) -> + if requestId = model.stockTradeSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { model with stockTradeInFlight = false; stockTradeMessage = Some(sprintf "买入失败 — %s" message) }, Cmd.none + else + model, Cmd.none + | StockPositionsReadRequested -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) -> + let requestId = model.stockPositionsReadSeq + 1 + + { + model with + stockPositionsReadSeq = requestId + stockPositionsInFlight = true + stockPositionsMessage = None + }, + readStockPositionsCommand model.token fund.id requestId + | _ -> model, Cmd.none + | StockPositionsReadCompleted (requestId, fundId, positions) -> + if requestId = model.stockPositionsReadSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + stockPositions = positions.positions |> Array.toList |> List.map Api.decodeStockPosition + stockPositionsInFlight = false + stockPositionsMessage = None + }, + Cmd.none + else + model, Cmd.none + | StockPositionsReadFailed (requestId, fundId, message) -> + if requestId = model.stockPositionsReadSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + stockPositions = [] + stockPositionsInFlight = false + stockPositionsMessage = 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 -> @@ -4865,6 +5102,72 @@ let private stocksPanel model dispatch = prop.hidden (model.stockDailyMessage.IsNone) prop.text (model.stockDailyMessage |> Option.defaultValue "") ] + Html.div [ + prop.className "stock-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 stock-trade-quantity-input" + prop.placeholder "买入数量(整数股),如 100" + prop.value model.stockTradeQuantity + prop.onChange (fun value -> dispatch (StockTradeQuantityChanged value)) + ] + Html.button [ + prop.className "primary-action stock-trade-action" + prop.disabled model.stockTradeInFlight + prop.onClick (fun _ -> dispatch StockTradeRequested) + prop.text ((if model.stockTradeInFlight then "买入中..." else "买入"): string) + ] + Html.button [ + prop.className "secondary-action stock-positions-refresh-action" + prop.disabled model.stockPositionsInFlight + prop.onClick (fun _ -> dispatch StockPositionsReadRequested) + prop.text ((if model.stockPositionsInFlight then "读取中..." else "刷新持仓"): string) + ] + ] + ] + Html.p [ + prop.className "stock-trade-unavailable" + prop.hidden (model.stockTradeMessage.IsNone) + prop.text (model.stockTradeMessage |> Option.defaultValue "") + ] + ] + ] + Html.div [ + prop.className "stock-positions" + prop.hidden (List.isEmpty model.stockPositions) + prop.children ( + Html.p [ prop.className "returns-subheading"; prop.text "股票持仓" ] + :: Html.div [ + prop.className "stock-positions-row stock-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.stockPositions + |> List.map (fun position -> + Html.div [ + prop.className "stock-positions-row stock-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.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.p [ + prop.className "stock-positions-unavailable" + prop.hidden (model.stockPositionsMessage.IsNone) + prop.text (model.stockPositionsMessage |> Option.defaultValue "") + ] ] ] diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index 304c79f..968b86f 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -147,6 +147,22 @@ export function getStockDaily(token, code, days) { return requestJson(`/api/market/stock-daily?code=${encodeURIComponent(code)}&days=${days}`, token); } +export function createStockTrade(token, fundId, payload) { + const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"quantity":${JSON.stringify(payload.quantity)}}`; + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/stock-trades`, 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); +} + export function createSipPlan(token, fundId, payload) { const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"amount":${JSON.stringify(payload.amount)},"frequency":${JSON.stringify(payload.frequency)}}`; return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token, { diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css index c0ac4a4..bf0627b 100644 --- a/src/FundLab.Web/src/styles.css +++ b/src/FundLab.Web/src/styles.css @@ -599,6 +599,25 @@ h2 { font-size: 12px; } +.stock-trade-unavailable, +.stock-positions-unavailable { + color: #b45309; + font-size: 12px; +} + +.stock-positions-header .fund-detail-label { + font-weight: 600; +} + +.stock-positions-row { + display: grid; + grid-template-columns: 1fr 1.4fr 1fr 1fr; + gap: 8px; + align-items: center; + padding: 4px 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); +} + .returns-pending { color: #b45309; font-size: 12px; |
