diff --git a/.env.sample b/.env.sample index 7a09f6d..5355cac 100644 --- a/.env.sample +++ b/.env.sample @@ -1,6 +1,6 @@ -DB_HOST=""; -DB_PORT=; -DB_USERNAME=""; -DB_PASSWORD=""; -DB_NAME=""; -SYNCHRONIZE=; \ No newline at end of file +DB_HOST="" +DB_PORT= +DB_USERNAME="" +DB_PASSWORD="" +DB_NAME="" +SYNCHRONIZE= \ No newline at end of file diff --git a/package.json b/package.json index 0e05d7e..e16bd3d 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "ts" ], "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", + "testRegex": ".*\\.(test|spec)\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, diff --git a/src/app.module.ts b/src/app.module.ts index 879fa26..57faa0d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { ConfigModule } from '@nestjs/config'; +import { AssetManagementModule } from './asset-management/asset-management.module'; import { DatabaseModule } from './database.module'; @Module({ @@ -11,6 +12,7 @@ import { DatabaseModule } from './database.module'; envFilePath: ['.env'], }), DatabaseModule, + AssetManagementModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/asset-management/asset-management.module.ts b/src/asset-management/asset-management.module.ts new file mode 100644 index 0000000..fcecd95 --- /dev/null +++ b/src/asset-management/asset-management.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AssetExchange } from './entities/asset-exchange.entity'; +import { Asset } from './entities/asset.entity'; +import { DeliveryData } from './entities/delivery-data.entity'; +import { Exchange } from './entities/exchange.entity'; +import { TradingData } from './entities/trading-data.entity'; +import { + AssetExchangeRepository, + AssetRepository, + DeliveryDataRepository, + ExchangeRepository, + TradingDataRepository, +} from './repositories'; +import { ExchangeService } from './services/exchange.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Asset, + Exchange, + AssetExchange, + TradingData, + DeliveryData, + ]), + ], + providers: [ + AssetRepository, + ExchangeRepository, + AssetExchangeRepository, + TradingDataRepository, + DeliveryDataRepository, + ExchangeService, + ], + exports: [ExchangeService], +}) +export class AssetManagementModule {} diff --git a/src/asset-management/entities/asset-exchange.entity.ts b/src/asset-management/entities/asset-exchange.entity.ts new file mode 100644 index 0000000..37abfc3 --- /dev/null +++ b/src/asset-management/entities/asset-exchange.entity.ts @@ -0,0 +1,23 @@ +import { Entity, PrimaryGeneratedColumn, ManyToOne, OneToMany } from 'typeorm'; +import { Asset } from './asset.entity'; +import { Exchange } from './exchange.entity'; +import { TradingData } from './trading-data.entity'; +import { DeliveryData } from './delivery-data.entity'; + +@Entity() +export class AssetExchange { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => Asset, (asset) => asset.assetExchanges) + asset: Asset; + + @ManyToOne(() => Exchange, (exchange) => exchange.assetExchanges) + exchange: Exchange; + + @OneToMany(() => TradingData, (tradingData) => tradingData) + tradingData: TradingData[]; + + @OneToMany(() => DeliveryData, (deliveryData) => deliveryData) + deliveryData: DeliveryData[]; +} diff --git a/src/asset-management/entities/asset.entity.ts b/src/asset-management/entities/asset.entity.ts new file mode 100644 index 0000000..f53f379 --- /dev/null +++ b/src/asset-management/entities/asset.entity.ts @@ -0,0 +1,37 @@ +import { Entity, Column, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { AssetExchange } from './asset-exchange.entity'; + +export enum AssetType { + EQUITY = 'EQUITY', + DEBT = 'DEBT', +} + +@Entity('assets') +export class Asset { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'varchar', length: 50, nullable: false, unique: true }) + isin: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + symbol: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + assetExchangeCode: string; + + @Column({ type: 'float', nullable: false }) + faceValue: number; + + @Column({ type: 'varchar', length: 50, nullable: true }) + industry: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + sector: string; + + @OneToMany(() => AssetExchange, (assetExchange) => assetExchange.asset) + assetExchanges: AssetExchange[]; +} diff --git a/src/asset-management/entities/delivery-data.entity.ts b/src/asset-management/entities/delivery-data.entity.ts new file mode 100644 index 0000000..47cfe5d --- /dev/null +++ b/src/asset-management/entities/delivery-data.entity.ts @@ -0,0 +1,26 @@ +import { Entity, Column, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { AssetExchange } from './asset-exchange.entity'; + +@Entity() +export class DeliveryData { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'date' }) + date: Date; + + @Column({ type: 'int', nullable: true }) + deliveryQuantity: number; + + @Column({ type: 'int', nullable: true }) + deliveryPercentage: number; + + @ManyToOne( + () => AssetExchange, + (assetExchange) => assetExchange.tradingData, + { + onDelete: 'CASCADE', + }, + ) + assetExchange: AssetExchange; +} diff --git a/src/asset-management/entities/exchange.entity.ts b/src/asset-management/entities/exchange.entity.ts new file mode 100644 index 0000000..ca5d04c --- /dev/null +++ b/src/asset-management/entities/exchange.entity.ts @@ -0,0 +1,24 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Unique, + OneToMany, +} from 'typeorm'; +import { AssetExchange } from './asset-exchange.entity'; + +@Entity() +@Unique(['name', 'abbreviation']) +export class Exchange { + @PrimaryGeneratedColumn() + id: number; + + @Column({ nullable: false }) + name: string; + + @Column({ nullable: false }) + abbreviation: string; + + @OneToMany(() => AssetExchange, (assetExchange) => assetExchange.exchange) + assetExchanges: AssetExchange[]; +} diff --git a/src/asset-management/entities/index.ts b/src/asset-management/entities/index.ts new file mode 100644 index 0000000..afa8534 --- /dev/null +++ b/src/asset-management/entities/index.ts @@ -0,0 +1,5 @@ +export * from './asset.entity'; +export * from './asset-exchange.entity'; +export * from './delivery-data.entity'; +export * from './exchange.entity'; +export * from './trading-data.entity'; diff --git a/src/asset-management/entities/trading-data.entity.ts b/src/asset-management/entities/trading-data.entity.ts new file mode 100644 index 0000000..91a2738 --- /dev/null +++ b/src/asset-management/entities/trading-data.entity.ts @@ -0,0 +1,47 @@ +import { Entity, Column, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { AssetExchange } from './asset-exchange.entity'; + +@Entity() +export class TradingData { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'date' }) + date: Date; + + @Column({ type: 'float', nullable: false }) + open: number; + + @Column({ type: 'float', nullable: false }) + high: number; + + @Column({ type: 'float', nullable: false }) + low: number; + + @Column({ type: 'float', nullable: false }) + close: number; + + @Column({ type: 'float', nullable: false }) + lastPrice: number; + + @Column({ type: 'float', nullable: false }) + previousClose: number; + + @Column({ type: 'bigint', nullable: false }) + volume: number; + + @Column({ type: 'float', nullable: false }) + turnover: number; + + @Column({ type: 'int', nullable: true }) + totalTrades: number; + + @ManyToOne( + () => AssetExchange, + (assetExchange) => assetExchange.tradingData, + { + onDelete: 'CASCADE', + }, + ) + assetExchange: AssetExchange; +} diff --git a/src/asset-management/repositories/asset-exchange.repository.ts b/src/asset-management/repositories/asset-exchange.repository.ts new file mode 100644 index 0000000..b8f636e --- /dev/null +++ b/src/asset-management/repositories/asset-exchange.repository.ts @@ -0,0 +1,13 @@ +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AssetExchange } from '../entities'; +import { BaseRepository } from './base.repository'; + +export class AssetExchangeRepository extends BaseRepository { + constructor( + @InjectRepository(AssetExchange) + assetExchangeRepository: Repository, + ) { + super(assetExchangeRepository); + } +} diff --git a/src/asset-management/repositories/asset.repository.ts b/src/asset-management/repositories/asset.repository.ts new file mode 100644 index 0000000..38ceb6f --- /dev/null +++ b/src/asset-management/repositories/asset.repository.ts @@ -0,0 +1,13 @@ +import { InjectRepository } from '@nestjs/typeorm'; +import { Asset } from '../entities'; +import { BaseRepository } from './base.repository'; +import { Repository } from 'typeorm'; + +export class AssetRepository extends BaseRepository { + constructor( + @InjectRepository(Asset) + assetRepository: Repository, + ) { + super(assetRepository); + } +} diff --git a/src/asset-management/repositories/base.repository.ts b/src/asset-management/repositories/base.repository.ts new file mode 100644 index 0000000..4c326a4 --- /dev/null +++ b/src/asset-management/repositories/base.repository.ts @@ -0,0 +1,53 @@ +import { + Repository, + FindOneOptions, + SelectQueryBuilder, + FindOptionsWhere, +} from 'typeorm'; + +export interface DatabaseRepository { + findAll(): Promise; + findById(id: any): Promise; + create(item: T): Promise; + update(id: string, item: T): Promise; + delete(id: string): Promise; + createQueryBuilder(alias: string): SelectQueryBuilder; +} + +export abstract class BaseRepository implements DatabaseRepository { + constructor(protected readonly repository: Repository) {} + + async findAll(): Promise { + return this.repository.find(); + } + + async findById(id: any): Promise { + return this.repository.findOne(id); + } + + async findOneBy(props: FindOptionsWhere): Promise { + return this.repository.findOneBy(props); + } + + async create(item: T): Promise { + return this.repository.save(item); + } + + async update(id: string, item: T): Promise { + const existingItem = await this.repository.findOne(id as FindOneOptions); + if (!existingItem) { + return null; + } + const updatedItem = { ...existingItem, ...item }; + await this.repository.save(updatedItem); + return updatedItem; + } + + async delete(id: string): Promise { + await this.repository.delete(id); + } + + createQueryBuilder(alias: string): SelectQueryBuilder { + return this.repository.createQueryBuilder(alias); + } +} diff --git a/src/asset-management/repositories/delivery-data.repository.ts b/src/asset-management/repositories/delivery-data.repository.ts new file mode 100644 index 0000000..5e24369 --- /dev/null +++ b/src/asset-management/repositories/delivery-data.repository.ts @@ -0,0 +1,13 @@ +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DeliveryData } from '../entities'; +import { BaseRepository } from './base.repository'; + +export class DeliveryDataRepository extends BaseRepository { + constructor( + @InjectRepository(DeliveryData) + deliveryDataRepository: Repository, + ) { + super(deliveryDataRepository); + } +} diff --git a/src/asset-management/repositories/exchange.repository.ts b/src/asset-management/repositories/exchange.repository.ts new file mode 100644 index 0000000..cc31c26 --- /dev/null +++ b/src/asset-management/repositories/exchange.repository.ts @@ -0,0 +1,13 @@ +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Exchange } from '../entities'; +import { BaseRepository } from './base.repository'; + +export class ExchangeRepository extends BaseRepository { + constructor( + @InjectRepository(Exchange) + exchangeRepository: Repository, + ) { + super(exchangeRepository); + } +} diff --git a/src/asset-management/repositories/index.ts b/src/asset-management/repositories/index.ts new file mode 100644 index 0000000..a34e12f --- /dev/null +++ b/src/asset-management/repositories/index.ts @@ -0,0 +1,6 @@ +export * from './base.repository'; +export * from './exchange.repository'; +export * from './asset.repository'; +export * from './trading-data.repository'; +export * from './delivery-data.repository'; +export * from './asset-exchange.repository'; diff --git a/src/asset-management/repositories/trading-data.repository.ts b/src/asset-management/repositories/trading-data.repository.ts new file mode 100644 index 0000000..bdf16f8 --- /dev/null +++ b/src/asset-management/repositories/trading-data.repository.ts @@ -0,0 +1,13 @@ +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { TradingData } from '../entities'; +import { BaseRepository } from './base.repository'; + +export class TradingDataRepository extends BaseRepository { + constructor( + @InjectRepository(TradingData) + tradingDataRepository: Repository, + ) { + super(tradingDataRepository); + } +} diff --git a/src/asset-management/services/asset.service.test.ts b/src/asset-management/services/asset.service.test.ts new file mode 100644 index 0000000..166339f --- /dev/null +++ b/src/asset-management/services/asset.service.test.ts @@ -0,0 +1,29 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AssetService } from './asset.service'; +import { AssetRepository } from '../repositories'; + +describe('AssetService', () => { + let assetService: AssetService; + let assetRepository: AssetRepository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AssetService, + { + provide: AssetRepository, + useValue: { + // Mock any methods you want to test + }, + }, + ], + }).compile(); + + assetService = module.get(AssetService); + assetRepository = module.get(AssetRepository); + expect(assetService).toBeDefined(); + expect(assetRepository).toBeDefined(); + }); + + // Add more test cases for other methods in the AssetService class +}); diff --git a/src/asset-management/services/asset.service.ts b/src/asset-management/services/asset.service.ts new file mode 100644 index 0000000..c769adf --- /dev/null +++ b/src/asset-management/services/asset.service.ts @@ -0,0 +1,7 @@ +import { Injectable } from '@nestjs/common'; +import { AssetRepository } from '../repositories'; + +@Injectable() +export class AssetService { + constructor(private readonly assetRepository: AssetRepository) {} +} diff --git a/src/asset-management/services/delivery-data.service.test.ts b/src/asset-management/services/delivery-data.service.test.ts new file mode 100644 index 0000000..bb62407 --- /dev/null +++ b/src/asset-management/services/delivery-data.service.test.ts @@ -0,0 +1,51 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DeliveryDataService } from './delivery-data.service'; +import { DeliveryDataRepository } from '../repositories'; +import { DeliveryData } from '../entities'; + +describe('DeliveryDataService', () => { + let deliveryDataService: DeliveryDataService; + let deliveryDataRepository: jest.Mocked; + + beforeEach(async () => { + const repoMock = { + findById: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DeliveryDataService, + { + provide: DeliveryDataRepository, + useValue: repoMock, + }, + ], + }).compile(); + + deliveryDataService = module.get(DeliveryDataService); + deliveryDataRepository = module.get(DeliveryDataRepository); + }); + + it('should be defined', () => { + expect(deliveryDataService).toBeDefined(); + expect(deliveryDataRepository).toBeDefined(); + }); + + describe('findDeliveryDataById', () => { + it('should return a delivery data if found', async () => { + const result = new DeliveryData(); + deliveryDataRepository.findById.mockResolvedValue(result); + + expect(await deliveryDataService.findDeliveryDataById('1')).toBe(result); + expect(deliveryDataRepository.findById).toHaveBeenCalledWith('1'); + }); + + it('should throw an error if delivery data not found', async () => { + deliveryDataRepository.findById.mockResolvedValue(null); + + await expect( + deliveryDataService.findDeliveryDataById('1'), + ).rejects.toThrow(); + expect(deliveryDataRepository.findById).toHaveBeenCalledWith('1'); + }); + }); +}); diff --git a/src/asset-management/services/delivery-data.service.ts b/src/asset-management/services/delivery-data.service.ts new file mode 100644 index 0000000..099e78d --- /dev/null +++ b/src/asset-management/services/delivery-data.service.ts @@ -0,0 +1,12 @@ +import { DeliveryData } from '../entities'; +import { DeliveryDataRepository } from '../repositories'; + +export class DeliveryDataService { + constructor( + private readonly deliveryDataRepository: DeliveryDataRepository, + ) {} + + async findDeliveryDataById(id: string): Promise { + return this.deliveryDataRepository.findById(id); + } +} diff --git a/src/asset-management/services/exchange.service.test.ts b/src/asset-management/services/exchange.service.test.ts new file mode 100644 index 0000000..e268a75 --- /dev/null +++ b/src/asset-management/services/exchange.service.test.ts @@ -0,0 +1,80 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ExchangeService } from './exchange.service'; +import { ExchangeRepository } from '../repositories/exchange.repository'; + +describe('ExchangeService', () => { + let exchangeService: ExchangeService; + let exchangeRepository: ExchangeRepository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ExchangeService, + { + provide: ExchangeRepository, + useValue: { + findOneBy: jest.fn(), + create: jest.fn(), + }, + }, + ], + }).compile(); + + exchangeService = module.get(ExchangeService); + exchangeRepository = module.get(ExchangeRepository); + }); + + describe('findOrCreateExchange', () => { + it('should return an existing exchange if found', async () => { + const abbreviation = 'ABC'; + const existingExchange = { + id: 1, + abbreviation, + name: 'Existing Exchange', + assetExchanges: [], + }; + + jest + .spyOn(exchangeRepository, 'findOneBy') + .mockResolvedValue(existingExchange); + + const result = await exchangeService.findOrCreateExchange( + abbreviation, + 'New Exchange', + ); + + expect(result).toEqual(existingExchange); + expect(exchangeRepository.findOneBy).toHaveBeenCalledWith({ + abbreviation, + }); + expect(exchangeRepository.create).not.toHaveBeenCalled(); + }); + + it('should create a new exchange if not found', async () => { + const abbreviation = 'XYZ'; + const newExchange = { + id: 1, + abbreviation, + name: 'New Exchange', + assetExchanges: [], + }; + + jest.spyOn(exchangeRepository, 'findOneBy').mockResolvedValue(null); + jest.spyOn(exchangeRepository, 'create').mockResolvedValue(newExchange); + + const result = await exchangeService.findOrCreateExchange( + abbreviation, + 'New Exchange', + ); + + expect(result).toEqual(newExchange); + expect(exchangeRepository.findOneBy).toHaveBeenCalledWith({ + abbreviation, + }); + expect(exchangeRepository.create).toHaveBeenCalledWith({ + abbreviation, + name: 'New Exchange', + }); + }); + }); +}); diff --git a/src/asset-management/services/exchange.service.ts b/src/asset-management/services/exchange.service.ts new file mode 100644 index 0000000..10dd188 --- /dev/null +++ b/src/asset-management/services/exchange.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { ExchangeRepository } from '../repositories'; +import { Exchange } from '../entities'; + +@Injectable() +export class ExchangeService { + constructor(private readonly exchangeRepository: ExchangeRepository) {} + + async findOrCreateExchange( + abbreviation: string, + name: string, + ): Promise { + const exchange = await this.exchangeRepository.findOneBy({ abbreviation }); + + return ( + exchange || + this.exchangeRepository.create({ name, abbreviation } as Exchange) + ); + } +} diff --git a/src/asset-management/services/index.ts b/src/asset-management/services/index.ts new file mode 100644 index 0000000..be10ebf --- /dev/null +++ b/src/asset-management/services/index.ts @@ -0,0 +1,4 @@ +export * from './exchange.service'; +export * from './asset.service'; +export * from './delivery-data.service'; +export * from './trading-data.service'; diff --git a/src/asset-management/services/trading-data.service.test.ts b/src/asset-management/services/trading-data.service.test.ts new file mode 100644 index 0000000..1c15d75 --- /dev/null +++ b/src/asset-management/services/trading-data.service.test.ts @@ -0,0 +1,51 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TradingDataService } from './trading-data.service'; +import { TradingDataRepository } from '../repositories'; +import { TradingData } from '../entities'; + +describe('TradingDataService', () => { + let tradingDataService: TradingDataService; + let tradingDataRepository: jest.Mocked; + + beforeEach(async () => { + const repoMock = { + findById: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TradingDataService, + { + provide: TradingDataRepository, + useValue: repoMock, + }, + ], + }).compile(); + + tradingDataService = module.get(TradingDataService); + tradingDataRepository = module.get(TradingDataRepository); + }); + + it('should be defined', () => { + expect(tradingDataService).toBeDefined(); + expect(tradingDataRepository).toBeDefined(); + }); + + describe('findTradingDataById', () => { + it('should return a trading data if found', async () => { + const result = new TradingData(); + tradingDataRepository.findById.mockResolvedValue(result); + + expect(await tradingDataService.findTradingDataById('1')).toBe(result); + expect(tradingDataRepository.findById).toHaveBeenCalledWith('1'); + }); + + it('should throw an error if trading data not found', async () => { + tradingDataRepository.findById.mockResolvedValue(null); + + await expect( + tradingDataService.findTradingDataById('1'), + ).rejects.toThrow(); + expect(tradingDataRepository.findById).toHaveBeenCalledWith('1'); + }); + }); +}); diff --git a/src/asset-management/services/trading-data.service.ts b/src/asset-management/services/trading-data.service.ts new file mode 100644 index 0000000..512ed4a --- /dev/null +++ b/src/asset-management/services/trading-data.service.ts @@ -0,0 +1,10 @@ +import { TradingData } from '../entities'; +import { TradingDataRepository } from '../repositories'; + +export class TradingDataService { + constructor(private readonly tradingDataRepository: TradingDataRepository) {} + + async findTradingDataById(id: string): Promise { + return this.tradingDataRepository.findById(id); + } +} diff --git a/src/database.module.ts b/src/database.module.ts index a0b2d42..175a307 100644 --- a/src/database.module.ts +++ b/src/database.module.ts @@ -4,17 +4,17 @@ import { TypeOrmModule } from '@nestjs/typeorm'; @Module({ imports: [ - ConfigModule.forRoot({ isGlobal: true }), TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ type: 'postgres', - host: configService.getOrThrow('DB_HOST'), - port: +configService.getOrThrow('DB_PORT'), - username: configService.getOrThrow('DB_USERNAME'), - password: configService.getOrThrow('DB_PASSWORD'), - database: configService.getOrThrow('DB_NAME'), - synchronize: configService.getOrThrow('SYNCHRONIZE'), + host: configService.get('DB_HOST'), + port: +configService.get('DB_PORT'), + username: configService.get('DB_USERNAME'), + password: configService.get('DB_PASSWORD'), + database: configService.get('DB_NAME'), + entities: [__dirname + '/**/*.entity{.ts,.js}'], + synchronize: true, }), inject: [ConfigService], }),