Skip to content

Commit

Permalink
Feature/#23 EPS, PER, 시가총액 등 주식 상세 정보 기능 추가 (#156)
Browse files Browse the repository at this point in the history
  • Loading branch information
demian-m00n authored Nov 14, 2024
2 parents e5a3de4 + 3ad766b commit 225309b
Show file tree
Hide file tree
Showing 8 changed files with 231 additions and 10 deletions.
41 changes: 41 additions & 0 deletions packages/backend/src/stock/domain/stockDetail.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {
Column,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Stock } from './stock.entity';

@Entity('stock_detail')
export class StockDetail {
@PrimaryGeneratedColumn()
id: number;

@OneToOne(() => Stock)
@JoinColumn({ name: 'stock_id' })
stock: Stock;

@Column({
type: 'decimal',
precision: 20,
scale: 2,
})
marketCap: number;

@Column({ type: 'integer' })
eps: number;

@Column({ type: 'decimal', precision: 6, scale: 3 })
per: number;

@Column({ type: 'integer' })
high52w: number;

@Column({ type: 'integer' })
low52w: number;

@UpdateDateColumn({ type: 'timestamp', name: 'updated_at' })
updatedAt: Date;
}
10 changes: 6 additions & 4 deletions packages/backend/src/stock/domain/stockLiveData.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Column,
OneToOne,
JoinColumn,
UpdateDateColumn,
} from 'typeorm';
import { Stock } from './stock.entity';

Expand All @@ -13,10 +14,10 @@ export class StockLiveData {
id: number;

@Column({ type: 'decimal', precision: 15, scale: 2 })
current_price: number;
currentPrice: number;

@Column({ type: 'decimal', precision: 5, scale: 2 })
change_rate: number;
changeRate: number;

@Column()
volume: number;
Expand All @@ -31,10 +32,11 @@ export class StockLiveData {
open: number;

@Column({ type: 'decimal', precision: 15, scale: 2 })
previous_close: number;
previousClose: number;

@UpdateDateColumn()
@Column({ type: 'timestamp' })
updated_at: Date;
updatedAt: Date;

@OneToOne(() => Stock)
@JoinColumn({ name: 'stock_id' })
Expand Down
33 changes: 33 additions & 0 deletions packages/backend/src/stock/dto/stockDetail.response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';

export class StockDetailResponse {
@ApiProperty({
description: '주식의 시가 총액',
example: 352510000000000,
})
marketCap: number;

@ApiProperty({
description: '주식의 EPS',
example: 4091,
})
eps: number;

@ApiProperty({
description: '주식의 PER',
example: 17.51,
})
per: number;

@ApiProperty({
description: '주식의 52주 최고가',
example: 88000,
})
high52w: number;

@ApiProperty({
description: '주식의 52주 최저가',
example: 53000,
})
low52w: number;
}
35 changes: 32 additions & 3 deletions packages/backend/src/stock/stock.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { Body, Controller, Delete, HttpCode, Post } from '@nestjs/common';
import { ApiOkResponse, ApiOperation } from '@nestjs/swagger';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Post,
} from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiParam } from '@nestjs/swagger';
import { StockDetailResponse } from './dto/stockDetail.response';
import { StockService } from './stock.service';
import { StockDetailService } from './stockDetail.service';
import { StockViewsResponse } from '@/stock/dto/stock.Response';
import { StockViewRequest } from '@/stock/dto/stockView.request';
import {
Expand All @@ -11,7 +21,10 @@ import { UserStockResponse } from '@/stock/dto/userStock.response';

@Controller('stock')
export class StockController {
constructor(private readonly stockService: StockService) {}
constructor(
private readonly stockService: StockService,
private readonly stockDetailService: StockDetailService,
) {}

@HttpCode(200)
@Post('/view')
Expand Down Expand Up @@ -80,4 +93,20 @@ export class StockController {
'사용자 소유 주식을 삭제했습니다.',
);
}

@ApiOperation({
summary: '주식 상세 정보 조회 API',
description: '시가 총액, EPS, PER, 52주 최고가, 52주 최저가를 조회합니다',
})
@ApiOkResponse({
description: '주식 상세 정보 조회 성공',
type: StockDetailResponse,
})
@ApiParam({ name: 'stockId', required: true, description: '주식 ID' })
@Get(':stockId/detail')
async getStockDetail(
@Param('stockId') stockId: string,
): Promise<StockDetailResponse> {
return await this.stockDetailService.getStockDetailByStockId(stockId);
}
}
8 changes: 7 additions & 1 deletion packages/backend/src/stock/stock.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ import { Stock } from './domain/stock.entity';
import { StockController } from './stock.controller';
import { StockGateway } from './stock.gateway';
import { StockService } from './stock.service';
import { StockDetailService } from './stockDetail.service';
import { StockLiveDataSubscriber } from './stockLiveData.subscriber';

@Module({
imports: [TypeOrmModule.forFeature([Stock])],
controllers: [StockController],
providers: [StockService, StockGateway, StockLiveDataSubscriber],
providers: [
StockService,
StockGateway,
StockLiveDataSubscriber,
StockDetailService,
],
})
export class StockModule {}
75 changes: 75 additions & 0 deletions packages/backend/src/stock/stockDetail.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Logger } from 'winston';
import { StockDetail } from './domain/stockDetail.entity';
import { StockDetailService } from './stockDetail.service';
import { createDataSourceMock } from '@/user/user.service.spec';

const logger: Logger = {
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
} as unknown as Logger;

describe('StockDetailService 테스트', () => {
const stockId = 'A005930';

test('stockId로 주식 상세 정보를 조회한다.', async () => {
const mockStockDetail = {
stock: { id: stockId },
marketCap: 352510000000000,
eps: 4091,
per: 17.51,
high52w: 88000,
low52w: 53000,
};
const managerMock = {
existsBy: jest.fn().mockResolvedValue(true),
findBy: jest.fn().mockResolvedValue([mockStockDetail]),
};
const dataSource = createDataSourceMock(managerMock);
const stockDetailService = new StockDetailService(
dataSource as DataSource,
logger,
);

const result = await stockDetailService.getStockDetailByStockId(stockId);

expect(managerMock.existsBy).toHaveBeenCalledWith(StockDetail, {
stock: { id: stockId },
});
expect(managerMock.findBy).toHaveBeenCalledWith(StockDetail, {
stock: { id: stockId },
});
expect(result).toMatchObject({
marketCap: expect.any(Number),
eps: expect.any(Number),
per: expect.any(Number),
high52w: expect.any(Number),
low52w: expect.any(Number),
});
expect(result.marketCap).toEqual(mockStockDetail.marketCap);
expect(result.eps).toEqual(mockStockDetail.eps);
expect(result.per).toEqual(mockStockDetail.per);
expect(result.high52w).toEqual(mockStockDetail.high52w);
expect(result.low52w).toEqual(mockStockDetail.low52w);
});

test('존재하지 않는 stockId로 조회 시 예외를 발생시킨다.', async () => {
const managerMock = {
existsBy: jest.fn().mockResolvedValue(false),
};
const dataSource = createDataSourceMock(managerMock);
const stockDetailService = new StockDetailService(
dataSource as DataSource,
logger,
);

await expect(
stockDetailService.getStockDetailByStockId('nonexistentId'),
).rejects.toThrow(NotFoundException);
expect(logger.warn).toHaveBeenCalledWith(
`stock detail not found (stockId: nonexistentId)`,
);
});
});
35 changes: 35 additions & 0 deletions packages/backend/src/stock/stockDetail.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { DataSource } from 'typeorm';
import { Logger } from 'winston';
import { StockDetail } from './domain/stockDetail.entity';
import { StockDetailResponse } from './dto/stockDetail.response';

@Injectable()
export class StockDetailService {
constructor(
private readonly datasource: DataSource,
@Inject('winston') private readonly logger: Logger,
) {}

async getStockDetailByStockId(stockId: string): Promise<StockDetailResponse> {
return await this.datasource.transaction(async (manager) => {
const isExists = await manager.existsBy(StockDetail, {
stock: { id: stockId },
});

if (!isExists) {
this.logger.warn(`stock detail not found (stockId: ${stockId})`);
throw new NotFoundException(
`stock detail not found (stockId: ${stockId}`,
);
}

const stockDetail = await manager.findBy(StockDetail, {
stock: { id: stockId },
});

return plainToInstance(StockDetailResponse, stockDetail[0]);
});
}
}
4 changes: 2 additions & 2 deletions packages/backend/src/stock/stockLiveData.subscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ export class StockLiveDataSubscriber
if (updatedStockLiveData?.stock?.id) {
const { id: stockId } = updatedStockLiveData.stock;
const {
current_price: price,
change_rate: change,
currentPrice: price,
changeRate: change,
volume: volume,
} = updatedStockLiveData;
this.stockGateway.onUpdateStock(stockId, price, change, volume);
Expand Down

0 comments on commit 225309b

Please sign in to comment.