├── .github └── workflows │ ├── living-documentation.yml │ └── maven.yml ├── .gitignore ├── LICENSE ├── bddtrader-app ├── pom.xml └── src │ ├── main │ ├── java │ │ └── net │ │ │ └── bddtrader │ │ │ ├── Application.java │ │ │ ├── clients │ │ │ ├── ClientController.java │ │ │ └── ClientDirectory.java │ │ │ ├── config │ │ │ ├── TraderConfiguration.java │ │ │ └── TradingDataSource.java │ │ │ ├── exceptions │ │ │ └── MissingMandatoryFieldsException.java │ │ │ ├── news │ │ │ └── NewsController.java │ │ │ ├── portfolios │ │ │ ├── InsufficientFundsException.java │ │ │ ├── Portfolio.java │ │ │ ├── PortfolioController.java │ │ │ ├── PortfolioDirectory.java │ │ │ ├── PortfolioNotFoundException.java │ │ │ └── PortfolioWithPositions.java │ │ │ ├── status │ │ │ └── StatusController.java │ │ │ ├── stocks │ │ │ └── StockController.java │ │ │ └── tradingdata │ │ │ ├── TradingData.java │ │ │ ├── TradingDataAPI.java │ │ │ ├── exceptions │ │ │ └── IllegalPriceManipulationException.java │ │ │ └── services │ │ │ ├── IEXtradingAPI.java │ │ │ └── StaticAPI.java │ └── resources │ │ ├── application.conf │ │ └── sample_data │ │ ├── news.json │ │ └── prices.json │ └── test │ ├── java │ └── net │ │ └── bddtrader │ │ ├── acceptancetests │ │ ├── CucumberTestSuite.java │ │ ├── actors │ │ │ ├── CastOfTraders.java │ │ │ └── Trader.java │ │ ├── endpoints │ │ │ └── BDDTraderEndPoints.java │ │ ├── model │ │ │ ├── MarketPrice.java │ │ │ └── PositionSummary.java │ │ ├── questions │ │ │ └── ThePortfolio.java │ │ ├── stepdefinitions │ │ │ ├── BuyingAndSellingSharesStepDefinitions.java │ │ │ ├── ClientStepDefinitions.java │ │ │ ├── MarketNewsStepDefinitions.java │ │ │ ├── SettingTheStage.java │ │ │ ├── SetupSteps.java │ │ │ ├── TestEnvironment.java │ │ │ └── ViewingPositionsStepDefinitons.java │ │ └── tasks │ │ │ ├── PlaceOrder.java │ │ │ ├── RegisterWithBDDTrader.java │ │ │ ├── ViewNewsAbout.java │ │ │ └── dsl │ │ │ └── PlaceOrderDSL.java │ │ └── apitests │ │ ├── clients │ │ ├── WhenAClientRegistersWithBDDTrader.java │ │ └── WhenDifferentTypesOfClientsRegister.java │ │ ├── news │ │ └── WhenReadingTheNews.java │ │ ├── portfolio │ │ ├── WhenClientsUseTheirPortfolios.java │ │ ├── WhenCreatingAPortfolio.java │ │ ├── WhenWorkingWithPositions.java │ │ └── WhenWorkingWithTrades.java │ │ ├── status │ │ └── WhenCheckingApplicationStatus.java │ │ └── stocks │ │ └── WhenRequestingThePrice.java │ └── resources │ ├── BDDTrader.postman_collection │ ├── features │ ├── clients │ │ └── registering_a_new_client.feature │ ├── portfolios │ │ ├── buying_and_selling_shares.feature │ │ └── viewing_positions.feature │ ├── readme.md │ └── viewing_market_data │ │ ├── reading_market_news.feature │ │ └── readme.md │ ├── junit-platform.properties │ └── serenity.conf ├── bddtrader-domain ├── pom.xml └── src │ └── main │ └── java │ └── net │ └── bddtrader │ ├── clients │ └── Client.java │ ├── news │ └── NewsItem.java │ ├── portfolios │ ├── MoneyCalculations.java │ ├── Position.java │ ├── Positions.java │ ├── Trade.java │ ├── TradeBuilder.java │ ├── TradeDirection.java │ ├── TradeType.java │ └── dsl │ │ ├── AtAPriceOf.java │ │ ├── CentsEach.java │ │ ├── DollarsEach.java │ │ ├── InCurrency.java │ │ └── SharesOf.java │ ├── stocks │ └── TopStock.java │ └── tradingdata │ ├── NewsReader.java │ ├── PriceReader.java │ └── PriceUpdater.java ├── circle.yml ├── exercises.adoc ├── manifest.yml ├── pom.xml ├── readme.adoc └── serenity.properties /.github/workflows/living-documentation.yml: -------------------------------------------------------------------------------- 1 | name: Living Documentation 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up JDK 17 17 | uses: actions/setup-java@v1 18 | with: 19 | distribution: 'zulu' 20 | java-version: '17' 21 | 22 | - name: Cache the Maven packages to speed up build 23 | uses: actions/cache@v1 24 | with: 25 | path: ~/.m2 26 | key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} 27 | restore-keys: ${{ runner.os }}-m2 28 | 29 | - name: Build with Maven 30 | run: mvn verify 31 | 32 | - name: Deploy 33 | uses: JamesIves/github-pages-deploy-action@4.1.1 34 | with: 35 | branch: gh-pages # The branch the action should deploy to. 36 | folder: bddtrader-app/target/site/serenity/ # The folder the action should deploy. 37 | -------------------------------------------------------------------------------- /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Maven 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven 3 | 4 | name: Java CI with Maven 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up JDK 17 20 | uses: actions/setup-java@v2 21 | with: 22 | java-version: '17' 23 | distribution: 'adopt' 24 | 25 | - name: Build with Maven 26 | run: mvn verify --file pom.xml 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | target 3 | *.iml 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /bddtrader-app/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | net.bddtrader 6 | bddtrader-app 7 | 1.0.0-SNAPSHOT 8 | jar 9 | 10 | The BDD Trader Application 11 | 12 | 13 | net.bddtrader 14 | bddtrader 15 | 1.0.0-SNAPSHOT 16 | 17 | 18 | 19 | 17 20 | UTF-8 21 | UTF-8 22 | 4.1.20 23 | 24 | 25 | 26 | 27 | net.bddtrader 28 | bddtrader-domain 29 | ${version} 30 | 31 | 32 | org.springframework.boot 33 | spring-boot-starter-parent 34 | 3.1.0 35 | pom 36 | 37 | 38 | org.springframework.boot 39 | spring-boot-starter-web 40 | 3.1.0 41 | 42 | 43 | com.typesafe 44 | config 45 | 1.4.3 46 | 47 | 48 | org.projectlombok 49 | lombok 50 | 1.18.34 51 | 52 | 53 | javax.servlet 54 | javax.servlet-api 55 | 4.0.1 56 | provided 57 | 58 | 59 | org.springdoc 60 | springdoc-openapi-ui 61 | 1.7.0 62 | 63 | 64 | com.github.lalyos 65 | jfiglet 66 | 0.0.9 67 | compile 68 | 69 | 70 | org.mockito 71 | mockito-core 72 | 5.12.0 73 | test 74 | 75 | 76 | 77 | net.serenity-bdd 78 | serenity-core 79 | ${serenity.version} 80 | test 81 | 82 | 83 | net.serenity-bdd 84 | serenity-junit 85 | ${serenity.version} 86 | test 87 | 88 | 89 | net.serenity-bdd 90 | serenity-screenplay 91 | ${serenity.version} 92 | test 93 | 94 | 95 | net.serenity-bdd 96 | serenity-screenplay-webdriver 97 | ${serenity.version} 98 | test 99 | 100 | 101 | net.serenity-bdd 102 | serenity-screenplay-rest 103 | ${serenity.version} 104 | test 105 | 106 | 107 | net.serenity-bdd 108 | serenity-cucumber 109 | ${serenity.version} 110 | test 111 | 112 | 113 | net.serenity-bdd 114 | serenity-rest-assured 115 | ${serenity.version} 116 | test 117 | 118 | 119 | io.cucumber 120 | cucumber-junit-platform-engine 121 | 7.18.1 122 | test 123 | 124 | 125 | 126 | 127 | 128 | org.springframework.boot 129 | spring-boot-maven-plugin 130 | 3.3.2 131 | 132 | 133 | 134 | repackage 135 | 136 | 137 | 138 | pre-integration-test 139 | 140 | start 141 | 142 | 143 | 144 | post-integration-test 145 | 146 | stop 147 | 148 | 149 | 150 | 151 | 152 | org.apache.maven.plugins 153 | maven-surefire-plugin 154 | 3.2.5 155 | 156 | 157 | **/When*.java 158 | **/*Spec.java 159 | **/*Test.java 160 | 161 | 162 | 163 | 164 | maven-failsafe-plugin 165 | 3.2.5 166 | 167 | 168 | **/acceptancetests/*.java 169 | 170 | 171 | 172 | 173 | 174 | integration-test 175 | 176 | 177 | 178 | 179 | 180 | org.apache.maven.plugins 181 | maven-compiler-plugin 182 | 3.8.1 183 | 184 | 17 185 | 17 186 | 187 | 188 | 189 | net.serenity-bdd.maven.plugins 190 | serenity-maven-plugin 191 | ${serenity.version} 192 | 193 | single-page-html 194 | 195 | 196 | 197 | 198 | net.serenity-bdd 199 | serenity-single-page-report 200 | ${serenity.version} 201 | 202 | 203 | 204 | 205 | check-gherkin 206 | process-test-resources 207 | 208 | check-gherkin 209 | 210 | 211 | 212 | serenity-reports 213 | post-integration-test 214 | 215 | aggregate 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/Application.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader; 2 | 3 | import com.github.lalyos.jfiglet.FigletFont; 4 | import org.springframework.boot.CommandLineRunner; 5 | import org.springframework.boot.SpringApplication; 6 | import org.springframework.boot.autoconfigure.SpringBootApplication; 7 | import org.springframework.context.ApplicationContext; 8 | import org.springframework.context.annotation.Bean; 9 | 10 | @SpringBootApplication 11 | public class Application { 12 | 13 | public static void main(String[] args) { 14 | SpringApplication.run(Application.class, args); 15 | } 16 | 17 | @Bean 18 | public CommandLineRunner commandLineRunner(ApplicationContext ctx) { 19 | return args -> { 20 | System.out.println(FigletFont.convertOneLine("BDD TRADER")); 21 | }; 22 | } 23 | 24 | } -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/clients/ClientController.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.clients; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import io.swagger.v3.oas.annotations.tags.Tag; 5 | import net.bddtrader.portfolios.PortfolioController; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.http.HttpStatus; 8 | import org.springframework.http.ResponseEntity; 9 | import org.springframework.web.bind.annotation.PathVariable; 10 | import org.springframework.web.bind.annotation.RequestBody; 11 | import org.springframework.web.bind.annotation.RequestMapping; 12 | import org.springframework.web.bind.annotation.RestController; 13 | 14 | import java.util.List; 15 | import java.util.Optional; 16 | 17 | import static org.springframework.http.HttpStatus.NOT_FOUND; 18 | import static org.springframework.http.HttpStatus.OK; 19 | import static org.springframework.web.bind.annotation.RequestMethod.*; 20 | 21 | /** 22 | * Clients register with the application by providing their first and last name. 23 | * When clients register, they are given a portfolio with $1000. 24 | */ 25 | @RestController 26 | @Tag(name = "Client", description = "Client APIs") 27 | public class ClientController { 28 | 29 | private final ClientDirectory clientDirectory; 30 | private final PortfolioController portfolioController; 31 | 32 | @Autowired 33 | public ClientController(ClientDirectory clientDirectory, 34 | PortfolioController portfolioController) { 35 | 36 | this.clientDirectory = clientDirectory; 37 | this.portfolioController = portfolioController; 38 | } 39 | 40 | @RequestMapping(value = "/api/client", method = POST) 41 | @Operation(summary = "Register a new client") 42 | public Client register(@RequestBody Client newClient) { 43 | Client client = clientDirectory.registerClient(newClient); 44 | 45 | portfolioController.createPortfolioForClient(client.getId()); 46 | 47 | return client; 48 | } 49 | 50 | @RequestMapping(value = "/api/client/{clientId}", method = GET) 51 | @Operation(summary = "Get the details of a given client") 52 | public ResponseEntity findClientById(@PathVariable Long clientId) { 53 | 54 | Optional client = clientDirectory.findClientById(clientId); 55 | 56 | return client.map( 57 | clientFound -> new ResponseEntity<>(clientFound, OK)) 58 | .orElseGet(() -> new ResponseEntity<>(NOT_FOUND)); 59 | } 60 | 61 | @RequestMapping(value = "/api/client/{clientId}", method = PUT) 62 | @Operation(summary = "Update the details of a given client") 63 | public ResponseEntity updateClientById(@PathVariable Long clientId, @RequestBody Client newClient) { 64 | 65 | Optional client = clientDirectory.findClientById(clientId); 66 | 67 | if (client.isPresent()) { 68 | clientDirectory.updateClient(clientId, newClient); 69 | return new ResponseEntity<>(newClient, OK); 70 | } else { 71 | return new ResponseEntity<>(NOT_FOUND); 72 | } 73 | } 74 | 75 | @RequestMapping(value = "/api/client/{clientId}", method = DELETE) 76 | @Operation(summary = "Delete a client") 77 | public ResponseEntity deleteClientById(@PathVariable Long clientId) { 78 | 79 | clientDirectory.deleteClientById(clientId); 80 | return new ResponseEntity<>(HttpStatus.NO_CONTENT); 81 | } 82 | 83 | @RequestMapping(value = "/api/clients", method = GET) 84 | @Operation(summary = "Get all the currently registered clients") 85 | public List findAll() { 86 | return clientDirectory.findAll(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/clients/ClientDirectory.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.clients; 2 | 3 | import net.bddtrader.exceptions.MissingMandatoryFieldsException; 4 | import org.springframework.stereotype.Component; 5 | 6 | import java.util.ArrayList; 7 | import java.util.List; 8 | import java.util.Optional; 9 | import java.util.concurrent.CopyOnWriteArrayList; 10 | import java.util.concurrent.atomic.AtomicLong; 11 | import java.util.stream.Collectors; 12 | 13 | import static org.springframework.util.StringUtils.isEmpty; 14 | 15 | /** 16 | * An in-memory directory of registered clients. 17 | */ 18 | @Component 19 | public class ClientDirectory { 20 | 21 | private final List registeredClients = new CopyOnWriteArrayList<>(); 22 | private final AtomicLong clientCount = new AtomicLong(1); 23 | 24 | public Client registerClient(Client newClient) { 25 | 26 | ensureMandatoryFieldsArePresentFor(newClient); 27 | 28 | Client registeredClient = new Client(clientCount.getAndIncrement(), 29 | newClient.getFirstName(), 30 | newClient.getLastName(), 31 | newClient.getEmail()); 32 | registeredClients.add(registeredClient); 33 | return registeredClient; 34 | } 35 | 36 | public void updateClient(Long clientId, Client client) { 37 | deleteClientById(clientId); 38 | Client updatedClient = new Client(clientId, 39 | client.getFirstName(), 40 | client.getLastName(), 41 | client.getEmail()); 42 | registeredClients.add(updatedClient); 43 | } 44 | 45 | private void ensureMandatoryFieldsArePresentFor(Client newClient) { 46 | 47 | List missingMandatoryFields = new ArrayList<>(); 48 | if (isEmpty(newClient.getFirstName())) { 49 | missingMandatoryFields.add("firstName"); 50 | } 51 | if (isEmpty(newClient.getLastName())) { 52 | missingMandatoryFields.add("lastName"); 53 | } 54 | if (isEmpty(newClient.getEmail())) { 55 | missingMandatoryFields.add("email"); 56 | } 57 | 58 | if (!missingMandatoryFields.isEmpty()) { 59 | throw new MissingMandatoryFieldsException("Missing mandatory fields for client: " 60 | + missingMandatoryFields.stream().collect(Collectors.joining(", "))); 61 | } 62 | } 63 | 64 | public Optional findClientById(long id) { 65 | return registeredClients.stream() 66 | .filter( client -> client.getId() == id) 67 | .findFirst(); 68 | } 69 | 70 | public void deleteClientById(long id) { 71 | Optional existingClient = findClientById(id); 72 | existingClient.ifPresent( 73 | registeredClients::remove 74 | ); 75 | } 76 | 77 | public List findAll() { 78 | return new ArrayList(registeredClients); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/config/TraderConfiguration.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.config; 2 | 3 | import com.typesafe.config.Config; 4 | import com.typesafe.config.ConfigFactory; 5 | import org.springframework.stereotype.Component; 6 | 7 | @Component 8 | public class TraderConfiguration { 9 | private final Config config = ConfigFactory.load(); 10 | 11 | public TradingDataSource getTradingDataSource() { 12 | return config.getEnum(TradingDataSource.class, "data.source"); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/config/TradingDataSource.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.config; 2 | 3 | public enum TradingDataSource { DEV } 4 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/exceptions/MissingMandatoryFieldsException.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.exceptions; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(value = HttpStatus.PRECONDITION_FAILED) 7 | public class MissingMandatoryFieldsException extends RuntimeException { 8 | public MissingMandatoryFieldsException(String message) { 9 | super(message); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/news/NewsController.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.news; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import io.swagger.v3.oas.annotations.tags.Tag; 5 | import net.bddtrader.config.TraderConfiguration; 6 | import net.bddtrader.config.TradingDataSource; 7 | import net.bddtrader.tradingdata.TradingData; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.web.bind.annotation.PathVariable; 10 | import org.springframework.web.bind.annotation.RequestMapping; 11 | import org.springframework.web.bind.annotation.RestController; 12 | 13 | import java.util.List; 14 | 15 | import static org.springframework.web.bind.annotation.RequestMethod.GET; 16 | 17 | @RestController 18 | @Tag(name = "news") 19 | public class NewsController { 20 | 21 | private final TradingDataSource tradingDataSource; 22 | 23 | public NewsController(TradingDataSource tradingDataSource) { 24 | this.tradingDataSource = tradingDataSource; 25 | } 26 | @Autowired 27 | public NewsController(TraderConfiguration traderConfiguration) { 28 | this(traderConfiguration.getTradingDataSource()); 29 | } 30 | 31 | @RequestMapping(value="/api/stock/{stockid}/news", method = GET) 32 | @Operation(summary = "Get news articles about a given stock", 33 | description="Use 'market' to get market-wide news.") 34 | public List newsFor(@PathVariable String stockid) { 35 | 36 | return TradingData.instanceFor(tradingDataSource).getNewsFor(stockid); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/portfolios/InsufficientFundsException.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.portfolios; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(value = HttpStatus.PAYMENT_REQUIRED) 7 | public class InsufficientFundsException extends RuntimeException { 8 | InsufficientFundsException(String message) { 9 | super(message); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/portfolios/Portfolio.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.portfolios; 2 | 3 | import net.bddtrader.tradingdata.PriceReader; 4 | 5 | import java.util.ArrayList; 6 | import java.util.List; 7 | import java.util.Map; 8 | import java.util.Optional; 9 | import java.util.concurrent.CopyOnWriteArrayList; 10 | 11 | import static net.bddtrader.portfolios.Trade.deposit; 12 | 13 | /** 14 | * A Portfolio records the financial position of a client, as well as the history of their trades. 15 | */ 16 | public class Portfolio { 17 | 18 | private static Long INITIAL_DEPOSIT_IN_DOLLARS = 1000L; 19 | 20 | private final Long portfolioId; 21 | private final Long clientId; 22 | private final List history; 23 | 24 | public Portfolio(Long portfolioId, Long clientId) { 25 | this.portfolioId = portfolioId; 26 | this.clientId = clientId; 27 | this.history = new CopyOnWriteArrayList<>(); 28 | placeOrder(deposit(INITIAL_DEPOSIT_IN_DOLLARS).dollars()); 29 | } 30 | 31 | protected Portfolio(Long portfolioId, Long clientId, List history) { 32 | this.portfolioId = portfolioId; 33 | this.clientId = clientId; 34 | this.history = new CopyOnWriteArrayList<>(history); 35 | } 36 | 37 | public Long getPortfolioId() { 38 | return portfolioId; 39 | } 40 | 41 | public Long getClientId() { 42 | return clientId; 43 | } 44 | 45 | public double getCash() { 46 | return getCashPosition().orElse(Position.EMPTY_CASH_POSITION).getTotalValueInDollars(); 47 | } 48 | 49 | private Long getCashInCents() { 50 | return (long) (getCashPosition().orElse(Position.EMPTY_CASH_POSITION).getTotalValueInDollars() * 100); 51 | } 52 | 53 | public void placeOrder(Trade trade) { 54 | 55 | ensureSufficientFundsAreAvailableFor(trade); 56 | 57 | trade.cashTransation().ifPresent( history::add ); 58 | 59 | history.add(trade); 60 | } 61 | 62 | public OrderPlacement placeOrderUsingPricesFrom(PriceReader priceReader) { 63 | return new OrderPlacement(priceReader); 64 | } 65 | 66 | public Portfolio withMarketPricesFrom(PriceReader priceReader) { 67 | return new PortfolioWithPositions(portfolioId, clientId, history, priceReader); 68 | } 69 | 70 | public class OrderPlacement { 71 | private PriceReader priceReader; 72 | 73 | OrderPlacement(PriceReader priceReader) { 74 | this.priceReader = priceReader; 75 | } 76 | 77 | public void forTrade(Trade trade) { 78 | if (shouldFindMarketPriceFor(trade)) { 79 | trade = trade.atPrice(priceReader.getPriceFor(trade.getSecurityCode())); 80 | } 81 | 82 | placeOrder(trade); 83 | } 84 | } 85 | 86 | private boolean shouldFindMarketPriceFor(Trade trade) { 87 | return trade.getPriceInCents() == 0; 88 | } 89 | 90 | private void ensureSufficientFundsAreAvailableFor(Trade trade) { 91 | 92 | if (!hasSufficientFundsFor(trade)){ 93 | throw new InsufficientFundsException("Insufficient funds: " + getCash() + " for purchase of " + trade.getTotalInCents() / 100); 94 | } 95 | } 96 | 97 | public boolean hasSufficientFundsFor(Trade trade) { 98 | return (trade.getType() == TradeType.Deposit) || (trade.getType() == TradeType.Sell) || ((getCashInCents() >= trade.getTotalInCents())); 99 | 100 | } 101 | 102 | public Map calculatePositionsUsing(PriceReader priceReader) { 103 | 104 | Positions positions = getPositions(); 105 | 106 | positions.updateMarketPricesUsing(priceReader); 107 | 108 | return positions.getPositions(); 109 | } 110 | 111 | public Double calculateProfitUsing(PriceReader priceReader) { 112 | 113 | Positions positions = getPositions(); 114 | 115 | positions.updateMarketPricesUsing(priceReader); 116 | 117 | return calculateProfitOn(positions); 118 | } 119 | 120 | private Optional getCashPosition() { 121 | return Optional.ofNullable(getPositions().getPositions().get(Trade.CASH_ACCOUNT)); 122 | } 123 | 124 | public List getHistory() { 125 | return new ArrayList<>(history); 126 | } 127 | 128 | 129 | private Positions getPositions() { 130 | Positions positions = new Positions(); 131 | 132 | for (Trade trade : history) { 133 | positions.apply(trade); 134 | } 135 | return positions; 136 | } 137 | 138 | private Double calculateProfitOn(Positions positions) { 139 | return positions.getPositions().values().stream() 140 | .filter(position -> (!position.getSecurityCode().equals(Trade.CASH_ACCOUNT))) 141 | .mapToDouble(Position::getProfit) 142 | .sum(); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/portfolios/PortfolioController.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.portfolios; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import io.swagger.v3.oas.annotations.tags.Tag; 5 | import io.swagger.v3.oas.annotations.Operation; 6 | import io.swagger.v3.oas.annotations.tags.Tag; 7 | 8 | import net.bddtrader.config.TraderConfiguration; 9 | import net.bddtrader.config.TradingDataSource; 10 | import net.bddtrader.tradingdata.TradingData; 11 | import net.bddtrader.tradingdata.TradingDataAPI; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.http.ResponseEntity; 14 | import org.springframework.web.bind.annotation.PathVariable; 15 | import org.springframework.web.bind.annotation.RequestBody; 16 | import org.springframework.web.bind.annotation.RequestMapping; 17 | import org.springframework.web.bind.annotation.RestController; 18 | 19 | import java.util.*; 20 | import java.util.stream.Collectors; 21 | 22 | import static java.util.Comparator.comparing; 23 | import static org.springframework.web.bind.annotation.RequestMethod.GET; 24 | import static org.springframework.web.bind.annotation.RequestMethod.POST; 25 | 26 | @RestController 27 | @Tag(name = "portfolio") 28 | public class PortfolioController { 29 | 30 | private final PortfolioDirectory portfolioDirectory; 31 | private final TradingDataAPI tradingDataAPI; 32 | 33 | public PortfolioController(TradingDataSource tradingDataSource, PortfolioDirectory portfolioDirectory) { 34 | this.portfolioDirectory = portfolioDirectory; 35 | this.tradingDataAPI = TradingData.instanceFor(tradingDataSource); 36 | } 37 | 38 | @Autowired 39 | public PortfolioController(TraderConfiguration traderConfiguration, PortfolioDirectory portfolioDirectory) { 40 | this(traderConfiguration.getTradingDataSource(), portfolioDirectory); 41 | } 42 | 43 | @RequestMapping(value = "/api/client/{clientId}/portfolio", method = POST) 44 | @Operation(summary = "Create a portfolio for a client") 45 | public Portfolio createPortfolioForClient(@PathVariable Long clientId) { 46 | return portfolioDirectory.addPortfolioFor(clientId); 47 | } 48 | 49 | @RequestMapping(value = "/api/client/{clientId}/portfolio", method = GET) 50 | @Operation(summary = "View the portfolio of a client") 51 | public Portfolio viewPortfolioForClient(@PathVariable Long clientId) { 52 | 53 | Portfolio portfolioFound = portfolioDirectory.findByClientId(clientId) 54 | .orElseThrow(() -> new PortfolioNotFoundException("No portfolio found for client id " + clientId)); 55 | 56 | return portfolioFound.withMarketPricesFrom(tradingDataAPI); 57 | } 58 | 59 | @RequestMapping(value = "/api/client/{clientId}/portfolio/positions", method = GET) 60 | @Operation(summary = "View the portfolio positions of a client") 61 | public List viewPortfolioPositionsForClient(@PathVariable Long clientId) { 62 | 63 | Portfolio portfolioFound = portfolioDirectory.findByClientId(clientId) 64 | .orElseThrow(() -> new PortfolioNotFoundException("No portfolio found for client id " + clientId)); 65 | 66 | return getPositions(portfolioFound.getPortfolioId()); 67 | } 68 | 69 | 70 | @RequestMapping(value = "/api/portfolio/{portfolioId}", method = GET) 71 | @Operation(summary = "View a portfolio with a given ID") 72 | public Portfolio viewPortfolio(@PathVariable Long portfolioId) { 73 | 74 | return findById(portfolioId).withMarketPricesFrom(tradingDataAPI); 75 | } 76 | 77 | @RequestMapping(value = "/api/portfolio/{portfolioId}/order", method = POST) 78 | @Operation(summary = "Place an order for a trade in a given portfolio", 79 | description="Use the special CASH security code to deposit more money into the portfolio") 80 | public Portfolio placeOrder(@PathVariable Long portfolioId, 81 | @RequestBody Trade trade) { 82 | 83 | Portfolio foundPortfolio = findById(portfolioId); 84 | 85 | foundPortfolio.placeOrderUsingPricesFrom(tradingDataAPI).forTrade(trade); 86 | 87 | return foundPortfolio; 88 | } 89 | 90 | @RequestMapping(value = "/api/portfolio/{portfolioId}/history", method = GET) 91 | @Operation(summary = "See the history of all the trades made in this portfolio") 92 | public List getHistory(@PathVariable Long portfolioId) { 93 | 94 | return findById(portfolioId).getHistory(); 95 | } 96 | 97 | @RequestMapping(value = "/api/portfolio/{portfolioId}/positions", method = GET) 98 | @Operation(summary = "Get the current positions for a given portfolio") 99 | public List getPositions(@PathVariable Long portfolioId) { 100 | return getIndexedPositions(portfolioId).values() 101 | .stream() 102 | .sorted(comparing(Position::getSecurityCode)) 103 | .collect(Collectors.toList()); 104 | } 105 | 106 | @RequestMapping(value = "/api/portfolio/{portfolioId}/indexed-positions", method = GET) 107 | @Operation(summary = "Get the current positions for a given portfolio as a Map") 108 | public Map getIndexedPositions(@PathVariable Long portfolioId) { 109 | return findById(portfolioId).calculatePositionsUsing(tradingDataAPI); 110 | } 111 | 112 | @RequestMapping(value = "/api/portfolio/{portfolioId}/profit", method = GET) 113 | @Operation(summary = "Get the overall profit or loss value for a given portfolio") 114 | public Double getProfitAndLoss(@PathVariable Long portfolioId) { 115 | 116 | return findById(portfolioId).calculateProfitUsing(tradingDataAPI); 117 | } 118 | 119 | private Portfolio findById(@PathVariable Long portfolioId) { 120 | return portfolioDirectory.findById(portfolioId) 121 | .orElseThrow(() -> new PortfolioNotFoundException("No portfolio found for id " + portfolioId)); 122 | } 123 | 124 | } 125 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/portfolios/PortfolioDirectory.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.portfolios; 2 | 3 | import org.springframework.stereotype.Component; 4 | 5 | import java.util.List; 6 | import java.util.Optional; 7 | import java.util.concurrent.CopyOnWriteArrayList; 8 | import java.util.concurrent.atomic.AtomicLong; 9 | 10 | /** 11 | * An in-memory directory of registered clients. 12 | */ 13 | @Component 14 | public class PortfolioDirectory { 15 | 16 | private final List portfolios = new CopyOnWriteArrayList<>(); 17 | private final AtomicLong portfolioCount = new AtomicLong(1); 18 | 19 | public Portfolio addPortfolioFor(Long clientId) { 20 | Portfolio portfolio = new Portfolio(portfolioCount.getAndIncrement(), clientId); 21 | portfolios.add(portfolio); 22 | return portfolio; 23 | } 24 | 25 | public Optional findByClientId(Long clientId) { 26 | return portfolios.stream() 27 | .filter( portfolio -> portfolio.getClientId().equals(clientId)) 28 | .findFirst(); 29 | } 30 | 31 | public Optional findById(Long portfolioId) { 32 | return portfolios.stream() 33 | .filter( portfolio -> portfolio.getPortfolioId().equals(portfolioId)) 34 | .findFirst(); 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/portfolios/PortfolioNotFoundException.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.portfolios; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(value = HttpStatus.NOT_FOUND) 7 | public class PortfolioNotFoundException extends RuntimeException { 8 | PortfolioNotFoundException(String message) { 9 | super(message); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/portfolios/PortfolioWithPositions.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.portfolios; 2 | 3 | import net.bddtrader.tradingdata.PriceReader; 4 | 5 | import java.util.List; 6 | import java.util.Map; 7 | 8 | public class PortfolioWithPositions extends Portfolio { 9 | 10 | private Map marketPositions; 11 | private Double profit; 12 | 13 | public PortfolioWithPositions(Long portfolioId, Long clientId, List history, PriceReader priceReader) { 14 | super(portfolioId, clientId, history); 15 | 16 | marketPositions = calculatePositionsUsing(priceReader); 17 | profit = calculateProfitUsing(priceReader); 18 | } 19 | 20 | public Map getMarketPositions() { 21 | return marketPositions; 22 | } 23 | 24 | public Double getProfit() { 25 | return profit; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/status/StatusController.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.status; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import io.swagger.v3.oas.annotations.tags.Tag; 5 | import net.bddtrader.config.TraderConfiguration; 6 | import net.bddtrader.config.TradingDataSource; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.web.bind.annotation.RequestMapping; 9 | import org.springframework.web.bind.annotation.RestController; 10 | 11 | import static org.springframework.web.bind.annotation.RequestMethod.GET; 12 | 13 | @RestController 14 | @Tag(name = "status") 15 | public class StatusController { 16 | 17 | private final TradingDataSource tradingDataSource; 18 | 19 | public StatusController(TradingDataSource tradingDataSource) { 20 | this.tradingDataSource = tradingDataSource; 21 | } 22 | 23 | @Autowired 24 | public StatusController(TraderConfiguration traderConfiguration) { 25 | this(traderConfiguration.getTradingDataSource()); 26 | } 27 | 28 | @RequestMapping(value = "/api/status", method = GET) 29 | @Operation(summary = "Check the status of the API") 30 | public String status() { 31 | return "BDDTrader running against " + tradingDataSource; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/stocks/StockController.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.stocks; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import io.swagger.v3.oas.annotations.tags.Tag; 5 | import net.bddtrader.config.TraderConfiguration; 6 | import net.bddtrader.config.TradingDataSource; 7 | import net.bddtrader.tradingdata.TradingData; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.web.bind.annotation.*; 10 | 11 | import java.util.List; 12 | 13 | import static org.springframework.web.bind.annotation.RequestMethod.GET; 14 | 15 | @RestController 16 | @Tag(name = "stock") 17 | public class StockController { 18 | 19 | private final TradingDataSource tradingDataSource; 20 | 21 | public StockController(TradingDataSource tradingDataSource) { 22 | this.tradingDataSource = tradingDataSource; 23 | } 24 | 25 | @Autowired 26 | public StockController(TraderConfiguration traderConfiguration) { 27 | this(traderConfiguration.getTradingDataSource()); 28 | } 29 | 30 | @RequestMapping(value="/api/stock/{stockid}/price", method = GET) 31 | @Operation(summary = "Find the price of a given stock") 32 | public Double priceFor(@PathVariable String stockid) { 33 | return TradingData.instanceFor(tradingDataSource).getPriceFor(stockid); 34 | } 35 | 36 | @RequestMapping(value = "/api/stock/{stockid}/price", method = RequestMethod.POST) 37 | @Operation(summary = "Update the price of a given stock in a test environment") 38 | public void updatePriceFor(@PathVariable String stockid, @RequestBody Double currentPrice) { 39 | TradingData.instanceFor(tradingDataSource).updatePriceFor(stockid, currentPrice); 40 | } 41 | 42 | @RequestMapping(value = "/api/stocks/reset", method = RequestMethod.POST) 43 | @Operation(summary = "Reset the prices to default values the stock in a test environment") 44 | public void resetTestPrices() { 45 | TradingData.instanceFor(tradingDataSource).reset(); 46 | } 47 | 48 | @RequestMapping(value = "/api/stock/popular", method = RequestMethod.GET) 49 | @Operation(summary = "List high volume stocks") 50 | public List getPopularStocks() { 51 | return TradingData.instanceFor(tradingDataSource).getPopularStocks(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/tradingdata/TradingData.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.tradingdata; 2 | 3 | import net.bddtrader.config.TradingDataSource; 4 | import net.bddtrader.tradingdata.services.IEXtradingAPI; 5 | import net.bddtrader.tradingdata.services.StaticAPI; 6 | 7 | import java.util.HashMap; 8 | import java.util.Map; 9 | 10 | 11 | /** 12 | * Return trading data either from a remote web service, or from static local data for testing. 13 | * The source of that trading data is defined by the data.source system property defined in the 14 | * application.conf file. 15 | * This value can be: 16 | * * IEX - The IEX trading API (https://iextrading.com/developer/docs/) 17 | * * DEV - Test data 18 | */ 19 | public class TradingData { 20 | 21 | private static Map TRADING_DATA_SOURCE_API = new HashMap<>(); 22 | static { 23 | // TRADING_DATA_SOURCE_API.put(TradingDataSource.IEX, new IEXtradingAPI()); 24 | TRADING_DATA_SOURCE_API.put(TradingDataSource.DEV, new StaticAPI()); 25 | } 26 | 27 | private final static TradingDataAPI DEFAULT_DATA_SOURCE = new StaticAPI(); 28 | 29 | public static TradingDataAPI instanceFor(TradingDataSource dataSource) { 30 | return TRADING_DATA_SOURCE_API.getOrDefault(dataSource, DEFAULT_DATA_SOURCE); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/tradingdata/TradingDataAPI.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.tradingdata; 2 | 3 | public interface TradingDataAPI extends PriceReader, PriceUpdater, NewsReader { 4 | void reset(); 5 | } 6 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/tradingdata/exceptions/IllegalPriceManipulationException.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.tradingdata.exceptions; 2 | 3 | public class IllegalPriceManipulationException extends RuntimeException { 4 | public IllegalPriceManipulationException(String message) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/tradingdata/services/IEXtradingAPI.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.tradingdata.services; 2 | 3 | import net.bddtrader.news.NewsItem; 4 | import net.bddtrader.portfolios.Trade; 5 | import net.bddtrader.stocks.TopStock; 6 | import net.bddtrader.tradingdata.TradingDataAPI; 7 | import net.bddtrader.tradingdata.exceptions.IllegalPriceManipulationException; 8 | import org.springframework.core.ParameterizedTypeReference; 9 | import org.springframework.http.ResponseEntity; 10 | import org.springframework.web.client.RestTemplate; 11 | 12 | import java.util.List; 13 | import java.util.stream.Collectors; 14 | 15 | import static java.util.Objects.requireNonNull; 16 | import static org.springframework.http.HttpMethod.GET; 17 | 18 | public class IEXtradingAPI implements TradingDataAPI { 19 | 20 | private final RestTemplate restTemplate = new RestTemplate(); 21 | 22 | @Override 23 | public List getNewsFor(String stockid) { 24 | return new StaticAPI().getNewsFor(stockid); 25 | } 26 | 27 | @Override 28 | public Double getPriceFor(String stockid) { 29 | 30 | if (stockid.equals(Trade.CASH_ACCOUNT)) { 31 | return 0.01; 32 | } 33 | 34 | ResponseEntity response = 35 | restTemplate.exchange("https://api.iextrading.com/1.0/stock/{stockid}/price", 36 | GET, null, 37 | new ParameterizedTypeReference() {}, 38 | stockid); 39 | return response.getBody(); 40 | } 41 | 42 | @Override 43 | public List getPopularStocks() { 44 | 45 | ResponseEntity> response = 46 | restTemplate.exchange("https://api.iextrading.com/1.0/tops", 47 | GET, null, 48 | new ParameterizedTypeReference>() {}); 49 | 50 | return requireNonNull(response.getBody()).stream() 51 | .map(TopStock::getSymbol) 52 | .collect(Collectors.toList()); 53 | } 54 | 55 | @Override 56 | public void updatePriceFor(String stockid, Double currentPrice) { 57 | throw new IllegalPriceManipulationException("Attempt to update prices in production"); 58 | } 59 | 60 | @Override 61 | public void reset() {} 62 | } 63 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/java/net/bddtrader/tradingdata/services/StaticAPI.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.tradingdata.services; 2 | 3 | import com.fasterxml.jackson.core.type.TypeReference; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; 6 | import net.bddtrader.news.NewsItem; 7 | import net.bddtrader.portfolios.Trade; 8 | import net.bddtrader.tradingdata.TradingDataAPI; 9 | import org.springframework.web.client.HttpClientErrorException; 10 | 11 | import java.io.File; 12 | import java.io.IOException; 13 | import java.util.*; 14 | import java.util.stream.Collectors; 15 | 16 | public class StaticAPI implements TradingDataAPI { 17 | 18 | private final ObjectMapper mapper; 19 | Map stockPrices; 20 | 21 | public StaticAPI() { 22 | mapper = new ObjectMapper(); 23 | mapper.registerModule(new JavaTimeModule()); 24 | 25 | stockPrices = loadSamplePrices(); 26 | } 27 | 28 | @Override 29 | public List getNewsFor(String stockid) { 30 | File jsonInput = testDataFrom("news.json"); 31 | try { 32 | List items = mapper.readValue(jsonInput, new TypeReference>(){}); 33 | return items.stream() 34 | .filter( item -> item.getRelated().contains(stockid)) 35 | .collect(Collectors.toList()); 36 | 37 | 38 | } catch (IOException e) { 39 | e.printStackTrace(); 40 | return new ArrayList<>(); 41 | } 42 | } 43 | 44 | 45 | private Map loadSamplePrices() { 46 | File jsonInput = testDataFrom("prices.json"); 47 | try { 48 | Map> samplePrices = mapper.readValue(jsonInput, new TypeReference>>(){}); 49 | return samplePrices.getOrDefault("marketPrices", new HashMap<>()); 50 | } catch (IOException e) { 51 | e.printStackTrace(); 52 | return new HashMap<>(); 53 | } 54 | } 55 | 56 | @Override 57 | public Double getPriceFor(String stockid) { 58 | if (stockid.equals(Trade.CASH_ACCOUNT)) { 59 | return 0.01; 60 | } 61 | if (!stockPrices.containsKey(stockid)) { 62 | throw new HttpClientErrorException(org.springframework.http.HttpStatus.NOT_FOUND, "Stock not found"); 63 | } 64 | return stockPrices.getOrDefault(stockid, 100.00); 65 | } 66 | 67 | @Override 68 | public List getPopularStocks() { 69 | return new ArrayList<>(loadSamplePrices().keySet()); 70 | } 71 | 72 | @Override 73 | public void updatePriceFor(String stockid, Double currentPrice) { 74 | stockPrices.put(stockid, currentPrice); 75 | } 76 | 77 | private File testDataFrom(String source) { 78 | return new File(this.getClass().getResource("/sample_data/" + source).getPath()); 79 | } 80 | 81 | @Override 82 | public void reset() { 83 | stockPrices = loadSamplePrices(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | # 2 | # Application configuration. 3 | # 4 | 5 | # Get external data from a static data source by default. Override via system properties to IEX for production. 6 | data.source = DEV 7 | 8 | springdoc.packagesToScan="net.bddtrader" 9 | springdoc.pathsToMatch="/api/**" 10 | -------------------------------------------------------------------------------- /bddtrader-app/src/main/resources/sample_data/news.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "datetime": "2018-05-23T18:25:35-04:00", 4 | "headline": "Will Earnings Normalize Soon?", 5 | "source": "SeekingAlpha", 6 | "url": "https://api.iextrading.com/1.0/stock/aapl/article/5793469523684637", 7 | "summary": " Singular Research Director's Letter, April 2018 The U.S equity markets are not getting much credit from the tax cuts yet this year. Earnings will be up a record 21%, the best showing in many years. We will focus on revenue growth in the quarter ahead, as well as on estimates and guidance. …", 8 | "related": "AAPL,EVIO,FB,Financial,GFN,GOOG,HBP,INT31168144,LYTS,NASDAQ01,NFLX,NMIH,ONL31168,OTCBULLB,SALM,Computing and Information Technology" 9 | }, 10 | { 11 | "datetime": "2018-05-23T18:22:46-04:00", 12 | "headline": "NYT: Apple signs with Volkswagen to make self-driving cars", 13 | "source": "SeekingAlpha", 14 | "url": "https://api.iextrading.com/1.0/stock/aapl/article/7655739711082797", 15 | "summary": " Apple (NASDAQ: AAPL ) has signed a deal with Volkswagen ( OTCPK:VLKAY ) to make self-driving cars, The New York Times reports, in a bit of a setback from grander visions of working with BMW ( OTCPK:BMWYY ) and Mercedes-Benz ( OTCPK:DDAIF ). More news on: Apple Inc., Volkswagen AG ADR, …", 16 | "related": "AAPL,BMWYY,Computer Hardware,CON31167138,DDAIF,GOOG,GOOGL,INTHPINK,NASDAQ01,New York,Computing and Information Technology,VLKAY,WOMPOLIX" 17 | }, 18 | { 19 | "datetime": "2018-05-23T16:03:02-04:00", 20 | "headline": "Tracking David Einhorn's Portfolio - Q1 2018 Update", 21 | "source": "SeekingAlpha", 22 | "url": "https://api.iextrading.com/1.0/stock/aapl/article/8153168084259347", 23 | "summary": " This article is part of a series that provides an ongoing analysis of the changes made to David Einhorns Greenlight Capital 13F portfolio on a quarterly basis. It is based on Einhorns regulatory 13F Form filed on 05/15/2018. Please visit our Tracking David Einhorn's Greenlig…", 24 | "related": "AABA,AAPL,ADNT,AER,AGO,BHF,CCR,CEIX,CNDT,CNX,ESV,Financial and Business Services,GLRE,GM,GMM.U:CA,GRBK,IAC,INS10326,INS10326060,MU,MYL,NASDAQ01,PRGO,Computing and Information Technology,TPX,TSXTSX01,TWTR,TWX,URI,VOYA,XELA" 25 | }, 26 | { 27 | "datetime": "2018-05-23T14:39:09-04:00", 28 | "headline": "Apple Watch shipments +35% in Q1", 29 | "source": "SeekingAlpha", 30 | "url": "https://api.iextrading.com/1.0/stock/aapl/article/4653980484810177", 31 | "summary": " Shipments of the Apple ( AAPL +0.1% ) Watch grew 35% in Q1, according to Canalys data . More news on: Apple Inc., Xiaomi, Fitbit, Inc., Tech stocks news, Stocks on the move, News on ETFs, Read more … ", 32 | "related": "AAPL,Computer Hardware,CON31167138,FIT,GRMN,NASDAQ01,Computing and Information Technology,WOMPOLIX,XI:BZX" 33 | }, 34 | { 35 | "datetime": "2018-05-23T13:01:24-04:00", 36 | "headline": "Apple: Still A Bargain", 37 | "source": "SeekingAlpha", 38 | "url": "https://api.iextrading.com/1.0/stock/aapl/article/6049214390013562", 39 | "summary": " Rethink Technology business briefs for May 23, 2018. Earnings assuages fears, encourages upgrades Expected iPhone X and X Plus. Source: Mac Rumors I wrote Apple: No Need To Panic on April 27, a few days before Apple's ( AAPL ) fiscal 2018 Q2 earnings release. At the time, fears o…", 40 | "related": "AAPL,Computer Hardware,CON31167138,Earnings,NASDAQ01,Computing and Information Technology" 41 | }, 42 | { 43 | "datetime": "2018-05-23T12:57:00-04:00", 44 | "headline": "Apple is giving a $50 credit to many iPhone owners who replaced the battery out of warranty", 45 | "source": "CNBC", 46 | "url": "https://api.iextrading.com/1.0/stock/aapl/article/8639547131374479", 47 | "summary": "No summary available.", 48 | "related": "AAPL" 49 | }, 50 | { 51 | "datetime": "2018-05-23T11:39:00-04:00", 52 | "headline": "Apple is missing out on billions of dollars by skirting the hottest trend in software", 53 | "source": "CNBC", 54 | "url": "https://api.iextrading.com/1.0/stock/aapl/article/5388416179558541", 55 | "summary": "No summary available.", 56 | "related": "AAPL,ADBE,CRM,NFLX" 57 | }, 58 | { 59 | "datetime": "2018-05-23T11:33:42-04:00", 60 | "headline": "Apple launches privacy portal for user data ahead of GDPR", 61 | "source": "SeekingAlpha", 62 | "url": "https://api.iextrading.com/1.0/stock/aapl/article/5376511203063061", 63 | "summary": " Apple (NASDAQ: AAPL ) launches a privacy portal for users to download any information or data the company has associated with their Apple ID. More news on: Apple Inc., Tech stocks news, Read more … ", 64 | "related": "AAPL,Computer Hardware,CON31167138,NASDAQ01,Computing and Information Technology,WOMPOLIX" 65 | }, 66 | { 67 | "datetime": "2018-05-23T10:47:00-04:00", 68 | "headline": "How to share your location with loved ones so they know you're safe", 69 | "source": "CNBC", 70 | "url": "https://api.iextrading.com/1.0/stock/aapl/article/8261114011927880", 71 | "summary": "No summary available.", 72 | "related": "AAPL,FB,GOOGL" 73 | }, 74 | { 75 | "datetime": "2018-05-23T10:14:28-04:00", 76 | "headline": "Trading Mistake 8 (Apple): Poor Profit-Taking", 77 | "source": "SeekingAlpha", 78 | "url": "https://api.iextrading.com/1.0/stock/aapl/article/7744287852061951", 79 | "summary": " Fear and greed are what run the stock market. Trader A buys a stock, makes a decent paper profit over a short time frame and becomes fearful of losing those gains. He ponders to himself - \" How can I let these profits go especially considering the time-frame I made them in\". He continues - \" I…", 80 | "related": "AAPL,Computer Hardware,CON31167138,NASDAQ01,Computing and Information Technology" 81 | } 82 | ] -------------------------------------------------------------------------------- /bddtrader-app/src/main/resources/sample_data/prices.json: -------------------------------------------------------------------------------- 1 | { 2 | "marketPrices" : 3 | { 4 | "AMZN" : 1641.54, 5 | "GOOG" : 1119.50, 6 | "IBM" : 141.95, 7 | "AAPL" : 190.24, 8 | "PBR" : 10.13, 9 | "BAC" : 29.40, 10 | "GE" : 14.10, 11 | "SNAP" : 11.63 12 | } 13 | } -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/acceptancetests/CucumberTestSuite.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.acceptancetests; 2 | 3 | import org.junit.platform.suite.api.IncludeEngines; 4 | import org.junit.platform.suite.api.SelectClasspathResource; 5 | import org.junit.platform.suite.api.Suite; 6 | 7 | @Suite 8 | @IncludeEngines("cucumber") 9 | @SelectClasspathResource("/features") 10 | public class CucumberTestSuite {} 11 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/acceptancetests/actors/CastOfTraders.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.acceptancetests.actors; 2 | 3 | import net.bddtrader.acceptancetests.stepdefinitions.TestEnvironment; 4 | import net.serenitybdd.screenplay.Ability; 5 | import net.serenitybdd.screenplay.Actor; 6 | import net.serenitybdd.screenplay.actors.Cast; 7 | import net.serenitybdd.screenplay.rest.abilities.CallAnApi; 8 | import net.thucydides.model.util.EnvironmentVariables; 9 | 10 | /** 11 | * The Cast is a factory we use to provide actors for our scenarios. 12 | * Each actor is given the ability to query our REST API using RestAssured. 13 | * We assign this cast to a scenario in the SettingTheStage class. 14 | */ 15 | public class CastOfTraders extends Cast { 16 | 17 | private final TestEnvironment testEnvironment; 18 | 19 | public CastOfTraders(EnvironmentVariables environmentVariables) { 20 | testEnvironment = new TestEnvironment(environmentVariables); 21 | } 22 | 23 | @Override 24 | public Actor actorNamed(String actorName, Ability... abilities) { 25 | Actor trader = super.actorNamed(actorName, abilities); 26 | trader.can(CallAnApi.at(testEnvironment.getRestAPIBaseUrl())); 27 | 28 | return trader; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/acceptancetests/actors/Trader.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.acceptancetests.actors; 2 | 3 | public class Trader { 4 | } 5 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/acceptancetests/endpoints/BDDTraderEndPoints.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.acceptancetests.endpoints; 2 | 3 | public enum BDDTraderEndPoints { 4 | RegisterClient("/client"), 5 | ClientPortfolio("/client/{clientId}/portfolio"), 6 | ClientPortfolioPositions("/client/{clientId}/portfolio/positions"), 7 | PlaceOrder("/portfolio/{portfolioId}/order"), 8 | UpdatePrice("/stock/{securityCode}/price"); 9 | 10 | private final String path; 11 | 12 | BDDTraderEndPoints(String path) { 13 | this.path = path; 14 | } 15 | 16 | public String path() { 17 | return path; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/acceptancetests/model/MarketPrice.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.acceptancetests.model; 2 | 3 | public class MarketPrice { 4 | private final String securityCode; 5 | private final double price; 6 | 7 | public MarketPrice(String securityCode, double price) { 8 | this.securityCode = securityCode; 9 | this.price = price; 10 | } 11 | 12 | public String getSecurityCode() { 13 | return securityCode; 14 | } 15 | 16 | public double getPrice() { 17 | return price; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/acceptancetests/model/PositionSummary.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.acceptancetests.model; 2 | 3 | import java.util.Objects; 4 | 5 | public class PositionSummary { 6 | private final String securityCode; 7 | private final Long amount; 8 | 9 | public PositionSummary(String securityCode, Long amount) { 10 | this.securityCode = securityCode; 11 | this.amount = amount; 12 | } 13 | 14 | @Override 15 | public boolean equals(Object o) { 16 | if (this == o) return true; 17 | if (o == null || getClass() != o.getClass()) return false; 18 | PositionSummary that = (PositionSummary) o; 19 | return Objects.equals(securityCode, that.securityCode) && 20 | Objects.equals(amount, that.amount); 21 | } 22 | 23 | @Override 24 | public int hashCode() { 25 | return Objects.hash(securityCode, amount); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/acceptancetests/questions/ThePortfolio.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.acceptancetests.questions; 2 | 3 | import net.bddtrader.acceptancetests.endpoints.BDDTraderEndPoints; 4 | import net.bddtrader.clients.Client; 5 | import net.bddtrader.portfolios.Position; 6 | import net.serenitybdd.screenplay.Question; 7 | import net.serenitybdd.screenplay.rest.questions.RestQuestionBuilder; 8 | 9 | import java.util.List; 10 | 11 | public class ThePortfolio { 12 | 13 | public static Question cashBalanceFor(Client client) { 14 | 15 | return new RestQuestionBuilder().about("Cash account balance") 16 | .to(BDDTraderEndPoints.ClientPortfolio.path()) 17 | .with(request -> request.pathParam("clientId", client.getId())) 18 | .returning(response -> response.path("cash")); 19 | 20 | } 21 | 22 | public static Question idFor(Client client) { 23 | 24 | return new RestQuestionBuilder().about("Cash account balance") 25 | .to(BDDTraderEndPoints.ClientPortfolio.path()) 26 | .with(request -> request.pathParam("clientId", client.getId())) 27 | .returning(response -> response.path("portfolioId")); 28 | 29 | } 30 | 31 | public static Question> positionsForClient(Client registeredClient) { 32 | 33 | return new RestQuestionBuilder>() 34 | .about("positions for client " + registeredClient) 35 | .to(BDDTraderEndPoints.ClientPortfolioPositions.path()) 36 | .with(request -> request.pathParam("clientId", registeredClient.getId())) 37 | .returning(response -> response.jsonPath().getList("", Position.class)); 38 | 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/acceptancetests/stepdefinitions/BuyingAndSellingSharesStepDefinitions.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.acceptancetests.stepdefinitions; 2 | 3 | import io.cucumber.java.DataTableType; 4 | import io.cucumber.java.en.Given; 5 | import io.cucumber.java.en.Then; 6 | import io.cucumber.java.en.When; 7 | import net.bddtrader.acceptancetests.model.PositionSummary; 8 | import net.bddtrader.acceptancetests.questions.ThePortfolio; 9 | import net.bddtrader.acceptancetests.tasks.PlaceOrder; 10 | import net.bddtrader.acceptancetests.tasks.RegisterWithBDDTrader; 11 | import net.bddtrader.clients.Client; 12 | import net.bddtrader.portfolios.Position; 13 | import net.serenitybdd.screenplay.Actor; 14 | import net.serenitybdd.screenplay.actors.OnStage; 15 | 16 | import java.util.List; 17 | import java.util.Map; 18 | import java.util.Optional; 19 | 20 | import static net.bddtrader.portfolios.TradeType.Buy; 21 | import static net.bddtrader.portfolios.TradeType.Sell; 22 | import static org.assertj.core.api.Assertions.assertThat; 23 | 24 | public class BuyingAndSellingSharesStepDefinitions { 25 | 26 | 27 | @Given("{word} {word} is a registered trader") 28 | public void a_registered_trader(String firstName, String lastName) { 29 | 30 | Actor trader = OnStage.theActorCalled(firstName); 31 | 32 | trader.attemptsTo( 33 | RegisterWithBDDTrader.asANewClient(Client.withFirstName(firstName) 34 | .andLastName(lastName) 35 | .andEmail(firstName + "@" + lastName + ".com")) 36 | ); 37 | } 38 | 39 | 40 | @When("^(.*) (?:purchases|has purchased) (\\d+) (.*) shares at \\$(.*) each$") 41 | public void purchases_shares(String traderName, int amount, String securityCode, double marketPrice) { 42 | 43 | Actor trader = OnStage.theActorCalled(traderName); 44 | 45 | Client registeredClient = trader.recall("registeredClient"); 46 | 47 | trader.attemptsTo( 48 | PlaceOrder.to(Buy, amount) 49 | .sharesOf(securityCode) 50 | .atPriceOf(marketPrice) 51 | .forClient(registeredClient) 52 | ); 53 | } 54 | 55 | @When("^(.*) sells (\\d+) (.*) shares for \\$(.*) each$") 56 | public void sells_shares(String traderName, int amount, String securityCode, double marketPrice) { 57 | 58 | Actor trader = OnStage.theActorCalled(traderName); 59 | 60 | Client registeredClient = trader.recall("registeredClient"); 61 | 62 | trader.attemptsTo( 63 | PlaceOrder.to(Sell, amount) 64 | .sharesOf(securityCode) 65 | .atPriceOf(marketPrice) 66 | .forClient(registeredClient) 67 | ); 68 | } 69 | 70 | @DataTableType 71 | public Position positionFrom(Map values) { 72 | 73 | return new Position( 74 | values.get("securityCode"), 75 | Long.parseLong(Optional.ofNullable(values.get("amount")).orElse("0")), 76 | values.get("totalPurchasePriceInDollars") != null ? Double.parseDouble(values.get("totalPurchasePriceInDollars")) : null, 77 | values.get("marketValueInDollars") != null ? Double.parseDouble(values.get("marketValueInDollars")) : null, 78 | values.get("totalValueInDollars") != null ? Double.parseDouble(values.get("totalValueInDollars")) : null, 79 | values.get("profit") != null ? Double.parseDouble(values.get("profit")) : null 80 | ); 81 | } 82 | 83 | @DataTableType 84 | public PositionSummary positionSummaryFrom(Map values) { 85 | 86 | return new PositionSummary( 87 | values.get("securityCode"), 88 | Long.parseLong(values.get("amount")) 89 | ); 90 | } 91 | 92 | 93 | 94 | @Then("{actor} should have the following position details:") 95 | public void should_have_the_following_position_details(Actor trader, List expectedPositions) { 96 | 97 | Client registeredClient = trader.recall("registeredClient"); 98 | 99 | List clientPositons = trader.asksFor(ThePortfolio.positionsForClient(registeredClient)); 100 | 101 | assertThat(clientPositons) 102 | .usingElementComparatorOnFields("securityCode","amount","totalValueInDollars","profit") 103 | .containsAll(expectedPositions); 104 | } 105 | 106 | @Then("{actor} should have the following positions:") 107 | public void should_have_the_following_positions(Actor trader, List expectedPositions) { 108 | 109 | Client registeredClient = trader.recall("registeredClient"); 110 | 111 | List clientPositons = trader.asksFor(ThePortfolio.positionsForClient(registeredClient)); 112 | 113 | assertThat(clientPositons) 114 | .usingElementComparatorOnFields("securityCode","amount") 115 | .containsAll(expectedPositions); 116 | } 117 | 118 | 119 | 120 | } 121 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/acceptancetests/stepdefinitions/ClientStepDefinitions.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.acceptancetests.stepdefinitions; 2 | 3 | import io.cucumber.java.DataTableType; 4 | import io.cucumber.java.en.Given; 5 | import io.cucumber.java.en.Then; 6 | import io.cucumber.java.en.When; 7 | import net.bddtrader.acceptancetests.questions.ThePortfolio; 8 | import net.bddtrader.acceptancetests.tasks.RegisterWithBDDTrader; 9 | import net.bddtrader.clients.Client; 10 | import net.serenitybdd.screenplay.Actor; 11 | import net.serenitybdd.screenplay.actors.OnStage; 12 | 13 | import java.util.List; 14 | import java.util.Map; 15 | 16 | import static net.serenitybdd.screenplay.GivenWhenThen.seeThat; 17 | import static net.serenitybdd.screenplay.rest.questions.ResponseConsequence.seeThatResponse; 18 | import static org.hamcrest.Matchers.equalTo; 19 | import static org.hamcrest.Matchers.is; 20 | 21 | public class ClientStepDefinitions { 22 | 23 | private final static int PRECONDITION_FAILED = 412; 24 | 25 | private Actor tim; 26 | private Client client; 27 | 28 | @DataTableType 29 | public Client clientFrom(Map values) { 30 | return new Client(null, 31 | values.get("firstName"), 32 | values.get("lastName"), 33 | values.get("email")); 34 | } 35 | 36 | @Given("a trader with the following details:") 37 | public void a_trader_with_the_following_details(List clients) { 38 | tim = OnStage.theActorCalled("Tim the trader"); 39 | client = clients.get(0); // We are only interested in a single client 40 | } 41 | 42 | 43 | @When("^the trader (?:attempts to register|registers) with BDD Trader$") 44 | public void the_trader_attempts_to_register_with_BDD_Trader() { 45 | 46 | tim.attemptsTo( 47 | RegisterWithBDDTrader.asANewClient(client) 48 | ); 49 | } 50 | 51 | @Then("the registration should be rejected") 52 | public void the_registration_should_be_rejected() { 53 | tim.should( 54 | seeThatResponse("An appropriate error code was returned", 55 | response -> response.statusCode(PRECONDITION_FAILED)) 56 | ); 57 | } 58 | 59 | @Then("^the trader should have a portfolio with \\$(.*) in cash$") 60 | public void should_have_portfolio_with_cash(float expectedBalance) { 61 | 62 | Client registeredClient = tim.recall("registeredClient"); 63 | 64 | tim.should( 65 | seeThat(ThePortfolio.cashBalanceFor(registeredClient), is(equalTo(expectedBalance))) 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/acceptancetests/stepdefinitions/MarketNewsStepDefinitions.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.acceptancetests.stepdefinitions; 2 | 3 | import io.cucumber.java.en.Given; 4 | import io.cucumber.java.en.Then; 5 | import io.cucumber.java.en.When; 6 | import net.bddtrader.acceptancetests.tasks.ViewNewsAbout; 7 | import net.serenitybdd.screenplay.Actor; 8 | import net.serenitybdd.screenplay.rest.abilities.CallAnApi; 9 | 10 | import java.util.List; 11 | 12 | import static net.serenitybdd.rest.SerenityRest.lastResponse; 13 | import static org.assertj.core.api.Assertions.assertThat; 14 | 15 | public class MarketNewsStepDefinitions { 16 | 17 | Actor theTrader; 18 | 19 | @Given("^(.*) (?:is interested in|wants to know about) (.*)") 20 | public void interested_in_a_topic(String traderName, String topic) throws Exception { 21 | theTrader = Actor.named(traderName).whoCan(CallAnApi.at("http://localhost:8080")); 22 | } 23 | 24 | 25 | @When("^(.*) views the news about (.*)") 26 | public void view_the_news_about(String traderName, String share) throws Exception { 27 | theTrader.attemptsTo( 28 | ViewNewsAbout.theShare(share) 29 | ); 30 | } 31 | 32 | @Then("^(.*) should only see articles related to (.*)") 33 | public void should_only_see_articles_related_to(String traderName, String share) throws Exception { 34 | 35 | List relatedTopics = lastResponse().jsonPath().getList("related"); 36 | 37 | assertThat(relatedTopics).allMatch( 38 | relatedTopic -> relatedTopic.contains(share) 39 | ); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/acceptancetests/stepdefinitions/SettingTheStage.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.acceptancetests.stepdefinitions; 2 | 3 | import io.cucumber.java.Before; 4 | import io.cucumber.java.ParameterType; 5 | import net.bddtrader.acceptancetests.actors.CastOfTraders; 6 | import net.serenitybdd.screenplay.Actor; 7 | import net.serenitybdd.screenplay.actors.OnStage; 8 | import net.thucydides.model.util.EnvironmentVariables; 9 | 10 | import static net.serenitybdd.screenplay.actors.OnStage.setTheStage; 11 | 12 | /** 13 | * Assign a cast of actors to the scenario stage so that we can call on them in our scenarios. 14 | * We invoke these actors using the OnStage.actorNamed() and OnStage.theActorInTheSpotlight() methods. 15 | */ 16 | public class SettingTheStage { 17 | 18 | /** 19 | * The EnvironmentVariables field wraps the system properties and the properties defined in the serenity.properties 20 | * file. This is a convenient way to access system or environment properties. Serenity will inject it automatically 21 | * into a step definition class. 22 | */ 23 | EnvironmentVariables environmentVariables; 24 | 25 | @Before 26 | public void set_the_stage() { 27 | setTheStage(new CastOfTraders(environmentVariables)); 28 | } 29 | 30 | @ParameterType(".*") 31 | public Actor actor(String name) { 32 | return OnStage.theActorCalled(name); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/acceptancetests/stepdefinitions/SetupSteps.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.acceptancetests.stepdefinitions; 2 | 3 | import io.cucumber.java.Before; 4 | import net.serenitybdd.screenplay.actors.OnStage; 5 | import net.serenitybdd.screenplay.actors.OnlineCast; 6 | 7 | public class SetupSteps { 8 | 9 | @Before("@web") 10 | public void setStage() { 11 | OnStage.setTheStage(new OnlineCast()); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/acceptancetests/stepdefinitions/TestEnvironment.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.acceptancetests.stepdefinitions; 2 | 3 | import net.thucydides.model.util.EnvironmentVariables; 4 | 5 | public class TestEnvironment { 6 | 7 | // Automatically injected by Serenity 8 | private EnvironmentVariables environmentVariables; 9 | 10 | public TestEnvironment(EnvironmentVariables environmentVariables) { 11 | this.environmentVariables = environmentVariables; 12 | } 13 | 14 | public String getRestAPIBaseUrl() { 15 | return environmentVariables.optionalProperty("restapi.baseurl") 16 | .orElse("http://localhost:8080/api"); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/acceptancetests/stepdefinitions/ViewingPositionsStepDefinitons.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.acceptancetests.stepdefinitions; 2 | 3 | import io.cucumber.java.DataTableType; 4 | import io.cucumber.java.en.Given; 5 | import net.bddtrader.acceptancetests.endpoints.BDDTraderEndPoints; 6 | import net.bddtrader.acceptancetests.model.MarketPrice; 7 | import net.serenitybdd.rest.SerenityRest; 8 | import net.serenitybdd.screenplay.rest.interactions.Post; 9 | 10 | import java.util.List; 11 | import java.util.Map; 12 | 13 | import static net.serenitybdd.screenplay.actors.OnStage.theActorCalled; 14 | import static org.assertj.core.api.Assertions.assertThat; 15 | 16 | public class ViewingPositionsStepDefinitons { 17 | 18 | @DataTableType 19 | public MarketPrice marketPrice(Map values) { 20 | return new MarketPrice( 21 | values.get("securityCode"), 22 | Double.parseDouble(values.get("price") 23 | ) 24 | ); 25 | } 26 | 27 | @Given("the following market prices:") 28 | public void marketPrices(List marketPrices) { 29 | marketPrices.forEach( 30 | this::updateMarketPrice 31 | ); 32 | } 33 | 34 | private void updateMarketPrice(MarketPrice marketPrice) { 35 | theActorCalled("Market Forces").attemptsTo( 36 | Post.to(BDDTraderEndPoints.UpdatePrice.path()) 37 | .with(request -> request.pathParam("securityCode", marketPrice.getSecurityCode()) 38 | .header("Content-Type", "application/json") 39 | .body(marketPrice.getPrice())) 40 | ); 41 | assertThat(SerenityRest.lastResponse().statusCode()).isEqualTo(200); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/acceptancetests/tasks/PlaceOrder.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.acceptancetests.tasks; 2 | 3 | import net.bddtrader.acceptancetests.endpoints.BDDTraderEndPoints; 4 | import net.bddtrader.acceptancetests.questions.ThePortfolio; 5 | import net.bddtrader.acceptancetests.tasks.dsl.PlaceOrderDSL; 6 | import net.bddtrader.clients.Client; 7 | import net.bddtrader.portfolios.Trade; 8 | import net.bddtrader.portfolios.TradeType; 9 | import net.serenitybdd.screenplay.Actor; 10 | import net.serenitybdd.screenplay.Task; 11 | import net.serenitybdd.screenplay.rest.interactions.Post; 12 | import net.serenitybdd.screenplay.rest.questions.TheResponse; 13 | 14 | import static net.serenitybdd.screenplay.GivenWhenThen.seeThat; 15 | import static net.serenitybdd.screenplay.Tasks.instrumented; 16 | import static org.hamcrest.Matchers.equalTo; 17 | 18 | public class PlaceOrder implements Task { 19 | 20 | private final TradeType buyOrSell; 21 | private final Long quantity; 22 | private String securityCode; 23 | private double price = 0.0; 24 | private final Client client; 25 | 26 | public PlaceOrder(TradeType type, long quantity, String securityCode, double price, Client client) { 27 | this.buyOrSell = type; 28 | this.quantity = quantity; 29 | this.securityCode = securityCode; 30 | this.price = price; 31 | this.client = client; 32 | } 33 | 34 | public static PlaceOrderDSL.SharesOf to(TradeType type, int quantity) { 35 | return new PlaceOrderBuilder(type, quantity); 36 | } 37 | 38 | @Override 39 | public void performAs(T actor) { 40 | 41 | Integer portfolioId = actor.asksFor(ThePortfolio.idFor(client)); 42 | 43 | actor.attemptsTo( 44 | Post.to(BDDTraderEndPoints.PlaceOrder.path()) 45 | .with(request -> request.header("Content-Type", "application/json") 46 | .body(Trade.of(buyOrSell, quantity) 47 | .sharesOf(securityCode) 48 | .at(price) 49 | .dollarsEach()) 50 | .pathParam("portfolioId",portfolioId) 51 | ) 52 | ); 53 | 54 | actor.should(seeThat(TheResponse.statusCode(), equalTo(200))); 55 | 56 | } 57 | 58 | public static class PlaceOrderBuilder implements PlaceOrderDSL.SharesOf, PlaceOrderDSL.AtAPriceOf { 59 | 60 | private final TradeType type; 61 | private final int quantity; 62 | private String securityCode; 63 | private double price = 0.0; 64 | 65 | public PlaceOrderBuilder(TradeType type, int quantity) { 66 | this.type = type; 67 | this.quantity = quantity; 68 | } 69 | 70 | @Override 71 | public PlaceOrderDSL.AtAPriceOf sharesOf(String securityCode) { 72 | this.securityCode = securityCode; 73 | return this; 74 | } 75 | 76 | @Override 77 | public PlaceOrderDSL.AtAPriceOf atPriceOf(double price) { 78 | this.price = price; 79 | return this; 80 | } 81 | 82 | @Override 83 | public PlaceOrder forClient(Client client) { 84 | return instrumented(PlaceOrder.class, type, quantity, securityCode, price, client); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/acceptancetests/tasks/RegisterWithBDDTrader.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.acceptancetests.tasks; 2 | 3 | import net.bddtrader.acceptancetests.questions.ThePortfolio; 4 | import net.bddtrader.clients.Client; 5 | import net.serenitybdd.rest.SerenityRest; 6 | import net.serenitybdd.screenplay.Performable; 7 | import net.serenitybdd.screenplay.Task; 8 | import net.serenitybdd.screenplay.rest.interactions.Post; 9 | 10 | import static net.bddtrader.acceptancetests.endpoints.BDDTraderEndPoints.RegisterClient; 11 | 12 | public class RegisterWithBDDTrader { 13 | 14 | public static Performable asANewClient(Client client) { 15 | 16 | return Task.where("{0} registers a client " + client, 17 | actor -> { 18 | actor.attemptsTo( 19 | Post.to(RegisterClient.path()) 20 | .with(request -> request.header("Content-Type", "application/json").body(client)) 21 | ); 22 | 23 | if (SerenityRest.lastResponse().statusCode() == 200) { 24 | Client newClient = SerenityRest.lastResponse().as(Client.class); 25 | actor.remember("registeredClient", newClient); 26 | actor.remember("clientPortfolioId", ThePortfolio.idFor(newClient)); 27 | } 28 | } 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/acceptancetests/tasks/ViewNewsAbout.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.acceptancetests.tasks; 2 | 3 | import net.serenitybdd.screenplay.Performable; 4 | import net.serenitybdd.screenplay.Task; 5 | import net.serenitybdd.screenplay.rest.interactions.Get; 6 | 7 | public class ViewNewsAbout { 8 | public static Performable theShare(String stockid) { 9 | return Task.where("{0} gets news about #share", 10 | Get.resource("/api/stock/{stockid}/news") 11 | .with( request -> request.pathParam("stockid", stockid))) 12 | .with("share").of(stockid); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/acceptancetests/tasks/dsl/PlaceOrderDSL.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.acceptancetests.tasks.dsl; 2 | 3 | import net.bddtrader.acceptancetests.tasks.PlaceOrder; 4 | import net.bddtrader.clients.Client; 5 | 6 | public class PlaceOrderDSL { 7 | public interface SharesOf { 8 | AtAPriceOf sharesOf(String securityCode); 9 | } 10 | 11 | public interface AtAPriceOf { 12 | AtAPriceOf atPriceOf(double marketPrice); 13 | PlaceOrder forClient(Client client); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/apitests/clients/WhenAClientRegistersWithBDDTrader.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.apitests.clients; 2 | 3 | import net.bddtrader.clients.Client; 4 | import net.bddtrader.clients.ClientController; 5 | import net.bddtrader.clients.ClientDirectory; 6 | import net.bddtrader.config.TradingDataSource; 7 | import net.bddtrader.exceptions.MissingMandatoryFieldsException; 8 | import net.bddtrader.portfolios.*; 9 | import net.bddtrader.tradingdata.TradingData; 10 | import org.junit.jupiter.api.Assertions; 11 | import org.junit.jupiter.api.BeforeEach; 12 | import org.junit.jupiter.api.Tag; 13 | import org.junit.jupiter.api.Test; 14 | import org.springframework.http.HttpStatus; 15 | import org.springframework.http.HttpStatusCode; 16 | 17 | import java.util.List; 18 | 19 | import static net.bddtrader.config.TradingDataSource.DEV; 20 | import static org.assertj.core.api.Assertions.assertThat; 21 | 22 | public class WhenAClientRegistersWithBDDTrader { 23 | 24 | ClientDirectory clientDirectory = new ClientDirectory(); 25 | PortfolioDirectory portfolioDirectory = new PortfolioDirectory(); 26 | PortfolioController portfolioController = new PortfolioController(TradingDataSource.DEV, portfolioDirectory); 27 | ClientController controller = new ClientController(clientDirectory, portfolioController); 28 | 29 | 30 | @BeforeEach 31 | public void resetTestData() { 32 | TradingData.instanceFor(DEV).reset(); 33 | } 34 | 35 | @Test 36 | @Tag("client") 37 | public void aClientRegistersByProvidingANameAPasswordAndAnEmail() { 38 | 39 | // WHEN 40 | Client registeredClient = controller.register(Client.withFirstName("Sarah-Jane").andLastName("Smith").andEmail("sarah-jane@smith.com")); 41 | 42 | // THEN 43 | assertThat(registeredClient).isEqualToComparingFieldByField(registeredClient); 44 | } 45 | 46 | @Test 47 | @Tag("client") 48 | public void firstNameIsMandatory() { 49 | Assertions.assertThrows(MissingMandatoryFieldsException.class, () -> { 50 | controller.register(controller.register(Client.withFirstName("").andLastName("Smith").andEmail("sarah-jane@smith.com"))); 51 | }); 52 | } 53 | 54 | @Test 55 | @Tag("client") 56 | public void lastNameIsMandatory() { 57 | Assertions.assertThrows(MissingMandatoryFieldsException.class, () -> { 58 | controller.register(controller.register(Client.withFirstName("Sarah-Jane").andLastName("").andEmail("sarah-jane@smith.com"))); 59 | }); 60 | } 61 | 62 | @Test 63 | @Tag("client") 64 | public void emailIsMandatory() { 65 | Assertions.assertThrows(MissingMandatoryFieldsException.class, () -> { 66 | controller.register(Client.withFirstName("Sarah-Jane").andLastName("Smith").andEmail("")); 67 | }); 68 | } 69 | 70 | @Test 71 | @Tag("client") 72 | public void registeredClientsAreStoredInTheClientDirectory() { 73 | 74 | // WHEN 75 | Client registeredClient = controller.register(Client.withFirstName("Sarah-Jane").andLastName("Smith").andEmail("sarah-jane@smith.com")); 76 | 77 | // THEN 78 | assertThat(clientDirectory.findClientById(1)) 79 | .isPresent() 80 | .contains(registeredClient); 81 | } 82 | 83 | @Test 84 | @Tag("client") 85 | public void registeredClientsCanBeRetrievedById() { 86 | 87 | // GIVEN 88 | Client sarahJane = controller.register(Client.withFirstName("Sarah-Jane").andLastName("Smith").andEmail("sarah-jane@smith.com")); 89 | 90 | // WHEN 91 | Client foundClient = controller.findClientById(1L).getBody(); 92 | 93 | // THEN 94 | assertThat(foundClient).isEqualToComparingFieldByField(sarahJane); 95 | } 96 | 97 | @Test 98 | @Tag("client") 99 | public void registeredClientsAreGivenAPortfolio() { 100 | 101 | // GIVEN 102 | Client sarahJane = controller.register(Client.withFirstName("Sarah-Jane").andLastName("Smith").andEmail("sarah-jane@smith.com")); 103 | 104 | // WHEN 105 | Portfolio clientPortfolio = portfolioController.viewPortfolioForClient(sarahJane.getId()); 106 | 107 | // THEN 108 | assertThat(clientPortfolio.getCash()).isEqualTo(1000.00); 109 | } 110 | 111 | @Test 112 | public void registeredClientsCanViewTheirPortfolioPositions() { 113 | 114 | // GIVEN 115 | Client sarahJane = controller.register(Client.withFirstName("Sarah-Jane").andLastName("Smith").andEmail("sarah-jane@smith.com")); 116 | 117 | // WHEN 118 | List positions = portfolioController.viewPortfolioPositionsForClient(sarahJane.getId()); 119 | 120 | // THEN 121 | assertThat(positions).hasSize(1) 122 | .contains(Position.fromTrade(Trade.buy(100000L).sharesOf("CASH").at(1L).centsEach())); 123 | } 124 | 125 | @Test 126 | public void shouldfailIfNoPortfolioCanBeFound() { 127 | // GIVEN 128 | controller.register(Client.withFirstName("Sarah-Jane").andLastName("Smith").andEmail("sarah-jane@smith.com")); 129 | 130 | // WHEN 131 | Assertions.assertThrows(PortfolioNotFoundException.class, () -> portfolioController.viewPortfolioForClient(-1L)); 132 | } 133 | 134 | @Test 135 | public void shouldfailIfNoClientForAPortfolioCanBeFound() { 136 | // GIVEN 137 | Client sarahJane = controller.register(Client.withFirstName("Sarah-Jane").andLastName("Smith").andEmail("sarah-jane@smith.com")); 138 | 139 | // WHEN 140 | Assertions.assertThrows(PortfolioNotFoundException.class, () -> portfolioController.viewPortfolio(-1L)); 141 | } 142 | 143 | 144 | @Test 145 | public void registeredClientsCanBeListed() { 146 | 147 | // GIVEN 148 | controller.register(Client.withFirstName("Sarah-Jane").andLastName("Smith").andEmail("sarah-jane@smith.com")); 149 | controller.register(Client.withFirstName("Joe").andLastName("Smith").andEmail("joe@smith.com")); 150 | 151 | // WHEN 152 | List foundClients = controller.findAll(); 153 | 154 | // THEN 155 | assertThat(foundClients).hasSize(2); 156 | } 157 | 158 | @Test 159 | public void returns404WhenNoMatchingClientIsFound() { 160 | 161 | // GIVEN 162 | controller.register(Client.withFirstName("Sarah-Jane").andLastName("Smith").andEmail("sarah-jane@smith.com")); 163 | 164 | // WHEN 165 | HttpStatusCode status = controller.findClientById(100L).getStatusCode(); 166 | 167 | // THEN 168 | assertThat(status).isEqualTo(HttpStatus.NOT_FOUND); 169 | } 170 | 171 | } 172 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/apitests/clients/WhenDifferentTypesOfClientsRegister.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.apitests.clients; 2 | 3 | import net.bddtrader.clients.Client; 4 | import net.bddtrader.clients.ClientController; 5 | import net.bddtrader.clients.ClientDirectory; 6 | import net.bddtrader.config.TradingDataSource; 7 | import net.bddtrader.portfolios.*; 8 | import net.bddtrader.tradingdata.TradingData; 9 | import org.junit.jupiter.api.BeforeEach; 10 | import org.junit.jupiter.params.ParameterizedTest; 11 | import org.junit.jupiter.params.provider.CsvSource; 12 | 13 | import static net.bddtrader.config.TradingDataSource.DEV; 14 | import static org.assertj.core.api.Assertions.assertThat; 15 | 16 | public class WhenDifferentTypesOfClientsRegister { 17 | 18 | ClientDirectory clientDirectory = new ClientDirectory(); 19 | PortfolioDirectory portfolioDirectory = new PortfolioDirectory(); 20 | PortfolioController portfolioController = new PortfolioController(TradingDataSource.DEV, portfolioDirectory); 21 | ClientController controller = new ClientController(clientDirectory, portfolioController); 22 | 23 | @BeforeEach 24 | public void resetTestData() { 25 | TradingData.instanceFor(DEV).reset(); 26 | } 27 | 28 | @ParameterizedTest 29 | @CsvSource(value = {"Sarah-Jane,Smith", "Bill,Oddie", "Tim,Brooke-Taylor"}) 30 | public void aClientRegisters(String firstName, String lastName) { 31 | 32 | // WHEN 33 | Client registeredClient = controller.register(Client.withFirstName(firstName).andLastName(lastName).andEmail("sarah-jane@smith.com")); 34 | 35 | // THEN 36 | assertThat(registeredClient).isEqualToComparingFieldByField(registeredClient); 37 | } 38 | 39 | @ParameterizedTest 40 | @CsvSource(value = {"李,小龍", "Louis,de Funès"}) 41 | public void aClientRegistersWithAForeignName(String firstName, String lastName) { 42 | 43 | // WHEN 44 | Client registeredClient = controller.register(Client.withFirstName(firstName).andLastName(lastName).andEmail("sarah-jane@smith.com")); 45 | 46 | // THEN 47 | assertThat(registeredClient).isEqualToComparingFieldByField(registeredClient); 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/apitests/news/WhenReadingTheNews.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.apitests.news; 2 | 3 | import net.bddtrader.news.NewsController; 4 | import net.bddtrader.news.NewsItem; 5 | import org.junit.Before; 6 | import org.junit.Test; 7 | 8 | import java.util.List; 9 | 10 | import static net.bddtrader.config.TradingDataSource.DEV; 11 | import static org.assertj.core.api.Assertions.assertThat; 12 | 13 | public class WhenReadingTheNews { 14 | 15 | NewsController newsController; 16 | 17 | @Before 18 | public void prepareNewsController() { 19 | newsController = new NewsController(DEV); 20 | } 21 | 22 | @Test 23 | public void shouldFindTheLatestNewsAboutAParticularStockFromStaticData() { 24 | 25 | newsController = new NewsController(DEV); 26 | 27 | List news = newsController.newsFor("AAPL"); 28 | 29 | assertThat(news).isNotEmpty(); 30 | 31 | news.forEach( 32 | newsItem -> {assertThat(newsItem.getRelated()).contains("AAPL");} 33 | ); 34 | } 35 | 36 | @Test 37 | public void shouldFindTheLatestNewsAboutAParticularStock() { 38 | 39 | List news = newsController.newsFor("AAPL"); 40 | 41 | assertThat(news).isNotEmpty(); 42 | 43 | news.forEach( 44 | newsItem -> {assertThat(newsItem.getRelated()).contains("AAPL");} 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/apitests/portfolio/WhenClientsUseTheirPortfolios.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.apitests.portfolio; 2 | 3 | import net.bddtrader.clients.Client; 4 | import net.bddtrader.clients.ClientController; 5 | import net.bddtrader.clients.ClientDirectory; 6 | import net.bddtrader.config.TradingDataSource; 7 | import net.bddtrader.portfolios.*; 8 | import net.bddtrader.tradingdata.TradingData; 9 | import org.junit.jupiter.api.BeforeEach; 10 | import org.junit.jupiter.api.Test; 11 | 12 | import java.util.List; 13 | import java.util.Map; 14 | 15 | import static net.bddtrader.config.TradingDataSource.DEV; 16 | import static net.bddtrader.portfolios.Trade.buy; 17 | import static org.assertj.core.api.Assertions.assertThat; 18 | 19 | public class WhenClientsUseTheirPortfolios { 20 | 21 | PortfolioDirectory portfolioDirectory = new PortfolioDirectory(); 22 | ClientDirectory clientDirectory = new ClientDirectory(); 23 | PortfolioController portfolioController = new PortfolioController(TradingDataSource.DEV, portfolioDirectory); 24 | ClientController clientController = new ClientController(clientDirectory, portfolioController); 25 | 26 | @BeforeEach 27 | public void resetTestData() { 28 | TradingData.instanceFor(DEV).reset(); 29 | } 30 | 31 | @Test 32 | public void whenAClientRegistersTheyAreGivenAPortfolio() { 33 | 34 | Client joe = clientController.register(Client.withFirstName("Sarah-Jane").andLastName("Smith").andEmail("sarah-jane@smith.com")); 35 | 36 | Portfolio portfolio = portfolioController.viewPortfolio(joe.getId()); 37 | 38 | assertThat(portfolio.getCash()).isEqualTo(1000.00); 39 | } 40 | 41 | @Test 42 | public void clientsCanPurchaseSharesWithTheirPortfolio() { 43 | 44 | Client joe = clientController.register(Client.withFirstName("Sarah-Jane").andLastName("Smith").andEmail("sarah-jane@smith.com")); 45 | 46 | Portfolio portfolio = portfolioController.viewPortfolio(joe.getId()); 47 | 48 | portfolioController.placeOrder(portfolio.getPortfolioId(), 49 | buy(10L).sharesOf("AAPL").at(500L).centsEach()); 50 | 51 | Position applPosition = portfolioController.getIndexedPositions(portfolio.getPortfolioId()).get("AAPL"); 52 | 53 | assertThat(applPosition.getAmount()).isEqualTo(10L); 54 | } 55 | 56 | @Test 57 | public void clientsCanPurchaseSharesWithTheirPortfolioAtMarketPrices() { 58 | 59 | Client joe = clientController.register(Client.withFirstName("Sarah-Jane").andLastName("Smith").andEmail("sarah-jane@smith.com")); 60 | Portfolio portfolio = portfolioController.viewPortfolio(joe.getId()); 61 | 62 | TradingData.instanceFor(TradingDataSource.DEV).updatePriceFor("AAPL", 50.00); 63 | 64 | portfolioController.placeOrder(portfolio.getPortfolioId(), 65 | buy(1L).sharesOf("AAPL").atMarketPrice()); 66 | 67 | Position applPosition = portfolioController.getIndexedPositions(portfolio.getPortfolioId()).get("AAPL"); 68 | 69 | assertThat(applPosition.getTotalPurchasePriceInDollars()).isEqualTo(50.00); 70 | } 71 | 72 | @Test 73 | public void clientsCanViewTheirPositions() { 74 | 75 | Client joe = clientController.register(Client.withFirstName("Sarah-Jane").andLastName("Smith").andEmail("sarah-jane@smith.com")); 76 | 77 | Portfolio portfolio = portfolioController.viewPortfolio(joe.getId()); 78 | 79 | portfolioController.placeOrder(portfolio.getPortfolioId(), 80 | buy(10L).sharesOf("AAPL").at(500L).centsEach()); 81 | 82 | portfolioController.placeOrder(portfolio.getPortfolioId(), 83 | buy(20L).sharesOf("IBM").at(500L).centsEach()); 84 | 85 | Map positions = portfolioController.getIndexedPositions(portfolio.getPortfolioId()); 86 | Position applPosition = positions.get("AAPL"); 87 | Position ibmPosition = positions.get("IBM"); 88 | Position cashPosition = positions.get("CASH"); 89 | 90 | assertThat(applPosition.getAmount()).isEqualTo(10L); 91 | assertThat(ibmPosition.getAmount()).isEqualTo(20L); 92 | assertThat(cashPosition.getAmount()).isEqualTo(85000L); 93 | } 94 | 95 | @Test 96 | public void clientsCanViewTheirProfitsForEachPosition() { 97 | 98 | Client joe = clientController.register(Client.withFirstName("Sarah-Jane").andLastName("Smith").andEmail("sarah-jane@smith.com")); 99 | 100 | Portfolio portfolio = portfolioController.viewPortfolio(joe.getId()); 101 | 102 | portfolioController.placeOrder(portfolio.getPortfolioId(), 103 | buy(10L).sharesOf("AAPL").at(10000L).centsEach()); 104 | 105 | Map positions = portfolioController.getIndexedPositions(portfolio.getPortfolioId()); 106 | Position applPosition = positions.get("AAPL"); 107 | 108 | assertThat(applPosition.getProfit()).isEqualTo(902.40); 109 | } 110 | 111 | @Test 112 | public void clientsCanViewTheirLossesForEachPosition() { 113 | 114 | Client joe = clientController.register(Client.withFirstName("Sarah-Jane").andLastName("Smith").andEmail("sarah-jane@smith.com")); 115 | 116 | Portfolio portfolio = portfolioController.viewPortfolio(joe.getId()); 117 | 118 | portfolioController.placeOrder(portfolio.getPortfolioId(), 119 | buy(1L).sharesOf("AAPL").at(20000L).centsEach()); 120 | 121 | Map positions = portfolioController.getIndexedPositions(portfolio.getPortfolioId()); 122 | Position applPosition = positions.get("AAPL"); 123 | 124 | assertThat(applPosition.getProfit()).isEqualTo(-9.76); 125 | } 126 | 127 | @Test 128 | public void clientsCanViewTheirOverallProfitsAndLosses() { 129 | 130 | Client joe = clientController.register(Client.withFirstName("Sarah-Jane").andLastName("Smith").andEmail("sarah-jane@smith.com")); 131 | 132 | Portfolio portfolio = portfolioController.viewPortfolio(joe.getId()); 133 | 134 | portfolioController.placeOrder(portfolio.getPortfolioId(), 135 | buy(1L).sharesOf("AAPL").at(20000L).centsEach()); 136 | 137 | portfolioController.placeOrder(portfolio.getPortfolioId(), 138 | buy(50L).sharesOf("GE").at(1110L).centsEach()); 139 | 140 | Double profitAndLoss = portfolioController.getProfitAndLoss(portfolio.getPortfolioId()); 141 | 142 | assertThat(profitAndLoss).isEqualTo(150.00 - 9.76); 143 | } 144 | 145 | 146 | 147 | @Test 148 | public void clientsCanViewTheirTradeHistory() { 149 | 150 | Client joe = clientController.register(Client.withFirstName("Sarah-Jane").andLastName("Smith").andEmail("sarah-jane@smith.com")); 151 | 152 | Portfolio portfolio = portfolioController.viewPortfolio(joe.getId()); 153 | 154 | portfolioController.placeOrder(portfolio.getPortfolioId(), 155 | buy(10L).sharesOf("AAPL").at(500L).centsEach()); 156 | 157 | portfolioController.placeOrder(portfolio.getPortfolioId(), 158 | buy(20L).sharesOf("IBM").at(500L).centsEach()); 159 | 160 | List history = portfolioController.getHistory(portfolio.getPortfolioId()); 161 | assertThat(history).hasSize(5); 162 | } 163 | 164 | } 165 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/apitests/portfolio/WhenCreatingAPortfolio.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.apitests.portfolio; 2 | 3 | import net.bddtrader.config.TradingDataSource; 4 | import net.bddtrader.portfolios.InsufficientFundsException; 5 | import net.bddtrader.portfolios.Portfolio; 6 | import net.bddtrader.portfolios.PortfolioWithPositions; 7 | import net.bddtrader.tradingdata.PriceReader; 8 | import net.bddtrader.tradingdata.TradingData; 9 | import org.junit.jupiter.api.BeforeEach; 10 | import org.junit.jupiter.api.Test; 11 | 12 | import static net.bddtrader.config.TradingDataSource.DEV; 13 | import static net.bddtrader.portfolios.Trade.buy; 14 | import static net.bddtrader.portfolios.Trade.deposit; 15 | import static net.bddtrader.portfolios.Trade.sell; 16 | import static org.assertj.core.api.Assertions.assertThat; 17 | import static org.junit.jupiter.api.Assertions.assertThrows; 18 | import static org.mockito.Mockito.mock; 19 | import static org.mockito.Mockito.when; 20 | 21 | public class WhenCreatingAPortfolio { 22 | 23 | Portfolio portfolio = new Portfolio(1L, 1L); 24 | 25 | @BeforeEach 26 | public void resetTestData() { 27 | TradingData.instanceFor(DEV).reset(); 28 | } 29 | 30 | @Test 31 | public void aNewPortfolioStartsWith1000DollarsOnACashAccount() { 32 | 33 | assertThat(portfolio.getCash()).isEqualTo(1000.00); 34 | } 35 | 36 | @Test 37 | public void purchasingSharesDrawsOnTheCashAccount() { 38 | 39 | portfolio.placeOrder( 40 | buy(50L).sharesOf("AAPL").at(100L).centsEach() 41 | ); 42 | 43 | assertThat(portfolio.getCash()).isEqualTo(950.00); 44 | } 45 | 46 | @Test 47 | public void sellingSharesDepositsOnTheCashAccount() { 48 | 49 | portfolio.placeOrder(buy(200L).sharesOf("IBM").at(100L).centsEach()); 50 | portfolio.placeOrder(sell(50L).sharesOf("IBM").at(100L).centsEach()); 51 | 52 | assertThat(portfolio.getCash()).isEqualTo(850.00); 53 | } 54 | 55 | 56 | @Test 57 | public void portfolioShoudShowProfitsOrLossesBasedOnCurrentMarketPrices() { 58 | 59 | PriceReader priceReader = mock(PriceReader.class); 60 | 61 | portfolio.placeOrder(buy(10L).sharesOf("IBM").at(10000L).centsEach()); 62 | 63 | when(priceReader.getPriceFor("IBM")).thenReturn(150.00); 64 | assertThat(portfolio.calculateProfitUsing(priceReader)).isEqualTo(500.00); 65 | } 66 | 67 | @Test 68 | public void buyerMustHaveEnoughCashToMakeAPurchase() { 69 | assertThrows(InsufficientFundsException.class, () -> 70 | portfolio.placeOrder(buy(20000L).sharesOf("IBM").at(100L).centsEach()) 71 | ); 72 | 73 | } 74 | 75 | @Test 76 | public void failedTransactionsShouldNotAffectBalance() { 77 | assertThrows(InsufficientFundsException.class, () -> portfolio.placeOrder(buy(20000L).sharesOf("IBM").at(100L).centsEach())); 78 | assertThat(portfolio.getCash()).isEqualTo(1000.00); 79 | } 80 | 81 | @Test 82 | public void portfolioWithPositionsShoudShowCashAccountValues() { 83 | 84 | PriceReader priceReader = TradingData.instanceFor(TradingDataSource.DEV); 85 | 86 | portfolio.placeOrder(deposit(1000L).dollars()); 87 | 88 | PortfolioWithPositions portfolioWithPositions = (PortfolioWithPositions) portfolio.withMarketPricesFrom(priceReader); 89 | 90 | assertThat(portfolioWithPositions.getMarketPositions().get("CASH").getAmount()).isEqualTo(200000); 91 | assertThat(portfolioWithPositions.getMarketPositions().get("CASH").getTotalValueInDollars()).isEqualTo(2000.00); 92 | assertThat(portfolioWithPositions.getMarketPositions().get("CASH").getMarketValueInDollars()).isEqualTo(0.01); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/apitests/portfolio/WhenWorkingWithPositions.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.apitests.portfolio; 2 | 3 | import net.bddtrader.portfolios.Position; 4 | import net.bddtrader.portfolios.Trade; 5 | import org.junit.jupiter.api.Test; 6 | import static net.bddtrader.portfolios.Trade.*; 7 | import static org.assertj.core.api.Assertions.assertThat; 8 | 9 | public class WhenWorkingWithPositions { 10 | 11 | @Test 12 | public void applyingAnInitialTrade() { 13 | 14 | Trade trade = buy(10L).sharesOf("AAPL").at(500L).centsEach(); 15 | Position position = Position.fromTrade(trade); 16 | 17 | assertThat(position.getSecurityCode()).isEqualTo("AAPL"); 18 | assertThat(position.getMarketValueInDollars()).isEqualTo(5.0); 19 | assertThat(position.getTotalPurchasePriceInDollars()).isEqualTo(50.00); 20 | assertThat(position.getTotalValueInDollars()).isEqualTo(50.00); 21 | 22 | } 23 | 24 | @Test 25 | public void applyingABuyTrade() { 26 | 27 | Trade trade = buy(10L).sharesOf("AAPL").at(500L).centsEach(); 28 | Position position = Position.fromTrade(trade); 29 | 30 | Position newPosition = position.apply(buy(10L).sharesOf("AAPL").at(500L).centsEach()); 31 | 32 | assertThat(newPosition.getSecurityCode()).isEqualTo("AAPL"); 33 | assertThat(newPosition.getTotalPurchasePriceInDollars()).isEqualTo(100.00); 34 | assertThat(newPosition.getTotalValueInDollars()).isEqualTo(100.00); 35 | assertThat(newPosition.getMarketValueInDollars()).isEqualTo(5.00); 36 | } 37 | 38 | 39 | @Test 40 | public void applyingASellTrade() { 41 | 42 | Trade trade = buy(10L).sharesOf("AAPL").at(500L).centsEach(); 43 | Position newPosition = Position.fromTrade(trade) 44 | .apply(buy(10L).sharesOf("AAPL").at(500L).centsEach()) 45 | .apply(sell(2L).sharesOf("AAPL").at(500L).centsEach()); 46 | 47 | assertThat(newPosition.getSecurityCode()).isEqualTo("AAPL"); 48 | assertThat(newPosition.getAmount()).isEqualTo(18); 49 | assertThat(newPosition.getTotalPurchasePriceInDollars()).isEqualTo(90.00); 50 | assertThat(newPosition.getTotalValueInDollars()).isEqualTo(90.00); 51 | assertThat(newPosition.getMarketValueInDollars()).isEqualTo(5.00); 52 | } 53 | 54 | @Test 55 | public void applyingBuyAndSellTrades() { 56 | 57 | Trade trade = buy(10L).sharesOf("AAPL").at(500L).centsEach(); 58 | Position newPosition = Position.fromTrade(trade) 59 | .apply(buy(10L).sharesOf("AAPL").at(500L).centsEach()) 60 | .apply(buy(20L).sharesOf("AAPL").at(500L).centsEach()) 61 | .apply(sell(2L).sharesOf("AAPL").at(500L).centsEach()); 62 | 63 | assertThat(newPosition.getSecurityCode()).isEqualTo("AAPL"); 64 | assertThat(newPosition.getAmount()).isEqualTo(38); 65 | assertThat(newPosition.getTotalPurchasePriceInDollars()).isEqualTo(190.00); 66 | assertThat(newPosition.getTotalValueInDollars()).isEqualTo(190.00); 67 | assertThat(newPosition.getMarketValueInDollars()).isEqualTo(5.00); 68 | } 69 | 70 | @Test 71 | public void depositing100DollarsInCash() { 72 | 73 | Trade trade = deposit(100L).dollars(); 74 | Position position = Position.fromTrade(trade); 75 | 76 | assertThat(position.getSecurityCode()).isEqualTo("CASH"); 77 | assertThat(position.getMarketValueInDollars()).isEqualTo(0.01); 78 | assertThat(position.getAmount()).isEqualTo(10000L); 79 | assertThat(position.getTotalPurchasePriceInDollars()).isEqualTo(100.00); 80 | assertThat(position.getTotalValueInDollars()).isEqualTo(100.00); 81 | 82 | } 83 | 84 | @Test 85 | public void theProfitOfTheCashAccountIsAlwaysZero() { 86 | 87 | Trade trade = deposit(100L).dollars(); 88 | Position position = Position.fromTrade(trade); 89 | 90 | assertThat(position.getProfit()).isEqualTo(0.0); 91 | } 92 | 93 | } 94 | 95 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/apitests/portfolio/WhenWorkingWithTrades.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.apitests.portfolio; 2 | 3 | import net.bddtrader.portfolios.Trade; 4 | import org.junit.jupiter.api.Test; 5 | 6 | import static net.bddtrader.portfolios.TradeType.*; 7 | import static org.assertj.core.api.Assertions.assertThat; 8 | 9 | public class WhenWorkingWithTrades { 10 | 11 | @Test 12 | public void purchasingActionsShouldCalculateTheTotalPriceOfTheTrade() { 13 | 14 | Trade trade = Trade.buy(10L).sharesOf("AAPL").at(20000L).centsEach(); 15 | 16 | assertThat(trade.getType()).isEqualTo(Buy); 17 | assertThat(trade.getTotalInCents()).isEqualTo(200000); 18 | 19 | } 20 | 21 | @Test 22 | public void sellinggActionsShouldCalculateTheTotalPriceOfTheTrade() { 23 | 24 | Trade trade = Trade.sell(10L).sharesOf("AAPL").at(20000L).centsEach(); 25 | 26 | assertThat(trade.getType()).isEqualTo(Sell); 27 | assertThat(trade.getTotalInCents()).isEqualTo(200000); 28 | 29 | } 30 | 31 | @Test 32 | public void makingACashDeposit() { 33 | 34 | Trade trade = Trade.deposit(1000L).dollars(); 35 | 36 | assertThat(trade.getType()).isEqualTo(Deposit); 37 | assertThat(trade.getTotalInCents()).isEqualTo(100000); 38 | 39 | } 40 | 41 | @Test 42 | public void makingACashDepositInCents() { 43 | 44 | Trade trade = Trade.deposit(1000L).cents(); 45 | 46 | assertThat(trade.getType()).isEqualTo(Deposit); 47 | assertThat(trade.getTotalInCents()).isEqualTo(1000); 48 | 49 | } 50 | 51 | } 52 | 53 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/apitests/status/WhenCheckingApplicationStatus.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.apitests.status; 2 | 3 | import net.bddtrader.config.TradingDataSource; 4 | import net.bddtrader.status.StatusController; 5 | import org.junit.Before; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.params.ParameterizedTest; 8 | import org.junit.jupiter.params.provider.CsvSource; 9 | 10 | import static net.bddtrader.config.TradingDataSource.DEV; 11 | import static org.assertj.core.api.Assertions.assertThat; 12 | 13 | public class WhenCheckingApplicationStatus { 14 | 15 | StatusController controller; 16 | 17 | @Before 18 | public void prepareNewsController() { 19 | controller = new StatusController(DEV); 20 | } 21 | 22 | @Test 23 | public void statusShouldIncludeTradeDataSource() { 24 | 25 | controller = new StatusController(DEV); 26 | 27 | assertThat(controller.status()).isEqualTo("BDDTrader running against DEV"); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/java/net/bddtrader/apitests/stocks/WhenRequestingThePrice.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.apitests.stocks; 2 | 3 | import net.bddtrader.stocks.StockController; 4 | import net.bddtrader.tradingdata.TradingData; 5 | import net.bddtrader.tradingdata.exceptions.IllegalPriceManipulationException; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.BeforeEach; 8 | import org.springframework.web.client.HttpClientErrorException; 9 | 10 | import static net.bddtrader.config.TradingDataSource.DEV; 11 | import static org.assertj.core.api.Assertions.assertThat; 12 | import static org.junit.jupiter.api.Assertions.assertThrows; 13 | 14 | public class WhenRequestingThePrice { 15 | 16 | StockController controller; 17 | 18 | @BeforeEach 19 | public void prepareNewsController() { 20 | controller = new StockController(DEV); 21 | TradingData.instanceFor(DEV).reset(); 22 | } 23 | 24 | @Test 25 | public void shouldFindTheLatestPriceForAParticularStockFromStaticData() { 26 | 27 | controller = new StockController(DEV); 28 | 29 | assertThat(controller.priceFor("AAPL")).isEqualTo(190.24); 30 | } 31 | 32 | @Test 33 | public void shouldFindTheLatestNewsAboutAParticularStock() { 34 | 35 | assertThat(controller.priceFor("AAPL")).isGreaterThan(0.00); 36 | } 37 | 38 | @Test 39 | public void shouldReportResourceNotFoundForUnknownStocks() { 40 | controller = new StockController(DEV); 41 | assertThrows(HttpClientErrorException.class, () -> controller.priceFor("UNKNOWN-STOCK")); 42 | } 43 | 44 | @Test 45 | public void priceForCashOnIEXShouldAlwaysBe1Cent() { 46 | controller = new StockController(DEV); 47 | assertThat(controller.priceFor("CASH")).isEqualTo(0.01); 48 | } 49 | 50 | @Test 51 | public void shouldFindPopularStocks() { 52 | controller = new StockController(DEV); 53 | assertThat(controller.getPopularStocks()).isNotEmpty(); 54 | } 55 | 56 | @Test 57 | public void shouldAllowPricesToBeUpdatedProgrammaticallyInDev() { 58 | 59 | controller = new StockController(DEV); 60 | 61 | controller.updatePriceFor("IBM", 200.00); 62 | 63 | assertThat(controller.priceFor("IBM")).isEqualTo(200.00); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/resources/BDDTrader.postman_collection: -------------------------------------------------------------------------------- 1 | { 2 | "variables": [], 3 | "info": { 4 | "name": "BDDTrader", 5 | "_postman_id": "c0b72841-49c4-4a4e-6ac5-16ec55a5f3c6", 6 | "description": "", 7 | "schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json" 8 | }, 9 | "item": [ 10 | { 11 | "name": "Register client", 12 | "request": { 13 | "url": "http://localhost:8080/api/client", 14 | "method": "POST", 15 | "header": [ 16 | { 17 | "key": "Content-Type", 18 | "value": "application/json", 19 | "description": "" 20 | } 21 | ], 22 | "body": { 23 | "mode": "raw", 24 | "raw": "{\n\"firstName\":\"Jane\",\n\"lastName\":\"Smith\"\n}" 25 | }, 26 | "description": "" 27 | }, 28 | "response": [] 29 | }, 30 | { 31 | "name": "View client portfolio", 32 | "request": { 33 | "url": "http://localhost:8080/api/client/1/portfolio", 34 | "method": "GET", 35 | "header": [], 36 | "body": {}, 37 | "description": "" 38 | }, 39 | "response": [] 40 | }, 41 | { 42 | "name": "stockprice", 43 | "request": { 44 | "url": "http://localhost:8080/stock/AAPL/price", 45 | "method": "GET", 46 | "header": [], 47 | "body": {}, 48 | "description": "" 49 | }, 50 | "response": [] 51 | }, 52 | { 53 | "name": "update stock price", 54 | "request": { 55 | "url": "http://localhost:8080/stock/AAPL/price", 56 | "method": "GET", 57 | "header": [], 58 | "body": {}, 59 | "description": "" 60 | }, 61 | "response": [] 62 | }, 63 | { 64 | "name": "See popular stocks", 65 | "request": { 66 | "url": "http://localhost:8080/api/stock/popular", 67 | "method": "GET", 68 | "header": [], 69 | "body": {}, 70 | "description": "" 71 | }, 72 | "response": [] 73 | }, 74 | { 75 | "name": "Place a historical order", 76 | "request": { 77 | "url": "http://localhost:8080/api/portfolio/1/order", 78 | "method": "POST", 79 | "header": [ 80 | { 81 | "key": "Content-Type", 82 | "value": "application/json", 83 | "description": "" 84 | } 85 | ], 86 | "body": { 87 | "mode": "raw", 88 | "raw": "{ \n\t\"securityCode\": \"AAPL\",\n\t\"type\" : \"Buy\",\n\t\"amount\" : 10,\n\t\"priceInCents\" : 5000\n}" 89 | }, 90 | "description": "" 91 | }, 92 | "response": [] 93 | }, 94 | { 95 | "name": "Place a new order", 96 | "request": { 97 | "url": "http://localhost:8080/api/portfolio/1/order", 98 | "method": "POST", 99 | "header": [ 100 | { 101 | "key": "Content-Type", 102 | "value": "application/json", 103 | "description": "" 104 | } 105 | ], 106 | "body": { 107 | "mode": "raw", 108 | "raw": "{ \n\t\"securityCode\": \"AAPL\",\n\t\"type\" : \"Buy\",\n\t\"amount\" : 10,\n\t\"priceInCents\" : 5000\n}" 109 | }, 110 | "description": "" 111 | }, 112 | "response": [] 113 | }, 114 | { 115 | "name": "Deposit cash", 116 | "request": { 117 | "url": "http://localhost:8080/api/portfolio/1/order", 118 | "method": "POST", 119 | "header": [ 120 | { 121 | "key": "Content-Type", 122 | "value": "application/json", 123 | "description": "" 124 | } 125 | ], 126 | "body": { 127 | "mode": "raw", 128 | "raw": "{ \n\t\"securityCode\": \"AAPL\",\n\t\"type\" : \"Buy\",\n\t\"amount\" : 10,\n\t\"priceInCents\" : 5000\n}" 129 | }, 130 | "description": "" 131 | }, 132 | "response": [] 133 | }, 134 | { 135 | "name": "Show portfolio profits", 136 | "request": { 137 | "url": "http://localhost:8080/api/portfolio/1/profit", 138 | "method": "GET", 139 | "header": [], 140 | "body": {}, 141 | "description": "" 142 | }, 143 | "response": [] 144 | } 145 | ] 146 | } -------------------------------------------------------------------------------- /bddtrader-app/src/test/resources/features/clients/registering_a_new_client.feature: -------------------------------------------------------------------------------- 1 | Feature: Registering a new client 2 | 3 | New clients are given a portfolio with $1000 to start with. 4 | 5 | Name and email is mandatory, e.g: 6 | 7 | {Examples} New clients need to provide their full name and email address 8 | 9 | Scenario: New clients are given a portfolio with $1000 to start with 10 | Given a trader with the following details: 11 | | firstName | lastName | email | 12 | | Jake | Smith | jack@smith.com | 13 | When the trader registers with BDD Trader 14 | Then the trader should have a portfolio with $1000 in cash 15 | 16 | Scenario Outline: New clients need to provide their full name and email address 17 | Given a trader with the following details: 18 | | firstName | lastName | email | 19 | | | | | 20 | When the trader attempts to register with BDD Trader 21 | Then the registration should be rejected 22 | Examples: 23 | | firstName | lastName | email | 24 | | Joe | | joe@smith.com | 25 | | | Smith | joe@smith.com | 26 | | Joe | Smith | | -------------------------------------------------------------------------------- /bddtrader-app/src/test/resources/features/portfolios/buying_and_selling_shares.feature: -------------------------------------------------------------------------------- 1 | @trading 2 | Feature: Buying and selling shares 3 | 4 | In order to make my investments grow 5 | As a trader 6 | I want to be able to buy and sell shares to make a profit 7 | 8 | All traders start with $1000 in cash in their portfolio 9 | CASH amounts are recorded in cents, so 50000 represents $500 10 | 11 | Key capabilities include the ability to buy and sell shares, e.g. 12 | 13 | {Scenario} Buying and selling shares 14 | 15 | Scenario: Buying shares 16 | 17 | Given Tom Smith is a registered trader 18 | When he purchases 5 AAPL shares at $100 each 19 | Then he should have the following positions: 20 | | securityCode | amount | 21 | | CASH | 50000 | 22 | | AAPL | 5 | 23 | 24 | Scenario: Buying and selling shares 25 | Given Tom Smith is a registered trader 26 | When he purchases 5 AAPL shares at $100 each 27 | And he sells 3 AAPL shares for $150 each 28 | Then he should have the following positions: 29 | | securityCode | amount | 30 | | CASH | 95000 | 31 | | AAPL | 2 | 32 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/resources/features/portfolios/viewing_positions.feature: -------------------------------------------------------------------------------- 1 | @trading 2 | Feature: Viewing positions 3 | In order to understand how my investments are doing 4 | As a trader 5 | I want to be able to see the profits and losses for my investments 6 | 7 | All traders start with $1000 in cash in their portfolio 8 | CASH amounts are recorded in cents, so 50000 represents $500 9 | 10 | Background: 11 | Given the following market prices: 12 | | securityCode | price | 13 | | SNAP | 200 | 14 | | IBM | 60 | 15 | And Sarah Smith is a registered trader 16 | 17 | Scenario: Making a profit on a single share 18 | Given Sarah has purchased 5 SNAP shares at $100 each 19 | Then she should have the following position details: 20 | | securityCode | amount | totalValueInDollars | profit | 21 | | CASH | 50000 | 500.00 | 0.00 | 22 | | SNAP | 5 | 1000.00 | 500.00 | 23 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/resources/features/readme.md: -------------------------------------------------------------------------------- 1 | # BDD Trader application 2 | 3 | The BDD Trader application allows users to create and follow a portfolio of shares. 4 | It is made up of two layers: 5 | * An AngularJS user interface 6 | * A set of REST APIs built using Spring Boot 7 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/resources/features/viewing_market_data/reading_market_news.feature: -------------------------------------------------------------------------------- 1 | Feature: Market news 2 | 3 | In order to make sensible trading decisions 4 | As a trader 5 | I want to be informed of relevant news about shares I am interested in 6 | 7 | @api @market 8 | Scenario: Viewing news about a particular share 9 | Given Tim is interested in Apple 10 | When Tim views the news about AAPL 11 | Then Tim should only see articles related to AAPL 12 | 13 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/resources/features/viewing_market_data/readme.md: -------------------------------------------------------------------------------- 1 | Viewing Market Data 2 | 3 | Traders need to be informed about the state of the market. 4 | This can be related to technical information about a particular share, 5 | news about the share, or news about the market in general. -------------------------------------------------------------------------------- /bddtrader-app/src/test/resources/junit-platform.properties: -------------------------------------------------------------------------------- 1 | cucumber.execution.parallel.enabled=true 2 | cucumber.execution.parallel.config.strategy=dynamic 3 | cucumber.plugin=io.cucumber.core.plugin.SerenityReporterParallel 4 | -------------------------------------------------------------------------------- /bddtrader-app/src/test/resources/serenity.conf: -------------------------------------------------------------------------------- 1 | serenity { 2 | } 3 | -------------------------------------------------------------------------------- /bddtrader-domain/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | net.bddtrader 6 | bddtrader-domain 7 | 1.0.0-SNAPSHOT 8 | jar 9 | 10 | The BDD Trader Domain 11 | 12 | 13 | net.bddtrader 14 | bddtrader 15 | 1.0.0-SNAPSHOT 16 | 17 | 18 | 19 | UTF-8 20 | UTF-8 21 | 22 | 23 | 24 | com.fasterxml.jackson.core 25 | jackson-databind 26 | 2.15.0 27 | 28 | 29 | 30 | 31 | 32 | org.apache.maven.plugins 33 | maven-compiler-plugin 34 | 3.7.0 35 | 36 | 17 37 | 17 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /bddtrader-domain/src/main/java/net/bddtrader/clients/Client.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.clients; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | 6 | public class Client { 7 | 8 | private final Long id; 9 | private final String firstName; 10 | private final String lastName; 11 | private final String email; 12 | 13 | @JsonCreator 14 | public Client(@JsonProperty("id") Long id, 15 | @JsonProperty("firstName") String firstName, 16 | @JsonProperty("lastName") String lastName, 17 | @JsonProperty("email") String email) { 18 | this.id = id; 19 | this.firstName = firstName; 20 | this.lastName = lastName; 21 | this.email = email; 22 | } 23 | 24 | public Long getId() { 25 | return id; 26 | } 27 | 28 | public String getFirstName() { 29 | return firstName; 30 | } 31 | 32 | public String getLastName() { 33 | return lastName; 34 | } 35 | 36 | public String getEmail() { 37 | return email; 38 | } 39 | 40 | public static AndLastName withFirstName(String firstName) { 41 | return new ClientBuilder(firstName); 42 | } 43 | 44 | public interface AndLastName { AndEmail andLastName(String lastName); } 45 | public interface AndEmail { Client andEmail(String email); } 46 | 47 | 48 | public static class ClientBuilder implements AndLastName, AndEmail { 49 | private final String firstName; 50 | private String lastName; 51 | 52 | public ClientBuilder(String firstName) { 53 | this.firstName = firstName; 54 | } 55 | 56 | public AndEmail andLastName(String lastName) { 57 | this.lastName = lastName; 58 | return this; 59 | } 60 | 61 | public Client andEmail(String email) { 62 | return new Client(null, firstName, lastName, email); 63 | } 64 | } 65 | 66 | @Override 67 | public String toString() { 68 | return firstName + " " + lastName; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /bddtrader-domain/src/main/java/net/bddtrader/news/NewsItem.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.news; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 5 | import com.fasterxml.jackson.annotation.JsonProperty; 6 | 7 | import java.time.ZonedDateTime; 8 | 9 | @JsonIgnoreProperties(ignoreUnknown = true) 10 | public class NewsItem { 11 | private ZonedDateTime datetime; 12 | private String headline; 13 | private String source; 14 | private String url; 15 | private String summary; 16 | private String related; 17 | 18 | @JsonCreator 19 | public NewsItem( 20 | @JsonProperty("datetime") ZonedDateTime datetime, 21 | @JsonProperty("headline") String headline, 22 | @JsonProperty("source") String source, 23 | @JsonProperty("url") String url, 24 | @JsonProperty("summary") String summary, 25 | @JsonProperty("related") String related) { 26 | this.datetime = datetime; 27 | this.headline = headline; 28 | this.source = source; 29 | this.url = url; 30 | this.summary = summary; 31 | this.related = related; 32 | } 33 | 34 | public ZonedDateTime getDatetime() { 35 | return datetime; 36 | } 37 | 38 | public String getHeadline() { 39 | return headline; 40 | } 41 | 42 | public String getSource() { 43 | return source; 44 | } 45 | 46 | public String getUrl() { 47 | return url; 48 | } 49 | 50 | public String getSummary() { 51 | return summary; 52 | } 53 | 54 | public String getRelated() { 55 | return related; 56 | } 57 | } -------------------------------------------------------------------------------- /bddtrader-domain/src/main/java/net/bddtrader/portfolios/MoneyCalculations.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.portfolios; 2 | 3 | import java.math.BigDecimal; 4 | import java.math.RoundingMode; 5 | 6 | public class MoneyCalculations { 7 | 8 | public static double dollarsFromCents(long valueInCents) { 9 | BigDecimal dollars = new BigDecimal(valueInCents).divide(new BigDecimal(100)); 10 | return dollars.setScale(2, RoundingMode.HALF_UP).doubleValue(); 11 | } 12 | 13 | public static double roundCents(double valueInDollars) { 14 | BigDecimal dollars = new BigDecimal(valueInDollars); 15 | return dollars.setScale(2, RoundingMode.HALF_UP).doubleValue(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /bddtrader-domain/src/main/java/net/bddtrader/portfolios/Position.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.portfolios; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | 6 | import java.util.Objects; 7 | 8 | import static net.bddtrader.portfolios.MoneyCalculations.dollarsFromCents; 9 | import static net.bddtrader.portfolios.MoneyCalculations.roundCents; 10 | 11 | public class Position { 12 | public static final Position EMPTY_CASH_POSITION = new Position(Trade.CASH_ACCOUNT, 0L, 0.0, 0.0, 0.0, 0.0); 13 | 14 | private final String securityCode; 15 | private final Long amount; 16 | private final Double totalValueInDollars; 17 | private final Double marketValueInDollars; 18 | private final Double totalPurchasePriceInDollars; 19 | private final Double profit; 20 | 21 | @JsonCreator 22 | public Position(@JsonProperty("securityCode") String securityCode, 23 | @JsonProperty("amount") Long amount, 24 | @JsonProperty("totalPurchasePriceInDollars") Double totalPurchasePriceInDollars, 25 | @JsonProperty("marketValueInDollars") Double marketValueInDollars, 26 | @JsonProperty("totalValueInDollars") Double totalValueInDollars, 27 | @JsonProperty("profit") Double profit) { 28 | this.securityCode = securityCode; 29 | this.amount = amount; 30 | this.totalValueInDollars = totalValueInDollars; 31 | this.marketValueInDollars = marketValueInDollars; 32 | this.totalPurchasePriceInDollars = totalPurchasePriceInDollars; 33 | this.profit = profit; 34 | } 35 | 36 | public String getSecurityCode() { 37 | return securityCode; 38 | } 39 | 40 | public Long getAmount() { 41 | return amount; 42 | } 43 | 44 | public double getTotalValueInDollars() { 45 | return totalValueInDollars; 46 | } 47 | 48 | public double getMarketValueInDollars() { 49 | return marketValueInDollars; 50 | } 51 | 52 | public double getTotalPurchasePriceInDollars() { 53 | return totalPurchasePriceInDollars; 54 | } 55 | 56 | public double getProfit() { 57 | return profit; 58 | } 59 | 60 | public static Position fromTrade(Trade trade) { 61 | return new Position(trade.getSecurityCode(), 62 | trade.getAmount(), 63 | dollarsFromCents(trade.getTotalInCents()), 64 | dollarsFromCents(trade.getPriceInCents()), 65 | dollarsFromCents(trade.getTotalInCents()), 66 | 0.0 67 | ); 68 | } 69 | 70 | 71 | public Position withMarketPriceOf(Double marketPrice) { 72 | return new Position(securityCode, 73 | amount, 74 | totalPurchasePriceInDollars, 75 | marketPrice, 76 | calculatedTotalValueFor(amount, marketPrice), 77 | calculatedProfitFor(securityCode, amount, marketPrice, totalPurchasePriceInDollars) 78 | ); 79 | } 80 | 81 | private static double calculatedTotalValueFor(Long amount, Double marketPrice) { 82 | return roundCents(marketPrice * amount); 83 | } 84 | 85 | private static double calculatedProfitFor(String securityCode, Long amount, Double marketPrice, Double totalPurchasePriceInDollars) { 86 | if (securityCode.equals(Trade.CASH_ACCOUNT)) { return 0.0; } 87 | return roundCents(calculatedTotalValueFor(amount, marketPrice) - totalPurchasePriceInDollars); 88 | } 89 | 90 | 91 | public Position apply(Trade newTrade) { 92 | long newAmount = amount + newTrade.getAmount() * newTrade.getType().multiplier(); 93 | double newPurchasePrice = totalPurchasePriceInDollars + (((double) newTrade.getTotalInCents() * newTrade.getType().multiplier()) / 100); 94 | double newMarketPrice = ((double) newTrade.getPriceInCents()) / 100; 95 | 96 | return new Position(securityCode, 97 | newAmount, 98 | newPurchasePrice, 99 | newMarketPrice, 100 | calculatedTotalValueFor(newAmount, newMarketPrice), 101 | calculatedProfitFor(securityCode, newAmount, newMarketPrice, newPurchasePrice) 102 | ); 103 | } 104 | 105 | @Override 106 | public boolean equals(Object o) { 107 | if (this == o) return true; 108 | if (o == null || getClass() != o.getClass()) return false; 109 | Position position = (Position) o; 110 | return securityCode.equals(position.securityCode) && 111 | amount.equals(position.amount) && 112 | Objects.equals(totalValueInDollars, position.totalValueInDollars) && 113 | Objects.equals(marketValueInDollars, position.marketValueInDollars) && 114 | Objects.equals(totalPurchasePriceInDollars, position.totalPurchasePriceInDollars) && 115 | Objects.equals(profit, position.profit); 116 | } 117 | 118 | @Override 119 | public int hashCode() { 120 | return Objects.hash(securityCode, amount, totalValueInDollars, marketValueInDollars, totalPurchasePriceInDollars, profit); 121 | } 122 | 123 | @Override 124 | public String toString() { 125 | return "Position{" + 126 | "securityCode='" + securityCode + '\'' + 127 | ", amount=" + amount + 128 | ", totalValueInDollars=" + totalValueInDollars + 129 | ", marketValueInDollars=" + marketValueInDollars + 130 | ", totalPurchasePriceInDollars=" + totalPurchasePriceInDollars + 131 | ", profit=" + profit + 132 | '}'; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /bddtrader-domain/src/main/java/net/bddtrader/portfolios/Positions.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.portfolios; 2 | 3 | import net.bddtrader.tradingdata.PriceReader; 4 | 5 | import java.util.HashMap; 6 | import java.util.Map; 7 | 8 | class Positions { 9 | 10 | private final Map positionsBySecurity = new HashMap<>(); 11 | 12 | public void apply(Trade trade) { 13 | if (positionsBySecurity.containsKey(trade.getSecurityCode())) { 14 | positionsBySecurity.put(trade.getSecurityCode(), 15 | positionsBySecurity.get(trade.getSecurityCode()).apply(trade)); 16 | } else { 17 | positionsBySecurity.put(trade.getSecurityCode(), Position.fromTrade(trade)); 18 | } 19 | } 20 | 21 | public Map getPositions() { 22 | return new HashMap<>(positionsBySecurity); 23 | } 24 | 25 | public void updateMarketPricesUsing(PriceReader priceReader) { 26 | positionsBySecurity.keySet().forEach( 27 | securityCode -> { 28 | Double marketPrice = priceReader.getPriceFor(securityCode); 29 | positionsBySecurity.put(securityCode, 30 | positionsBySecurity.get(securityCode).withMarketPriceOf(marketPrice)); 31 | } 32 | ); 33 | } 34 | } -------------------------------------------------------------------------------- /bddtrader-domain/src/main/java/net/bddtrader/portfolios/Trade.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.portfolios; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import com.fasterxml.jackson.databind.annotation.JsonSerialize; 6 | import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; 7 | import net.bddtrader.portfolios.dsl.InCurrency; 8 | import net.bddtrader.portfolios.dsl.SharesOf; 9 | 10 | import java.time.LocalDateTime; 11 | import java.util.Map; 12 | import java.util.Optional; 13 | import java.util.concurrent.atomic.AtomicLong; 14 | 15 | import static net.bddtrader.portfolios.TradeType.*; 16 | 17 | public class Trade { 18 | 19 | public static final String CASH_ACCOUNT = "CASH"; 20 | 21 | private final Long id; 22 | /** 23 | * The security code (e.g. AAPL), or CASH for the cash account 24 | */ 25 | private final String securityCode; 26 | /** 27 | * Deposit, Buy or Sell 28 | */ 29 | @JsonSerialize(using = ToStringSerializer.class) 30 | private LocalDateTime timestamp; 31 | private final TradeType type; 32 | private final Long amount; 33 | private final Long priceInCents; 34 | private final Long totalInCents; 35 | 36 | private static final Map OPPOSITE_TRADETYPE = Map.of( 37 | Buy, Sell, 38 | Sell, Buy, 39 | Deposit, Withdraw, 40 | Withdraw, Deposit 41 | ); 42 | 43 | 44 | private static final AtomicLong ID_COUNTER = new AtomicLong(1); 45 | 46 | @JsonCreator 47 | protected Trade(@JsonProperty("securityCode") String securityCode, 48 | @JsonProperty("type") TradeType type, 49 | @JsonProperty("amount") Long amount, 50 | @JsonProperty("priceInCents") Long priceInCents) { 51 | this.id = ID_COUNTER.getAndIncrement(); 52 | this.timestamp = LocalDateTime.now(); 53 | this.securityCode = securityCode; 54 | this.type = type; 55 | this.amount = amount; 56 | this.priceInCents = priceInCents; 57 | this.totalInCents = amount * priceInCents; 58 | } 59 | 60 | private Trade(Long id, String securityCode, LocalDateTime timestamp, TradeType type, Long amount, Long priceInCents, Long totalInCents) { 61 | this.id = id; 62 | this.securityCode = securityCode; 63 | this.timestamp = timestamp; 64 | this.type = type; 65 | this.amount = amount; 66 | this.priceInCents = priceInCents; 67 | this.totalInCents = totalInCents; 68 | } 69 | 70 | public Long getId() { 71 | return id; 72 | } 73 | 74 | public String getSecurityCode() { 75 | return securityCode; 76 | } 77 | 78 | public TradeType getType() { 79 | return type; 80 | } 81 | 82 | public Long getAmount() { 83 | return amount; 84 | } 85 | 86 | public Long getPriceInCents() { 87 | return priceInCents; 88 | } 89 | 90 | public Long getTotalInCents() { 91 | return totalInCents; 92 | } 93 | 94 | public LocalDateTime getTimestamp() { 95 | return timestamp; 96 | } 97 | 98 | public static SharesOf of(TradeType type, Long numberOfShares) { 99 | return new TradeBuilder(type, numberOfShares); 100 | } 101 | 102 | public static SharesOf buy(Long numberOfShares) { 103 | return new TradeBuilder(Buy, numberOfShares); 104 | } 105 | 106 | public static SharesOf sell(Long numberOfShares) { 107 | return new TradeBuilder(Sell, numberOfShares); 108 | 109 | } 110 | 111 | public static InCurrency deposit(Long amountInCents) { 112 | return new TradeBuilder(TradeType.Deposit, amountInCents); 113 | } 114 | 115 | /** 116 | * All trades except CASH deposits have an impact on the CASH account. 117 | */ 118 | public Optional cashTransation() { 119 | if (securityCode.equals(CASH_ACCOUNT)) { 120 | return Optional.empty(); 121 | } 122 | 123 | return Optional.of(new Trade(CASH_ACCOUNT, OPPOSITE_TRADETYPE.get(type), getTotalInCents(), 1L)); 124 | } 125 | 126 | public Trade atPrice(Double marketPrice) { 127 | return new Trade(id, securityCode, timestamp, type, amount, (long) (marketPrice * 100), (long) (marketPrice * 100 * amount)); 128 | } 129 | 130 | @Override 131 | public String toString() { 132 | return "Trade{" + 133 | "securityCode='" + securityCode + '\'' + 134 | ", type=" + type + 135 | ", amount=" + amount + 136 | ", priceInCents=" + priceInCents + 137 | ", totalInCents=" + totalInCents + 138 | '}'; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /bddtrader-domain/src/main/java/net/bddtrader/portfolios/TradeBuilder.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.portfolios; 2 | 3 | 4 | import net.bddtrader.portfolios.dsl.*; 5 | 6 | public class TradeBuilder implements SharesOf, AtAPriceOf, CentsEach, DollarsEach, InCurrency { 7 | private final TradeType tradeType; 8 | private final Long numberOfShares; 9 | private String securityCode; 10 | private Long priceInCents; 11 | private Double priceInDollars; 12 | 13 | public TradeBuilder(TradeType tradeType, Long numberOfShares) { 14 | this.tradeType = tradeType; 15 | this.numberOfShares = numberOfShares; 16 | } 17 | 18 | @Override 19 | public AtAPriceOf sharesOf(String securityCode) { 20 | this.securityCode = securityCode; 21 | return this; 22 | } 23 | 24 | @Override 25 | public CentsEach at(Long priceInCents) { 26 | this.priceInCents = priceInCents; 27 | return this; 28 | } 29 | 30 | @Override 31 | public DollarsEach at(Double priceInDollars) { 32 | this.priceInDollars = priceInDollars;; 33 | return this; 34 | } 35 | 36 | @Override 37 | public Trade atMarketPrice() { 38 | return new Trade(securityCode, tradeType, numberOfShares, 0L); 39 | } 40 | 41 | public Trade centsEach() { 42 | return new Trade(securityCode, tradeType, numberOfShares, priceInCents); 43 | } 44 | 45 | @Override 46 | public Trade dollarsEach() { 47 | return new Trade(securityCode, tradeType, numberOfShares, (long) (priceInDollars * 100)); 48 | } 49 | 50 | @Override 51 | public Trade dollars() { 52 | return new Trade(Trade.CASH_ACCOUNT, tradeType, numberOfShares * 100, 1L); 53 | } 54 | 55 | @Override 56 | public Trade cents() { 57 | return new Trade(Trade.CASH_ACCOUNT, tradeType, numberOfShares, 1L); 58 | } 59 | 60 | 61 | } 62 | -------------------------------------------------------------------------------- /bddtrader-domain/src/main/java/net/bddtrader/portfolios/TradeDirection.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.portfolios; 2 | 3 | public enum TradeDirection { 4 | 5 | Increase(1), 6 | Decrease(-1); 7 | 8 | public final int multiplier; 9 | 10 | TradeDirection(int multiplier) { 11 | this.multiplier = multiplier; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /bddtrader-domain/src/main/java/net/bddtrader/portfolios/TradeType.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.portfolios; 2 | 3 | import static net.bddtrader.portfolios.TradeDirection.*; 4 | 5 | public enum TradeType { 6 | Deposit(Increase), // Deposit cash into the portfolio 7 | Withdraw(Decrease), // With cash from the portfolio 8 | Buy(Increase), // Buy some shares 9 | Sell(Decrease); // Sell some shares 10 | 11 | private final TradeDirection direction; 12 | 13 | TradeType(TradeDirection direction) { 14 | this.direction = direction; 15 | } 16 | 17 | public int multiplier() { 18 | return direction.multiplier; 19 | } 20 | 21 | public TradeDirection direction() { 22 | return direction; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /bddtrader-domain/src/main/java/net/bddtrader/portfolios/dsl/AtAPriceOf.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.portfolios.dsl; 2 | 3 | import net.bddtrader.portfolios.Trade; 4 | 5 | public interface AtAPriceOf { 6 | CentsEach at(Long priceInCents); 7 | DollarsEach at(Double priceInCents); 8 | Trade atMarketPrice(); 9 | } 10 | -------------------------------------------------------------------------------- /bddtrader-domain/src/main/java/net/bddtrader/portfolios/dsl/CentsEach.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.portfolios.dsl; 2 | 3 | import net.bddtrader.portfolios.Trade; 4 | 5 | public interface CentsEach { 6 | Trade centsEach(); 7 | } 8 | -------------------------------------------------------------------------------- /bddtrader-domain/src/main/java/net/bddtrader/portfolios/dsl/DollarsEach.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.portfolios.dsl; 2 | 3 | import net.bddtrader.portfolios.Trade; 4 | 5 | public interface DollarsEach { 6 | Trade dollarsEach(); 7 | } 8 | -------------------------------------------------------------------------------- /bddtrader-domain/src/main/java/net/bddtrader/portfolios/dsl/InCurrency.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.portfolios.dsl; 2 | 3 | import net.bddtrader.portfolios.Trade; 4 | 5 | public interface InCurrency { 6 | Trade dollars(); 7 | Trade cents(); 8 | } 9 | -------------------------------------------------------------------------------- /bddtrader-domain/src/main/java/net/bddtrader/portfolios/dsl/SharesOf.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.portfolios.dsl; 2 | 3 | public interface SharesOf { 4 | AtAPriceOf sharesOf(String securityCode); 5 | } 6 | -------------------------------------------------------------------------------- /bddtrader-domain/src/main/java/net/bddtrader/stocks/TopStock.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.stocks; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | 6 | public class TopStock { 7 | private final String symbol; 8 | private final Long volume; 9 | 10 | 11 | @JsonCreator 12 | public TopStock(@JsonProperty("symbol") String symbol, @JsonProperty("volume") Long volume) { 13 | this.symbol = symbol; 14 | this.volume = volume; 15 | } 16 | 17 | public String getSymbol() { 18 | return symbol; 19 | } 20 | 21 | public Long getVolume() { 22 | return volume; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /bddtrader-domain/src/main/java/net/bddtrader/tradingdata/NewsReader.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.tradingdata; 2 | 3 | import net.bddtrader.news.NewsItem; 4 | 5 | import java.util.List; 6 | 7 | public interface NewsReader { 8 | List getNewsFor(String stockid); 9 | } 10 | -------------------------------------------------------------------------------- /bddtrader-domain/src/main/java/net/bddtrader/tradingdata/PriceReader.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.tradingdata; 2 | 3 | import java.util.List; 4 | 5 | public interface PriceReader { 6 | Double getPriceFor(String stockid); 7 | List getPopularStocks(); 8 | } 9 | -------------------------------------------------------------------------------- /bddtrader-domain/src/main/java/net/bddtrader/tradingdata/PriceUpdater.java: -------------------------------------------------------------------------------- 1 | package net.bddtrader.tradingdata; 2 | 3 | public interface PriceUpdater { 4 | void updatePriceFor(String stockid, Double currentPrice); 5 | } 6 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | # Java Maven CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-java/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/openjdk:8-jdk 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/postgres:9.4 16 | 17 | working_directory: ~/repo 18 | 19 | environment: 20 | # Customize the JVM maximum heap limit 21 | MAVEN_OPTS: -Xmx3200m 22 | 23 | steps: 24 | - checkout 25 | 26 | # Download and cache dependencies 27 | - restore_cache: 28 | keys: 29 | - v1-dependencies-{{ checksum "pom.xml" }} 30 | # fallback to using the latest cache if no exact match is found 31 | - v1-dependencies- 32 | 33 | - run: mvn dependency:go-offline 34 | 35 | - save_cache: 36 | paths: 37 | - ~/.m2 38 | key: v1-dependencies-{{ checksum "pom.xml" }} 39 | 40 | # run tests! 41 | - run: mvn verify -------------------------------------------------------------------------------- /exercises.adoc: -------------------------------------------------------------------------------- 1 | = Serenity Screenplay with REST Exercises 2 | 3 | These exercises demonstrate how to use Serenity Screenplay with a REST API. 4 | They start with an existing application, which you can find on the `master` branch. 5 | The solutions can be found on the `solutions` branch. 6 | 7 | Before doing these exercises, make sure you have the server running. 8 | You can launch the server by typing the following command in the project root directory: 9 | 10 | ---- 11 | mvn spring-boot:run -Ddata.source=DEV 12 | ---- 13 | 14 | == Exercise 1 - View the overall profit 15 | 16 | Add a new scenario to the `viewing_positions.feature` feature file: 17 | 18 | [source,gherkin] 19 | ---- 20 | Scenario: Making profits on multiple shares 21 | Given Sarah Smith is a registered trader 22 | When Sarah has purchased 5 SNAP shares at $100 each 23 | And Sarah has purchased 10 IBM shares at $50 each 24 | Then she should have the following positions: 25 | | securityCode | amount | totalValueInDollars | profit | 26 | | CASH | 0 | 0.00 | 0.00 | 27 | | SNAP | 5 | 1000.00 | 500.00 | 28 | | IBM | 10 | 600.00 | 100.00 | 29 | ---- 30 | 31 | Now run the `ViewingPositions` test runner to check that it works. 32 | 33 | Next we want to see the overall profit for the portfolio. 34 | Modify the scenario we just added so that it also checks the overall profit 35 | 36 | [source,gherkin] 37 | ---- 38 | Scenario: Making profits on multiple shares 39 | Given Sarah Smith is a registered trader 40 | When Sarah has purchased 5 SNAP shares at $100 each 41 | And Sarah has purchased 10 IBM shares at $50 each 42 | Then she should have the following positions: 43 | | securityCode | amount | totalValueInDollars | profit | 44 | | CASH | 0 | 0.00 | 0.00 | 45 | | SNAP | 5 | 1000.00 | 500.00 | 46 | | IBM | 10 | 600.00 | 100.00 | 47 | And the overall profit should be $600 48 | ---- 49 | 50 | Add a step definition method for this last step in the `ViewingPositionsStepDefinitons` class: 51 | 52 | 53 | [source,java] 54 | ---- 55 | @And("^the overall profit should be \\$(.*)$") 56 | public void theOverallProfitShouldBe(double expectedProfit) throws Throwable { 57 | } 58 | ---- 59 | 60 | To check the overall profits in a portfolio, we can use the `/portfolio/1{portfolioId}/profit` endpoint. 61 | Add this endpoint to the `BDDTraderEndPoints` enum: 62 | 63 | [source,java] 64 | ---- 65 | PortfolioProfit("/portfolio/{portfolioId}/profit") 66 | ---- 67 | 68 | Implement the Step Definition so that the current actor sends a GET query to this endpoint: 69 | 70 | [source,java] 71 | ---- 72 | @And("^the overall profit should be \\$(.*)$") 73 | public void theOverallProfitShouldBe(double expectedProfit) throws Throwable { 74 | Integer portfolioId = theActorInTheSpotlight().recall("clientPortfolioId"); // <1> 75 | 76 | theActorInTheSpotlight().attemptsTo( 77 | Get.resource(BDDTraderEndPoints.PortfolioProfit.path()) // <2> 78 | .with(request -> request.pathParam("portfolioId", portfolioId)) 79 | ); 80 | 81 | Double actualProfit = SerenityRest.lastResponse().as(Double.class); // <3> 82 | 83 | assertThat(actualProfit).isEqualTo(expectedProfit);} 84 | ---- 85 | <1> We stored the client's portfolio id when they where registered 86 | <2> Perform a simple GET on the /portfolio/{portfolioId}/profit endpoint 87 | <3> Retrieve the response. 88 | 89 | == Exercise 2 - Using a Question object 90 | 91 | We can refactor this code to make it more reusable by using a Question class. 92 | 93 | [source,java] 94 | ---- 95 | public static Question overallProfitForPortfolioId(Long portfolioId) { 96 | return new RestQuestionBuilder().about("Overall profit") 97 | .to(BDDTraderEndPoints.PortfolioProfit.path()) 98 | .withPathParameters("portfolioId", portfolioId) 99 | .returning(response -> response.as(Double.class)); 100 | } 101 | ---- 102 | 103 | Then refactor the step definition method to use this Question class with the `seeThat()` expression: 104 | 105 | [source,java] 106 | ----- 107 | @And("^the overall profit should be \\$(.*)$") 108 | public void theOverallProfitShouldBe(double expectedProfit) throws Throwable { 109 | 110 | Integer portfolioId = theActorInTheSpotlight().recall("clientPortfolioId"); 111 | 112 | theActorInTheSpotlight().should( 113 | seeThat(ThePortfolio.overallProfitForPortfolioId(portfolioId), is(equalTo(expectedProfit))) 114 | ); 115 | } 116 | ----- 117 | 118 | == Exercise 3 - Variations on a theme 119 | 120 | Add some additional scenarios to explore variations. 121 | Some possible scenarios can include the following: 122 | 123 | [source,gherkin] 124 | ----- 125 | Scenario: Making losses a single share 126 | Given Sarah Smith is a registered trader 127 | When Sarah has purchased 2 SNAP shares at $300 each 128 | Then she should have the following positions: 129 | | securityCode | amount | totalValueInDollars | profit | 130 | | CASH | 40000 | 400.00 | 0.00 | 131 | | SNAP | 2 | 400.00 | -200.00 | 132 | 133 | Scenario: Making profits and losses across multiple share 134 | Given Sarah Smith is a registered trader 135 | When Sarah has purchased 2 SNAP shares at $300 each 136 | And she has purchased 5 IBM shares at $50 each 137 | Then she should have the following positions: 138 | | securityCode | amount | totalValueInDollars | profit | 139 | | CASH | 15000 | 150.00 | 0.00 | 140 | | SNAP | 2 | 400.00 | -200.00 | 141 | | IBM | 5 | 300.00 | 50.00 | 142 | And the overall profit should be $-150.00 143 | ----- 144 | 145 | == Exercise 4 - Transaction history 146 | 147 | In this exercise, we will write some scenarios to test the portfolio transaction history. 148 | 149 | The full transaction history for a portfolio can be seen in the "history" entry of the portfolio record. 150 | We can access this record at the `/client/{clientId}/portfolio` endpoint. 151 | 152 | Create a new feature file called `transation_history.feature` in the `src/test/resources/features` folder. 153 | 154 | [source,cucumber] 155 | ----- 156 | Feature: Transaction history 157 | In order to understand why I have no money left 158 | As a trader 159 | I want to see a historyu of all my transactions 160 | 161 | Scenario: All transactions are recorded in the transaction history 162 | Given Tim Trady is a registered trader 163 | When Tim has purchased 5 SNAP shares at $100 each 164 | Then his transaction history should be the following: 165 | | securityCode | type | amount | priceInCents | totalInCents | 166 | | CASH | Deposit | 100000 | 1 | 100000 | 167 | | CASH | Sell | 50000 | 1 | 50000 | 168 | | SNAP | Buy | 5 | 10000 | 50000 | 169 | ----- 170 | 171 | Now create a test runner for this feature file: 172 | 173 | [source,java] 174 | ---- 175 | @RunWith(CucumberWithSerenity.class) 176 | @CucumberOptions( 177 | plugin = {"pretty"}, 178 | features = "src/test/resources/features/portfolios/transaction_history.feature" 179 | ) 180 | public class TransactionHistory {} 181 | ---- 182 | 183 | Next, create a new step definition class called `TransactionHistoryStepDefinitions` in the `stepdefinitions` package. 184 | This class will query the REST end point to retrieve the transaction history (a list of trades), 185 | and compare them with the expected history: 186 | 187 | [source,java] 188 | ---- 189 | @Then("^(?:his|her) transaction history should be the following:$") 190 | public void his_transaction_history_should_be_the_following(List transactionHistory) throws Exception { 191 | 192 | Client registeredClient = theActorInTheSpotlight().recall("registeredClient"); // <1> 193 | 194 | theActorInTheSpotlight().attemptsTo( // <2> 195 | Get.resource(BDDTraderEndPoints.ClientPortfolio.path()) 196 | .with(request -> request.pathParam("clientId", registeredClient.getId())) 197 | ); 198 | 199 | assertThat(SerenityRest.lastResponse().statusCode()).isEqualTo(200); // <3> 200 | 201 | List actualTransactionHistory = SerenityRest.lastResponse() 202 | .jsonPath() 203 | .getList("history", Trade.class); 204 | 205 | assertThat(actualTransactionHistory).usingElementComparatorIgnoringFields("id","timestamp") 206 | .containsExactlyElementsOf(transactionHistory); //<4> 207 | } 208 | ---- 209 | <1> Fetch the client ID 210 | <2> Get the portfolio record from the REST end point 211 | <3> Ensure that the query worked 212 | <4> Compare the transaction lists, ignoring irrelevant fields 213 | 214 | === Exercise 5 - refactoring tasks and questions 215 | 216 | To make the code in this step definition more readable and more usable, let's extract some tasks and questions. 217 | 218 | Create a new `Task` class in the `tasks` package to fetch the transaction history for a given client: 219 | 220 | [source,java] 221 | ---- 222 | public class FetchTransactionHistory implements Task { 223 | 224 | private final Long clientId; 225 | 226 | public FetchTransactionHistory(Long clientId) { 227 | this.clientId = clientId; 228 | } 229 | 230 | @Override 231 | public void performAs(T actor) { 232 | 233 | actor.attemptsTo( 234 | Get.resource(BDDTraderEndPoints.ClientPortfolio.path()) 235 | .with(request -> request.pathParam("clientId", clientId)) 236 | ); 237 | 238 | assertThat(SerenityRest.lastResponse().statusCode()).isEqualTo(200); 239 | } 240 | 241 | public static FetchTransactionHistory forClient(Client client) { 242 | return instrumented(FetchTransactionHistory.class, client.getId()); 243 | } 244 | } 245 | ---- 246 | 247 | Next, add a method to the `ThePortfolio` class to return a new Question. 248 | This Question will return the transaction history that was retrieved in the previous task: 249 | 250 | [source,java] 251 | ---- 252 | public static Question> history() { 253 | return actor -> SerenityRest.lastResponse().jsonPath().getList("history", Trade.class); 254 | } 255 | ---- 256 | 257 | Finally, update the test to use the new classes: 258 | [source,java] 259 | ---- 260 | @Then("^(?:his|her) transaction history should be the following:$") 261 | public void his_transaction_history_should_be_the_following(List transactionHistory) throws Exception { 262 | 263 | Client registeredClient = theActorInTheSpotlight().recall("registeredClient"); 264 | 265 | theActorInTheSpotlight().attemptsTo( 266 | FetchTransactionHistory.forClient(registeredClient) // <1> 267 | ); 268 | 269 | theActorInTheSpotlight().should( 270 | seeThat("the portfolio history is correctly retrieved", 271 | ThePortfolio.history(), // <2> 272 | matchesTradesIn(transactionHistory)) // <3> 273 | ); 274 | } 275 | ---- 276 | <1> Fetch the transaction history 277 | <2> Compare with the expected history 278 | <3> Compare the transaction sets using a custom Hamcrest matcher 279 | 280 | === Exercise 6 - living documentation 281 | 282 | Serenity generates rich living documentation for REST API tests. 283 | Stop the server and run `mvn verify` from the command line. 284 | When the tests are finished, open the Serenity report in `target/site/serenity/index.html` 285 | and see how the tests are rendered. 286 | -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | applications: 3 | - name: bdd-trader 4 | memory: 768M 5 | instances: 1 6 | random-route: true 7 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | net.bddtrader 6 | bddtrader 7 | 1.0.0-SNAPSHOT 8 | pom 9 | 10 | The BDD Trader Demo 11 | 12 | 13 | bddtrader-app 14 | bddtrader-domain 15 | 16 | 17 | 18 | UTF-8 19 | 5.10.3 20 | 1.10.3 21 | 22 | 23 | 24 | 25 | org.assertj 26 | assertj-core 27 | 3.26.3 28 | test 29 | 30 | 31 | 32 | 33 | org.junit.platform 34 | junit-platform-suite-api 35 | ${junit-platform-suite-api.version} 36 | test 37 | 38 | 39 | org.junit.platform 40 | junit-platform-suite 41 | ${junit-platform-suite-api.version} 42 | test 43 | 44 | 45 | org.junit.jupiter 46 | junit-jupiter-api 47 | ${junit5.version} 48 | test 49 | 50 | 51 | org.junit.jupiter 52 | junit-jupiter-engine 53 | ${junit5.version} 54 | test 55 | 56 | 57 | org.junit.vintage 58 | junit-vintage-engine 59 | ${junit5.version} 60 | test 61 | 62 | 63 | org.junit.jupiter 64 | junit-jupiter-params 65 | ${junit5.version} 66 | test 67 | 68 | 69 | com.fasterxml.jackson.core 70 | jackson-databind 71 | 2.15.0 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /readme.adoc: -------------------------------------------------------------------------------- 1 | = BDD Trader application 2 | 3 | The BDD Trader application demonstrates BDD and test automation techniques using Serenity BDD and the Screenplay pattern. 4 | These exercises are used as part of the **https://expansion.serenity-dojo.com/courses/testing-rest-apis-with-serenity-bdd[Testing REST APIs with Serenity BDD and RestAssured]** online course , and as part of the https://www.serenity-dojo.com[Serenity Dojo Online Training Programme]. 5 | 6 | == Setting up your environment 7 | To run this tutorial, you will need https://www.oracle.com/java/technologies/javase/jdk17-archive-downloads.html[JDK 17] or higher, 8 | https://maven.apache.org[Maven] and https://git-scm.com/downloads[Git]. 9 | 10 | You will also need a modern Java IDE such as https://www.jetbrains.com/idea/download[IntelliJ IDE] 11 | (this is our recommendation - the free Community Edition will do fine). Make sure that the bundled 12 | https://plugins.jetbrains.com/plugin/7212-cucumber-for-java[Cucumber for Java] plugin is enabled. 13 | 14 | You can also use https://www.getpostman.com[Postman] to experiment with the REST end points - 15 | there is a set of preconfigured Postman queries in `src/test/resources/BDDTrader.postman_collection`. 16 | 17 | Now clone this project to your local machine (you will need to setup a https://github.com[Github] account if you do not already have one): 18 | 19 | ----- 20 | git clone git@github.com:serenity-bdd/bdd-trader.git 21 | ----- 22 | 23 | == Building the project 24 | 25 | This project uses a Maven build. To run the tests and build an executable jar, run: 26 | 27 | ---- 28 | mvn install 29 | ---- 30 | 31 | This project contains three modules: 32 | 33 | - **bddtrader-app** (containing the Spring Boot application and the tests) 34 | - **bddtrader-domain** (containing the domain model used by the application and the tests) 35 | 36 | The first time you run this it may take some time to download the dependencies. 37 | 38 | === Running the application 39 | 40 | You can run the application from the command line by changing the path to *bddtrader-app* module and running: 41 | 42 | ---- 43 | mvn spring-boot:run 44 | ---- 45 | 46 | To check that the application is running correctly, open http://localhost:8080/api/status in your browser. 47 | 48 | === Running the tests 49 | 50 | You can run the full test suite by running `mvn verify` from the *bddtrader-test* module. 51 | Before running the tests the application must be started as described above. 52 | 53 | === The application domain 54 | 55 | The application allows users to buy and sell shares using real market data. 56 | Each **Client** is given a **Portfolio**, a special account where the client keeps track 57 | of the shares they buy and sell. At any point in time, the client's **Position** represents 58 | the number of shares they have in their portfolio, the amount of cash they have available, 59 | and the current value of these shares. A client buys and sells shares by placing an **Order**. 60 | When the order is executed, a **Trade** is recorded in the portfolio **history**. 61 | 62 | === The Client service 63 | Clients need to register with the service by providing their first and last name via the `client` REST end-point. 64 | When clients register, they are given a portfolio with $1000. 65 | 66 | === The Portfolio service 67 | 68 | Each client has a portfolio to invest with. 69 | You can view the details of a portfolio via the `/api/client/{clientId}/portfolio` endpoint. 70 | Each portfolio has a unique identifier, and we use the portfolio identifier to interact with the portfolio. 71 | 72 | You can place orders for trades to buy or sell shares through the portfolio. 73 | In the DEV environment, a number of shares are predefined, including: 74 | 75 | * Amazon (AMZN) 76 | * Google (GOOG) 77 | * Apple (AAPL) 78 | * IBM (IBM) 79 | 80 | You can see them all by using the `/api/stock/popular` endpoint. 81 | 82 | Users place orders to buy or sell shares by posting a trade order to the `/api/portfolio/{portfolioId}/order` endpoint. 83 | The share price and cost of the order can be provided (so that you can inject historical trades), 84 | or will be based on the current market price if the price is set to 0. If the user has insufficient funds, 85 | a 402 PAYMENT REQUIRED error will be returned. 86 | 87 | You can see a client's current position via the `/api/portfolio/{portfolioId}/positions` endpoint. 88 | You can also view a list of the trading history of a portfolio using `/api/portfolio/{portfolioId}/history`. 89 | 90 | === Swagger REST Documentation 91 | 92 | When the application is running, you can see the REST API documentation at http://localhost:8080/swagger-ui.html 93 | 94 | === Tutorial 95 | 96 | Continue learning with the **link:exercises.adoc[tutorial]** 97 | -------------------------------------------------------------------------------- /serenity.properties: -------------------------------------------------------------------------------- 1 | serenity.project.name=Serenity BDD Trader Demo App 2 | --------------------------------------------------------------------------------