diff --git a/server/repository/stock_repo.go b/server/repository/stock_repo.go index 8c88643..670ae10 100644 --- a/server/repository/stock_repo.go +++ b/server/repository/stock_repo.go @@ -49,6 +49,23 @@ func (r *StockRepo) getLatestDate() (string, error) { return result.MaxDate, nil } +// resolveTradeDate 将用户指定日期映射到不晚于该日期的最近交易日。 +// 当用户选中非交易日时,使用最近一个有数据的交易日,避免页面空白。 +func (r *StockRepo) resolveTradeDate(date string) (string, error) { + if date == "" { + return r.getLatestDate() + } + + var result struct { + ResolvedDate string + } + err := r.db.Raw("SELECT MAX(date) as resolved_date FROM stock_daily WHERE date <= ?", date).Scan(&result).Error + if err != nil { + return "", err + } + return result.ResolvedDate, nil +} + // GetLatestTradeDate 获取最新交易日期(公开方法) func (r *StockRepo) GetLatestTradeDate() (string, error) { return r.getLatestDate() @@ -58,6 +75,19 @@ func (r *StockRepo) GetLatestTradeDate() (string, error) { // 包括: 涨跌统计、涨停跌停列表、百日新高列表 // 使用单条聚合查询替代多次 COUNT 查询,提升性能 func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, error) { + var err error + date, err = r.resolveTradeDate(date) + if err != nil { + return nil, err + } + if date == "" { + return &model.DashboardSummary{ + LimitUpList: []model.StockRank{}, + LimitDownList: []model.StockRank{}, + High100List: []model.StockRank{}, + }, nil + } + summary := &model.DashboardSummary{ LimitUpList: []model.StockRank{}, LimitDownList: []model.StockRank{}, @@ -138,11 +168,12 @@ func (r *StockRepo) GetHigh100() ([]model.StockRank, error) { // GetHigh100ByDate 获取指定日期创百日新高的股票列表,date 为空时使用最新交易日 func (r *StockRepo) GetHigh100ByDate(date string) ([]model.StockRank, error) { var err error + date, err = r.resolveTradeDate(date) + if err != nil { + return nil, err + } if date == "" { - date, err = r.getLatestDate() - if err != nil { - return nil, err - } + return []model.StockRank{}, nil } var list []model.StockRank r.db.Raw(` @@ -172,13 +203,12 @@ func (r *StockRepo) GetHigh100ByDate(date string) ([]model.StockRank, error) { func (r *StockRepo) GetDailySummary(days int, endDate string) ([]model.DailySummary, error) { var latest string var err error - if endDate != "" { - latest = endDate - } else { - latest, err = r.getLatestDate() - if err != nil { - return nil, err - } + latest, err = r.resolveTradeDate(endDate) + if err != nil { + return nil, err + } + if latest == "" { + return []model.DailySummary{}, nil } var result []model.DailySummary @@ -260,13 +290,9 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) { func (r *StockRepo) GetConsecutiveLimitsByDate(date string) ([]model.ConsecutiveLimit, error) { var latest string var err error - if date != "" { - latest = date - } else { - latest, err = r.getLatestDate() - if err != nil { - return nil, err - } + latest, err = r.resolveTradeDate(date) + if err != nil { + return nil, err } if latest == "" { return []model.ConsecutiveLimit{}, nil diff --git a/web/src/components/DailyChart.tsx b/web/src/components/DailyChart.tsx index 0bdd60f..1329d22 100644 --- a/web/src/components/DailyChart.tsx +++ b/web/src/components/DailyChart.tsx @@ -6,8 +6,8 @@ interface Props { data: DailySummary[]; } -function fmtYi(v: number) { - return +(v / 1e8).toFixed(0); +function fmtPct(v: number) { + return +v.toFixed(2); } export default function DailyChart({ data }: Props) { @@ -23,6 +23,24 @@ export default function DailyChart({ data }: Props) { const chart = chartRef.current; const dates = data.map((d) => d.date.slice(0, 10)); + const turnoverYi = data.map((d) => d.total_turnover / 1e8); + const up10Max = Math.max(...data.map((d) => d.limit_up_10), 0); + const down10Max = Math.max(...data.map((d) => d.limit_down_10), 0); + const tenAxisLimit = Math.max(up10Max, down10Max, 1); + const up20Max = Math.max(...data.map((d) => d.limit_up_20), 0); + const down20Max = Math.max(...data.map((d) => d.limit_down_20), 0); + const twentyAxisLimit = Math.max(up20Max, down20Max, 1); + const turnoverChangePct = turnoverYi.map((value, index) => { + if (index === 0) return 0; + const prev = turnoverYi[index - 1]; + if (!prev) return 0; + return ((value - prev) / prev) * 100; + }); + const turnoverMin = Math.min(...turnoverYi); + const turnoverMax = Math.max(...turnoverYi); + const turnoverPad = Math.max((turnoverMax - turnoverMin) * 0.15, turnoverMax * 0.08, 1); + const turnoverAxisMin = Math.max(0, Math.floor((turnoverMin - turnoverPad) * 10) / 10); + const turnoverAxisMax = Math.ceil((turnoverMax + turnoverPad) * 10) / 10; chart.setOption({ tooltip: { @@ -31,7 +49,7 @@ export default function DailyChart({ data }: Props) { }, legend: { top: 0, - data: ['上涨数', '下跌数', '成交额(亿)', '涨停10%', '跌停10%', '涨停20%', '跌停20%', '连板家数'], + data: ['上涨数', '下跌数', '成交额(亿)', '成交额日环比(%)', '涨停10%', '跌停10%', '涨停20%', '跌停20%', '连板家数'], }, grid: [ { left: 60, right: 70, top: 60, height: '25%' }, @@ -43,9 +61,45 @@ export default function DailyChart({ data }: Props) { ], yAxis: [ { type: 'value', name: '股票数', gridIndex: 0 }, - { type: 'value', name: '亿', gridIndex: 0, position: 'right', splitLine: { show: false } }, - { type: 'value', name: '10%家', gridIndex: 1, position: 'left' }, - { type: 'value', name: '20%家', gridIndex: 1, position: 'right', splitLine: { show: false } }, + { + type: 'value', + name: '亿', + gridIndex: 0, + position: 'right', + scale: true, + min: turnoverAxisMin, + max: turnoverAxisMax, + splitNumber: 4, + splitLine: { show: false }, + axisLabel: { + formatter: (value: number) => value.toFixed(1), + }, + }, + { + type: 'value', + name: '%', + gridIndex: 0, + position: 'right', + offset: 70, + splitLine: { show: false }, + }, + { + type: 'value', + name: '10%家', + gridIndex: 1, + position: 'left', + min: 0, + max: tenAxisLimit, + }, + { + type: 'value', + name: '20%家', + gridIndex: 1, + position: 'right', + min: 0, + max: twentyAxisLimit, + splitLine: { show: false }, + }, { type: 'value', name: '连板', gridIndex: 1, position: 'right', offset: 60, splitLine: { show: false } }, ], dataZoom: [ @@ -77,17 +131,30 @@ export default function DailyChart({ data }: Props) { type: 'line', xAxisIndex: 0, yAxisIndex: 1, - data: data.map((d) => fmtYi(d.total_turnover)), + data: turnoverYi.map((v) => +v.toFixed(0)), itemStyle: { color: 'rgba(114,46,209,0.6)' }, lineStyle: { width: 2 }, areaStyle: { color: 'rgba(114,46,209,0.15)' }, symbol: 'none', }, + { + name: '成交额日环比(%)', + type: 'line', + xAxisIndex: 0, + yAxisIndex: 2, + data: turnoverChangePct.map((v) => fmtPct(v)), + itemStyle: { color: '#13c2c2' }, + lineStyle: { width: 2, type: 'dashed' }, + symbol: 'circle', + symbolSize: 4, + smooth: true, + areaStyle: { color: 'rgba(19,194,194,0.08)' }, + }, { name: '涨停10%', type: 'line', xAxisIndex: 1, - yAxisIndex: 2, + yAxisIndex: 3, data: data.map((d) => d.limit_up_10), itemStyle: { color: '#ff4d4f' }, lineStyle: { width: 2 }, @@ -98,8 +165,8 @@ export default function DailyChart({ data }: Props) { name: '跌停10%', type: 'line', xAxisIndex: 1, - yAxisIndex: 2, - data: data.map((d) => -d.limit_down_10), + yAxisIndex: 3, + data: data.map((d) => Math.abs(d.limit_down_10)), itemStyle: { color: '#52c41a' }, lineStyle: { width: 2 }, symbol: 'circle', @@ -109,7 +176,7 @@ export default function DailyChart({ data }: Props) { name: '涨停20%', type: 'line', xAxisIndex: 1, - yAxisIndex: 3, + yAxisIndex: 4, data: data.map((d) => d.limit_up_20), itemStyle: { color: '#ff7875' }, lineStyle: { width: 2, type: 'dashed' }, @@ -120,8 +187,8 @@ export default function DailyChart({ data }: Props) { name: '跌停20%', type: 'line', xAxisIndex: 1, - yAxisIndex: 3, - data: data.map((d) => -d.limit_down_20), + yAxisIndex: 4, + data: data.map((d) => Math.abs(d.limit_down_20)), itemStyle: { color: '#95de64' }, lineStyle: { width: 2, type: 'dashed' }, symbol: 'circle', @@ -131,7 +198,7 @@ export default function DailyChart({ data }: Props) { name: '连板家数', type: 'line', xAxisIndex: 1, - yAxisIndex: 4, + yAxisIndex: 5, data: data.map((d) => d.consecutive_limit_count), itemStyle: { color: '#fa8c16' }, lineStyle: { width: 3 },