diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 01:40:10 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 01:40:10 +0800 |
| commit | be1afd248e4cc9b03a1e21090602a945af6988b2 (patch) | |
| tree | d31bd6e6c1736ab4ad784b52ebc5010ade074439 /src | |
| parent | 2ada70d6467aec11b45328112d598454ca50f2d6 (diff) | |
| download | fund-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 的现有修改。
Diffstat (limited to 'src')
| -rw-r--r-- | src/FundLab.Api/App.fs | 152 | ||||
| -rw-r--r-- | src/FundLab.Api/FundLab.Api.fsproj | 5 | ||||
| -rw-r--r-- | src/FundLab.Api/MarketData.fs | 294 | ||||
| -rw-r--r-- | src/FundLab.Api/MarketDataService.fs | 181 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 272 | ||||
| -rw-r--r-- | src/FundLab.Api/Program.fs | 4 | ||||
| -rw-r--r-- | src/FundLab.Api/akshare_collector.py | 171 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 607 | ||||
| -rw-r--r-- | src/FundLab.Web/index.html | 2 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 38 | ||||
| -rw-r--r-- | src/FundLab.Web/src/main.js | 1 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 501 | ||||
| -rw-r--r-- | src/FundLab.Web/vite.config.js | 8 |
13 files changed, 2214 insertions, 22 deletions
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 + } + } } }); |
