summaryrefslogtreecommitdiff
path: root/src/FundLab.Web
diff options
context:
space:
mode:
Diffstat (limited to 'src/FundLab.Web')
-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
6 files changed, 1142 insertions, 15 deletions
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
+ }
+ }
}
});