增加功能
continuous-integration/drone Build is failing

This commit is contained in:
曾志威
2026-05-12 00:02:27 +08:00
parent 4b1ffb4c6a
commit 4a5ae4cd6c
8 changed files with 184 additions and 115 deletions
+16 -12
View File
@@ -18,10 +18,11 @@ func NewDashboardHandler(svc *service.DashboardService) *DashboardHandler {
return &DashboardHandler{svc: svc}
}
// GetSummary 获取最新交易日的市场概览数据
// GET /api/dashboard/summary
// GetSummary 获取市场概览数据,支持可选 date 参数查询历史数据
// GET /api/dashboard/summary?date=2024-01-15
func (h *DashboardHandler) GetSummary(c *gin.Context) {
summary, err := h.svc.GetSummary()
date := c.Query("date") // 空字符串表示查最新交易日
summary, err := h.svc.GetSummary(date)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -29,14 +30,15 @@ func (h *DashboardHandler) GetSummary(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": summary})
}
// GetDailySummary 获取近 N 天的每日市场统计数据
// GET /api/dashboard/daily?days=30
// GetDailySummary 获取近 N 天的每日市场统计数据,支持可选 end_date 参数
// GET /api/dashboard/daily?days=30&end_date=2024-01-15
func (h *DashboardHandler) GetDailySummary(c *gin.Context) {
days, _ := strconv.Atoi(c.DefaultQuery("days", "30"))
if days <= 0 || days > 365 {
days = 30
}
data, err := h.svc.GetDailySummary(days)
endDate := c.Query("end_date")
data, err := h.svc.GetDailySummary(days, endDate)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -44,10 +46,11 @@ func (h *DashboardHandler) GetDailySummary(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": data})
}
// GetConsecutiveLimits 获取最新交易日的连板股票统计
// GET /api/dashboard/consecutive
// GetConsecutiveLimits 获取连板股票统计,支持可选 date 参数查询历史数据
// GET /api/dashboard/consecutive?date=2024-01-15
func (h *DashboardHandler) GetConsecutiveLimits(c *gin.Context) {
data, err := h.svc.GetConsecutiveLimits()
date := c.Query("date")
data, err := h.svc.GetConsecutiveLimits(date)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -55,10 +58,11 @@ func (h *DashboardHandler) GetConsecutiveLimits(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": data})
}
// GetHigh100 获取百日新高股票列表
// GET /api/dashboard/high100
// GetHigh100 获取百日新高股票列表,支持可选 date 参数查询历史数据
// GET /api/dashboard/high100?date=2024-01-15
func (h *DashboardHandler) GetHigh100(c *gin.Context) {
data, err := h.svc.GetHigh100()
date := c.Query("date")
data, err := h.svc.GetHigh100(date)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+40 -15
View File
@@ -128,9 +128,17 @@ func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, erro
// GetHigh100 获取最新交易日创百日新高的股票列表
func (r *StockRepo) GetHigh100() ([]model.StockRank, error) {
date, err := r.getLatestDate()
if err != nil {
return nil, err
return r.GetHigh100ByDate("")
}
// GetHigh100ByDate 获取指定日期创百日新高的股票列表,date 为空时使用最新交易日
func (r *StockRepo) GetHigh100ByDate(date string) ([]model.StockRank, error) {
var err error
if date == "" {
date, err = r.getLatestDate()
if err != nil {
return nil, err
}
}
var list []model.StockRank
r.db.Raw(`
@@ -154,10 +162,16 @@ func (r *StockRepo) GetHigh100() ([]model.StockRank, error) {
// GetDailySummary 获取近 N 天的每日市场统计
// 包含: 涨跌数量、成交额、涨停跌停数量、连板数量
func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) {
latest, err := r.getLatestDate()
if err != nil {
return nil, err
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
}
}
var result []model.DailySummary
@@ -175,10 +189,10 @@ func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) {
SUM(CASE WHEN pct_change >= 19.5 THEN 1 ELSE 0 END) as limit_up_20,
SUM(CASE WHEN pct_change <= -19.5 THEN 1 ELSE 0 END) as limit_down_20
FROM stock_daily
WHERE date >= DATE_SUB(?, INTERVAL ? DAY)
WHERE date >= DATE_SUB(?, INTERVAL ? DAY) AND date <= ?
GROUP BY date
ORDER BY date ASC
`, latest, days-1).Scan(&result)
`, latest, days-1, latest).Scan(&result)
// 计算连板数量: 获取相邻交易日对,批量查询当日和前一日都涨停的股票数量
type datePair struct {
@@ -188,10 +202,10 @@ func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) {
var pairs []datePair
r.db.Raw(`
SELECT d1.date AS cur_date, MAX(d2.date) AS prev_date
FROM (SELECT DISTINCT date FROM stock_daily WHERE date >= DATE_SUB(?, INTERVAL ? DAY)) d1
JOIN (SELECT DISTINCT date FROM stock_daily WHERE date >= DATE_SUB(?, INTERVAL ? DAY)) d2 ON d2.date < d1.date
FROM (SELECT DISTINCT date FROM stock_daily WHERE date >= DATE_SUB(?, INTERVAL ? DAY) AND date <= ?) d1
JOIN (SELECT DISTINCT date FROM stock_daily WHERE date >= DATE_SUB(?, INTERVAL ? DAY) AND date <= ?) d2 ON d2.date < d1.date
GROUP BY d1.date
`, latest, days-1, latest, days).Scan(&pairs)
`, latest, days-1, latest, latest, days, latest).Scan(&pairs)
type consecRow struct {
Date string `gorm:"column:date"`
@@ -229,9 +243,20 @@ func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) {
// GetConsecutiveLimits 获取最新交易日连板股票列表
// 查询近 30 天的涨停记录,计算当日涨停股票的连续涨停天数
func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
latest, err := r.getLatestDate()
if err != nil {
return nil, err
return r.GetConsecutiveLimitsByDate("")
}
// GetConsecutiveLimitsByDate 获取指定日期的连板股票列表,date 为空时使用最新交易日
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
}
}
if latest == "" {
return nil, nil
+28 -11
View File
@@ -35,8 +35,13 @@ func NewDashboardService(repo *repository.StockRepo) *DashboardService {
const cacheTTL = 3 * time.Minute
// GetSummary 获取最新交易日市场概览(涨跌数量、涨停跌停、百日新高等)
func (s *DashboardService) GetSummary() (*model.DashboardSummary, error) {
// GetSummary 获取市场概览,date 为空时查最新交易日(走缓存),指定日期时跳过缓存直接查询
func (s *DashboardService) GetSummary(date string) (*model.DashboardSummary, error) {
// 指定历史日期:直接查询,不走缓存
if date != "" {
return s.repo.GetSummaryByDate(date)
}
s.mu.RLock()
if s.summary != nil && time.Now().Before(s.summary.expires) {
data := s.summary.data
@@ -45,11 +50,11 @@ func (s *DashboardService) GetSummary() (*model.DashboardSummary, error) {
}
s.mu.RUnlock()
date, err := s.repo.GetLatestTradeDate()
latestDate, err := s.repo.GetLatestTradeDate()
if err != nil {
return nil, err
}
summary, err := s.repo.GetSummaryByDate(date)
summary, err := s.repo.GetSummaryByDate(latestDate)
if err != nil {
return nil, err
}
@@ -61,8 +66,12 @@ func (s *DashboardService) GetSummary() (*model.DashboardSummary, error) {
return summary, nil
}
// GetDailySummary 获取近 N 天每日市场统计
func (s *DashboardService) GetDailySummary(days int) ([]model.DailySummary, error) {
// GetDailySummary 获取近 N 天每日市场统计,endDate 为空时查到最新交易日(走缓存)
func (s *DashboardService) GetDailySummary(days int, endDate string) ([]model.DailySummary, error) {
if endDate != "" {
return s.repo.GetDailySummary(days, endDate)
}
s.mu.RLock()
if e, ok := s.daily[days]; ok && time.Now().Before(e.expires) {
data := e.data
@@ -71,7 +80,7 @@ func (s *DashboardService) GetDailySummary(days int) ([]model.DailySummary, erro
}
s.mu.RUnlock()
data, err := s.repo.GetDailySummary(days)
data, err := s.repo.GetDailySummary(days, "")
if err != nil {
return nil, err
}
@@ -83,8 +92,12 @@ func (s *DashboardService) GetDailySummary(days int) ([]model.DailySummary, erro
return data, nil
}
// GetConsecutiveLimits 获取最新交易日连板股票(连续多日涨停)
func (s *DashboardService) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
// GetConsecutiveLimits 获取连板股票,date 为空时查最新交易日(走缓存),指定日期时跳过缓存
func (s *DashboardService) GetConsecutiveLimits(date string) ([]model.ConsecutiveLimit, error) {
if date != "" {
return s.repo.GetConsecutiveLimitsByDate(date)
}
s.mu.RLock()
if s.consec != nil && time.Now().Before(s.consec.expires) {
data := s.consec.data
@@ -105,8 +118,12 @@ func (s *DashboardService) GetConsecutiveLimits() ([]model.ConsecutiveLimit, err
return data, nil
}
// GetHigh100 获取最新交易日创百日新高的股票列表
func (s *DashboardService) GetHigh100() ([]model.StockRank, error) {
// GetHigh100 获取百日新高股票,date 为空时查最新交易日(走缓存),指定日期时跳过缓存
func (s *DashboardService) GetHigh100(date string) ([]model.StockRank, error) {
if date != "" {
return s.repo.GetHigh100ByDate(date)
}
s.mu.RLock()
if s.high100 != nil && time.Now().Before(s.high100.expires) {
data := s.high100.data
+12 -12
View File
@@ -45,15 +45,15 @@ export interface DailySummary {
consecutive_limit_count: number; // 连板股票数量
}
/** 获取市场概览数据 */
export async function getDashboardSummary(): Promise<DashboardSummary> {
const res = await api.get('/dashboard/summary');
/** 获取市场概览数据date 为空时返回最新交易日数据 */
export async function getDashboardSummary(date?: string): Promise<DashboardSummary> {
const res = await api.get('/dashboard/summary', { params: date ? { date } : undefined });
return res.data.data;
}
/** 获取近 N 天每日市场统计 */
export async function getDailySummary(days = 30): Promise<DailySummary[]> {
const res = await api.get('/dashboard/daily', { params: { days } });
/** 获取近 N 天每日市场统计,endDate 为空时截止到最新交易日 */
export async function getDailySummary(days = 30, endDate?: string): Promise<DailySummary[]> {
const res = await api.get('/dashboard/daily', { params: { days, ...(endDate ? { end_date: endDate } : {}) } });
return res.data.data;
}
@@ -66,15 +66,15 @@ export interface ConsecutiveLimit {
pct_change: number;
}
/** 获取连板股票统计 */
export async function getConsecutiveLimits(): Promise<ConsecutiveLimit[]> {
const res = await api.get('/dashboard/consecutive');
/** 获取连板股票统计date 为空时返回最新交易日数据 */
export async function getConsecutiveLimits(date?: string): Promise<ConsecutiveLimit[]> {
const res = await api.get('/dashboard/consecutive', { params: date ? { date } : undefined });
return res.data.data;
}
/** 获取百日新高股票列表 */
export async function getHigh100(): Promise<StockRank[]> {
const res = await api.get('/dashboard/high100');
/** 获取百日新高股票列表date 为空时返回最新交易日数据 */
export async function getHigh100(date?: string): Promise<StockRank[]> {
const res = await api.get('/dashboard/high100', { params: date ? { date } : undefined });
return res.data.data;
}
+21 -17
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Card, Spin, Table, Tag, Typography } from 'antd';
import { getConsecutiveLimits, type ConsecutiveLimit } from '../api/dashboard';
import DateNav from '../components/DateNav';
const { Title } = Typography;
@@ -39,20 +40,14 @@ const columns = [
export default function ConsecutivePage() {
const [data, setData] = useState<ConsecutiveLimit[]>([]);
const [loading, setLoading] = useState(true);
const [date, setDate] = useState<string | undefined>(undefined);
useEffect(() => {
getConsecutiveLimits()
.then(setData)
setLoading(true);
getConsecutiveLimits(date)
.then((res) => setData(res || []))
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}>
<Spin size="large" />
</div>
);
}
}, [date]);
// Group by streak
const grouped = data.reduce<Record<number, ConsecutiveLimit[]>>((acc, item) => {
@@ -65,14 +60,23 @@ export default function ConsecutivePage() {
return (
<div style={{ width: '100%' }}>
<Card style={{ marginBottom: 16, textAlign: 'center' }}>
<Title level={4} style={{ margin: 0 }}>
<span style={{ color: '#cf1322' }}>{data.length}</span>
<span style={{ color: '#722ed1', fontSize: 28 }}>{maxStreak}</span>
</Title>
<Card style={{ marginBottom: 16 }} size="small">
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<Spin size="large" />
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 24 }}>
<Title level={4} style={{ margin: 0 }}>
<span style={{ color: '#cf1322' }}>{data.length}</span>
<span style={{ color: '#722ed1', fontSize: 28 }}>{maxStreak}</span>
</Title>
<DateNav value={date} onChange={setDate} />
</div>
)}
</Card>
{Object.keys(grouped)
{!loading && Object.keys(grouped)
.sort((a, b) => Number(b) - Number(a))
.map((streak) => (
<Card
+13 -5
View File
@@ -5,6 +5,7 @@ import {
type DashboardSummary,
} from '../api/dashboard';
import StatCards from '../components/StatCard';
import DateNav from '../components/DateNav';
const columns = [
{ title: '代码', dataIndex: 'code', key: 'code', width: 80 },
@@ -35,12 +36,14 @@ const columns = [
export default function Dashboard() {
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [loading, setLoading] = useState(true);
const [date, setDate] = useState<string | undefined>(undefined);
useEffect(() => {
getDashboardSummary()
.then(setSummary)
setLoading(true);
getDashboardSummary(date)
.then((res) => setSummary(res || null))
.finally(() => setLoading(false));
}, []);
}, [date]);
if (loading) {
return (
@@ -54,9 +57,14 @@ export default function Dashboard() {
return (
<div style={{ width: '100%' }}>
<StatCards data={summary} />
<Card style={{ marginBottom: 16 }} size="small">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<DateNav value={date} onChange={setDate} />
</div>
<StatCards data={summary} />
</Card>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginTop: 24 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<Card title={`涨停 (${summary.limit_up_list.length}只)`} size="small">
<Table
columns={columns}
+35 -29
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Card, Spin, Table, Tag, Typography } from 'antd';
import { getHigh100, type StockRank } from '../api/dashboard';
import DateNav from '../components/DateNav';
const { Title } = Typography;
@@ -35,45 +36,50 @@ const columns = [
export default function High100Page() {
const [data, setData] = useState<StockRank[]>([]);
const [loading, setLoading] = useState(true);
const [date, setDate] = useState<string | undefined>(undefined);
useEffect(() => {
getHigh100()
.then(setData)
setLoading(true);
getHigh100(date)
.then((res) => setData(res || []))
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}>
<Spin size="large" />
</div>
);
}
}, [date]);
const limitUp = data.filter((s) => s.pct_change >= 9.5).length;
return (
<div style={{ width: '100%' }}>
<Card style={{ marginBottom: 16, textAlign: 'center' }}>
<Title level={4} style={{ margin: 0 }}>
150 <span style={{ color: '#cf1322', fontSize: 28 }}>{data.length}</span>
{limitUp > 0 && (
<span style={{ color: '#722ed1', marginLeft: 16 }}>
{limitUp}
</span>
)}
</Title>
<Card style={{ marginBottom: 16 }} size="small">
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<Spin size="large" />
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 24 }}>
<Title level={4} style={{ margin: 0 }}>
150 <span style={{ color: '#cf1322', fontSize: 28 }}>{data.length}</span>
{limitUp > 0 && (
<span style={{ color: '#722ed1', marginLeft: 16 }}>
{limitUp}
</span>
)}
</Title>
<DateNav value={date} onChange={setDate} />
</div>
)}
</Card>
<Card size="small">
<Table
columns={columns}
dataSource={data}
rowKey="code"
pagination={{ pageSize: 100, showSizeChanger: false, size: 'small' }}
size="small"
/>
</Card>
{!loading && (
<Card size="small">
<Table
columns={columns}
dataSource={data}
rowKey="code"
pagination={{ pageSize: 100, showSizeChanger: false, size: 'small' }}
size="small"
/>
</Card>
)}
</div>
);
}
+19 -14
View File
@@ -2,35 +2,40 @@ import { useEffect, useState } from 'react';
import { Card, Segmented, Spin } from 'antd';
import { getDailySummary, type DailySummary } from '../api/dashboard';
import DailyChart from '../components/DailyChart';
import DateNav from '../components/DateNav';
export default function TrendPage() {
const [daily, setDaily] = useState<DailySummary[]>([]);
const [loading, setLoading] = useState(true);
const [days, setDays] = useState<number>(30);
const [endDate, setEndDate] = useState<string | undefined>(undefined);
useEffect(() => {
setLoading(true);
getDailySummary(days)
.then(setDaily)
getDailySummary(days, endDate)
.then((res) => setDaily(res || []))
.finally(() => setLoading(false));
}, [days]);
}, [days, endDate]);
return (
<div style={{ width: '100%' }}>
<Card
size="small"
extra={
<Segmented
value={days}
onChange={(v) => setDays(v as number)}
options={[
{ label: '7天', value: 7 },
{ label: '30天', value: 30 },
{ label: '90天', value: 90 },
{ label: '180天', value: 180 },
]}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<DateNav value={endDate} onChange={setEndDate} />
<Segmented
value={days}
onChange={(v) => setDays(v as number)}
options={[
{ label: '7天', value: 7 },
{ label: '30天', value: 30 },
{ label: '90天', value: 90 },
{ label: '180天', value: 180 },
]}
/>
</div>
}
>
{loading ? (