├── project ├── build.properties ├── plugins.sbt ├── versions.scala └── Dependencies.scala ├── modules ├── core │ └── src │ │ └── main │ │ ├── scala │ │ └── tradex │ │ │ └── domain │ │ │ ├── service │ │ │ ├── AccountService.scala │ │ │ ├── FrontOfficeOrderParsingService.scala │ │ │ ├── ExchangeExecutionParsingService.scala │ │ │ ├── live │ │ │ │ ├── AccountServiceLive.scala │ │ │ │ ├── InstrumentServiceLive.scala │ │ │ │ ├── ExchangeExecutionParsingServiceLive.scala │ │ │ │ ├── FrontOfficeOrderParsingServiceLive.scala │ │ │ │ └── TradingServiceLive.scala │ │ │ ├── InstrumentService.scala │ │ │ └── TradingService.scala │ │ │ ├── repository │ │ │ ├── UserRepository.scala │ │ │ ├── InstrumentRepository.scala │ │ │ ├── BalanceRepository.scala │ │ │ ├── AccountRepository.scala │ │ │ ├── TradeRepository.scala │ │ │ ├── OrderRepository.scala │ │ │ ├── ExecutionRepository.scala │ │ │ ├── live │ │ │ │ ├── UserRepositoryLive.scala │ │ │ │ ├── BalanceRepositoryLive.scala │ │ │ │ ├── ExecutionRepositoryLive.scala │ │ │ │ ├── InstrumentRepositoryLive.scala │ │ │ │ ├── OrderRepositoryLive.scala │ │ │ │ ├── AccountRepositoryLive.scala │ │ │ │ └── TradeRepositoryLive.scala │ │ │ └── codecs.scala │ │ │ ├── api │ │ │ ├── InstrumentResponse.scala │ │ │ ├── common │ │ │ │ ├── Exceptions.scala │ │ │ │ ├── ErrorMapper.scala │ │ │ │ ├── DefectHandler.scala │ │ │ │ ├── BaseEndpoints.scala │ │ │ │ ├── ErrorInfo.scala │ │ │ │ └── CustomDecodeFailureHandler.scala │ │ │ ├── AddEquityRequest.scala │ │ │ ├── AddFixedIncomeRequest.scala │ │ │ └── endpoints │ │ │ │ ├── TradingServerEndpoints.scala │ │ │ │ └── TradingEndpoints.scala │ │ │ ├── model │ │ │ ├── FrontOfficeOrder.scala │ │ │ ├── market.scala │ │ │ ├── balance.scala │ │ │ ├── user.scala │ │ │ ├── exchangeExecution.scala │ │ │ ├── execution.scala │ │ │ ├── order.scala │ │ │ ├── instrument.scala │ │ │ ├── trade.scala │ │ │ └── Account.scala │ │ │ ├── transport │ │ │ ├── executionT.scala │ │ │ ├── frontOfficeOrderT.scala │ │ │ ├── tradeT.scala │ │ │ ├── orderT.scala │ │ │ ├── userT.scala │ │ │ ├── exchangeExecutionT.scala │ │ │ ├── instrumentT.scala │ │ │ ├── cellCodecs.scala │ │ │ └── accountT.scala │ │ │ ├── FlywayMigration.scala │ │ │ ├── Endpoints.scala │ │ │ ├── config.scala │ │ │ ├── csv │ │ │ └── CSV.scala │ │ │ ├── resources │ │ │ └── AppResources.scala │ │ │ ├── package.scala │ │ │ ├── Main.scala │ │ │ └── TradeApp.scala │ │ └── resources │ │ ├── drop.sql │ │ ├── forders.csv │ │ ├── application.conf │ │ ├── executions.csv │ │ ├── migrations │ │ └── V1__Init.sql │ │ └── tables.sql ├── it │ └── src │ │ └── it │ │ ├── resources │ │ └── application.conf │ │ └── scala │ │ └── tradex │ │ └── domain │ │ ├── Fixture.scala │ │ ├── api │ │ ├── TestUtils.scala │ │ ├── RepositoryTestSupport.scala │ │ ├── TradingEndpointsTestSupport.scala │ │ └── TradingEndpointsSpec.scala │ │ └── service │ │ ├── FrontOfficeOrderParsingServiceSpec.scala │ │ ├── generators.scala │ │ └── OrderExecutionParsingSpec.scala └── tests │ └── src │ └── test │ └── scala │ └── tradex │ └── domain │ ├── model │ └── AccountSpec.scala │ └── csv │ └── CSVSpec.scala ├── .gitignore ├── .scalafmt.conf ├── docker-compose.yml ├── LICENSE └── README.md /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.8.2 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0") 2 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/service/AccountService.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package service 3 | 4 | import zio.UIO 5 | import model.account.* 6 | 7 | trait AccountService: 8 | def query(accountNo: AccountNo): UIO[Option[ClientAccount]] 9 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/service/FrontOfficeOrderParsingService.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package service 3 | 4 | import zio.Task 5 | import java.io.Reader 6 | 7 | trait FrontOfficeOrderParsingService: 8 | def parse(data: Reader): Task[Unit] 9 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/service/ExchangeExecutionParsingService.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package service 3 | 4 | import zio.Task 5 | import java.io.Reader 6 | 7 | trait ExchangeExecutionParsingService: 8 | def parse(data: Reader): Task[Unit] 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.log 3 | .metals/readonly/scala/Predef.scala 4 | .bloop 5 | .metals 6 | project/.bloop 7 | target/ 8 | core/target/ 9 | project/metals.sbt 10 | project/project/metals.sbt 11 | project/project/project/metals.sbt 12 | tradeio3.code-workspace 13 | .bsp/ 14 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "3.7.4" 2 | align.preset = more 3 | maxColumn = 120 4 | rewrite.rules = [AsciiSortImports] 5 | spaces.inImportCurlyBraces = true 6 | runner.dialect = scala3 7 | project.includeFilters = [".*\\.scala$"] 8 | -------------------------------------------------------------------------------- /modules/core/src/main/resources/drop.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE executions; 2 | DROP TABLE lineitems; 3 | DROP TABLE orders; 4 | DROP TABLE tradeTaxFees; 5 | DROP TABLE taxFees; 6 | DROP TABLE trades; 7 | DROP TABLE balance; 8 | DROP TABLE instruments; 9 | DROP TABLE accounts; 10 | DROP TABLE users; -------------------------------------------------------------------------------- /modules/it/src/it/resources/application.conf: -------------------------------------------------------------------------------- 1 | tradex { 2 | tradingConfig { 3 | maxAccountNoLength = 10 4 | minAccountNoLength = 5 5 | zeroBalanceAllowed = true 6 | } 7 | postgreSQL { 8 | host = "localhost", 9 | port = 5432, 10 | user = "postgres", 11 | password = "toughgraff", 12 | database = "trading", 13 | max = 10 14 | } 15 | } -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/repository/UserRepository.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package repository 3 | 4 | import zio.UIO 5 | import model.user.* 6 | 7 | trait UserRepository { 8 | 9 | /** query by username */ 10 | def query(username: UserName): UIO[Option[User]] 11 | 12 | /** store a user * */ 13 | def store(username: UserName, password: EncryptedPassword): UIO[UserId] 14 | } 15 | -------------------------------------------------------------------------------- /modules/core/src/main/resources/forders.csv: -------------------------------------------------------------------------------- 1 | Account No,Order Date,ISIN Code,Quantity,Unit Price,Buy/Sell 2 | ibm-123,2023-05-28T18:00:17.440087Z,US0378331005,100,12.25,buy 3 | ibm-123,2023-05-28T18:00:17.440087Z,US0378331005,100,12.25,buy 4 | ibm-123,2023-05-28T18:00:17.440087Z,US0378331005,100,12.25,buy 5 | ibm-123,2023-05-28T18:00:17.440087Z,US0378331005,100,12.25,buy 6 | ibm-123,2023-05-28T18:00:17.440087Z,US0378331005,100,12.25,buy -------------------------------------------------------------------------------- /modules/core/src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | tradex { 2 | tradingConfig { 3 | maxAccountNoLength = 10 4 | minAccountNoLength = 5 5 | zeroBalanceAllowed = true 6 | } 7 | postgreSQL { 8 | host = "localhost", 9 | port = 5432, 10 | user = "postgres", 11 | password = "toughgraff", 12 | database = "trading", 13 | max = 10 14 | } 15 | httpServer { 16 | host = "localhost", 17 | port = 8080 18 | } 19 | } -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/service/live/AccountServiceLive.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package service 3 | package live 4 | 5 | import zio.UIO 6 | import model.account.* 7 | import repository.AccountRepository 8 | 9 | final case class AccountServiceLive( 10 | repository: AccountRepository 11 | ) extends AccountService: 12 | 13 | override def query(accountNo: AccountNo): UIO[Option[ClientAccount]] = 14 | repository.query(accountNo) 15 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/api/InstrumentResponse.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package api 3 | 4 | import zio.json.* 5 | import model.instrument.* 6 | import transport.instrumentT.{ *, given } 7 | import sttp.tapir.Schema 8 | 9 | final case class InstrumentResponse( 10 | instrument: Instrument 11 | ) 12 | 13 | object InstrumentResponse: 14 | given JsonCodec[InstrumentResponse] = DeriveJsonCodec.gen[InstrumentResponse] 15 | given Schema[InstrumentResponse] = Schema.derived[InstrumentResponse] 16 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/repository/InstrumentRepository.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package repository 3 | 4 | import zio.UIO 5 | import model.instrument.* 6 | 7 | trait InstrumentRepository: 8 | 9 | /** query by isin code */ 10 | def query(isin: ISINCode): UIO[Option[Instrument]] 11 | 12 | /** query by instrument type Equity / FI / CCY */ 13 | def queryByInstrumentType(instrumentType: InstrumentType): UIO[List[Instrument]] 14 | 15 | /** store */ 16 | def store(ins: Instrument): UIO[Instrument] 17 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/api/common/Exceptions.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package api 3 | package common 4 | 5 | object Exceptions: 6 | 7 | private val InvalidCredentialsMsg = "Invalid email or password!" 8 | 9 | case class BadRequest(message: String) extends RuntimeException(message) 10 | 11 | case class Unauthorized(message: String = InvalidCredentialsMsg) extends RuntimeException(message) 12 | 13 | case class NotFound(message: String) extends RuntimeException(message) 14 | 15 | case class AlreadyInUse(message: String) extends RuntimeException(message) 16 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/model/FrontOfficeOrder.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package model 3 | 4 | import java.time.Instant 5 | import zio.prelude.NonEmptyList 6 | import account.AccountNo 7 | import order.{ BuySell, Order, Quantity } 8 | import instrument.{ ISINCode, UnitPrice } 9 | import zio.stream.ZStream 10 | 11 | object frontOfficeOrder: 12 | final case class FrontOfficeOrder private[domain] ( 13 | accountNo: AccountNo, 14 | date: Instant, 15 | isin: ISINCode, 16 | qty: Quantity, 17 | unitPrice: UnitPrice, 18 | buySell: BuySell 19 | ) 20 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | postgres: 4 | restart: always 5 | image: postgres:15.2-alpine 6 | command: ["postgres", "-c", "log_statement=all", "-c", "log_destination=stderr"] 7 | ports: 8 | - "5432:5432" 9 | environment: 10 | - DEBUG=false 11 | - POSTGRES_DB=trading 12 | - POSTGRES_PASSWORD=toughgraff 13 | volumes: 14 | - ./modules/core/src/main/resources/tables.sql:/docker-entrypoint-initdb.d/init.sql 15 | healthcheck: 16 | test: ["CMD-SHELL", "pg_isready -U postgres"] 17 | interval: 5s 18 | timeout: 5s 19 | retries: 5 -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/api/common/ErrorMapper.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package api 3 | package common 4 | 5 | import zio.{ IO, Task, ZIO } 6 | 7 | object ErrorMapper: 8 | 9 | def defaultErrorsMappings[A](io: Task[A]): ZIO[Any, ErrorInfo, A] = 10 | io.mapError: 11 | case e: Exceptions.AlreadyInUse => Conflict(e.message) 12 | case e: Exceptions.NotFound => NotFound(e.message) 13 | case e: Exceptions.BadRequest => BadRequest(e.message) 14 | case e: Exceptions.Unauthorized => Unauthorized(e.message) 15 | case e => InternalServerError(e.getMessage) 16 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/repository/BalanceRepository.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package repository 3 | 4 | import zio.UIO 5 | import model.account.* 6 | import model.balance.* 7 | import java.time.LocalDate 8 | 9 | trait BalanceRepository: 10 | 11 | /** query by account number */ 12 | def query(no: AccountNo): UIO[Option[Balance]] 13 | 14 | /** store */ 15 | def store(b: Balance): UIO[Balance] 16 | 17 | /** query all balances that have amount as of this date */ 18 | /** asOf date <= this date */ 19 | def query(date: LocalDate): UIO[List[Balance]] 20 | 21 | /** all balances */ 22 | def all: UIO[List[Balance]] 23 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/transport/executionT.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package transport 3 | 4 | import zio.json.* 5 | import cats.syntax.all.* 6 | import model.execution.* 7 | import accountT.{ *, given } 8 | import orderT.{ *, given } 9 | import instrumentT.{ *, given } 10 | import java.util.UUID 11 | 12 | object executionT: 13 | given JsonDecoder[ExecutionRefNo] = 14 | JsonDecoder[UUID].mapOrFail(ExecutionRefNo.make(_).toEither.leftMap(_.head)) 15 | given JsonEncoder[ExecutionRefNo] = JsonEncoder[UUID].contramap(ExecutionRefNo.unwrap(_)) 16 | 17 | given JsonDecoder[Execution] = DeriveJsonDecoder.gen[Execution] 18 | given JsonEncoder[Execution] = DeriveJsonEncoder.gen[Execution] 19 | -------------------------------------------------------------------------------- /modules/it/src/it/scala/tradex/domain/Fixture.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | 3 | import zio.ZIO 4 | import tradex.domain.config.AppConfig 5 | import zio.{ Task, ZLayer } 6 | import tradex.domain.resources.AppResources 7 | import zio.interop.catz.* 8 | import cats.effect.std.Console 9 | import natchez.Trace.Implicits.noop 10 | 11 | object Fixture { 12 | 13 | val setupDB = 14 | for dbConf <- ZIO.serviceWith[AppConfig](_.postgreSQL) 15 | yield () 16 | 17 | given Console[Task] = Console.make[Task] 18 | 19 | val appResourcesL: ZLayer[AppConfig, Throwable, AppResources] = ZLayer.scoped( 20 | for 21 | config <- ZIO.service[AppConfig] 22 | res <- AppResources.make(config).toScopedZIO 23 | yield res 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/FlywayMigration.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | 3 | import org.flywaydb.core.Flyway 4 | import zio.Task 5 | import config._ 6 | import zio.ZIO 7 | 8 | object FlywayMigration { 9 | def migrate(config: AppConfig.PostgreSQLConfig): Task[Unit] = 10 | ZIO 11 | .attemptBlocking( 12 | Flyway 13 | .configure(this.getClass.getClassLoader) 14 | .dataSource( 15 | s"jdbc:postgresql://${config.host}:${config.port}/${config.database}", 16 | config.user, 17 | config.password.toString() 18 | ) 19 | .locations("migrations") 20 | .connectRetries(Int.MaxValue) 21 | .load() 22 | .migrate() 23 | ) 24 | .unit 25 | } 26 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/repository/AccountRepository.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package repository 3 | 4 | import java.time.LocalDate 5 | import model.account.* 6 | import zio.UIO 7 | 8 | trait AccountRepository: 9 | 10 | /** query by account number */ 11 | def query(no: AccountNo): UIO[Option[ClientAccount]] 12 | 13 | /** store */ 14 | def store(a: ClientAccount, upsert: Boolean = true): UIO[ClientAccount] 15 | 16 | /** query by opened date */ 17 | def query(openedOn: LocalDate): UIO[List[ClientAccount]] 18 | 19 | /** all accounts */ 20 | def all: UIO[List[ClientAccount]] 21 | 22 | /** all closed accounts, if date supplied then all closed after that date */ 23 | def allClosed(closeDate: Option[LocalDate]): UIO[List[ClientAccount]] 24 | -------------------------------------------------------------------------------- /modules/core/src/main/resources/executions.csv: -------------------------------------------------------------------------------- 1 | Exchange Execution Ref No,Account No,Order No,ISIN Code,Market,Buy/Sell,Unit Price,Quantity,Date of Execution 2 | 2a7d18cf-f4d7-4d11-958e-6a40e25738b5,ibm-123,ibm-123-2023-05-28,US0378331005,New York,buy,12.25,100,2023-05-28T18:00:17.440087 3 | 2a7d18cf-f4d7-4d11-958e-6a40e25738b5,ibm-123,ibm-123-2023-05-28,US0378331005,New York,buy,12.25,100,2023-05-28T18:00:17.440087 4 | 2a7d18cf-f4d7-4d11-958e-6a40e25738b5,ibm-123,ibm-123-2023-05-28,US0378331005,New York,buy,12.25,100,2023-05-28T18:00:17.440087 5 | 2a7d18cf-f4d7-4d11-958e-6a40e25738b5,ibm-123,ibm-123-2023-05-28,US0378331005,New York,buy,12.25,100,2023-05-28T18:00:17.440087 6 | 2a7d18cf-f4d7-4d11-958e-6a40e25738b5,ibm-123,ibm-123-2023-05-28,US0378331005,New York,buy,12.25,100,2023-05-28T18:00:17.440087 -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/repository/TradeRepository.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package repository 3 | 4 | import zio.{ Chunk, Task, UIO } 5 | import model.account.* 6 | import model.trade.* 7 | import model.market.* 8 | import java.time.LocalDate 9 | import zio.prelude.NonEmptyList 10 | 11 | trait TradeRepository: 12 | 13 | /** query by account number and trade date (compares using the date part only) */ 14 | def query(accountNo: AccountNo, date: LocalDate): UIO[List[Trade]] 15 | 16 | /** query by market */ 17 | def queryByMarket(market: Market): UIO[List[Trade]] 18 | 19 | /** query all trades */ 20 | def all: UIO[List[Trade]] 21 | 22 | /** store */ 23 | def store(trd: Trade): UIO[Trade] 24 | 25 | /** store many trades */ 26 | def store(trades: Chunk[Trade]): UIO[Unit] 27 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/api/common/DefectHandler.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package api 3 | package common 4 | 5 | import sttp.model.StatusCode 6 | import sttp.monad.MonadError 7 | import sttp.tapir.server.interceptor.exception.{ ExceptionContext, ExceptionHandler } 8 | import sttp.tapir.server.model.ValuedEndpointOutput 9 | import sttp.tapir.ztapir.RIOMonadError 10 | import sttp.tapir.{ statusCode, stringBody } 11 | import zio.{ Cause, RIO, Task, ZIO } 12 | 13 | class DefectHandler extends ExceptionHandler[Task]: 14 | 15 | override def apply(ctx: ExceptionContext)(implicit monad: MonadError[Task]): Task[Option[ValuedEndpointOutput[_]]] = 16 | monad.unit( 17 | Some(ValuedEndpointOutput(statusCode.and(stringBody), (StatusCode.InternalServerError, "Internal server error"))) 18 | ) 19 | -------------------------------------------------------------------------------- /modules/tests/src/test/scala/tradex/domain/model/AccountSpec.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package model 3 | 4 | import zio.test.* 5 | import zio.test.Assertion.* 6 | import squants.market.* 7 | import account._ 8 | 9 | object AccountSpec extends ZIOSpecDefault { 10 | val spec = suite("Account")( 11 | test("successfully creates an account") { 12 | val ta = TradingAccount 13 | .tradingAccount( 14 | no = AccountNo(NonEmptyString("a-123456")), 15 | name = AccountName("debasish ghosh"), 16 | baseCurrency = USD, 17 | tradingCcy = USD, 18 | dateOfOpen = None, 19 | dateOfClose = None 20 | ) 21 | .fold(errs => throw new Exception(errs.mkString), identity) 22 | assertTrue(ta.tradingCurrency == USD) 23 | } 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/service/InstrumentService.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package service 3 | 4 | import zio.{ Task, UIO } 5 | import model.instrument.* 6 | import java.time.LocalDateTime 7 | import squants.market.Money 8 | 9 | trait InstrumentService: 10 | def query(isin: ISINCode): Task[Option[Instrument]] 11 | 12 | def addEquity( 13 | isin: ISINCode, 14 | name: InstrumentName, 15 | lotSize: LotSize, 16 | issueDate: LocalDateTime, 17 | unitPrice: UnitPrice 18 | ): UIO[Instrument] 19 | 20 | def addFixedIncome( 21 | isin: ISINCode, 22 | name: InstrumentName, 23 | lotSize: LotSize, 24 | issueDate: LocalDateTime, 25 | maturityDate: Option[LocalDateTime], 26 | couponRate: Money, 27 | couponFrequency: CouponFrequency 28 | ): Task[Instrument] 29 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/repository/OrderRepository.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package repository 3 | 4 | import zio.UIO 5 | import model.order.* 6 | import java.time.LocalDate 7 | import zio.prelude.NonEmptyList 8 | import zio.stream.ZStream 9 | 10 | trait OrderRepository: 11 | 12 | /** query by unique key order no, account number and date */ 13 | def query(no: OrderNo): UIO[Option[Order]] 14 | 15 | /** query by order date */ 16 | def queryByOrderDate(date: LocalDate): UIO[List[Order]] 17 | 18 | /** store */ 19 | def store(ord: Order): UIO[Order] 20 | 21 | /** store many orders */ 22 | def store(orders: NonEmptyList[Order]): UIO[Unit] 23 | 24 | /** stream all orders for the day */ 25 | def streamOrders( 26 | executionDate: LocalDate 27 | ): ZStream[Any, Throwable, Order] = ??? 28 | 29 | def cleanAllOrders: UIO[Unit] 30 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/api/AddEquityRequest.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package api 3 | 4 | import model.instrument.* 5 | import java.time.LocalDateTime 6 | import zio.json.* 7 | import transport.instrumentT.{ *, given } 8 | import sttp.tapir.Schema 9 | import sttp.tapir.generic.auto.* 10 | 11 | final case class AddEquityRequest( 12 | equityData: AddEquityData 13 | ) 14 | 15 | object AddEquityRequest: 16 | given JsonCodec[AddEquityRequest] = DeriveJsonCodec.gen[AddEquityRequest] 17 | given Schema[AddEquityRequest] = Schema.derived[AddEquityRequest] 18 | 19 | final case class AddEquityData( 20 | isin: ISINCode, 21 | name: InstrumentName, 22 | lotSize: LotSize, 23 | issueDate: LocalDateTime, 24 | unitPrice: UnitPrice 25 | ) 26 | 27 | object AddEquityData: 28 | given JsonCodec[AddEquityData] = DeriveJsonCodec.gen[AddEquityData] 29 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/repository/ExecutionRepository.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package repository 3 | 4 | import zio.UIO 5 | import model.execution.* 6 | import zio.prelude.NonEmptyList 7 | import java.time.LocalDate 8 | import zio.stream.ZStream 9 | 10 | trait ExecutionRepository: 11 | 12 | /** store */ 13 | def store(exe: Execution): UIO[Execution] 14 | 15 | /** store many executions */ 16 | def store(executions: NonEmptyList[Execution]): UIO[Unit] 17 | 18 | /** query all executions for the day */ 19 | def query(dateOfExecution: LocalDate): UIO[List[Execution]] 20 | 21 | /** delete all executions */ 22 | def cleanAllExecutions: UIO[Unit] 23 | 24 | /** stream all executions for the day for all orders group by orderNo */ 25 | def streamExecutions( 26 | executionDate: LocalDate 27 | ): ZStream[Any, Throwable, Execution] = ??? 28 | -------------------------------------------------------------------------------- /project/versions.scala: -------------------------------------------------------------------------------- 1 | object Versions { 2 | val catsVersion = "2.9.0" 3 | val catsEffectVersion = "3.5.1" 4 | val scalaVersion = "3.3.0" 5 | val log4j2Version = "2.13.1" 6 | val logbackVersion = "1.2.6" 7 | val squantsVersion = "1.8.3" 8 | val circeVersion = "0.14.1" 9 | val monocleVersion = "3.1.0" 10 | val skunkVersion = "0.5.1" 11 | val zioVersion = "2.0.15" 12 | val zioPreludeVersion = "1.0.0-RC19" 13 | val zioInteropCatsVersion = "23.0.0.8" 14 | val zioConfigVersion = "4.0.0-RC16" 15 | val zioLoggingVersion = "2.1.11" 16 | val quickLensVersion = "1.9.2" 17 | val zioJsonVersion = "0.6.0" 18 | val sttpZioJsonVersion = "3.8.15" 19 | val flywayDbVersion = "9.1.6" 20 | val kantanCsvVersion = "0.7.0" 21 | val tapirVersion = "1.3.0" 22 | } 23 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/Endpoints.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | 3 | import zio.{ Task, ZLayer } 4 | import api.endpoints.TradingServerEndpoints 5 | import sttp.tapir.ztapir.ZServerEndpoint 6 | import sttp.tapir.swagger.bundle.SwaggerInterpreter 7 | 8 | final case class Endpoints( 9 | tradingServerEndpoints: TradingServerEndpoints 10 | ): 11 | val endpoints: List[ZServerEndpoint[Any, Any]] = 12 | val api = tradingServerEndpoints.endpoints 13 | val docs = docsEndpoints(api) 14 | api ++ docs 15 | 16 | private def docsEndpoints(apiEndpoints: List[ZServerEndpoint[Any, Any]]): List[ZServerEndpoint[Any, Any]] = 17 | SwaggerInterpreter() 18 | .fromServerEndpoints[Task](apiEndpoints, "trading-back-office", "0.1.0") 19 | 20 | object Endpoints: 21 | val live: ZLayer[ 22 | TradingServerEndpoints, 23 | Nothing, 24 | Endpoints 25 | ] = ZLayer.fromFunction(Endpoints.apply _) 26 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/model/market.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package model 3 | 4 | import zio.prelude.* 5 | 6 | object market: 7 | 8 | enum Market(val entryName: String): 9 | case NewYork extends Market("New York") 10 | case Tokyo extends Market("Tokyo") 11 | case Singapore extends Market("Singapore") 12 | case HongKong extends Market("Hong Kong") 13 | case Other extends Market("Other") 14 | 15 | object Market: 16 | 17 | def withValue(value: String): Validation[String, Market] = 18 | value match 19 | case "New York" => Validation.succeed(NewYork) 20 | case "Tokyo" => Validation.succeed(Tokyo) 21 | case "Singapore" => Validation.succeed(Singapore) 22 | case "HongKong" => Validation.succeed(HongKong) 23 | case "Other" => Validation.succeed(Other) 24 | case _ => Validation.fail("Error in value") 25 | 26 | end Market 27 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/model/balance.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package model 3 | 4 | import zio.prelude.Validation 5 | import java.time.LocalDateTime 6 | import squants.market.* 7 | import account.* 8 | 9 | object balance: 10 | final case class Balance private[domain] ( 11 | accountNo: AccountNo, 12 | amount: Money, 13 | currency: Currency, 14 | asOf: LocalDateTime 15 | ) 16 | 17 | object Balance: 18 | def balance( 19 | accountNo: AccountNo, 20 | amount: Money, 21 | currency: Currency, 22 | asOf: LocalDateTime 23 | ): Validation[String, Balance] = 24 | validateAsOfDate(asOf) 25 | .map(dt => Balance(accountNo, amount, currency, dt)) 26 | 27 | private def validateAsOfDate( 28 | date: LocalDateTime 29 | ): Validation[String, LocalDateTime] = 30 | if (date.isAfter(today)) 31 | Validation.fail(s"Balance date [$date] cannot be later than today") 32 | else Validation.succeed(date) 33 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/transport/frontOfficeOrderT.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package transport 3 | 4 | import kantan.csv.{ HeaderCodec, RowDecoder } 5 | import kantan.csv.java8.* 6 | import model.frontOfficeOrder.FrontOfficeOrder 7 | import cellCodecs.{ *, given } 8 | import model.account.AccountNo 9 | import java.time.Instant 10 | import model.instrument.ISINCode 11 | import model.order.Quantity 12 | import model.instrument.UnitPrice 13 | import model.order.BuySell 14 | 15 | object frontOfficeOrderT: 16 | given RowDecoder[FrontOfficeOrder] = RowDecoder.decoder(0, 1, 2, 3, 4, 5)(FrontOfficeOrder.apply) 17 | given HeaderCodec[FrontOfficeOrder] = 18 | HeaderCodec.codec[AccountNo, Instant, ISINCode, Quantity, UnitPrice, BuySell, FrontOfficeOrder]( 19 | "Account No", 20 | "Order Date", 21 | "ISIN Code", 22 | "Quantity", 23 | "Unit Price", 24 | "Buy/Sell" 25 | )(FrontOfficeOrder(_, _, _, _, _, _))(fo => (fo.accountNo, fo.date, fo.isin, fo.qty, fo.unitPrice, fo.buySell)) 26 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/transport/tradeT.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package transport 3 | 4 | import zio.json.* 5 | import sttp.tapir.{ Schema, SchemaType } 6 | import sttp.tapir.generic.auto.* 7 | import java.util.UUID 8 | import cats.syntax.all.* 9 | import model.trade.* 10 | import accountT.{ *, given } 11 | import instrumentT.{ *, given } 12 | import orderT.{ *, given } 13 | import userT.{ *, given } 14 | 15 | object tradeT: 16 | given JsonDecoder[TradeRefNo] = 17 | JsonDecoder[UUID].mapOrFail(TradeRefNo.make(_).toEither.leftMap(_.head)) 18 | given JsonEncoder[TradeRefNo] = JsonEncoder[UUID].contramap(TradeRefNo.unwrap(_)) 19 | 20 | given JsonCodec[TaxFeeId] = DeriveJsonCodec.gen[TaxFeeId] 21 | given JsonCodec[TradeTaxFee] = DeriveJsonCodec.gen[TradeTaxFee] 22 | given JsonCodec[Trade] = DeriveJsonCodec.gen[Trade] 23 | 24 | given Schema[TradeRefNo] = Schema.string 25 | given Schema[TradeTaxFee] = Schema.derived[TradeTaxFee] 26 | given Schema[Trade] = Schema.derived[Trade] 27 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/api/AddFixedIncomeRequest.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package api 3 | 4 | import model.instrument.* 5 | import java.time.LocalDateTime 6 | import zio.json.* 7 | import transport.instrumentT.{ *, given } 8 | import sttp.tapir.Schema 9 | import sttp.tapir.generic.auto.* 10 | import squants.market.Money 11 | 12 | final case class AddFixedIncomeRequest( 13 | fiData: AddFixedIncomeData 14 | ) 15 | 16 | object AddFixedIncomeRequest: 17 | given JsonCodec[AddFixedIncomeRequest] = DeriveJsonCodec.gen[AddFixedIncomeRequest] 18 | given Schema[AddFixedIncomeRequest] = Schema.derived[AddFixedIncomeRequest] 19 | 20 | final case class AddFixedIncomeData( 21 | isin: ISINCode, 22 | name: InstrumentName, 23 | lotSize: LotSize, 24 | issueDate: LocalDateTime, 25 | maturityDate: Option[LocalDateTime], 26 | couponRate: Money, 27 | couponFrequency: CouponFrequency 28 | ) 29 | 30 | object AddFixedIncomeData: 31 | given JsonCodec[AddFixedIncomeData] = DeriveJsonCodec.gen[AddFixedIncomeData] 32 | -------------------------------------------------------------------------------- /modules/it/src/it/scala/tradex/domain/api/TestUtils.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package api 3 | 4 | import zio.* 5 | import sttp.tapir.server.stub.TapirStubInterpreter 6 | import sttp.tapir.server.ziohttp.ZioHttpServerOptions 7 | import sttp.client3.testing.SttpBackendStub 8 | import sttp.tapir.ztapir.{ RIOMonadError, ZServerEndpoint } 9 | import sttp.client3.SttpBackend 10 | import api.common.{ CustomDecodeFailureHandler, DefectHandler } 11 | 12 | object TestUtils: 13 | 14 | def zioTapirStubInterpreter: TapirStubInterpreter[[_$1] =>> RIO[Any, _$1], Nothing, ZioHttpServerOptions[Any]] = 15 | TapirStubInterpreter( 16 | ZioHttpServerOptions.customiseInterceptors 17 | .exceptionHandler(new DefectHandler()) 18 | .decodeFailureHandler(CustomDecodeFailureHandler.create()), 19 | SttpBackendStub(new RIOMonadError[Any]) 20 | ) 21 | 22 | def backendStub(endpoint: ZServerEndpoint[Any, Any]): SttpBackend[[_$1] =>> RIO[Any, _$1], Nothing] = 23 | zioTapirStubInterpreter 24 | .whenServerEndpoint(endpoint) 25 | .thenRunLogic() 26 | .backend() 27 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/transport/orderT.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package transport 3 | 4 | import zio.json.* 5 | import cats.syntax.all.* 6 | import model.order.* 7 | import model.market.* 8 | import instrumentT.{ *, given } 9 | import accountT.{ *, given } 10 | import sttp.tapir.{ Schema, SchemaType } 11 | 12 | object orderT { 13 | given JsonDecoder[OrderNo] = 14 | JsonDecoder[String].mapOrFail(OrderNo.make(_).toEither.leftMap(_.head)) 15 | given JsonEncoder[OrderNo] = JsonEncoder[String].contramap(OrderNo.unwrap(_)) 16 | 17 | given JsonCodec[BuySell] = DeriveJsonCodec.gen[BuySell] 18 | given JsonCodec[Market] = DeriveJsonCodec.gen[Market] 19 | given JsonDecoder[Quantity] = 20 | JsonDecoder[BigDecimal].mapOrFail(Quantity.make(_).toEither.leftMap(_.head)) 21 | given JsonEncoder[Quantity] = 22 | JsonEncoder[BigDecimal].contramap(Quantity.unwrap(_)) 23 | 24 | given JsonCodec[LineItem] = DeriveJsonCodec.gen[LineItem] 25 | given JsonCodec[Order] = DeriveJsonCodec.gen[Order] 26 | 27 | given Schema[Quantity] = Schema(SchemaType.SNumber()) 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Debasish Ghosh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/config.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | 3 | import zio.config.*, typesafe.*, magnolia.* 4 | import zio.{ Config, TaskLayer, ZLayer } 5 | import zio.Config.Secret 6 | 7 | object config: 8 | final case class AppConfig( 9 | postgreSQL: AppConfig.PostgreSQLConfig, 10 | httpServer: AppConfig.HttpServerConfig, 11 | tradingConfig: AppConfig.TradingConfig 12 | ) 13 | 14 | object AppConfig: 15 | final case class PostgreSQLConfig( 16 | host: NonEmptyString, 17 | port: Int, 18 | user: NonEmptyString, 19 | password: NonEmptyString, // @todo : need to change to Secret 20 | database: NonEmptyString, 21 | max: Int 22 | ) 23 | final case class HttpServerConfig(host: NonEmptyString, port: Int) 24 | 25 | final case class TradingConfig( 26 | maxAccountNoLength: Int, 27 | minAccountNoLength: Int, 28 | zeroBalanceAllowed: Boolean 29 | ) 30 | 31 | final val Root = "tradex" 32 | 33 | private final val Descriptor = deriveConfig[AppConfig] 34 | 35 | val appConfig = ZLayer(TypesafeConfigProvider.fromResourcePath().nested(Root).load(Descriptor)) 36 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/model/user.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package model 3 | 4 | import zio.prelude.* 5 | import scala.util.control.NoStackTrace 6 | import java.util.UUID 7 | 8 | object user: 9 | 10 | object UserId extends Newtype[UUID]: 11 | given Equal[UserId] = Equal.default 12 | 13 | type UserId = UserId.Type 14 | 15 | object UserName extends Newtype[NonEmptyString] 16 | type UserName = UserName.Type 17 | 18 | object Password extends Newtype[NonEmptyString] 19 | type Password = Password.Type 20 | 21 | object EncryptedPassword extends Newtype[NonEmptyString] 22 | type EncryptedPassword = EncryptedPassword.Type 23 | 24 | case class UserNotFound(username: UserName) extends NoStackTrace 25 | case class UserNameInUse(username: UserName) extends NoStackTrace 26 | case class InvalidPassword(username: UserName) extends NoStackTrace 27 | case object UnsupportedOperation extends NoStackTrace 28 | case object TokenNotFound extends NoStackTrace 29 | 30 | private[domain] final case class User private[domain] ( 31 | userId: UserId, 32 | userName: UserName, 33 | password: EncryptedPassword 34 | ) 35 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/transport/userT.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package transport 3 | 4 | import model.user.* 5 | import zio.json.* 6 | import java.util.UUID 7 | import cats.syntax.all.* 8 | import sttp.tapir.Schema 9 | 10 | object userT: 11 | given JsonDecoder[UserId] = 12 | JsonDecoder[UUID].mapOrFail(UserId.make(_).toEither.leftMap(_.head)) 13 | given JsonEncoder[UserId] = JsonEncoder[UUID].contramap(UserId.unwrap(_)) 14 | 15 | given JsonDecoder[UserName] = 16 | JsonDecoder[NonEmptyString].mapOrFail(UserName.make(_).toEither.leftMap(_.head)) 17 | given JsonEncoder[UserName] = JsonEncoder[String].contramap(UserName.unwrap(_)) 18 | 19 | given JsonDecoder[EncryptedPassword] = 20 | JsonDecoder[NonEmptyString].mapOrFail(EncryptedPassword.make(_).toEither.leftMap(_.head)) 21 | given JsonEncoder[EncryptedPassword] = JsonEncoder[String].contramap(EncryptedPassword.unwrap(_)) 22 | 23 | given JsonCodec[User] = DeriveJsonCodec.gen[User] 24 | 25 | given Schema[UserId] = Schema.string 26 | given Schema[UserName] = Schema.string 27 | given Schema[EncryptedPassword] = Schema.string 28 | given Schema[User] = Schema.derived[User] 29 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/api/common/BaseEndpoints.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package api 3 | package common 4 | 5 | import zio.ZLayer 6 | import sttp.tapir.{ EndpointOutput, PublicEndpoint } 7 | import sttp.tapir.ztapir.* 8 | import sttp.model.StatusCode 9 | import sttp.tapir.json.zio.jsonBody 10 | import sttp.tapir.generic.auto.* 11 | 12 | case class BaseEndpoints(): 13 | 14 | val publicEndpoint: PublicEndpoint[Unit, ErrorInfo, Unit, Any] = endpoint 15 | .errorOut(BaseEndpoints.defaultErrorOutputs) 16 | 17 | object BaseEndpoints: 18 | val live: ZLayer[Any, Nothing, BaseEndpoints] = 19 | ZLayer.fromFunction(BaseEndpoints.apply _) 20 | 21 | val defaultErrorOutputs: EndpointOutput.OneOf[ErrorInfo, ErrorInfo] = oneOf[ErrorInfo]( 22 | oneOfVariant(statusCode(StatusCode.BadRequest).and(jsonBody[BadRequest])), 23 | oneOfVariant(statusCode(StatusCode.Forbidden).and(jsonBody[Forbidden])), 24 | oneOfVariant(statusCode(StatusCode.NotFound).and(jsonBody[NotFound])), 25 | oneOfVariant(statusCode(StatusCode.Conflict).and(jsonBody[Conflict])), 26 | oneOfVariant(statusCode(StatusCode.Unauthorized).and(jsonBody[Unauthorized])), 27 | oneOfVariant(statusCode(StatusCode.UnprocessableEntity).and(jsonBody[ValidationFailed])), 28 | oneOfVariant(statusCode(StatusCode.InternalServerError).and(jsonBody[InternalServerError])) 29 | ) 30 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/csv/CSV.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package csv 3 | 4 | import java.io.Reader 5 | import zio.{ Chunk, ZIO } 6 | import zio.stream.{ ZPipeline, ZStream } 7 | import kantan.csv.{ CsvConfiguration, HeaderEncoder, ReadError, RowDecoder, rfc } 8 | import kantan.csv.engine.ReaderEngine 9 | import kantan.csv.ops.* 10 | import java.nio.charset.CharacterCodingException 11 | import kantan.csv.HeaderEncoder 12 | 13 | object CSV: 14 | def encode[A: HeaderEncoder]: ZPipeline[Any, CharacterCodingException, A, Byte] = 15 | ZPipeline.suspend( 16 | ZPipeline.mapChunks((in: Chunk[A]) => Chunk.single(in.asCsv(rfc.withHeader).trim)) >>> 17 | ZPipeline.intersperse("\r\n") >>> 18 | ZPipeline.utf8Encode 19 | ) 20 | 21 | def decode[A: RowDecoder](reader: Reader, conf: CsvConfiguration)(using 22 | ReaderEngine 23 | ): ZStream[Any, Throwable, A] = 24 | ZStream.fromIterator( 25 | reader 26 | .asCsvReader[A](conf) 27 | .collect { case Right(value) => value } 28 | .iterator 29 | ) 30 | 31 | enum ParsedResult[+A]: 32 | case Failed(index: Long, error: ReadError) extends ParsedResult[Nothing] 33 | case Succeed[A](index: Long, value: A) extends ParsedResult[A] 34 | 35 | def toEither: Either[ReadError, A] = this match 36 | case Failed(_, error) => Left(error) 37 | case Succeed(_, value) => Right(value) 38 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/model/exchangeExecution.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package model 3 | 4 | import zio.prelude.* 5 | import zio.{ Clock, Random, Task, ZIO } 6 | import instrument.* 7 | import order.* 8 | import market.* 9 | import account.* 10 | import java.time.LocalDateTime 11 | import java.util.UUID 12 | import java.time.ZoneOffset 13 | import java.time.Instant 14 | 15 | object exchangeExecution: 16 | 17 | final case class ExchangeExecution private[domain] ( 18 | exchangeExecutionRefNo: String, 19 | accountNo: AccountNo, 20 | orderNo: OrderNo, 21 | isin: ISINCode, 22 | market: Market, 23 | buySell: BuySell, 24 | unitPrice: UnitPrice, 25 | quantity: Quantity, 26 | dateOfExecution: LocalDateTime 27 | ) 28 | 29 | object ExchangeExecution: 30 | def fromOrder(order: Order, market: Market, date: Instant): Task[NonEmptyList[ExchangeExecution]] = 31 | Random.nextUUID.flatMap: uuid => 32 | val executions = order.items.map { item => 33 | ExchangeExecution( 34 | uuid.toString, 35 | order.accountNo, 36 | order.no, 37 | item.isin, 38 | market, 39 | item.buySell, 40 | item.unitPrice, 41 | item.quantity, 42 | LocalDateTime.ofInstant(date, ZoneOffset.UTC) 43 | ) 44 | } 45 | ZIO.succeed(executions) 46 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/transport/exchangeExecutionT.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package transport 3 | 4 | import kantan.csv.{ HeaderCodec, RowDecoder } 5 | import kantan.csv.java8.* 6 | import model.exchangeExecution.* 7 | import cellCodecs.{ *, given } 8 | import model.account.AccountNo 9 | import model.order.{ BuySell, OrderNo, Quantity } 10 | import model.instrument.ISINCode 11 | import model.market.Market 12 | import model.instrument.UnitPrice 13 | import java.time.LocalDateTime 14 | 15 | object exchangeExecutionT: 16 | given RowDecoder[ExchangeExecution] = RowDecoder.decoder(0, 1, 2, 3, 4, 5, 6, 7, 8)(ExchangeExecution.apply) 17 | given HeaderCodec[ExchangeExecution] = 18 | HeaderCodec.codec[ 19 | String, 20 | AccountNo, 21 | OrderNo, 22 | ISINCode, 23 | Market, 24 | BuySell, 25 | UnitPrice, 26 | Quantity, 27 | LocalDateTime, 28 | ExchangeExecution 29 | ]( 30 | "Exchange Execution Ref No", 31 | "Account No", 32 | "Order No", 33 | "ISIN Code", 34 | "Market", 35 | "Buy/Sell", 36 | "Unit Price", 37 | "Quantity", 38 | "Date of Execution" 39 | )(ExchangeExecution(_, _, _, _, _, _, _, _, _))(ee => 40 | ( 41 | ee.exchangeExecutionRefNo, 42 | ee.accountNo, 43 | ee.orderNo, 44 | ee.isin, 45 | ee.market, 46 | ee.buySell, 47 | ee.unitPrice, 48 | ee.quantity, 49 | ee.dateOfExecution 50 | ) 51 | ) 52 | -------------------------------------------------------------------------------- /modules/it/src/it/scala/tradex/domain/api/RepositoryTestSupport.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package api 3 | 4 | import zio.ZIO 5 | import model.instrument.* 6 | import cats.syntax.all.* 7 | import java.time.LocalDateTime 8 | import repository.InstrumentRepository 9 | 10 | object RepositoryTestSupport: 11 | val exampleInstrument = Equity.equity( 12 | isin = ISINCode 13 | .make("US30303M1027") 14 | .toEitherAssociative 15 | .leftMap(identity) 16 | .fold(err => throw new Exception(err), identity), 17 | name = InstrumentName(NonEmptyString("Meta")), 18 | lotSize = LotSize(100), 19 | issueDate = LocalDateTime.now(), 20 | unitPrice = UnitPrice 21 | .make(100) 22 | .toEitherAssociative 23 | .leftMap(identity) 24 | .fold(err => throw new Exception(err), identity) 25 | ) 26 | 27 | def insertOneEquity: ZIO[InstrumentRepository, Throwable, Instrument] = 28 | ZIO.serviceWithZIO[InstrumentRepository](_.store(exampleInstrument)) 29 | 30 | val addEquityData = AddEquityData( 31 | isin = ISINCode 32 | .make("US30303M1057") 33 | .toEitherAssociative 34 | .leftMap(identity) 35 | .fold(err => throw new Exception(err), identity), 36 | name = InstrumentName(NonEmptyString("NRI")), 37 | lotSize = LotSize(100), 38 | issueDate = LocalDateTime.now(), 39 | unitPrice = UnitPrice 40 | .make(100) 41 | .toEitherAssociative 42 | .leftMap(identity) 43 | .fold(err => throw new Exception(err), identity) 44 | ) 45 | -------------------------------------------------------------------------------- /modules/tests/src/test/scala/tradex/domain/csv/CSVSpec.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain.csv 2 | 3 | import zio.Scope 4 | import zio.test.* 5 | import zio.test.Assertion.* 6 | import kantan.csv.{ CsvConfiguration, HeaderCodec, RowDecoder, rfc } 7 | import zio.stream.ZStream 8 | import kantan.csv.CsvConfiguration 9 | import java.io.StringReader 10 | 11 | object CSVSpec extends ZIOSpecDefault { 12 | override def spec: Spec[TestEnvironment & Scope, Any] = 13 | suite("CSV")( 14 | test("encode / decode") { 15 | val data = List(Account("ibm", 1), Account("nri", 2), Account("hitachi", 3)) 16 | 17 | val encoded = 18 | ZStream 19 | .fromIterable(data) 20 | .via(CSV.encode[Account]) 21 | .runCollect 22 | .map(bytes => new String(bytes.toArray)) 23 | 24 | encoded.map(actual => 25 | assertTrue(actual.split("\r\n") sameElements Array("Name,No", "ibm,1", "nri,2", "hitachi,3")) 26 | ) 27 | 28 | encoded.flatMap(e => 29 | CSV 30 | .decode[Account](new StringReader(e), rfc) 31 | .runCollect 32 | .map(decoded => assertTrue(decoded sameElements data)) 33 | ) 34 | } 35 | ) 36 | } 37 | 38 | case class Account(name: String, no: Int) 39 | 40 | object Account: 41 | given RowDecoder[Account] = 42 | RowDecoder.decoder(0, 1)(Account.apply) 43 | given HeaderCodec[Account] = 44 | HeaderCodec.codec[String, Int, Account]("Name", "No")(Account(_, _))(e => (e.name, e.no)) 45 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/service/live/InstrumentServiceLive.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package service 3 | package live 4 | 5 | import zio.{ Task, UIO, ZIO, ZLayer } 6 | import repository.InstrumentRepository 7 | import model.instrument.* 8 | import java.time.LocalDateTime 9 | import squants.market.Money 10 | import zio.prelude.ZValidation.{ Failure, Success } 11 | 12 | final case class InstrumentServiceLive( 13 | repository: InstrumentRepository 14 | ) extends InstrumentService: 15 | override def query(isin: ISINCode): Task[Option[Instrument]] = 16 | repository.query(isin) 17 | 18 | override def addEquity( 19 | isin: ISINCode, 20 | name: InstrumentName, 21 | lotSize: LotSize, 22 | issueDate: LocalDateTime, 23 | unitPrice: UnitPrice 24 | ): UIO[Instrument] = 25 | repository.store(Equity.equity(isin, name, lotSize, issueDate, unitPrice)) 26 | 27 | override def addFixedIncome( 28 | isin: ISINCode, 29 | name: InstrumentName, 30 | lotSize: LotSize, 31 | issueDate: LocalDateTime, 32 | maturityDate: Option[LocalDateTime], 33 | couponRate: Money, 34 | couponFrequency: CouponFrequency 35 | ): Task[Instrument] = 36 | FixedIncome.fixedIncome(isin, name, lotSize, issueDate, maturityDate, couponRate, couponFrequency) match 37 | case Success(_, fi) => repository.store(fi) 38 | case Failure(_, errs) => 39 | ZIO.fail(new IllegalArgumentException(s"Invalid FixedIncome: ${errs.mkString(",")}")) 40 | 41 | object InstrumentServiceLive: 42 | val layer = ZLayer.fromFunction(InstrumentServiceLive.apply _) 43 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/model/execution.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package model 3 | 4 | import zio.prelude.* 5 | import zio.{ Task, ZIO } 6 | import instrument.* 7 | import order.* 8 | import market.* 9 | import account.* 10 | import java.time.LocalDateTime 11 | import java.util.UUID 12 | import zio.Clock 13 | import java.time.ZoneOffset 14 | import zio.Random 15 | 16 | object execution: 17 | 18 | object ExecutionRefNo extends Newtype[UUID]: 19 | implicit val ExecutionRefNoEqual: Equal[ExecutionRefNo] = 20 | Equal.default 21 | 22 | type ExecutionRefNo = ExecutionRefNo.Type 23 | 24 | final case class Execution private[domain] ( 25 | executionRefNo: ExecutionRefNo, 26 | accountNo: AccountNo, 27 | orderNo: OrderNo, 28 | isin: ISINCode, 29 | market: Market, 30 | buySell: BuySell, 31 | unitPrice: UnitPrice, 32 | quantity: Quantity, 33 | dateOfExecution: LocalDateTime, 34 | exchangeExecutionRefNo: Option[String] = None 35 | ) 36 | 37 | object Execution: 38 | def fromOrder(order: Order, market: Market): Task[NonEmptyList[Execution]] = 39 | Clock.instant.flatMap: now => 40 | Random.nextUUID.flatMap: uuid => 41 | val executions = order.items.map: item => 42 | Execution( 43 | ExecutionRefNo(uuid), 44 | order.accountNo, 45 | order.no, 46 | item.isin, 47 | market, 48 | item.buySell, 49 | item.unitPrice, 50 | item.quantity, 51 | LocalDateTime.ofInstant(now, ZoneOffset.UTC) 52 | ) 53 | ZIO.succeed(executions) 54 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/resources/AppResources.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package resources 3 | 4 | import zio.{ Task, ZIO } 5 | import skunk.{ Session, SessionPool } 6 | import skunk.util.Typer 7 | import skunk.codec.text._ 8 | import skunk.implicits._ 9 | import natchez.Trace.Implicits.noop // needed for skunk 10 | import cats.effect._ 11 | import cats.effect.std.Console 12 | import cats.syntax.all._ 13 | import cats.effect.kernel.{ Resource, Temporal } 14 | import fs2.io.net.Network 15 | import tradex.domain.config.AppConfig 16 | import config.AppConfig.PostgreSQLConfig 17 | 18 | sealed abstract class AppResources private ( 19 | val postgres: Resource[Task, Session[Task]] 20 | ) 21 | 22 | object AppResources { 23 | def make( 24 | cfg: AppConfig 25 | )(using Temporal[Task], natchez.Trace[Task], Network[Task], Console[Task]): Resource[Task, AppResources] = { 26 | 27 | def checkPostgresConnection( 28 | postgres: Resource[Task, Session[Task]] 29 | ): Task[Unit] = 30 | postgres.use { session => 31 | session.unique(sql"select version();".query(text)).flatMap { v => 32 | ZIO.logInfo(s"Connected to Postgres $v") 33 | } 34 | } 35 | 36 | def mkPostgreSqlResource(c: PostgreSQLConfig): SessionPool[Task] = 37 | Session 38 | .pooled[Task]( 39 | host = c.host, 40 | port = c.port, 41 | user = c.user, 42 | password = Some(c.password), 43 | database = c.database, 44 | max = c.max, 45 | strategy = Typer.Strategy.SearchPath 46 | ) 47 | .evalTap(checkPostgresConnection) 48 | 49 | mkPostgreSqlResource(cfg.postgreSQL).map(r => new AppResources(r) {}) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/repository/live/UserRepositoryLive.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package repository 3 | package live 4 | 5 | import zio.{ Random, Task, UIO } 6 | import cats.effect.Resource 7 | import skunk.* 8 | import skunk.codec.all.* 9 | import skunk.implicits.* 10 | import model.user.* 11 | import codecs.{ *, given } 12 | import zio.interop.catz.* 13 | 14 | final case class UserRepositoryLive(postgres: Resource[Task, Session[Task]]) extends UserRepository: 15 | import UserRepositorySQL.* 16 | 17 | override def query(userName: UserName): UIO[Option[User]] = 18 | postgres 19 | .use: session => 20 | session 21 | .prepare(selectByUserName) 22 | .flatMap: ps => 23 | ps.option(userName) 24 | .orDie 25 | 26 | override def store(userName: UserName, password: EncryptedPassword): UIO[UserId] = 27 | postgres 28 | .use: session => 29 | session 30 | .prepare(upsertUser) 31 | .flatMap: cmd => 32 | Random.nextUUID.flatMap: id => 33 | cmd 34 | .execute(User(UserId(id), userName, password)) 35 | .as(UserId(id)) 36 | .orDie 37 | 38 | private[domain] object UserRepositorySQL: 39 | val decoder: Decoder[User] = 40 | (userId ~ userName ~ encPassword) 41 | .gmap[User] 42 | 43 | val selectByUserName: Query[UserName, User] = 44 | sql""" 45 | SELECT u.id, u.name, u.password 46 | FROM users AS u 47 | WHERE u.name = $userName 48 | """.query(decoder) 49 | 50 | val upsertUser: Command[User] = 51 | sql""" 52 | INSERT INTO users (id, name, password) 53 | VALUES ($userId, $userName, $encPassword) 54 | ON CONFLICT(name) DO UPDATE SET 55 | password = EXCLUDED.password 56 | """.command.gcontramap[User] 57 | -------------------------------------------------------------------------------- /modules/it/src/it/scala/tradex/domain/api/TradingEndpointsTestSupport.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package api 3 | 4 | import zio.* 5 | import sttp.model.Uri 6 | import sttp.client3.* 7 | import sttp.client3.ziojson.* 8 | import api.endpoints.TradingEndpoints 9 | import sttp.tapir.ztapir.ZServerEndpoint 10 | import api.endpoints.TradingServerEndpoints 11 | import TestUtils.* 12 | import zio.json.JsonCodec 13 | 14 | object TradingEndpointsTestSupport: 15 | def callGetInstrumentEndpoint( 16 | uri: Uri 17 | ): ZIO[TradingServerEndpoints, Throwable, Either[ResponseException[String, String], InstrumentResponse]] = 18 | val getInstrumentEndpoint = 19 | ZIO 20 | .service[TradingServerEndpoints] 21 | .map(_.getInstrumentEndpoint) 22 | 23 | val requestWithUri = basicRequest.get(uri) 24 | executeRequest[InstrumentResponse](requestWithUri, getInstrumentEndpoint) 25 | 26 | def callAddEquityEndpoint( 27 | uri: Uri, 28 | equityData: AddEquityData 29 | ): ZIO[TradingServerEndpoints, Throwable, Either[ResponseException[String, String], InstrumentResponse]] = 30 | ZIO 31 | .service[TradingServerEndpoints] 32 | .map(_.addEquityEndpoint) 33 | .flatMap { endpoint => 34 | basicRequest 35 | .put(uri) 36 | .body(AddEquityRequest(equityData)) 37 | .response(asJson[InstrumentResponse]) 38 | .send(backendStub(endpoint)) 39 | .map(_.body) 40 | } 41 | 42 | private def executeRequest[T: JsonCodec]( 43 | requestWithUri: Request[Either[String, String], Any], 44 | endpoint: ZIO[TradingServerEndpoints, Nothing, ZServerEndpoint[Any, Any]] 45 | ) = 46 | endpoint 47 | .flatMap { endpoint => 48 | requestWithUri 49 | .response(asJson[T]) 50 | .send(backendStub(endpoint)) 51 | .map(_.body) 52 | } 53 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/service/live/ExchangeExecutionParsingServiceLive.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package service 3 | package live 4 | 5 | import zio.{ Random, Task, UIO, ZIO, ZLayer } 6 | import zio.stream.{ ZPipeline, ZStream } 7 | import zio.prelude.NonEmptyList 8 | import java.io.Reader 9 | import kantan.csv.rfc 10 | import transport.exchangeExecutionT.{ *, given } 11 | import model.exchangeExecution.* 12 | import model.execution.* 13 | import repository.ExecutionRepository 14 | import csv.CSV 15 | 16 | final case class ExchangeExecutionParsingServiceLive( 17 | executionRepo: ExecutionRepository 18 | ) extends ExchangeExecutionParsingService: 19 | def parse(data: Reader): Task[Unit] = 20 | parseAllRows(data) 21 | .via(convertToExecution) 22 | .runForeachChunk(executions => 23 | ZIO.when(executions.nonEmpty)(executionRepo.store(NonEmptyList(executions.head, executions.tail.toList: _*))) 24 | ) 25 | 26 | private def parseAllRows(data: Reader): ZStream[Any, Throwable, ExchangeExecution] = 27 | CSV.decode[ExchangeExecution](data, rfc.withHeader) 28 | 29 | private def convertToExecution: ZPipeline[Any, Nothing, ExchangeExecution, Execution] = 30 | ZPipeline 31 | .mapZIO(toExecution(_)) 32 | 33 | private def toExecution(exchangeExecution: ExchangeExecution): UIO[Execution] = Random.nextUUID.map: uuid => 34 | Execution( 35 | ExecutionRefNo(uuid), 36 | exchangeExecution.accountNo, 37 | exchangeExecution.orderNo, 38 | exchangeExecution.isin, 39 | exchangeExecution.market, 40 | exchangeExecution.buySell, 41 | exchangeExecution.unitPrice, 42 | exchangeExecution.quantity, 43 | exchangeExecution.dateOfExecution, 44 | Some(exchangeExecution.exchangeExecutionRefNo) 45 | ) 46 | 47 | object ExchangeExecutionParsingServiceLive: 48 | val layer = ZLayer.fromFunction(ExchangeExecutionParsingServiceLive.apply _) 49 | -------------------------------------------------------------------------------- /modules/it/src/it/scala/tradex/domain/service/FrontOfficeOrderParsingServiceSpec.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package service 3 | 4 | import zio.test.* 5 | import zio.test.Assertion.* 6 | import zio.{ Scope, ZIO } 7 | import zio.stream.{ ZPipeline, ZStream } 8 | import java.nio.charset.StandardCharsets 9 | import java.time.{ Instant, LocalDate, ZoneOffset } 10 | import csv.CSV 11 | import model.frontOfficeOrder.FrontOfficeOrder 12 | import transport.frontOfficeOrderT.{ *, given } 13 | import service.live.FrontOfficeOrderParsingServiceLive 14 | import generators.frontOfficeOrderGen 15 | import repository.live.OrderRepositoryLive 16 | import repository.OrderRepository 17 | import Fixture.appResourcesL 18 | 19 | object FrontOfficeOrderParsingServiceSpec extends ZIOSpecDefault: 20 | val now = Instant.now 21 | override def spec = suite("FrontOfficeOrderParsingServiceSpec")( 22 | test("parse front office orders and create orders")( 23 | check(Gen.listOfN(10)(frontOfficeOrderGen(now)))(frontOfficeOrders => 24 | for 25 | service <- ZIO.service[FrontOfficeOrderParsingService] 26 | reader <- ZStream 27 | .fromIterable(frontOfficeOrders) 28 | .via(CSV.encode[FrontOfficeOrder]) 29 | .via(ZPipeline.decodeCharsWith(StandardCharsets.UTF_8)) 30 | .toReader 31 | _ <- service.parse(reader) 32 | inserted <- ZIO.serviceWithZIO[OrderRepository](_.queryByOrderDate(LocalDate.ofInstant(now, ZoneOffset.UTC))) 33 | yield assertTrue(inserted.nonEmpty) 34 | ) 35 | ) @@ TestAspect.before(clean) 36 | ) 37 | .provideSome[Scope]( 38 | FrontOfficeOrderParsingServiceLive.layer, 39 | OrderRepositoryLive.layer, 40 | config.appConfig, 41 | appResourcesL.project(_.postgres), 42 | Sized.default, 43 | TestRandom.deterministic 44 | ) 45 | 46 | def clean = 47 | ZIO.serviceWithZIO[OrderRepository](_.cleanAllOrders) 48 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/package.scala: -------------------------------------------------------------------------------- 1 | package tradex 2 | 3 | import zio.prelude._ 4 | import zio.prelude.Assertion.* 5 | import cats.syntax.all.* 6 | import squants.market.* 7 | import zio.config.magnolia.DeriveConfig 8 | import zio.Config.Secret 9 | import zio.json.* 10 | import java.util.UUID 11 | import sttp.tapir.Schema 12 | import sttp.tapir.SchemaType 13 | 14 | package object domain { 15 | given MoneyContext = defaultMoneyContext 16 | 17 | object NonEmptyString extends Subtype[String] { 18 | override inline def assertion: Assertion[String] = !isEmptyString 19 | given DeriveConfig[NonEmptyString] = 20 | DeriveConfig[String].map(NonEmptyString.make(_).fold(_ => NonEmptyString("empty string"), identity)) 21 | } 22 | type NonEmptyString = NonEmptyString.Type 23 | given JsonDecoder[NonEmptyString] = JsonDecoder[String].mapOrFail(NonEmptyString.make(_).toEither.leftMap(_.head)) 24 | given JsonEncoder[NonEmptyString] = JsonEncoder[String].contramap(NonEmptyString.unwrap(_)) 25 | given Schema[NonEmptyString] = Schema.string 26 | 27 | given DeriveConfig[Secret] = 28 | DeriveConfig[String].map(Secret(_)) 29 | 30 | given JsonDecoder[Currency] = 31 | JsonDecoder[String].map(Currency.apply(_).get) 32 | given JsonEncoder[Currency] = 33 | JsonEncoder[String].contramap(_.toString) 34 | 35 | given JsonDecoder[Money] = 36 | JsonDecoder[BigDecimal].map(USD.apply) 37 | given JsonEncoder[Money] = 38 | JsonEncoder[BigDecimal].contramap(_.amount) 39 | given Schema[Money] = Schema(SchemaType.SNumber()) 40 | 41 | given nelDecoder[A: JsonDecoder]: JsonDecoder[NonEmptyList[A]] = 42 | JsonDecoder[List[A]].map(l => NonEmptyList.apply(l.head, l.tail: _*)) 43 | given nelEncoder[A: JsonEncoder]: JsonEncoder[NonEmptyList[A]] = 44 | JsonEncoder[List[A]].contramap(_.toList) 45 | 46 | given JsonDecoder[UUID] = 47 | JsonDecoder[String].map(UUID.fromString(_)) 48 | given JsonEncoder[UUID] = 49 | JsonEncoder[String].contramap(_.toString()) 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tradeio3 2 | Sample trading domain model using Scala 3 3 | 4 | ## Run docker compose 5 | Spins up PostgreSQL in docker 6 | 7 | ``` 8 | docker compose up 9 | ``` 10 | 11 | ## Connect to docker and run psql 12 | 13 | ``` 14 | docker ps 15 | ``` 16 | 17 | Use the container id returned fom docker ps 18 | 19 | ``` 20 | docker exec -it 2a70a427bec5 bash 21 | ``` 22 | 23 | Invoke psql 24 | 25 | ``` 26 | psql -U postgres 27 | ``` 28 | 29 | Connect to database 30 | 31 | ``` 32 | \c trading; 33 | ``` 34 | 35 | Use database to fetch data 36 | 37 | ``` 38 | select * from accounts; 39 | ``` 40 | 41 | ## Compile and run tests 42 | 43 | ``` 44 | $ sbt clean 45 | $ sbt compile 46 | $ sbt testOnly 47 | $ sbt it:testOnly 48 | ``` 49 | 50 | Note running integration tests needs the `docker-compose` to run 51 | 52 | ## Run the trading application 53 | 54 | The trading application runs with the front office order and execution files as present in `modules/core/src/main/resources`. 55 | 56 | ``` 57 | sbt "project core; runMain tradex.domain.TradeApp" 58 | ``` 59 | 60 | ## Tapir integration 61 | 62 | Service integration with tapir is available for selective end-points. Try the instrument query service once the server is started as follows: 63 | 64 | ``` 65 | sbt "project core; runMain tradex.domain.Main" 66 | ``` 67 | 68 | * Invoke http://localhost:8080/api/instrument/US0378331005 for a sample instrument query 69 | 70 | * Invoke `curl -X PUT http://localhost:8080/api/instrument -H "Accept: application/json" -H "Content-Type: application/json" -d @./equity.json` 71 | 72 | with equity.json having the following: 73 | 74 | ```{ 75 | "equityData": { 76 | "isin": "US30303M1027", 77 | "name": {"value" : "Meta"}, 78 | "lotSize": 1, 79 | "issueDate": "2019-08-25T19:10:25", 80 | "unitPrice": 180.00 81 | } 82 | }``` 83 | 84 | * Running `TradeApp` will generate trades and insert into trade repository. Run `Main` and invoke http://localhost:8080/api/trade/ibm-123?tradedate=2023-05-28 for a sample trade query 85 | 86 | * Invoke http://localhost:8080/docs to use Swagger UI -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/transport/instrumentT.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package transport 3 | 4 | import zio.json.* 5 | import cats.syntax.all.* 6 | import model.instrument.* 7 | import sttp.tapir.Schema 8 | import sttp.tapir.generic.auto.* 9 | import zio.config.magnolia.examples.P.S 10 | import sttp.tapir.SchemaType 11 | 12 | object instrumentT: 13 | given JsonDecoder[ISINCode] = 14 | JsonDecoder[String].mapOrFail(ISINCode.make(_).toEither.leftMap(_.head)) 15 | given JsonEncoder[ISINCode] = JsonEncoder[String].contramap(ISINCode.unwrap(_)) 16 | 17 | given JsonDecoder[UnitPrice] = 18 | JsonDecoder[BigDecimal].mapOrFail(UnitPrice.make(_).toEither.leftMap(_.head)) 19 | given JsonEncoder[UnitPrice] = 20 | JsonEncoder[BigDecimal].contramap(UnitPrice.unwrap(_)) 21 | 22 | given JsonDecoder[LotSize] = 23 | JsonDecoder[Int].mapOrFail(LotSize.make(_).toEither.leftMap(_.head)) 24 | given JsonEncoder[LotSize] = 25 | JsonEncoder[Int].contramap(LotSize.unwrap(_)) 26 | 27 | given JsonCodec[InstrumentName] = DeriveJsonCodec.gen[InstrumentName] 28 | given JsonCodec[CouponFrequency] = DeriveJsonCodec.gen[CouponFrequency] 29 | given JsonCodec[InstrumentBase] = DeriveJsonCodec.gen[InstrumentBase] 30 | given JsonCodec[Equity] = DeriveJsonCodec.gen[Equity] 31 | given JsonCodec[FixedIncome] = DeriveJsonCodec.gen[FixedIncome] 32 | given JsonCodec[Ccy] = DeriveJsonCodec.gen[Ccy] 33 | given JsonCodec[Instrument] = DeriveJsonCodec.gen[Instrument] 34 | 35 | given Schema[ISINCode] = Schema.string 36 | given Schema[LotSize] = Schema(SchemaType.SInteger()) 37 | given Schema[UnitPrice] = Schema(SchemaType.SNumber()) 38 | given Schema[InstrumentName] = Schema.derivedSchema 39 | given Schema[CouponFrequency] = Schema.derivedSchema 40 | given Schema[InstrumentBase] = Schema.derivedSchema 41 | given Schema[Equity] = Schema.derivedSchema 42 | given Schema[FixedIncome] = Schema.derivedSchema 43 | given Schema[Ccy] = Schema.derivedSchema 44 | given Schema[Instrument] = Schema.derivedSchema 45 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/model/order.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package model 3 | 4 | import zio.prelude.* 5 | 6 | import instrument.* 7 | import account.* 8 | import java.time.LocalDateTime 9 | 10 | object order { 11 | object OrderNo extends Newtype[String]: 12 | given Equal[OrderNo] = Equal.default 13 | 14 | type OrderNo = OrderNo.Type 15 | 16 | extension (ono: OrderNo) 17 | def validateNo: Validation[String, OrderNo] = 18 | if (OrderNo.unwrap(ono).size > 50 || OrderNo.unwrap(ono).size < 5) 19 | Validation.fail(s"OrderNo cannot be more than 50 characters or less than 5 characters long") 20 | else Validation.succeed(ono) 21 | 22 | object Quantity extends Subtype[BigDecimal]: 23 | override inline def assertion = Assertion.greaterThan(BigDecimal(0)) 24 | 25 | type Quantity = Quantity.Type 26 | 27 | enum BuySell(val entryName: NonEmptyString): 28 | case Buy extends BuySell(NonEmptyString("buy")) 29 | case Sell extends BuySell(NonEmptyString("sell")) 30 | 31 | object BuySell: 32 | 33 | def withValue(value: String): Validation[String, BuySell] = 34 | value match 35 | case "buy" => Validation.succeed(Buy) 36 | case "sell" => Validation.succeed(Sell) 37 | case _ => Validation.fail("Error in value") 38 | 39 | end BuySell 40 | 41 | final case class LineItem private ( 42 | orderNo: OrderNo, 43 | isin: ISINCode, 44 | quantity: Quantity, 45 | unitPrice: UnitPrice, 46 | buySell: BuySell 47 | ) 48 | 49 | final case class Order private ( 50 | no: OrderNo, 51 | date: LocalDateTime, 52 | accountNo: AccountNo, 53 | items: NonEmptyList[LineItem] 54 | ) 55 | 56 | object Order: 57 | def make( 58 | no: OrderNo, 59 | orderDate: LocalDateTime, 60 | accountNo: AccountNo, 61 | items: NonEmptyList[LineItem] 62 | ): Order = Order(no, orderDate, accountNo, items) 63 | 64 | object LineItem: 65 | def make( 66 | orderNo: OrderNo, 67 | isin: ISINCode, 68 | quantity: Quantity, 69 | unitPrice: UnitPrice, 70 | buySell: BuySell 71 | ): LineItem = LineItem(orderNo, isin, quantity, unitPrice, buySell) 72 | } 73 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/api/common/ErrorInfo.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package api 3 | package common 4 | 5 | import zio.json.{ DeriveJsonDecoder, DeriveJsonEncoder } 6 | 7 | sealed trait ErrorInfo 8 | case class BadRequest(error: String = "Bad request.") extends ErrorInfo 9 | case class Unauthorized(error: String = "Unauthorized.") extends ErrorInfo 10 | case class Forbidden(error: String = "Forbidden.") extends ErrorInfo 11 | case class NotFound(error: String = "Not found.") extends ErrorInfo 12 | case class Conflict(error: String = "Conflict.") extends ErrorInfo 13 | case class ValidationFailed(errors: Map[String, List[String]]) extends ErrorInfo 14 | case class InternalServerError(error: String = "Internal server error.") extends ErrorInfo 15 | 16 | object ErrorInfo: 17 | 18 | given badRequestEncoder: zio.json.JsonEncoder[BadRequest] = DeriveJsonEncoder.gen[BadRequest] 19 | given badRequestDecoder: zio.json.JsonDecoder[BadRequest] = DeriveJsonDecoder.gen[BadRequest] 20 | given forbiddenEncoder: zio.json.JsonEncoder[Forbidden] = DeriveJsonEncoder.gen[Forbidden] 21 | given forbiddenDecoder: zio.json.JsonDecoder[Forbidden] = DeriveJsonDecoder.gen[Forbidden] 22 | given notFoundEncoder: zio.json.JsonEncoder[NotFound] = DeriveJsonEncoder.gen[NotFound] 23 | given notFoundDecoder: zio.json.JsonDecoder[NotFound] = DeriveJsonDecoder.gen[NotFound] 24 | given conflictEncoder: zio.json.JsonEncoder[Conflict] = DeriveJsonEncoder.gen[Conflict] 25 | given conflictDecoder: zio.json.JsonDecoder[Conflict] = DeriveJsonDecoder.gen[Conflict] 26 | given unauthorizedEncoder: zio.json.JsonEncoder[Unauthorized] = DeriveJsonEncoder.gen[Unauthorized] 27 | given unauthorizedDecoder: zio.json.JsonDecoder[Unauthorized] = DeriveJsonDecoder.gen[Unauthorized] 28 | given validationFailedEncoder: zio.json.JsonEncoder[ValidationFailed] = DeriveJsonEncoder.gen[ValidationFailed] 29 | given validationFailedDecoder: zio.json.JsonDecoder[ValidationFailed] = DeriveJsonDecoder.gen[ValidationFailed] 30 | given internalServerErrorEncoder: zio.json.JsonEncoder[InternalServerError] = 31 | DeriveJsonEncoder.gen[InternalServerError] 32 | given internalServerErrorDecoder: zio.json.JsonDecoder[InternalServerError] = 33 | DeriveJsonDecoder.gen[InternalServerError] 34 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/service/TradingService.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package service 3 | 4 | import zio.{ Task, UIO } 5 | import zio.stream.{ ZPipeline, ZStream } 6 | import java.time.LocalDate 7 | import model.trade.* 8 | import model.user.* 9 | import model.account.AccountNo 10 | import tradex.domain.model.execution.Execution 11 | 12 | trait TradingService: 13 | /** Generate trades for the day and by a specific user. Here are the steps: 14 | * 15 | * 1. Query all executions for the day 2. Group executions by order no 3. For each order no, get the account no 16 | * from the order details 4. Allocate the trade to the client account 5. Store the trade 17 | * 18 | * @param date 19 | * the date the trade is made 20 | * @param userId 21 | * the user who created the trade 22 | * 23 | * @return 24 | * a stream of trades 25 | */ 26 | def generateTrades( 27 | date: LocalDate, 28 | userId: UserId 29 | ): ZStream[Any, Throwable, Trade] = 30 | queryExecutionsForDate(date) 31 | .groupByKey(_.orderNo): 32 | case (orderNo, executions) => 33 | executions 34 | .via(getAccountNoFromExecution) 35 | .via(allocateTradeToClientAccount(userId)) 36 | .via(storeTrades) 37 | 38 | /** Get client account numbers from a stream of executions 39 | * 40 | * @return 41 | * a stream containing an execution and its associated client account no fetched from the order details 42 | */ 43 | def getAccountNoFromExecution: ZPipeline[Any, Throwable, Execution, (Execution, AccountNo)] 44 | 45 | /** Generate trades from executions and allocate to the associated client account 46 | * 47 | * @param userId 48 | * @return 49 | * the stream of trades generated 50 | */ 51 | def allocateTradeToClientAccount(userId: UserId): ZPipeline[Any, Throwable, (Execution, AccountNo), Trade] 52 | 53 | /** persist trades to database and return the stored trades 54 | * 55 | * @return 56 | * a stream of trades stored in the database 57 | */ 58 | def storeTrades: ZPipeline[Any, Throwable, Trade, Trade] 59 | 60 | def queryTradesForDate(accountNo: AccountNo, date: LocalDate): UIO[List[Trade]] 61 | 62 | /** query all exeutions for the day 63 | * 64 | * @param date 65 | * the date when executions were done in the exchange 66 | * @return 67 | * the stream of executions 68 | */ 69 | def queryExecutionsForDate(date: LocalDate): ZStream[Any, Throwable, Execution] 70 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/api/common/CustomDecodeFailureHandler.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package api 3 | package common 4 | 5 | import sttp.model.{ Header, StatusCode } 6 | import sttp.tapir.generic.auto.* 7 | import sttp.tapir.json.zio.jsonBody 8 | import sttp.tapir.server.interceptor.DecodeFailureContext 9 | import sttp.tapir.server.interceptor.decodefailure.DefaultDecodeFailureHandler.FailureMessages 10 | import sttp.tapir.server.interceptor.decodefailure.{ DecodeFailureHandler, DefaultDecodeFailureHandler } 11 | import sttp.tapir.server.model.ValuedEndpointOutput 12 | import sttp.tapir.{ EndpointIO, EndpointInput, headers, statusCode, stringBody } 13 | import zio.json.{ DeriveJsonDecoder, DeriveJsonEncoder } 14 | 15 | // Spec requires using invalid field name as a key in object returned in response. 16 | // Tapir gives us only a message, thus custom handler. 17 | // Problem mentioned in https://github.com/softwaremill/tapir/issues/2729 18 | class CustomDecodeFailureHandler( 19 | defaultHandler: DecodeFailureHandler, 20 | failureMessage: DecodeFailureContext => String, 21 | defaultRespond: DecodeFailureContext => Option[(StatusCode, List[Header])] 22 | ) extends DecodeFailureHandler: 23 | 24 | override def apply(ctx: DecodeFailureContext): Option[ValuedEndpointOutput[_]] = 25 | ctx.failingInput match 26 | case EndpointInput.Query(name, _, _, _) => getErrorResponseForField(name, ctx) 27 | case EndpointInput.PathCapture(name, _, _) => getErrorResponseForField(name.getOrElse("?"), ctx) 28 | case _: EndpointIO.Body[_, _] => getErrorResponseForField("body", ctx) 29 | case _: EndpointIO.StreamBodyWrapper[_, _] => getErrorResponseForField("body", ctx) 30 | case _ => defaultHandler(ctx) 31 | 32 | private def getErrorResponseForField(name: String, ctx: DecodeFailureContext): Option[ValuedEndpointOutput[_]] = 33 | defaultRespond(ctx) match 34 | case Some((_, hs)) => 35 | val failureMsg = failureMessage(ctx) 36 | Some( 37 | ValuedEndpointOutput( 38 | statusCode.and(headers).and(jsonBody[ValidationFailed]), 39 | (StatusCode.UnprocessableEntity, hs, ValidationFailed(Map(name -> List(failureMsg)))) 40 | ) 41 | ) 42 | case None => None 43 | 44 | object CustomDecodeFailureHandler: 45 | 46 | def create(): DecodeFailureHandler = 47 | new CustomDecodeFailureHandler( 48 | DefaultDecodeFailureHandler.default, 49 | FailureMessages.failureMessage, 50 | DefaultDecodeFailureHandler.respond(_, false, true) 51 | ) 52 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/Main.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | 3 | import zio.{ Console => ZConsole, * } 4 | import zio.logging.backend.SLF4J 5 | import zio.logging.LogFormat 6 | import zio.http.Server 7 | import zio.interop.catz.* 8 | import sttp.tapir.server.ziohttp.ZioHttpServerOptions 9 | import api.common.{ CustomDecodeFailureHandler, DefectHandler } 10 | import sttp.tapir.server.ziohttp.ZioHttpInterpreter 11 | import api.endpoints.{ TradingEndpoints, TradingServerEndpoints } 12 | import api.common.BaseEndpoints 13 | import tradex.domain.config.AppConfig 14 | import resources.AppResources 15 | import natchez.Trace.Implicits.noop 16 | import cats.effect.std.Console 17 | import service.live.{ InstrumentServiceLive, TradingServiceLive } 18 | import repository.live.{ InstrumentRepositoryLive, OrderRepositoryLive, TradeRepositoryLive } 19 | 20 | object Main extends ZIOAppDefault: 21 | 22 | override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] = SLF4J.slf4j(LogFormat.colored) 23 | 24 | override def run: ZIO[Any with ZIOAppArgs with Scope, Any, Any] = 25 | val port = sys.env.get("HTTP_PORT").flatMap(_.toIntOption).getOrElse(8080) 26 | val options: ZioHttpServerOptions[Any] = ZioHttpServerOptions.customiseInterceptors 27 | .exceptionHandler(new DefectHandler()) 28 | .decodeFailureHandler(CustomDecodeFailureHandler.create()) 29 | .options 30 | 31 | given Console[Task] = Console.make[Task] 32 | val appResourcesL: ZLayer[AppConfig, Throwable, AppResources] = ZLayer.scoped( 33 | for 34 | config <- ZIO.service[AppConfig] 35 | res <- AppResources.make(config).toScopedZIO 36 | yield res 37 | ) 38 | 39 | (for 40 | endpoints <- ZIO.service[Endpoints] 41 | httpApp = ZioHttpInterpreter(options).toHttp(endpoints.endpoints) 42 | actualPort <- Server.install(httpApp.withDefaultErrorResponse) 43 | _ <- ZConsole.printLine(s"Trading Application started") 44 | _ <- ZConsole.printLine(s"Go to http://localhost:$actualPort/docs to open SwaggerUI") 45 | _ <- ZIO.never 46 | yield ()) 47 | .provide( 48 | Endpoints.live, 49 | TradingServerEndpoints.live, 50 | TradingEndpoints.live, 51 | BaseEndpoints.live, 52 | InstrumentServiceLive.layer, 53 | TradingServiceLive.layer, 54 | InstrumentRepositoryLive.layer, 55 | OrderRepositoryLive.layer, 56 | TradeRepositoryLive.layer, 57 | appResourcesL.project(_.postgres), 58 | appResourcesL, 59 | tradex.domain.config.appConfig, 60 | Server.defaultWithPort(port) 61 | ) 62 | .exitCode 63 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/service/live/FrontOfficeOrderParsingServiceLive.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package service 3 | package live 4 | 5 | import java.io.Reader 6 | import java.time.{ Instant, LocalDateTime, ZoneOffset } 7 | import zio.prelude.NonEmptyList 8 | import zio.{ Clock, NonEmptyChunk, Task, UIO, ZIO, ZLayer } 9 | import kantan.csv.rfc 10 | import csv.CSV 11 | import model.frontOfficeOrder.FrontOfficeOrder 12 | import transport.frontOfficeOrderT.{ *, given } 13 | import zio.stream.{ ZPipeline, ZStream } 14 | import model.order.* 15 | import model.account.* 16 | import model.instrument.* 17 | import repository.OrderRepository 18 | import java.time.LocalDate 19 | import java.time.format.DateTimeFormatter 20 | 21 | final case class FrontOfficeOrderParsingServiceLive( 22 | orderRepo: OrderRepository 23 | ) extends FrontOfficeOrderParsingService: 24 | 25 | def parse(data: Reader): Task[Unit] = 26 | parseAllRows(data) 27 | .via(convertToOrder) 28 | .runForeachChunk(orders => 29 | ZIO.when(orders.nonEmpty)(orderRepo.store(NonEmptyList(orders.head, orders.tail.toList: _*))) 30 | ) 31 | 32 | private def parseAllRows(data: Reader): ZStream[Any, Throwable, FrontOfficeOrder] = 33 | CSV.decode[FrontOfficeOrder](data, rfc.withHeader) 34 | 35 | private def convertToOrder: ZPipeline[Any, Nothing, FrontOfficeOrder, Order] = 36 | ZPipeline 37 | .groupAdjacentBy[FrontOfficeOrder, AccountNo](_.accountNo) 38 | .mapZIO: 39 | case (ano, fos) => makeOrder(ano, fos) 40 | 41 | private def makeOrderNo(accountNo: String, date: LocalDate): String = 42 | s"$accountNo-${DateTimeFormatter.ISO_LOCAL_DATE.format(date)}" 43 | 44 | private def makeOrder(ano: AccountNo, fos: NonEmptyChunk[FrontOfficeOrder]): UIO[Order] = 45 | Clock.instant 46 | .map(_.atOffset(ZoneOffset.UTC)) 47 | .flatMap: now => 48 | val odate = LocalDate.ofInstant(fos.head.date, ZoneOffset.UTC) 49 | val ono = makeOrderNo(AccountNo.unwrap(ano), odate) 50 | val lineItems = fos.map: fo => 51 | LineItem.make( 52 | OrderNo(ono), 53 | fo.isin, 54 | fo.qty, 55 | fo.unitPrice, 56 | fo.buySell 57 | ) 58 | ZIO.succeed( 59 | Order.make( 60 | OrderNo(ono), 61 | LocalDateTime 62 | .ofInstant(fos.head.date, ZoneOffset.UTC), 63 | ano, 64 | NonEmptyList(lineItems.head, lineItems.tail.toList: _*) 65 | ) 66 | ) 67 | 68 | object FrontOfficeOrderParsingServiceLive: 69 | val layer = ZLayer.fromFunction(FrontOfficeOrderParsingServiceLive.apply _) 70 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/transport/cellCodecs.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package transport 3 | 4 | import zio.prelude.{ BicovariantOps, Validation } 5 | import kantan.csv.{ CellDecoder, CellEncoder, DecodeError, RowDecoder } 6 | import kantan.csv.java8.* 7 | import model.account.AccountNo 8 | import model.instrument.{ ISINCode, UnitPrice } 9 | import model.order.{ BuySell, OrderNo, Quantity } 10 | import model.market.Market 11 | 12 | object cellCodecs: 13 | given CellDecoder[AccountNo] = CellDecoder.from[AccountNo](v => 14 | AccountNo(v).validateNo.toEitherAssociative.leftMap(err => DecodeError.TypeError(argOrEmpty("account no", v, err))) 15 | ) 16 | 17 | given CellEncoder[AccountNo] = CellEncoder.from(AccountNo.unwrap(_)) 18 | 19 | given CellDecoder[OrderNo] = CellDecoder.from[OrderNo](v => 20 | OrderNo(v).validateNo.toEitherAssociative.leftMap(err => DecodeError.TypeError(argOrEmpty("order no", v, err))) 21 | ) 22 | given CellEncoder[OrderNo] = CellEncoder.from(OrderNo.unwrap(_)) 23 | 24 | given CellDecoder[ISINCode] = CellDecoder.from[ISINCode](v => 25 | ISINCode 26 | .make(v) 27 | .toEitherAssociative 28 | .leftMap(err => DecodeError.TypeError(argOrEmpty("isin code", v, err))) 29 | ) 30 | 31 | given CellEncoder[ISINCode] = CellEncoder.from(ISINCode.unwrap(_)) 32 | 33 | given CellDecoder[UnitPrice] = CellDecoder.from[UnitPrice](v => 34 | UnitPrice 35 | .make(BigDecimal(v)) 36 | .toEitherAssociative 37 | .leftMap(err => DecodeError.TypeError(argOrEmpty("unit price", v, err))) 38 | ) 39 | 40 | given CellEncoder[UnitPrice] = CellEncoder.from(_.toString) 41 | 42 | given CellDecoder[Quantity] = CellDecoder.from[Quantity](v => 43 | Quantity 44 | .make(BigDecimal(v)) 45 | .toEitherAssociative 46 | .leftMap(err => DecodeError.TypeError(argOrEmpty("quantity", v, err))) 47 | ) 48 | 49 | given CellEncoder[Quantity] = CellEncoder.from(_.toString) 50 | 51 | given CellDecoder[BuySell] = CellDecoder.from[BuySell](v => 52 | BuySell 53 | .withValue(v) 54 | .toEitherAssociative 55 | .leftMap(err => DecodeError.TypeError(argOrEmpty("buy sell", v, err))) 56 | ) 57 | 58 | given CellEncoder[BuySell] = CellEncoder.from(_.entryName) 59 | 60 | given CellDecoder[Market] = CellDecoder.from[Market](v => 61 | Market 62 | .withValue(v) 63 | .toEitherAssociative 64 | .leftMap(err => DecodeError.TypeError(argOrEmpty("market", v, err))) 65 | ) 66 | 67 | given CellEncoder[Market] = CellEncoder.from(_.entryName) 68 | 69 | private def argOrEmpty(column: String, cell: String, error: String): String = 70 | if (cell.trim.isEmpty) s"Empty $column" else error 71 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/service/live/TradingServiceLive.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package service 3 | package live 4 | 5 | import java.time.LocalDate 6 | import zio.{ Chunk, Task, UIO, ZIO, ZLayer } 7 | import zio.stream.{ ZPipeline, ZStream } 8 | import zio.interop.catz.* 9 | import zio.stream.interop.fs2z.* 10 | import skunk.Session 11 | 12 | import model.account.* 13 | import model.trade.* 14 | import model.execution.* 15 | import model.order.* 16 | import model.user.* 17 | import repository.{ ExecutionRepository, OrderRepository, TradeRepository } 18 | import repository.live.ExecutionRepositorySQL 19 | import resources.AppResources 20 | 21 | final case class TradingServiceLive( 22 | session: Session[Task], 23 | orderRepository: OrderRepository, 24 | tradeRepository: TradeRepository 25 | ) extends TradingService: 26 | 27 | override def queryTradesForDate(accountNo: AccountNo, date: LocalDate): UIO[List[Trade]] = 28 | tradeRepository.query(accountNo, date) 29 | 30 | override def queryExecutionsForDate(date: LocalDate): ZStream[Any, Throwable, Execution] = 31 | ZStream 32 | .fromZIO(session.prepare(ExecutionRepositorySQL.selectByExecutionDate)) 33 | .flatMap: preparedQuery => 34 | preparedQuery 35 | .stream(date, 512) 36 | .toZStream() 37 | 38 | override def getAccountNoFromExecution: ZPipeline[Any, Throwable, Execution, (Execution, AccountNo)] = 39 | ZPipeline.mapChunksZIO((inputs: Chunk[Execution]) => 40 | ZIO.foreach(inputs): 41 | case exe => 42 | orderRepository 43 | .query(exe.orderNo) 44 | .someOrFail(new Throwable(s"Order not found for order no ${exe.orderNo}")) 45 | .map(order => (exe, order.accountNo)) 46 | ) 47 | 48 | override def allocateTradeToClientAccount(userId: UserId): ZPipeline[Any, Throwable, (Execution, AccountNo), Trade] = 49 | ZPipeline.mapChunksZIO((inputs: Chunk[(Execution, AccountNo)]) => 50 | ZIO.foreach(inputs): 51 | case (exe, accountNo) => 52 | Trade 53 | .trade( 54 | accountNo, 55 | exe.isin, 56 | exe.market, 57 | exe.buySell, 58 | exe.unitPrice, 59 | exe.quantity, 60 | exe.dateOfExecution, 61 | valueDate = None, 62 | userId = Some(userId) 63 | ) 64 | .map(Trade.withTaxFee) 65 | ) 66 | 67 | override def storeTrades: ZPipeline[Any, Throwable, Trade, Trade] = 68 | ZPipeline.mapChunksZIO((trades: Chunk[Trade]) => tradeRepository.store(trades).as(trades)) 69 | 70 | object TradingServiceLive: 71 | val layer = 72 | ZLayer.scoped(for 73 | orderRepository <- ZIO.service[OrderRepository] 74 | tradeRepository <- ZIO.service[TradeRepository] 75 | appResources <- ZIO.service[AppResources] 76 | session <- appResources.postgres.toScopedZIO 77 | yield TradingServiceLive(session, orderRepository, tradeRepository)) 78 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/repository/live/BalanceRepositoryLive.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package repository 3 | package live 4 | 5 | import zio.{ Task, UIO, ZLayer } 6 | import cats.effect.kernel.Resource 7 | import skunk.* 8 | import skunk.codec.all.* 9 | import skunk.implicits.* 10 | import model.account.* 11 | import model.balance.* 12 | import java.time.LocalDate 13 | import codecs.{ *, given } 14 | import model.balance.* 15 | import zio.interop.catz.* 16 | 17 | final case class BalanceRepositoryLive(postgres: Resource[Task, Session[Task]]) extends BalanceRepository: 18 | import BalanceRepositorySQL.* 19 | 20 | override def store(b: Balance): UIO[Balance] = 21 | postgres 22 | .use: session => 23 | session 24 | .prepare(upsertBalance) 25 | .flatMap: cmd => 26 | cmd.execute(b).unit.map(_ => b) 27 | .orDie 28 | 29 | override def all: UIO[List[Balance]] = postgres.use(_.execute(selectAll)).orDie 30 | 31 | override def query(date: LocalDate): UIO[List[Balance]] = 32 | postgres 33 | .use: session => 34 | session 35 | .prepare(selectByDate) 36 | .flatMap: ps => 37 | ps.stream(date, 1024).compile.toList 38 | .orDie 39 | 40 | override def query(no: AccountNo): UIO[Option[Balance]] = 41 | postgres 42 | .use: session => 43 | session 44 | .prepare(selectByAccountNo) 45 | .flatMap: ps => 46 | ps.option(no) 47 | .orDie 48 | 49 | private[domain] object BalanceRepositorySQL: 50 | val decoder: Decoder[Balance] = 51 | (accountNo ~ money ~ timestamp ~ currency) 52 | .map: 53 | case ano ~ amt ~ asOf ~ ccy => Balance(ano, amt, ccy, asOf) 54 | 55 | val encoder: Encoder[Balance] = 56 | (accountNo ~ money ~ timestamp ~ currency).values 57 | .contramap((b: Balance) => b.accountNo ~ b.amount ~ b.asOf ~ b.currency) 58 | 59 | val selectByAccountNo: Query[AccountNo, Balance] = 60 | sql""" 61 | SELECT b.accountNo, b.amount, b.asOf, b.currency 62 | FROM balance AS b 63 | WHERE b.accountNo = $accountNo 64 | """.query(decoder) 65 | 66 | val selectByDate: Query[LocalDate, Balance] = 67 | sql""" 68 | SELECT b.accountNo, b.amount, b.asOf, b.currency 69 | FROM balance AS b 70 | WHERE DATE(b.asOf) <= $date 71 | """.query(decoder) 72 | 73 | val selectAll: Query[Void, Balance] = 74 | sql""" 75 | SELECT b.accountNo, b.amount, b.asOf, b.currency 76 | FROM balance AS b 77 | """.query(decoder) 78 | 79 | val insertBalance: Command[Balance] = 80 | sql""" 81 | INSERT INTO balance (accountNo, amount, asOf, currency) 82 | VALUES $encoder 83 | """.command 84 | 85 | val upsertBalance: Command[Balance] = 86 | sql""" 87 | INSERT INTO balance (accountNo, amount, asOf, currency) 88 | VALUES $encoder 89 | ON CONFLICT(accountNo) DO UPDATE SET 90 | amount = EXCLUDED.amount, 91 | asOf = EXCLUDED.asOf, 92 | currency = EXCLUDED.currency 93 | """.command 94 | 95 | object BalanceeRepositoryLive: 96 | val layer = ZLayer.fromFunction(BalanceRepositoryLive.apply _) 97 | -------------------------------------------------------------------------------- /modules/it/src/it/scala/tradex/domain/api/TradingEndpointsSpec.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package api 3 | 4 | import zio.* 5 | import zio.test.* 6 | import zio.test.Assertion.* 7 | import sttp.client3.UriContext 8 | import api.endpoints.{ TradingEndpoints, TradingServerEndpoints } 9 | import api.common.BaseEndpoints 10 | import resources.AppResources 11 | import tradex.domain.config.AppConfig 12 | import zio.interop.catz.* 13 | import natchez.Trace.Implicits.noop 14 | import cats.effect.std.Console 15 | import service.live.{ InstrumentServiceLive, TradingServiceLive } 16 | import repository.live.{ InstrumentRepositoryLive, OrderRepositoryLive, TradeRepositoryLive } 17 | import Fixture.appResourcesL 18 | import sttp.client3.HttpError 19 | 20 | object TradingEndpointsSpec extends ZIOSpecDefault: 21 | 22 | override def spec = suite("trading endpoints tests")( 23 | suite("getInstrumentEndpoint")( 24 | test("should return instrument")( 25 | for 26 | _ <- RepositoryTestSupport.insertOneEquity 27 | instrument <- TradingEndpointsTestSupport.callGetInstrumentEndpoint( 28 | uri"http://test.com/api/instrument/US30303M1027" 29 | ) 30 | yield assert(instrument)(isRight(anything)) 31 | ), 32 | test("should return 404")( 33 | for 34 | _ <- RepositoryTestSupport.insertOneEquity 35 | instrument <- TradingEndpointsTestSupport.callGetInstrumentEndpoint( 36 | uri"http://test.com/api/instrument/US30303M1029" 37 | ) 38 | yield assert(instrument)( 39 | isLeft( 40 | equalTo( 41 | HttpError( 42 | body = "{\"error\":\"Instrument with ISIN US30303M1029 not found\"}", 43 | statusCode = sttp.model.StatusCode(404) 44 | ) 45 | ) 46 | ) 47 | ) 48 | ), 49 | test("should fail in validation of ISIN code - internal server error")( 50 | for 51 | _ <- RepositoryTestSupport.insertOneEquity 52 | instrument <- TradingEndpointsTestSupport.callGetInstrumentEndpoint( 53 | uri"http://test.com/api/instrument/US30303M10" 54 | ) 55 | yield assert(instrument)( // fails through defect handler 56 | isLeft( 57 | equalTo( 58 | HttpError( 59 | body = "Internal server error", 60 | statusCode = sttp.model.StatusCode(500) 61 | ) 62 | ) 63 | ) 64 | ) 65 | ) 66 | ), 67 | suite("addEquityEndpoint")( 68 | test("should add equity")( 69 | for instrument <- TradingEndpointsTestSupport.callAddEquityEndpoint( 70 | uri"http://test.com/api/instrument/equity", 71 | RepositoryTestSupport.addEquityData 72 | ) 73 | yield assert(instrument)(isRight(anything)) 74 | ) 75 | ) 76 | ) 77 | .provide( 78 | InstrumentRepositoryLive.layer, 79 | OrderRepositoryLive.layer, 80 | TradeRepositoryLive.layer, 81 | InstrumentServiceLive.layer, 82 | TradingServiceLive.layer, 83 | TradingServerEndpoints.live, 84 | TradingEndpoints.live, 85 | BaseEndpoints.live, 86 | tradex.domain.config.appConfig, 87 | appResourcesL.project(_.postgres), 88 | appResourcesL 89 | ) 90 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/transport/accountT.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package transport 3 | 4 | import model.account.* 5 | import zio.json.* 6 | import cats.syntax.all.* 7 | import squants.market.Currency 8 | import sttp.tapir.Schema 9 | 10 | object accountT { 11 | given JsonDecoder[AccountNo] = 12 | JsonDecoder[String].mapOrFail(AccountNo.make(_).toEither.leftMap(_.head)) 13 | 14 | given JsonEncoder[AccountNo] = JsonEncoder[String].contramap(AccountNo.unwrap(_)) 15 | 16 | given JsonDecoder[AccountName] = 17 | JsonDecoder[String].mapOrFail(AccountName.make(_).toEither.leftMap(_.head)) 18 | 19 | given JsonEncoder[AccountName] = JsonEncoder[String].contramap(AccountName.unwrap(_)) 20 | 21 | given JsonCodec[AccountBase] = DeriveJsonCodec.gen[AccountBase] 22 | 23 | given JsonDecoder[TradingAccount] = 24 | JsonDecoder[(AccountBase, String, Currency)].mapOrFail: 25 | case (base, accountType, ccy) if accountType == "Trading" => 26 | TradingAccount 27 | .tradingAccount( 28 | base.no, 29 | base.name, 30 | Some(base.dateOfOpen), 31 | base.dateOfClose, 32 | base.baseCurrency, 33 | ccy 34 | ) 35 | .toEither 36 | .leftMap(_.head) 37 | case (base, accountType, ccy) => 38 | s"Invalid account type: $accountType".invalid[TradingAccount].toEither 39 | 40 | given JsonEncoder[TradingAccount] = 41 | JsonEncoder[(AccountBase, String, Currency)].contramap { account => 42 | (account.base, "Trading", account.tradingCurrency) 43 | } 44 | 45 | given JsonDecoder[SettlementAccount] = 46 | JsonDecoder[(AccountBase, String, Currency)].mapOrFail { 47 | case (base, accountType, ccy) if accountType == "Settlement" => 48 | SettlementAccount 49 | .settlementAccount( 50 | base.no, 51 | base.name, 52 | Some(base.dateOfOpen), 53 | base.dateOfClose, 54 | base.baseCurrency, 55 | ccy 56 | ) 57 | .toEither 58 | .leftMap(_.head) 59 | case (base, accountType, ccy) => 60 | s"Invalid account type: $accountType".invalid[SettlementAccount].toEither 61 | } 62 | 63 | given JsonEncoder[SettlementAccount] = 64 | JsonEncoder[(AccountBase, String, Currency)].contramap: account => 65 | (account.base, "Settlement", account.settlementCurrency) 66 | 67 | given JsonDecoder[TradingAndSettlementAccount] = 68 | JsonDecoder[(AccountBase, String, Currency, Currency)].mapOrFail: 69 | case (base, accountType, tCcy, sCcy) if accountType == "Trading & Settlement" => 70 | TradingAndSettlementAccount 71 | .tradingAndSettlementAccount( 72 | base.no, 73 | base.name, 74 | Some(base.dateOfOpen), 75 | base.dateOfClose, 76 | base.baseCurrency, 77 | tCcy, 78 | sCcy 79 | ) 80 | .toEither 81 | .leftMap(_.head) 82 | case (base, accountType, tCcy, sCcy) => 83 | s"Invalid account type: $accountType".invalid[TradingAndSettlementAccount].toEither 84 | 85 | given JsonEncoder[TradingAndSettlementAccount] = 86 | JsonEncoder[(AccountBase, String, Currency, Currency)].contramap: account => 87 | (account.base, "Trading & Settlement", account.tradingCurrency, account.settlementCurrency) 88 | 89 | // union types don't work with zio-json 90 | // given JsonCodec[ClientAccount] = DeriveJsonCodec.gen[ClientAccount] 91 | 92 | given Schema[AccountNo] = Schema.string 93 | 94 | } 95 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/repository/codecs.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package repository 3 | 4 | import cats.syntax.all.* 5 | import skunk.* 6 | import skunk.codec.all.* 7 | import squants.market.* 8 | import model.account.* 9 | import model.instrument.* 10 | import model.order.* 11 | import model.execution.* 12 | import model.trade.* 13 | import model.market.* 14 | import model.user.* 15 | 16 | object codecs: 17 | given MoneyContext = defaultMoneyContext 18 | 19 | val accountNo: Codec[AccountNo] = 20 | varchar.eimap[AccountNo] { s => 21 | AccountNo(s).validateNo.toEitherAssociative.leftMap(identity) 22 | }(AccountNo.unwrap(_)) 23 | 24 | val accountName: Codec[AccountName] = 25 | varchar.eimap[AccountName] { s => 26 | AccountName(s).validateName.toEitherAssociative.leftMap(identity) 27 | }(AccountName.unwrap(_)) 28 | 29 | val money: Codec[Money] = numeric.imap[Money](USD(_))(_.amount) 30 | 31 | val currency: Codec[Currency] = 32 | varchar.eimap[Currency](Currency(_).toEither.leftMap(_.getMessage()))( 33 | _.code 34 | ) 35 | 36 | val instrumentName: Codec[InstrumentName] = 37 | varchar.eimap[InstrumentName] { s => 38 | NonEmptyString.make(s).map(InstrumentName(_)).toEitherAssociative.leftMap(identity) 39 | }(_.value.toString) 40 | 41 | val isinCode: Codec[ISINCode] = 42 | varchar.eimap[ISINCode] { s => 43 | ISINCode.make(s).toEitherAssociative.leftMap(identity) 44 | }(ISINCode.unwrap(_)) 45 | 46 | val orderNo: Codec[OrderNo] = 47 | varchar.eimap[OrderNo] { s => 48 | OrderNo(s).validateNo.toEitherAssociative.leftMap(identity) 49 | }(OrderNo.unwrap(_)) 50 | 51 | val unitPrice: Codec[UnitPrice] = 52 | numeric.eimap[UnitPrice] { s => 53 | UnitPrice.make(s).toEitherAssociative.leftMap(identity) 54 | }(UnitPrice.unwrap(_)) 55 | 56 | val quantity: Codec[Quantity] = 57 | numeric.eimap[Quantity] { s => 58 | Quantity.make(s).toEitherAssociative.leftMap(identity) 59 | }(Quantity.unwrap(_)) 60 | 61 | val lotSize: Codec[LotSize] = 62 | int4.eimap[LotSize] { s => 63 | LotSize.make(s).toEitherAssociative.leftMap(identity) 64 | }(LotSize.unwrap(_)) 65 | 66 | val couponFrequency: Codec[CouponFrequency] = 67 | varchar.imap[CouponFrequency](CouponFrequency.valueOf(_))(_.entryName) 68 | 69 | val executionRefNo: Codec[ExecutionRefNo] = 70 | uuid.imap[ExecutionRefNo](ExecutionRefNo(_))(ExecutionRefNo.unwrap(_)) 71 | 72 | val tradeRefNo: Codec[TradeRefNo] = 73 | uuid.imap[TradeRefNo](TradeRefNo(_))(TradeRefNo.unwrap(_)) 74 | 75 | val market: Codec[Market] = 76 | varchar.eimap[Market](Market.withValue(_).toEitherAssociative.leftMap(identity))(_.entryName) 77 | 78 | val instrumentType: Codec[InstrumentType] = 79 | varchar.imap[InstrumentType](InstrumentType.valueOf(_))(_.entryName) 80 | 81 | val buySell: Codec[BuySell] = 82 | varchar.eimap[BuySell](BuySell.withValue(_).toEitherAssociative.leftMap(identity))(_.entryName) 83 | 84 | val taxFeeId: Codec[TaxFeeId] = 85 | varchar.imap[TaxFeeId](TaxFeeId.valueOf(_))(_.entryName) 86 | 87 | val userId: Codec[UserId] = 88 | uuid.imap[UserId](UserId(_))(UserId.unwrap(_)) 89 | 90 | val userName: Codec[UserName] = 91 | varchar.eimap[UserName] { s => 92 | NonEmptyString.make(s).map(UserName(_)).toEitherAssociative.leftMap(identity) 93 | }(UserName.unwrap(_)) 94 | 95 | val encPassword: Codec[EncryptedPassword] = 96 | varchar.eimap[EncryptedPassword] { s => 97 | NonEmptyString.make(s).map(EncryptedPassword(_)).toEitherAssociative.leftMap(identity) 98 | }(EncryptedPassword.unwrap(_)) 99 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/repository/live/ExecutionRepositoryLive.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package repository 3 | package live 4 | 5 | import zio.{ Task, UIO, ZLayer } 6 | import cats.effect.kernel.Resource 7 | import skunk.* 8 | import skunk.codec.all.* 9 | import skunk.implicits.* 10 | import model.execution.* 11 | import zio.interop.catz.* 12 | import codecs.{ *, given } 13 | import zio.prelude.NonEmptyList 14 | import java.time.LocalDate 15 | 16 | final case class ExecutionRepositoryLive(postgres: Resource[Task, Session[Task]]) extends ExecutionRepository: 17 | import ExecutionRepositorySQL.* 18 | 19 | override def store(execution: Execution): UIO[Execution] = 20 | postgres 21 | .use(session => 22 | session 23 | .prepare(insertExecution) 24 | .flatMap(_.execute(execution)) 25 | .map(_ => execution) 26 | ) 27 | .orDie 28 | 29 | override def store(executions: NonEmptyList[Execution]): UIO[Unit] = 30 | postgres 31 | .use(session => 32 | session 33 | .prepare(insertExecutions(executions.size)) 34 | .flatMap(_.execute(executions.toList)) 35 | .unit 36 | ) 37 | .orDie 38 | 39 | override def query(dateOfExecution: LocalDate): UIO[List[Execution]] = 40 | postgres 41 | .use(session => 42 | session 43 | .prepare(selectByExecutionDate) 44 | .flatMap(_.stream(dateOfExecution, 1024).compile.toList) 45 | ) 46 | .orDie 47 | 48 | override def cleanAllExecutions: UIO[Unit] = 49 | postgres 50 | .use(session => 51 | session 52 | .execute(deleteAllExecutions) 53 | .unit 54 | ) 55 | .orDie 56 | 57 | private[domain] object ExecutionRepositorySQL: 58 | val executionEncoder: Encoder[Execution] = 59 | (executionRefNo ~ accountNo ~ orderNo ~ isinCode ~ market ~ buySell ~ unitPrice ~ quantity ~ timestamp ~ varchar.opt).values 60 | .gcontramap[Execution] 61 | 62 | val executionDecoder: Decoder[Execution] = 63 | (executionRefNo ~ accountNo ~ orderNo ~ isinCode ~ market ~ buySell ~ unitPrice ~ quantity ~ timestamp ~ varchar.opt) 64 | .gmap[Execution] 65 | 66 | val insertExecution: Command[Execution] = 67 | sql""" 68 | INSERT INTO executions 69 | ( 70 | executionRefNo, 71 | accountNo, 72 | orderNo, 73 | isinCode, 74 | market, 75 | buySellFlag, 76 | unitPrice, 77 | quantity, 78 | dateOfExecution, 79 | exchangeExecutionRefNo 80 | ) 81 | VALUES $executionEncoder 82 | """.command 83 | 84 | def insertExecutions(ps: List[Execution]): Command[ps.type] = 85 | val enc = executionEncoder.values.list(ps) 86 | sql"INSERT INTO executions VALUES $enc".command 87 | 88 | def insertExecutions(n: Int): Command[List[Execution]] = 89 | val enc = executionEncoder.list(n) 90 | sql"INSERT INTO executions VALUES $enc".command 91 | 92 | val selectByExecutionDate: Query[LocalDate, Execution] = 93 | sql""" 94 | SELECT e.executionRefNo, e.accountNo, e.orderNo, e.isinCode, e.market, e.buySellFlag, e.unitPrice, e.quantity, e.dateOfExecution, e.exchangeExecutionRefNo 95 | FROM executions AS e 96 | WHERE DATE(e.dateOfExecution) = $date 97 | """.query(executionDecoder) 98 | 99 | val deleteAllExecutions: Command[Void] = 100 | sql"DELETE FROM executions".command 101 | 102 | object ExecutionRepositoryLive: 103 | val layer = ZLayer.fromFunction(ExecutionRepositoryLive.apply _) 104 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/api/endpoints/TradingServerEndpoints.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package api 3 | package endpoints 4 | 5 | import zio.{ ZIO, ZLayer } 6 | import cats.syntax.all.* 7 | import sttp.tapir.ztapir.* 8 | import service.{ InstrumentService, TradingService } 9 | import common.* 10 | import common.ErrorMapper.defaultErrorsMappings 11 | import scala.util.chaining.* 12 | import model.instrument.ISINCode 13 | import model.account.AccountNo 14 | 15 | final case class TradingServerEndpoints( 16 | instrumentService: InstrumentService, 17 | tradingService: TradingService, 18 | tradingEndpoints: TradingEndpoints 19 | ): 20 | val getInstrumentEndpoint: ZServerEndpoint[Any, Any] = tradingEndpoints.getInstrumentEndpoint 21 | .serverLogic: isin => 22 | makeISINCode(isin) 23 | .flatMap: isinCode => 24 | instrumentService 25 | .query(isinCode) 26 | .logError 27 | .pipe(r => 28 | defaultErrorsMappings(r.someOrFail(Exceptions.NotFound(s"Instrument with ISIN $isin not found"))) 29 | ) 30 | .map(InstrumentResponse.apply) 31 | .either 32 | .catchAll(th => ZIO.fail(Exceptions.BadRequest(th.getMessage))) 33 | 34 | val addEquityEndpoint: ZServerEndpoint[Any, Any] = tradingEndpoints.addEquityEndpoint 35 | .serverLogic(data => 36 | instrumentService 37 | .addEquity( 38 | data.equityData.isin, 39 | data.equityData.name, 40 | data.equityData.lotSize, 41 | data.equityData.issueDate, 42 | data.equityData.unitPrice 43 | ) 44 | .logError 45 | .pipe(defaultErrorsMappings) 46 | .map(InstrumentResponse.apply) 47 | .either 48 | ) 49 | 50 | val addFixedIncomeEndpoint: ZServerEndpoint[Any, Any] = tradingEndpoints.addFixedIncomeEndpoint 51 | .serverLogic(data => 52 | instrumentService 53 | .addFixedIncome( 54 | data.fiData.isin, 55 | data.fiData.name, 56 | data.fiData.lotSize, 57 | data.fiData.issueDate, 58 | data.fiData.maturityDate, 59 | data.fiData.couponRate, 60 | data.fiData.couponFrequency 61 | ) 62 | .logError 63 | .pipe(defaultErrorsMappings) 64 | .map(InstrumentResponse.apply) 65 | .either 66 | ) 67 | 68 | val queryTradesByDateEndpoint: ZServerEndpoint[Any, Any] = tradingEndpoints.queryTradesByDateEndpoint.serverLogic { 69 | case (accountNo, tradeDate) => 70 | tradingService 71 | .queryTradesForDate( 72 | AccountNo(accountNo).validateNo 73 | .fold(errs => throw new Exception(errs.toString), identity), 74 | tradeDate 75 | ) 76 | .logError 77 | .pipe(r => 78 | defaultErrorsMappings( 79 | r.collect(Exceptions.NotFound(s"No trades found for $accountNo and $tradeDate")) { 80 | case trades if trades.nonEmpty => trades 81 | } 82 | ).either 83 | ) 84 | } 85 | 86 | private def makeISINCode(isin: String) = 87 | val is = ISINCode 88 | .make(isin) 89 | .toEitherAssociative 90 | .leftMap(identity) 91 | 92 | ZIO 93 | .fromEither(is) 94 | .mapError(new Throwable(_)) 95 | // .mapError(BadRequest.apply) 96 | 97 | val endpoints: List[ZServerEndpoint[Any, Any]] = List( 98 | getInstrumentEndpoint, 99 | addEquityEndpoint, 100 | addFixedIncomeEndpoint, 101 | queryTradesByDateEndpoint 102 | ) 103 | 104 | object TradingServerEndpoints: 105 | val live = ZLayer.fromFunction(TradingServerEndpoints.apply _) 106 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/api/endpoints/TradingEndpoints.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package api 3 | package endpoints 4 | 5 | import zio.ZLayer 6 | import cats.syntax.all.* 7 | import java.util.UUID 8 | import common.BaseEndpoints 9 | import sttp.tapir.ztapir.ZPartialServerEndpoint 10 | import api.common.ErrorInfo 11 | import sttp.tapir.ztapir.* 12 | import sttp.tapir.json.zio.jsonBody 13 | import model.instrument.* 14 | import model.account.* 15 | import model.order.* 16 | import model.trade.* 17 | import model.market.Market 18 | import model.user.UserId 19 | import squants.market.USD 20 | import transport.instrumentT.{ *, given } 21 | import transport.tradeT.{ *, given } 22 | import java.time.{ LocalDate, LocalDateTime } 23 | 24 | final case class TradingEndpoints( 25 | base: BaseEndpoints 26 | ): 27 | val getInstrumentEndpoint = 28 | base.publicEndpoint.get 29 | .in("api" / "instrument" / path[String]("isin")) 30 | .out(jsonBody[InstrumentResponse].example(Examples.instrumentResponse)) 31 | 32 | val addEquityEndpoint = 33 | base.publicEndpoint.put 34 | .in("api" / "instrument" / "equity") 35 | .in(jsonBody[AddEquityRequest].example(Examples.addEquityRequest)) 36 | .out(jsonBody[InstrumentResponse].example(Examples.instrumentResponse)) 37 | 38 | val addFixedIncomeEndpoint = 39 | base.publicEndpoint.put 40 | .in("api" / "instrument" / "fi") 41 | .in(jsonBody[AddFixedIncomeRequest]) // .example(Examples.addFixedIncomeRequest)) 42 | .out(jsonBody[InstrumentResponse].example(Examples.instrumentResponse)) 43 | 44 | val queryTradesByDateEndpoint = 45 | base.publicEndpoint.get 46 | .in("api" / "trade" / path[String]("accountno")) 47 | .in(query[LocalDate]("tradedate")) 48 | .out(jsonBody[List[Trade]].example(List(Examples.trade))) 49 | 50 | private object Examples: 51 | val exampleInstrument = Equity.equity( 52 | isin = ISINCode 53 | .make("US0378331005") 54 | .toEitherAssociative 55 | .leftMap(identity) 56 | .fold(err => throw new Exception(err), identity), 57 | name = InstrumentName(NonEmptyString("Apple Inc.")), 58 | lotSize = LotSize(100), 59 | issueDate = LocalDateTime.now(), 60 | unitPrice = UnitPrice 61 | .make(100) 62 | .toEitherAssociative 63 | .leftMap(identity) 64 | .fold(err => throw new Exception(err), identity) 65 | ) 66 | val addEquityRequest = AddEquityRequest( 67 | AddEquityData( 68 | isin = ISINCode 69 | .make("US0378331005") 70 | .toEitherAssociative 71 | .leftMap(identity) 72 | .fold(err => throw new Exception(err), identity), 73 | name = InstrumentName(NonEmptyString("Apple Inc.")), 74 | lotSize = LotSize(100), 75 | issueDate = LocalDateTime.now(), 76 | unitPrice = UnitPrice 77 | .make(100) 78 | .toEitherAssociative 79 | .leftMap(identity) 80 | .fold(err => throw new Exception(err), identity) 81 | ) 82 | ) 83 | val instrumentResponse = InstrumentResponse(exampleInstrument) 84 | val trade = 85 | Trade( 86 | TradeRefNo(UUID.randomUUID()), 87 | AccountNo("ibm-123"), 88 | ISINCode("US0378331005"), 89 | Market.NewYork, 90 | BuySell.Buy, 91 | UnitPrice.wrap(BigDecimal(12.25)), 92 | Quantity.wrap(100), 93 | LocalDateTime.now, 94 | None, 95 | Some(UserId(UUID.randomUUID())), 96 | List(TradeTaxFee(TaxFeeId.TradeTax, USD(245)), TradeTaxFee(TaxFeeId.Commission, USD(183.75))), 97 | None 98 | ) 99 | 100 | object TradingEndpoints: 101 | val live: ZLayer[BaseEndpoints, Nothing, TradingEndpoints] = 102 | ZLayer.fromFunction(TradingEndpoints.apply _) 103 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/TradeApp.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | 3 | import zio.{ Scope, Task, ZIO, ZIOAppArgs, ZIOAppDefault, ZLayer } 4 | import zio.stream.{ ZPipeline, ZStream } 5 | import zio.interop.catz.* 6 | import cats.effect.std.Console 7 | import natchez.Trace.Implicits.noop 8 | import java.time.{ LocalDate, ZoneOffset } 9 | import java.util.UUID 10 | import java.nio.charset.StandardCharsets 11 | 12 | import config.AppConfig 13 | import resources.AppResources 14 | import model.user.UserId 15 | import repository.live.OrderRepositoryLive 16 | import repository.live.ExecutionRepositoryLive 17 | import repository.live.TradeRepositoryLive 18 | import service.{ ExchangeExecutionParsingService, FrontOfficeOrderParsingService, TradingService } 19 | import service.live.{ ExchangeExecutionParsingServiceLive, FrontOfficeOrderParsingServiceLive, TradingServiceLive } 20 | import java.io.IOException 21 | import java.io.Reader 22 | 23 | object TradeApp extends ZIOAppDefault: 24 | import TradeAppConfig.* 25 | import TradeAppComponents.* 26 | 27 | override def run: ZIO[Any & (ZIOAppArgs & Scope), Any, Any] = 28 | 29 | val tradingCycle = 30 | ZIO.logInfo(s"Parsing front office orders ..") *> 31 | parseFrontOfficeOrders *> 32 | ZIO.logInfo(s"Parsing exchange executions ..") *> 33 | parseExchangeExecutions *> 34 | ZIO.logInfo(s"Generating trades ..") *> 35 | generateTrades 36 | 37 | setupDB.provide(config.appConfig) 38 | *> tradingCycle.provide(live) 39 | 40 | object TradeAppConfig: 41 | val setupDB = 42 | for dbConf <- ZIO.serviceWith[AppConfig](_.postgreSQL) 43 | yield () 44 | 45 | given Console[Task] = Console.make[Task] 46 | 47 | val appResourcesL: ZLayer[AppConfig, Throwable, AppResources] = ZLayer.scoped( 48 | for 49 | config <- ZIO.service[AppConfig] 50 | res <- AppResources.make(config).toScopedZIO 51 | yield res 52 | ) 53 | 54 | val live: ZLayer[ 55 | Any, 56 | Throwable, 57 | TradingService & FrontOfficeOrderParsingService & ExchangeExecutionParsingService 58 | ] = ZLayer 59 | .make[TradingService & FrontOfficeOrderParsingService & ExchangeExecutionParsingService]( 60 | TradingServiceLive.layer, 61 | OrderRepositoryLive.layer, 62 | ExecutionRepositoryLive.layer, 63 | TradeRepositoryLive.layer, 64 | FrontOfficeOrderParsingServiceLive.layer, 65 | ExchangeExecutionParsingServiceLive.layer, 66 | appResourcesL.project(_.postgres), 67 | appResourcesL, 68 | config.appConfig 69 | ) 70 | 71 | object TradeAppComponents: 72 | val tradeDate = LocalDate.of(2023, 5, 28) 73 | val generateTrades = for 74 | now <- zio.Clock.instant 75 | uuid <- zio.Random.nextUUID 76 | trades <- ZIO 77 | .serviceWithZIO[TradingService]( 78 | _.generateTrades(tradeDate, UserId(uuid)).runCollect 79 | ) 80 | _ <- ZIO.logInfo(s"Done generating ${trades.size} trades") 81 | _ <- ZIO.logInfo(s"$trades") 82 | yield () 83 | 84 | val parseFrontOfficeOrders: ZIO[FrontOfficeOrderParsingService, Throwable, Unit] = 85 | ZIO.scoped( 86 | withCSV("forders.csv") 87 | .flatMap(reader => ZIO.serviceWithZIO[FrontOfficeOrderParsingService](_.parse(reader))) 88 | ) 89 | 90 | val parseExchangeExecutions: ZIO[ExchangeExecutionParsingService, Throwable, Unit] = 91 | ZIO.scoped( 92 | withCSV("executions.csv") 93 | .flatMap(reader => ZIO.serviceWithZIO[ExchangeExecutionParsingService](_.parse(reader))) 94 | ) 95 | 96 | private def reader(name: String): ZIO[Scope, IOException, Reader] = 97 | ZStream 98 | .fromResource(name) 99 | .via(ZPipeline.decodeCharsWith(StandardCharsets.UTF_8)) 100 | .toReader 101 | 102 | private def withCSV(name: String): ZIO[Scope, IOException, Reader] = 103 | ZIO 104 | .acquireRelease(reader(name))(rdr => ZIO.succeedBlocking(rdr.close())) 105 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/repository/live/InstrumentRepositoryLive.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package repository 3 | package live 4 | 5 | import cats.syntax.all.* 6 | import cats.effect.Resource 7 | import skunk.* 8 | import skunk.codec.all.* 9 | import skunk.implicits.* 10 | 11 | import model.account.* 12 | import codecs.{ *, given } 13 | import zio.{ Task, UIO, ZLayer } 14 | import zio.stream.ZStream 15 | import zio.interop.catz.* 16 | import model.instrument.* 17 | 18 | final case class InstrumentRepositoryLive(postgres: Resource[Task, Session[Task]]) extends InstrumentRepository: 19 | import InstrumentRepositorySQL._ 20 | 21 | override def queryByInstrumentType(instrumentType: InstrumentType): UIO[List[Instrument]] = 22 | postgres 23 | .use(session => 24 | session.prepare(selectByInstrumentType).flatMap(ps => ps.stream(instrumentType, 1024).compile.toList) 25 | ) 26 | .orDie 27 | 28 | override def query(isin: ISINCode): UIO[Option[Instrument]] = 29 | postgres.use(session => session.prepare(selectByISINCode).flatMap(ps => ps.option(isin))).orDie 30 | 31 | override def store(ins: Instrument): UIO[Instrument] = 32 | postgres.use(session => session.prepare(upsertInstrument).flatMap(_.execute(ins).void.map(_ => ins))).orDie 33 | 34 | private object InstrumentRepositorySQL: 35 | 36 | val instrumentEncoder: Encoder[Instrument] = 37 | ( 38 | isinCode ~ instrumentName ~ instrumentType ~ timestamp.opt ~ timestamp.opt ~ lotSize ~ unitPrice.opt ~ money.opt ~ couponFrequency.opt 39 | ).values 40 | .contramap: 41 | case Ccy(InstrumentBase(isin, name, ls)) => 42 | isin ~ name ~ InstrumentType.CCY ~ None ~ None ~ ls ~ None ~ None ~ None 43 | case Equity(InstrumentBase(isin, name, ls), di, up) => 44 | isin ~ name ~ InstrumentType.Equity ~ Some(di) ~ None ~ ls ~ Some(up) ~ None ~ None 45 | case FixedIncome(InstrumentBase(isin, name, ls), di, dm, cr, cf) => 46 | isin ~ name ~ InstrumentType.FixedIncome ~ Some(di) ~ dm ~ ls ~ None ~ Some(cr) ~ Some(cf) 47 | 48 | val decoder: Decoder[Instrument] = 49 | (isinCode ~ instrumentName ~ instrumentType ~ timestamp.opt ~ timestamp.opt ~ lotSize ~ unitPrice.opt ~ money.opt ~ couponFrequency.opt) 50 | .map: 51 | case isin ~ nm ~ tp ~ di ~ dm ~ ls ~ up ~ cr ~ cf => 52 | tp match 53 | case InstrumentType.CCY => Ccy(InstrumentBase(isin, nm, ls)) 54 | case InstrumentType.Equity => Equity(InstrumentBase(isin, nm, ls), di.get, up.get) 55 | case InstrumentType.FixedIncome => FixedIncome(InstrumentBase(isin, nm, ls), di.get, dm, cr.get, cf.get) 56 | 57 | val selectByISINCode: Query[ISINCode, Instrument] = 58 | sql""" 59 | SELECT i.isinCode, i.name, i.type, i.dateOfIssue, i.dateOfMaturity, i.lotSize, i.unitPrice, i.couponRate, i.couponFrequency 60 | FROM instruments AS i 61 | WHERE i.isinCode = $isinCode 62 | """.query(decoder) 63 | 64 | val selectByInstrumentType: Query[InstrumentType, Instrument] = 65 | sql""" 66 | SELECT i.isinCode, i.name, i.type, i.dateOfIssue, i.dateOfMaturity, i.lotSize, i.unitPrice, i.couponRate, i.couponFrequency 67 | FROM instruments AS i 68 | WHERE i.type = $instrumentType 69 | """.query(decoder) 70 | 71 | val upsertInstrument: Command[Instrument] = 72 | sql""" 73 | INSERT INTO instruments 74 | VALUES $instrumentEncoder 75 | ON CONFLICT(isinCode) DO UPDATE SET 76 | name = EXCLUDED.name, 77 | type = EXCLUDED.type, 78 | dateOfIssue = EXCLUDED.dateOfIssue, 79 | dateOfMaturity = EXCLUDED.dateOfMaturity, 80 | lotSize = EXCLUDED.lotSize, 81 | unitPrice = EXCLUDED.unitPrice, 82 | couponRate = EXCLUDED.couponRate, 83 | couponFrequency = EXCLUDED.couponFrequency 84 | """.command 85 | 86 | object InstrumentRepositoryLive: 87 | val layer = ZLayer.fromFunction(InstrumentRepositoryLive.apply _) 88 | -------------------------------------------------------------------------------- /modules/it/src/it/scala/tradex/domain/service/generators.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package service 3 | 4 | import zio.prelude._ 5 | import zio.Random 6 | import zio.test._ 7 | import zio.test.Gen.fromZIOSample 8 | 9 | import model.account.* 10 | import model.instrument.* 11 | import model.order.* 12 | import model.frontOfficeOrder.* 13 | import java.time.Instant 14 | import tradex.domain.model.exchangeExecution.ExchangeExecution 15 | import tradex.domain.model.market.Market 16 | import java.time.LocalDateTime 17 | import java.time.ZoneOffset 18 | 19 | object generators: 20 | val posIntGen = 21 | fromZIOSample(Random.nextIntBetween(0, Int.MaxValue).map(Sample.shrinkIntegral(0))) 22 | 23 | val nonEmptyStringGen: Gen[Random with Sized, String] = Gen.alphaNumericStringBounded(21, 40) 24 | val accountNoStringGen: Gen[Random with Sized, String] = Gen.alphaNumericStringBounded(5, 12) 25 | val orderNoStringGen: Gen[Random with Sized, String] = Gen.alphaNumericStringBounded(5, 50) 26 | 27 | val orderNoGen: Gen[Random with Sized, OrderNo] = 28 | orderNoStringGen.map(OrderNo(_)) 29 | 30 | val accountNoGen: Gen[Random with Sized, AccountNo] = 31 | val accs = List("ibm-123", "ibm-124", "nri-654").map(str => 32 | AccountNo(str).validateNo 33 | .fold(errs => throw new Exception(errs.toString), identity) 34 | ) 35 | Gen.fromIterable(accs) 36 | 37 | def isinGen: Gen[Any, ISINCode] = 38 | val appleISINStr = "US0378331005" 39 | val baeISINStr = "GB0002634946" 40 | val ibmISINStr = "US4592001014" 41 | 42 | val isins = List(appleISINStr, baeISINStr, ibmISINStr) 43 | .map(str => 44 | ISINCode 45 | .make(str) 46 | .toEitherAssociative 47 | .leftMap(identity) 48 | .fold(err => throw new Exception(err), identity) 49 | ) 50 | Gen.fromIterable(isins) 51 | 52 | val unitPriceGen: Gen[Any, UnitPrice] = 53 | val ups = List(BigDecimal(12.25), BigDecimal(51.25), BigDecimal(55.25)) 54 | .map(n => 55 | UnitPrice 56 | .make(n) 57 | .toEitherAssociative 58 | .leftMap(identity) 59 | .fold(err => throw new Exception(err), identity) 60 | ) 61 | Gen.fromIterable(ups) 62 | 63 | val quantityGen: Gen[Any, Quantity] = 64 | val qtys = List(BigDecimal(100), BigDecimal(200), BigDecimal(300)) 65 | .map(n => 66 | Quantity 67 | .make(n) 68 | .toEitherAssociative 69 | .leftMap(identity) 70 | .fold(err => throw new Exception(err), identity) 71 | ) 72 | Gen.fromIterable(qtys) 73 | 74 | def frontOfficeOrderGen(orderDate: Instant): Gen[Random & Sized, FrontOfficeOrder] = for 75 | ano <- accountNoGen 76 | isin <- isinGen 77 | qty <- quantityGen 78 | up <- unitPriceGen 79 | bs <- Gen.fromIterable(BuySell.values) 80 | yield FrontOfficeOrder(ano, orderDate, isin, qty, up, bs) 81 | 82 | def exchangeExecutionGen(date: Instant): Gen[Random & Sized, ExchangeExecution] = for 83 | erefNo <- nonEmptyStringGen 84 | ano <- accountNoGen 85 | ono <- orderNoGen 86 | isin <- isinGen 87 | market <- Gen.fromIterable(Market.values) 88 | bs <- Gen.fromIterable(BuySell.values) 89 | up <- unitPriceGen 90 | qty <- quantityGen 91 | yield ExchangeExecution(erefNo, ano, ono, isin, market, bs, up, qty, LocalDateTime.ofInstant(date, ZoneOffset.UTC)) 92 | 93 | def lineItemGen(ono: OrderNo): Gen[Any, LineItem] = for 94 | isin <- isinGen 95 | qty <- quantityGen 96 | up <- unitPriceGen 97 | bs <- Gen.fromIterable(BuySell.values) 98 | yield LineItem.make(ono, isin, qty, up, bs) 99 | 100 | def orderGen(date: Instant): Gen[Random & Sized, Order] = for 101 | ono <- orderNoGen 102 | ano <- accountNoGen 103 | items <- Gen.listOfN(5)(lineItemGen(ono)) 104 | yield Order.make( 105 | ono, 106 | LocalDateTime.ofInstant(date, ZoneOffset.UTC), 107 | ano, 108 | NonEmptyList.fromIterable(items.head, items.tail) 109 | ) 110 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/model/instrument.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package model 3 | 4 | import zio.prelude.* 5 | import zio.prelude.Assertion.* 6 | import cats.syntax.all.* 7 | import java.time.LocalDateTime 8 | import squants.market.Money 9 | import java.time.LocalDateTime 10 | 11 | object instrument: 12 | object ISINCode extends Newtype[String]: 13 | override inline def assertion: Assertion[String] = hasLength(equalTo(12)) && 14 | matches("([A-Z]{2})((?![A-Z]{10}\b)[A-Z0-9]{10})") 15 | 16 | type ISINCode = ISINCode.Type 17 | 18 | case class InstrumentName(value: NonEmptyString) 19 | 20 | object LotSize extends Subtype[Int]: 21 | override inline def assertion: Assertion[Int] = greaterThan(0) 22 | 23 | type LotSize = LotSize.Type 24 | 25 | enum InstrumentType(val entryName: String): 26 | case CCY extends InstrumentType("Ccy") 27 | case Equity extends InstrumentType("Equity") 28 | case FixedIncome extends InstrumentType("Fixed Income") 29 | 30 | object UnitPrice extends Subtype[BigDecimal]: 31 | override inline def assertion = Assertion.greaterThan(BigDecimal(0)) 32 | type UnitPrice = UnitPrice.Type 33 | 34 | enum CouponFrequency(val entryName: NonEmptyString): 35 | case Annual extends CouponFrequency(NonEmptyString("annual")) 36 | case SemiAnnual extends CouponFrequency(NonEmptyString("semi-annual")) 37 | 38 | final case class InstrumentBase( 39 | isinCode: ISINCode, 40 | name: InstrumentName, 41 | lotSize: LotSize 42 | ) 43 | 44 | sealed trait Instrument: 45 | private[instrument] def base: InstrumentBase 46 | def instrumentType: InstrumentType 47 | val isinCode = base.isinCode 48 | val name = base.name 49 | val lotSize = base.lotSize 50 | 51 | final case class Ccy( 52 | private[instrument] val base: InstrumentBase 53 | ) extends Instrument: 54 | val instrumentType = InstrumentType.CCY 55 | 56 | object Ccy: 57 | def ccy(isin: ISINCode, name: InstrumentName) = 58 | Ccy( 59 | base = InstrumentBase(isin, name, LotSize(1)) 60 | ) 61 | 62 | final case class Equity( 63 | private[instrument] val base: InstrumentBase, 64 | dateOfIssue: LocalDateTime, 65 | unitPrice: UnitPrice 66 | ) extends Instrument: 67 | val instrumentType = InstrumentType.Equity 68 | 69 | object Equity: 70 | def equity(isin: ISINCode, name: InstrumentName, lotSize: LotSize, issueDate: LocalDateTime, unitPrice: UnitPrice) = 71 | Equity( 72 | base = InstrumentBase(isin, name, lotSize), 73 | dateOfIssue = issueDate, 74 | unitPrice = unitPrice 75 | ) 76 | 77 | final case class FixedIncome( 78 | private[instrument] val base: InstrumentBase, 79 | dateOfIssue: LocalDateTime, 80 | dateOfMaturity: Option[LocalDateTime], 81 | couponRate: Money, 82 | couponFrequency: CouponFrequency 83 | ) extends Instrument: 84 | val instrumentType = InstrumentType.FixedIncome 85 | 86 | object FixedIncome: 87 | def fixedIncome( 88 | isin: ISINCode, 89 | name: InstrumentName, 90 | lotSize: LotSize, 91 | issueDate: LocalDateTime, 92 | maturityDate: Option[LocalDateTime], 93 | couponRate: Money, 94 | couponFrequency: CouponFrequency 95 | ): Validation[String, FixedIncome] = 96 | validateIssueAndMaturityDate(issueDate, maturityDate).map: (id, md) => 97 | FixedIncome( 98 | base = InstrumentBase(isin, name, lotSize), 99 | dateOfIssue = id, 100 | dateOfMaturity = md, 101 | couponRate = couponRate, 102 | couponFrequency = couponFrequency 103 | ) 104 | 105 | private def validateIssueAndMaturityDate( 106 | id: LocalDateTime, 107 | md: Option[LocalDateTime] 108 | ): Validation[String, (LocalDateTime, Option[LocalDateTime])] = 109 | md.map: c => 110 | if (c isBefore id) 111 | Validation.fail(s"Maturity date [$c] cannot be earlier than issue date [$id]") 112 | else Validation.succeed((id, md)) 113 | .getOrElse(Validation.succeed((id, md))) 114 | -------------------------------------------------------------------------------- /project/Dependencies.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | import Keys._ 3 | import Versions._ 4 | 5 | object Dependencies { 6 | def circe(artifact: String): ModuleID = "io.circe" %% s"circe-$artifact" % circeVersion 7 | 8 | object Zio { 9 | val zio = "dev.zio" %% "zio" % zioVersion 10 | val zioStreams = "dev.zio" %% "zio-streams" % zioVersion 11 | val zioPrelude = "dev.zio" %% "zio-prelude" % zioPreludeVersion 12 | val zioInteropCats = "dev.zio" %% "zio-interop-cats" % zioInteropCatsVersion 13 | val zioConfig = "dev.zio" %% "zio-config" % zioConfigVersion 14 | val zioConfigTypesafe = "dev.zio" %% "zio-config-typesafe" % zioConfigVersion 15 | val zioConfigMagnolia = "dev.zio" %% "zio-config-magnolia" % zioConfigVersion 16 | val zioLogging = "dev.zio" %% "zio-logging-slf4j" % zioLoggingVersion 17 | val zioLoggingSlf4j = "dev.zio" %% "zio-logging-slf4j" % zioLoggingVersion 18 | val zioLoggingSlf4jBridge = "dev.zio" %% "zio-logging-slf4j-bridge" % zioLoggingVersion 19 | val zioJson = "dev.zio" %% "zio-json" % zioJsonVersion 20 | val zioTest = "dev.zio" %% "zio-test" % zioVersion % "it,test" 21 | val zioTestSbt = "dev.zio" %% "zio-test-sbt" % zioVersion % "it,test" 22 | } 23 | object Cats { 24 | val cats = "org.typelevel" %% "cats-core" % catsVersion 25 | val catsEffect = "org.typelevel" %% "cats-effect" % catsEffectVersion 26 | } 27 | object Circe { 28 | val circeCore = circe("core") 29 | val circeGeneric = circe("generic") 30 | val circeParser = circe("parser") 31 | } 32 | object Skunk { 33 | val skunkCore = "org.tpolecat" %% "skunk-core" % skunkVersion 34 | val skunkCirce = "org.tpolecat" %% "skunk-circe" % skunkVersion 35 | } 36 | 37 | object Tapir { 38 | val tapirZioHttpServer = "com.softwaremill.sttp.tapir" %% "tapir-zio-http-server" % tapirVersion 39 | val tapirJsonZio = "com.softwaremill.sttp.tapir" %% "tapir-json-zio" % tapirVersion 40 | val tapirSwagger = "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % tapirVersion 41 | } 42 | val squants = "org.typelevel" %% "squants" % squantsVersion 43 | val monocleCore = "dev.optics" %% "monocle-core" % monocleVersion 44 | val quickLens = "com.softwaremill.quicklens" %% "quicklens" % quickLensVersion 45 | val flywayDb = "org.flywaydb" % "flyway-core" % flywayDbVersion 46 | val kantanCSV = "com.nrinaudo" % "kantan.csv_2.13" % kantanCsvVersion 47 | val kantanCSVDateTime = "com.nrinaudo" % "kantan.csv-java8_2.13" % kantanCsvVersion 48 | val sttpZioJson = "com.softwaremill.sttp.client3" %% "zio-json" % sttpZioJsonVersion % Test 49 | val sttpStubServer = "com.softwaremill.sttp.tapir" %% "tapir-sttp-stub-server" % tapirVersion % Test 50 | 51 | // Runtime 52 | val logback = "ch.qos.logback" % "logback-classic" % logbackVersion % Runtime 53 | 54 | val commonDependencies: Seq[ModuleID] = 55 | Seq( 56 | Cats.cats, 57 | Cats.catsEffect, 58 | Zio.zio, 59 | Zio.zioPrelude, 60 | Zio.zioInteropCats, 61 | Zio.zioConfig, 62 | Zio.zioConfigMagnolia, 63 | Zio.zioConfigTypesafe, 64 | Zio.zioLogging, 65 | Zio.zioLoggingSlf4j, 66 | Zio.zioLoggingSlf4jBridge, 67 | Zio.zioJson, 68 | quickLens, 69 | kantanCSV, 70 | kantanCSVDateTime 71 | ) 72 | 73 | val tradeioDependencies: Seq[ModuleID] = 74 | commonDependencies ++ Seq(squants) ++ Seq(flywayDb) ++ 75 | Seq(Circe.circeCore, Circe.circeGeneric, Circe.circeParser) ++ Seq(monocleCore) ++ 76 | Seq(Skunk.skunkCore, Skunk.skunkCirce) ++ 77 | Seq(Tapir.tapirZioHttpServer, Tapir.tapirJsonZio, Tapir.tapirSwagger) ++ 78 | Seq(sttpZioJson, sttpStubServer) 79 | 80 | val testDependencies: Seq[ModuleID] = 81 | Seq(Zio.zioTest, Zio.zioTestSbt, sttpZioJson, sttpStubServer) 82 | } 83 | -------------------------------------------------------------------------------- /modules/it/src/it/scala/tradex/domain/service/OrderExecutionParsingSpec.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package service 3 | 4 | import zio.test.* 5 | import zio.test.Assertion.* 6 | import zio.{ Scope, ZIO } 7 | import zio.stream.{ ZPipeline, ZStream } 8 | import java.nio.charset.StandardCharsets 9 | import java.time.{ Instant, LocalDate, ZoneOffset } 10 | import csv.CSV 11 | import model.frontOfficeOrder.FrontOfficeOrder 12 | import model.exchangeExecution.ExchangeExecution 13 | import model.order.Order 14 | import model.market.Market 15 | import transport.frontOfficeOrderT.{ *, given } 16 | import transport.exchangeExecutionT.{ *, given } 17 | import service.live.FrontOfficeOrderParsingServiceLive 18 | import service.live.ExchangeExecutionParsingServiceLive 19 | import repository.live.OrderRepositoryLive 20 | import repository.OrderRepository 21 | import repository.live.ExecutionRepositoryLive 22 | import repository.ExecutionRepository 23 | import Fixture.appResourcesL 24 | import generators.frontOfficeOrderGen 25 | 26 | object OrderExecutionParsingSpec extends ZIOSpecDefault: 27 | val now = Instant.now 28 | override def spec = suite("OrderExecutionParsingSpec")( 29 | test("parse front office orders and create orders")( 30 | check(Gen.listOfN(5)(frontOfficeOrderGen(now)))(frontOfficeOrders => 31 | for 32 | _ <- parseFrontOfficeOrders(frontOfficeOrders) 33 | 34 | s <- ZStream 35 | .fromIterable(frontOfficeOrders) 36 | .via(CSV.encode[FrontOfficeOrder]) 37 | .runCollect 38 | .map(bytes => new String(bytes.toArray)) 39 | 40 | _ <- ZIO.logInfo(s) 41 | 42 | ordersInserted <- ZIO 43 | .serviceWithZIO[OrderRepository]( 44 | _.queryByOrderDate(LocalDate.ofInstant(now, ZoneOffset.UTC)) 45 | ) 46 | _ <- parseExchangeExecutions(ordersInserted) 47 | _ <- generateExchangeExecutionsCSV(ordersInserted) 48 | exesInserted <- ZIO.serviceWithZIO[ExecutionRepository]( 49 | _.query(LocalDate.ofInstant(now, ZoneOffset.UTC)) 50 | ) 51 | yield assertTrue( 52 | ordersInserted.nonEmpty, 53 | exesInserted.nonEmpty 54 | ) 55 | ) 56 | ) @@ TestAspect.samples(10) @@ TestAspect.sequential @@ TestAspect.after(clean) 57 | ) 58 | .provideSome[Scope]( 59 | FrontOfficeOrderParsingServiceLive.layer, 60 | ExchangeExecutionParsingServiceLive.layer, 61 | OrderRepositoryLive.layer, 62 | ExecutionRepositoryLive.layer, 63 | config.appConfig, 64 | appResourcesL.project(_.postgres), 65 | Sized.default, 66 | TestRandom.deterministic 67 | ) 68 | 69 | private def parseFrontOfficeOrders(frontOfficeOrders: List[FrontOfficeOrder]) = for 70 | service <- ZIO.service[FrontOfficeOrderParsingService] 71 | reader <- ZStream 72 | .fromIterable(frontOfficeOrders) 73 | .via(CSV.encode[FrontOfficeOrder]) 74 | .via(ZPipeline.decodeCharsWith(StandardCharsets.UTF_8)) 75 | .toReader 76 | _ <- service.parse(reader) 77 | yield () 78 | 79 | private def parseExchangeExecutions(ordersInserted: List[Order]) = for 80 | exchangeExes <- generateExchangeExecutions(ordersInserted, now) 81 | reader <- ZStream 82 | .fromIterable(exchangeExes) 83 | .via(CSV.encode[ExchangeExecution]) 84 | .via(ZPipeline.decodeCharsWith(StandardCharsets.UTF_8)) 85 | .toReader 86 | service <- ZIO.service[ExchangeExecutionParsingService] 87 | _ <- service.parse(reader) 88 | yield () 89 | 90 | private def generateExchangeExecutions(orders: List[Order], now: Instant) = 91 | ZIO 92 | .foreachPar(orders)(order => ExchangeExecution.fromOrder(order, Market.NewYork, now).map(_.toList)) 93 | .map(_.flatten) 94 | 95 | private def generateExchangeExecutionsCSV(ordersInserted: List[Order]) = for 96 | exchangeExes <- generateExchangeExecutions(ordersInserted, now) 97 | csv <- ZStream 98 | .fromIterable(exchangeExes) 99 | .via(CSV.encode[ExchangeExecution]) 100 | .runCollect 101 | .map(bytes => new String(bytes.toArray)) 102 | _ <- ZIO.logInfo(csv) 103 | yield () 104 | 105 | private def clean = for 106 | _ <- ZIO.serviceWithZIO[ExecutionRepository](_.cleanAllExecutions) 107 | _ <- ZIO.serviceWithZIO[OrderRepository](_.cleanAllOrders) 108 | yield () 109 | -------------------------------------------------------------------------------- /modules/core/src/main/resources/migrations/V1__Init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS accounts ( 2 | no varchar NOT NULL PRIMARY KEY, 3 | name varchar NOT NULL, 4 | type varchar NOT NULL, 5 | dateOfOpen timestamp with time zone NOT NULL, 6 | dateOfClose timestamp with time zone, 7 | baseCurrency varchar NOT NULL, 8 | tradingCurrency varchar, 9 | settlementCurrency varchar 10 | ); 11 | 12 | CREATE TABLE IF NOT EXISTS instruments ( 13 | isinCode varchar NOT NULL PRIMARY KEY, 14 | name varchar NOT NULL, 15 | type varchar NOT NULL, 16 | dateOfIssue timestamp, 17 | dateOfMaturity timestamp, 18 | lotSize integer, 19 | unitPrice decimal, 20 | couponRate decimal, 21 | couponFrequency decimal 22 | ); 23 | 24 | CREATE TABLE IF NOT EXISTS orders ( 25 | no varchar NOT NULL PRIMARY KEY, 26 | dateOfOrder timestamp NOT NULL, 27 | accountNo varchar references accounts(no) 28 | ); 29 | 30 | CREATE TABLE IF NOT EXISTS lineItems ( 31 | lineItemId serial PRIMARY KEY, 32 | orderNo varchar references orders(no), 33 | isinCode varchar references instruments(isinCode), 34 | quantity decimal NOT NULL, 35 | unitPrice decimal NOT NULL, 36 | buySellFlag varchar NOT NULL 37 | ); 38 | 39 | CREATE TABLE IF NOT EXISTS executions ( 40 | executionRefNo uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), 41 | accountNo varchar NOT NULL references accounts(no), 42 | orderNo varchar NOT NULL references orders(no), 43 | isinCode varchar NOT NULL references instruments(isinCode), 44 | market varchar NOT NULL, 45 | buySellFlag varchar NOT NULL, 46 | unitPrice decimal NOT NULL, 47 | quantity decimal NOT NULL, 48 | dateOfExecution timestamp NOT NULL, 49 | exchangeExecutionRefNo varchar 50 | ); 51 | 52 | CREATE TABLE IF NOT EXISTS taxFees ( 53 | taxFeeId varchar NOT NULL PRIMARY KEY, 54 | description varchar NOT NULL 55 | ); 56 | 57 | CREATE TABLE IF NOT EXISTS trades ( 58 | tradeRefNo uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), 59 | accountNo varchar NOT NULL references accounts(no), 60 | isinCode varchar NOT NULL references instruments(isinCode), 61 | market varchar NOT NULL, 62 | buySellFlag varchar NOT NULL, 63 | unitPrice decimal NOT NULL, 64 | quantity decimal NOT NULL, 65 | tradeDate timestamp NOT NULL, 66 | valueDate timestamp, 67 | netAmount decimal, 68 | userId uuid NOT NULL 69 | ); 70 | 71 | CREATE TABLE IF NOT EXISTS tradeTaxFees ( 72 | tradeTaxFeeId serial PRIMARY KEY, 73 | tradeRefNo uuid NOT NULL references trades(tradeRefNo), 74 | taxFeeId varchar NOT NULL references taxFees(taxFeeId), 75 | amount decimal NOT NULL 76 | ); 77 | 78 | CREATE TABLE IF NOT EXISTS balance ( 79 | balanceId serial PRIMARY KEY, 80 | accountNo varchar NOT NULL UNIQUE references accounts(no), 81 | amount decimal NOT NULL, 82 | asOf timestamp NOT NULL, 83 | currency varchar NOT NULL 84 | ); 85 | 86 | CREATE TABLE IF NOT EXISTS users ( 87 | id uuid PRIMARY KEY DEFAULT gen_random_uuid(), 88 | name varchar UNIQUE NOT NULL, 89 | password varchar NOT NULL 90 | ); 91 | 92 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 93 | 94 | insert into accounts 95 | values( 96 | 'ibm-123', 97 | 'IBM', 98 | 'Trading', 99 | '2019-06-22 19:10:25', 100 | null, 101 | 'USD', 102 | 'USD', 103 | null 104 | ); 105 | 106 | insert into accounts 107 | values( 108 | 'ibm-124', 109 | 'IBM', 110 | 'Trading', 111 | '2019-08-22 19:10:25', 112 | null, 113 | 'USD', 114 | 'USD', 115 | null 116 | ); 117 | 118 | insert into accounts 119 | values( 120 | 'nri-654', 121 | 'Nomura', 122 | 'Trading', 123 | '2019-08-25 19:10:25', 124 | null, 125 | 'USD', 126 | 'USD', 127 | null 128 | ); 129 | 130 | insert into instruments 131 | values ( 132 | 'US0378331005', 133 | 'apple', 134 | 'equity', 135 | '2019-08-25 19:10:25', 136 | null, 137 | 100, 138 | 1200.50, 139 | null, 140 | null 141 | ); 142 | 143 | insert into instruments 144 | values ( 145 | 'GB0002634946', 146 | 'bae systems', 147 | 'equity', 148 | '2018-08-25 19:10:25', 149 | null, 150 | 100, 151 | 200.50, 152 | null, 153 | null 154 | ); 155 | 156 | insert into taxFees (taxFeeId, description) 157 | values 158 | ('TradeTax', 'Trade Tax'), 159 | ('Commission', 'Commission'), 160 | ('VAT', 'VAT'), 161 | ('Surcharge', 'Surcharge'); 162 | 163 | insert into users 164 | values (uuid_generate_v4(), 'debasish', 'toughgraff'); -------------------------------------------------------------------------------- /modules/core/src/main/resources/tables.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS accounts ( 2 | no varchar NOT NULL PRIMARY KEY, 3 | name varchar NOT NULL, 4 | type varchar NOT NULL, 5 | dateOfOpen timestamp with time zone NOT NULL, 6 | dateOfClose timestamp with time zone, 7 | baseCurrency varchar NOT NULL, 8 | tradingCurrency varchar, 9 | settlementCurrency varchar 10 | ); 11 | 12 | CREATE TABLE IF NOT EXISTS instruments ( 13 | isinCode varchar NOT NULL PRIMARY KEY, 14 | name varchar NOT NULL, 15 | type varchar NOT NULL, 16 | dateOfIssue timestamp, 17 | dateOfMaturity timestamp, 18 | lotSize integer, 19 | unitPrice decimal, 20 | couponRate decimal, 21 | couponFrequency varchar 22 | ); 23 | 24 | CREATE TABLE IF NOT EXISTS orders ( 25 | no varchar NOT NULL PRIMARY KEY, 26 | dateOfOrder timestamp NOT NULL, 27 | accountNo varchar references accounts(no) 28 | ); 29 | 30 | CREATE TABLE IF NOT EXISTS lineItems ( 31 | lineItemId serial PRIMARY KEY, 32 | orderNo varchar references orders(no), 33 | isinCode varchar references instruments(isinCode), 34 | quantity decimal NOT NULL, 35 | unitPrice decimal NOT NULL, 36 | buySellFlag varchar NOT NULL 37 | ); 38 | 39 | CREATE TABLE IF NOT EXISTS executions ( 40 | executionRefNo uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), 41 | accountNo varchar NOT NULL references accounts(no), 42 | orderNo varchar NOT NULL references orders(no), 43 | isinCode varchar NOT NULL references instruments(isinCode), 44 | market varchar NOT NULL, 45 | buySellFlag varchar NOT NULL, 46 | unitPrice decimal NOT NULL, 47 | quantity decimal NOT NULL, 48 | dateOfExecution timestamp NOT NULL, 49 | exchangeExecutionRefNo varchar 50 | ); 51 | 52 | CREATE TABLE IF NOT EXISTS taxFees ( 53 | taxFeeId varchar NOT NULL PRIMARY KEY, 54 | description varchar NOT NULL 55 | ); 56 | 57 | CREATE TABLE IF NOT EXISTS trades ( 58 | tradeRefNo uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), 59 | accountNo varchar NOT NULL references accounts(no), 60 | isinCode varchar NOT NULL references instruments(isinCode), 61 | market varchar NOT NULL, 62 | buySellFlag varchar NOT NULL, 63 | unitPrice decimal NOT NULL, 64 | quantity decimal NOT NULL, 65 | tradeDate timestamp NOT NULL, 66 | valueDate timestamp, 67 | netAmount decimal, 68 | userId uuid NOT NULL 69 | ); 70 | 71 | CREATE TABLE IF NOT EXISTS tradeTaxFees ( 72 | tradeTaxFeeId serial PRIMARY KEY, 73 | tradeRefNo uuid NOT NULL references trades(tradeRefNo), 74 | taxFeeId varchar NOT NULL references taxFees(taxFeeId), 75 | amount decimal NOT NULL 76 | ); 77 | 78 | CREATE TABLE IF NOT EXISTS balance ( 79 | balanceId serial PRIMARY KEY, 80 | accountNo varchar NOT NULL UNIQUE references accounts(no), 81 | amount decimal NOT NULL, 82 | asOf timestamp NOT NULL, 83 | currency varchar NOT NULL 84 | ); 85 | 86 | CREATE TABLE IF NOT EXISTS users ( 87 | id uuid PRIMARY KEY DEFAULT gen_random_uuid(), 88 | name varchar UNIQUE NOT NULL, 89 | password varchar NOT NULL 90 | ); 91 | 92 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 93 | 94 | insert into accounts 95 | values( 96 | 'ibm-123', 97 | 'IBM', 98 | 'Trading', 99 | current_date, 100 | null, 101 | 'USD', 102 | 'USD', 103 | null 104 | ); 105 | 106 | insert into accounts 107 | values( 108 | 'ibm-124', 109 | 'IBM', 110 | 'Trading', 111 | current_date, 112 | null, 113 | 'USD', 114 | 'USD', 115 | null 116 | ); 117 | 118 | insert into accounts 119 | values( 120 | 'nri-654', 121 | 'Nomura', 122 | 'Trading', 123 | current_date, 124 | null, 125 | 'USD', 126 | 'USD', 127 | null 128 | ); 129 | 130 | insert into instruments 131 | values ( 132 | 'US0378331005', 133 | 'apple', 134 | 'Equity', 135 | '2019-08-25 19:10:25', 136 | null, 137 | 100, 138 | 1200.50, 139 | null, 140 | null 141 | ); 142 | 143 | insert into instruments 144 | values ( 145 | 'GB0002634946', 146 | 'bae systems', 147 | 'Equity', 148 | '2018-08-25 19:10:25', 149 | null, 150 | 100, 151 | 200.50, 152 | null, 153 | null 154 | ); 155 | 156 | insert into instruments 157 | values ( 158 | 'US4592001014', 159 | 'ibm', 160 | 'Equity', 161 | '2018-08-25 19:10:25', 162 | null, 163 | 100, 164 | 500.50, 165 | null, 166 | null 167 | ); 168 | 169 | insert into instruments 170 | values ( 171 | 'AU0000XVGZA3', 172 | 'Treasury Corp Victoria', 173 | 'FixedIncome', 174 | '2018-08-25 19:10:25', 175 | '2030-08-25 19:10:25', 176 | 1, 177 | 10000, 178 | 5.75, 179 | 'Annual' 180 | ); 181 | 182 | insert into taxFees 183 | values 184 | ('TradeTax', 'Trade Tax'), 185 | ('VAT', 'VAT'), 186 | ('Surcharge', 'Surcharge'), 187 | ('Commission', 'Commission') -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/model/trade.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package model 3 | 4 | import zio.prelude.* 5 | import market.* 6 | import account.* 7 | import instrument.* 8 | import order.* 9 | import user.* 10 | import squants.market.* 11 | import com.softwaremill.quicklens.* 12 | import zio.{ Random, Task, ZIO } 13 | import java.time.LocalDateTime 14 | import java.util.UUID 15 | 16 | object trade: 17 | object TradeRefNo extends Newtype[UUID]: 18 | given Equal[TradeRefNo] = Equal.default 19 | 20 | type TradeRefNo = TradeRefNo.Type 21 | 22 | enum TaxFeeId(val entryName: NonEmptyString): 23 | case TradeTax extends TaxFeeId(NonEmptyString("TradeTax")) 24 | case Commission extends TaxFeeId(NonEmptyString("Commission")) 25 | case VAT extends TaxFeeId(NonEmptyString("VAT")) 26 | case Surcharge extends TaxFeeId(NonEmptyString("Surcharge")) 27 | 28 | import TaxFeeId.* 29 | 30 | // rates of tax/fees expressed as fractions of the principal of the trade 31 | final val rates: Map[TaxFeeId, BigDecimal] = 32 | Map(TradeTax -> 0.2, Commission -> 0.15, VAT -> 0.1) 33 | 34 | // tax and fees applicable for each market 35 | // Other signifies the general rule applicable for all markets 36 | final val taxFeeForMarket: Map[Market, List[TaxFeeId]] = 37 | Map( 38 | Market.Other -> List(TradeTax, Commission), 39 | Market.Singapore -> List(TradeTax, Commission, VAT) 40 | ) 41 | 42 | // get the list of tax/fees applicable for this trade 43 | // depending on the market 44 | final val forTrade: Trade => Option[List[TaxFeeId]] = { trade => 45 | taxFeeForMarket.get(trade.market).orElse(taxFeeForMarket.get(Market.Other)) 46 | } 47 | 48 | final def principal(trade: Trade): Money = 49 | Money(UnitPrice.unwrap(trade.unitPrice) * Quantity.unwrap(trade.quantity)) 50 | 51 | // combinator to value a tax/fee for a specific trade 52 | private def valueAs(trade: Trade, taxFeeId: TaxFeeId): Money = 53 | ((rates get taxFeeId) map (principal(trade) * _)) getOrElse (Money(0)) 54 | 55 | // all tax/fees for a specific trade 56 | private def taxFeeCalculate( 57 | trade: Trade, 58 | taxFeeIds: List[TaxFeeId] 59 | ): List[TradeTaxFee] = 60 | taxFeeIds 61 | .zip(taxFeeIds.map(valueAs(trade, _))) 62 | .map: 63 | case (tid, amt) => TradeTaxFee(tid, amt) 64 | 65 | private def netAmount( 66 | trade: Trade, 67 | taxFeeAmounts: List[TradeTaxFee] 68 | ): Money = 69 | principal(trade) + taxFeeAmounts.map(_.amount).foldLeft(Money(0))(_ + _) 70 | 71 | final case class Trade private[domain] ( 72 | tradeRefNo: TradeRefNo, 73 | accountNo: AccountNo, 74 | isin: ISINCode, 75 | market: Market, 76 | buySell: BuySell, 77 | unitPrice: UnitPrice, 78 | quantity: Quantity, 79 | tradeDate: LocalDateTime, 80 | valueDate: Option[LocalDateTime] = None, 81 | userId: Option[UserId] = None, 82 | taxFees: List[TradeTaxFee] = List.empty, 83 | netAmount: Option[Money] = None 84 | ) 85 | 86 | private[domain] final case class TradeTaxFee( 87 | taxFeeId: TaxFeeId, 88 | amount: Money 89 | ) 90 | 91 | object Trade: 92 | 93 | def trade( 94 | accountNo: AccountNo, 95 | isin: ISINCode, 96 | market: Market, 97 | buySell: BuySell, 98 | unitPrice: UnitPrice, 99 | quantity: Quantity, 100 | tradeDate: LocalDateTime, 101 | valueDate: Option[LocalDateTime] = None, 102 | userId: Option[UserId] = None 103 | ): Task[Trade] = (for 104 | tdvd <- ZIO.fromEither(validateTradeValueDate(tradeDate, valueDate).toEither) 105 | refNo <- Random.nextUUID.map(uuid => TradeRefNo.make(uuid).toEither).absolve 106 | yield Trade( 107 | refNo, 108 | accountNo, 109 | isin, 110 | market, 111 | buySell, 112 | unitPrice, 113 | quantity, 114 | tdvd._1, 115 | tdvd._2, 116 | userId 117 | )).mapError(errors => new Throwable(errors.mkString(","))) 118 | 119 | private def validateTradeValueDate( 120 | td: LocalDateTime, 121 | vd: Option[LocalDateTime] 122 | ): Validation[String, (LocalDateTime, Option[LocalDateTime])] = 123 | vd.map: v => 124 | if (v.isBefore(td)) Validation.fail(s"Value date $v cannot be earlier than trade date $td") 125 | else Validation.succeed((td, vd)) 126 | .getOrElse(Validation.succeed((td, vd))) 127 | 128 | def withTaxFee(trade: Trade): Trade = 129 | if (trade.taxFees.isEmpty && !trade.netAmount.isDefined) 130 | val taxFees = forTrade(trade).map(taxFeeCalculate(trade, _)).getOrElse(List.empty) 131 | val netAmt = netAmount(trade, taxFees) 132 | trade 133 | .modify(_.taxFees) 134 | .setTo(taxFees) 135 | .modify(_.netAmount.each) 136 | .setTo(netAmt) 137 | else trade 138 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/repository/live/OrderRepositoryLive.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package repository 3 | package live 4 | 5 | import java.time.LocalDate 6 | 7 | import model.account.* 8 | import model.order.* 9 | import zio.prelude.NonEmptyList 10 | import zio.{ Task, UIO, ZIO, ZLayer } 11 | import cats.effect.kernel.Resource 12 | import skunk.* 13 | import skunk.codec.all.* 14 | import skunk.implicits.* 15 | import codecs.{ *, given } 16 | import zio.prelude.Associative 17 | import zio.interop.catz.* 18 | 19 | final case class OrderRepositoryLive(postgres: Resource[Task, Session[Task]]) extends OrderRepository: 20 | import OrderRepositorySQL._ 21 | 22 | implicit val orderConcatAssociative: Associative[Order] = 23 | new Associative[Order]: 24 | def combine(x: => Order, y: => Order): Order = 25 | Order.make( 26 | no = x.no, 27 | orderDate = x.date, 28 | accountNo = x.accountNo, 29 | items = x.items ++ y.items 30 | ) 31 | 32 | override def store(orders: NonEmptyList[Order]): UIO[Unit] = 33 | postgres 34 | .use(session => 35 | session.transaction.use(_ => ZIO.foreach(orders.toList)(storeOrderAndLineItems(_, session)).map(_ => ())) 36 | ) 37 | .orDie 38 | 39 | override def store(ord: Order): UIO[Order] = 40 | postgres.use(session => session.transaction.use(_ => storeOrderAndLineItems(ord, session))).orDie 41 | 42 | private def storeOrderAndLineItems( 43 | ord: Order, 44 | session: Session[Task] 45 | ): Task[Order] = 46 | val lineItems = ord.items.toList 47 | session 48 | .prepare(deleteLineItems) 49 | .flatMap(_.execute(OrderNo.unwrap(ord.no))) *> 50 | session 51 | .prepare(upsertOrder) 52 | .flatMap( 53 | _.execute( 54 | OrderNo.unwrap(ord.no) ~ ord.date ~ AccountNo.unwrap(ord.accountNo) 55 | ) 56 | ) *> 57 | session 58 | .prepare(insertLineItems(ord.no, lineItems)) 59 | .flatMap: cmd => 60 | cmd.execute(lineItems) 61 | .unit 62 | .map(_ => ord) 63 | 64 | override def query(no: OrderNo): UIO[Option[Order]] = 65 | postgres 66 | .use: session => 67 | session 68 | .prepare(selectByOrderNo) 69 | .flatMap: ps => 70 | ps.stream(no, 1024) 71 | .compile 72 | .toList 73 | .map(_.groupBy(_.no)) 74 | .map: 75 | _.map: 76 | case (_, lis) => lis.reduce(Associative[Order].combine(_, _)) 77 | .headOption 78 | .orDie 79 | 80 | override def queryByOrderDate(date: LocalDate): UIO[List[Order]] = 81 | postgres 82 | .use: session => 83 | session 84 | .prepare(selectByOrderDate) 85 | .flatMap(ps => 86 | ps.stream(date, 1024) 87 | .compile 88 | .toList 89 | .map(_.groupBy(_.no)) 90 | .map: m => 91 | m.map: 92 | case (_, lis) => lis.reduce(Associative[Order].combine(_, _)) 93 | .toList 94 | ) 95 | .orDie 96 | 97 | override def cleanAllOrders: UIO[Unit] = 98 | postgres.use(session => session.execute(deleteAllLineItems).unit *> session.execute(deleteAllOrders).unit).orDie 99 | 100 | private object OrderRepositorySQL: 101 | 102 | val orderLineItemDecoder: Decoder[Order] = 103 | (timestamp ~ accountNo ~ isinCode ~ quantity ~ unitPrice ~ buySell ~ orderNo) 104 | .map { case od ~ ano ~ isin ~ qty ~ up ~ bs ~ ono => 105 | Order.make(ono, od, ano, NonEmptyList(LineItem.make(ono, isin, qty, up, bs))) 106 | } 107 | 108 | val orderEncoder: Encoder[Order] = 109 | (orderNo ~ accountNo ~ timestamp).values 110 | .contramap((o: Order) => o.no ~ o.accountNo ~ o.date) 111 | 112 | def lineItemEncoder(ordNo: OrderNo) = 113 | (orderNo ~ isinCode ~ quantity ~ unitPrice ~ buySell).values 114 | .contramap((li: LineItem) => ordNo ~ li.isin ~ li.quantity ~ li.unitPrice ~ li.buySell) 115 | 116 | val selectByOrderNo: Query[OrderNo, Order] = 117 | sql""" 118 | SELECT o.dateOfOrder, o.accountNo, l.isinCode, l.quantity, l.unitPrice, l.buySellFlag, o.no 119 | FROM orders o, lineItems l 120 | WHERE o.no = $orderNo 121 | AND o.no = l.orderNo 122 | """.query(orderLineItemDecoder) 123 | 124 | val selectByOrderDate: Query[LocalDate, Order] = 125 | sql""" 126 | SELECT o.dateOfOrder, o.accountNo, l.isinCode, l.quantity, l.unitPrice, l.buySellFlag, o.no 127 | FROM orders o, lineItems l 128 | WHERE Date(o.dateOfOrder) = $date 129 | AND o.no = l.orderNo 130 | """.query(orderLineItemDecoder) 131 | 132 | val insertOrder: Command[Order] = 133 | sql"INSERT INTO orders (no, dateOfOrder, accountNo) VALUES $orderEncoder".command 134 | 135 | def insertLineItem(orderNo: OrderNo): Command[LineItem] = 136 | sql"INSERT INTO lineItems (orderNo, isinCode, quantity, unitPrice, buySellFlag) VALUES ${lineItemEncoder(orderNo)}".command 137 | 138 | def insertLineItems(orderNo: OrderNo, n: Int): Command[List[LineItem]] = { 139 | val es = lineItemEncoder(orderNo).list(n) 140 | sql"INSERT INTO lineItems (orderNo, isinCode, quantity, unitPrice, buySellFlag) VALUES $es".command 141 | } 142 | 143 | def insertLineItems( 144 | orderNo: OrderNo, 145 | lineItems: List[LineItem] 146 | ): Command[lineItems.type] = 147 | val es = lineItemEncoder(orderNo).list(lineItems) 148 | sql"INSERT INTO lineItems (orderNo, isinCode, quantity, unitPrice, buySellFlag) VALUES $es".command 149 | 150 | val upsertOrder = 151 | sql""" 152 | INSERT INTO orders 153 | VALUES ($varchar, $timestamp, $varchar) 154 | ON CONFLICT(no) DO UPDATE SET 155 | dateOfOrder = EXCLUDED.dateOfOrder, 156 | accountNo = EXCLUDED.accountNo 157 | """.command 158 | 159 | val deleteLineItems: Command[String] = 160 | sql"DELETE FROM lineItems WHERE orderNo = $varchar".command 161 | 162 | val deleteAllLineItems: Command[Void] = 163 | sql"DELETE FROM lineItems".command 164 | 165 | val deleteAllOrders: Command[Void] = 166 | sql"DELETE FROM orders".command 167 | 168 | object OrderRepositoryLive: 169 | val layer = ZLayer.fromFunction(OrderRepositoryLive.apply _) 170 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/repository/live/AccountRepositoryLive.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package repository 3 | package live 4 | 5 | import java.time.LocalDate 6 | import cats.syntax.all.* 7 | import cats.effect.Resource 8 | import skunk.* 9 | import skunk.codec.all.* 10 | import skunk.implicits.* 11 | 12 | import model.account.* 13 | import codecs.{ *, given } 14 | import zio.{ Chunk, Task, UIO, ZIO, ZLayer } 15 | import zio.stream.ZStream 16 | import zio.interop.catz.* 17 | import zio.stream.interop.fs2z.* 18 | 19 | final case class AccountRepositoryLive(postgres: Resource[Task, Session[Task]]) extends AccountRepository: 20 | import AccountRepositorySQL._ 21 | 22 | def query(no: AccountNo): UIO[Option[ClientAccount]] = 23 | postgres.use(session => session.prepare(selectByAccountNo).flatMap(ps => ps.option(no))).orDie 24 | 25 | def store(a: ClientAccount, upsert: Boolean = true): UIO[ClientAccount] = 26 | postgres 27 | .use: session => 28 | session 29 | .prepare(if (upsert) upsertAccount else insertAccount) 30 | .flatMap: cmd => 31 | cmd.execute(a).void.map(_ => a) 32 | .orDie 33 | 34 | def query(openedOn: LocalDate): UIO[List[ClientAccount]] = 35 | postgres 36 | .use: session => 37 | session 38 | .prepare(selectByOpenedDate) 39 | .flatMap: ps => 40 | ps.stream(openedOn, 1024).compile.toList 41 | .orDie 42 | 43 | def all: UIO[List[ClientAccount]] = 44 | postgres 45 | .use: session => 46 | session.execute(selectAll) 47 | .orDie 48 | 49 | def allClosed(closeDate: Option[LocalDate]): UIO[List[ClientAccount]] = 50 | postgres 51 | .use: session => 52 | closeDate 53 | .map: cd => 54 | session 55 | .prepare(selectClosedAfter) 56 | .flatMap: ps => 57 | ps.stream(cd, 1024).compile.toList 58 | .getOrElse: 59 | session.execute(selectAllClosed) 60 | .orDie 61 | 62 | private[domain] object AccountRepositorySQL: 63 | 64 | val accountEncoder: Encoder[ClientAccount] = 65 | ( 66 | accountNo ~ accountName ~ timestamp ~ timestamp.opt ~ currency ~ currency.opt ~ currency.opt 67 | ).values.contramap: 68 | case TradingAccount(AccountBase(no, nm, dop, doc, bc), tc) => 69 | no ~ nm ~ dop ~ doc ~ bc ~ Some(tc.tradingCurrency) ~ None 70 | 71 | case SettlementAccount(AccountBase(no, nm, dop, doc, bc), sc) => 72 | no ~ nm ~ dop ~ doc ~ bc ~ None ~ Some(sc.settlementCurrency) 73 | 74 | case TradingAndSettlementAccount(AccountBase(no, nm, dop, doc, bc), tsc) => 75 | no ~ nm ~ dop ~ doc ~ bc ~ Some(tsc.tradingCurrency) ~ Some(tsc.settlementCurrency) 76 | 77 | val accountDecoder: Decoder[ClientAccount] = 78 | (accountNo ~ accountName ~ timestamp ~ timestamp.opt ~ currency ~ currency.opt ~ currency.opt) 79 | .map: 80 | case no ~ nm ~ dp ~ dc ~ bc ~ tc ~ None => 81 | TradingAccount 82 | .tradingAccount( 83 | no, 84 | nm, 85 | Some(dp), 86 | dc, 87 | bc, 88 | tc.get 89 | ) 90 | .fold(errs => throw new Exception(errs.mkString), identity) 91 | case no ~ nm ~ dp ~ dc ~ bc ~ None ~ sc => 92 | SettlementAccount 93 | .settlementAccount( 94 | no, 95 | nm, 96 | Some(dp), 97 | dc, 98 | bc, 99 | sc.get 100 | ) 101 | .fold(errs => throw new Exception(errs.mkString), identity) 102 | case no ~ nm ~ dp ~ dc ~ bc ~ tc ~ sc => 103 | TradingAndSettlementAccount 104 | .tradingAndSettlementAccount( 105 | no, 106 | nm, 107 | Some(dp), 108 | dc, 109 | bc, 110 | tc.get, 111 | sc.get 112 | ) 113 | .fold(errs => throw new Exception(errs.mkString), identity) 114 | 115 | val selectByAccountNo: Query[AccountNo, ClientAccount] = 116 | sql""" 117 | SELECT a.no, a.name, a.dateOfOpen, a.dateOfClose, a.baseCurrency, a.tradingCurrency, a.settlementCurrency 118 | FROM accounts AS a 119 | WHERE a.no = $accountNo 120 | """.query(accountDecoder) 121 | 122 | val selectByOpenedDate: Query[LocalDate, ClientAccount] = 123 | sql""" 124 | SELECT a.no, a.name, a.dateOfOpen, a.dateOfClose, a.baseCurrency, a.tradingCurrency, a.settlementCurrency 125 | FROM accounts AS a 126 | WHERE DATE(a.dateOfOpen) = $date 127 | """.query(accountDecoder) 128 | 129 | val selectAll: Query[Void, ClientAccount] = 130 | sql""" 131 | SELECT a.no, a.name, a.dateOfOpen, a.dateOfClose, a.baseCurrency, a.tradingCurrency, a.settlementCurrency 132 | FROM accounts AS a 133 | """.query(accountDecoder) 134 | 135 | val selectClosedAfter: Query[LocalDate, ClientAccount] = 136 | sql""" 137 | SELECT a.no, a.name, a.dateOfOpen, a.dateOfClose, a.baseCurrency, a.tradingCurrency, a.settlementCurrency 138 | FROM accounts AS a 139 | WHERE a.dateOfClose >= $date 140 | """.query(accountDecoder) 141 | 142 | val selectAllClosed: Query[Void, ClientAccount] = 143 | sql""" 144 | SELECT a.no, a.name, a.dateOfOpen, a.dateOfClose, a.baseCurrency, a.tradingCurrency, a.settlementCurrency 145 | FROM accounts AS a 146 | WHERE a.dateOfClose IS NOT NULL 147 | """.query(accountDecoder) 148 | 149 | val insertAccount: Command[ClientAccount] = 150 | sql""" 151 | INSERT INTO accounts 152 | VALUES $accountEncoder 153 | """.command 154 | 155 | val upsertAccount: Command[ClientAccount] = 156 | sql""" 157 | INSERT INTO accounts 158 | VALUES $accountEncoder 159 | ON CONFLICT(no) DO UPDATE SET 160 | name = EXCLUDED.name, 161 | dateOfOpen = EXCLUDED.dateOfOpen, 162 | dateOfClose = EXCLUDED.dateOfClose, 163 | baseCurrency = EXCLUDED.baseCurrency, 164 | tradingCurrency = EXCLUDED.tradingCurrency, 165 | settlementCurrency = EXCLUDED.settlementCurrency 166 | """.command 167 | 168 | object AccountRepositoryLive: 169 | val layer = ZLayer.fromFunction(AccountRepositoryLive.apply _) 170 | 171 | /** stream based computation pattern. Need a session which will be passed from the call site using `AppResources` */ 172 | def streamAccountsAndDoStuff( 173 | session: Session[Task] 174 | ): Task[Long] = 175 | session 176 | .prepare(AccountRepositorySQL.selectAll) // prepare the query once 177 | .flatMap(_.stream(Void, 512).toZStream().runCount) // run the streaming logic within the flatMap 178 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/model/Account.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package model 3 | 4 | import squants.market.* 5 | import zio.prelude.{ Equal, Newtype, Validation } 6 | import cats.implicits.catsSyntaxEither 7 | import java.time.LocalDateTime 8 | 9 | def today = LocalDateTime.now() 10 | 11 | object account: 12 | final case class AccountBase private[model] ( 13 | no: AccountNo, 14 | name: AccountName, 15 | dateOfOpen: LocalDateTime, 16 | dateOfClose: Option[LocalDateTime], 17 | baseCurrency: Currency 18 | ) 19 | 20 | sealed trait AccountType 21 | 22 | sealed trait Trading extends AccountType: 23 | def tradingCurrency: Currency 24 | 25 | sealed trait Settlement extends AccountType: 26 | def settlementCurrency: Currency 27 | 28 | private[model] trait Account[C <: AccountType]: 29 | private[account] def base: AccountBase 30 | def accountType: C 31 | def closeAccount(closeDate: LocalDateTime): Validation[String, Account[C]] 32 | val no = base.no 33 | val name = base.name 34 | val dateOfOpen = base.dateOfOpen 35 | val dateOfClose = base.dateOfClose 36 | val baseCurrency = base.baseCurrency 37 | 38 | final case class TradingAccount private ( 39 | private[domain] val base: AccountBase, 40 | accountType: Trading 41 | ) extends Account[Trading]: 42 | val tradingCurrency = accountType.tradingCurrency 43 | def closeAccount(closeDate: LocalDateTime): Validation[String, TradingAccount] = 44 | close(base, closeDate).map(TradingAccount(_, accountType)) 45 | 46 | final case class SettlementAccount private ( 47 | private[domain] val base: AccountBase, 48 | accountType: Settlement 49 | ) extends Account[Settlement]: 50 | val settlementCurrency = accountType.settlementCurrency 51 | def closeAccount(closeDate: LocalDateTime): Validation[String, SettlementAccount] = 52 | close(base, closeDate).map(SettlementAccount(_, accountType)) 53 | 54 | final case class TradingAndSettlementAccount private ( 55 | private[domain] val base: AccountBase, 56 | accountType: Trading & Settlement 57 | ) extends Account[Trading & Settlement]: 58 | val tradingCurrency = accountType.tradingCurrency 59 | val settlementCurrency = accountType.settlementCurrency 60 | def closeAccount(closeDate: LocalDateTime): Validation[String, TradingAndSettlementAccount] = 61 | close(base, closeDate).map(TradingAndSettlementAccount(_, accountType)) 62 | 63 | type ClientAccount = TradingAccount | SettlementAccount | TradingAndSettlementAccount 64 | 65 | object AccountNo extends Newtype[String]: 66 | given Equal[AccountNo] = Equal.default 67 | 68 | type AccountNo = AccountNo.Type 69 | extension (ano: AccountNo) 70 | def validateNo: Validation[String, AccountNo] = 71 | if (AccountNo.unwrap(ano).size > 12 || AccountNo.unwrap(ano).size < 5) 72 | Validation.fail(s"AccountNo cannot be more than 12 characters or less than 5 characters long") 73 | else Validation.succeed(ano) 74 | 75 | object AccountName extends Newtype[String] 76 | 77 | type AccountName = AccountName.Type 78 | extension (aname: AccountName) 79 | def validateName: Validation[String, AccountName] = 80 | if (AccountName.unwrap(aname).isEmpty || AccountName.unwrap(aname).isBlank) 81 | Validation.fail(s"Account Name cannot be empty") 82 | else Validation.succeed(aname) 83 | 84 | object TradingAccount: 85 | 86 | def tradingAccount( 87 | no: AccountNo, 88 | name: AccountName, 89 | dateOfOpen: Option[LocalDateTime], 90 | dateOfClose: Option[LocalDateTime], 91 | baseCurrency: Currency, 92 | tradingCcy: Currency 93 | ): Validation[String, TradingAccount] = 94 | Validation.validateWith( 95 | no.validateNo, 96 | name.validateName, 97 | validateOpenCloseDate(dateOfOpen.getOrElse(today), dateOfClose) 98 | ) { (n, nm, d) => 99 | TradingAccount( 100 | base = AccountBase(no, name, d._1, d._2, baseCurrency), 101 | accountType = new Trading: 102 | def tradingCurrency = tradingCcy 103 | ) 104 | } 105 | 106 | object SettlementAccount: 107 | 108 | def settlementAccount( 109 | no: AccountNo, 110 | name: AccountName, 111 | dateOfOpen: Option[LocalDateTime], 112 | dateOfClose: Option[LocalDateTime], 113 | baseCurrency: Currency, 114 | settlementCcy: Currency 115 | ): Validation[String, SettlementAccount] = 116 | Validation.validateWith( 117 | no.validateNo, 118 | name.validateName, 119 | validateOpenCloseDate(dateOfOpen.getOrElse(today), dateOfClose) 120 | ): (n, nm, d) => 121 | SettlementAccount( 122 | base = AccountBase(no, name, d._1, d._2, baseCurrency), 123 | accountType = new Settlement: 124 | def settlementCurrency = settlementCcy 125 | ) 126 | 127 | object TradingAndSettlementAccount: 128 | abstract case class Both() extends Trading, Settlement 129 | 130 | def tradingAndSettlementAccount( 131 | no: AccountNo, 132 | name: AccountName, 133 | dateOfOpen: Option[LocalDateTime], 134 | dateOfClose: Option[LocalDateTime], 135 | baseCurrency: Currency, 136 | tradingCcy: Currency, 137 | settlementCcy: Currency 138 | ): Validation[String, TradingAndSettlementAccount] = 139 | Validation.validateWith( 140 | no.validateNo, 141 | name.validateName, 142 | validateOpenCloseDate(dateOfOpen.getOrElse(today), dateOfClose) 143 | ): (n, nm, d) => 144 | TradingAndSettlementAccount( 145 | base = AccountBase(no, name, d._1, d._2, baseCurrency), 146 | accountType = new Both: 147 | def tradingCurrency = tradingCcy 148 | def settlementCurrency = settlementCcy 149 | ) 150 | 151 | private def validateOpenCloseDate( 152 | od: LocalDateTime, 153 | cd: Option[LocalDateTime] 154 | ): Validation[String, (LocalDateTime, Option[LocalDateTime])] = 155 | cd.map: c => 156 | if (c isBefore od) 157 | Validation.fail(s"Close date [$c] cannot be earlier than open date [$od]") 158 | else Validation.succeed((od, cd)) 159 | .getOrElse(Validation.succeed((od, cd))) 160 | 161 | private def validateAccountAlreadyClosed( 162 | a: AccountBase 163 | ): Validation[String, AccountBase] = 164 | if (a.dateOfClose.isDefined) 165 | Validation.fail(s"Account ${a.no} is already closed") 166 | else Validation.succeed(a) 167 | 168 | private def validateCloseDate( 169 | a: AccountBase, 170 | cd: LocalDateTime 171 | ): Validation[String, LocalDateTime] = 172 | if (cd isBefore a.dateOfOpen) 173 | Validation.fail(s"Close date [$cd] cannot be earlier than open date [${a.dateOfOpen}]") 174 | else Validation.succeed(cd) 175 | 176 | private def close( 177 | a: AccountBase, 178 | closeDate: LocalDateTime 179 | ): Validation[String, AccountBase] = 180 | Validation 181 | .validateWith(validateAccountAlreadyClosed(a), validateCloseDate(a, closeDate)) { (acc, _) => 182 | acc.copy(dateOfClose = Some(closeDate)) 183 | } 184 | 185 | object Main: 186 | import account._ 187 | val ta = TradingAccount 188 | .tradingAccount( 189 | no = AccountNo(NonEmptyString("a-123456")), 190 | name = AccountName("debasish ghosh"), 191 | baseCurrency = USD, 192 | tradingCcy = USD, 193 | dateOfOpen = None, 194 | dateOfClose = None 195 | ) 196 | .fold(errs => throw new Exception(errs.mkString), identity) 197 | -------------------------------------------------------------------------------- /modules/core/src/main/scala/tradex/domain/repository/live/TradeRepositoryLive.scala: -------------------------------------------------------------------------------- 1 | package tradex.domain 2 | package repository 3 | package live 4 | 5 | import zio.{ Task, UIO, ZIO } 6 | import cats.effect.kernel.Resource 7 | import skunk.* 8 | import skunk.codec.all.* 9 | import skunk.implicits.* 10 | import model.market.* 11 | import model.trade.* 12 | import model.account.* 13 | import java.time.LocalDate 14 | import zio.prelude.NonEmptyList 15 | import codecs.{ *, given } 16 | import zio.interop.catz.* 17 | import zio.prelude.Associative 18 | import zio.ZLayer 19 | import zio.Chunk 20 | 21 | final case class TradeRepositoryLive(postgres: Resource[Task, Session[Task]]) extends TradeRepository: 22 | import TradeRepositorySQL.* 23 | 24 | // semigroup that combines trades with same reference number 25 | // used in combining join records between trades and taxFees tables 26 | // NOT a generic semigroup that combines all trades - only specific 27 | // to this query - hence not added in the companion object 28 | implicit val tradeConcatSemigroup: Associative[Trade] = 29 | new Associative[Trade]: 30 | def combine(x: => Trade, y: => Trade): Trade = 31 | x.copy(taxFees = x.taxFees ++ y.taxFees) 32 | 33 | override def store(trades: Chunk[Trade]): UIO[Unit] = 34 | postgres 35 | .use: session => 36 | ZIO 37 | .foreach(trades.toList)(trade => storeTradeAndTaxFees(trade, session)) 38 | .unit 39 | .orDie 40 | 41 | override def all: UIO[List[Trade]] = 42 | postgres 43 | .use: session => 44 | session 45 | .prepare(selectAll) 46 | .flatMap: ps => 47 | ps.stream(skunk.Void, 1024) 48 | .compile 49 | .toList 50 | .map(_.groupBy(_.tradeRefNo)) 51 | .map: 52 | _.map: 53 | case (_, trades) => trades.reduce(Associative[Trade].combine(_, _)) 54 | .toList 55 | .orDie 56 | 57 | override def store(trd: Trade): UIO[Trade] = 58 | postgres 59 | .use: session => 60 | storeTradeAndTaxFees(trd, session) 61 | .orDie 62 | 63 | private def storeTradeAndTaxFees( 64 | t: Trade, 65 | session: Session[Task] 66 | ): Task[Trade] = 67 | val r = for 68 | p1 <- session.prepare(insertTrade) 69 | p2 <- session.prepare(insertTaxFees(t.tradeRefNo, t.taxFees)) 70 | yield (p1, p2) 71 | 72 | r.flatMap: 73 | case (p1, p2) => 74 | session.transaction.use: _ => 75 | for 76 | _ <- p1.execute(t) 77 | _ <- p2.execute(t.taxFees) 78 | yield () 79 | .map(_ => t) 80 | 81 | override def query(accountNo: AccountNo, date: LocalDate): UIO[List[Trade]] = 82 | postgres 83 | .use: session => 84 | session 85 | .prepare(selectByAccountNoAndDate) 86 | .flatMap: ps => 87 | ps.stream(accountNo ~ date, 1024) 88 | .compile 89 | .toList 90 | .map(_.groupBy(_.tradeRefNo)) 91 | .map: 92 | _.map: 93 | case (_, trades) => trades.reduce(Associative[Trade].combine(_, _)) 94 | .toList 95 | .orDie 96 | 97 | override def queryByMarket(market: Market): UIO[List[Trade]] = 98 | postgres 99 | .use: session => 100 | session.prepare(selectByMarket).flatMap { ps => 101 | ps.stream(market, 1024) 102 | .compile 103 | .toList 104 | .map(_.groupBy(_.tradeRefNo)) 105 | .map: 106 | _.map: 107 | case (_, trades) => trades.reduce(Associative[Trade].combine(_, _)) 108 | .toList 109 | } 110 | .orDie 111 | 112 | private[domain] object TradeRepositorySQL: 113 | val tradeTaxFeeDecoder: Decoder[Trade] = 114 | (accountNo ~ isinCode ~ market ~ buySell ~ unitPrice ~ quantity ~ timestamp ~ timestamp.opt ~ userId.opt ~ money.opt ~ taxFeeId ~ money ~ tradeRefNo) 115 | .map: 116 | case ano ~ isin ~ mkt ~ bs ~ up ~ qty ~ td ~ vdOpt ~ uidOpt ~ naOpt ~ tx ~ amt ~ ref => 117 | ( 118 | Trade( 119 | ref, 120 | ano, 121 | isin, 122 | mkt, 123 | bs, 124 | up, 125 | qty, 126 | td, 127 | vdOpt, 128 | uidOpt, 129 | List(TradeTaxFee(tx, amt)), 130 | naOpt 131 | ) 132 | ) 133 | 134 | val tradeEncoder: Encoder[Trade] = 135 | (tradeRefNo ~ accountNo ~ isinCode ~ market ~ buySell ~ unitPrice ~ quantity ~ timestamp ~ timestamp.opt ~ userId.opt ~ money.opt).values 136 | .contramap((t: Trade) => 137 | t.tradeRefNo ~ t.accountNo ~ t.isin ~ t.market ~ t.buySell ~ t.unitPrice ~ t.quantity ~ t.tradeDate ~ t.valueDate ~ t.userId ~ t.netAmount 138 | ) 139 | 140 | def taxFeeEncoder(refNo: TradeRefNo): Encoder[TradeTaxFee] = 141 | (tradeRefNo ~ taxFeeId ~ money).values 142 | .contramap((t: TradeTaxFee) => refNo ~ t.taxFeeId ~ t.amount) 143 | 144 | val insertTrade: Command[Trade] = 145 | sql""" 146 | INSERT INTO trades 147 | ( 148 | tradeRefNo, 149 | accountNo, 150 | isinCode, 151 | market, 152 | buySellFlag, 153 | unitPrice, 154 | quantity, 155 | tradeDate, 156 | valueDate, 157 | userId, 158 | netAmount 159 | ) 160 | VALUES $tradeEncoder 161 | """.command 162 | 163 | def insertTaxFee(tradeRefNo: TradeRefNo): Command[TradeTaxFee] = 164 | sql"INSERT INTO tradeTaxFees (tradeRefNo, taxFeeId, amount) VALUES ${taxFeeEncoder(tradeRefNo)}".command 165 | 166 | def insertTaxFees( 167 | tradeRefNo: TradeRefNo, 168 | taxFees: List[TradeTaxFee] 169 | ): Command[taxFees.type] = { 170 | val es = taxFeeEncoder(tradeRefNo).list(taxFees) 171 | sql"INSERT INTO tradeTaxFees (tradeRefNo, taxFeeId, amount) VALUES $es".command 172 | } 173 | 174 | def insertTrades(trades: List[Trade]): Command[trades.type] = 175 | val enc = tradeEncoder.list(trades) 176 | sql""" 177 | INSERT INTO trades 178 | ( 179 | tradeRefNo 180 | , accountNo 181 | , isinCode 182 | , market 183 | , buySellFlag 184 | , unitPrice 185 | , quantity 186 | , tradeDate 187 | , valueDate 188 | , userId 189 | , netAmount 190 | ) 191 | VALUES $enc""".command 192 | 193 | val selectByAccountNoAndDate = 194 | sql""" 195 | SELECT t.accountNo, 196 | t.isinCode, 197 | t.market, 198 | t.buySellFlag, 199 | t.unitPrice, 200 | t.quantity, 201 | t.tradeDate, 202 | t.valueDate, 203 | t.userId, 204 | t.netAmount, 205 | f.taxFeeId, 206 | f.amount, 207 | t.tradeRefNo 208 | FROM trades t, tradeTaxFees f 209 | WHERE t.accountNo = $accountNo 210 | AND DATE(t.tradeDate) = $date 211 | AND t.tradeRefNo = f.tradeRefNo 212 | """.query(tradeTaxFeeDecoder) 213 | 214 | val selectByMarket = 215 | sql""" 216 | SELECT t.accountNo, 217 | t.isinCode, 218 | t.market, 219 | t.buySellFlag, 220 | t.unitPrice, 221 | t.quantity, 222 | t.tradeDate, 223 | t.valueDate, 224 | t.userId, 225 | t.netAmount, 226 | f.taxFeeId, 227 | f.amount, 228 | t.tradeRefNo 229 | FROM trades t, tradeTaxFees f 230 | WHERE t.market = $market 231 | AND t.tradeRefNo = f.tradeRefNo 232 | """.query(tradeTaxFeeDecoder) 233 | 234 | val selectAll = 235 | sql""" 236 | SELECT t.accountNo, 237 | t.isinCode, 238 | t.market, 239 | t.buySellFlag, 240 | t.unitPrice, 241 | t.quantity, 242 | t.tradeDate, 243 | t.valueDate, 244 | t.userId, 245 | t.netAmount, 246 | f.taxFeeId, 247 | f.amount, 248 | t.tradeRefNo 249 | FROM trades t, tradeTaxFees f 250 | WHERE t.tradeRefNo = f.tradeRefNo 251 | """.query(tradeTaxFeeDecoder) 252 | 253 | object TradeRepositoryLive: 254 | val layer = ZLayer.fromFunction(TradeRepositoryLive.apply _) 255 | --------------------------------------------------------------------------------