feat(FEAT-DS-001): 接入AKTools超短行情补充数据源

This commit is contained in:
2026-08-24 21:40:22 +08:00
parent 81c48af8cc
commit 6e8a05114e
42 changed files with 1449 additions and 36 deletions
+124
View File
@@ -84,6 +84,38 @@ export interface paths {
patch?: never;
trace?: never;
};
'/api/v1/market-overview/short-term/conclusion': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['ShortTermMarketController_getConclusion'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/market-overview/short-term/emotion': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['ShortTermMarketController_getEmotion'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/market-overview/summary': {
parameters: {
query?: never;
@@ -207,6 +239,60 @@ export interface components {
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
};
ShortTermConclusionDataDto: {
drivers: string[];
risks: string[];
verdict: string;
};
ShortTermConclusionResponseDto: {
/** Format: date-time */
asOf: string;
data: components['schemas']['ShortTermConclusionDataDto'] | null;
snapshotId: string;
/** @enum {string} */
source: 'akshare';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
/** @example 20260824 */
tradeDate: string;
warnings: string[];
};
ShortTermEmotionDataDto: {
brokenBoardCount?: number | null;
industries: components['schemas']['ShortTermIndustryDto'][];
limitUpCount?: number | null;
maxBoard?: number | null;
previousAverageChangePercent?: number | null;
previousDownCount?: number | null;
previousFlatCount?: number | null;
previousUpCount?: number | null;
/** @description 封板资金,亿元 */
sealFundsAmount?: number | null;
tiers: components['schemas']['ShortTermTierDto'][];
/** @example 20260824 */
tradeDate: string;
};
ShortTermEmotionResponseDto: {
/** Format: date-time */
asOf: string;
data: components['schemas']['ShortTermEmotionDataDto'] | null;
snapshotId: string;
/** @enum {string} */
source: 'akshare';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
/** @example 20260824 */
tradeDate: string;
warnings: string[];
};
ShortTermIndustryDto: {
count: number;
name: string;
};
ShortTermTierDto: {
board: number;
count: number;
};
SummaryDataDto: {
drivers: string[];
marketStatus: string;
@@ -338,6 +424,44 @@ export interface operations {
};
};
};
ShortTermMarketController_getConclusion: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['ShortTermConclusionResponseDto'];
};
};
};
};
ShortTermMarketController_getEmotion: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['ShortTermEmotionResponseDto'];
};
};
};
};
MarketOverviewController_getSummary: {
parameters: {
query?: never;
@@ -17,6 +17,8 @@ describe('市场概览业务 API', () => {
await api.getBreadth();
await api.getSectors();
await api.getFunds();
await api.getShortTermEmotion();
await api.getShortTermConclusion();
expect(requestMock.mock.calls.map(([config]) => config.url)).toEqual([
'/api/v1/market-overview/summary',
@@ -24,6 +26,8 @@ describe('市场概览业务 API', () => {
'/api/v1/market-overview/breadth',
'/api/v1/market-overview/sectors',
'/api/v1/market-overview/funds',
'/api/v1/market-overview/short-term/emotion',
'/api/v1/market-overview/short-term/conclusion',
]);
});
});
+12
View File
@@ -4,6 +4,8 @@ import type {
FundsResponse,
IndicesResponse,
SectorsResponse,
ShortTermConclusionResponse,
ShortTermEmotionResponse,
SummaryResponse,
} from '@/types/market-overview';
@@ -19,6 +21,16 @@ export function createMarketOverviewApi(requestClient: RequestClient = request)
requestClient<SectorsResponse>({ method: 'get', url: '/api/v1/market-overview/sectors' }),
getFunds: () =>
requestClient<FundsResponse>({ method: 'get', url: '/api/v1/market-overview/funds' }),
getShortTermEmotion: () =>
requestClient<ShortTermEmotionResponse>({
method: 'get',
url: '/api/v1/market-overview/short-term/emotion',
}),
getShortTermConclusion: () =>
requestClient<ShortTermConclusionResponse>({
method: 'get',
url: '/api/v1/market-overview/short-term/conclusion',
}),
};
}
+80
View File
@@ -536,6 +536,86 @@ textarea {
font-size: 12px;
}
.short-term-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-top: 4px;
}
.short-term-metrics div {
display: grid;
gap: 5px;
padding: 9px 10px;
background: #f5f8f9;
border: 1px solid #e3e9ec;
border-radius: 4px;
}
.short-term-metrics span,
.short-term-secondary span,
.short-term-industries .column-label {
color: #7b8792;
font-size: 11px;
}
.short-term-metrics strong {
color: #1c2a35;
font-size: 20px;
}
.short-term-secondary {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-top: 15px;
padding-bottom: 12px;
border-bottom: 1px solid #e6eaee;
}
.short-term-secondary strong {
color: #c33f3f;
font-size: 17px;
}
.short-term-industries {
margin-top: 12px;
}
.short-term-industries .column-label {
display: block;
margin-bottom: 7px;
font-weight: 700;
}
.short-term-industries div {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.short-term-industries div span {
padding: 4px 7px;
color: #3f5e68;
background: #eef6f5;
border-radius: 3px;
font-size: 11px;
}
.short-term-conclusion {
display: grid;
gap: 6px;
margin-top: 12px;
padding-top: 11px;
border-top: 1px solid #e6eaee;
}
.short-term-conclusion strong {
color: #167d82;
font-size: 14px;
}
@media (max-width: 900px) {
.overview-grid-top,
.overview-grid-bottom {
+4
View File
@@ -5,10 +5,14 @@ export type IndicesResponse = components['schemas']['IndicesResponseDto'];
export type BreadthResponse = components['schemas']['BreadthResponseDto'];
export type SectorsResponse = components['schemas']['SectorsResponseDto'];
export type FundsResponse = components['schemas']['FundsResponseDto'];
export type ShortTermEmotionResponse = components['schemas']['ShortTermEmotionResponseDto'];
export type ShortTermConclusionResponse = components['schemas']['ShortTermConclusionResponseDto'];
export type SummaryData = NonNullable<SummaryResponse['data']>;
export type IndicesData = NonNullable<IndicesResponse['data']>;
export type BreadthData = NonNullable<BreadthResponse['data']>;
export type SectorsData = NonNullable<SectorsResponse['data']>;
export type FundsData = NonNullable<FundsResponse['data']>;
export type ShortTermEmotionData = NonNullable<ShortTermEmotionResponse['data']>;
export type ShortTermConclusionData = NonNullable<ShortTermConclusionResponse['data']>;
export type OverviewModuleStatus = 'idle' | 'loading' | 'ready' | 'error';
@@ -7,6 +7,7 @@ import MarketDrivers from './components/MarketDrivers.vue';
import SectorOverview from './components/SectorOverview.vue';
import FundTrend from './components/FundTrend.vue';
import IndexOverview from './components/IndexOverview.vue';
import ShortTermEmotion from './components/ShortTermEmotion.vue';
import { useMarketOverview } from './useMarketOverview';
const {
@@ -15,6 +16,8 @@ const {
breadth,
sectors,
funds,
shortTermEmotion,
shortTermConclusion,
isLoading,
analysisHorizon,
analysisEngine,
@@ -91,6 +94,11 @@ function openDetail(label: string) {
@select="openDetail"
/>
<FundTrend :response="funds.response" :loading="funds.status === 'loading'" />
<ShortTermEmotion
:response="shortTermEmotion.response"
:loading="shortTermEmotion.status === 'loading'"
:conclusion="shortTermConclusion.response"
/>
</div>
</div>
</template>
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
import FundTrend from './FundTrend.vue';
import MarketBreadth from './MarketBreadth.vue';
import SectorOverview from './SectorOverview.vue';
import ShortTermEmotion from './ShortTermEmotion.vue';
const meta = {
snapshotId: '20260818-150000',
@@ -152,4 +153,66 @@ describe('市场概览真实数据面板', () => {
expect(wrapper.text()).toContain('2026-08-18');
expect(wrapper.get('[data-direction="flat"]').attributes('data-direction')).toBe('flat');
});
it('短线情绪面板展示连板、炸板和昨日涨停表现', () => {
const wrapper = mount(ShortTermEmotion, {
props: {
loading: false,
response: {
...meta,
source: 'akshare' as const,
tradeDate: '20260818',
data: {
tradeDate: '20260818',
limitUpCount: 46,
maxBoard: 5,
tiers: [{ board: 5, count: 1 }],
brokenBoardCount: 16,
previousAverageChangePercent: 1.25,
previousUpCount: 30,
previousDownCount: 20,
previousFlatCount: 4,
sealFundsAmount: 12.34,
industries: [{ name: '软件服务', count: 8 }],
},
},
conclusion: {
...meta,
source: 'akshare' as const,
tradeDate: '20260818',
data: {
verdict: '超短情绪偏强',
drivers: ['涨停 46 家'],
risks: ['炸板 16 家'],
},
},
},
});
expect(wrapper.text()).toContain('涨停 46');
expect(wrapper.text()).toContain('最高 5 板');
expect(wrapper.text()).toContain('炸板 16');
expect(wrapper.text()).toContain('昨日涨停平均 +1.25%');
expect(wrapper.text()).toContain('软件服务');
expect(wrapper.text()).toContain('超短情绪偏强');
});
it('短线情绪模块不可用时显示来源状态和警告', () => {
const wrapper = mount(ShortTermEmotion, {
props: {
loading: false,
response: {
...meta,
source: 'akshare' as const,
tradeDate: '20260818',
status: 'unavailable' as const,
warnings: ['涨停池数据不可用'],
data: null,
},
},
});
expect(wrapper.text()).toContain('AKShare 数据暂不可用');
expect(wrapper.text()).toContain('涨停池数据不可用');
});
});
@@ -0,0 +1,77 @@
<script setup lang="ts">
import type {
ShortTermConclusionResponse,
ShortTermEmotionResponse,
} from '@/types/market-overview';
defineProps<{
response: ShortTermEmotionResponse | null;
loading: boolean;
conclusion?: ShortTermConclusionResponse | null;
}>();
function formatTradeDate(value: string | undefined): string {
return value?.length === 8 ? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6)}` : '—';
}
function formatPercent(value: number | null | undefined): string {
if (value === null || value === undefined) return '—';
return `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
}
</script>
<template>
<section class="overview-panel short-term-emotion" aria-labelledby="short-term-emotion-title">
<div class="panel-heading">
<div>
<span class="section-kicker">超短情绪</span>
<h2 id="short-term-emotion-title">涨停与连板</h2>
</div>
<span class="panel-meta">{{ formatTradeDate(response?.data?.tradeDate) }}</span>
</div>
<div v-if="loading" class="module-placeholder">正在读取 AKShare 情绪数据…</div>
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
<strong>AKShare 数据暂不可用</strong>
<span>{{ response.warnings[0] || '请确认 AKTools 服务已启动' }}</span>
</div>
<template v-else-if="response?.data">
<div v-if="response.status !== 'fresh'" class="module-warning">
<strong>{{ response.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<div class="short-term-metrics">
<div>
<span>涨停 {{ response.data.limitUpCount ?? '—' }}</span>
<strong aria-hidden="true"> {{ response.data.limitUpCount ?? '—' }}</strong>
</div>
<div>
<span>最高 {{ response.data.maxBoard ?? '—' }} 板</span>
<strong aria-hidden="true"> {{ response.data.maxBoard ?? '—' }} 板</strong>
</div>
<div>
<span>炸板 {{ response.data.brokenBoardCount ?? '—' }}</span>
<strong aria-hidden="true"> {{ response.data.brokenBoardCount ?? '—' }}</strong>
</div>
</div>
<div class="short-term-secondary">
<strong
>昨日涨停平均 {{ formatPercent(response.data.previousAverageChangePercent) }}</strong
>
</div>
<div class="short-term-industries">
<span class="column-label">涨停行业</span>
<div>
<span v-for="industry in response.data.industries.slice(0, 3)" :key="industry.name">
{{ industry.name }} {{ industry.count }}
</span>
<span v-if="!response.data.industries.length">—</span>
</div>
</div>
<div v-if="conclusion?.data" class="short-term-conclusion">
<span class="column-label">规则结论</span>
<strong>{{ conclusion.data.verdict }}</strong>
</div>
</template>
<div v-else class="module-placeholder">暂无超短情绪数据</div>
</section>
</template>
@@ -19,6 +19,8 @@ describe('市场概览状态', () => {
getBreadth: vi.fn().mockResolvedValue(response),
getSectors: vi.fn().mockResolvedValue(response),
getFunds: vi.fn().mockResolvedValue(response),
getShortTermEmotion: vi.fn().mockResolvedValue(response),
getShortTermConclusion: vi.fn().mockResolvedValue(response),
};
const state = useMarketOverview(api);
@@ -28,6 +30,8 @@ describe('市场概览状态', () => {
expect(state.indices.value.status).toBe('error');
expect(state.breadth.value.status).toBe('ready');
expect(state.isLoading.value).toBe(false);
expect(state.shortTermEmotion.value.status).toBe('ready');
expect(state.shortTermConclusion.value.status).toBe('ready');
});
it('分析偏好写入本地存储并可恢复', () => {
@@ -7,11 +7,19 @@ import type {
IndicesResponse,
OverviewModuleStatus,
SectorsResponse,
ShortTermConclusionResponse,
ShortTermEmotionResponse,
SummaryResponse,
} from '@/types/market-overview';
type MarketResponse =
SummaryResponse | IndicesResponse | BreadthResponse | SectorsResponse | FundsResponse;
| SummaryResponse
| IndicesResponse
| BreadthResponse
| SectorsResponse
| FundsResponse
| ShortTermEmotionResponse
| ShortTermConclusionResponse;
export interface OverviewModuleState<TResponse extends MarketResponse> {
status: OverviewModuleStatus;
@@ -46,10 +54,20 @@ export function useMarketOverview(
const breadth = ref<OverviewModuleState<BreadthResponse>>(createState());
const sectors = ref<OverviewModuleState<SectorsResponse>>(createState());
const funds = ref<OverviewModuleState<FundsResponse>>(createState());
const shortTermEmotion = ref<OverviewModuleState<ShortTermEmotionResponse>>(createState());
const shortTermConclusion = ref<OverviewModuleState<ShortTermConclusionResponse>>(createState());
const analysisHorizon = ref(readPreference('a-share-analysis:overview-horizon', '综合'));
const analysisEngine = ref(readPreference('a-share-analysis:overview-engine', '规则'));
const isLoading = computed(() =>
[summary.value, indices.value, breadth.value, sectors.value, funds.value].some(
[
summary.value,
indices.value,
breadth.value,
sectors.value,
funds.value,
shortTermEmotion.value,
shortTermConclusion.value,
].some(
(module) => module.status === 'loading',
),
);
@@ -60,6 +78,8 @@ export function useMarketOverview(
breadth.value.status = 'loading';
sectors.value.status = 'loading';
funds.value.status = 'loading';
shortTermEmotion.value.status = 'loading';
shortTermConclusion.value.status = 'loading';
}
async function loadModule<TResponse extends MarketResponse>(
@@ -83,6 +103,8 @@ export function useMarketOverview(
loadModule(breadth, api.getBreadth),
loadModule(sectors, api.getSectors),
loadModule(funds, api.getFunds),
loadModule(shortTermEmotion, api.getShortTermEmotion),
loadModule(shortTermConclusion, api.getShortTermConclusion),
]);
}
@@ -102,6 +124,8 @@ export function useMarketOverview(
breadth,
sectors,
funds,
shortTermEmotion,
shortTermConclusion,
isLoading,
analysisHorizon,
analysisEngine,