diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/FundLab.Api/App.fs | 53 | ||||
| -rw-r--r-- | src/FundLab.Api/Authentication.fs | 25 | ||||
| -rw-r--r-- | src/FundLab.Api/FundLab.Api.fsproj | 20 | ||||
| -rw-r--r-- | src/FundLab.Api/Health.fs | 16 | ||||
| -rw-r--r-- | src/FundLab.Api/Program.fs | 15 | ||||
| -rw-r--r-- | src/FundLab.Domain/Domain.fs | 60 | ||||
| -rw-r--r-- | src/FundLab.Domain/FundLab.Domain.fsproj | 13 | ||||
| -rw-r--r-- | src/FundLab.Domain/Ledger.fs | 921 | ||||
| -rw-r--r-- | src/FundLab.Domain/Performance.fs | 51 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 41 | ||||
| -rw-r--r-- | src/FundLab.Web/FundLab.Web.fsproj | 17 | ||||
| -rw-r--r-- | src/FundLab.Web/index.html | 12 | ||||
| -rw-r--r-- | src/FundLab.Web/package-lock.json | 1581 | ||||
| -rw-r--r-- | src/FundLab.Web/package.json | 17 | ||||
| -rw-r--r-- | src/FundLab.Web/src/main.js | 3 | ||||
| -rw-r--r-- | src/FundLab.Web/vite.config.js | 8 | ||||
| -rw-r--r-- | src/FundLab.Worker/FundLab.Worker.fsproj | 18 | ||||
| -rw-r--r-- | src/FundLab.Worker/Program.fs | 12 | ||||
| -rw-r--r-- | src/FundLab.Worker/Worker.fs | 15 |
19 files changed, 2898 insertions, 0 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs new file mode 100644 index 0000000..44628ab --- /dev/null +++ b/src/FundLab.Api/App.fs @@ -0,0 +1,53 @@ +namespace FundLab.Api + +open System +open Giraffe +open Microsoft.AspNetCore.Http + +type EmptyPortfolioResponse = + { + status: string + message: string + } + +module App = + let private unauthorized : HttpHandler = + setStatusCode 401 + >=> setHttpHeader "WWW-Authenticate" "Bearer" + >=> text "Unauthorized" + + let private requireBearer (next: HttpFunc) (ctx: HttpContext) = + let expectedToken = + Environment.GetEnvironmentVariable("FUND_LAB_AUTH_TOKEN") + |> Option.ofObj + |> Option.defaultValue "" + + let authorizationHeader = ctx.Request.Headers.Authorization.ToString() + + match Authentication.authorize expectedToken authorizationHeader with + | AuthDecision.Authorized -> next ctx + | AuthDecision.Unauthorized -> unauthorized next ctx + + let private health : HttpHandler = + json ({ + service = "fund-lab-api" + status = "ok" + } : HealthResponse) + + let private emptyPortfolio : HttpHandler = + json { + status = "empty" + message = "尚未创建基金/尚未选择投资" + } + + let createApplication () : HttpHandler = + choose [ + GET >=> route "/health" >=> health + subRoute "/api" ( + requireBearer + >=> choose [ + GET >=> route "/portfolio/summary" >=> emptyPortfolio + ] + ) + setStatusCode 404 >=> text "Not Found" + ] diff --git a/src/FundLab.Api/Authentication.fs b/src/FundLab.Api/Authentication.fs new file mode 100644 index 0000000..e98d71e --- /dev/null +++ b/src/FundLab.Api/Authentication.fs @@ -0,0 +1,25 @@ +namespace FundLab.Api + +open System +open System.Security.Cryptography +open System.Text + +type AuthDecision = + | Authorized + | Unauthorized + +module Authentication = + let authorize (expectedToken: string) (authorizationHeader: string) : AuthDecision = + if String.IsNullOrWhiteSpace expectedToken + || String.IsNullOrWhiteSpace authorizationHeader + || not (authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) then + AuthDecision.Unauthorized + else + let presentedToken = authorizationHeader.Substring("Bearer ".Length) + let expectedBytes = Encoding.UTF8.GetBytes expectedToken + let presentedBytes = Encoding.UTF8.GetBytes presentedToken + + if CryptographicOperations.FixedTimeEquals(expectedBytes, presentedBytes) then + AuthDecision.Authorized + else + AuthDecision.Unauthorized diff --git a/src/FundLab.Api/FundLab.Api.fsproj b/src/FundLab.Api/FundLab.Api.fsproj new file mode 100644 index 0000000..81952df --- /dev/null +++ b/src/FundLab.Api/FundLab.Api.fsproj @@ -0,0 +1,20 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <GenerateDocumentationFile>true</GenerateDocumentationFile> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <Nullable>enable</Nullable> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Giraffe" Version="6.0.0" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\FundLab.Domain\FundLab.Domain.fsproj" /> + </ItemGroup> + <ItemGroup> + <Compile Include="Authentication.fs" /> + <Compile Include="Health.fs" /> + <Compile Include="App.fs" /> + <Compile Include="Program.fs" /> + </ItemGroup> +</Project> diff --git a/src/FundLab.Api/Health.fs b/src/FundLab.Api/Health.fs new file mode 100644 index 0000000..719446f --- /dev/null +++ b/src/FundLab.Api/Health.fs @@ -0,0 +1,16 @@ +namespace FundLab.Api + +open System.Text.Json + +type HealthResponse = + { + service: string + status: string + } + +module Health = + let payload (serviceName: string) : string = + JsonSerializer.Serialize<HealthResponse> { + service = serviceName + status = "ok" + } diff --git a/src/FundLab.Api/Program.fs b/src/FundLab.Api/Program.fs new file mode 100644 index 0000000..d5cc0b2 --- /dev/null +++ b/src/FundLab.Api/Program.fs @@ -0,0 +1,15 @@ +module FundLab.Api.Program + +open Giraffe +open Microsoft.AspNetCore.Builder +open Microsoft.Extensions.DependencyInjection + +[<EntryPoint>] +let main argv = + let builder = WebApplication.CreateBuilder(argv) + builder.Services.AddGiraffe() |> ignore + + let app = builder.Build() + app.UseGiraffe(App.createApplication()) + app.Run() + 0 diff --git a/src/FundLab.Domain/Domain.fs b/src/FundLab.Domain/Domain.fs new file mode 100644 index 0000000..9c42d5a --- /dev/null +++ b/src/FundLab.Domain/Domain.fs @@ -0,0 +1,60 @@ +namespace FundLab.Domain + +open System + +type FundStatus = + | Empty + | Active + | ZeroUnits + +type UnitNavStatus = + | HasValue of decimal + | ZeroUnits + +type Fund = + { + Id: Guid + Name: string + Currency: string + InitialCash: decimal + InitialUnitNav: decimal + NetAssets: decimal + OutstandingUnits: decimal + Status: FundStatus + } + +module Fund = + let create (name: string) (initialCash: decimal) (initialUnitNav: decimal) : Fund = + { + Id = Guid.NewGuid() + Name = name + Currency = "CNY" + InitialCash = initialCash + InitialUnitNav = initialUnitNav + NetAssets = initialCash + OutstandingUnits = 0m + Status = FundStatus.Empty + } + + let unitNavStatus (fund: Fund) : UnitNavStatus = + if fund.OutstandingUnits = 0m then + UnitNavStatus.ZeroUnits + else + UnitNavStatus.HasValue (fund.NetAssets / fund.OutstandingUnits) + +type OrderStatus = + | Submitted + | CashFrozen + | UnitsFrozen + | PendingNav + | Confirmed + | Settled + | Cancelled + | Failed + +module OrderStatus = + let isConfirmed status = + match status with + | OrderStatus.Confirmed + | OrderStatus.Settled -> true + | _ -> false diff --git a/src/FundLab.Domain/FundLab.Domain.fsproj b/src/FundLab.Domain/FundLab.Domain.fsproj new file mode 100644 index 0000000..9e46c00 --- /dev/null +++ b/src/FundLab.Domain/FundLab.Domain.fsproj @@ -0,0 +1,13 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <GenerateDocumentationFile>true</GenerateDocumentationFile> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <Nullable>enable</Nullable> + </PropertyGroup> + <ItemGroup> + <Compile Include="Domain.fs" /> + <Compile Include="Ledger.fs" /> + <Compile Include="Performance.fs" /> + </ItemGroup> +</Project> diff --git a/src/FundLab.Domain/Ledger.fs b/src/FundLab.Domain/Ledger.fs new file mode 100644 index 0000000..2937ca6 --- /dev/null +++ b/src/FundLab.Domain/Ledger.fs @@ -0,0 +1,921 @@ +namespace FundLab.Domain + +open System + +type ResidualOwner = + | FundReserve + | ExternalParty + +type ResidualState = + | Clear + | RequiresAllocation of decimal + | Allocated of decimal * ResidualOwner + +type Holding = + { + InstrumentId: Guid + AvailableUnits: decimal + FrozenUnits: decimal + SettledValue: decimal + } + +type BalanceSheet = + { + AvailableCash: decimal + FrozenCash: decimal + SettledHoldingsValue: decimal + RedemptionReceivable: decimal + OtherInTransitAssets: decimal + Assets: decimal + RedemptionPayable: decimal + SubscriptionRefundPayable: decimal + FeePayable: decimal + OtherInTransitLiabilities: decimal + Liabilities: decimal + NetAssets: decimal + } + +type CashFlowKind = + | ExternalDeposit + | ExternalRedemption + | ExternalRedemptionPayment + | UnderlyingPurchase + | UnderlyingRedemption + | UnderlyingRedemptionReceipt + +type CashFlow = + { + TransactionId: Guid + FundId: Guid + Kind: CashFlowKind + EffectiveAt: DateTimeOffset + GrossCash: decimal + CreditedCash: decimal + ConvertedUnits: decimal + UnitNav: decimal option + RoundingResidual: decimal + ResidualOwner: ResidualOwner option + IsSynthetic: bool + } + +type ExternalRedemptionStatus = + | PendingPayment + | Paid + +type ExternalRedemptionRecord = + { + TransactionId: Guid + FundId: Guid + IdempotencyKey: string + Units: decimal + UnitNav: decimal + PayableCash: decimal + Status: ExternalRedemptionStatus + ConfirmedAt: DateTimeOffset + PaidAt: DateTimeOffset option + IsSynthetic: bool + } + +type ResidualAllocationRecord = + { + TransactionId: Guid + FundId: Guid + Amount: decimal + Owner: ResidualOwner + EffectiveAt: DateTimeOffset + IsSynthetic: bool + } + +type LedgerEvent = + { + TransactionId: Guid + FundId: Guid + EventType: string + EffectiveAt: DateTimeOffset + CashAmount: decimal + Units: decimal + RoundingResidual: decimal + ResidualOwner: ResidualOwner option + IsSynthetic: bool + } + +type LedgerOrderKind = + | Purchase + | Redemption + +type LedgerOrder = + { + Id: Guid + FundId: Guid + InstrumentId: Guid + Kind: LedgerOrderKind + IdempotencyKey: string + Status: OrderStatus + RequestedCash: decimal + RequestedUnits: decimal + ConfirmedCash: decimal + ConfirmedUnits: decimal + RoundingResidual: decimal + ResidualOwner: ResidualOwner option + IsSynthetic: bool + SubmittedAt: DateTimeOffset + ConfirmedAt: DateTimeOffset option + } + +type LedgerFund = + { + Id: Guid + Name: string + Currency: string + InitialCash: decimal + InitialUnitNav: decimal + IsSynthetic: bool + AvailableCash: decimal + FrozenCash: decimal + Holdings: Map<Guid, Holding> + OutstandingUnits: decimal + RedemptionPayable: decimal + RedemptionReceivable: decimal + OtherInTransitAssets: decimal + SubscriptionRefundPayable: decimal + FeePayable: decimal + OtherInTransitLiabilities: decimal + Status: FundStatus + Orders: Map<Guid, LedgerOrder> + ExternalRedemptions: Map<Guid, ExternalRedemptionRecord> + CashFlows: CashFlow list + Events: LedgerEvent list + ResidualAllocations: ResidualAllocationRecord list + ResidualState: ResidualState + } + with + member this.SettledHoldingsValue = + this.Holdings + |> Map.toSeq + |> Seq.sumBy (fun (_, holding) -> holding.SettledValue) + + member this.balanceSheet = + let settledHoldingsValue = this.SettledHoldingsValue + let assets = + this.AvailableCash + + this.FrozenCash + + settledHoldingsValue + + this.RedemptionReceivable + + this.OtherInTransitAssets + + let liabilities = + this.RedemptionPayable + + this.SubscriptionRefundPayable + + this.FeePayable + + this.OtherInTransitLiabilities + + { + AvailableCash = this.AvailableCash + FrozenCash = this.FrozenCash + SettledHoldingsValue = settledHoldingsValue + RedemptionReceivable = this.RedemptionReceivable + OtherInTransitAssets = this.OtherInTransitAssets + Assets = assets + RedemptionPayable = this.RedemptionPayable + SubscriptionRefundPayable = this.SubscriptionRefundPayable + FeePayable = this.FeePayable + OtherInTransitLiabilities = this.OtherInTransitLiabilities + Liabilities = liabilities + NetAssets = assets - liabilities + } + + member this.BalanceSheet = this.balanceSheet + member this.NetAssets = this.balanceSheet.NetAssets + member this.UnitNavStatus = + if this.OutstandingUnits = 0m then + UnitNavStatus.ZeroUnits + else + UnitNavStatus.HasValue (this.NetAssets / this.OutstandingUnits) + +type LedgerState = + { + Funds: Map<Guid, LedgerFund> + Idempotencies: Map<Guid * string, string> + } + +type LedgerError = + | FundNotFound of Guid + | FundAlreadyExists of Guid + | OrderNotFound of Guid + | ExternalRedemptionNotFound of Guid + | IdempotencyConflict of string + | InvalidIdentifier of string + | InvalidAmount of string + | InvalidPrecision of string + | InvalidState of string + | TransactionAlreadyExists of Guid + | InsufficientCash of decimal + | InsufficientUnits of decimal + | ResidualOwnershipRequired of decimal + +module Ledger = + type private ResultBuilder() = + member _.Bind(result, binder) = Result.bind binder result + member _.Return(value) = Ok value + member _.ReturnFrom(result) = result + member _.Zero() = Ok() + member _.Combine(first, second) = Result.bind (fun () -> second) first + member _.Delay(generator) = generator() + + let private result = ResultBuilder() + + [<Literal>] + let CashScale = 2 + + [<Literal>] + let UnitScale = 8 + + let empty = + { + Funds = Map.empty + Idempotencies = Map.empty + } + + let private decimalScale value = + let bits = Decimal.GetBits(value) + int ((bits[3] >>> 16) &&& 0x7f) + + let private roundDown scale value = + Decimal.Round(value, scale, MidpointRounding.ToZero) + + let private validateNonNegative label scale value = + if value < 0m then + Error(InvalidAmount label) + elif decimalScale value > scale then + Error(InvalidPrecision label) + else + Ok() + + let private validatePositive label scale value = + validateNonNegative label scale value + |> Result.bind (fun () -> + if value = 0m then + Error(InvalidAmount label) + else + Ok()) + + let private fundStatus outstandingUnits = + if outstandingUnits = 0m then + FundStatus.ZeroUnits + else + FundStatus.Active + + let getFund fundId state = + match state.Funds |> Map.tryFind fundId with + | Some fund -> Ok fund + | None -> Error(FundNotFound fundId) + + let private updateFund fund state = + { state with Funds = state.Funds |> Map.add fund.Id fund } + + let private validateIdentifier label value = + if value = Guid.Empty then + Error(InvalidIdentifier label) + else + Ok() + + let private transactionExists transactionId fund = + fund.Events |> List.exists (fun event -> event.TransactionId = transactionId) + || fund.CashFlows |> List.exists (fun cashFlow -> cashFlow.TransactionId = transactionId) + || fund.ExternalRedemptions |> Map.containsKey transactionId + || fund.ResidualAllocations |> List.exists (fun allocation -> allocation.TransactionId = transactionId) + + let private ensureTransactionIsNew transactionId fund = + if transactionExists transactionId fund then + Error(TransactionAlreadyExists transactionId) + else + Ok() + + let private addResidual residual residualState = + if residual = 0m then + residualState + else + match residualState with + | Clear -> RequiresAllocation residual + | RequiresAllocation existing -> RequiresAllocation(existing + residual) + | Allocated _ -> RequiresAllocation residual + + let private runIdempotent fundId key fingerprint state action = + if fundId = Guid.Empty then + Error(InvalidIdentifier "fund id") + elif String.IsNullOrWhiteSpace key then + Error(InvalidState "idempotency key cannot be empty") + else + match state.Idempotencies |> Map.tryFind (fundId, key) with + | Some previous when previous = fingerprint -> Ok state + | Some _ -> Error(IdempotencyConflict key) + | None -> + action state + |> Result.map (fun next -> + { + next with + Idempotencies = next.Idempotencies |> Map.add (fundId, key) fingerprint + }) + + let initializeFund fundId name initialCash initialUnitNav isSynthetic state = + result { + do! validateIdentifier "fund id" fundId + do! validateNonNegative "initial cash" CashScale initialCash + do! validatePositive "initial unit NAV" UnitScale initialUnitNav + + if String.IsNullOrWhiteSpace name then + return! Error(InvalidState "fund name cannot be empty") + + if state.Funds |> Map.containsKey fundId then + return! Error(FundAlreadyExists fundId) + + let fund = + { + Id = fundId + Name = name + Currency = "CNY" + InitialCash = initialCash + InitialUnitNav = initialUnitNav + IsSynthetic = isSynthetic + AvailableCash = initialCash + FrozenCash = 0m + Holdings = Map.empty + OutstandingUnits = 0m + RedemptionPayable = 0m + RedemptionReceivable = 0m + OtherInTransitAssets = 0m + SubscriptionRefundPayable = 0m + FeePayable = 0m + OtherInTransitLiabilities = 0m + Status = FundStatus.Empty + Orders = Map.empty + ExternalRedemptions = Map.empty + CashFlows = [] + Events = [] + ResidualAllocations = [] + ResidualState = Clear + } + + return updateFund fund state + } + + let private canIssueUnits fund = + match fund.ResidualState with + | RequiresAllocation residual when fund.OutstandingUnits = 0m -> + Error(ResidualOwnershipRequired residual) + | _ -> Ok() + + let private addEvent transactionId eventType at cashAmount units residual owner isSynthetic fund = + { + fund with + Events = + { + TransactionId = transactionId + FundId = fund.Id + EventType = eventType + EffectiveAt = at + CashAmount = cashAmount + Units = units + RoundingResidual = residual + ResidualOwner = owner + IsSynthetic = isSynthetic + } + :: fund.Events + } + + let confirmExternalDeposit fundId transactionId idempotencyKey grossCash unitNav effectiveAt isSynthetic state = + let fingerprint = sprintf "external-deposit|%O|%M|%M|%O|%b" transactionId grossCash unitNav effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "transaction id" transactionId + do! validatePositive "external deposit cash" CashScale grossCash + do! validatePositive "external deposit unit NAV" UnitScale unitNav + + let! fund = getFund fundId current + do! ensureTransactionIsNew transactionId fund + do! canIssueUnits fund + + let convertedUnits = roundDown UnitScale (grossCash / unitNav) + if convertedUnits = 0m then + return! Error(InvalidAmount "external deposit does not create units") + + let creditedCash = roundDown CashScale (convertedUnits * unitNav) + let residual = grossCash - creditedCash + let updatedFund = + { + fund with + AvailableCash = fund.AvailableCash + creditedCash + OutstandingUnits = fund.OutstandingUnits + convertedUnits + Status = FundStatus.Active + ResidualState = addResidual residual fund.ResidualState + CashFlows = + { + TransactionId = transactionId + FundId = fundId + Kind = CashFlowKind.ExternalDeposit + EffectiveAt = effectiveAt + GrossCash = grossCash + CreditedCash = creditedCash + ConvertedUnits = convertedUnits + UnitNav = Some unitNav + RoundingResidual = residual + ResidualOwner = None + IsSynthetic = isSynthetic + } + :: fund.CashFlows + } + |> addEvent transactionId "external_deposit_confirmed" effectiveAt creditedCash convertedUnits residual None isSynthetic + + return updateFund updatedFund current + }) + + let private orderAlreadyExists orderId fund = + if fund.Orders |> Map.containsKey orderId then + Error(InvalidState "order already exists") + else + Ok() + + let private emptyHolding instrumentId = + { + InstrumentId = instrumentId + AvailableUnits = 0m + FrozenUnits = 0m + SettledValue = 0m + } + + let private addSettledHolding instrumentId units value holdings = + let holding = holdings |> Map.tryFind instrumentId |> Option.defaultValue (emptyHolding instrumentId) + + holdings + |> Map.add instrumentId { + holding with + AvailableUnits = holding.AvailableUnits + units + SettledValue = holding.SettledValue + value + } + + let private addCashFlow cashFlow fund = + { fund with CashFlows = cashFlow :: fund.CashFlows } + + let private missingOrder orderId = + Error(OrderNotFound orderId) + + let freezeUnderlyingPurchase fundId orderId idempotencyKey instrumentId requestedCash effectiveAt isSynthetic state = + let fingerprint = sprintf "underlying-purchase-freeze|%O|%O|%M|%O|%b" orderId instrumentId requestedCash effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "order id" orderId + do! validateIdentifier "instrument id" instrumentId + do! validatePositive "underlying purchase cash" CashScale requestedCash + let! fund = getFund fundId current + do! orderAlreadyExists orderId fund + do! ensureTransactionIsNew orderId fund + + if fund.AvailableCash < requestedCash then + return! Error(InsufficientCash requestedCash) + + let order = + { + Id = orderId + FundId = fundId + InstrumentId = instrumentId + Kind = LedgerOrderKind.Purchase + IdempotencyKey = idempotencyKey + Status = OrderStatus.CashFrozen + RequestedCash = requestedCash + RequestedUnits = 0m + ConfirmedCash = 0m + ConfirmedUnits = 0m + RoundingResidual = 0m + ResidualOwner = None + IsSynthetic = isSynthetic + SubmittedAt = effectiveAt + ConfirmedAt = None + } + + let updatedFund = + { + fund with + AvailableCash = fund.AvailableCash - requestedCash + FrozenCash = fund.FrozenCash + requestedCash + Orders = fund.Orders |> Map.add orderId order + } + |> addEvent orderId "underlying_purchase_frozen" effectiveAt requestedCash 0m 0m None isSynthetic + + return updateFund updatedFund current + }) + + let confirmUnderlyingPurchase fundId orderId idempotencyKey confirmedUnits unitNav effectiveAt isSynthetic state = + let fingerprint = sprintf "underlying-purchase-confirm|%O|%M|%M|%O|%b" orderId confirmedUnits unitNav effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "order id" orderId + do! validatePositive "underlying purchase units" UnitScale confirmedUnits + do! validatePositive "underlying purchase unit NAV" UnitScale unitNav + let! fund = getFund fundId current + let! order = fund.Orders |> Map.tryFind orderId |> Option.map Ok |> Option.defaultValue (missingOrder orderId) + + if order.Kind <> LedgerOrderKind.Purchase || order.Status <> OrderStatus.CashFrozen then + return! Error(InvalidState "underlying purchase is not cash frozen") + + let grossCash = confirmedUnits * unitNav + let confirmedCash = roundDown CashScale grossCash + if confirmedCash <= 0m then + return! Error(InvalidAmount "underlying purchase cash rounds to zero") + + if confirmedCash > order.RequestedCash then + return! Error(InvalidState "underlying purchase exceeds frozen cash") + + let residual = grossCash - confirmedCash + let updatedOrder = + { + order with + Status = OrderStatus.Confirmed + ConfirmedCash = confirmedCash + ConfirmedUnits = confirmedUnits + RoundingResidual = residual + ResidualOwner = None + ConfirmedAt = Some effectiveAt + } + + let updatedFund = + { + fund with + AvailableCash = fund.AvailableCash + order.RequestedCash - confirmedCash + FrozenCash = fund.FrozenCash - order.RequestedCash + Holdings = fund.Holdings |> addSettledHolding order.InstrumentId confirmedUnits confirmedCash + Orders = fund.Orders |> Map.add orderId updatedOrder + ResidualState = addResidual residual fund.ResidualState + } + |> addCashFlow { + TransactionId = orderId + FundId = fundId + Kind = CashFlowKind.UnderlyingPurchase + EffectiveAt = effectiveAt + GrossCash = grossCash + CreditedCash = confirmedCash + ConvertedUnits = confirmedUnits + UnitNav = Some unitNav + RoundingResidual = residual + ResidualOwner = None + IsSynthetic = isSynthetic + } + |> addEvent orderId "underlying_purchase_confirmed" effectiveAt confirmedCash confirmedUnits residual None isSynthetic + + return updateFund updatedFund current + }) + + let cancelUnderlyingPurchase fundId orderId idempotencyKey effectiveAt isSynthetic state = + let fingerprint = sprintf "underlying-purchase-cancel|%O|%O|%b" orderId effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "order id" orderId + let! fund = getFund fundId current + let! order = fund.Orders |> Map.tryFind orderId |> Option.map Ok |> Option.defaultValue (missingOrder orderId) + + if order.Kind <> LedgerOrderKind.Purchase || order.Status <> OrderStatus.CashFrozen then + return! Error(InvalidState "underlying purchase is not cancellable") + + let updatedOrder = { order with Status = OrderStatus.Cancelled } + let updatedFund = + { + fund with + AvailableCash = fund.AvailableCash + order.RequestedCash + FrozenCash = fund.FrozenCash - order.RequestedCash + Orders = fund.Orders |> Map.add orderId updatedOrder + } + |> addEvent orderId "underlying_purchase_cancelled" effectiveAt order.RequestedCash 0m 0m None isSynthetic + + return updateFund updatedFund current + }) + + let freezeUnderlyingRedemption fundId orderId idempotencyKey instrumentId requestedUnits effectiveAt isSynthetic state = + let fingerprint = sprintf "underlying-redemption-freeze|%O|%O|%M|%O|%b" orderId instrumentId requestedUnits effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "order id" orderId + do! validateIdentifier "instrument id" instrumentId + do! validatePositive "underlying redemption units" UnitScale requestedUnits + let! fund = getFund fundId current + do! orderAlreadyExists orderId fund + do! ensureTransactionIsNew orderId fund + let! holding = fund.Holdings |> Map.tryFind instrumentId |> Option.map Ok |> Option.defaultValue (Error(InvalidState "holding not found")) + + if holding.AvailableUnits < requestedUnits then + return! Error(InsufficientUnits requestedUnits) + + let updatedHolding = + { + holding with + AvailableUnits = holding.AvailableUnits - requestedUnits + FrozenUnits = holding.FrozenUnits + requestedUnits + } + + let order = + { + Id = orderId + FundId = fundId + InstrumentId = instrumentId + Kind = LedgerOrderKind.Redemption + IdempotencyKey = idempotencyKey + Status = OrderStatus.UnitsFrozen + RequestedCash = 0m + RequestedUnits = requestedUnits + ConfirmedCash = 0m + ConfirmedUnits = 0m + RoundingResidual = 0m + ResidualOwner = None + IsSynthetic = isSynthetic + SubmittedAt = effectiveAt + ConfirmedAt = None + } + + let updatedFund = + { + fund with + Holdings = fund.Holdings |> Map.add instrumentId updatedHolding + Orders = fund.Orders |> Map.add orderId order + } + |> addEvent orderId "underlying_redemption_frozen" effectiveAt 0m requestedUnits 0m None isSynthetic + + return updateFund updatedFund current + }) + + let private releaseFrozenHolding holding units = + let totalUnits = holding.AvailableUnits + holding.FrozenUnits + let releasedValue = + if units = totalUnits then + holding.SettledValue + else + roundDown CashScale (holding.SettledValue * units / totalUnits) + + let remaining = + { + holding with + FrozenUnits = holding.FrozenUnits - units + SettledValue = holding.SettledValue - releasedValue + } + + remaining, releasedValue + + let confirmUnderlyingRedemption fundId orderId idempotencyKey unitNav effectiveAt isSynthetic state = + let fingerprint = sprintf "underlying-redemption-confirm|%O|%M|%O|%b" orderId unitNav effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "order id" orderId + do! validatePositive "underlying redemption unit NAV" UnitScale unitNav + let! fund = getFund fundId current + let! order = fund.Orders |> Map.tryFind orderId |> Option.map Ok |> Option.defaultValue (missingOrder orderId) + + if order.Kind <> LedgerOrderKind.Redemption || order.Status <> OrderStatus.UnitsFrozen then + return! Error(InvalidState "underlying redemption is not units frozen") + + let! holding = fund.Holdings |> Map.tryFind order.InstrumentId |> Option.map Ok |> Option.defaultValue (Error(InvalidState "holding not found")) + if holding.FrozenUnits < order.RequestedUnits then + return! Error(InsufficientUnits order.RequestedUnits) + + let grossCash = order.RequestedUnits * unitNav + let receivable = roundDown CashScale grossCash + if receivable <= 0m then + return! Error(InvalidAmount "underlying redemption cash rounds to zero") + + let residual = grossCash - receivable + let updatedHolding, _ = releaseFrozenHolding holding order.RequestedUnits + let updatedHoldings = + if updatedHolding.AvailableUnits = 0m && updatedHolding.FrozenUnits = 0m then + fund.Holdings |> Map.remove order.InstrumentId + else + fund.Holdings |> Map.add order.InstrumentId updatedHolding + + let updatedOrder = + { + order with + Status = OrderStatus.Confirmed + ConfirmedCash = receivable + ConfirmedUnits = order.RequestedUnits + RoundingResidual = residual + ResidualOwner = None + ConfirmedAt = Some effectiveAt + } + + let updatedFund = + { + fund with + Holdings = updatedHoldings + RedemptionReceivable = fund.RedemptionReceivable + receivable + Orders = fund.Orders |> Map.add orderId updatedOrder + ResidualState = addResidual residual fund.ResidualState + } + |> addCashFlow { + TransactionId = orderId + FundId = fundId + Kind = CashFlowKind.UnderlyingRedemption + EffectiveAt = effectiveAt + GrossCash = grossCash + CreditedCash = receivable + ConvertedUnits = order.RequestedUnits + UnitNav = Some unitNav + RoundingResidual = residual + ResidualOwner = None + IsSynthetic = isSynthetic + } + |> addEvent orderId "underlying_redemption_confirmed" effectiveAt receivable order.RequestedUnits residual None isSynthetic + + return updateFund updatedFund current + }) + + let receiveUnderlyingRedemption fundId orderId idempotencyKey effectiveAt isSynthetic state = + let fingerprint = sprintf "underlying-redemption-receive|%O|%O|%b" orderId effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "order id" orderId + let! fund = getFund fundId current + let! order = fund.Orders |> Map.tryFind orderId |> Option.map Ok |> Option.defaultValue (missingOrder orderId) + + if order.Kind <> LedgerOrderKind.Redemption || order.Status <> OrderStatus.Confirmed then + return! Error(InvalidState "underlying redemption is not confirmed") + + if fund.RedemptionReceivable < order.ConfirmedCash then + return! Error(InvalidState "underlying redemption receivable is unavailable") + + let updatedOrder = { order with Status = OrderStatus.Settled } + let updatedFund = + { + fund with + AvailableCash = fund.AvailableCash + order.ConfirmedCash + RedemptionReceivable = fund.RedemptionReceivable - order.ConfirmedCash + Orders = fund.Orders |> Map.add orderId updatedOrder + } + |> addCashFlow { + TransactionId = orderId + FundId = fundId + Kind = CashFlowKind.UnderlyingRedemptionReceipt + EffectiveAt = effectiveAt + GrossCash = order.ConfirmedCash + CreditedCash = order.ConfirmedCash + ConvertedUnits = 0m + UnitNav = None + RoundingResidual = 0m + ResidualOwner = None + IsSynthetic = isSynthetic + } + |> addEvent orderId "underlying_redemption_received" effectiveAt order.ConfirmedCash 0m 0m None isSynthetic + + return updateFund updatedFund current + }) + + let allocateResidual fundId transactionId idempotencyKey owner effectiveAt isSynthetic state = + let fingerprint = sprintf "residual-allocation|%O|%A|%O|%b" transactionId owner effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "transaction id" transactionId + let! fund = getFund fundId current + + match fund.ResidualState with + | RequiresAllocation residual -> + if fund.OutstandingUnits <> 0m then + return! Error(InvalidState "residual allocation requires zero outstanding units") + + do! ensureTransactionIsNew transactionId fund + let allocation = + { + TransactionId = transactionId + FundId = fundId + Amount = residual + Owner = owner + EffectiveAt = effectiveAt + IsSynthetic = isSynthetic + } + + let updatedFund = + { + fund with + ResidualState = Allocated(residual, owner) + ResidualAllocations = allocation :: fund.ResidualAllocations + } + |> addEvent transactionId "residual_allocated" effectiveAt 0m 0m residual (Some owner) isSynthetic + + return updateFund updatedFund current + | _ -> + return! Error(InvalidState "no residual requires allocation") + }) + + let confirmExternalRedemption fundId transactionId idempotencyKey units unitNav effectiveAt isSynthetic state = + let fingerprint = sprintf "external-redemption|%O|%M|%M|%O|%b" transactionId units unitNav effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "transaction id" transactionId + do! validatePositive "external redemption units" UnitScale units + do! validatePositive "external redemption unit NAV" UnitScale unitNav + + let! fund = getFund fundId current + do! ensureTransactionIsNew transactionId fund + if fund.OutstandingUnits < units then + return! Error(InsufficientUnits units) + + let grossCash = units * unitNav + let payableCash = roundDown CashScale grossCash + if payableCash = 0m then + return! Error(InvalidAmount "external redemption cash rounds to zero") + + let residual = grossCash - payableCash + let redemption = + { + TransactionId = transactionId + FundId = fundId + IdempotencyKey = idempotencyKey + Units = units + UnitNav = unitNav + PayableCash = payableCash + Status = ExternalRedemptionStatus.PendingPayment + ConfirmedAt = effectiveAt + PaidAt = None + IsSynthetic = isSynthetic + } + + let updatedFund = + { + fund with + OutstandingUnits = fund.OutstandingUnits - units + RedemptionPayable = fund.RedemptionPayable + payableCash + Status = fundStatus (fund.OutstandingUnits - units) + ResidualState = addResidual residual fund.ResidualState + ExternalRedemptions = fund.ExternalRedemptions |> Map.add transactionId redemption + CashFlows = + { + TransactionId = transactionId + FundId = fundId + Kind = CashFlowKind.ExternalRedemption + EffectiveAt = effectiveAt + GrossCash = grossCash + CreditedCash = payableCash + ConvertedUnits = units + UnitNav = Some unitNav + RoundingResidual = residual + ResidualOwner = None + IsSynthetic = isSynthetic + } + :: fund.CashFlows + } + |> addEvent transactionId "external_redemption_confirmed" effectiveAt payableCash units residual None isSynthetic + + return updateFund updatedFund current + }) + + let payExternalRedemption fundId transactionId idempotencyKey effectiveAt isSynthetic state = + let fingerprint = sprintf "external-redemption-payment|%O|%O|%b" transactionId effectiveAt isSynthetic + + runIdempotent fundId idempotencyKey fingerprint state (fun current -> + result { + do! validateIdentifier "transaction id" transactionId + let! fund = getFund fundId current + let! redemption = + fund.ExternalRedemptions + |> Map.tryFind transactionId + |> Option.map Ok + |> Option.defaultValue (Error(ExternalRedemptionNotFound transactionId)) + + if redemption.Status <> ExternalRedemptionStatus.PendingPayment then + return! Error(InvalidState "external redemption is already paid") + + let payable = redemption.PayableCash + + if fund.AvailableCash < payable then + return! Error(InsufficientCash payable) + + let updatedRedemption = { redemption with Status = ExternalRedemptionStatus.Paid; PaidAt = Some effectiveAt } + + let updatedFund = + { + fund with + AvailableCash = fund.AvailableCash - payable + RedemptionPayable = fund.RedemptionPayable - payable + ExternalRedemptions = fund.ExternalRedemptions |> Map.add transactionId updatedRedemption + CashFlows = + { + TransactionId = transactionId + FundId = fundId + Kind = CashFlowKind.ExternalRedemptionPayment + EffectiveAt = effectiveAt + GrossCash = payable + CreditedCash = payable + ConvertedUnits = 0m + UnitNav = None + RoundingResidual = 0m + ResidualOwner = None + IsSynthetic = isSynthetic + } + :: fund.CashFlows + } + |> addEvent transactionId "external_redemption_paid" effectiveAt payable 0m 0m None isSynthetic + + return updateFund updatedFund current + }) diff --git a/src/FundLab.Domain/Performance.fs b/src/FundLab.Domain/Performance.fs new file mode 100644 index 0000000..b1c51db --- /dev/null +++ b/src/FundLab.Domain/Performance.fs @@ -0,0 +1,51 @@ +namespace FundLab.Domain + +open System + +type PerformanceObservation = + { + At: DateTimeOffset + NetAssets: decimal + ExternalCashFlow: decimal + } + +type PerformanceError = + | InvalidObservation of string + +module Performance = + let timeWeightedReturn observations = + let validate observation previousAt = + if observation.NetAssets < 0m then + Error(InvalidObservation "net assets cannot be negative") + elif previousAt |> Option.exists (fun at -> observation.At <= at) then + Error(InvalidObservation "observations must be strictly ordered") + else + Ok() + + match observations with + | [] -> Ok 0m + | first :: rest -> + validate first None + |> Result.bind (fun () -> + if first.NetAssets <= 0m then + Error(InvalidObservation "first net assets must be positive") + else + rest + |> List.fold + (fun result observation -> + result + |> Result.bind (fun (previous, linkedReturn) -> + validate observation (Some previous.At) + |> Result.bind (fun () -> + let endingAssetsBeforeFlow = observation.NetAssets - observation.ExternalCashFlow + + if endingAssetsBeforeFlow < 0m then + Error(InvalidObservation "external cash flow exceeds ending net assets") + elif previous.NetAssets <= 0m then + Error(InvalidObservation "period start net assets must be positive") + else + let periodReturn = endingAssetsBeforeFlow / previous.NetAssets + Ok(observation, linkedReturn * periodReturn))) + ) + (Ok(first, 1m)) + |> Result.map (fun (_, linkedReturn) -> linkedReturn - 1m)) diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs new file mode 100644 index 0000000..630a8e9 --- /dev/null +++ b/src/FundLab.Web/App.fs @@ -0,0 +1,41 @@ +module FundLab.Web + +open Elmish +open Elmish.React +open Feliz + +type Model = + { + hasFund: bool + } + +type Msg = + | CreateFundRequested + +let init () = + { hasFund = false } + +let update message model = + match message with + | CreateFundRequested -> model + +let view model dispatch = + Html.main [ + prop.className "empty-state" + prop.children [ + Html.p [ prop.className "eyebrow"; prop.text "FUND LAB" ] + Html.h1 "尚未创建基金" + Html.p "先创建一支人民币模拟基金,再选择基金代码和持仓金额。" + Html.p (if model.hasFund then "已创建基金" else "尚未选择投资") + Html.button [ + prop.className "primary-action" + prop.text "创建基金" + prop.onClick (fun _ -> dispatch CreateFundRequested) + ] + ] + ] + +let start () = + Program.mkSimple init update view + |> Program.withReactSynchronous "fund-lab-root" + |> Program.run diff --git a/src/FundLab.Web/FundLab.Web.fsproj b/src/FundLab.Web/FundLab.Web.fsproj new file mode 100644 index 0000000..529740f --- /dev/null +++ b/src/FundLab.Web/FundLab.Web.fsproj @@ -0,0 +1,17 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <OutputType>Library</OutputType> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <Nullable>enable</Nullable> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Elmish" Version="4.1.0" /> + <PackageReference Include="Fable.Core" Version="4.4.0" /> + <PackageReference Include="Fable.Elmish.React" Version="4.0.0" /> + <PackageReference Include="Feliz" Version="2.9.0" /> + </ItemGroup> + <ItemGroup> + <Compile Include="App.fs" /> + </ItemGroup> +</Project> diff --git a/src/FundLab.Web/index.html b/src/FundLab.Web/index.html new file mode 100644 index 0000000..7b27019 --- /dev/null +++ b/src/FundLab.Web/index.html @@ -0,0 +1,12 @@ +<!doctype html> +<html lang="zh-CN"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Fund Lab</title> + </head> + <body> + <div id="fund-lab-root"></div> + <script type="module" src="/src/main.js"></script> + </body> +</html> diff --git a/src/FundLab.Web/package-lock.json b/src/FundLab.Web/package-lock.json new file mode 100644 index 0000000..56782fe --- /dev/null +++ b/src/FundLab.Web/package-lock.json @@ -0,0 +1,1581 @@ +{ + "name": "fund-lab-web", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fund-lab-web", + "dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "4.3.4", + "vite": "5.4.14" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.4", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.25", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/browserslist/node_modules/node-releases": { + "version": "2.0.56", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.56.tgz", + "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.433", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.4", + "@rollup/rollup-android-arm64": "4.63.4", + "@rollup/rollup-darwin-arm64": "4.63.4", + "@rollup/rollup-darwin-x64": "4.63.4", + "@rollup/rollup-freebsd-arm64": "4.63.4", + "@rollup/rollup-freebsd-x64": "4.63.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", + "@rollup/rollup-linux-arm-musleabihf": "4.63.4", + "@rollup/rollup-linux-arm64-gnu": "4.63.4", + "@rollup/rollup-linux-arm64-musl": "4.63.4", + "@rollup/rollup-linux-loong64-gnu": "4.63.4", + "@rollup/rollup-linux-loong64-musl": "4.63.4", + "@rollup/rollup-linux-ppc64-gnu": "4.63.4", + "@rollup/rollup-linux-ppc64-musl": "4.63.4", + "@rollup/rollup-linux-riscv64-gnu": "4.63.4", + "@rollup/rollup-linux-riscv64-musl": "4.63.4", + "@rollup/rollup-linux-s390x-gnu": "4.63.4", + "@rollup/rollup-linux-x64-gnu": "4.63.4", + "@rollup/rollup-linux-x64-musl": "4.63.4", + "@rollup/rollup-openbsd-x64": "4.63.4", + "@rollup/rollup-openharmony-arm64": "4.63.4", + "@rollup/rollup-win32-arm64-msvc": "4.63.4", + "@rollup/rollup-win32-ia32-msvc": "4.63.4", + "@rollup/rollup-win32-x64-gnu": "4.63.4", + "@rollup/rollup-win32-x64-msvc": "4.63.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz", + "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz", + "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz", + "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz", + "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz", + "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz", + "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz", + "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz", + "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz", + "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz", + "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz", + "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz", + "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz", + "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz", + "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz", + "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz", + "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz", + "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz", + "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz", + "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz", + "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz", + "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz", + "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz", + "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz", + "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/rollup/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.14", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + } + } +} diff --git a/src/FundLab.Web/package.json b/src/FundLab.Web/package.json new file mode 100644 index 0000000..98db215 --- /dev/null +++ b/src/FundLab.Web/package.json @@ -0,0 +1,17 @@ +{ + "name": "fund-lab-web", + "private": true, + "type": "module", + "scripts": { + "build": "dotnet fable FundLab.Web.fsproj --outDir dist && vite build", + "dev": "dotnet fable FundLab.Web.fsproj --outDir dist --run vite" + }, + "dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "4.3.4", + "vite": "5.4.14" + } +} diff --git a/src/FundLab.Web/src/main.js b/src/FundLab.Web/src/main.js new file mode 100644 index 0000000..57c83db --- /dev/null +++ b/src/FundLab.Web/src/main.js @@ -0,0 +1,3 @@ +import { start } from "../dist/App.js"; + +start(); diff --git a/src/FundLab.Web/vite.config.js b/src/FundLab.Web/vite.config.js new file mode 100644 index 0000000..5e01572 --- /dev/null +++ b/src/FundLab.Web/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + outDir: "dist", + emptyOutDir: true + } +}); diff --git a/src/FundLab.Worker/FundLab.Worker.fsproj b/src/FundLab.Worker/FundLab.Worker.fsproj new file mode 100644 index 0000000..9694479 --- /dev/null +++ b/src/FundLab.Worker/FundLab.Worker.fsproj @@ -0,0 +1,18 @@ +<Project Sdk="Microsoft.NET.Sdk.Worker"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <OutputType>Exe</OutputType> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <Nullable>enable</Nullable> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\FundLab.Domain\FundLab.Domain.fsproj" /> + </ItemGroup> + <ItemGroup> + <Compile Include="Worker.fs" /> + <Compile Include="Program.fs" /> + </ItemGroup> +</Project> diff --git a/src/FundLab.Worker/Program.fs b/src/FundLab.Worker/Program.fs new file mode 100644 index 0000000..058d9b7 --- /dev/null +++ b/src/FundLab.Worker/Program.fs @@ -0,0 +1,12 @@ +module FundLab.Worker.Program + +open Microsoft.Extensions.DependencyInjection +open Microsoft.Extensions.Hosting + +[<EntryPoint>] +let main argv = + Host.CreateDefaultBuilder(argv) + .ConfigureServices(fun services -> services.AddHostedService<Worker>() |> ignore) + .Build() + .Run() + 0 diff --git a/src/FundLab.Worker/Worker.fs b/src/FundLab.Worker/Worker.fs new file mode 100644 index 0000000..6a7b7a7 --- /dev/null +++ b/src/FundLab.Worker/Worker.fs @@ -0,0 +1,15 @@ +namespace FundLab.Worker + +open System +open System.Threading +open System.Threading.Tasks +open Microsoft.Extensions.Hosting + +type Worker() = + inherit BackgroundService() + + override _.ExecuteAsync(stoppingToken: CancellationToken) : Task = + task { + while not stoppingToken.IsCancellationRequested do + do! Task.Delay(TimeSpan.FromMinutes 5, stoppingToken) + } |
