summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/FundLab.Api/App.fs103
-rw-r--r--src/FundLab.Api/Persistence.fs331
-rw-r--r--src/FundLab.Web/App.fs298
-rw-r--r--src/FundLab.Web/src/api.js16
-rw-r--r--src/FundLab.Web/src/styles.css37
5 files changed, 777 insertions, 8 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index f61e760..a2aa271 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -22,9 +22,23 @@ type FundApiResponse =
initialUnitNav: string
isSynthetic: bool
availableCash: string
+ reservedCash: string
status: string
}
+type SubscriptionOrderApiResponse =
+ {
+ id: Guid
+ fundId: Guid
+ fundCode: string
+ amount: string
+ feeAmount: string
+ reservedTotal: string
+ status: string
+ submittedAt: string
+ isSynthetic: bool
+ }
+
type ApiErrorResponse =
{
error: string
@@ -86,9 +100,23 @@ module App =
initialUnitNav = unitNavText fund.InitialUnitNav
isSynthetic = fund.IsSynthetic
availableCash = cashText fund.AvailableCash
+ reservedCash = cashText fund.ReservedCash
status = fund.Status
}
+ let private orderResponse (order: SubscriptionOrderRecord) : SubscriptionOrderApiResponse =
+ {
+ id = order.Id
+ fundId = order.FundId
+ fundCode = order.FundCode
+ amount = cashText order.Amount
+ feeAmount = cashText order.FeeAmount
+ reservedTotal = cashText order.ReservedTotal
+ status = order.Status
+ submittedAt = timestampText order.SubmittedAt
+ isSynthetic = order.IsSynthetic
+ }
+
let private errorResponse status error message : HttpHandler =
setStatusCode status
>=> json ({
@@ -148,6 +176,30 @@ module App =
with
| :? JsonException -> Error "request body must be valid JSON"
+ let private parseOrderCommand (body: string) =
+ try
+ use document = JsonDocument.Parse(body)
+ let root = document.RootElement
+
+ if root.ValueKind <> JsonValueKind.Object then
+ Error "request body must be a JSON object"
+ else
+ match tryStringProperty root "fundCode", tryStringProperty root "amount", tryStringProperty root "feeAmount" with
+ | Some fundCode, Some amountText, Some feeAmountText ->
+ match tryDecimal "amount" amountText, tryDecimal "feeAmount" feeAmountText with
+ | Ok amount, Ok feeAmount ->
+ Ok {
+ FundCode = fundCode
+ Amount = amount
+ FeeAmount = feeAmount
+ }
+ | Error message, _
+ | _, Error message -> Error message
+ | _ ->
+ Error "fundCode, amount and feeAmount are required"
+ with
+ | :? JsonException -> Error "request body must be valid JSON"
+
let private invokeHandler handler next ctx = handler next ctx
let private unauthorized : HttpHandler =
@@ -216,6 +268,55 @@ module App =
with _ ->
errorResponse 500 "PERSISTENCE_ERROR" "fund persistence failed" next ctx
+ let private createOrder (repository: FundRepository) (fundIdText: string) : HttpHandler =
+ fun next ctx ->
+ task {
+ match Guid.TryParse fundIdText with
+ | false, _ ->
+ return! invokeHandler (errorResponse 400 "INVALID_FUND_ID" "fund id must be a UUID") next ctx
+ | true, fundId ->
+ use reader = new StreamReader(ctx.Request.Body)
+ let! body = reader.ReadToEndAsync()
+ let idempotencyKey = ctx.Request.Headers["Idempotency-Key"].ToString()
+
+ match parseOrderCommand body with
+ | Error message ->
+ return! invokeHandler (errorResponse 400 "INVALID_ORDER_REQUEST" message) next ctx
+ | Ok command ->
+ try
+ match repository.CreateSubscriptionOrder(idempotencyKey, fundId, command) with
+ | SubscriptionOrderWriteResult.OrderCreated order ->
+ return! invokeHandler (setStatusCode 201 >=> json (orderResponse order)) next ctx
+ | SubscriptionOrderWriteResult.OrderReplayed order ->
+ return! invokeHandler (json (orderResponse order)) next ctx
+ | SubscriptionOrderWriteResult.OrderIdempotencyConflict ->
+ return! invokeHandler (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") next ctx
+ | SubscriptionOrderWriteResult.OrderInvalid message ->
+ return! invokeHandler (errorResponse 400 "INVALID_ORDER_REQUEST" message) next ctx
+ | SubscriptionOrderWriteResult.OrderFundNotFound ->
+ return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx
+ | SubscriptionOrderWriteResult.OrderInstrumentNotFound ->
+ return! invokeHandler (errorResponse 404 "INSTRUMENT_NOT_FOUND" "fund code was not found in the instrument catalog") next ctx
+ | SubscriptionOrderWriteResult.OrderInsufficientFunds ->
+ return! invokeHandler (errorResponse 409 "INSUFFICIENT_FUNDS" "available cash is not enough to reserve the amount plus fee") next ctx
+ with _ ->
+ return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "order persistence failed") next ctx
+ }
+
+ let private getOrders (repository: FundRepository) (fundIdText: string) : HttpHandler =
+ fun next ctx ->
+ match Guid.TryParse fundIdText with
+ | false, _ -> errorResponse 400 "INVALID_FUND_ID" "fund id must be a UUID" next ctx
+ | true, fundId ->
+ try
+ match repository.GetFund fundId with
+ | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx
+ | Some _ ->
+ let orders = repository.GetSubscriptionOrders fundId
+ json (orders |> List.map orderResponse) next ctx
+ with _ ->
+ errorResponse 500 "PERSISTENCE_ERROR" "order persistence failed" next ctx
+
let private marketDataError (failure: MarketDataFailure) : HttpHandler =
let status, error, message =
match failure with
@@ -311,6 +412,8 @@ module App =
[
GET >=> route "/portfolio/summary" >=> emptyPortfolio
POST >=> route "/funds" >=> createFund repository
+ POST >=> routef "/funds/%s/orders" (createOrder repository)
+ GET >=> routef "/funds/%s/orders" (getOrders repository)
GET >=> routef "/funds/%s" (getFund repository)
]
@ (marketData |> Option.map marketDataRoutes |> Option.defaultValue [])
diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs
index e48d9b1..88c0350 100644
--- a/src/FundLab.Api/Persistence.fs
+++ b/src/FundLab.Api/Persistence.fs
@@ -28,6 +28,7 @@ type FundRecord =
InitialUnitNav: decimal
IsSynthetic: bool
AvailableCash: decimal
+ ReservedCash: decimal
Status: string
}
@@ -37,6 +38,36 @@ type FundWriteResult =
| IdempotencyConflict
| Invalid of string
+[<CLIMutable>]
+type SubscriptionOrderCommand =
+ {
+ FundCode: string
+ Amount: decimal
+ FeeAmount: decimal
+ }
+
+type SubscriptionOrderRecord =
+ {
+ Id: Guid
+ FundId: Guid
+ FundCode: string
+ Amount: decimal
+ FeeAmount: decimal
+ ReservedTotal: decimal
+ Status: string
+ SubmittedAt: DateTimeOffset
+ IsSynthetic: bool
+ }
+
+type SubscriptionOrderWriteResult =
+ | OrderCreated of SubscriptionOrderRecord
+ | OrderReplayed of SubscriptionOrderRecord
+ | OrderIdempotencyConflict
+ | OrderInvalid of string
+ | OrderFundNotFound
+ | OrderInstrumentNotFound
+ | OrderInsufficientFunds
+
type FundRepository(connectionString: string) =
let cashMaximum = 999999999999999999.99m
let unitNavMaximum = 99999999999999999999.99999999m
@@ -92,6 +123,28 @@ type FundRepository(connectionString: string) =
CREATE INDEX IF NOT EXISTS fund_nav_observations_date_idx
ON fund_nav_observations (instrument_code, nav_date);
+
+ ALTER TABLE funds ADD COLUMN IF NOT EXISTS reserved_cash numeric(20, 2) NOT NULL DEFAULT 0;
+
+ CREATE TABLE IF NOT EXISTS subscription_orders (
+ id uuid PRIMARY KEY,
+ fund_id uuid NOT NULL REFERENCES funds(id),
+ fund_code text NOT NULL,
+ amount numeric(20, 2) NOT NULL CHECK (amount > 0),
+ fee_amount numeric(20, 2) NOT NULL CHECK (fee_amount > 0),
+ reserved_total numeric(20, 2) NOT NULL CHECK (reserved_total > 0),
+ status text NOT NULL,
+ is_synthetic boolean NOT NULL,
+ submitted_at timestamptz NOT NULL DEFAULT now()
+ );
+
+ CREATE TABLE IF NOT EXISTS subscription_order_idempotencies (
+ idempotency_key text PRIMARY KEY,
+ request_hash text NOT NULL,
+ order_id uuid NOT NULL REFERENCES subscription_orders(id),
+ fund_id uuid NOT NULL REFERENCES funds(id),
+ created_at timestamptz NOT NULL DEFAULT now()
+ );
"""
let statusText status =
@@ -109,6 +162,7 @@ type FundRepository(connectionString: string) =
InitialUnitNav = fund.InitialUnitNav
IsSynthetic = fund.IsSynthetic
AvailableCash = fund.AvailableCash
+ ReservedCash = fund.FrozenCash
Status = statusText fund.Status
}
@@ -121,7 +175,8 @@ type FundRepository(connectionString: string) =
InitialUnitNav = reader.GetDecimal(4)
IsSynthetic = reader.GetBoolean(5)
AvailableCash = reader.GetDecimal(6)
- Status = reader.GetString(7)
+ ReservedCash = reader.GetDecimal(7)
+ Status = reader.GetString(8)
}
let dateTimeOffsetFromReader (reader: DbDataReader) index =
@@ -189,7 +244,7 @@ type FundRepository(connectionString: string) =
transaction
"""
SELECT id, name, currency, initial_cash, initial_unit_nav,
- is_synthetic, available_cash, status
+ is_synthetic, available_cash, reserved_cash, status
FROM funds
WHERE id = @fund_id
"""
@@ -219,10 +274,10 @@ type FundRepository(connectionString: string) =
"""
INSERT INTO funds
(id, name, currency, initial_cash, initial_unit_nav,
- is_synthetic, available_cash, status)
+ is_synthetic, available_cash, reserved_cash, status)
VALUES
(@id, @name, @currency, @initial_cash, @initial_unit_nav,
- @is_synthetic, @available_cash, @status)
+ @is_synthetic, @available_cash, @reserved_cash, @status)
"""
addParameter command "id" NpgsqlDbType.Uuid (box fund.Id) |> ignore
@@ -232,6 +287,7 @@ type FundRepository(connectionString: string) =
addParameter command "initial_unit_nav" NpgsqlDbType.Numeric (box fund.InitialUnitNav) |> ignore
addParameter command "is_synthetic" NpgsqlDbType.Boolean (box fund.IsSynthetic) |> ignore
addParameter command "available_cash" NpgsqlDbType.Numeric (box fund.AvailableCash) |> ignore
+ addParameter command "reserved_cash" NpgsqlDbType.Numeric (box fund.FrozenCash) |> ignore
addParameter command "status" NpgsqlDbType.Text (box (statusText fund.Status)) |> ignore
command.ExecuteNonQuery() |> ignore
@@ -250,6 +306,121 @@ type FundRepository(connectionString: string) =
addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
command.ExecuteNonQuery() |> ignore
+ let orderStatusText = "submitted"
+
+ let orderRecordFromReader (reader: DbDataReader) : SubscriptionOrderRecord =
+ {
+ Id = reader.GetGuid(0)
+ FundId = reader.GetGuid(1)
+ FundCode = reader.GetString(2)
+ Amount = reader.GetDecimal(3)
+ FeeAmount = reader.GetDecimal(4)
+ ReservedTotal = reader.GetDecimal(5)
+ Status = reader.GetString(6)
+ IsSynthetic = reader.GetBoolean(7)
+ SubmittedAt = reader.GetFieldValue<DateTimeOffset>(8)
+ }
+
+ let findOrder connection transaction orderId =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ SELECT id, fund_id, fund_code, amount, fee_amount,
+ reserved_total, status, is_synthetic, submitted_at
+ FROM subscription_orders
+ WHERE id = @order_id
+ """
+
+ addParameter command "order_id" NpgsqlDbType.Uuid (box orderId) |> ignore
+
+ use reader = command.ExecuteReader()
+ if reader.Read() then Some(orderRecordFromReader reader) else None
+
+ let findOrderIdempotency connection transaction key =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ "SELECT request_hash, fund_id, order_id FROM subscription_order_idempotencies WHERE idempotency_key = @idempotency_key"
+
+ addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore
+
+ use reader = command.ExecuteReader()
+ if reader.Read() then
+ Some(reader.GetString(0), reader.GetGuid(1), reader.GetGuid(2))
+ else
+ None
+
+ let instrumentExists connection transaction code =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ "SELECT 1 FROM instruments WHERE code = @code"
+
+ addParameter command "code" NpgsqlDbType.Text (box code) |> ignore
+
+ use reader = command.ExecuteReader()
+ reader.Read()
+
+ let lockFundForOrder connection transaction fundId =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ "SELECT is_synthetic FROM funds WHERE id = @fund_id FOR UPDATE"
+
+ addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+
+ use reader = command.ExecuteReader()
+ if reader.Read() then Some(reader.GetBoolean(0)) else None
+
+ let insertSubscriptionOrder connection transaction (order: SubscriptionOrderRecord) =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ INSERT INTO subscription_orders
+ (id, fund_id, fund_code, amount, fee_amount,
+ reserved_total, status, is_synthetic)
+ VALUES
+ (@id, @fund_id, @fund_code, @amount, @fee_amount,
+ @reserved_total, @status, @is_synthetic)
+ RETURNING submitted_at
+ """
+
+ addParameter command "id" NpgsqlDbType.Uuid (box order.Id) |> ignore
+ addParameter command "fund_id" NpgsqlDbType.Uuid (box order.FundId) |> ignore
+ addParameter command "fund_code" NpgsqlDbType.Text (box order.FundCode) |> ignore
+ addParameter command "amount" NpgsqlDbType.Numeric (box order.Amount) |> ignore
+ addParameter command "fee_amount" NpgsqlDbType.Numeric (box order.FeeAmount) |> ignore
+ addParameter command "reserved_total" NpgsqlDbType.Numeric (box order.ReservedTotal) |> ignore
+ addParameter command "status" NpgsqlDbType.Text (box order.Status) |> ignore
+ addParameter command "is_synthetic" NpgsqlDbType.Boolean (box order.IsSynthetic) |> ignore
+
+ use reader = command.ExecuteReader()
+ reader.Read() |> ignore
+ reader.GetFieldValue<DateTimeOffset>(0)
+
+ let insertOrderIdempotency connection transaction key requestHash orderId fundId =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ INSERT INTO subscription_order_idempotencies (idempotency_key, request_hash, order_id, fund_id)
+ VALUES (@idempotency_key, @request_hash, @order_id, @fund_id)
+ """
+
+ addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore
+ addParameter command "request_hash" NpgsqlDbType.Text (box requestHash) |> ignore
+ addParameter command "order_id" NpgsqlDbType.Uuid (box orderId) |> ignore
+ addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+ command.ExecuteNonQuery() |> ignore
+
let upsertInstrument connection transaction (payload: MarketDataSearchPayload) payloadHash (instrument: MarketDataInstrument) =
use command =
commandWithTransaction
@@ -371,6 +542,43 @@ type FundRepository(connectionString: string) =
else
Ok()
+ let orderRequestHash (fundId: Guid) (command: SubscriptionOrderCommand) =
+ let invariant = CultureInfo.InvariantCulture
+ let encoded (value: string) = sprintf "%d:%s" value.Length value
+ let fundCode = if isNull command.FundCode then "" else command.FundCode
+ let payload =
+ String.concat
+ "|"
+ [
+ "subscription-order"
+ encoded (fundId.ToString("D"))
+ encoded fundCode
+ (encoded (command.Amount.ToString("G29", invariant)))
+ (encoded (command.FeeAmount.ToString("G29", invariant)))
+ ]
+
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload)))
+
+ let validateOrderCommand (command: SubscriptionOrderCommand) =
+ if String.IsNullOrWhiteSpace command.FundCode then
+ Error "fund code cannot be empty"
+ elif command.Amount <= 0m then
+ Error "amount must be positive"
+ elif command.FeeAmount <= 0m then
+ Error "fee amount must be positive"
+ elif Decimal.Round(command.Amount, 2) <> command.Amount then
+ Error "amount exceeds cash precision"
+ elif Decimal.Round(command.FeeAmount, 2) <> command.FeeAmount then
+ Error "fee amount exceeds cash precision"
+ elif command.Amount > cashMaximum then
+ Error "amount exceeds database precision"
+ elif command.FeeAmount > cashMaximum then
+ Error "fee amount exceeds database precision"
+ elif command.Amount + command.FeeAmount > cashMaximum then
+ Error "reserved total exceeds database precision"
+ else
+ Ok()
+
let ledgerErrorMessage error =
match error with
| InvalidIdentifier label -> sprintf "%s is invalid" label
@@ -552,3 +760,118 @@ type FundRepository(connectionString: string) =
()
raise error
+
+ member _.CreateSubscriptionOrder(idempotencyKey: string, fundId: Guid, command: SubscriptionOrderCommand) =
+ if String.IsNullOrWhiteSpace idempotencyKey then
+ OrderInvalid "idempotency key cannot be empty"
+ else
+ match validateOrderCommand command with
+ | Error message -> OrderInvalid message
+ | Ok() ->
+ let fingerprint = orderRequestHash fundId command
+ let reservedTotal = command.Amount + command.FeeAmount
+ use connection = new NpgsqlConnection(connectionString)
+ connection.Open()
+ use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted)
+
+ try
+ use lockCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ "SELECT pg_advisory_xact_lock(hashtext(@lock_key))"
+
+ addParameter lockCommand "lock_key" NpgsqlDbType.Text (box idempotencyKey) |> ignore
+ lockCommand.ExecuteNonQuery() |> ignore
+
+ match findOrderIdempotency connection (Some transaction) idempotencyKey with
+ | Some(existingHash, existingFundId, orderId)
+ when existingHash = fingerprint && existingFundId = fundId ->
+ match findOrder connection (Some transaction) orderId with
+ | Some order ->
+ transaction.Commit()
+ OrderReplayed order
+ | None ->
+ transaction.Rollback()
+ OrderInvalid "idempotency record references a missing order"
+ | Some _ ->
+ transaction.Rollback()
+ OrderIdempotencyConflict
+ | None ->
+ match lockFundForOrder connection (Some transaction) fundId with
+ | None ->
+ transaction.Rollback()
+ OrderFundNotFound
+ | Some isSynthetic ->
+ if instrumentExists connection (Some transaction) command.FundCode then
+ use cashCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ """
+ UPDATE funds
+ SET available_cash = available_cash - @reserved_total,
+ reserved_cash = reserved_cash + @reserved_total
+ WHERE id = @fund_id AND available_cash >= @reserved_total
+ """
+
+ addParameter cashCommand "reserved_total" NpgsqlDbType.Numeric (box reservedTotal) |> ignore
+ addParameter cashCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+
+ if cashCommand.ExecuteNonQuery() = 0 then
+ transaction.Rollback()
+ OrderInsufficientFunds
+ else
+ let order: SubscriptionOrderRecord =
+ {
+ Id = Guid.NewGuid()
+ FundId = fundId
+ FundCode = command.FundCode
+ Amount = command.Amount
+ FeeAmount = command.FeeAmount
+ ReservedTotal = reservedTotal
+ Status = orderStatusText
+ SubmittedAt = DateTimeOffset.UnixEpoch
+ IsSynthetic = isSynthetic
+ }
+
+ let submittedAt = insertSubscriptionOrder connection (Some transaction) order
+ insertOrderIdempotency connection (Some transaction) idempotencyKey fingerprint order.Id fundId
+ transaction.Commit()
+ OrderCreated { order with SubmittedAt = submittedAt }
+ else
+ transaction.Rollback()
+ OrderInstrumentNotFound
+ with error ->
+ try
+ transaction.Rollback()
+ with _ ->
+ ()
+
+ raise error
+
+ member _.GetSubscriptionOrders(fundId: Guid) =
+ use connection = new NpgsqlConnection(connectionString)
+ connection.Open()
+
+ use command =
+ commandWithTransaction
+ connection
+ None
+ """
+ SELECT id, fund_id, fund_code, amount, fee_amount,
+ reserved_total, status, is_synthetic, submitted_at
+ FROM subscription_orders
+ WHERE fund_id = @fund_id
+ ORDER BY submitted_at DESC, id
+ """
+
+ addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+
+ use reader = command.ExecuteReader()
+ let records = ResizeArray<SubscriptionOrderRecord>()
+
+ while reader.Read() do
+ records.Add(orderRecordFromReader reader)
+
+ records |> Seq.toList
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index 43c438f..5a89b5b 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -117,9 +117,29 @@ type FundSummary =
initialUnitNav: string
isSynthetic: bool
availableCash: string
+ reservedCash: string
status: string
}
+type CreateOrderPayload =
+ {
+ idempotencyKey: string
+ fundCode: string
+ amount: string
+ feeAmount: string
+ }
+
+type RawOrder =
+ {
+ id: string
+ fundCode: string
+ amount: string
+ feeAmount: string
+ reservedTotal: string
+ status: string
+ submittedAt: string
+ }
+
type CreateAttempt =
{
idempotencyKey: string
@@ -127,6 +147,14 @@ type CreateAttempt =
cash: string
}
+type OrderAttempt =
+ {
+ idempotencyKey: string
+ fundCode: string
+ amount: string
+ feeAmount: string
+ }
+
module Api =
[<Import("searchInstruments", "./src/api.js")>]
let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative
@@ -143,6 +171,12 @@ module Api =
[<Import("getFund", "./src/api.js")>]
let getFund (token: string) (fundId: string) : JS.Promise<FundSummary> = jsNative
+ [<Import("createOrder", "./src/api.js")>]
+ let createOrder (token: string) (fundId: string) (payload: CreateOrderPayload) : JS.Promise<RawOrder> = jsNative
+
+ [<Import("getOrders", "./src/api.js")>]
+ let getOrders (token: string) (fundId: string) : JS.Promise<RawOrder array> = jsNative
+
let decodeOptionalText (raw: obj) : string option =
if isNull raw then
None
@@ -191,6 +225,13 @@ type Model =
fundInFlight: bool
createdFund: FundSummary option
lastCreateAttempt: CreateAttempt option
+ orderCode: string
+ orderAmount: string
+ orderFee: string
+ orderSeq: int
+ orderInFlight: bool
+ lastOrderAttempt: OrderAttempt option
+ pendingOrders: RawOrder list
error: string option
}
@@ -213,6 +254,15 @@ type Msg =
| FundReadRequested
| FundReadCompleted of requestId: int * fund: FundSummary
| FundReadFailed of requestId: int * message: string
+ | OrderCodeChanged of string
+ | OrderAmountChanged of string
+ | OrderFeeChanged of string
+ | OrderCreateRequested
+ | OrderCreateCompleted of requestId: int * order: RawOrder
+ | OrderCreateFailed of requestId: int * message: string
+ | OrdersReadRequested
+ | OrdersReadCompleted of requestId: int * orders: RawOrder array
+ | OrdersReadFailed of requestId: int * message: string
let defaultInitialUnitNav = "1.00000000"
@@ -221,11 +271,22 @@ let private cashPattern =
let isValidCashText (text: string) = cashPattern.IsMatch text
+let isPositiveCash (text: string) =
+ 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
| _ -> Guid.NewGuid().ToString("N")
+let resolveOrderKey (lastAttempt: OrderAttempt option) (code: string) (amount: string) (fee: string) =
+ match lastAttempt with
+ | Some attempt when attempt.fundCode = code && attempt.amount = amount && attempt.feeAmount = fee ->
+ attempt.idempotencyKey
+ | _ -> Guid.NewGuid().ToString("N")
+
let init () =
{
token = ""
@@ -243,6 +304,13 @@ let init () =
fundInFlight = false
createdFund = None
lastCreateAttempt = None
+ orderCode = ""
+ orderAmount = ""
+ orderFee = ""
+ orderSeq = 0
+ orderInFlight = false
+ lastOrderAttempt = None
+ pendingOrders = []
error = None
}
@@ -287,6 +355,20 @@ let private readFundCommand token fundId requestId =
(fun fund -> FundReadCompleted(requestId, fund))
(fun error -> FundReadFailed(requestId, errorText error))
+let private createOrderCommand token fundId payload requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.createOrder token fundId payload)
+ ()
+ (fun order -> OrderCreateCompleted(requestId, order))
+ (fun error -> OrderCreateFailed(requestId, errorText error))
+
+let private readOrdersCommand token fundId requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.getOrders token fundId)
+ ()
+ (fun orders -> OrdersReadCompleted(requestId, orders))
+ (fun error -> OrdersReadFailed(requestId, errorText error))
+
let update message model =
match message with
| TokenChanged token ->
@@ -306,6 +388,13 @@ let update message model =
fundInFlight = false
createdFund = None
lastCreateAttempt = None
+ orderCode = ""
+ orderAmount = ""
+ orderFee = ""
+ orderSeq = model.orderSeq + 1
+ orderInFlight = false
+ lastOrderAttempt = None
+ pendingOrders = []
error = None
},
Cmd.none
@@ -456,7 +545,7 @@ let update message model =
lastCreateAttempt = None
error = None
},
- Cmd.none
+ Cmd.ofMsg OrdersReadRequested
else
model, Cmd.none
| FundCreateFailed (requestId, message) ->
@@ -488,6 +577,102 @@ let update message model =
{ model with fundInFlight = false; error = Some message }, Cmd.none
else
model, Cmd.none
+ | OrderCodeChanged value ->
+ { model with orderCode = value; error = None; lastOrderAttempt = None }, Cmd.none
+ | OrderAmountChanged value ->
+ { model with orderAmount = value; error = None; lastOrderAttempt = None }, Cmd.none
+ | OrderFeeChanged value ->
+ { model with orderFee = value; error = None; lastOrderAttempt = None }, Cmd.none
+ | OrderCreateRequested ->
+ let code = model.orderCode.Trim()
+ let amount = model.orderAmount.Trim()
+ let fee = model.orderFee.Trim()
+
+ if String.IsNullOrWhiteSpace model.token then
+ { model with error = Some "请输入 API token" }, Cmd.none
+ elif model.createdFund.IsNone then
+ { model with error = Some "请先创建一个基金" }, Cmd.none
+ elif code = "" then
+ { model with error = Some "请输入基金代码" }, Cmd.none
+ elif not (isValidCashText amount) || not (isPositiveCash amount) then
+ { model with error = Some "认购金额必须是大于零的两位小数金额,例如 100.00" }, Cmd.none
+ elif not (isValidCashText fee) || not (isPositiveCash fee) then
+ { model with error = Some "认购手续费必须是大于零的两位小数金额,例如 1.50" }, Cmd.none
+ elif model.orderInFlight then
+ model, Cmd.none
+ else
+ let requestId = model.orderSeq + 1
+ let idempotencyKey = resolveOrderKey model.lastOrderAttempt code amount fee
+
+ {
+ model with
+ orderCode = code
+ orderAmount = amount
+ orderFee = fee
+ orderSeq = requestId
+ orderInFlight = true
+ lastOrderAttempt =
+ Some
+ {
+ idempotencyKey = idempotencyKey
+ fundCode = code
+ amount = amount
+ feeAmount = fee
+ }
+ error = None
+ },
+ createOrderCommand
+ model.token
+ model.createdFund.Value.id
+ {
+ idempotencyKey = idempotencyKey
+ fundCode = code
+ amount = amount
+ feeAmount = fee
+ }
+ requestId
+ | OrderCreateCompleted (requestId, order) ->
+ if requestId = model.orderSeq then
+ {
+ model with
+ orderInFlight = false
+ lastOrderAttempt = None
+ error = None
+ },
+ Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg OrdersReadRequested ]
+ else
+ model, Cmd.none
+ | OrderCreateFailed (requestId, message) ->
+ if requestId = model.orderSeq then
+ { model with orderInFlight = false; error = Some message }, Cmd.none
+ else
+ model, Cmd.none
+ | OrdersReadRequested ->
+ match model.createdFund with
+ | Some fund when not (String.IsNullOrWhiteSpace model.token) ->
+ let requestId = model.orderSeq + 1
+
+ { model with orderSeq = requestId; error = None },
+ readOrdersCommand model.token fund.id requestId
+ | Some _ ->
+ { model with error = Some "请输入 API token" }, Cmd.none
+ | None ->
+ model, Cmd.none
+ | OrdersReadCompleted (requestId, orders) ->
+ if requestId = model.orderSeq then
+ {
+ model with
+ pendingOrders = orders |> Array.toList
+ error = None
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | OrdersReadFailed (requestId, message) ->
+ if requestId = model.orderSeq then
+ { model with error = Some message }, Cmd.none
+ else
+ model, Cmd.none
let private navText (text: string) =
match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with
@@ -794,6 +979,7 @@ let private fundPanel model dispatch =
fundSummaryRow "币种" fund.currency
fundSummaryRow "初始现金" fund.initialCash
fundSummaryRow "可用现金" fund.availableCash
+ fundSummaryRow "保留现金" fund.reservedCash
fundSummaryRow "初始单位净值" fund.initialUnitNav
fundSummaryRow "份额" "0(新基金尚未买入持仓)"
fundSummaryRow "状态" fund.status
@@ -819,6 +1005,109 @@ let private fundPanel model dispatch =
]
]
+let private orderStatusText (status: string) =
+ if status = "submitted" then "已提交 · 待确认" else status
+
+let private orderRow (order: RawOrder) =
+ Html.div [
+ prop.className "order-row"
+ prop.children [
+ Html.span [ prop.className "order-code"; prop.text order.fundCode ]
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "金额 %s" order.amount) ]
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "手续费 %s" order.feeAmount) ]
+ Html.span [ prop.className "order-cell"; prop.text (sprintf "预留合计 %s" order.reservedTotal) ]
+ Html.span [ prop.className "order-status"; prop.text (orderStatusText order.status) ]
+ Html.span [ prop.className "order-cell"; prop.text order.submittedAt ]
+ ]
+ ]
+
+let private subscribePanel model dispatch =
+ Html.section [
+ prop.className "panel subscribe-panel"
+ prop.children [
+ Html.div [
+ prop.className "section-heading"
+ prop.children [
+ Html.div [
+ Html.p [ prop.className "eyebrow"; prop.text "04 / SUBSCRIBE" ]
+ Html.h2 "认购一笔基金(模拟)"
+ ]
+ Html.span [ prop.className "section-note"; prop.text "Pending · no execution" ]
+ ]
+ ]
+ Html.div [
+ prop.className "fund-form-row"
+ prop.children [
+ Html.label [
+ prop.className "field-label"
+ prop.children [
+ Html.span "基金代码"
+ Html.input [
+ prop.className "text-input order-code-input"
+ prop.placeholder "六位基金代码"
+ prop.value model.orderCode
+ prop.onChange (fun value -> dispatch (OrderCodeChanged value))
+ ]
+ ]
+ ]
+ Html.label [
+ prop.className "field-label"
+ prop.children [
+ Html.span "认购金额(元)"
+ Html.input [
+ prop.className "text-input order-amount-input"
+ prop.placeholder "例如 100.00"
+ prop.value model.orderAmount
+ prop.onChange (fun value -> dispatch (OrderAmountChanged value))
+ ]
+ ]
+ ]
+ Html.label [
+ prop.className "field-label"
+ prop.children [
+ Html.span "认购手续费(元)"
+ Html.input [
+ prop.className "text-input order-fee-input"
+ prop.placeholder "例如 1.50"
+ prop.value model.orderFee
+ prop.onChange (fun value -> dispatch (OrderFeeChanged value))
+ ]
+ ]
+ ]
+ Html.button [
+ prop.className "primary-action order-submit-action"
+ prop.disabled model.orderInFlight
+ prop.onClick (fun _ -> dispatch OrderCreateRequested)
+ prop.text ((if model.orderInFlight then "下单中..." else "认购下单"): string)
+ ]
+ ]
+ ]
+ Html.p [
+ prop.className "hint"
+ prop.text "金额与手续费按两位小数字符串提交;下单即冻结现金,订单保持已提交 · 待确认,不产生任何份额或收益。"
+ ]
+ Html.div [
+ prop.className "pending-orders"
+ prop.children [
+ if List.isEmpty model.pendingOrders then
+ Html.p [ prop.className "hint"; prop.text "暂无待确认订单" ]
+ else
+ yield! (model.pendingOrders |> List.map orderRow)
+ ]
+ ]
+ Html.div [
+ prop.className "panel-actions"
+ prop.children [
+ Html.button [
+ prop.className "secondary-action orders-refresh-action"
+ prop.onClick (fun _ -> dispatch OrdersReadRequested)
+ prop.text "刷新订单"
+ ]
+ ]
+ ]
+ ]
+ ]
+
let view model dispatch =
Html.main [
prop.className "app-shell"
@@ -833,7 +1122,7 @@ let view model dispatch =
Html.span "FUND LAB"
]
]
- Html.span [ prop.className "topbar-status"; prop.text "LEDGER / 3C-1" ]
+ Html.span [ prop.className "topbar-status"; prop.text "LEDGER / 3D-1" ]
]
]
Html.div [
@@ -844,7 +1133,7 @@ let view model dispatch =
prop.children [
Html.p [ prop.className "eyebrow"; prop.text "人民币模拟基金 · 观察台" ]
Html.h1 "把净值曲线,先看清楚。"
- Html.p "从真实基金代码开始,读取、校验并追踪每日单位净值。这里不下单,只保留可复核的数据。"
+ Html.p "从真实基金代码开始,读取、校验并追踪每日单位净值。认购只生成待确认订单并冻结现金,不执行真实交易。"
]
]
Html.div [
@@ -874,7 +1163,8 @@ let view model dispatch =
searchPanel model dispatch
navPanel model dispatch
fundPanel model dispatch
- Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ" ]
+ subscribePanel model dispatch
+ Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ & SUBSCRIBE" ]
]
]
diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js
index 0d3b6fd..7b4f738 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -52,3 +52,19 @@ export function createFund(token, payload) {
export function getFund(token, fundId) {
return requestJson(`/api/funds/${encodeURIComponent(fundId)}`, token);
}
+
+export function createOrder(token, fundId, payload) {
+ const body = `{"fundCode":${JSON.stringify(payload.fundCode)},"amount":${JSON.stringify(payload.amount)},"feeAmount":${JSON.stringify(payload.feeAmount)}}`;
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}/orders`, token, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": payload.idempotencyKey
+ },
+ body
+ });
+}
+
+export function getOrders(token, fundId) {
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}/orders`, token);
+}
diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css
index dcd8833..58d340d 100644
--- a/src/FundLab.Web/src/styles.css
+++ b/src/FundLab.Web/src/styles.css
@@ -479,6 +479,43 @@ h2 {
margin-top: 10px;
}
+.pending-orders {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.order-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+ background: #ffffff;
+ font-size: 12px;
+ color: #1f2937;
+}
+
+.order-code {
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-weight: 700;
+ color: #1e40af;
+}
+
+.order-cell {
+ color: #475569;
+}
+
+.order-status {
+ padding: 2px 10px;
+ border: 1px solid #f1b6b6;
+ border-radius: 999px;
+ color: #b45309;
+ font-size: 11px;
+}
+
.footer-note {
padding: 16px 0 34px;
font-family: "SFMono-Regular", Consolas, monospace;