feat(FEAT-MO-005): 展示市场结构与成交额趋势

This commit is contained in:
2026-08-25 02:33:21 +08:00
parent b95b9e5c92
commit 64a9f2e866
10 changed files with 610 additions and 10 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **a-share-analysis** (3793 symbols, 5473 relationships, 115 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. This project is indexed by GitNexus as **a-share-analysis** (4210 symbols, 6274 relationships, 147 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
@@ -1,4 +1,4 @@
import { BadRequestException, Controller, Get, Query } from '@nestjs/common'; import { BadRequestException, Controller, Get, Inject, Query } from '@nestjs/common';
import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger'; import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger';
import { import {
@@ -16,7 +16,7 @@ import {
@ApiTags('market-overview-structure') @ApiTags('market-overview-structure')
@Controller('market-overview') @Controller('market-overview')
export class MarketStructureController { export class MarketStructureController {
constructor(private readonly service: MarketStructureService) {} constructor(@Inject(MarketStructureService) private readonly service: MarketStructureService) {}
@Get('sector-rankings') @Get('sector-rankings')
@ApiQuery({ name: 'category', required: false, enum: ['industry', 'concept', 'board'] }) @ApiQuery({ name: 'category', required: false, enum: ['industry', 'concept', 'board'] })
+137 -1
View File
@@ -542,6 +542,10 @@ textarea {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.overview-grid-structure {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.overview-panel { .overview-panel {
min-height: 190px; min-height: 190px;
padding: 18px; padding: 18px;
@@ -556,6 +560,125 @@ textarea {
white-space: nowrap; white-space: nowrap;
} }
.structure-panel {
min-width: 0;
}
.structure-tabs,
.structure-sort {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 10px;
}
.structure-tabs button,
.structure-sort button {
min-height: 28px;
padding: 4px 9px;
color: #62717d;
background: #f2f5f6;
border: 1px solid #dfe6e9;
border-radius: 4px;
cursor: pointer;
font: inherit;
font-size: 11px;
}
.structure-tabs button.active,
.structure-sort button.active {
color: #11686c;
background: #e8f5f3;
border-color: #a9d3cf;
font-weight: 700;
}
.structure-list {
display: grid;
gap: 5px;
}
.structure-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 31px;
padding: 5px 7px;
color: #3e4d58;
background: #f7f9fa;
border: 1px solid transparent;
border-radius: 3px;
cursor: pointer;
font: inherit;
text-align: left;
}
.structure-row:hover {
border-color: #b9d7d4;
background: #f0f7f6;
}
.structure-row-static {
cursor: default;
}
.structure-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.structure-value {
color: #c33f3f;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.structure-value[data-direction='down'] {
color: #19846e;
}
.structure-row small {
max-width: 84px;
overflow: hidden;
color: #87939c;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.turnover-summary {
display: flex;
align-items: baseline;
gap: 7px;
margin-top: 12px;
}
.turnover-summary strong {
color: #1c2a35;
font-size: 25px;
}
.turnover-summary span {
color: #7d8893;
font-size: 12px;
}
.turnover-chart {
display: block;
width: 100%;
height: 150px;
margin-top: 13px;
overflow: visible;
background: #f7f9fa;
border: 1px solid #e3e9ec;
border-radius: 4px;
}
.quiet-button { .quiet-button {
padding: 6px 9px; padding: 6px 9px;
color: #167d82; color: #167d82;
@@ -838,7 +961,8 @@ textarea {
@media (max-width: 900px) { @media (max-width: 900px) {
.overview-grid-top, .overview-grid-top,
.overview-grid-bottom { .overview-grid-bottom,
.overview-grid-structure {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -896,6 +1020,18 @@ textarea {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.structure-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.structure-row small {
display: none;
}
.turnover-chart {
height: 130px;
}
.driver-column + .driver-column { .driver-column + .driver-column {
padding-top: 14px; padding-top: 14px;
padding-left: 0; padding-left: 0;
@@ -9,6 +9,9 @@ import SectorOverview from './components/SectorOverview.vue';
import FundTrend from './components/FundTrend.vue'; import FundTrend from './components/FundTrend.vue';
import IndexOverview from './components/IndexOverview.vue'; import IndexOverview from './components/IndexOverview.vue';
import ShortTermEmotion from './components/ShortTermEmotion.vue'; import ShortTermEmotion from './components/ShortTermEmotion.vue';
import SectorRankingPanel from './components/SectorRankingPanel.vue';
import CapitalFlowPanel from './components/CapitalFlowPanel.vue';
import TurnoverTrendPanel from './components/TurnoverTrendPanel.vue';
import { useMarketOverview } from './useMarketOverview'; import { useMarketOverview } from './useMarketOverview';
const { const {
@@ -17,12 +20,19 @@ const {
breadth, breadth,
sectors, sectors,
funds, funds,
sectorRankings,
capitalFlow,
turnoverTrend,
shortTermEmotion, shortTermEmotion,
shortTermConclusion, shortTermConclusion,
sectorCategory,
capitalFlowCategory,
isLoading, isLoading,
analysisHorizon, analysisHorizon,
analysisEngine, analysisEngine,
refresh, refresh,
refreshSectorRankings,
refreshCapitalFlow,
setAnalysisHorizon, setAnalysisHorizon,
setAnalysisEngine, setAnalysisEngine,
} = useMarketOverview(); } = useMarketOverview();
@@ -99,6 +109,26 @@ function openSectorDetail(code: string) {
@select="openSectorDetail" @select="openSectorDetail"
/> />
<FundTrend :response="funds.response" :loading="funds.status === 'loading'" /> <FundTrend :response="funds.response" :loading="funds.status === 'loading'" />
</div>
<div class="overview-grid overview-grid-structure">
<SectorRankingPanel
:response="sectorRankings.response"
:loading="sectorRankings.status === 'loading'"
:category="sectorCategory"
@category-change="refreshSectorRankings"
@select="openSectorDetail"
/>
<CapitalFlowPanel
:response="capitalFlow.response"
:loading="capitalFlow.status === 'loading'"
:category="capitalFlowCategory"
@category-change="refreshCapitalFlow"
/>
<TurnoverTrendPanel
:response="turnoverTrend.response"
:loading="turnoverTrend.status === 'loading'"
/>
<ShortTermEmotion <ShortTermEmotion
:response="shortTermEmotion.response" :response="shortTermEmotion.response"
:loading="shortTermEmotion.status === 'loading'" :loading="shortTermEmotion.status === 'loading'"
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { CapitalFlowResponse } from '@/types/market-overview';
type Category = 'industry' | 'concept' | 'board';
type Sort = 'inflow' | 'outflow';
const props = defineProps<{
response: CapitalFlowResponse | null;
loading: boolean;
category: Category;
}>();
const emit = defineEmits<{ 'category-change': [category: Category] }>();
const sort = ref<Sort>('inflow');
const categoryLabels: Record<Category, string> = {
industry: '行业',
concept: '题材',
board: '板块',
};
const items = computed(() => {
const source = props.response?.data?.items ?? [];
return [...source].sort((left, right) => {
const leftValue = left.netInflow ?? Number.NEGATIVE_INFINITY;
const rightValue = right.netInflow ?? Number.NEGATIVE_INFINITY;
return sort.value === 'inflow' ? rightValue - leftValue : leftValue - rightValue;
});
});
function formatFlow(value: number | null | undefined): string {
return value == null ? '未提供' : `${value > 0 ? '+' : ''}${value.toFixed(2)} 亿`;
}
</script>
<template>
<section class="overview-panel structure-panel" aria-labelledby="capital-flow-title">
<div class="panel-heading">
<div>
<span class="section-kicker">资金方向</span>
<h2 id="capital-flow-title">{{ categoryLabels[category] }}资金流向</h2>
</div>
<span class="panel-meta">净额口径</span>
</div>
<div class="structure-tabs" role="tablist" aria-label="资金分类">
<button
v-for="(label, value) in categoryLabels"
:key="value"
type="button"
:data-category="value"
:class="{ active: category === value }"
@click="emit('category-change', value as Category)"
>
{{ label }}
</button>
</div>
<div class="structure-sort" role="group" aria-label="资金排序">
<button type="button" :class="{ active: sort === 'inflow' }" @click="sort = 'inflow'">
净流入
</button>
<button type="button" :class="{ active: sort === 'outflow' }" @click="sort = 'outflow'">
净流出
</button>
</div>
<div v-if="loading" class="module-placeholder">正在读取资金流向…</div>
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
<strong>暂不可用</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<template v-else-if="items.length">
<div v-if="response?.status !== 'fresh'" class="module-warning">
<strong>{{ response?.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
<span>{{ response?.warnings[0] }}</span>
</div>
<div class="structure-list">
<div
v-for="item in items"
:key="item.code ?? item.name"
class="structure-row structure-row-static"
>
<span class="structure-name">{{ item.name }}</span>
<strong
class="structure-value"
:data-direction="(item.netInflow ?? 0) >= 0 ? 'up' : 'down'"
>
{{ formatFlow(item.netInflow) }}
</strong>
<small>{{
item.flowDefinition === 'unavailable' ? '口径未提供' : item.flowDefinition
}}</small>
</div>
</div>
</template>
<div v-else class="module-placeholder">暂无资金流向数据</div>
</section>
</template>
@@ -0,0 +1,114 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import CapitalFlowPanel from './CapitalFlowPanel.vue';
import SectorRankingPanel from './SectorRankingPanel.vue';
import TurnoverTrendPanel from './TurnoverTrendPanel.vue';
const meta = {
snapshotId: '20260825-103000',
asOf: '2026-08-25T10:30:00+08:00',
source: 'aktools' as const,
status: 'fresh' as const,
warnings: [],
};
describe('市场结构首屏面板', () => {
it('展示行业排行并支持类别切换和详情事件', async () => {
const wrapper = mount(SectorRankingPanel, {
props: {
loading: false,
category: 'industry' as const,
response: {
...meta,
data: {
category: 'industry' as const,
sort: 'rise' as const,
items: [
{
category: 'industry' as const,
code: 'BK1',
name: '半导体',
changePercent: 3.2,
turnoverAmount: 12.3,
advancingCount: 20,
decliningCount: 2,
leader: '测试股份',
tradeDate: '20260825',
},
],
},
},
},
});
expect(wrapper.text()).toContain('半导体');
expect(wrapper.text()).toContain('+3.20%');
await wrapper.get('[data-category="concept"]').trigger('click');
expect(wrapper.emitted('category-change')).toEqual([['concept']]);
await wrapper.get('[data-code="BK1"]').trigger('click');
expect(wrapper.emitted('select')).toEqual([['BK1']]);
});
it('资金流向缺失时显示 partial 警告且不显示零净流入', () => {
const wrapper = mount(CapitalFlowPanel, {
props: {
loading: false,
category: 'industry' as const,
response: {
...meta,
status: 'partial' as const,
warnings: ['部分资金流向字段不可用'],
data: {
category: 'industry' as const,
sort: 'inflow' as const,
items: [
{
category: 'industry' as const,
code: 'BK1',
name: '未知',
netInflow: null,
netOutflow: null,
turnoverAmount: 1,
tradeDate: null,
flowDefinition: 'unavailable',
},
],
},
},
},
});
expect(wrapper.text()).toContain('部分数据');
expect(wrapper.text()).toContain('部分资金流向字段不可用');
expect(wrapper.text()).toContain('未提供');
expect(wrapper.text()).not.toContain('0.00');
});
it('成交额趋势绘制非空 SVG 并展示单位', () => {
const wrapper = mount(TurnoverTrendPanel, {
props: {
loading: false,
response: {
...meta,
source: 'tdx' as const,
data: {
granularity: '5' as const,
unit: '亿元' as const,
marketCount: 3,
invalidPointCount: 0,
points: [
{ time: '2026-08-25 09:30:00', amount: 1, cumulativeAmount: 1 },
{ time: '2026-08-25 09:35:00', amount: 2, cumulativeAmount: 3 },
{ time: '2026-08-25 09:40:00', amount: 1.5, cumulativeAmount: 4.5 },
],
},
},
},
});
expect(wrapper.text()).toContain('亿元');
expect(wrapper.find('svg').exists()).toBe(true);
expect(wrapper.find('polyline').attributes('points')).not.toBe('');
});
});
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { SectorRankingsResponse } from '@/types/market-overview';
type Category = 'industry' | 'concept' | 'board';
type Sort = 'rise' | 'fall' | 'turnover';
const props = defineProps<{
response: SectorRankingsResponse | null;
loading: boolean;
category: Category;
}>();
const emit = defineEmits<{
select: [code: string];
'category-change': [category: Category];
}>();
const sort = ref<Sort>('rise');
const categoryLabels: Record<Category, string> = {
industry: '行业',
concept: '题材',
board: '板块',
};
const sortLabels: Record<Sort, string> = { rise: '涨幅', fall: '跌幅', turnover: '成交额' };
const items = computed(() => {
const source = props.response?.data?.items ?? [];
return [...source].sort((left, right) => {
const leftValue = sort.value === 'turnover' ? left.turnoverAmount : left.changePercent;
const rightValue = sort.value === 'turnover' ? right.turnoverAmount : right.changePercent;
const leftNumber = leftValue ?? Number.NEGATIVE_INFINITY;
const rightNumber = rightValue ?? Number.NEGATIVE_INFINITY;
const difference = sort.value === 'fall' ? leftNumber - rightNumber : rightNumber - leftNumber;
return difference || left.name.localeCompare(right.name, 'zh-CN');
});
});
function formatPercent(value: number | null | undefined): string {
return value == null ? '—' : `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
}
function formatAmount(value: number | null | undefined): string {
return value == null ? '—' : `${value.toFixed(2)} 亿`;
}
</script>
<template>
<section class="overview-panel structure-panel" aria-labelledby="sector-ranking-title">
<div class="panel-heading">
<div>
<span class="section-kicker">结构扫描</span>
<h2 id="sector-ranking-title">{{ categoryLabels[category] }}强弱排行</h2>
</div>
<span class="panel-meta">Top {{ response?.data?.items.length ?? 0 }}</span>
</div>
<div class="structure-tabs" role="tablist" aria-label="板块分类">
<button
v-for="(label, value) in categoryLabels"
:key="value"
type="button"
:data-category="value"
:class="{ active: category === value }"
@click="emit('category-change', value as Category)"
>
{{ label }}
</button>
</div>
<div class="structure-sort" role="group" aria-label="排行口径">
<button
v-for="(label, value) in sortLabels"
:key="value"
type="button"
:class="{ active: sort === value }"
@click="sort = value as Sort"
>
{{ label }}
</button>
</div>
<div v-if="loading" class="module-placeholder">正在读取{{ categoryLabels[category] }}排行…</div>
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
<strong>暂不可用</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<template v-else-if="items.length">
<div v-if="response?.status !== 'fresh'" class="module-warning">
<strong>{{ response?.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
<span>{{ response?.warnings[0] }}</span>
</div>
<div class="structure-list">
<button
v-for="item in items"
:key="item.code"
type="button"
class="structure-row"
:data-code="item.code"
:title="`进入${item.name}详细分析`"
@click="emit('select', item.code)"
>
<span class="structure-name">{{ item.name }}</span>
<span class="structure-value" data-direction="up">{{
formatPercent(item.changePercent)
}}</span>
<small>{{
sort === 'turnover'
? formatAmount(item.turnoverAmount)
: formatAmount(item.turnoverAmount)
}}</small>
</button>
</div>
</template>
<div v-else class="module-placeholder">暂无{{ categoryLabels[category] }}排行数据</div>
</section>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { TurnoverTrendResponse } from '@/types/market-overview';
const props = defineProps<{ response: TurnoverTrendResponse | null; loading: boolean }>();
const chartPoints = computed(() => {
const points = props.response?.data?.points ?? [];
if (!points.length) return '';
const max = Math.max(...points.map((point) => point.cumulativeAmount), 1);
const width = 620;
const height = 150;
return points
.map((point, index) => {
const x = points.length === 1 ? width / 2 : (index / (points.length - 1)) * width;
const y = height - (point.cumulativeAmount / max) * height;
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(' ');
});
const latest = computed(() => props.response?.data?.points.at(-1)?.cumulativeAmount ?? null);
</script>
<template>
<section
class="overview-panel structure-panel turnover-panel"
aria-labelledby="turnover-trend-title"
>
<div class="panel-heading">
<div>
<span class="section-kicker">量能观察</span>
<h2 id="turnover-trend-title">全天成交额趋势</h2>
</div>
<span class="panel-meta">{{ response?.data?.granularity ?? '—' }} 分钟</span>
</div>
<div v-if="loading" class="module-placeholder">正在读取成交额趋势…</div>
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
<strong>暂不可用</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<template v-else-if="response?.data?.points.length">
<div v-if="response.status !== 'fresh'" class="module-warning">
<strong>{{ response.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<div class="turnover-summary">
<strong>{{ latest?.toFixed(2) ?? '—' }}</strong>
<span>{{ response.data.unit }}累计成交额</span>
</div>
<svg class="turnover-chart" viewBox="0 0 640 180" role="img" aria-label="全天累计成交额趋势">
<polyline
:points="chartPoints"
fill="none"
stroke="#167d82"
stroke-width="3"
vector-effect="non-scaling-stroke"
/>
</svg>
</template>
<div v-else class="module-placeholder">暂无成交额趋势数据</div>
</section>
</template>
@@ -50,4 +50,28 @@ describe('市场概览状态', () => {
expect(second.analysisHorizon.value).toBe('短线'); expect(second.analysisHorizon.value).toBe('短线');
expect(second.analysisEngine.value).toBe('混合'); expect(second.analysisEngine.value).toBe('混合');
}); });
it('切换行业结构分类时重新请求对应数据', async () => {
const api = {
getSectorRankings: vi.fn().mockResolvedValue(response),
getCapitalFlow: vi.fn().mockResolvedValue(response),
};
const state = useMarketOverview(api as never);
await state.refreshSectorRankings('concept');
await state.refreshCapitalFlow('board');
expect(api.getSectorRankings).toHaveBeenCalledWith({
category: 'concept',
sort: 'rise',
limit: 10,
});
expect(api.getCapitalFlow).toHaveBeenCalledWith({
category: 'board',
sort: 'inflow',
limit: 10,
});
expect(state.sectorCategory.value).toBe('concept');
expect(state.capitalFlowCategory.value).toBe('board');
});
}); });
@@ -27,6 +27,8 @@ type MarketResponse =
| CapitalFlowResponse | CapitalFlowResponse
| TurnoverTrendResponse; | TurnoverTrendResponse;
export type MarketStructureCategory = 'industry' | 'concept' | 'board';
export interface OverviewModuleState<TResponse extends MarketResponse> { export interface OverviewModuleState<TResponse extends MarketResponse> {
status: OverviewModuleStatus; status: OverviewModuleStatus;
response: TResponse | null; response: TResponse | null;
@@ -63,6 +65,8 @@ export function useMarketOverview(
const sectorRankings = ref<OverviewModuleState<SectorRankingsResponse>>(createState()); const sectorRankings = ref<OverviewModuleState<SectorRankingsResponse>>(createState());
const capitalFlow = ref<OverviewModuleState<CapitalFlowResponse>>(createState()); const capitalFlow = ref<OverviewModuleState<CapitalFlowResponse>>(createState());
const turnoverTrend = ref<OverviewModuleState<TurnoverTrendResponse>>(createState()); const turnoverTrend = ref<OverviewModuleState<TurnoverTrendResponse>>(createState());
const sectorCategory = ref<MarketStructureCategory>('industry');
const capitalFlowCategory = ref<MarketStructureCategory>('industry');
const shortTermEmotion = ref<OverviewModuleState<ShortTermEmotionResponse>>(createState()); const shortTermEmotion = ref<OverviewModuleState<ShortTermEmotionResponse>>(createState());
const shortTermConclusion = ref<OverviewModuleState<ShortTermConclusionResponse>>(createState()); const shortTermConclusion = ref<OverviewModuleState<ShortTermConclusionResponse>>(createState());
const analysisHorizon = ref(readPreference('a-share-analysis:overview-horizon', '综合')); const analysisHorizon = ref(readPreference('a-share-analysis:overview-horizon', '综合'));
@@ -108,6 +112,22 @@ export function useMarketOverview(
} }
} }
async function refreshSectorRankings(category = sectorCategory.value): Promise<void> {
sectorCategory.value = category;
sectorRankings.value.status = 'loading';
await loadModule(sectorRankings, () =>
api.getSectorRankings({ category, sort: 'rise', limit: 10 }),
);
}
async function refreshCapitalFlow(category = capitalFlowCategory.value): Promise<void> {
capitalFlowCategory.value = category;
capitalFlow.value.status = 'loading';
await loadModule(capitalFlow, () =>
api.getCapitalFlow({ category, sort: 'inflow', limit: 10 }),
);
}
async function refresh(): Promise<void> { async function refresh(): Promise<void> {
setLoading(); setLoading();
await Promise.all([ await Promise.all([
@@ -116,12 +136,8 @@ export function useMarketOverview(
loadModule(breadth, api.getBreadth), loadModule(breadth, api.getBreadth),
loadModule(sectors, api.getSectors), loadModule(sectors, api.getSectors),
loadModule(funds, api.getFunds), loadModule(funds, api.getFunds),
loadModule(sectorRankings, () => refreshSectorRankings(),
api.getSectorRankings({ category: 'industry', sort: 'rise', limit: 10 }), refreshCapitalFlow(),
),
loadModule(capitalFlow, () =>
api.getCapitalFlow({ category: 'industry', sort: 'inflow', limit: 10 }),
),
loadModule(turnoverTrend, () => api.getTurnoverTrend({ interval: '5' })), loadModule(turnoverTrend, () => api.getTurnoverTrend({ interval: '5' })),
loadModule(shortTermEmotion, api.getShortTermEmotion), loadModule(shortTermEmotion, api.getShortTermEmotion),
loadModule(shortTermConclusion, api.getShortTermConclusion), loadModule(shortTermConclusion, api.getShortTermConclusion),
@@ -149,10 +165,14 @@ export function useMarketOverview(
turnoverTrend, turnoverTrend,
shortTermEmotion, shortTermEmotion,
shortTermConclusion, shortTermConclusion,
sectorCategory,
capitalFlowCategory,
isLoading, isLoading,
analysisHorizon, analysisHorizon,
analysisEngine, analysisEngine,
refresh, refresh,
refreshSectorRankings,
refreshCapitalFlow,
setAnalysisHorizon, setAnalysisHorizon,
setAnalysisEngine, setAnalysisEngine,
}; };