summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/FundLab.Api/App.fs75
-rw-r--r--src/FundLab.Api/FundLab.Api.fsproj1
-rw-r--r--src/FundLab.Api/NavSeriesProbe.fs76
-rw-r--r--src/FundLab.Api/Program.fs7
-rw-r--r--src/FundLab.Web/App.fs205
-rw-r--r--src/FundLab.Web/src/api.js4
-rw-r--r--src/FundLab.Web/src/styles.css12
7 files changed, 369 insertions, 11 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index 370f503..e359af9 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -337,6 +337,27 @@ type MarketNavDatesApiResponse =
dates: string list
}
+type MarketNavSeriesPointApiResponse =
+ {
+ navDate: string
+ nav: string
+ accumulatedNav: string option
+ }
+
+type MarketNavSeriesApiResponse =
+ {
+ code: string
+ points: MarketNavSeriesPointApiResponse list
+ }
+
+/// The AKShare-backed probes the market endpoints depend on. Injected together
+/// so the app stays repository-only unless a caller really wants market routes.
+type MarketProbes =
+ {
+ NavDates: INavDateProbe
+ NavSeries: INavSeriesProbe
+ }
+
module App =
let addOptionFriendlyJson (services: IServiceCollection) =
let settings =
@@ -1649,13 +1670,49 @@ module App =
json ({ code = code.Trim(); dates = dates |> List.map dateText } : MarketNavDatesApiResponse) next ctx
| Error failure -> marketDataError failure next ctx
- let private navDateProbeRoutes (probe: INavDateProbe) =
- [ GET >=> route "/market/nav-dates" >=> getMarketNavDates probe ]
+ let private marketNavSeriesPointResponse (point: NavSeriesPoint) : MarketNavSeriesPointApiResponse =
+ {
+ navDate = dateText point.NavDate
+ nav = decimalText point.Nav
+ accumulatedNav = point.AccumulatedNav |> Option.map decimalText
+ }
+
+ let private getMarketNavSeries (probe: INavSeriesProbe) : HttpHandler =
+ fun next ctx ->
+ let code = ctx.Request.Query["code"].ToString()
+ let limitText = ctx.Request.Query["limit"].ToString()
+
+ let limit =
+ if String.IsNullOrWhiteSpace limitText then
+ Ok 30
+ else
+ match Int32.TryParse(limitText, NumberStyles.Integer, invariant) with
+ | true, value when value >= 1 && value <= 250 -> Ok value
+ | _ -> Error "limit must be an integer between 1 and 250"
+
+ match limit with
+ | Error message -> marketDataError (InvalidMarketDataRequest message) next ctx
+ | Ok value ->
+ match probe.RecentNavSeries(code, value, ctx.RequestAborted) with
+ | Ok points ->
+ json
+ ({ code = code.Trim()
+ points = points |> List.map marketNavSeriesPointResponse }
+ : MarketNavSeriesApiResponse)
+ next
+ ctx
+ | Error failure -> marketDataError failure next ctx
+
+ let private marketProbeRoutes (probes: MarketProbes) =
+ [
+ GET >=> route "/market/nav-dates" >=> getMarketNavDates probes.NavDates
+ GET >=> route "/market/nav-series" >=> getMarketNavSeries probes.NavSeries
+ ]
let private createApplicationInternal
(repository: FundRepository)
(marketData: IMarketDataService option)
- (navDateProbe: INavDateProbe option)
+ (probes: MarketProbes option)
: HttpHandler =
let apiRoutes =
[
@@ -1687,7 +1744,7 @@ module App =
GET >=> routef "/funds/%s" (getFund repository)
]
@ (marketData |> Option.map marketDataRoutes |> Option.defaultValue [])
- @ (navDateProbe |> Option.map navDateProbeRoutes |> Option.defaultValue [])
+ @ (probes |> Option.map marketProbeRoutes |> Option.defaultValue [])
choose [
GET >=> route "/health" >=> health
@@ -1701,15 +1758,15 @@ module App =
let createApplicationWithMarketData (repository: FundRepository) (marketData: IMarketDataService) : HttpHandler =
createApplicationInternal repository (Some marketData) None
- let createApplicationWithNavDateProbe (repository: FundRepository) (probe: INavDateProbe) : HttpHandler =
- createApplicationInternal repository None (Some probe)
+ let createApplicationWithProbes (repository: FundRepository) (probes: MarketProbes) : HttpHandler =
+ createApplicationInternal repository None (Some probes)
- let createApplicationWithMarketDataAndProbe
+ let createApplicationWithMarketDataAndProbes
(repository: FundRepository)
(marketData: IMarketDataService)
- (probe: INavDateProbe)
+ (probes: MarketProbes)
: HttpHandler =
- createApplicationInternal repository (Some marketData) (Some probe)
+ createApplicationInternal repository (Some marketData) (Some probes)
let createApplication (repository: FundRepository) : HttpHandler =
createApplicationInternal repository None None
diff --git a/src/FundLab.Api/FundLab.Api.fsproj b/src/FundLab.Api/FundLab.Api.fsproj
index ed2c637..11df882 100644
--- a/src/FundLab.Api/FundLab.Api.fsproj
+++ b/src/FundLab.Api/FundLab.Api.fsproj
@@ -19,6 +19,7 @@
<Compile Include="Persistence.fs" />
<Compile Include="MarketDataService.fs" />
<Compile Include="NavDateProbe.fs" />
+ <Compile Include="NavSeriesProbe.fs" />
<Compile Include="App.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
diff --git a/src/FundLab.Api/NavSeriesProbe.fs b/src/FundLab.Api/NavSeriesProbe.fs
new file mode 100644
index 0000000..7c7a437
--- /dev/null
+++ b/src/FundLab.Api/NavSeriesProbe.fs
@@ -0,0 +1,76 @@
+namespace FundLab.Api
+
+open System
+open System.Collections.Concurrent
+open System.Globalization
+open System.Threading
+
+/// One point of a fund's real net-value series.
+type NavSeriesPoint =
+ {
+ NavDate: DateOnly
+ Nav: decimal
+ AccumulatedNav: decimal option
+ }
+
+/// Read-only probe that returns the most recent NAV points for a fund code.
+/// Like INavDateProbe there is no synthetic fallback: a failure is surfaced as a
+/// MarketDataFailure so the API answers 503 with the underlying reason.
+type INavSeriesProbe =
+ abstract RecentNavSeries: code: string * limit: int * CancellationToken -> Result<NavSeriesPoint list, MarketDataFailure>
+
+/// AKShare-backed series probe. Successful lookups are memoised per code and
+/// calendar day (the F# equivalent of functools cache keyed on code + today), so
+/// the UI can re-read the curve during one day without re-spawning the collector.
+/// Failures are never cached.
+type AkshareNavSeriesProbe(collector: IMarketDataCollector) =
+ let cache = ConcurrentDictionary<string, NavSeriesPoint list>(StringComparer.Ordinal)
+
+ let isFundCode (value: string) =
+ not (String.IsNullOrWhiteSpace value)
+ && value.Length = 6
+ && value |> Seq.forall Char.IsDigit
+
+ let clip limit =
+ if limit < 1 then 1
+ elif limit > 250 then 250
+ else limit
+
+ let todayText () =
+ (ConfirmationPolicy.eventDateFor DateTimeOffset.UtcNow).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
+
+ member _.Clear() = cache.Clear()
+
+ interface INavSeriesProbe with
+ member _.RecentNavSeries(code, limit, token) =
+ let normalized = if isNull code then "" else code.Trim()
+
+ if not (isFundCode normalized) then
+ Error(InvalidMarketDataRequest "fund code must contain exactly six digits")
+ else
+ let bounded = clip limit
+ let cacheKey = sprintf "%s|%s" normalized (todayText ())
+
+ match cache.TryGetValue cacheKey with
+ | true, cached -> Ok(cached |> List.truncate bounded)
+ | _ ->
+ match collector.FetchNav(normalized, token) with
+ | Error message -> Error(MarketDataCollectorUnavailable message)
+ | Ok json ->
+ match MarketData.parseNavPayload json with
+ | Error message -> Error(InvalidMarketDataPayload message)
+ | Ok payload ->
+ let points =
+ payload.Observations
+ |> List.filter (fun observation -> observation.Nav > 0m)
+ |> List.map (fun observation ->
+ {
+ NavDate = observation.NavDate
+ Nav = observation.Nav
+ AccumulatedNav = observation.AccumulatedNav
+ })
+ |> List.distinctBy (fun point -> point.NavDate)
+ |> List.sortByDescending (fun point -> point.NavDate)
+
+ cache.[cacheKey] <- points
+ Ok(points |> List.truncate bounded)
diff --git a/src/FundLab.Api/Program.fs b/src/FundLab.Api/Program.fs
index ba5936e..fa58219 100644
--- a/src/FundLab.Api/Program.fs
+++ b/src/FundLab.Api/Program.fs
@@ -20,8 +20,13 @@ let main argv =
let collector = ProcessMarketDataCollector.FromEnvironment() :> IMarketDataCollector
let marketData = MarketDataService(repository, collector) :> IMarketDataService
let navDateProbe = AkshareNavDateProbe(collector) :> INavDateProbe
+ let navSeriesProbe = AkshareNavSeriesProbe(collector) :> INavSeriesProbe
+
+ let probes =
+ { NavDates = navDateProbe
+ NavSeries = navSeriesProbe }
let app = builder.Build()
- app.UseGiraffe(App.createApplicationWithMarketDataAndProbe repository marketData navDateProbe)
+ app.UseGiraffe(App.createApplicationWithMarketDataAndProbes repository marketData probes)
app.Run()
0
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index 44130de..1552e15 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -379,6 +379,19 @@ type RawMarketNavDates =
dates: string array
}
+type RawMarketNavSeriesPoint =
+ {
+ navDate: string
+ nav: string
+ accumulatedNav: obj
+ }
+
+type RawMarketNavSeries =
+ {
+ code: string
+ points: RawMarketNavSeriesPoint array
+ }
+
type CreateAttempt =
{
idempotencyKey: string
@@ -672,6 +685,13 @@ type FundReturns =
points: ReturnsPoint list
}
+type MarketNavSeriesPoint =
+ {
+ navDate: string
+ nav: string
+ accumulatedNav: string option
+ }
+
module Api =
[<Import("searchInstruments", "./src/api.js")>]
let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative
@@ -718,6 +738,9 @@ module Api =
[<Import("getMarketNavDates", "./src/api.js")>]
let getMarketNavDates (token: string) (code: string) : JS.Promise<RawMarketNavDates> = jsNative
+ [<Import("getMarketNavSeries", "./src/api.js")>]
+ let getMarketNavSeries (token: string) (code: string) : JS.Promise<RawMarketNavSeries> = jsNative
+
[<Import("createCapitalDeposit", "./src/api.js")>]
let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative
@@ -983,6 +1006,13 @@ module Api =
points = raw.points |> Array.map decodeReturnsPoint |> Array.toList
}
+ let decodeNavSeriesPoint (raw: RawMarketNavSeriesPoint) : MarketNavSeriesPoint =
+ {
+ navDate = raw.navDate
+ nav = raw.nav
+ accumulatedNav = decodeOptionalText raw.accumulatedNav
+ }
+
type Model =
{
token: string
@@ -1062,6 +1092,10 @@ type Model =
navDatesInFlight: bool
navDates: string list
navDatesAvailable: bool
+ navSeriesReadSeq: int
+ navSeriesInFlight: bool
+ navSeries: MarketNavSeriesPoint list
+ navSeriesAvailable: bool
returnsSelectedDate: string option
planReadSeq: int
planInFlight: bool
@@ -1159,6 +1193,9 @@ type Msg =
| NavDatesReadRequested
| NavDatesReadCompleted of requestId: int * dates: RawMarketNavDates
| NavDatesReadFailed of requestId: int * message: string
+ | NavSeriesReadRequested
+ | NavSeriesReadCompleted of requestId: int * series: RawMarketNavSeries
+ | NavSeriesReadFailed of requestId: int * message: string
| ReturnsDateChanged of string
| InvestmentPlansReadRequested
| InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array
@@ -1274,6 +1311,10 @@ let init () =
navDatesInFlight = false
navDates = []
navDatesAvailable = false
+ navSeriesReadSeq = 0
+ navSeriesInFlight = false
+ navSeries = []
+ navSeriesAvailable = false
returnsSelectedDate = None
planReadSeq = 0
planInFlight = false
@@ -1451,6 +1492,13 @@ let private readNavDatesCommand token code requestId =
(fun dates -> NavDatesReadCompleted(requestId, dates))
(fun error -> NavDatesReadFailed(requestId, errorText error))
+let private readNavSeriesCommand token code requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.getMarketNavSeries token code)
+ ()
+ (fun series -> NavSeriesReadCompleted(requestId, series))
+ (fun error -> NavSeriesReadFailed(requestId, errorText error))
+
let private readInvestmentPlansCommand token fundId requestId =
Cmd.OfPromise.either
(fun () -> Api.getInvestmentPlans token fundId)
@@ -1546,6 +1594,10 @@ let update message model =
navDatesInFlight = false
navDates = []
navDatesAvailable = false
+ navSeriesReadSeq = model.navSeriesReadSeq + 1
+ navSeriesInFlight = false
+ navSeries = []
+ navSeriesAvailable = false
returnsSelectedDate = None
planReadSeq = model.planReadSeq + 1
planInFlight = false
@@ -1760,6 +1812,10 @@ let update message model =
navDatesInFlight = false
navDates = []
navDatesAvailable = false
+ navSeriesReadSeq = model.navSeriesReadSeq + 1
+ navSeriesInFlight = false
+ navSeries = []
+ navSeriesAvailable = false
returnsSelectedDate = None
planReadSeq = model.planReadSeq + 1
planInFlight = false
@@ -2539,7 +2595,12 @@ let update message model =
returnsInFlight = true
error = None
},
- Cmd.batch [ readReturnsCommand model.token fund.id requestId; Cmd.ofMsg NavDatesReadRequested ]
+ Cmd.batch
+ [
+ readReturnsCommand model.token fund.id requestId
+ Cmd.ofMsg NavDatesReadRequested
+ Cmd.ofMsg NavSeriesReadRequested
+ ]
| Some _ -> { model with error = Some "请输入 API token" }, Cmd.none
| None -> model, Cmd.none
| ReturnsReadCompleted (requestId, returns) ->
@@ -2611,6 +2672,59 @@ let update message model =
Cmd.none
else
model, Cmd.none
+ | NavSeriesReadRequested ->
+ match model.createdFund with
+ | Some _ when String.IsNullOrWhiteSpace model.token ->
+ { model with error = Some "请输入 API token" }, Cmd.none
+ | Some _ ->
+ let heldCode =
+ model.positions
+ |> Option.bind (fun positions -> positions.positions |> List.tryHead)
+ |> Option.map (fun position -> position.instrumentCode)
+
+ match heldCode with
+ | Some code ->
+ let requestId = model.navSeriesReadSeq + 1
+
+ {
+ model with
+ navSeriesReadSeq = requestId
+ navSeriesInFlight = true
+ },
+ readNavSeriesCommand model.token code requestId
+ | None ->
+ {
+ model with
+ navSeries = []
+ navSeriesAvailable = false
+ navSeriesInFlight = false
+ },
+ Cmd.none
+ | None -> model, Cmd.none
+ | NavSeriesReadCompleted (requestId, series) ->
+ if requestId = model.navSeriesReadSeq then
+ let resolved = series.points |> Array.toList |> List.map Api.decodeNavSeriesPoint
+
+ {
+ model with
+ navSeries = resolved
+ navSeriesAvailable = not (List.isEmpty resolved)
+ navSeriesInFlight = false
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | NavSeriesReadFailed (requestId, _) ->
+ if requestId = model.navSeriesReadSeq then
+ {
+ model with
+ navSeries = []
+ navSeriesAvailable = false
+ navSeriesInFlight = false
+ },
+ Cmd.none
+ else
+ model, Cmd.none
| ReturnsDateChanged value ->
{ model with returnsSelectedDate = (if String.IsNullOrWhiteSpace value then None else Some value) }, Cmd.none
| InvestmentPlansReadRequested ->
@@ -3983,6 +4097,90 @@ let private returnsChartView (points: ReturnsPoint list) =
]
]
+let private marketNavSeriesView (points: MarketNavSeriesPoint list) =
+ let ordered = points |> List.sortBy (fun point -> point.navDate)
+ let dates = ordered |> List.map (fun point -> point.navDate)
+ let navSeries = ordered |> List.map (fun point -> point.navDate, point.nav)
+ let navPoints = Chart.seriesPoints dates navSeries
+
+ let empty =
+ Html.div [
+ prop.className "chart-empty returns-nav-series-empty"
+ prop.hidden (not (List.isEmpty navPoints))
+ prop.text "暂无真实净值数据"
+ ]
+
+ let chart =
+ if List.isEmpty navPoints then
+ Html.none
+ 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 (series: SeriesPoint list) =
+ series
+ |> 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
+ ])
+
+ Html.div [
+ prop.className "chart-wrap returns-nav-series-chart"
+ prop.children [
+ Svg.svg [
+ svg.className "nav-chart"
+ svg.viewBox (0, 0, 960, 320)
+ svg.children [
+ yield! gridLines
+ yield
+ Svg.polyline [
+ svg.points (coordinates navPoints)
+ svg.fill "none"
+ svg.stroke "#f59e0b"
+ svg.strokeWidth 3
+ ]
+ yield!
+ (coordinates navPoints
+ |> List.map (fun (x, y) ->
+ Svg.circle [
+ svg.className "nav-series-marker"
+ svg.cx x
+ svg.cy y
+ svg.r 3.5
+ svg.fill "#f59e0b"
+ ]))
+ ]
+ ]
+ Html.div [
+ prop.className "chart-axis"
+ prop.children [
+ Html.span [ prop.text (ordered |> List.head |> fun point -> point.navDate) ]
+ Html.span [ prop.text (ordered |> List.last |> fun point -> point.navDate) ]
+ ]
+ ]
+ ]
+ ]
+
+ Html.div [
+ prop.className "returns-nav-series"
+ prop.custom ("data-nav-series-points", string (List.length navPoints))
+ prop.children [ empty; chart ]
+ ]
+
let private returnsPanel model dispatch =
let visiblePoints =
match model.returns with
@@ -4083,6 +4281,11 @@ let private returnsPanel model dispatch =
prop.className "returns-note"
prop.text "蓝线 总资产 / 橙线 单位净值(各自归一化);缺少净值的数据点不连线,也不补零。"
]
+ Html.p [
+ prop.className "returns-subheading"
+ prop.text "持仓基金真实净值序列(AKShare 多日)"
+ ]
+ marketNavSeriesView model.navSeries
if returns.pending then
Html.p [
prop.className "returns-pending"
diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js
index 80ded41..c94a9e0 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -131,6 +131,10 @@ export function getMarketNavDates(token, code) {
return requestJson(`/api/market/nav-dates?code=${encodeURIComponent(code)}`, token);
}
+export function getMarketNavSeries(token, code) {
+ return requestJson(`/api/market/nav-series?code=${encodeURIComponent(code)}&limit=30`, token);
+}
+
export function createSipPlan(token, fundId, payload) {
const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"amount":${JSON.stringify(payload.amount)},"frequency":${JSON.stringify(payload.frequency)}}`;
return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token, {
diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css
index 4cdf165..b994a80 100644
--- a/src/FundLab.Web/src/styles.css
+++ b/src/FundLab.Web/src/styles.css
@@ -566,6 +566,18 @@ h2 {
font-size: 12px;
}
+.returns-subheading {
+ margin: 14px 0 6px;
+ font-size: 12px;
+ font-weight: 600;
+ color: #334155;
+}
+
+.returns-nav-series-empty {
+ color: #b45309;
+ font-size: 12px;
+}
+
.returns-pending {
color: #b45309;
font-size: 12px;