Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BE] 7.07 거래 체결 기능 구현 (WebSocket) #53 #98

Merged
merged 22 commits into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
26b9f66
✨ feat: 체결에 필요한 stock code 가져오기 위한 stock item 도메인 레이어 구현 #53
sieunie Nov 12, 2024
1a4d658
🔧 fix: stock_id를 stock_code로 변경
sieunie Nov 12, 2024
c8cc1c5
✨ feat: 체결 가능한 주문인지 확인하는 로직 구현 #53
sieunie Nov 12, 2024
3ca62ea
⚙️ chore: 소켓 모듈 import 업데이트 #53
sieunie Nov 12, 2024
4ae4780
✨ feat: 체결가 웹소켓과 주문 체결 연동 #53
sieunie Nov 12, 2024
426b752
🔧 fix: 매수/매도 메소드의 stock_id를 stock_code로 수정
sieunie Nov 12, 2024
f059980
Merge branch 'back/main' into feature/api/execute-#53
sieunie Nov 13, 2024
64d342c
Merge branch 'back/main' into feature/api/execute-#53
sieunie Nov 13, 2024
01e3293
🔥 remove: stock-item 도메인 삭제
sieunie Nov 13, 2024
eb204fd
🔧 fix: 주문 예약 시에만 웹소켓 구독하도록 로직 변경
sieunie Nov 13, 2024
f1f0c0b
♻️ refactor: stock index socket 디렉토리 위치 이동
sieunie Nov 13, 2024
0530c30
➕ add: 초기 실행 시 등록된 주문에 대해 웹소켓 구독
sieunie Nov 13, 2024
caf6a1a
➕ add: 체결 완료 되거나 취소된 주문 종목에 대해 구독 취소 로직 구현
sieunie Nov 13, 2024
7061ec1
➕ add: 주문 체결에 대한 로깅 추가 #53
sieunie Nov 13, 2024
8a4ec38
⚙️ chore: eslint 규칙 수정
sieunie Nov 13, 2024
98e432e
Merge branch 'back/main' into feature/api/execute-#53
sieunie Nov 13, 2024
a6b6ea6
✨ feat: 회원가입 시 assets table에도 row 추가되도록 트랜잭션 구현
sieunie Nov 13, 2024
4910002
✨ feat: 주문 체결 시 자산 업데이트 로직 트랜잭션으로 구현 #53
sieunie Nov 13, 2024
16c34a5
🔧 fix: 프로그램 시작 시 종목 중복 구독 안되도록 로직 수정 #53
sieunie Nov 13, 2024
8276c03
🔧 fix: stock_balance 초기값 수정 및 rate 소수점 5자리까지 나타내도록 수정 #53
sieunie Nov 14, 2024
b0b13e6
🔧 fix: stock-order-socket과 stock-order 서비스가 순환참조 하지 않게 수정 #53
sieunie Nov 14, 2024
690d05a
⚙️ chore: lint 오류 수정 및 import/no-cycle 다시 on
sieunie Nov 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions BE/src/asset/asset.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Controller } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';

@Controller('/api/assets')
@ApiTags('자산 API')
export class AssetController {}
30 changes: 30 additions & 0 deletions BE/src/asset/asset.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

const INIT_ASSET = 10000000;

@Entity('assets')
export class Asset {
@PrimaryGeneratedColumn()
id: number;

@Column({ nullable: false })
user_id: number;

@Column({ nullable: false, default: INIT_ASSET })
cash_balance: number;

@Column({ nullable: false, default: 0 })
stock_balance: number;

@Column({ nullable: false, default: INIT_ASSET })
total_asset: number;

@Column({ nullable: false, default: 0 })
total_profit: number;

@Column('decimal', { nullable: false, default: 0, precision: 10, scale: 5 })
total_profit_rate: number;

@Column({ nullable: true })
last_updated?: Date;
}
14 changes: 14 additions & 0 deletions BE/src/asset/asset.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetController } from './asset.controller';
import { AssetService } from './asset.service';
import { AssetRepository } from './asset.repository';
import { Asset } from './asset.entity';

@Module({
imports: [TypeOrmModule.forFeature([Asset])],
controllers: [AssetController],
providers: [AssetService, AssetRepository],
exports: [AssetRepository],
})
export class AssetModule {}
11 changes: 11 additions & 0 deletions BE/src/asset/asset.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { DataSource, Repository } from 'typeorm';
import { InjectDataSource } from '@nestjs/typeorm';
import { Injectable } from '@nestjs/common';
import { Asset } from './asset.entity';

@Injectable()
export class AssetRepository extends Repository<Asset> {
constructor(@InjectDataSource() dataSource: DataSource) {
super(Asset, dataSource.createEntityManager());
}
}
4 changes: 4 additions & 0 deletions BE/src/asset/asset.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common';

@Injectable()
export class AssetService {}
2 changes: 2 additions & 0 deletions BE/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { User } from './user.entity';
import { UserRepository } from './user.repository';
import { JwtStrategy } from './strategy/jwt.strategy';
import { KakaoStrategy } from './strategy/kakao.strategy';
import { AssetModule } from '../asset/asset.module';

@Module({
imports: [
Expand All @@ -25,6 +26,7 @@ import { KakaoStrategy } from './strategy/kakao.strategy';
}),
inject: [ConfigService],
}),
AssetModule,
],
controllers: [AuthController],
providers: [AuthService, UserRepository, JwtStrategy, KakaoStrategy],
Expand Down
55 changes: 44 additions & 11 deletions BE/src/auth/user.repository.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,63 @@
import { Injectable } from '@nestjs/common';
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from './user.entity';
import { AuthCredentialsDto } from './dto/auth-credentials.dto';
import { AssetRepository } from '../asset/asset.repository';

@Injectable()
export class UserRepository extends Repository<User> {
constructor(@InjectDataSource() dataSource: DataSource) {
constructor(
@InjectDataSource() private dataSource: DataSource,
private readonly assetRepository: AssetRepository,
) {
super(User, dataSource.createEntityManager());
}

async registerUser(authCredentialsDto: AuthCredentialsDto) {
const { email, password } = authCredentialsDto;
const salt: string = await bcrypt.genSalt();
const hashedPassword: string = await bcrypt.hash(password, salt);
const user = this.create({ email, password: hashedPassword });
await this.save(user);

const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.startTransaction();

try {
const salt: string = await bcrypt.genSalt();
const hashedPassword: string = await bcrypt.hash(password, salt);
const user = this.create({ email, password: hashedPassword });
await queryRunner.manager.save(user);
const asset = this.assetRepository.create({ user_id: user.id });
await queryRunner.manager.save(asset);

await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw new InternalServerErrorException();
} finally {
await queryRunner.release();
}
}

async registerKakaoUser(authCredentialsDto: AuthCredentialsDto) {
const { kakaoId, email } = authCredentialsDto;
const salt: string = await bcrypt.genSalt();
const hashedPassword: string = await bcrypt.hash(String(kakaoId), salt);
const user = this.create({ email, kakaoId, password: hashedPassword });
await this.save(user);
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.startTransaction();

try {
const { kakaoId, email } = authCredentialsDto;
const salt: string = await bcrypt.genSalt();
const hashedPassword: string = await bcrypt.hash(String(kakaoId), salt);
const user = this.create({ email, kakaoId, password: hashedPassword });
await this.save(user);
const asset = this.assetRepository.create({ user_id: user.id });
await queryRunner.manager.save(asset);

await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw new InternalServerErrorException();
} finally {
await queryRunner.release();
}
}

async updateUserWithRefreshToken(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { StockIndexValueElementDto } from '../stock/index/dto/stock-index-value-element.dto';
import { BaseSocketService } from './base-socket.service';
import { SocketGateway } from './socket.gateway';
import { StockIndexValueElementDto } from './dto/stock-index-value-element.dto';
import { BaseSocketService } from '../../websocket/base-socket.service';
import { SocketGateway } from '../../websocket/socket.gateway';

@Injectable()
export class StockIndexSocketService {
Expand Down
3 changes: 2 additions & 1 deletion BE/src/stock/index/stock-index.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { StockIndexController } from './stock-index.controller';
import { StockIndexService } from './stock-index.service';
import { KoreaInvestmentModule } from '../../koreaInvestment/korea-investment.module';
import { SocketModule } from '../../websocket/socket.module';
import { StockIndexSocketService } from './stock-index-socket.service';

@Module({
imports: [KoreaInvestmentModule, SocketModule],
controllers: [StockIndexController],
providers: [StockIndexService],
providers: [StockIndexService, StockIndexSocketService],
})
export class StockIndexModule {}
6 changes: 2 additions & 4 deletions BE/src/stock/order/dto/stock-order-request.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsNumber, IsPositive } from 'class-validator';

export class StockOrderRequestDto {
@ApiProperty({ description: '주식 id' })
@IsInt()
@IsPositive()
stock_id: number;
@ApiProperty({ description: '주식 id', example: '005930' })
stock_code: string;

@ApiProperty({ description: '매수/매도 희망 가격' })
@IsNumber()
Expand Down
119 changes: 119 additions & 0 deletions BE/src/stock/order/stock-order-socket.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import {
Injectable,
InternalServerErrorException,
Logger,
} from '@nestjs/common';
import { LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { BaseSocketService } from '../../websocket/base-socket.service';
import { SocketGateway } from '../../websocket/socket.gateway';
import { Order } from './stock-order.entity';
import { TradeType } from './enum/trade-type';
import { StatusType } from './enum/status-type';
import { StockOrderRepository } from './stock-order.repository';

@Injectable()
export class StockOrderSocketService {
private TR_ID = 'H0STCNT0';

private readonly logger = new Logger();

constructor(
private readonly socketGateway: SocketGateway,
private readonly baseSocketService: BaseSocketService,
private readonly stockOrderRepository: StockOrderRepository,
) {
baseSocketService.registerSocketOpenHandler(async () => {
const orders: Order[] =
await this.stockOrderRepository.findAllCodeByStatus();
orders.forEach((order) => {
baseSocketService.registerCode(this.TR_ID, order.stock_code);
});
});

baseSocketService.registerSocketDataHandler(
this.TR_ID,
(data: string[]) => {
this.checkExecutableOrder(
data[0], // 주식 코드
data[2], // 주식 체결가
).catch(() => {
throw new InternalServerErrorException();
});
},
);
}

subscribeByCode(trKey: string) {
this.baseSocketService.registerCode(this.TR_ID, trKey);
}

unsubscribeByCode(trKey: string) {
this.baseSocketService.unregisterCode(this.TR_ID, trKey);
}

private async checkExecutableOrder(stockCode: string, value) {
const buyOrders = await this.stockOrderRepository.find({
where: {
stock_code: stockCode,
trade_type: TradeType.BUY,
status: StatusType.PENDING,
price: MoreThanOrEqual(value),
},
});

const sellOrders = await this.stockOrderRepository.find({
where: {
stock_code: stockCode,
trade_type: TradeType.SELL,
status: StatusType.PENDING,
price: LessThanOrEqual(value),
},
});

await Promise.all(buyOrders.map((buyOrder) => this.executeBuy(buyOrder)));
await Promise.all(
sellOrders.map((sellOrder) => this.executeSell(sellOrder)),
);

if (
!(await this.stockOrderRepository.existsBy({
stock_code: stockCode,
status: StatusType.PENDING,
}))
)
this.unsubscribeByCode(stockCode);
}

private async executeBuy(order) {
this.logger.log(`${order.id}번 매수 예약이 체결되었습니다.`, 'BUY');

const totalPrice = order.price * order.amount;
const fee = this.calculateFee(totalPrice);
await this.stockOrderRepository.updateOrderAndAssetWhenBuy(
order,
totalPrice + fee,
);
}

private async executeSell(order) {
this.logger.log(`${order.id}번 매도 예약이 체결되었습니다.`, 'SELL');

const totalPrice = order.price * order.amount;
const fee = this.calculateFee(totalPrice);
await this.stockOrderRepository.updateOrderAndAssetWhenSell(
order,
totalPrice - fee,
);
}

private calculateFee(totalPrice: number) {
if (totalPrice <= 10000000) return totalPrice * 0.16;
if (totalPrice > 10000000 && totalPrice <= 50000000)
return totalPrice * 0.14;
if (totalPrice > 50000000 && totalPrice <= 100000000)
return totalPrice * 0.12;
if (totalPrice > 100000000 && totalPrice <= 300000000)
return totalPrice * 0.1;
return totalPrice * 0.08;
}
}
2 changes: 1 addition & 1 deletion BE/src/stock/order/stock-order.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class Order {
user_id: number;

@Column({ nullable: false })
stock_id: number;
stock_code: string;

@Column({
type: 'enum',
Expand Down
7 changes: 5 additions & 2 deletions BE/src/stock/order/stock-order.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import { StockOrderController } from './stock-order.controller';
import { StockOrderService } from './stock-order.service';
import { Order } from './stock-order.entity';
import { StockOrderRepository } from './stock-order.repository';
import { SocketModule } from '../../websocket/socket.module';
import { AssetModule } from '../../asset/asset.module';
import { StockOrderSocketService } from './stock-order-socket.service';

@Module({
imports: [TypeOrmModule.forFeature([Order])],
imports: [TypeOrmModule.forFeature([Order]), SocketModule, AssetModule],
controllers: [StockOrderController],
providers: [StockOrderService, StockOrderRepository],
providers: [StockOrderService, StockOrderRepository, StockOrderSocketService],
})
export class StockOrderModule {}
Loading
Loading