summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 01:40:10 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 01:40:10 +0800
commitbe1afd248e4cc9b03a1e21090602a945af6988b2 (patch)
treed31bd6e6c1736ab4ad784b52ebc5010ade074439
parent2ada70d6467aec11b45328112d598454ca50f2d6 (diff)
downloadfund-lab-be1afd248e4cc9b03a1e21090602a945af6988b2.tar.gz
feat(app): 接入 AKShare 行情采集、净值查询与前端净值曲线
[变更性质] - 本提交完成 3b 行情闭环:真实 AKShare 搜索与净值采集、PostgreSQL 持久化、鉴权 API 与 Fable 前端净值曲线展示。 [新增功能] - API 新增 /api/instruments/search、/api/instruments/{code}/nav 与 nav/refresh 接口,经 Bearer 鉴权调用 AKShare Python 采集器并幂等落库。 - 前端提供基金搜索、精确代码选择、历史净值刷新/重读与 SVG 折线图,token 变更即清空私有结果并失效在途请求。 [实现方案] - MarketData 以 requiredProperty 校验采集器 payload,净值观测按 (code, nav_date) 幂等 upsert 并保留来源与哈希。 - 前端以 requestId 序列守卫 SearchCompleted/SearchFailed/NavCompleted/NavFailed,TokenChanged 同时递增 searchSeq/navSeq 拒绝过期响应;边界解码兼容 F# option 的 {"case":"Some"} 线格式。 [影响范围] - 新增 FundLab.Web.Tests(边界解码、序列失效、图表纯函数共 8 项),扩展 API 测试至 20 项;vite dev 代理 /api 至本地 API。 - 真实 AKShare 端到端依赖本机 akshare 环境,由负责人另行验收;本提交不包含 docs/overnight-progress.md 的现有修改。
-rw-r--r--FundLab.sln10
-rw-r--r--src/FundLab.Api/App.fs152
-rw-r--r--src/FundLab.Api/FundLab.Api.fsproj5
-rw-r--r--src/FundLab.Api/MarketData.fs294
-rw-r--r--src/FundLab.Api/MarketDataService.fs181
-rw-r--r--src/FundLab.Api/Persistence.fs272
-rw-r--r--src/FundLab.Api/Program.fs4
-rw-r--r--src/FundLab.Api/akshare_collector.py171
-rw-r--r--src/FundLab.Web/App.fs607
-rw-r--r--src/FundLab.Web/index.html2
-rw-r--r--src/FundLab.Web/src/api.js38
-rw-r--r--src/FundLab.Web/src/main.js1
-rw-r--r--src/FundLab.Web/src/styles.css501
-rw-r--r--src/FundLab.Web/vite.config.js8
-rw-r--r--tests/FundLab.Api.Tests/ApiTests.fs66
-rw-r--r--tests/FundLab.Api.Tests/PersistenceTests.fs144
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs151
-rw-r--r--tests/FundLab.Web.Tests/ChartTests.fs23
-rw-r--r--tests/FundLab.Web.Tests/FundLab.Web.Tests.fsproj26
-rw-r--r--tests/FundLab.Web.Tests/Program.fs5
20 files changed, 2636 insertions, 25 deletions
diff --git a/FundLab.sln b/FundLab.sln
index a9fdb01..f28b139 100644
--- a/FundLab.sln
+++ b/FundLab.sln
@@ -12,8 +12,10 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FundLab.Web", "src\FundLab.
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FundLab.Domain.Tests", "tests\FundLab.Domain.Tests\FundLab.Domain.Tests.fsproj", "{55555555-5555-4555-8555-555555555555}"
EndProject
-Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FundLab.Api.Tests", "tests\FundLab.Api.Tests\FundLab.Api.Tests.fsproj", "{66666666-6666-4666-8666-666666666666}"
-EndProject
+ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FundLab.Api.Tests", "tests\FundLab.Api.Tests\FundLab.Api.Tests.fsproj", "{66666666-6666-4666-8666-666666666666}"
+ EndProject
+ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FundLab.Web.Tests", "tests\FundLab.Web.Tests\FundLab.Web.Tests.fsproj", "{77777777-7777-4777-8777-777777777777}"
+ EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -44,6 +46,10 @@ Global
{66666666-6666-4666-8666-666666666666}.Debug|Any CPU.Build.0 = Debug|Any CPU
{66666666-6666-4666-8666-666666666666}.Release|Any CPU.ActiveCfg = Release|Any CPU
{66666666-6666-4666-8666-666666666666}.Release|Any CPU.Build.0 = Release|Any CPU
+ {77777777-7777-4777-8777-777777777777}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {77777777-7777-4777-8777-777777777777}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {77777777-7777-4777-8777-777777777777}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {77777777-7777-4777-8777-777777777777}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index 0eb367d..a4e0a00 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -31,11 +31,51 @@ type ApiErrorResponse =
message: string
}
+type MarketDataInstrumentApiResponse =
+ {
+ code: string
+ name: string
+ fundType: string option
+ }
+
+type MarketDataSearchApiResponse =
+ {
+ source: string
+ sourceRevision: string
+ collectedAt: string
+ instruments: MarketDataInstrumentApiResponse list
+ }
+
+type MarketDataObservationApiResponse =
+ {
+ code: string
+ navDate: string
+ publishedAt: string option
+ nav: string
+ accumulatedNav: string option
+ dailyReturn: string option
+ source: string
+ sourceRevision: string
+ sourceCollectedAt: string
+ sourcePayloadHash: string
+ firstSeenAt: string
+ lastSeenAt: string
+ }
+
+type MarketDataNavApiResponse =
+ {
+ code: string
+ observations: MarketDataObservationApiResponse list
+ }
+
module App =
let private invariant = CultureInfo.InvariantCulture
let private cashText (value: decimal) = value.ToString("0.00", invariant)
let private unitNavText (value: decimal) = value.ToString("0.00000000", invariant)
+ let private decimalText (value: decimal) = value.ToString("0.00000000", invariant)
+ let private dateText (value: DateOnly) = value.ToString("yyyy-MM-dd", invariant)
+ let private timestampText (value: DateTimeOffset) = value.ToString("O", invariant)
let private fundResponse (fund: FundRecord) : FundApiResponse =
{
@@ -176,16 +216,116 @@ module App =
with _ ->
errorResponse 500 "PERSISTENCE_ERROR" "fund persistence failed" next ctx
- let createApplication (repository: FundRepository) : HttpHandler =
+ let private marketDataError (failure: MarketDataFailure) : HttpHandler =
+ let status, error, message =
+ match failure with
+ | InvalidMarketDataRequest message -> 400, "INVALID_MARKET_DATA_REQUEST", message
+ | MarketDataCollectorUnavailable message -> 503, "MARKET_DATA_UNAVAILABLE", message
+ | InvalidMarketDataPayload message -> 502, "INVALID_MARKET_DATA_PAYLOAD", message
+ | MarketDataPersistenceFailure _ -> 500, "PERSISTENCE_ERROR", "market data persistence failed"
+
+ errorResponse status error message
+
+ let private marketDataInstrumentResponse (instrument: MarketDataInstrument) =
+ {
+ code = instrument.Code
+ name = instrument.Name
+ fundType = instrument.FundType
+ }
+
+ let private marketDataSearchResponse (payload: MarketDataSearchPayload) =
+ {
+ source = payload.Source
+ sourceRevision = payload.SourceRevision
+ collectedAt = timestampText payload.CollectedAt
+ instruments = payload.Instruments |> List.map marketDataInstrumentResponse
+ }
+
+ let private marketDataObservationResponse (observation: MarketDataNavRecord) =
+ {
+ code = observation.Code
+ navDate = dateText observation.NavDate
+ publishedAt = observation.PublishedAt |> Option.map timestampText
+ nav = decimalText observation.Nav
+ accumulatedNav = observation.AccumulatedNav |> Option.map decimalText
+ dailyReturn = observation.DailyReturn |> Option.map decimalText
+ source = observation.Source
+ sourceRevision = observation.SourceRevision
+ sourceCollectedAt = timestampText observation.SourceCollectedAt
+ sourcePayloadHash = observation.SourcePayloadHash
+ firstSeenAt = timestampText observation.FirstSeenAt
+ lastSeenAt = timestampText observation.LastSeenAt
+ }
+
+ let private marketDataNavResponse code observations =
+ {
+ code = code
+ observations = observations |> List.map marketDataObservationResponse
+ }
+
+ let private searchInstruments (marketData: IMarketDataService) : HttpHandler =
+ fun next ctx ->
+ match marketData.Search(ctx.Request.Query["q"].ToString()) with
+ | Ok payload -> json (marketDataSearchResponse payload) next ctx
+ | Error failure -> marketDataError failure next ctx
+
+ let private refreshNav (marketData: IMarketDataService) (code: string) : HttpHandler =
+ fun next ctx ->
+ match marketData.RefreshNav code with
+ | Ok observations -> json (marketDataNavResponse code observations) next ctx
+ | Error failure -> marketDataError failure next ctx
+
+ let private queryDate (name: string) (ctx: HttpContext) =
+ let value = ctx.Request.Query[name].ToString()
+
+ if String.IsNullOrWhiteSpace value then
+ Ok None
+ else
+ let mutable date = DateOnly.MinValue
+
+ if DateOnly.TryParseExact(value, "yyyy-MM-dd", invariant, DateTimeStyles.None, &date) then
+ Ok(Some date)
+ else
+ Error(sprintf "%s must be an ISO date" name)
+
+ let private getNav (marketData: IMarketDataService) (code: string) : HttpHandler =
+ fun next ctx ->
+ match queryDate "from" ctx, queryDate "to" ctx with
+ | Ok fromDate, Ok toDate ->
+ match marketData.GetNav(code, fromDate, toDate) with
+ | Ok observations -> json (marketDataNavResponse code observations) next ctx
+ | Error failure -> marketDataError failure next ctx
+ | Error message, _
+ | _, Error message ->
+ marketDataError (InvalidMarketDataRequest message) next ctx
+
+ let private marketDataRoutes (marketData: IMarketDataService) =
+ [
+ GET >=> route "/instruments/search" >=> searchInstruments marketData
+ POST >=> routef "/instruments/%s/nav/refresh" (refreshNav marketData)
+ GET >=> routef "/instruments/%s/nav" (getNav marketData)
+ ]
+
+ let private createApplicationInternal (repository: FundRepository) (marketData: IMarketDataService option) : HttpHandler =
+ let apiRoutes =
+ [
+ GET >=> route "/portfolio/summary" >=> emptyPortfolio
+ POST >=> route "/funds" >=> createFund repository
+ GET >=> routef "/funds/%s" (getFund repository)
+ ]
+ @ (marketData |> Option.map marketDataRoutes |> Option.defaultValue [])
+
choose [
GET >=> route "/health" >=> health
subRoute "/api" (
requireBearer
- >=> choose [
- GET >=> route "/portfolio/summary" >=> emptyPortfolio
- POST >=> route "/funds" >=> createFund repository
- GET >=> routef "/funds/%s" (getFund repository)
- ]
+ >=> choose apiRoutes
)
setStatusCode 404 >=> text "Not Found"
]
+
+ let createApplicationWithMarketData (repository: FundRepository) (marketData: IMarketDataService) : HttpHandler =
+ createApplicationInternal repository (Some marketData)
+
+ let createApplication (repository: FundRepository) : HttpHandler =
+ createApplicationInternal repository None
diff --git a/src/FundLab.Api/FundLab.Api.fsproj b/src/FundLab.Api/FundLab.Api.fsproj
index 8a3f590..04724c0 100644
--- a/src/FundLab.Api/FundLab.Api.fsproj
+++ b/src/FundLab.Api/FundLab.Api.fsproj
@@ -15,8 +15,13 @@
<ItemGroup>
<Compile Include="Authentication.fs" />
<Compile Include="Health.fs" />
+ <Compile Include="MarketData.fs" />
<Compile Include="Persistence.fs" />
+ <Compile Include="MarketDataService.fs" />
<Compile Include="App.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
+ <ItemGroup>
+ <None Include="akshare_collector.py" CopyToOutputDirectory="PreserveNewest" />
+ </ItemGroup>
</Project>
diff --git a/src/FundLab.Api/MarketData.fs b/src/FundLab.Api/MarketData.fs
new file mode 100644
index 0000000..e396a7d
--- /dev/null
+++ b/src/FundLab.Api/MarketData.fs
@@ -0,0 +1,294 @@
+namespace FundLab.Api
+
+open System
+open System.Globalization
+open System.Text.Json
+
+type MarketDataInstrument =
+ {
+ Code: string
+ Name: string
+ FundType: string option
+ }
+
+type MarketDataObservation =
+ {
+ NavDate: DateOnly
+ PublishedAt: DateTimeOffset option
+ Nav: decimal
+ AccumulatedNav: decimal option
+ DailyReturn: decimal option
+ }
+
+type MarketDataSearchPayload =
+ {
+ Source: string
+ SourceRevision: string
+ CollectedAt: DateTimeOffset
+ Instruments: MarketDataInstrument list
+ }
+
+type MarketDataNavPayload =
+ {
+ Source: string
+ SourceRevision: string
+ CollectedAt: DateTimeOffset
+ Code: string
+ Observations: MarketDataObservation list
+ }
+
+type MarketDataInstrumentRecord =
+ {
+ Code: string
+ Name: string
+ FundType: string option
+ Source: string
+ SourceRevision: string
+ SourceCollectedAt: DateTimeOffset
+ SourcePayloadHash: string
+ FirstSeenAt: DateTimeOffset
+ LastSeenAt: DateTimeOffset
+ }
+
+type MarketDataNavRecord =
+ {
+ Code: string
+ NavDate: DateOnly
+ PublishedAt: DateTimeOffset option
+ Nav: decimal
+ AccumulatedNav: decimal option
+ DailyReturn: decimal option
+ Source: string
+ SourceRevision: string
+ SourceCollectedAt: DateTimeOffset
+ SourcePayloadHash: string
+ FirstSeenAt: DateTimeOffset
+ LastSeenAt: DateTimeOffset
+ }
+
+module MarketData =
+ type private ResultBuilder() =
+ member _.Bind(value, binder) = Result.bind binder value
+ member _.Return(value) = Ok value
+ member _.ReturnFrom(value) = value
+ member _.Zero() : Result<unit, string> = Ok ()
+ member _.Combine(first: Result<unit, string>, second: unit -> Result<'a, string>) =
+ match first with
+ | Ok () -> second ()
+ | Error message -> Error message
+ member _.Delay(generator: unit -> Result<'a, string>) = generator
+ member _.Run(generator: unit -> Result<'a, string>) = generator ()
+
+ let private result = ResultBuilder()
+ let private invariant = CultureInfo.InvariantCulture
+
+ let private requiredProperty (root: JsonElement) (name: string) =
+ let mutable property = Unchecked.defaultof<JsonElement>
+
+ if root.ValueKind <> JsonValueKind.Object then
+ Error "payload root must be a JSON object"
+ elif root.TryGetProperty(name, &property) then
+ Ok property
+ else
+ Error(sprintf "payload property '%s' is required" name)
+
+ let private requiredString (root: JsonElement) name =
+ requiredProperty root name
+ |> Result.bind (fun property ->
+ if property.ValueKind = JsonValueKind.String then
+ let value = property.GetString()
+
+ if String.IsNullOrWhiteSpace value then
+ Error(sprintf "payload property '%s' must not be empty" name)
+ else
+ Ok value
+ else
+ Error(sprintf "payload property '%s' must be a string" name))
+
+ let private optionalString (root: JsonElement) name =
+ requiredProperty root name
+ |> Result.bind (fun property ->
+ if property.ValueKind = JsonValueKind.Null then
+ Ok None
+ elif property.ValueKind = JsonValueKind.String then
+ let value = property.GetString()
+
+ if String.IsNullOrWhiteSpace value then
+ Ok None
+ else
+ Ok(Some value)
+ else
+ Error(sprintf "payload property '%s' must be null or a string" name))
+
+ let private validateEnvelope root operation =
+ result {
+ let! schemaVersion = requiredString root "schema_version"
+ let! actualOperation = requiredString root "operation"
+ let! source = requiredString root "source"
+ let! sourceRevision = requiredString root "source_revision"
+ let! collectedAtText = requiredString root "collected_at"
+
+ if schemaVersion <> "fund-lab.akshare.v1" then
+ return! Error(sprintf "unsupported schema_version '%s'" schemaVersion)
+
+ if actualOperation <> operation then
+ return! Error(sprintf "payload operation must be '%s'" operation)
+
+ match DateTimeOffset.TryParse(collectedAtText, invariant, DateTimeStyles.RoundtripKind) with
+ | true, collectedAt ->
+ return source, sourceRevision, collectedAt
+ | false, _ ->
+ return! Error "collected_at must be an ISO-8601 timestamp"
+ }
+
+ let private isFundCode (value: string) =
+ value.Length = 6 && value |> Seq.forall Char.IsDigit
+
+ let private parseFundCode root =
+ requiredString root "code"
+ |> Result.bind (fun code ->
+ if isFundCode code then
+ Ok code
+ else
+ Error "fund code must contain exactly six digits")
+
+ let private parseDate text =
+ let mutable date = DateOnly.MinValue
+
+ if DateOnly.TryParseExact(text, "yyyy-MM-dd", invariant, DateTimeStyles.None, &date) then
+ Ok date
+ else
+ Error "nav_date must be an ISO date"
+
+ let private parseDecimal label (property: JsonElement) =
+ if property.ValueKind <> JsonValueKind.String then
+ Error(sprintf "%s must be a decimal string" label)
+ else
+ let text = property.GetString()
+
+ if not (String.IsNullOrWhiteSpace text) then
+ match Decimal.TryParse(text, NumberStyles.AllowLeadingSign ||| NumberStyles.AllowDecimalPoint, invariant) with
+ | true, value -> Ok value
+ | false, _ -> Error(sprintf "%s must be a decimal string" label)
+ else
+ Error(sprintf "%s must be a decimal string" label)
+
+ let private optionalDecimal label (property: JsonElement) =
+ if property.ValueKind = JsonValueKind.Null then
+ Ok None
+ else
+ parseDecimal label property |> Result.map Some
+
+ let private optionalPublishedAt (property: JsonElement) =
+ if property.ValueKind = JsonValueKind.Null then
+ Ok None
+ elif property.ValueKind <> JsonValueKind.String then
+ Error "published_at must be null or an ISO-8601 timestamp"
+ else
+ let text = property.GetString()
+
+ if String.IsNullOrWhiteSpace text then
+ Error "published_at must be null or an ISO-8601 timestamp"
+ else
+ match DateTimeOffset.TryParse(text, invariant, DateTimeStyles.RoundtripKind) with
+ | true, value -> Ok(Some value)
+ | false, _ -> Error "published_at must be null or an ISO-8601 timestamp"
+
+ let private parseArray parser (property: JsonElement) =
+ if property.ValueKind <> JsonValueKind.Array then
+ Error "payload collection must be a JSON array"
+ else
+ property.EnumerateArray()
+ |> Seq.fold
+ (fun state item ->
+ match state, parser item with
+ | Ok values, Ok value -> Ok(value :: values)
+ | Error message, _ -> Error message
+ | _, Error message -> Error message)
+ (Ok [])
+ |> Result.map List.rev
+
+ let private parseSearchInstrument root =
+ result {
+ let! code = parseFundCode root
+ let! name = requiredString root "name"
+ let! fundType = optionalString root "fund_type"
+
+ return
+ {
+ Code = code
+ Name = name
+ FundType = fundType
+ }
+ }
+
+ let private parseObservation root =
+ result {
+ let! navDateText = requiredString root "nav_date"
+ let! navDate = parseDate navDateText
+ let! publishedAtProperty = requiredProperty root "published_at"
+ let! publishedAt = optionalPublishedAt publishedAtProperty
+ let! navProperty = requiredProperty root "nav"
+ let! nav = parseDecimal "nav" navProperty
+ let! accumulatedNavProperty = requiredProperty root "accumulated_nav"
+ let! accumulatedNav = optionalDecimal "accumulated_nav" accumulatedNavProperty
+ let! dailyReturnProperty = requiredProperty root "daily_return"
+ let! dailyReturn = optionalDecimal "daily_return" dailyReturnProperty
+
+ return
+ {
+ NavDate = navDate
+ PublishedAt = publishedAt
+ Nav = nav
+ AccumulatedNav = accumulatedNav
+ DailyReturn = dailyReturn
+ }
+ }
+
+ let parseSearchPayload (json: string) : Result<MarketDataSearchPayload, string> =
+ try
+ use document = JsonDocument.Parse(json)
+ let root = document.RootElement
+
+ result {
+ let! source, sourceRevision, collectedAt = validateEnvelope root "search"
+ let! instrumentsProperty = requiredProperty root "instruments"
+ let! instruments = parseArray parseSearchInstrument instrumentsProperty
+
+ return
+ {
+ Source = source
+ SourceRevision = sourceRevision
+ CollectedAt = collectedAt
+ Instruments = instruments
+ }
+ }
+ with
+ | :? JsonException -> Error "payload must be valid JSON"
+
+ let parseNavPayload (json: string) : Result<MarketDataNavPayload, string> =
+ try
+ use document = JsonDocument.Parse(json)
+ let root = document.RootElement
+
+ result {
+ let! source, sourceRevision, collectedAt = validateEnvelope root "nav"
+ let! instrumentProperty = requiredProperty root "instrument"
+ let! code = parseFundCode instrumentProperty
+ let! observationsProperty = requiredProperty root "observations"
+ let! observations = parseArray parseObservation observationsProperty
+
+ if List.isEmpty observations then
+ return! Error "observations must not be empty"
+
+ return
+ {
+ Source = source
+ SourceRevision = sourceRevision
+ CollectedAt = collectedAt
+ Code = code
+ Observations = observations
+ }
+ }
+ with
+ | :? JsonException -> Error "payload must be valid JSON"
diff --git a/src/FundLab.Api/MarketDataService.fs b/src/FundLab.Api/MarketDataService.fs
new file mode 100644
index 0000000..8047071
--- /dev/null
+++ b/src/FundLab.Api/MarketDataService.fs
@@ -0,0 +1,181 @@
+namespace FundLab.Api
+
+open System
+open System.Diagnostics
+open System.IO
+open System.Security.Cryptography
+open System.Text
+
+type MarketDataFailure =
+ | InvalidMarketDataRequest of string
+ | MarketDataCollectorUnavailable of string
+ | InvalidMarketDataPayload of string
+ | MarketDataPersistenceFailure of string
+
+type IMarketDataCollector =
+ abstract Search: query: string -> Result<string, string>
+ abstract FetchNav: code: string -> Result<string, string>
+
+type IMarketDataService =
+ abstract Search: query: string -> Result<MarketDataSearchPayload, MarketDataFailure>
+ abstract RefreshNav: code: string -> Result<MarketDataNavRecord list, MarketDataFailure>
+ abstract GetNav: code: string * fromDate: DateOnly option * toDate: DateOnly option -> Result<MarketDataNavRecord list, MarketDataFailure>
+
+type ProcessMarketDataCollector(pythonExecutable: string, scriptPath: string, pythonPath: string option) =
+ let execute arguments =
+ if not (File.Exists scriptPath) then
+ Error(sprintf "collector script was not found at %s" scriptPath)
+ else
+ let startInfo = ProcessStartInfo()
+ startInfo.FileName <- pythonExecutable
+ startInfo.UseShellExecute <- false
+ startInfo.RedirectStandardOutput <- true
+ startInfo.RedirectStandardError <- true
+ startInfo.WorkingDirectory <- Path.GetDirectoryName(scriptPath)
+
+ startInfo.ArgumentList.Add(scriptPath)
+
+ for argument in arguments do
+ startInfo.ArgumentList.Add(argument)
+
+ match pythonPath with
+ | Some value -> startInfo.Environment["PYTHONPATH"] <- value
+ | None -> ()
+
+ use child = new Process()
+ child.StartInfo <- startInfo
+
+ try
+ if not (child.Start()) then
+ Error "could not start the AKShare collector"
+ else
+ let output = child.StandardOutput.ReadToEnd()
+ let error = child.StandardError.ReadToEnd()
+ child.WaitForExit()
+
+ if child.ExitCode = 0 && not (String.IsNullOrWhiteSpace output) then
+ Ok output
+ elif String.IsNullOrWhiteSpace error then
+ Error(sprintf "collector exited with code %d" child.ExitCode)
+ else
+ Error(error.Trim())
+ with error ->
+ Error(error.Message)
+
+ static member FromEnvironment() =
+ let value name fallback =
+ Environment.GetEnvironmentVariable(name)
+ |> Option.ofObj
+ |> Option.filter (String.IsNullOrWhiteSpace >> not)
+ |> Option.defaultValue fallback
+
+ let python = value "FUND_LAB_AKSHARE_PYTHON" "python3"
+ let script = Path.Combine(AppContext.BaseDirectory, "akshare_collector.py")
+
+ let pythonPath =
+ Environment.GetEnvironmentVariable("FUND_LAB_AKSHARE_PYTHONPATH")
+ |> Option.ofObj
+ |> Option.filter (String.IsNullOrWhiteSpace >> not)
+
+ ProcessMarketDataCollector(python, script, pythonPath)
+
+ interface IMarketDataCollector with
+ member _.Search(query: string) =
+ execute [ "--operation"; "search"; "--query"; query ]
+
+ member _.FetchNav(code: string) =
+ execute [ "--operation"; "nav"; "--code"; code ]
+
+type MarketDataService(repository: FundRepository, collector: IMarketDataCollector) =
+ let codePattern = Text.RegularExpressions.Regex("^[0-9]{6}$", Text.RegularExpressions.RegexOptions.Compiled)
+
+ let payloadHash (raw: string) =
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))
+
+ let collectorFailure error = MarketDataCollectorUnavailable error
+ let payloadFailure error = InvalidMarketDataPayload error
+
+ let parseSearch raw =
+ match MarketData.parseSearchPayload raw with
+ | Ok payload -> Ok(payload, payloadHash raw)
+ | Error message -> Error(payloadFailure message)
+
+ let parseNav raw =
+ match MarketData.parseNavPayload raw with
+ | Ok payload -> Ok(payload, payloadHash raw)
+ | Error message -> Error(payloadFailure message)
+
+ let persistSearch (payload: MarketDataSearchPayload) hash =
+ try
+ repository.UpsertInstruments(payload, hash)
+ Ok payload
+ with error ->
+ Error(MarketDataPersistenceFailure error.Message)
+
+ let persistNav (payload: MarketDataNavPayload) hash =
+ try
+ repository.UpsertNavObservations(payload, hash)
+ Ok(repository.GetNav(payload.Code, None, None))
+ with error ->
+ Error(MarketDataPersistenceFailure error.Message)
+
+ let validCode code =
+ not (String.IsNullOrWhiteSpace code) && codePattern.IsMatch(code)
+
+ member _.Search(query: string) =
+ let normalizedQuery = if isNull query then "" else query.Trim()
+
+ if String.IsNullOrWhiteSpace normalizedQuery || normalizedQuery.Length > 80 then
+ Error(InvalidMarketDataRequest "search query must contain 1 to 80 characters")
+ else
+ match collector.Search normalizedQuery with
+ | Error message -> Error(collectorFailure message)
+ | Ok raw ->
+ match parseSearch raw with
+ | Error failure -> Error failure
+ | Ok(payload, hash) -> persistSearch payload hash
+
+ member _.RefreshNav(code: string) =
+ let normalizedCode = if isNull code then "" else code.Trim()
+
+ if not (validCode normalizedCode) then
+ Error(InvalidMarketDataRequest "fund code must contain exactly six digits")
+ else
+ match collector.Search normalizedCode with
+ | Error message -> Error(collectorFailure message)
+ | Ok searchRaw ->
+ match parseSearch searchRaw with
+ | Error failure -> Error failure
+ | Ok(searchPayload, searchHash) ->
+ match searchPayload.Instruments |> List.tryFind (fun instrument -> instrument.Code = normalizedCode) with
+ | None -> Error(InvalidMarketDataRequest "fund code was not found in the source catalog")
+ | Some _ ->
+ match persistSearch searchPayload searchHash with
+ | Error failure -> Error failure
+ | Ok _ ->
+ match collector.FetchNav normalizedCode with
+ | Error message -> Error(collectorFailure message)
+ | Ok navRaw ->
+ match parseNav navRaw with
+ | Error failure -> Error failure
+ | Ok(navPayload, navHash) when navPayload.Code <> normalizedCode ->
+ Error(payloadFailure "NAV payload code does not match the requested fund code")
+ | Ok(navPayload, navHash) -> persistNav navPayload navHash
+
+ member _.GetNav(code: string, fromDate: DateOnly option, toDate: DateOnly option) =
+ let normalizedCode = if isNull code then "" else code.Trim()
+
+ if not (validCode normalizedCode) then
+ Error(InvalidMarketDataRequest "fund code must contain exactly six digits")
+ elif fromDate.IsSome && toDate.IsSome && fromDate.Value > toDate.Value then
+ Error(InvalidMarketDataRequest "from date must not be after to date")
+ else
+ try
+ Ok(repository.GetNav(normalizedCode, fromDate, toDate))
+ with error ->
+ Error(MarketDataPersistenceFailure error.Message)
+
+ interface IMarketDataService with
+ member this.Search(query) = this.Search(query)
+ member this.RefreshNav(code) = this.RefreshNav(code)
+ member this.GetNav(code, fromDate, toDate) = this.GetNav(code, fromDate, toDate)
diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs
index db0fb6f..e48d9b1 100644
--- a/src/FundLab.Api/Persistence.fs
+++ b/src/FundLab.Api/Persistence.fs
@@ -61,6 +61,37 @@ type FundRepository(connectionString: string) =
fund_id uuid NOT NULL REFERENCES funds(id),
created_at timestamptz NOT NULL DEFAULT now()
);
+
+ CREATE TABLE IF NOT EXISTS instruments (
+ code text PRIMARY KEY CHECK (code ~ '^[0-9]{6}$'),
+ name text NOT NULL,
+ fund_type text NULL,
+ source text NOT NULL,
+ source_revision text NOT NULL,
+ source_collected_at timestamptz NOT NULL,
+ source_payload_hash text NOT NULL,
+ first_seen_at timestamptz NOT NULL DEFAULT now(),
+ last_seen_at timestamptz NOT NULL DEFAULT now()
+ );
+
+ CREATE TABLE IF NOT EXISTS fund_nav_observations (
+ instrument_code text NOT NULL REFERENCES instruments(code),
+ nav_date date NOT NULL,
+ published_at timestamptz NULL,
+ nav numeric(28, 8) NOT NULL,
+ accumulated_nav numeric(28, 8) NULL,
+ daily_return numeric(20, 8) NULL,
+ source text NOT NULL,
+ source_revision text NOT NULL,
+ source_collected_at timestamptz NOT NULL,
+ source_payload_hash text NOT NULL,
+ first_seen_at timestamptz NOT NULL DEFAULT now(),
+ last_seen_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (instrument_code, nav_date)
+ );
+
+ CREATE INDEX IF NOT EXISTS fund_nav_observations_date_idx
+ ON fund_nav_observations (instrument_code, nav_date);
"""
let statusText status =
@@ -93,6 +124,44 @@ type FundRepository(connectionString: string) =
Status = reader.GetString(7)
}
+ let dateTimeOffsetFromReader (reader: DbDataReader) index =
+ reader.GetFieldValue<DateTimeOffset>(index)
+
+ let optionalDateTimeOffsetFromReader (reader: DbDataReader) index =
+ if reader.IsDBNull(index) then None else Some(dateTimeOffsetFromReader reader index)
+
+ let optionalDecimalFromReader (reader: DbDataReader) index =
+ if reader.IsDBNull(index) then None else Some(reader.GetDecimal(index))
+
+ let instrumentRecordFromReader (reader: DbDataReader) : MarketDataInstrumentRecord =
+ {
+ Code = reader.GetString(0)
+ Name = reader.GetString(1)
+ FundType = if reader.IsDBNull(2) then None else Some(reader.GetString(2))
+ Source = reader.GetString(3)
+ SourceRevision = reader.GetString(4)
+ SourceCollectedAt = dateTimeOffsetFromReader reader 5
+ SourcePayloadHash = reader.GetString(6)
+ FirstSeenAt = dateTimeOffsetFromReader reader 7
+ LastSeenAt = dateTimeOffsetFromReader reader 8
+ }
+
+ let navRecordFromReader (reader: DbDataReader) : MarketDataNavRecord =
+ {
+ Code = reader.GetString(0)
+ NavDate = reader.GetFieldValue<DateOnly>(1)
+ PublishedAt = optionalDateTimeOffsetFromReader reader 2
+ Nav = reader.GetDecimal(3)
+ AccumulatedNav = optionalDecimalFromReader reader 4
+ DailyReturn = optionalDecimalFromReader reader 5
+ Source = reader.GetString(6)
+ SourceRevision = reader.GetString(7)
+ SourceCollectedAt = dateTimeOffsetFromReader reader 8
+ SourcePayloadHash = reader.GetString(9)
+ FirstSeenAt = dateTimeOffsetFromReader reader 10
+ LastSeenAt = dateTimeOffsetFromReader reader 11
+ }
+
let commandWithTransaction (connection: NpgsqlConnection) (transaction: NpgsqlTransaction option) sql =
let command = connection.CreateCommand()
command.CommandText <- sql
@@ -108,6 +177,11 @@ type FundRepository(connectionString: string) =
parameter.Value <- value
parameter
+ let optionalParameterValue value =
+ match value with
+ | Some actual -> box actual
+ | None -> box DBNull.Value
+
let findFund connection transaction fundId =
use command =
commandWithTransaction
@@ -176,6 +250,102 @@ type FundRepository(connectionString: string) =
addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
command.ExecuteNonQuery() |> ignore
+ let upsertInstrument connection transaction (payload: MarketDataSearchPayload) payloadHash (instrument: MarketDataInstrument) =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ INSERT INTO instruments
+ (code, name, fund_type, source, source_revision,
+ source_collected_at, source_payload_hash)
+ VALUES
+ (@code, @name, @fund_type, @source, @source_revision,
+ @source_collected_at, @source_payload_hash)
+ ON CONFLICT (code) DO UPDATE SET
+ name = EXCLUDED.name,
+ fund_type = EXCLUDED.fund_type,
+ source = EXCLUDED.source,
+ source_revision = EXCLUDED.source_revision,
+ source_collected_at = EXCLUDED.source_collected_at,
+ source_payload_hash = EXCLUDED.source_payload_hash,
+ last_seen_at = now()
+ """
+
+ addParameter command "code" NpgsqlDbType.Text (box instrument.Code) |> ignore
+ addParameter command "name" NpgsqlDbType.Text (box instrument.Name) |> ignore
+
+ addParameter
+ command
+ "fund_type"
+ NpgsqlDbType.Text
+ (optionalParameterValue instrument.FundType)
+ |> ignore
+
+ addParameter command "source" NpgsqlDbType.Text (box payload.Source) |> ignore
+ addParameter command "source_revision" NpgsqlDbType.Text (box payload.SourceRevision) |> ignore
+ addParameter command "source_collected_at" NpgsqlDbType.TimestampTz (box payload.CollectedAt) |> ignore
+ addParameter command "source_payload_hash" NpgsqlDbType.Text (box payloadHash) |> ignore
+ command.ExecuteNonQuery() |> ignore
+
+ let upsertNavObservation connection transaction (payload: MarketDataNavPayload) payloadHash (observation: MarketDataObservation) =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ INSERT INTO fund_nav_observations
+ (instrument_code, nav_date, published_at, nav, accumulated_nav,
+ daily_return, source, source_revision, source_collected_at,
+ source_payload_hash)
+ VALUES
+ (@instrument_code, @nav_date, @published_at, @nav, @accumulated_nav,
+ @daily_return, @source, @source_revision, @source_collected_at,
+ @source_payload_hash)
+ ON CONFLICT (instrument_code, nav_date) DO UPDATE SET
+ published_at = EXCLUDED.published_at,
+ nav = EXCLUDED.nav,
+ accumulated_nav = EXCLUDED.accumulated_nav,
+ daily_return = EXCLUDED.daily_return,
+ source = EXCLUDED.source,
+ source_revision = EXCLUDED.source_revision,
+ source_collected_at = EXCLUDED.source_collected_at,
+ source_payload_hash = EXCLUDED.source_payload_hash,
+ last_seen_at = now()
+ """
+
+ addParameter command "instrument_code" NpgsqlDbType.Text (box payload.Code) |> ignore
+ addParameter command "nav_date" NpgsqlDbType.Date (box observation.NavDate) |> ignore
+
+ addParameter
+ command
+ "published_at"
+ NpgsqlDbType.TimestampTz
+ (optionalParameterValue observation.PublishedAt)
+ |> ignore
+
+ addParameter command "nav" NpgsqlDbType.Numeric (box observation.Nav) |> ignore
+
+ addParameter
+ command
+ "accumulated_nav"
+ NpgsqlDbType.Numeric
+ (optionalParameterValue observation.AccumulatedNav)
+ |> ignore
+
+ addParameter
+ command
+ "daily_return"
+ NpgsqlDbType.Numeric
+ (optionalParameterValue observation.DailyReturn)
+ |> ignore
+
+ addParameter command "source" NpgsqlDbType.Text (box payload.Source) |> ignore
+ addParameter command "source_revision" NpgsqlDbType.Text (box payload.SourceRevision) |> ignore
+ addParameter command "source_collected_at" NpgsqlDbType.TimestampTz (box payload.CollectedAt) |> ignore
+ addParameter command "source_payload_hash" NpgsqlDbType.Text (box payloadHash) |> ignore
+ command.ExecuteNonQuery() |> ignore
+
let requestHash (command: FundCreateCommand) =
let invariant = CultureInfo.InvariantCulture
let encoded (value: string) = sprintf "%d:%s" value.Length value
@@ -222,6 +392,108 @@ type FundRepository(connectionString: string) =
connection.Open()
findFund connection None fundId
+ member _.UpsertInstruments(payload: MarketDataSearchPayload, payloadHash: string) =
+ use connection = new NpgsqlConnection(connectionString)
+ connection.Open()
+ use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted)
+
+ try
+ for instrument in payload.Instruments do
+ upsertInstrument connection (Some transaction) payload payloadHash instrument
+
+ transaction.Commit()
+ with error ->
+ try
+ transaction.Rollback()
+ with _ ->
+ ()
+
+ raise error
+
+ member _.GetInstrument(code: string) =
+ use connection = new NpgsqlConnection(connectionString)
+ connection.Open()
+
+ use command =
+ commandWithTransaction
+ connection
+ None
+ """
+ SELECT code, name, fund_type, source, source_revision,
+ source_collected_at, source_payload_hash,
+ first_seen_at, last_seen_at
+ FROM instruments
+ WHERE code = @code
+ """
+
+ addParameter command "code" NpgsqlDbType.Text (box code) |> ignore
+ use reader = command.ExecuteReader()
+ if reader.Read() then Some(instrumentRecordFromReader reader) else None
+
+ member _.UpsertNavObservations(payload: MarketDataNavPayload, payloadHash: string) =
+ use connection = new NpgsqlConnection(connectionString)
+ connection.Open()
+ use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted)
+
+ try
+ for observation in payload.Observations do
+ upsertNavObservation connection (Some transaction) payload payloadHash observation
+
+ transaction.Commit()
+ with error ->
+ try
+ transaction.Rollback()
+ with _ ->
+ ()
+
+ raise error
+
+ member _.GetNav(code: string, fromDate: DateOnly option, toDate: DateOnly option) =
+ use connection = new NpgsqlConnection(connectionString)
+ connection.Open()
+
+ let conditions = ResizeArray<string>()
+ conditions.Add("instrument_code = @instrument_code")
+
+ if fromDate.IsSome then
+ conditions.Add("nav_date >= @from_date")
+
+ if toDate.IsSome then
+ conditions.Add("nav_date <= @to_date")
+
+ use command =
+ commandWithTransaction
+ connection
+ None
+ (sprintf
+ """
+ SELECT instrument_code, nav_date, published_at, nav, accumulated_nav,
+ daily_return, source, source_revision, source_collected_at,
+ source_payload_hash, first_seen_at, last_seen_at
+ FROM fund_nav_observations
+ WHERE %s
+ ORDER BY nav_date ASC
+ """
+ (String.concat " AND " conditions))
+
+ addParameter command "instrument_code" NpgsqlDbType.Text (box code) |> ignore
+
+ match fromDate with
+ | Some value -> addParameter command "from_date" NpgsqlDbType.Date (box value) |> ignore
+ | None -> ()
+
+ match toDate with
+ | Some value -> addParameter command "to_date" NpgsqlDbType.Date (box value) |> ignore
+ | None -> ()
+
+ use reader = command.ExecuteReader()
+ let records = ResizeArray<MarketDataNavRecord>()
+
+ while reader.Read() do
+ records.Add(navRecordFromReader reader)
+
+ records |> Seq.toList
+
member _.CreateFund(idempotencyKey: string, command: FundCreateCommand) =
if String.IsNullOrWhiteSpace idempotencyKey then
FundWriteResult.Invalid "idempotency key cannot be empty"
diff --git a/src/FundLab.Api/Program.fs b/src/FundLab.Api/Program.fs
index 4c757d6..d4f2c76 100644
--- a/src/FundLab.Api/Program.fs
+++ b/src/FundLab.Api/Program.fs
@@ -16,8 +16,10 @@ let main argv =
builder.Services.AddGiraffe() |> ignore
let repository = FundRepository(requiredEnvironment "FUND_LAB_DATABASE_URL")
repository.EnsureSchema()
+ let collector = ProcessMarketDataCollector.FromEnvironment() :> IMarketDataCollector
+ let marketData = MarketDataService(repository, collector) :> IMarketDataService
let app = builder.Build()
- app.UseGiraffe(App.createApplication(repository))
+ app.UseGiraffe(App.createApplicationWithMarketData repository marketData)
app.Run()
0
diff --git a/src/FundLab.Api/akshare_collector.py b/src/FundLab.Api/akshare_collector.py
new file mode 100644
index 0000000..f988a61
--- /dev/null
+++ b/src/FundLab.Api/akshare_collector.py
@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+"""Small raw-data adapter for the AKShare endpoints used by Fund Lab."""
+
+import argparse
+import datetime as dt
+import json
+import re
+import sys
+from decimal import Decimal, InvalidOperation
+
+import akshare as ak
+import pandas as pd
+
+
+SCHEMA_VERSION = "fund-lab.akshare.v1"
+
+
+def collected_at():
+ return dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def source_revision():
+ return f"akshare-{getattr(ak, '__version__', 'unknown')}/eastmoney"
+
+
+def is_missing(value):
+ try:
+ return bool(pd.isna(value))
+ except (TypeError, ValueError):
+ return False
+
+
+def text(value):
+ if is_missing(value):
+ return None
+ value = str(value).strip()
+ return value or None
+
+
+def fund_code(value):
+ value = text(value)
+ if value is None:
+ return None
+ if value.isdigit() and len(value) < 6:
+ value = value.zfill(6)
+ return value if re.fullmatch(r"\d{6}", value) else None
+
+
+def decimal_text(value):
+ value = text(value)
+ if value is None:
+ return None
+ try:
+ return format(Decimal(value), "f")
+ except InvalidOperation:
+ return None
+
+
+def date_text(value):
+ if is_missing(value):
+ return None
+ if isinstance(value, (dt.datetime, dt.date)):
+ return value.strftime("%Y-%m-%d")
+ parsed = pd.to_datetime(value, errors="coerce")
+ if pd.isna(parsed):
+ return None
+ return parsed.strftime("%Y-%m-%d")
+
+
+def search(query):
+ query = query.strip()
+ if not query:
+ raise ValueError("search query cannot be empty")
+
+ frame = ak.fund_name_em()
+ query_lower = query.casefold()
+ rows = []
+
+ for _, row in frame.iterrows():
+ code = fund_code(row.get("基金代码"))
+ name = text(row.get("基金简称"))
+ pinyin = text(row.get("拼音缩写"))
+ full_pinyin = text(row.get("拼音全称"))
+
+ if code is None or name is None:
+ continue
+
+ searchable = [code, name, pinyin or "", full_pinyin or ""]
+ if not any(query_lower in value.casefold() for value in searchable):
+ continue
+
+ rows.append(
+ {
+ "code": code,
+ "name": name,
+ "fund_type": text(row.get("基金类型")),
+ "rank": 0 if code == query else (1 if name == query else 2),
+ }
+ )
+
+ rows.sort(key=lambda item: (item.pop("rank"), item["code"]))
+ return {
+ "schema_version": SCHEMA_VERSION,
+ "operation": "search",
+ "source": "akshare",
+ "source_revision": source_revision(),
+ "collected_at": collected_at(),
+ "instruments": rows[:100],
+ }
+
+
+def nav(code):
+ if not re.fullmatch(r"\d{6}", code):
+ raise ValueError("fund code must contain exactly six digits")
+
+ frame = ak.fund_open_fund_info_em(
+ symbol=code,
+ indicator="单位净值走势",
+ period="成立来",
+ )
+ observations = []
+
+ for _, row in frame.iterrows():
+ nav_date = date_text(row.get("净值日期"))
+ nav_value = decimal_text(row.get("单位净值"))
+ if nav_date is None or nav_value is None:
+ continue
+
+ observations.append(
+ {
+ "nav_date": nav_date,
+ "published_at": None,
+ "nav": nav_value,
+ "accumulated_nav": None,
+ "daily_return": decimal_text(row.get("日增长率")),
+ }
+ )
+
+ if not observations:
+ raise ValueError(f"AKShare returned no usable NAV observations for {code}")
+
+ return {
+ "schema_version": SCHEMA_VERSION,
+ "operation": "nav",
+ "source": "akshare",
+ "source_revision": source_revision(),
+ "collected_at": collected_at(),
+ "instrument": {"code": code},
+ "observations": observations,
+ }
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--operation", choices=("search", "nav"), required=True)
+ parser.add_argument("--query")
+ parser.add_argument("--code")
+ args = parser.parse_args()
+
+ try:
+ payload = search(args.query) if args.operation == "search" else nav(args.code)
+ json.dump(payload, sys.stdout, ensure_ascii=False, separators=(",", ":"))
+ sys.stdout.write("\n")
+ return 0
+ except Exception as error:
+ print(f"AKShare collector failed: {error}", file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index 630a8e9..0ee4d56 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -1,41 +1,620 @@
module FundLab.Web
+open System
+open System.Globalization
open Elmish
open Elmish.React
open Feliz
+open Fable.Core
+
+type Instrument =
+ {
+ code: string
+ name: string
+ fundType: string option
+ }
+
+type NavObservation =
+ {
+ navDate: string
+ nav: string
+ dailyReturn: string option
+ }
+
+type ChartPoint =
+ {
+ x: float
+ y: float
+ navDate: string
+ nav: string
+ }
+
+module Chart =
+ let private invariant = CultureInfo.InvariantCulture
+
+ let private navValue (observation: NavObservation) =
+ Decimal.Parse(observation.nav, NumberStyles.Float, invariant)
+
+ let chartPoints (observations: NavObservation list) =
+ let ordered: NavObservation list = observations |> List.sortBy (fun observation -> observation.navDate)
+ let values = ordered |> List.map navValue
+
+ match values with
+ | [] -> []
+ | _ ->
+ let minimum = List.min values
+ let maximum = List.max values
+ let range = maximum - minimum
+ let count = List.length ordered
+
+ ordered
+ |> List.mapi (fun index observation ->
+ let value = navValue observation
+ let x =
+ if count = 1 then
+ 0.5
+ else
+ float index / float (count - 1)
+
+ let y =
+ if range = 0M then
+ 0.5
+ else
+ float ((value - minimum) / range)
+
+ {
+ x = x
+ y = y
+ navDate = observation.navDate
+ nav = observation.nav
+ })
+
+type RawOptionalText =
+ {
+ case: string
+ fields: string array
+ }
+
+type RawInstrument =
+ {
+ code: string
+ name: string
+ fundType: obj
+ }
+
+type RawSearchResponse =
+ {
+ instruments: RawInstrument array
+ }
+
+type RawObservation =
+ {
+ navDate: string
+ nav: string
+ dailyReturn: obj
+ }
+
+type RawNavResponse =
+ {
+ observations: RawObservation array
+ }
+
+module Api =
+ [<Import("searchInstruments", "./src/api.js")>]
+ let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative
+
+ [<Import("getNav", "./src/api.js")>]
+ let getNav (token: string) (code: string) : JS.Promise<RawNavResponse> = jsNative
+
+ [<Import("refreshNav", "./src/api.js")>]
+ let refreshNav (token: string) (code: string) : JS.Promise<RawNavResponse> = jsNative
+
+ let decodeOptionalText (raw: obj) : string option =
+ if isNull raw then
+ None
+ else
+ let envelope = unbox<RawOptionalText> raw
+
+ if isNull (box envelope.fields) || envelope.fields.Length = 0 then
+ None
+ else
+ Some envelope.fields[0]
+
+ let decodeInstrument (raw: RawInstrument) : Instrument =
+ {
+ code = raw.code
+ name = raw.name
+ fundType = decodeOptionalText raw.fundType
+ }
+
+ let decodeSearch (raw: RawSearchResponse) : Instrument list =
+ raw.instruments |> Array.map decodeInstrument |> List.ofArray
+
+ let decodeObservation (raw: RawObservation) : NavObservation =
+ {
+ navDate = raw.navDate
+ nav = raw.nav
+ dailyReturn = decodeOptionalText raw.dailyReturn
+ }
+
+ let decodeNav (raw: RawNavResponse) : NavObservation list =
+ raw.observations |> Array.map decodeObservation |> List.ofArray
type Model =
{
- hasFund: bool
+ token: string
+ query: string
+ searchSeq: int
+ navSeq: int
+ searchResults: Instrument list
+ selected: Instrument option
+ observations: NavObservation list
+ searchInFlight: bool
+ navInFlight: bool
+ error: string option
}
type Msg =
- | CreateFundRequested
+ | TokenChanged of string
+ | QueryChanged of string
+ | SearchRequested
+ | SearchCompleted of requestId: int * instruments: Instrument list
+ | SearchFailed of requestId: int * message: string
+ | InstrumentSelected of Instrument
+ | LoadNavRequested
+ | RefreshNavRequested
+ | NavCompleted of requestId: int * observations: NavObservation list
+ | NavFailed of requestId: int * message: string
let init () =
- { hasFund = false }
+ {
+ token = ""
+ query = ""
+ searchSeq = 0
+ navSeq = 0
+ searchResults = []
+ selected = None
+ observations = []
+ searchInFlight = false
+ navInFlight = false
+ error = None
+ }
+
+let private errorText (error: exn) =
+ if String.IsNullOrWhiteSpace error.Message then
+ "请求失败"
+ else
+ error.Message
+
+let private searchCommand token query requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.searchInstruments token query)
+ ()
+ (fun raw -> SearchCompleted(requestId, Api.decodeSearch raw))
+ (fun error -> SearchFailed(requestId, errorText error))
+
+let private navCommand token code requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.getNav token code)
+ ()
+ (fun raw -> NavCompleted(requestId, Api.decodeNav raw))
+ (fun error -> NavFailed(requestId, errorText error))
+
+let private refreshCommand token code requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.refreshNav token code)
+ ()
+ (fun raw -> NavCompleted(requestId, Api.decodeNav raw))
+ (fun error -> NavFailed(requestId, errorText error))
let update message model =
match message with
- | CreateFundRequested -> model
+ | TokenChanged token ->
+ {
+ model with
+ token = token
+ searchSeq = model.searchSeq + 1
+ navSeq = model.navSeq + 1
+ searchResults = []
+ selected = None
+ observations = []
+ searchInFlight = false
+ navInFlight = false
+ error = None
+ },
+ Cmd.none
+ | QueryChanged query ->
+ { model with query = query; error = None }, Cmd.none
+ | SearchRequested ->
+ let query = model.query.Trim()
+
+ if String.IsNullOrWhiteSpace model.token then
+ { model with error = Some "请输入 API token" }, Cmd.none
+ elif String.IsNullOrWhiteSpace query then
+ { model with error = Some "请输入基金名称或六位代码" }, Cmd.none
+ else
+ let requestId = model.searchSeq + 1
+
+ {
+ model with
+ query = query
+ searchSeq = requestId
+ searchInFlight = true
+ error = None
+ },
+ searchCommand model.token query requestId
+ | SearchCompleted (requestId, instruments) ->
+ if requestId = model.searchSeq then
+ {
+ model with
+ searchResults = instruments
+ searchInFlight = false
+ error = None
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | SearchFailed (requestId, message) ->
+ if requestId = model.searchSeq then
+ { model with searchInFlight = false; error = Some message }, Cmd.none
+ else
+ model, Cmd.none
+ | InstrumentSelected instrument ->
+ if String.IsNullOrWhiteSpace model.token then
+ { model with error = Some "请输入 API token" }, Cmd.none
+ else
+ let requestId = model.navSeq + 1
+
+ {
+ model with
+ selected = Some instrument
+ observations = []
+ navSeq = requestId
+ navInFlight = true
+ error = None
+ },
+ navCommand model.token instrument.code requestId
+ | LoadNavRequested ->
+ match model.selected with
+ | Some instrument when not (String.IsNullOrWhiteSpace model.token) ->
+ let requestId = model.navSeq + 1
+
+ {
+ model with
+ navSeq = requestId
+ navInFlight = true
+ error = None
+ },
+ navCommand model.token instrument.code requestId
+ | Some _ ->
+ { model with error = Some "请输入 API token" }, Cmd.none
+ | None ->
+ { model with error = Some "请先选择一个基金" }, Cmd.none
+ | RefreshNavRequested ->
+ match model.selected with
+ | Some instrument when not (String.IsNullOrWhiteSpace model.token) ->
+ let requestId = model.navSeq + 1
+
+ {
+ model with
+ navSeq = requestId
+ navInFlight = true
+ error = None
+ },
+ refreshCommand model.token instrument.code requestId
+ | Some _ ->
+ { model with error = Some "请输入 API token" }, Cmd.none
+ | None ->
+ { model with error = Some "请先选择一个基金" }, Cmd.none
+ | NavCompleted (requestId, observations) ->
+ if requestId = model.navSeq then
+ {
+ model with
+ observations = observations |> List.sortBy (fun observation -> observation.navDate)
+ navInFlight = false
+ error = None
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | NavFailed (requestId, message) ->
+ if requestId = model.navSeq then
+ { model with navInFlight = false; error = Some message }, Cmd.none
+ else
+ model, Cmd.none
+
+let private navText (text: string) =
+ match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with
+ | true, value -> value.ToString("0.00000000", CultureInfo.InvariantCulture)
+ | false, _ -> text
+
+let private latestStats (observations: NavObservation list) =
+ let ordered: NavObservation list = observations |> List.sortBy (fun observation -> observation.navDate)
+
+ match ordered with
+ | [] -> None
+ | ordered ->
+ let first = List.head ordered
+ let latest = List.last ordered
+
+ Some(
+ navText latest.nav,
+ latest.dailyReturn |> Option.map (fun value -> value + "%"),
+ first.navDate,
+ latest.navDate
+ )
+
+let private chartView (observations: NavObservation list) =
+ let points = Chart.chartPoints observations
+
+ if List.isEmpty points then
+ Html.div [ prop.className "chart-empty"; prop.text "选择基金后加载历史净值" ]
+ else
+ let width = 960.0
+ let height = 320.0
+ let padding = 28.0
+ let chartWidth = width - padding * 2.0
+ let chartHeight = height - padding * 2.0
+
+ let coordinates =
+ points
+ |> List.map (fun point ->
+ padding + point.x * chartWidth,
+ padding + (1.0 - point.y) * chartHeight)
+
+ let gridLines =
+ [ 0 .. 4 ]
+ |> List.map (fun index ->
+ let y = padding + float index / 4.0 * chartHeight
+
+ Svg.line [
+ svg.x1 padding
+ svg.y1 y
+ svg.x2 (width - padding)
+ svg.y2 y
+ svg.stroke "#e2e8f0"
+ svg.strokeWidth 1
+ ])
+
+ let markers =
+ coordinates
+ |> List.mapi (fun index (x, y) ->
+ Svg.circle [
+ svg.cx x
+ svg.cy y
+ svg.r 3.5
+ svg.fill "#f59e0b"
+ ])
+
+ Html.div [
+ prop.className "chart-wrap"
+ prop.children [
+ Svg.svg [
+ svg.className "nav-chart"
+ svg.viewBox (0, 0, 960, 320)
+ svg.children [
+ yield! gridLines
+ Svg.polyline [
+ svg.points coordinates
+ svg.fill "none"
+ svg.stroke "#1e40af"
+ svg.strokeWidth 3
+ ]
+ yield! markers
+ ]
+ ]
+ Html.div [
+ prop.className "chart-axis"
+ prop.children [
+ Html.span [ prop.text (points |> List.head |> fun point -> point.navDate) ]
+ Html.span [ prop.text (points |> List.last |> fun point -> point.navDate) ]
+ ]
+ ]
+ ]
+ ]
+
+let private resultRow instrument dispatch =
+ Html.button [
+ prop.className "result-row"
+ prop.onClick (fun _ -> dispatch (InstrumentSelected instrument))
+ prop.children [
+ Html.span [ prop.className "result-code"; prop.text instrument.code ]
+ Html.span [ prop.className "result-name"; prop.text instrument.name ]
+ Html.span [ prop.className "result-type"; prop.text (instrument.fundType |> Option.defaultValue "基金") ]
+ ]
+ ]
+
+let private searchPanel model dispatch =
+ Html.section [
+ prop.className "panel search-panel"
+ prop.children [
+ Html.div [
+ prop.className "section-heading"
+ prop.children [
+ Html.div [
+ Html.p [ prop.className "eyebrow"; prop.text "01 / FIND" ]
+ Html.h2 "先锁定真实代码"
+ ]
+ Html.span [ prop.className "section-note"; prop.text "AKShare · exact match" ]
+ ]
+ ]
+ Html.div [
+ prop.className "search-row"
+ prop.children [
+ Html.input [
+ prop.className "text-input search-input"
+ prop.placeholder "输入基金名称或六位代码"
+ prop.value model.query
+ prop.onChange (fun value -> dispatch (QueryChanged value))
+ ]
+ Html.button [
+ prop.className "primary-action"
+ prop.disabled model.searchInFlight
+ prop.onClick (fun _ -> dispatch SearchRequested)
+ prop.text (if model.searchInFlight then "查询中..." else "搜索")
+ ]
+ ]
+ ]
+ if List.isEmpty model.searchResults then
+ Html.p [ prop.className "hint"; prop.text "搜索结果会保留来源与代码,避免名称歧义。" ]
+ else
+ Html.div [
+ prop.className "result-list"
+ prop.children (model.searchResults |> List.map (fun instrument -> resultRow instrument dispatch))
+ ]
+ ]
+ ]
+
+let private navPanel model dispatch =
+ let selectedName =
+ model.selected
+ |> Option.map (fun instrument -> instrument.name)
+ |> Option.defaultValue "尚未选择基金"
+
+ Html.section [
+ prop.className "panel nav-panel"
+ prop.children [
+ Html.div [
+ prop.className "section-heading"
+ prop.children [
+ Html.div [
+ Html.p [ prop.className "eyebrow"; prop.text "02 / TRACE" ]
+ Html.h2 selectedName
+ ]
+ match model.selected with
+ | Some instrument ->
+ Html.div [
+ prop.className "instrument-meta"
+ prop.children [
+ Html.span [ prop.className "code-chip"; prop.text instrument.code ]
+ Html.span [ prop.className "section-note"; prop.text (instrument.fundType |> Option.defaultValue "基金") ]
+ ]
+ ]
+ | None -> Html.none
+ ]
+ ]
+ match latestStats model.observations with
+ | None ->
+ Html.div [
+ prop.className "chart-placeholder"
+ prop.children [
+ Html.div [ prop.className "placeholder-line" ]
+ Html.p "历史净值会在这里形成一条可追溯的曲线"
+ Html.span "数据来自已持久化的日频观察值"
+ ]
+ ]
+ | Some(latestNav, dailyReturn, fromDate, toDate) ->
+ Html.div [
+ prop.className "nav-content"
+ prop.children [
+ Html.div [
+ prop.className "metric-strip"
+ prop.children [
+ Html.div [
+ prop.className "metric"
+ prop.children [
+ Html.span "最新单位净值"
+ Html.strong latestNav
+ ]
+ ]
+ Html.div [
+ prop.className "metric"
+ prop.children [
+ Html.span "最新日收益"
+ Html.strong (dailyReturn |> Option.defaultValue "暂无")
+ ]
+ ]
+ Html.div [
+ prop.className "metric"
+ prop.children [
+ Html.span "观察区间"
+ Html.strong (sprintf "%s — %s" fromDate toDate)
+ ]
+ ]
+ ]
+ ]
+ chartView model.observations
+ ]
+ ]
+ Html.div [
+ prop.className "panel-actions"
+ prop.children [
+ Html.button [
+ prop.className "secondary-action"
+ prop.disabled (model.navInFlight || model.selected.IsNone)
+ prop.onClick (fun _ -> dispatch LoadNavRequested)
+ prop.text "重新读取"
+ ]
+ Html.button [
+ prop.className "primary-action"
+ prop.disabled (model.navInFlight || model.selected.IsNone)
+ prop.onClick (fun _ -> dispatch RefreshNavRequested)
+ prop.text (if model.navInFlight then "同步中..." else "刷新 AKShare")
+ ]
+ ]
+ ]
+ ]
+ ]
let view model dispatch =
Html.main [
- prop.className "empty-state"
+ prop.className "app-shell"
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)
+ Html.header [
+ prop.className "topbar"
+ prop.children [
+ Html.div [
+ prop.className "brand"
+ prop.children [
+ Html.span [ prop.className "brand-mark"; prop.text "F" ]
+ Html.span "FUND LAB"
+ ]
+ ]
+ Html.span [ prop.className "topbar-status"; prop.text "MARKET DATA / 3B" ]
+ ]
+ ]
+ Html.div [
+ prop.className "hero"
+ prop.children [
+ Html.div [
+ prop.className "hero-copy"
+ prop.children [
+ Html.p [ prop.className "eyebrow"; prop.text "人民币模拟基金 · 观察台" ]
+ Html.h1 "把净值曲线,先看清楚。"
+ Html.p "从真实基金代码开始,读取、校验并追踪每日单位净值。这里不下单,只保留可复核的数据。"
+ ]
+ ]
+ Html.div [
+ prop.className "token-card"
+ prop.children [
+ Html.label [
+ prop.className "field-label"
+ prop.children [
+ Html.span "API TOKEN"
+ Html.input [
+ prop.className "text-input token-input"
+ prop.type' "password"
+ prop.placeholder "仅保存在当前页面内存"
+ prop.value model.token
+ prop.onChange (fun value -> dispatch (TokenChanged value))
+ ]
+ ]
+ ]
+ Html.p [ prop.className "token-note"; prop.text "请求通过 Bearer header 发送,不写入构建产物。" ]
+ ]
+ ]
+ ]
]
+ match model.error with
+ | Some message -> Html.div [ prop.className "error-banner"; prop.text message ]
+ | None -> Html.none
+ searchPanel model dispatch
+ navPanel model dispatch
+ Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / MODE · READ-ONLY" ]
]
]
let start () =
- Program.mkSimple init update view
+ Program.mkProgram (fun () -> init (), Cmd.none) update view
|> Program.withReactSynchronous "fund-lab-root"
|> Program.run
diff --git a/src/FundLab.Web/index.html b/src/FundLab.Web/index.html
index 7b27019..d87ee2b 100644
--- a/src/FundLab.Web/index.html
+++ b/src/FundLab.Web/index.html
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
- <title>Fund Lab</title>
+ <title>Fund Lab / Market Data</title>
</head>
<body>
<div id="fund-lab-root"></div>
diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js
new file mode 100644
index 0000000..7d12d4d
--- /dev/null
+++ b/src/FundLab.Web/src/api.js
@@ -0,0 +1,38 @@
+async function requestJson(path, token, options = {}) {
+ const response = await fetch(path, {
+ ...options,
+ headers: {
+ ...options.headers,
+ Authorization: `Bearer ${token}`
+ }
+ });
+
+ const body = await response.text();
+ let payload = {};
+
+ if (body) {
+ try {
+ payload = JSON.parse(body);
+ } catch {
+ payload = {};
+ }
+ }
+
+ if (!response.ok) {
+ throw new Error(payload.message || `API request failed (${response.status})`);
+ }
+
+ return payload;
+}
+
+export function searchInstruments(token, query) {
+ return requestJson(`/api/instruments/search?q=${encodeURIComponent(query)}`, token);
+}
+
+export function getNav(token, code) {
+ return requestJson(`/api/instruments/${encodeURIComponent(code)}/nav`, token);
+}
+
+export function refreshNav(token, code) {
+ return requestJson(`/api/instruments/${encodeURIComponent(code)}/nav/refresh`, token, { method: "POST" });
+}
diff --git a/src/FundLab.Web/src/main.js b/src/FundLab.Web/src/main.js
index 57c83db..0048fef 100644
--- a/src/FundLab.Web/src/main.js
+++ b/src/FundLab.Web/src/main.js
@@ -1,3 +1,4 @@
import { start } from "../dist/App.js";
+import "./styles.css";
start();
diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css
new file mode 100644
index 0000000..f895353
--- /dev/null
+++ b/src/FundLab.Web/src/styles.css
@@ -0,0 +1,501 @@
+:root {
+ color: #172033;
+ background: #f8fafc;
+ font-family: Inter, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ background: #f8fafc;
+}
+
+button,
+input {
+ font: inherit;
+}
+
+button {
+ cursor: pointer;
+}
+
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.app-shell {
+ min-height: 100vh;
+ background:
+ radial-gradient(circle at 82% 0%, rgba(59, 130, 246, 0.09), transparent 24rem),
+ #f8fafc;
+}
+
+.topbar,
+.hero,
+.panel,
+.footer-note,
+.error-banner {
+ width: min(1120px, calc(100% - 48px));
+ margin-inline: auto;
+}
+
+.topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 28px 0 18px;
+ border-bottom: 1px solid #dbe4ef;
+}
+
+.brand,
+.instrument-meta,
+.topbar-status,
+.section-heading,
+.search-row,
+.panel-actions,
+.metric-strip,
+.chart-axis {
+ display: flex;
+ align-items: center;
+}
+
+.brand {
+ gap: 10px;
+ color: #102a68;
+ font-size: 13px;
+ font-weight: 800;
+ letter-spacing: 0.15em;
+}
+
+.brand-mark {
+ display: grid;
+ width: 28px;
+ height: 28px;
+ place-items: center;
+ border-radius: 8px;
+ color: white;
+ background: #1e40af;
+ box-shadow: 0 6px 16px rgba(30, 64, 175, 0.24);
+}
+
+.topbar-status,
+.section-note,
+.footer-note,
+.token-note,
+.hint,
+.field-label,
+.metric span,
+.result-type,
+.chart-axis {
+ color: #667085;
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.hero {
+ display: grid;
+ grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
+ gap: 56px;
+ align-items: end;
+ padding: 84px 0 58px;
+}
+
+.hero-copy {
+ max-width: 680px;
+}
+
+.eyebrow {
+ margin: 0 0 16px;
+ color: #1e40af;
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+h1,
+h2,
+p {
+ margin-top: 0;
+}
+
+h1 {
+ max-width: 650px;
+ margin-bottom: 20px;
+ color: #101828;
+ font-size: clamp(42px, 6vw, 74px);
+ font-weight: 750;
+ letter-spacing: -0.065em;
+ line-height: 0.98;
+}
+
+.hero-copy > p:last-child {
+ max-width: 570px;
+ margin-bottom: 0;
+ color: #526074;
+ font-size: 16px;
+ line-height: 1.8;
+}
+
+.token-card,
+.panel {
+ border: 1px solid #dbe4ef;
+ background: rgba(255, 255, 255, 0.82);
+ box-shadow: 0 20px 60px rgba(30, 64, 175, 0.06);
+}
+
+.token-card {
+ padding: 22px;
+ border-radius: 16px;
+}
+
+.field-label {
+ display: grid;
+ gap: 10px;
+ font-weight: 800;
+}
+
+.text-input {
+ width: 100%;
+ border: 1px solid #cbd5e1;
+ border-radius: 9px;
+ outline: none;
+ color: #172033;
+ background: #fff;
+ transition: border-color 140ms ease, box-shadow 140ms ease;
+}
+
+.text-input:focus {
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
+}
+
+.token-input,
+.search-input {
+ padding: 13px 14px;
+}
+
+.token-note {
+ margin: 12px 0 0;
+ font-size: 10px;
+ line-height: 1.6;
+ text-transform: none;
+ letter-spacing: 0;
+}
+
+.error-banner {
+ margin-bottom: 20px;
+ padding: 14px 16px;
+ border: 1px solid #f1b6b6;
+ border-radius: 10px;
+ color: #9b1c1c;
+ background: #fff4f4;
+ font-size: 13px;
+}
+
+.panel {
+ margin-bottom: 20px;
+ padding: 30px;
+ border-radius: 18px;
+}
+
+.section-heading {
+ justify-content: space-between;
+ gap: 20px;
+ margin-bottom: 25px;
+}
+
+.section-heading .eyebrow {
+ margin-bottom: 8px;
+}
+
+h2 {
+ margin-bottom: 0;
+ color: #172033;
+ font-size: clamp(22px, 3vw, 32px);
+ letter-spacing: -0.04em;
+}
+
+.search-row {
+ gap: 12px;
+}
+
+.search-input {
+ flex: 1;
+ min-width: 0;
+}
+
+.primary-action,
+.secondary-action {
+ min-height: 46px;
+ padding: 0 20px;
+ border-radius: 9px;
+ font-size: 13px;
+ font-weight: 750;
+ transition: transform 140ms ease, box-shadow 140ms ease, background 140ms ease;
+}
+
+.primary-action {
+ border: 1px solid #1e40af;
+ color: white;
+ background: #1e40af;
+ box-shadow: 0 8px 18px rgba(30, 64, 175, 0.2);
+}
+
+.primary-action:hover:not(:disabled) {
+ background: #18358f;
+ box-shadow: 0 12px 24px rgba(30, 64, 175, 0.25);
+ transform: translateY(-1px);
+}
+
+.secondary-action {
+ border: 1px solid #cbd5e1;
+ color: #334155;
+ background: white;
+}
+
+.secondary-action:hover:not(:disabled) {
+ border-color: #94a3b8;
+ background: #f8fafc;
+}
+
+.hint {
+ margin: 16px 0 0;
+ font-size: 11px;
+ text-transform: none;
+ letter-spacing: 0;
+}
+
+.result-list {
+ display: grid;
+ gap: 8px;
+ margin-top: 20px;
+}
+
+.result-row {
+ display: grid;
+ grid-template-columns: 100px minmax(0, 1fr) 80px;
+ gap: 14px;
+ align-items: center;
+ width: 100%;
+ padding: 14px 16px;
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+ text-align: left;
+ background: #fff;
+ transition: border-color 140ms ease, transform 140ms ease;
+}
+
+.result-row:hover {
+ border-color: #3b82f6;
+ transform: translateX(2px);
+}
+
+.result-code,
+.code-chip {
+ color: #1e40af;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 12px;
+ font-weight: 800;
+ letter-spacing: 0.04em;
+}
+
+.result-name {
+ overflow: hidden;
+ color: #1f2937;
+ font-size: 14px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.result-type {
+ overflow: hidden;
+ text-align: right;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ text-transform: none;
+ letter-spacing: 0;
+}
+
+.instrument-meta {
+ gap: 14px;
+}
+
+.code-chip {
+ padding: 7px 9px;
+ border-radius: 6px;
+ background: #eff6ff;
+}
+
+.chart-placeholder {
+ min-height: 270px;
+ display: grid;
+ align-content: center;
+ justify-items: center;
+ border: 1px dashed #cbd5e1;
+ border-radius: 12px;
+ color: #667085;
+ text-align: center;
+}
+
+.placeholder-line {
+ width: min(420px, 80%);
+ height: 70px;
+ margin-bottom: 20px;
+ border-top: 2px solid #bfdbfe;
+ border-radius: 50%;
+ transform: rotate(-7deg);
+}
+
+.chart-placeholder p {
+ margin-bottom: 8px;
+ color: #334155;
+ font-size: 15px;
+}
+
+.chart-placeholder span {
+ font-size: 12px;
+}
+
+.nav-content {
+ display: grid;
+ gap: 24px;
+}
+
+.metric-strip {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 1px;
+ overflow: hidden;
+ border: 1px solid #e2e8f0;
+ border-radius: 11px;
+ background: #e2e8f0;
+}
+
+.metric {
+ display: grid;
+ gap: 8px;
+ min-width: 0;
+ padding: 16px;
+ background: #fff;
+}
+
+.metric strong {
+ overflow: hidden;
+ color: #172033;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 16px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.chart-wrap {
+ overflow: hidden;
+ padding: 18px 14px 0;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ background: linear-gradient(180deg, #fff, #f8fbff);
+}
+
+.nav-chart {
+ display: block;
+ width: 100%;
+ height: 320px;
+}
+
+.chart-axis {
+ justify-content: space-between;
+ padding: 10px 6px 13px;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 10px;
+ letter-spacing: 0;
+ text-transform: none;
+}
+
+.panel-actions {
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 24px;
+}
+
+.footer-note {
+ padding: 16px 0 34px;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 9px;
+ text-align: right;
+}
+
+@media (max-width: 760px) {
+ .topbar,
+ .hero,
+ .panel,
+ .footer-note,
+ .error-banner {
+ width: min(100% - 28px, 1120px);
+ }
+
+ .hero {
+ grid-template-columns: 1fr;
+ gap: 32px;
+ padding: 52px 0 34px;
+ }
+
+ h1 {
+ font-size: clamp(42px, 14vw, 62px);
+ }
+
+ .panel {
+ padding: 20px;
+ }
+
+ .section-heading {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .search-row {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .search-row .primary-action {
+ width: 100%;
+ }
+
+ .result-row {
+ grid-template-columns: 82px minmax(0, 1fr);
+ }
+
+ .result-type {
+ display: none;
+ }
+
+ .instrument-meta {
+ align-self: stretch;
+ }
+
+ .metric-strip {
+ grid-template-columns: 1fr;
+ }
+
+ .panel-actions {
+ align-items: stretch;
+ flex-direction: column-reverse;
+ }
+
+ .panel-actions button {
+ width: 100%;
+ }
+
+ .footer-note {
+ text-align: left;
+ }
+}
diff --git a/src/FundLab.Web/vite.config.js b/src/FundLab.Web/vite.config.js
index 5e01572..21680e0 100644
--- a/src/FundLab.Web/vite.config.js
+++ b/src/FundLab.Web/vite.config.js
@@ -4,5 +4,13 @@ export default defineConfig({
build: {
outDir: "dist",
emptyOutDir: true
+ },
+ server: {
+ proxy: {
+ "/api": {
+ target: process.env.FUND_LAB_API_TARGET || "http://127.0.0.1:5099",
+ changeOrigin: true
+ }
+ }
}
});
diff --git a/tests/FundLab.Api.Tests/ApiTests.fs b/tests/FundLab.Api.Tests/ApiTests.fs
index a46d330..1aaab6b 100644
--- a/tests/FundLab.Api.Tests/ApiTests.fs
+++ b/tests/FundLab.Api.Tests/ApiTests.fs
@@ -2,6 +2,7 @@ namespace FundLab.Api.Tests
module ApiTests =
+ open System
open Xunit
open FundLab.Api
@@ -22,3 +23,68 @@ module ApiTests =
Assert.Contains("\"status\":\"ok\"", payload)
Assert.DoesNotContain("secret", payload)
+
+ [<Fact>]
+ let ``AKShare NAV payload keeps missing publication time empty`` () =
+ let payload =
+ """
+ {
+ "schema_version": "fund-lab.akshare.v1",
+ "operation": "nav",
+ "source": "akshare",
+ "source_revision": "akshare-1.18.96/eastmoney",
+ "collected_at": "2026-09-21T10:00:00Z",
+ "instrument": { "code": "000001" },
+ "observations": [
+ {
+ "nav_date": "2001-12-18",
+ "published_at": null,
+ "nav": "1.00000000",
+ "accumulated_nav": null,
+ "daily_return": "0.00"
+ }
+ ]
+ }
+ """
+
+ match MarketData.parseNavPayload payload with
+ | Ok parsed ->
+ let observation = parsed.Observations |> List.head
+ Assert.Equal("000001", parsed.Code)
+ Assert.Equal(DateOnly(2001, 12, 18), observation.NavDate)
+ Assert.Equal(None, observation.PublishedAt)
+ Assert.Equal(None, observation.AccumulatedNav)
+ Assert.Equal(Some 0.00m, observation.DailyReturn)
+ Assert.Equal(1.00000000m, observation.Nav)
+ | Error message ->
+ Assert.Fail(sprintf "expected valid NAV payload, got %s" message)
+
+ [<Fact>]
+ let ``AKShare search payload preserves exact fund code and source`` () =
+ let payload =
+ """
+ {
+ "schema_version": "fund-lab.akshare.v1",
+ "operation": "search",
+ "source": "akshare",
+ "source_revision": "akshare-1.18.96/eastmoney",
+ "collected_at": "2026-09-21T10:00:00Z",
+ "instruments": [
+ {
+ "code": "000001",
+ "name": "华夏成长混合",
+ "fund_type": "混合型-灵活"
+ }
+ ]
+ }
+ """
+
+ match MarketData.parseSearchPayload payload with
+ | Ok parsed ->
+ let instrument = parsed.Instruments |> List.head
+ Assert.Equal("000001", instrument.Code)
+ Assert.Equal("华夏成长混合", instrument.Name)
+ Assert.Equal(Some "混合型-灵活", instrument.FundType)
+ Assert.Equal("akshare", parsed.Source)
+ | Error message ->
+ Assert.Fail(sprintf "expected valid search payload, got %s" message)
diff --git a/tests/FundLab.Api.Tests/PersistenceTests.fs b/tests/FundLab.Api.Tests/PersistenceTests.fs
index f319839..5157a9c 100644
--- a/tests/FundLab.Api.Tests/PersistenceTests.fs
+++ b/tests/FundLab.Api.Tests/PersistenceTests.fs
@@ -203,7 +203,15 @@ module PersistenceTestHelpers =
use services = ServiceCollection().AddGiraffe().BuildServiceProvider()
context.RequestServices <- services
context.Request.Method <- method
- context.Request.Path <- PathString(path)
+
+ let queryIndex = path.IndexOf('?')
+
+ if queryIndex >= 0 then
+ context.Request.Path <- PathString(path.Substring(0, queryIndex))
+ context.Request.QueryString <- QueryString(path.Substring(queryIndex))
+ else
+ context.Request.Path <- PathString(path)
+
context.Request.Body <-
new MemoryStream(Encoding.UTF8.GetBytes(body))
:> Stream
@@ -319,6 +327,72 @@ type PersistenceTests(fixture: PostgresFixture) =
value
[<Fact>]
+ member _.``market data survives repository restart with source provenance``() =
+ let code = Random.Shared.Next(0, 1000000).ToString("D6")
+ let collectedAt = DateTimeOffset(2026, 9, 21, 10, 0, 0, TimeSpan.Zero)
+
+ let searchPayload =
+ {
+ Source = "akshare"
+ SourceRevision = "akshare-test/eastmoney"
+ CollectedAt = collectedAt
+ Instruments =
+ [
+ {
+ Code = code
+ Name = "测试基金"
+ FundType = None
+ }
+ ]
+ }
+
+ let navPayload =
+ {
+ Source = "akshare"
+ SourceRevision = "akshare-test/eastmoney"
+ CollectedAt = collectedAt
+ Code = code
+ Observations =
+ [
+ {
+ NavDate = DateOnly(2026, 9, 18)
+ PublishedAt = None
+ Nav = 1.23456789m
+ AccumulatedNav = Some 2.34567890m
+ DailyReturn = Some -0.12m
+ }
+ ]
+ }
+
+ let firstRepository = repository ()
+ firstRepository.UpsertInstruments(searchPayload, "search-hash")
+ firstRepository.UpsertNavObservations(navPayload, "nav-hash")
+
+ let restarted = FundRepository(fixture.ConnectionString)
+
+ match restarted.GetInstrument(code) with
+ | Some instrument ->
+ Assert.Equal(code, instrument.Code)
+ Assert.Equal("测试基金", instrument.Name)
+ Assert.Equal(None, instrument.FundType)
+ Assert.Equal("akshare-test/eastmoney", instrument.SourceRevision)
+ Assert.Equal("search-hash", instrument.SourcePayloadHash)
+ | None ->
+ Assert.Fail("instrument was not readable after repository restart")
+
+ match restarted.GetNav(code, Some(DateOnly(2026, 9, 1)), Some(DateOnly(2026, 9, 30))) with
+ | [ observation ] ->
+ Assert.Equal(code, observation.Code)
+ Assert.Equal(DateOnly(2026, 9, 18), observation.NavDate)
+ Assert.Equal(None, observation.PublishedAt)
+ Assert.Equal(1.23456789m, observation.Nav)
+ Assert.Equal(Some 2.34567890m, observation.AccumulatedNav)
+ Assert.Equal(Some -0.12m, observation.DailyReturn)
+ Assert.Equal("nav-hash", observation.SourcePayloadHash)
+ | observations ->
+ Assert.Fail(sprintf "expected one NAV observation, got %d" observations.Length)
+
+ [<Fact>]
member _.``created fund remains readable after repository restart with exact values``() =
let command =
{
@@ -584,6 +658,74 @@ type PersistenceTests(fixture: PostgresFixture) =
Assert.Contains("\"initialCash\":\"42.10\"", readBody)
[<Fact>]
+ member _.``market data API searches exact codes and returns persisted NAV dates``() =
+ let code = "000001"
+ let collectedAt = DateTimeOffset(2026, 9, 21, 10, 0, 0, TimeSpan.Zero)
+
+ let searchPayload =
+ {
+ Source = "akshare"
+ SourceRevision = "akshare-test/eastmoney"
+ CollectedAt = collectedAt
+ Instruments =
+ [
+ {
+ Code = code
+ Name = "华夏成长混合"
+ FundType = Some "混合型-灵活"
+ }
+ ]
+ }
+
+ let observation =
+ {
+ Code = code
+ NavDate = DateOnly(2026, 9, 18)
+ PublishedAt = None
+ Nav = 1.23456789m
+ AccumulatedNav = None
+ DailyReturn = Some -0.12m
+ Source = "akshare"
+ SourceRevision = "akshare-test/eastmoney"
+ SourceCollectedAt = collectedAt
+ SourcePayloadHash = "nav-hash"
+ FirstSeenAt = collectedAt
+ LastSeenAt = collectedAt
+ }
+
+ let marketData =
+ { new IMarketDataService with
+ member _.Search _ = Ok searchPayload
+ member _.RefreshNav _ = Ok [ observation ]
+ member _.GetNav(_, _, _) = Ok [ observation ] }
+
+ let app = App.createApplicationWithMarketData (repository ()) marketData
+
+ let searchStatus, searchBody =
+ PersistenceTestHelpers.invoke
+ app
+ "GET"
+ "/api/instruments/search?q=000001"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(200, searchStatus)
+ Assert.Contains("\"code\":\"000001\"", searchBody)
+ Assert.Contains("华夏成长混合", searchBody)
+
+ let navStatus, navBody =
+ PersistenceTestHelpers.invoke
+ app
+ "POST"
+ "/api/instruments/000001/nav/refresh"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(200, navStatus)
+ Assert.Contains("\"navDate\":\"2026-09-18\"", navBody)
+ Assert.Contains("\"source\":\"akshare\"", navBody)
+
+ [<Fact>]
member _.``fund API rejects non-object JSON bodies``() =
let app = App.createApplication (repository ())
diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs
new file mode 100644
index 0000000..9059cef
--- /dev/null
+++ b/tests/FundLab.Web.Tests/BoundaryTests.fs
@@ -0,0 +1,151 @@
+namespace FundLab.Web.Tests
+
+open Xunit
+open FundLab.Web
+
+module BoundaryTests =
+
+ let someText (value: string) : obj = box { case = "Some"; fields = [| value |] }
+
+ let sampleInstrument code name fundType : Instrument =
+ { code = code; name = name; fundType = fundType }
+
+ let sampleObservation navDate nav dailyReturn : NavObservation =
+ { navDate = navDate; nav = nav; dailyReturn = dailyReturn }
+
+ [<Fact>]
+ let ``decode search converts raw JSON array into F# list with optional fund type`` () =
+ let raw =
+ {
+ instruments =
+ [|
+ { code = "000001"; name = "华夏成长混合"; fundType = someText "混合型" }
+ { code = "110011"; name = "易方达优质精选混合"; fundType = null }
+ |]
+ }
+
+ let decoded = Api.decodeSearch raw
+
+ Assert.Equal(2, decoded.Length)
+ Assert.Equal("000001", decoded[0].code)
+ Assert.Equal("华夏成长混合", decoded[0].name)
+ Assert.Equal(Some "混合型", decoded[0].fundType)
+ Assert.Equal(None, decoded[1].fundType)
+
+ [<Fact>]
+ let ``decode nav tolerates missing daily return at the boundary`` () =
+ let raw =
+ {
+ observations =
+ [|
+ { navDate = "2026-09-01"; nav = "1.2345"; dailyReturn = someText "0.45" }
+ { navDate = "2026-09-02"; nav = "1.2400"; dailyReturn = null }
+ |]
+ }
+
+ let decoded = Api.decodeNav raw
+
+ Assert.Equal(2, decoded.Length)
+ Assert.Equal(Some "0.45", decoded[0].dailyReturn)
+ Assert.Equal(None, decoded[1].dailyReturn)
+ Assert.Equal("2026-09-02", decoded[1].navDate)
+
+ [<Fact>]
+ let ``stale search completion does not overwrite newer results`` () =
+ let sentinel = [ sampleInstrument "999999" "旧结果" None ]
+ let model = { init () with searchSeq = 2; searchInFlight = true; searchResults = sentinel }
+
+ let staleModel, _ = update (SearchCompleted(1, [ sampleInstrument "000001" "新结果" None ])) model
+
+ Assert.Equal<Instrument>(sentinel, staleModel.searchResults)
+ Assert.True(staleModel.searchInFlight)
+
+ let freshModel, _ = update (SearchCompleted(2, [ sampleInstrument "000001" "新结果" None ])) model
+
+ Assert.Equal(1, freshModel.searchResults.Length)
+ Assert.Equal("000001", freshModel.searchResults[0].code)
+ Assert.False(freshModel.searchInFlight)
+
+ [<Fact>]
+ let ``stale nav failure does not overwrite newer in-flight state`` () =
+ let selected = sampleInstrument "000001" "华夏成长混合" (Some "混合型")
+ let model = { init () with navSeq = 3; navInFlight = true; selected = Some selected }
+
+ let staleModel, _ = update (NavFailed(2, "过期失败")) model
+
+ Assert.True(staleModel.navInFlight)
+ Assert.Equal(None, staleModel.error)
+
+ let freshModel, _ = update (NavFailed(3, "同步失败")) model
+
+ Assert.False(freshModel.navInFlight)
+ Assert.Equal(Some "同步失败", freshModel.error)
+
+ [<Fact>]
+ let ``changing token clears private results selection and loading state`` () =
+ let model =
+ { init () with
+ token = "old-token"
+ searchResults = [ sampleInstrument "000001" "华夏成长混合" (Some "混合型") ]
+ selected = Some(sampleInstrument "110011" "易方达优质精选混合" None)
+ observations = [ sampleObservation "2026-09-01" "1.2345" (Some "0.45") ]
+ searchInFlight = true
+ navInFlight = true
+ error = Some "旧错误" }
+
+ let updated, _ = update (TokenChanged "new-token") model
+
+ Assert.Equal("new-token", updated.token)
+ Assert.Empty(updated.searchResults)
+ Assert.Equal(None, updated.selected)
+ Assert.Empty(updated.observations)
+ Assert.False(updated.searchInFlight)
+ Assert.False(updated.navInFlight)
+ Assert.Equal(None, updated.error)
+
+ [<Fact>]
+ let ``token change invalidates in flight search and nav responses`` () =
+ let model = { init () with searchSeq = 1; navSeq = 1 }
+
+ let cleared, _ = update (TokenChanged "new-token") model
+
+ Assert.Equal(2, cleared.searchSeq)
+ Assert.Equal(2, cleared.navSeq)
+
+ let staleSearchModel, _ =
+ update (SearchCompleted(1, [ sampleInstrument "000001" "过期结果" (Some "混合型") ])) cleared
+
+ Assert.Empty(staleSearchModel.searchResults)
+
+ let staleSearchFailureModel, _ = update (SearchFailed(1, "过期搜索失败")) cleared
+
+ Assert.Equal(None, staleSearchFailureModel.error)
+
+ let staleNavModel, _ =
+ update (NavCompleted(1, [ sampleObservation "2026-09-01" "1.2345" (Some "0.45") ])) cleared
+
+ Assert.Empty(staleNavModel.observations)
+
+ let staleNavFailureModel, _ = update (NavFailed(1, "过期净值失败")) cleared
+
+ Assert.Equal(None, staleNavFailureModel.error)
+
+ let freshNavModel, _ =
+ update (NavCompleted(2, [ sampleObservation "2026-09-01" "1.2345" None ])) cleared
+
+ Assert.Equal(1, freshNavModel.observations.Length)
+
+ [<Fact>]
+ let ``search failure clears loading and next attempt recovers`` () =
+ let model = { init () with token = "qa-token"; query = "华夏"; searchSeq = 1; searchInFlight = true }
+
+ let failedModel, _ = update (SearchFailed(1, "collector exited with code 1")) model
+
+ Assert.False(failedModel.searchInFlight)
+ Assert.Equal(Some "collector exited with code 1", failedModel.error)
+
+ let retryModel, _ = update SearchRequested failedModel
+
+ Assert.True(retryModel.searchInFlight)
+ Assert.Equal(None, retryModel.error)
+ Assert.Equal(2, retryModel.searchSeq)
diff --git a/tests/FundLab.Web.Tests/ChartTests.fs b/tests/FundLab.Web.Tests/ChartTests.fs
new file mode 100644
index 0000000..cede2d2
--- /dev/null
+++ b/tests/FundLab.Web.Tests/ChartTests.fs
@@ -0,0 +1,23 @@
+namespace FundLab.Web.Tests
+
+open Xunit
+open FundLab.Web
+
+module ChartTests =
+
+ [<Fact>]
+ let ``chart points preserve chronological order and normalize NAV values`` () =
+ let observations : NavObservation list =
+ [
+ { navDate = "2026-01-01"; nav = "1.00000000"; dailyReturn = None }
+ { navDate = "2026-01-02"; nav = "1.25000000"; dailyReturn = Some "25.00000000" }
+ { navDate = "2026-01-03"; nav = "1.10000000"; dailyReturn = Some "-12.00000000" }
+ ]
+
+ let points = Chart.chartPoints observations
+
+ Assert.Equal(3, points.Length)
+ Assert.Equal(0.0, points[0].x)
+ Assert.Equal(1.0, points[1].y)
+ Assert.Equal(0.4, points[2].y)
+ Assert.Equal("2026-01-03", points[2].navDate)
diff --git a/tests/FundLab.Web.Tests/FundLab.Web.Tests.fsproj b/tests/FundLab.Web.Tests/FundLab.Web.Tests.fsproj
new file mode 100644
index 0000000..0c1c7f9
--- /dev/null
+++ b/tests/FundLab.Web.Tests/FundLab.Web.Tests.fsproj
@@ -0,0 +1,26 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <TargetFramework>net8.0</TargetFramework>
+ <OutputType>Library</OutputType>
+ <IsPackable>false</IsPackable>
+ <IsTestProject>true</IsTestProject>
+ <GenerateProgramFile>false</GenerateProgramFile>
+ <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+ </PropertyGroup>
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
+ <PackageReference Include="xunit" Version="2.9.2" />
+ <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\..\src\FundLab.Web\FundLab.Web.fsproj" />
+ </ItemGroup>
+ <ItemGroup>
+ <Compile Include="ChartTests.fs" />
+ <Compile Include="BoundaryTests.fs" />
+ <Compile Include="Program.fs" />
+ </ItemGroup>
+</Project>
diff --git a/tests/FundLab.Web.Tests/Program.fs b/tests/FundLab.Web.Tests/Program.fs
new file mode 100644
index 0000000..120e538
--- /dev/null
+++ b/tests/FundLab.Web.Tests/Program.fs
@@ -0,0 +1,5 @@
+module FundLab.Web.Tests.Program
+
+[<EntryPoint>]
+let main _argv =
+ 0