summaryrefslogtreecommitdiff
path: root/src/FundLab.Web
diff options
context:
space:
mode:
Diffstat (limited to 'src/FundLab.Web')
-rw-r--r--src/FundLab.Web/App.fs303
-rw-r--r--src/FundLab.Web/src/api.js16
-rw-r--r--src/FundLab.Web/src/styles.css19
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;